-
Notifications
You must be signed in to change notification settings - Fork 26
/
loader.js
250 lines (218 loc) · 7.06 KB
/
loader.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
const fs = require('fs');
const path = require('path');
const Markdoc = require('@markdoc/markdoc');
const DEFAULT_SCHEMA_PATH = './markdoc';
function normalize(s) {
return s.replace(/\\/g, path.win32.sep.repeat(2));
}
async function gatherPartials(ast, schemaDir, tokenizer, parseOptions) {
let partials = {};
for (const node of ast.walk()) {
const file = node.attributes.file;
if (
node.type === 'tag' &&
node.tag === 'partial' &&
typeof file === 'string' &&
!partials[file]
) {
const filepath = path.join(schemaDir, file);
// parsing is not done here because then we have to serialize and reload from JSON at runtime
const content = await fs.promises.readFile(filepath, {encoding: 'utf8'});
if (content) {
const tokens = tokenizer.tokenize(content);
const ast = Markdoc.parse(tokens, parseOptions);
partials = {
...partials,
[file]: content,
...(await gatherPartials.call(
this,
ast,
schemaDir,
tokenizer,
parseOptions
)),
};
}
}
}
return partials;
}
// Returning a JSX object is what allows fast refresh to work
async function load(source) {
// https://webpack.js.org/concepts/module-resolution/
const resolve = this.getResolve({
// https://webpack.js.org/api/loaders/#thisgetresolve
extensions: ['.js', '.jsx', '.json', '.ts', '.tsx', '...'],
preferRelative: true,
});
const {
dir, // Root directory from Next.js (contains next.config.js)
mode = 'static',
schemaPath = DEFAULT_SCHEMA_PATH,
options: {slots = false, ...options} = {
allowComments: true,
},
nextjsExports = ['metadata', 'revalidate'],
appDir = false,
} = this.getOptions() || {};
const tokenizer = new Markdoc.Tokenizer(options);
const parseOptions = {slots};
const schemaDir = path.resolve(dir, schemaPath || DEFAULT_SCHEMA_PATH);
const tokens = tokenizer.tokenize(source);
const ast = Markdoc.parse(tokens, parseOptions);
// 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
// all pages will be located under either {app,pages}/ or src/{app,pages}/
// https://nextjs.org/docs/advanced-features/src-directory
const filepath = this.resourcePath.split(appDir ? 'app' : 'pages')[1];
const partials = await gatherPartials.call(
this,
ast,
path.resolve(schemaDir, 'partials'),
tokenizer,
parseOptions
);
// IDEA: consider making this an option per-page
const dataFetchingFunction =
mode === 'server' ? 'getServerSideProps' : 'getStaticProps';
let schemaCode = 'const schema = {};';
try {
const directoryExists = await fs.promises.stat(schemaDir);
// This creates import strings that cause the config to be imported runtime
async function importAtRuntime(variable) {
try {
const module = await resolve(schemaDir, variable);
return `import * as ${variable} from '${normalize(module)}'`;
} catch (error) {
return `const ${variable} = {};`;
}
}
if (directoryExists) {
schemaCode = `
${await importAtRuntime('config')}
${await importAtRuntime('tags')}
${await importAtRuntime('nodes')}
${await importAtRuntime('functions')}
const schema = {
tags: defaultObject(tags),
nodes: defaultObject(nodes),
functions: defaultObject(functions),
...defaultObject(config),
};`
.trim()
.replace(/^\s+/gm, '');
}
} catch (error) {
// Only throw module not found errors if user is passing a custom schemaPath
if (schemaPath && schemaPath !== DEFAULT_SCHEMA_PATH) {
throw new Error(`Cannot find module '${schemaPath}' at '${schemaDir}'`);
}
}
this.addContextDependency(schemaDir);
const nextjsExportsCode = nextjsExports
.map((name) => `export const ${name} = frontmatter.nextjs?.${name};`)
.join('\n');
const result = `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 '${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) : ''
});
/**
* Source will never change at runtime, so parse happens at the file root
*/
const source = ${JSON.stringify(source)};
const filepath = ${JSON.stringify(filepath)};
const tokens = tokenizer.tokenize(source);
const parseOptions = ${JSON.stringify(parseOptions)};
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)
async function getMarkdocData(context = {}) {
const partials = ${JSON.stringify(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 = await Markdoc.transform(ast, cfg);
// Removes undefined
return JSON.parse(
JSON.stringify({
content,
frontmatter,
file: {
path: filepath,
},
})
);
}
${
appDir
? ''
: `export async function ${dataFetchingFunction}(context) {
return {
props: {
markdoc: await getMarkdocData(context),
},
};
}`
}
${appDir ? nextjsExportsCode : ''}
export const markdoc = {frontmatter};
export default${appDir ? ' async' : ''} function MarkdocComponent(props) {
const markdoc = ${appDir ? 'await getMarkdocData()' : 'props.markdoc'};
// 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,
},
});
}
`;
return result;
}
module.exports = async function loader(source) {
const callback = this.async();
try {
const result = await load.call(this, source);
callback(null, result);
} catch (error) {
console.error(error);
callback(error);
}
};