Skip to content

Commit 24c749a

Browse files
authored
Enhance Turbopack support and improve loader functionality (#67)
- Added schemaPath configuration to README for Turbopack setup. - Implemented relative import path resolution in loader. - Updated tests to ensure proper handling of schema imports and resource paths. - Improved error handling and debugging in loader.
1 parent 85f0619 commit 24c749a

File tree

3 files changed

+160
-41
lines changed

3 files changed

+160
-41
lines changed

README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,11 +32,18 @@ The first thing you'll need to do is install `@markdoc/next.js` and add it to yo
3232
// next.config.js
3333
module.exports = withMarkdoc({
3434
dir: process.cwd(), // Required for Turbopack file resolution
35+
schemaPath: './markdoc', // Wherever your Markdoc schema lives
3536
})({
3637
pageExtensions: ['js', 'md'],
38+
turbopack: {}, // Turbopack only runs the loader when a base config exists
3739
});
3840
```
3941

42+
Turbopack currently requires every schema entry file referenced by `schemaPath` to exist,
43+
even if you are not customizing them yet. Create `config.js`, `nodes.js`, `tags.js`, and
44+
`functions.js` in that directory (exporting empty objects is fine) so the loader can resolve
45+
them during the build.
46+
4047
3. Create a new Markdoc file in `pages/docs` named `getting-started.md`.
4148

4249
```

src/loader.js

Lines changed: 38 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,16 @@ function normalize(s) {
88
return s.replace(/\\/g, path.win32.sep.repeat(2));
99
}
1010

11+
function getRelativeImportPath(from, to) {
12+
const relative = path.relative(path.dirname(from), to);
13+
if (!relative) {
14+
return './';
15+
}
16+
17+
const request = relative.startsWith('.') ? relative : `./${relative}`;
18+
return normalize(request);
19+
}
20+
1121
async function gatherPartials(ast, schemaDir, tokenizer, parseOptions) {
1222
let partials = {};
1323

@@ -67,7 +77,7 @@ async function load(source) {
6777
const ast = Markdoc.parse(tokens, parseOptions);
6878

6979
// Determine if this is a page file by checking if it starts with the provided directories
70-
const isPage = (appDir && this.resourcePath.startsWith(appDir)) ||
80+
const isPage = (appDir && this.resourcePath.startsWith(appDir)) ||
7181
(pagesDir && this.resourcePath.startsWith(pagesDir));
7282

7383
// Grabs the path of the file relative to the `/{app,pages}` directory
@@ -93,14 +103,34 @@ async function load(source) {
93103
const directoryExists = await fs.promises.stat(schemaDir);
94104

95105
// This creates import strings that cause the config to be imported runtime
96-
async function importAtRuntime(variable) {
97-
try {
98-
const module = await resolve(schemaDir, variable);
99-
return `import * as ${variable} from '${normalize(module)}'`;
100-
} catch (error) {
101-
return `const ${variable} = {};`;
106+
const importAtRuntime = async (variable) => {
107+
const requests = [variable];
108+
109+
// Turbopack module resolution currently requires explicit relative paths
110+
// when `preferRelative` is used with bare specifiers (e.g. `tags`).
111+
if (
112+
typeof variable === 'string' &&
113+
!variable.startsWith('.') &&
114+
!variable.startsWith('/')
115+
) {
116+
requests.push(`./${variable}`);
102117
}
103-
}
118+
119+
let lastError;
120+
121+
for (const request of requests) {
122+
try {
123+
const module = await resolve(schemaDir, request);
124+
const modulePath = getRelativeImportPath(this.resourcePath, module);
125+
return `import * as ${variable} from '${modulePath}'`;
126+
} catch (error) {
127+
lastError = error;
128+
}
129+
}
130+
131+
console.debug('[Markdoc loader] Failed to resolve', { schemaDir, variable, error: lastError });
132+
return `const ${variable} = {};`;
133+
};
104134

105135
if (directoryExists) {
106136
schemaCode = `

tests/index.test.js

Lines changed: 115 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ const vm = require('vm');
22
const fs = require('fs');
33
const path = require('path');
44
const babel = require('@babel/core');
5+
const Module = require('module');
56
const React = require('react');
67
const enhancedResolve = require('enhanced-resolve');
78
const loader = require('../src/loader');
@@ -10,6 +11,8 @@ const loader = require('../src/loader');
1011
jest.mock('@markdoc/next.js/runtime', () => require('../src/runtime'), {virtual: true});
1112

1213
const source = fs.readFileSync(require.resolve('./fixture.md'), 'utf-8');
14+
const consoleErrorMock = jest.spyOn(console, 'error').mockImplementation(() => {});
15+
const consoleDebugMock = jest.spyOn(console, 'debug').mockImplementation(() => {});
1316

1417
// https://stackoverflow.com/questions/53799385/how-can-i-convert-a-windows-path-to-posix-path-using-node-path
1518
function normalizeAbsolutePath(s) {
@@ -27,12 +30,8 @@ function normalizeOperatingSystemPaths(s) {
2730
.replace(/\/r\/n/g, '\\n');
2831
}
2932

30-
function evaluate(output) {
31-
const {code} = babel.transformSync(output);
32-
const exports = {};
33-
34-
// https://stackoverflow.com/questions/38332094/how-can-i-mock-webpacks-require-context-in-jest
35-
require.context = require.context = (base = '.') => {
33+
function createRequireContext(requireFn) {
34+
return (base = '.') => {
3635
const files = [];
3736

3837
function readDirectory(directory) {
@@ -49,20 +48,56 @@ function evaluate(output) {
4948

5049
readDirectory(path.resolve(__dirname, base));
5150

52-
return Object.assign(require, {keys: () => files});
51+
return Object.assign(requireFn, {keys: () => files});
52+
};
53+
}
54+
55+
function evaluate(output, filename = path.join(__dirname, 'pages/test/index.md')) {
56+
const {code} = babel.transformSync(output, {filename});
57+
58+
const resourceRequire = Module.createRequire(filename);
59+
const baseRequire = require;
60+
61+
const customRequire = (specifier) => {
62+
if (specifier.startsWith('.') || specifier.startsWith('/')) {
63+
return resourceRequire(specifier);
64+
}
65+
66+
return baseRequire(specifier);
5367
};
5468

69+
customRequire.resolve = (specifier) => {
70+
if (specifier.startsWith('.') || specifier.startsWith('/')) {
71+
return resourceRequire.resolve(specifier);
72+
}
73+
74+
return baseRequire.resolve(specifier);
75+
};
76+
77+
customRequire.cache = baseRequire.cache;
78+
customRequire.main = baseRequire.main;
79+
customRequire.extensions = baseRequire.extensions;
80+
customRequire.paths = baseRequire.paths;
81+
82+
const context = createRequireContext(customRequire);
83+
customRequire.context = context;
84+
require.context = context;
85+
86+
const exports = {};
87+
const module = {exports};
88+
5589
vm.runInNewContext(code, {
5690
exports,
57-
require,
91+
module,
92+
require: customRequire,
5893
console,
5994
});
6095

61-
return exports;
96+
return module.exports;
6297
}
6398

6499
function options(config = {}) {
65-
const dir = `${'/Users/someone/a-next-js-repo'}/${config.appDir ? 'app' : 'pages'}`;
100+
const dir = path.join(__dirname, config.appDir ? 'app' : 'pages');
66101

67102
const webpackThis = {
68103
context: __dirname,
@@ -87,7 +122,7 @@ function options(config = {}) {
87122
resolve(context, file, (err, result) => (err ? rej(err) : res(result)))
88123
).then(normalizeAbsolutePath);
89124
},
90-
resourcePath: dir + '/test/index.md',
125+
resourcePath: path.join(dir, 'test', 'index.md'),
91126
};
92127

93128
return webpackThis;
@@ -117,13 +152,14 @@ test('should fail build if invalid `schemaPath` is used', async () => {
117152
});
118153

119154
test('file output is correct', async () => {
120-
const output = await callLoader(options(), source);
155+
const webpackThis = options();
156+
const output = await callLoader(webpackThis, source);
121157

122158
expect(normalizeOperatingSystemPaths(output)).toMatchSnapshot();
123159

124-
const page = evaluate(output);
160+
const page = evaluate(output, webpackThis.resourcePath);
125161

126-
expect(evaluate(output)).toEqual({
162+
expect(page).toEqual({
127163
default: expect.any(Function),
128164
getStaticProps: expect.any(Function),
129165
markdoc: {
@@ -162,13 +198,14 @@ test('file output is correct', async () => {
162198
});
163199

164200
test('app router', async () => {
165-
const output = await callLoader(options({appDir: true}), source);
201+
const webpackThis = options({appDir: true});
202+
const output = await callLoader(webpackThis, source);
166203

167204
expect(normalizeOperatingSystemPaths(output)).toMatchSnapshot();
168205

169-
const page = evaluate(output);
206+
const page = evaluate(output, webpackThis.resourcePath);
170207

171-
expect(evaluate(output)).toEqual({
208+
expect(page).toEqual({
172209
default: expect.any(Function),
173210
markdoc: {
174211
frontmatter: {
@@ -183,8 +220,9 @@ test('app router', async () => {
183220
});
184221

185222
test('app router metadata', async () => {
223+
const webpackThis = options({appDir: true});
186224
const output = await callLoader(
187-
options({appDir: true}),
225+
webpackThis,
188226
source.replace('---', '---\nmetadata:\n title: Metadata title')
189227
);
190228

@@ -199,40 +237,78 @@ test.each([
199237
['schemas/files', 'markdoc2'],
200238
['schemas/typescript', source],
201239
])('Custom schema path ("%s")', async (schemaPath, expectedChild) => {
202-
const output = await callLoader(options({schemaPath}), source);
240+
const webpackThis = options({schemaPath});
241+
const output = await callLoader(webpackThis, source);
203242

204-
const page = evaluate(output);
243+
const page = evaluate(output, webpackThis.resourcePath);
205244

206245
const data = await page.getStaticProps({});
207246
expect(data.props.markdoc.content.children[0].children[0]).toEqual('Custom title');
208247
expect(data.props.markdoc.content.children[1]).toEqual(expectedChild);
209248
});
210249

211250
test('Partials', async () => {
251+
const webpackThis = options({schemaPath: './schemas/partials'});
212252
const output = await callLoader(
213-
options({schemaPath: './schemas/partials'}),
253+
webpackThis,
214254
`${source}\n{% partial file="footer.md" /%}`
215255
);
216256

217-
const page = evaluate(output);
257+
const page = evaluate(output, webpackThis.resourcePath);
218258

219259
const data = await page.getStaticProps({});
220260
expect(data.props.markdoc.content.children[1].children[0]).toEqual('footer');
221261
});
222262

223263
test('Ejected config', async () => {
264+
const webpackThis = options({schemaPath: './schemas/ejectedConfig'});
224265
const output = await callLoader(
225-
options({schemaPath: './schemas/ejectedConfig'}),
266+
webpackThis,
226267
`${source}\n{% $product %}`
227268
);
228269

229-
const page = evaluate(output);
270+
const page = evaluate(output, webpackThis.resourcePath);
230271

231272
const data = await page.getStaticProps({});
232273
expect(data.props.markdoc.content.children[1]).toEqual('Extra value');
233274
expect(data.props.markdoc.content.children[2].children[0]).toEqual('meal');
234275
});
235276

277+
test('falls back to relative schema imports when bare specifiers fail', async () => {
278+
const schemaDir = path.resolve(__dirname, 'schemas/files');
279+
const resolveRequests = [];
280+
const webpackThis = {
281+
...options({schemaPath: './schemas/files'}),
282+
};
283+
284+
webpackThis.getResolve = () => async (_context, request) => {
285+
resolveRequests.push(request);
286+
const target = {
287+
'./tags': path.join(schemaDir, 'tags.js'),
288+
'./nodes': path.join(schemaDir, 'nodes.js'),
289+
config: path.join(schemaDir, 'config.js'),
290+
'./config': path.join(schemaDir, 'config.js'),
291+
functions: path.join(schemaDir, 'functions.js'),
292+
'./functions': path.join(schemaDir, 'functions.js'),
293+
}[request];
294+
295+
if (target) {
296+
return normalizeAbsolutePath(target);
297+
}
298+
299+
throw new Error(`Unable to resolve "${request}"`);
300+
};
301+
302+
const output = await callLoader(webpackThis, source);
303+
304+
expect(resolveRequests).toEqual(
305+
expect.arrayContaining(['tags', './tags', 'nodes', './nodes'])
306+
);
307+
308+
const importMatch = output.match(/import \* as tags from '([^']+)'/);
309+
expect(importMatch?.[1].startsWith('.')).toBe(true);
310+
});
311+
236312
test('HMR', async () => {
237313
const output = await callLoader(
238314
{
@@ -246,9 +322,10 @@ test('HMR', async () => {
246322
});
247323

248324
test('mode="server"', async () => {
249-
const output = await callLoader(options({mode: 'server'}), source);
325+
const webpackThis = options({mode: 'server'});
326+
const output = await callLoader(webpackThis, source);
250327

251-
expect(evaluate(output)).toEqual({
328+
expect(evaluate(output, webpackThis.resourcePath)).toEqual({
252329
default: expect.any(Function),
253330
getServerSideProps: expect.any(Function),
254331
markdoc: {
@@ -270,26 +347,26 @@ test('import as frontend component', async () => {
270347

271348
test('Turbopack configuration', () => {
272349
const withMarkdoc = require('../src/index.js');
273-
350+
274351
// Test basic Turbopack configuration
275352
const config = withMarkdoc()({
276353
pageExtensions: ['js', 'md', 'mdoc'],
277354
turbopack: {
278355
rules: {},
279356
},
280357
});
281-
358+
282359
expect(config.turbopack).toBeDefined();
283360
expect(config.turbopack.rules).toBeDefined();
284361
expect(config.turbopack.rules['*.md']).toBeDefined();
285362
expect(config.turbopack.rules['*.mdoc']).toBeDefined();
286-
363+
287364
// Verify rule structure
288365
const mdRule = config.turbopack.rules['*.md'];
289366
expect(mdRule.loaders).toHaveLength(1);
290367
expect(mdRule.loaders[0].loader).toContain('loader');
291368
expect(mdRule.as).toBe('*.js');
292-
369+
293370
// Test that existing turbopack config is preserved
294371
const configWithExisting = withMarkdoc()({
295372
pageExtensions: ['js', 'md'],
@@ -302,10 +379,10 @@ test('Turbopack configuration', () => {
302379
},
303380
},
304381
});
305-
382+
306383
expect(configWithExisting.turbopack.rules['*.svg']).toBeDefined();
307384
expect(configWithExisting.turbopack.rules['*.md']).toBeDefined();
308-
385+
309386
// Test custom extension
310387
const configWithCustomExt = withMarkdoc({
311388
extension: /\.(markdown|mdx)$/,
@@ -315,7 +392,12 @@ test('Turbopack configuration', () => {
315392
rules: {},
316393
},
317394
});
318-
395+
319396
expect(configWithCustomExt.turbopack.rules['*.markdown']).toBeDefined();
320397
expect(configWithCustomExt.turbopack.rules['*.mdx']).toBeDefined();
321398
});
399+
400+
afterAll(() => {
401+
consoleErrorMock.mockRestore();
402+
consoleDebugMock.mockRestore();
403+
});

0 commit comments

Comments
 (0)