Skip to content

Commit 41d30cf

Browse files
authored
Support loading Markdoc components client side (#60)
* support loading markdoc components client side * add unit test
1 parent 0867e4d commit 41d30cf

File tree

4 files changed

+131
-48
lines changed

4 files changed

+131
-48
lines changed

src/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ const withMarkdoc =
1212
loader: require.resolve('./loader'),
1313
options: {
1414
appDir: options.defaultLoaders.babel.options.appDir,
15+
pagesDir: options.defaultLoaders.babel.options.pagesDir,
1516
...pluginOptions,
1617
dir: options.dir,
1718
nextRuntime: options.nextRuntime,

src/loader.js

Lines changed: 15 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -30,13 +30,7 @@ async function gatherPartials(ast, schemaDir, tokenizer, parseOptions) {
3030
partials = {
3131
...partials,
3232
[file]: content,
33-
...(await gatherPartials.call(
34-
this,
35-
ast,
36-
schemaDir,
37-
tokenizer,
38-
parseOptions
39-
)),
33+
...(await gatherPartials.call(this, ast, schemaDir, tokenizer, parseOptions)),
4034
};
4135
}
4236
}
@@ -63,6 +57,7 @@ async function load(source) {
6357
},
6458
nextjsExports = ['metadata', 'revalidate'],
6559
appDir = false,
60+
pagesDir,
6661
} = this.getOptions() || {};
6762

6863
const tokenizer = new Markdoc.Tokenizer(options);
@@ -72,6 +67,8 @@ async function load(source) {
7267
const tokens = tokenizer.tokenize(source);
7368
const ast = Markdoc.parse(tokens, parseOptions);
7469

70+
const isPage = this.resourcePath.startsWith(appDir || pagesDir);
71+
7572
// Grabs the path of the file relative to the `/{app,pages}` directory
7673
// to pass into the app props later.
7774
// This array access @ index 1 is safe since Next.js guarantees that
@@ -88,8 +85,7 @@ async function load(source) {
8885
);
8986

9087
// IDEA: consider making this an option per-page
91-
const dataFetchingFunction =
92-
mode === 'server' ? 'getServerSideProps' : 'getStaticProps';
88+
const dataFetchingFunction = mode === 'server' ? 'getServerSideProps' : 'getStaticProps';
9389

9490
let schemaCode = 'const schema = {};';
9591
try {
@@ -138,18 +134,14 @@ import yaml from 'js-yaml';
138134
// renderers is imported separately so Markdoc isn't sent to the client
139135
import Markdoc, {renderers} from '@markdoc/markdoc'
140136
141-
import {getSchema, defaultObject} from '${normalize(
142-
await resolve(__dirname, './runtime')
143-
)}';
137+
import {getSchema, defaultObject} from '${normalize(await resolve(__dirname, './runtime'))}';
144138
/**
145139
* Schema is imported like this so end-user's code is compiled using build-in babel/webpack configs.
146140
* This enables typescript/ESnext support
147141
*/
148142
${schemaCode}
149143
150-
const tokenizer = new Markdoc.Tokenizer(${
151-
options ? JSON.stringify(options) : ''
152-
});
144+
const tokenizer = new Markdoc.Tokenizer(${options ? JSON.stringify(options) : ''});
153145
154146
/**
155147
* Source will never change at runtime, so parse happens at the file root
@@ -170,7 +162,7 @@ const frontmatter = ast.attributes.frontmatter
170162
171163
const {components, ...rest} = getSchema(schema)
172164
173-
async function getMarkdocData(context = {}) {
165+
${isPage ? 'async ' : ''}function getMarkdocData(context = {}) {
174166
const partials = ${JSON.stringify(partials)};
175167
176168
// Ensure Node.transformChildren is available
@@ -196,7 +188,7 @@ async function getMarkdocData(context = {}) {
196188
* transform must be called in dataFetchingFunction to support server-side rendering while
197189
* accessing variables on the server
198190
*/
199-
const content = await Markdoc.transform(ast, cfg);
191+
const content = ${isPage ? 'await ' : ''}Markdoc.transform(ast, cfg);
200192
201193
// Removes undefined
202194
return JSON.parse(
@@ -211,7 +203,7 @@ async function getMarkdocData(context = {}) {
211203
}
212204
213205
${
214-
appDir
206+
appDir || !isPage
215207
? ''
216208
: `export async function ${dataFetchingFunction}(context) {
217209
return {
@@ -221,10 +213,12 @@ ${
221213
};
222214
}`
223215
}
224-
${appDir ? nextjsExportsCode : ''}
216+
${appDir && isPage ? nextjsExportsCode : ''}
225217
export const markdoc = {frontmatter};
226-
export default${appDir ? ' async' : ''} function MarkdocComponent(props) {
227-
const markdoc = ${appDir ? 'await getMarkdocData()' : 'props.markdoc'};
218+
export default${appDir && isPage ? ' async' : ''} function MarkdocComponent(props) {
219+
const markdoc = ${
220+
isPage ? (appDir ? 'await getMarkdocData()' : 'props.markdoc') : 'getMarkdocData()'
221+
};
228222
// Only execute HMR code in development
229223
return renderers.react(markdoc.content, React, {
230224
components: {

tests/__snapshots__/index.test.js.snap

Lines changed: 92 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ const tokenizer = new Markdoc.Tokenizer({\\"allowComments\\":true});
116116
* Source will never change at runtime, so parse happens at the file root
117117
*/
118118
const source = \\"---\\\\ntitle: Custom title\\\\n---\\\\n\\\\n# {% $markdoc.frontmatter.title %}\\\\n\\\\n{% tag /%}\\\\n\\";
119-
const filepath = undefined;
119+
const filepath = \\"/test/index.md\\";
120120
const tokens = tokenizer.tokenize(source);
121121
const parseOptions = {\\"slots\\":false};
122122
const ast = Markdoc.parse(tokens, parseOptions);
@@ -285,3 +285,94 @@ export default function MarkdocComponent(props) {
285285
}
286286
"
287287
`;
288+
289+
exports[`import as frontend component 1`] = `
290+
"import React from 'react';
291+
import yaml from 'js-yaml';
292+
// renderers is imported separately so Markdoc isn't sent to the client
293+
import Markdoc, {renderers} from '@markdoc/markdoc'
294+
295+
import {getSchema, defaultObject} from './src/runtime.js';
296+
/**
297+
* Schema is imported like this so end-user's code is compiled using build-in babel/webpack configs.
298+
* This enables typescript/ESnext support
299+
*/
300+
const schema = {};
301+
302+
const tokenizer = new Markdoc.Tokenizer({\\"allowComments\\":true});
303+
304+
/**
305+
* Source will never change at runtime, so parse happens at the file root
306+
*/
307+
const source = \\"---\\\\ntitle: Custom title\\\\n---\\\\n\\\\n# {% $markdoc.frontmatter.title %}\\\\n\\\\n{% tag /%}\\\\n\\";
308+
const filepath = undefined;
309+
const tokens = tokenizer.tokenize(source);
310+
const parseOptions = {\\"slots\\":false};
311+
const ast = Markdoc.parse(tokens, parseOptions);
312+
313+
/**
314+
* Like the AST, frontmatter won't change at runtime, so it is loaded at file root.
315+
* This unblocks future features, such a per-page dataFetchingFunction.
316+
*/
317+
const frontmatter = ast.attributes.frontmatter
318+
? yaml.load(ast.attributes.frontmatter)
319+
: {};
320+
321+
const {components, ...rest} = getSchema(schema)
322+
323+
function getMarkdocData(context = {}) {
324+
const partials = {};
325+
326+
// Ensure Node.transformChildren is available
327+
Object.keys(partials).forEach((key) => {
328+
const tokens = tokenizer.tokenize(partials[key]);
329+
partials[key] = Markdoc.parse(tokens, parseOptions);
330+
});
331+
332+
const cfg = {
333+
...rest,
334+
variables: {
335+
...(rest ? rest.variables : {}),
336+
// user can't override this namespace
337+
markdoc: {frontmatter},
338+
// Allows users to eject from Markdoc rendering and pass in dynamic variables via getServerSideProps
339+
...(context.variables || {})
340+
},
341+
partials,
342+
source,
343+
};
344+
345+
/**
346+
* transform must be called in dataFetchingFunction to support server-side rendering while
347+
* accessing variables on the server
348+
*/
349+
const content = Markdoc.transform(ast, cfg);
350+
351+
// Removes undefined
352+
return JSON.parse(
353+
JSON.stringify({
354+
content,
355+
frontmatter,
356+
file: {
357+
path: filepath,
358+
},
359+
})
360+
);
361+
}
362+
363+
364+
365+
export const markdoc = {frontmatter};
366+
export default function MarkdocComponent(props) {
367+
const markdoc = getMarkdocData();
368+
// Only execute HMR code in development
369+
return renderers.react(markdoc.content, React, {
370+
components: {
371+
...components,
372+
// Allows users to override default components at runtime, via their _app
373+
...props.components,
374+
},
375+
});
376+
}
377+
"
378+
`;

tests/index.test.js

Lines changed: 23 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -59,13 +59,17 @@ function evaluate(output) {
5959
}
6060

6161
function options(config = {}) {
62+
const dir = `${'/Users/someone/a-next-js-repo'}/${config.appDir ? 'app' : 'pages'}`;
63+
6264
const webpackThis = {
6365
context: __dirname,
6466
getOptions() {
6567
return {
6668
...config,
6769
dir: __dirname,
6870
nextRuntime: 'nodejs',
71+
appDir: config.appDir ? dir : undefined,
72+
pagesDir: config.appDir ? undefined : dir,
6973
};
7074
},
7175
getLogger() {
@@ -77,12 +81,10 @@ function options(config = {}) {
7781
const resolve = enhancedResolve.create(options);
7882
return async (context, file) =>
7983
new Promise((res, rej) =>
80-
resolve(context, file, (err, result) =>
81-
err ? rej(err) : res(result)
82-
)
84+
resolve(context, file, (err, result) => (err ? rej(err) : res(result)))
8385
).then(normalizeAbsolutePath);
8486
},
85-
resourcePath: '/Users/someone/a-next-js-repo/pages/test/index.md',
87+
resourcePath: dir + '/test/index.md',
8688
};
8789

8890
return webpackThis;
@@ -102,15 +104,13 @@ async function callLoader(config, source) {
102104
}
103105

104106
test('should not fail build if default `schemaPath` is used', async () => {
105-
await expect(callLoader(options(), source)).resolves.toEqual(
106-
expect.any(String)
107-
);
107+
await expect(callLoader(options(), source)).resolves.toEqual(expect.any(String));
108108
});
109109

110110
test('should fail build if invalid `schemaPath` is used', async () => {
111-
await expect(
112-
callLoader(options({schemaPath: 'unknown_schema_path'}), source)
113-
).rejects.toThrow("Cannot find module 'unknown_schema_path'");
111+
await expect(callLoader(options({schemaPath: 'unknown_schema_path'}), source)).rejects.toThrow(
112+
"Cannot find module 'unknown_schema_path'"
113+
);
114114
});
115115

116116
test('file output is correct', async () => {
@@ -154,11 +154,7 @@ test('file output is correct', async () => {
154154
});
155155

156156
expect(page.default(data.props)).toEqual(
157-
React.createElement(
158-
'article',
159-
undefined,
160-
React.createElement('h1', undefined, 'Custom title')
161-
)
157+
React.createElement('article', undefined, React.createElement('h1', undefined, 'Custom title'))
162158
);
163159
});
164160

@@ -179,11 +175,7 @@ test('app router', async () => {
179175
});
180176

181177
expect(await page.default({})).toEqual(
182-
React.createElement(
183-
'article',
184-
undefined,
185-
React.createElement('h1', undefined, 'Custom title')
186-
)
178+
React.createElement('article', undefined, React.createElement('h1', undefined, 'Custom title'))
187179
);
188180
});
189181

@@ -193,9 +185,7 @@ test('app router metadata', async () => {
193185
source.replace('---', '---\nmetadata:\n title: Metadata title')
194186
);
195187

196-
expect(output).toContain(
197-
'export const metadata = frontmatter.nextjs?.metadata;'
198-
);
188+
expect(output).toContain('export const metadata = frontmatter.nextjs?.metadata;');
199189
});
200190

201191
test.each([
@@ -211,9 +201,7 @@ test.each([
211201
const page = evaluate(output);
212202

213203
const data = await page.getStaticProps({});
214-
expect(data.props.markdoc.content.children[0].children[0]).toEqual(
215-
'Custom title'
216-
);
204+
expect(data.props.markdoc.content.children[0].children[0]).toEqual('Custom title');
217205
expect(data.props.markdoc.content.children[1]).toEqual(expectedChild);
218206
});
219207

@@ -267,3 +255,12 @@ test('mode="server"', async () => {
267255
},
268256
});
269257
});
258+
259+
test('import as frontend component', async () => {
260+
const o = options();
261+
// Use a non-page pathway
262+
o.resourcePath = o.resourcePath.replace('pages/test/index.md', 'components/table.md');
263+
const output = await callLoader(o, source);
264+
265+
expect(normalizeOperatingSystemPaths(output)).toMatchSnapshot();
266+
});

0 commit comments

Comments
 (0)