diff --git a/src/index.js b/src/index.js index 90c5bed..7ade0d3 100644 --- a/src/index.js +++ b/src/index.js @@ -12,6 +12,7 @@ const withMarkdoc = loader: require.resolve('./loader'), options: { appDir: options.defaultLoaders.babel.options.appDir, + pagesDir: options.defaultLoaders.babel.options.pagesDir, ...pluginOptions, dir: options.dir, nextRuntime: options.nextRuntime, diff --git a/src/loader.js b/src/loader.js index 245dd19..74e2edc 100644 --- a/src/loader.js +++ b/src/loader.js @@ -30,13 +30,7 @@ async function gatherPartials(ast, schemaDir, tokenizer, parseOptions) { partials = { ...partials, [file]: content, - ...(await gatherPartials.call( - this, - ast, - schemaDir, - tokenizer, - parseOptions - )), + ...(await gatherPartials.call(this, ast, schemaDir, tokenizer, parseOptions)), }; } } @@ -63,6 +57,7 @@ async function load(source) { }, nextjsExports = ['metadata', 'revalidate'], appDir = false, + pagesDir, } = this.getOptions() || {}; const tokenizer = new Markdoc.Tokenizer(options); @@ -72,6 +67,8 @@ async function load(source) { const tokens = tokenizer.tokenize(source); const ast = Markdoc.parse(tokens, parseOptions); + const isPage = this.resourcePath.startsWith(appDir || pagesDir); + // Grabs the path of the file relative to the `/{app,pages}` directory // to pass into the app props later. // This array access @ index 1 is safe since Next.js guarantees that @@ -88,8 +85,7 @@ async function load(source) { ); // IDEA: consider making this an option per-page - const dataFetchingFunction = - mode === 'server' ? 'getServerSideProps' : 'getStaticProps'; + const dataFetchingFunction = mode === 'server' ? 'getServerSideProps' : 'getStaticProps'; let schemaCode = 'const schema = {};'; try { @@ -138,18 +134,14 @@ import yaml from 'js-yaml'; // renderers is imported separately so Markdoc isn't sent to the client import Markdoc, {renderers} from '@markdoc/markdoc' -import {getSchema, defaultObject} from '${normalize( - await resolve(__dirname, './runtime') - )}'; +import {getSchema, defaultObject} from '${normalize(await resolve(__dirname, './runtime'))}'; /** * Schema is imported like this so end-user's code is compiled using build-in babel/webpack configs. * This enables typescript/ESnext support */ ${schemaCode} -const tokenizer = new Markdoc.Tokenizer(${ - options ? JSON.stringify(options) : '' - }); +const tokenizer = new Markdoc.Tokenizer(${options ? JSON.stringify(options) : ''}); /** * Source will never change at runtime, so parse happens at the file root @@ -170,7 +162,7 @@ const frontmatter = ast.attributes.frontmatter const {components, ...rest} = getSchema(schema) -async function getMarkdocData(context = {}) { +${isPage ? 'async ' : ''}function getMarkdocData(context = {}) { const partials = ${JSON.stringify(partials)}; // Ensure Node.transformChildren is available @@ -196,7 +188,7 @@ async function getMarkdocData(context = {}) { * transform must be called in dataFetchingFunction to support server-side rendering while * accessing variables on the server */ - const content = await Markdoc.transform(ast, cfg); + const content = ${isPage ? 'await ' : ''}Markdoc.transform(ast, cfg); // Removes undefined return JSON.parse( @@ -211,7 +203,7 @@ async function getMarkdocData(context = {}) { } ${ - appDir + appDir || !isPage ? '' : `export async function ${dataFetchingFunction}(context) { return { @@ -221,10 +213,12 @@ ${ }; }` } -${appDir ? nextjsExportsCode : ''} +${appDir && isPage ? nextjsExportsCode : ''} export const markdoc = {frontmatter}; -export default${appDir ? ' async' : ''} function MarkdocComponent(props) { - const markdoc = ${appDir ? 'await getMarkdocData()' : 'props.markdoc'}; +export default${appDir && isPage ? ' async' : ''} function MarkdocComponent(props) { + const markdoc = ${ + isPage ? (appDir ? 'await getMarkdocData()' : 'props.markdoc') : 'getMarkdocData()' + }; // Only execute HMR code in development return renderers.react(markdoc.content, React, { components: { diff --git a/tests/__snapshots__/index.test.js.snap b/tests/__snapshots__/index.test.js.snap index 6e25b4e..64141f3 100644 --- a/tests/__snapshots__/index.test.js.snap +++ b/tests/__snapshots__/index.test.js.snap @@ -116,7 +116,7 @@ const tokenizer = new Markdoc.Tokenizer({\\"allowComments\\":true}); * Source will never change at runtime, so parse happens at the file root */ const source = \\"---\\\\ntitle: Custom title\\\\n---\\\\n\\\\n# {% $markdoc.frontmatter.title %}\\\\n\\\\n{% tag /%}\\\\n\\"; -const filepath = undefined; +const filepath = \\"/test/index.md\\"; const tokens = tokenizer.tokenize(source); const parseOptions = {\\"slots\\":false}; const ast = Markdoc.parse(tokens, parseOptions); @@ -285,3 +285,94 @@ export default function MarkdocComponent(props) { } " `; + +exports[`import as frontend component 1`] = ` +"import React from 'react'; +import yaml from 'js-yaml'; +// renderers is imported separately so Markdoc isn't sent to the client +import Markdoc, {renderers} from '@markdoc/markdoc' + +import {getSchema, defaultObject} from './src/runtime.js'; +/** + * Schema is imported like this so end-user's code is compiled using build-in babel/webpack configs. + * This enables typescript/ESnext support + */ +const schema = {}; + +const tokenizer = new Markdoc.Tokenizer({\\"allowComments\\":true}); + +/** + * Source will never change at runtime, so parse happens at the file root + */ +const source = \\"---\\\\ntitle: Custom title\\\\n---\\\\n\\\\n# {% $markdoc.frontmatter.title %}\\\\n\\\\n{% tag /%}\\\\n\\"; +const filepath = undefined; +const tokens = tokenizer.tokenize(source); +const parseOptions = {\\"slots\\":false}; +const ast = Markdoc.parse(tokens, parseOptions); + +/** + * Like the AST, frontmatter won't change at runtime, so it is loaded at file root. + * This unblocks future features, such a per-page dataFetchingFunction. + */ +const frontmatter = ast.attributes.frontmatter + ? yaml.load(ast.attributes.frontmatter) + : {}; + +const {components, ...rest} = getSchema(schema) + +function getMarkdocData(context = {}) { + const partials = {}; + + // Ensure Node.transformChildren is available + Object.keys(partials).forEach((key) => { + const tokens = tokenizer.tokenize(partials[key]); + partials[key] = Markdoc.parse(tokens, parseOptions); + }); + + const cfg = { + ...rest, + variables: { + ...(rest ? rest.variables : {}), + // user can't override this namespace + markdoc: {frontmatter}, + // Allows users to eject from Markdoc rendering and pass in dynamic variables via getServerSideProps + ...(context.variables || {}) + }, + partials, + source, + }; + + /** + * transform must be called in dataFetchingFunction to support server-side rendering while + * accessing variables on the server + */ + const content = Markdoc.transform(ast, cfg); + + // Removes undefined + return JSON.parse( + JSON.stringify({ + content, + frontmatter, + file: { + path: filepath, + }, + }) + ); +} + + + +export const markdoc = {frontmatter}; +export default function MarkdocComponent(props) { + const markdoc = getMarkdocData(); + // Only execute HMR code in development + return renderers.react(markdoc.content, React, { + components: { + ...components, + // Allows users to override default components at runtime, via their _app + ...props.components, + }, + }); +} +" +`; diff --git a/tests/index.test.js b/tests/index.test.js index 6f2fef3..84a7ff2 100644 --- a/tests/index.test.js +++ b/tests/index.test.js @@ -59,6 +59,8 @@ function evaluate(output) { } function options(config = {}) { + const dir = `${'/Users/someone/a-next-js-repo'}/${config.appDir ? 'app' : 'pages'}`; + const webpackThis = { context: __dirname, getOptions() { @@ -66,6 +68,8 @@ function options(config = {}) { ...config, dir: __dirname, nextRuntime: 'nodejs', + appDir: config.appDir ? dir : undefined, + pagesDir: config.appDir ? undefined : dir, }; }, getLogger() { @@ -77,12 +81,10 @@ function options(config = {}) { const resolve = enhancedResolve.create(options); return async (context, file) => new Promise((res, rej) => - resolve(context, file, (err, result) => - err ? rej(err) : res(result) - ) + resolve(context, file, (err, result) => (err ? rej(err) : res(result))) ).then(normalizeAbsolutePath); }, - resourcePath: '/Users/someone/a-next-js-repo/pages/test/index.md', + resourcePath: dir + '/test/index.md', }; return webpackThis; @@ -102,15 +104,13 @@ async function callLoader(config, source) { } test('should not fail build if default `schemaPath` is used', async () => { - await expect(callLoader(options(), source)).resolves.toEqual( - expect.any(String) - ); + await expect(callLoader(options(), source)).resolves.toEqual(expect.any(String)); }); test('should fail build if invalid `schemaPath` is used', async () => { - await expect( - callLoader(options({schemaPath: 'unknown_schema_path'}), source) - ).rejects.toThrow("Cannot find module 'unknown_schema_path'"); + await expect(callLoader(options({schemaPath: 'unknown_schema_path'}), source)).rejects.toThrow( + "Cannot find module 'unknown_schema_path'" + ); }); test('file output is correct', async () => { @@ -154,11 +154,7 @@ test('file output is correct', async () => { }); expect(page.default(data.props)).toEqual( - React.createElement( - 'article', - undefined, - React.createElement('h1', undefined, 'Custom title') - ) + React.createElement('article', undefined, React.createElement('h1', undefined, 'Custom title')) ); }); @@ -179,11 +175,7 @@ test('app router', async () => { }); expect(await page.default({})).toEqual( - React.createElement( - 'article', - undefined, - React.createElement('h1', undefined, 'Custom title') - ) + React.createElement('article', undefined, React.createElement('h1', undefined, 'Custom title')) ); }); @@ -193,9 +185,7 @@ test('app router metadata', async () => { source.replace('---', '---\nmetadata:\n title: Metadata title') ); - expect(output).toContain( - 'export const metadata = frontmatter.nextjs?.metadata;' - ); + expect(output).toContain('export const metadata = frontmatter.nextjs?.metadata;'); }); test.each([ @@ -211,9 +201,7 @@ test.each([ const page = evaluate(output); const data = await page.getStaticProps({}); - expect(data.props.markdoc.content.children[0].children[0]).toEqual( - 'Custom title' - ); + expect(data.props.markdoc.content.children[0].children[0]).toEqual('Custom title'); expect(data.props.markdoc.content.children[1]).toEqual(expectedChild); }); @@ -267,3 +255,12 @@ test('mode="server"', async () => { }, }); }); + +test('import as frontend component', async () => { + const o = options(); + // Use a non-page pathway + o.resourcePath = o.resourcePath.replace('pages/test/index.md', 'components/table.md'); + const output = await callLoader(o, source); + + expect(normalizeOperatingSystemPaths(output)).toMatchSnapshot(); +});