From 28565eb5e075dc1aa80f0714eb441fb022b9f4e7 Mon Sep 17 00:00:00 2001 From: Dillan Date: Thu, 13 Nov 2025 19:26:43 -0800 Subject: [PATCH] Enhance Turbopack support and improve loader functionality - 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. --- README.md | 7 +++ src/loader.js | 46 +++++++++++--- tests/index.test.js | 148 ++++++++++++++++++++++++++++++++++---------- 3 files changed, 160 insertions(+), 41 deletions(-) diff --git a/README.md b/README.md index 592f515..3ccad93 100644 --- a/README.md +++ b/README.md @@ -32,11 +32,18 @@ The first thing you'll need to do is install `@markdoc/next.js` and add it to yo // next.config.js module.exports = withMarkdoc({ dir: process.cwd(), // Required for Turbopack file resolution + schemaPath: './markdoc', // Wherever your Markdoc schema lives })({ pageExtensions: ['js', 'md'], + turbopack: {}, // Turbopack only runs the loader when a base config exists }); ``` + Turbopack currently requires every schema entry file referenced by `schemaPath` to exist, + even if you are not customizing them yet. Create `config.js`, `nodes.js`, `tags.js`, and + `functions.js` in that directory (exporting empty objects is fine) so the loader can resolve + them during the build. + 3. Create a new Markdoc file in `pages/docs` named `getting-started.md`. ``` diff --git a/src/loader.js b/src/loader.js index 2702550..b555321 100644 --- a/src/loader.js +++ b/src/loader.js @@ -8,6 +8,16 @@ function normalize(s) { return s.replace(/\\/g, path.win32.sep.repeat(2)); } +function getRelativeImportPath(from, to) { + const relative = path.relative(path.dirname(from), to); + if (!relative) { + return './'; + } + + const request = relative.startsWith('.') ? relative : `./${relative}`; + return normalize(request); +} + async function gatherPartials(ast, schemaDir, tokenizer, parseOptions) { let partials = {}; @@ -67,7 +77,7 @@ async function load(source) { const ast = Markdoc.parse(tokens, parseOptions); // Determine if this is a page file by checking if it starts with the provided directories - const isPage = (appDir && this.resourcePath.startsWith(appDir)) || + const isPage = (appDir && this.resourcePath.startsWith(appDir)) || (pagesDir && this.resourcePath.startsWith(pagesDir)); // Grabs the path of the file relative to the `/{app,pages}` directory @@ -93,14 +103,34 @@ async function load(source) { 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} = {};`; + const importAtRuntime = async (variable) => { + const requests = [variable]; + + // Turbopack module resolution currently requires explicit relative paths + // when `preferRelative` is used with bare specifiers (e.g. `tags`). + if ( + typeof variable === 'string' && + !variable.startsWith('.') && + !variable.startsWith('/') + ) { + requests.push(`./${variable}`); } - } + + let lastError; + + for (const request of requests) { + try { + const module = await resolve(schemaDir, request); + const modulePath = getRelativeImportPath(this.resourcePath, module); + return `import * as ${variable} from '${modulePath}'`; + } catch (error) { + lastError = error; + } + } + + console.debug('[Markdoc loader] Failed to resolve', { schemaDir, variable, error: lastError }); + return `const ${variable} = {};`; + }; if (directoryExists) { schemaCode = ` diff --git a/tests/index.test.js b/tests/index.test.js index 128b13d..21f3d6f 100644 --- a/tests/index.test.js +++ b/tests/index.test.js @@ -2,6 +2,7 @@ const vm = require('vm'); const fs = require('fs'); const path = require('path'); const babel = require('@babel/core'); +const Module = require('module'); const React = require('react'); const enhancedResolve = require('enhanced-resolve'); const loader = require('../src/loader'); @@ -10,6 +11,8 @@ const loader = require('../src/loader'); jest.mock('@markdoc/next.js/runtime', () => require('../src/runtime'), {virtual: true}); const source = fs.readFileSync(require.resolve('./fixture.md'), 'utf-8'); +const consoleErrorMock = jest.spyOn(console, 'error').mockImplementation(() => {}); +const consoleDebugMock = jest.spyOn(console, 'debug').mockImplementation(() => {}); // https://stackoverflow.com/questions/53799385/how-can-i-convert-a-windows-path-to-posix-path-using-node-path function normalizeAbsolutePath(s) { @@ -27,12 +30,8 @@ function normalizeOperatingSystemPaths(s) { .replace(/\/r\/n/g, '\\n'); } -function evaluate(output) { - const {code} = babel.transformSync(output); - const exports = {}; - - // https://stackoverflow.com/questions/38332094/how-can-i-mock-webpacks-require-context-in-jest - require.context = require.context = (base = '.') => { +function createRequireContext(requireFn) { + return (base = '.') => { const files = []; function readDirectory(directory) { @@ -49,20 +48,56 @@ function evaluate(output) { readDirectory(path.resolve(__dirname, base)); - return Object.assign(require, {keys: () => files}); + return Object.assign(requireFn, {keys: () => files}); + }; +} + +function evaluate(output, filename = path.join(__dirname, 'pages/test/index.md')) { + const {code} = babel.transformSync(output, {filename}); + + const resourceRequire = Module.createRequire(filename); + const baseRequire = require; + + const customRequire = (specifier) => { + if (specifier.startsWith('.') || specifier.startsWith('/')) { + return resourceRequire(specifier); + } + + return baseRequire(specifier); }; + customRequire.resolve = (specifier) => { + if (specifier.startsWith('.') || specifier.startsWith('/')) { + return resourceRequire.resolve(specifier); + } + + return baseRequire.resolve(specifier); + }; + + customRequire.cache = baseRequire.cache; + customRequire.main = baseRequire.main; + customRequire.extensions = baseRequire.extensions; + customRequire.paths = baseRequire.paths; + + const context = createRequireContext(customRequire); + customRequire.context = context; + require.context = context; + + const exports = {}; + const module = {exports}; + vm.runInNewContext(code, { exports, - require, + module, + require: customRequire, console, }); - return exports; + return module.exports; } function options(config = {}) { - const dir = `${'/Users/someone/a-next-js-repo'}/${config.appDir ? 'app' : 'pages'}`; + const dir = path.join(__dirname, config.appDir ? 'app' : 'pages'); const webpackThis = { context: __dirname, @@ -87,7 +122,7 @@ function options(config = {}) { resolve(context, file, (err, result) => (err ? rej(err) : res(result))) ).then(normalizeAbsolutePath); }, - resourcePath: dir + '/test/index.md', + resourcePath: path.join(dir, 'test', 'index.md'), }; return webpackThis; @@ -117,13 +152,14 @@ test('should fail build if invalid `schemaPath` is used', async () => { }); test('file output is correct', async () => { - const output = await callLoader(options(), source); + const webpackThis = options(); + const output = await callLoader(webpackThis, source); expect(normalizeOperatingSystemPaths(output)).toMatchSnapshot(); - const page = evaluate(output); + const page = evaluate(output, webpackThis.resourcePath); - expect(evaluate(output)).toEqual({ + expect(page).toEqual({ default: expect.any(Function), getStaticProps: expect.any(Function), markdoc: { @@ -162,13 +198,14 @@ test('file output is correct', async () => { }); test('app router', async () => { - const output = await callLoader(options({appDir: true}), source); + const webpackThis = options({appDir: true}); + const output = await callLoader(webpackThis, source); expect(normalizeOperatingSystemPaths(output)).toMatchSnapshot(); - const page = evaluate(output); + const page = evaluate(output, webpackThis.resourcePath); - expect(evaluate(output)).toEqual({ + expect(page).toEqual({ default: expect.any(Function), markdoc: { frontmatter: { @@ -183,8 +220,9 @@ test('app router', async () => { }); test('app router metadata', async () => { + const webpackThis = options({appDir: true}); const output = await callLoader( - options({appDir: true}), + webpackThis, source.replace('---', '---\nmetadata:\n title: Metadata title') ); @@ -199,9 +237,10 @@ test.each([ ['schemas/files', 'markdoc2'], ['schemas/typescript', source], ])('Custom schema path ("%s")', async (schemaPath, expectedChild) => { - const output = await callLoader(options({schemaPath}), source); + const webpackThis = options({schemaPath}); + const output = await callLoader(webpackThis, source); - const page = evaluate(output); + const page = evaluate(output, webpackThis.resourcePath); const data = await page.getStaticProps({}); expect(data.props.markdoc.content.children[0].children[0]).toEqual('Custom title'); @@ -209,30 +248,67 @@ test.each([ }); test('Partials', async () => { + const webpackThis = options({schemaPath: './schemas/partials'}); const output = await callLoader( - options({schemaPath: './schemas/partials'}), + webpackThis, `${source}\n{% partial file="footer.md" /%}` ); - const page = evaluate(output); + const page = evaluate(output, webpackThis.resourcePath); const data = await page.getStaticProps({}); expect(data.props.markdoc.content.children[1].children[0]).toEqual('footer'); }); test('Ejected config', async () => { + const webpackThis = options({schemaPath: './schemas/ejectedConfig'}); const output = await callLoader( - options({schemaPath: './schemas/ejectedConfig'}), + webpackThis, `${source}\n{% $product %}` ); - const page = evaluate(output); + const page = evaluate(output, webpackThis.resourcePath); const data = await page.getStaticProps({}); expect(data.props.markdoc.content.children[1]).toEqual('Extra value'); expect(data.props.markdoc.content.children[2].children[0]).toEqual('meal'); }); +test('falls back to relative schema imports when bare specifiers fail', async () => { + const schemaDir = path.resolve(__dirname, 'schemas/files'); + const resolveRequests = []; + const webpackThis = { + ...options({schemaPath: './schemas/files'}), + }; + + webpackThis.getResolve = () => async (_context, request) => { + resolveRequests.push(request); + const target = { + './tags': path.join(schemaDir, 'tags.js'), + './nodes': path.join(schemaDir, 'nodes.js'), + config: path.join(schemaDir, 'config.js'), + './config': path.join(schemaDir, 'config.js'), + functions: path.join(schemaDir, 'functions.js'), + './functions': path.join(schemaDir, 'functions.js'), + }[request]; + + if (target) { + return normalizeAbsolutePath(target); + } + + throw new Error(`Unable to resolve "${request}"`); + }; + + const output = await callLoader(webpackThis, source); + + expect(resolveRequests).toEqual( + expect.arrayContaining(['tags', './tags', 'nodes', './nodes']) + ); + + const importMatch = output.match(/import \* as tags from '([^']+)'/); + expect(importMatch?.[1].startsWith('.')).toBe(true); +}); + test('HMR', async () => { const output = await callLoader( { @@ -246,9 +322,10 @@ test('HMR', async () => { }); test('mode="server"', async () => { - const output = await callLoader(options({mode: 'server'}), source); + const webpackThis = options({mode: 'server'}); + const output = await callLoader(webpackThis, source); - expect(evaluate(output)).toEqual({ + expect(evaluate(output, webpackThis.resourcePath)).toEqual({ default: expect.any(Function), getServerSideProps: expect.any(Function), markdoc: { @@ -270,7 +347,7 @@ test('import as frontend component', async () => { test('Turbopack configuration', () => { const withMarkdoc = require('../src/index.js'); - + // Test basic Turbopack configuration const config = withMarkdoc()({ pageExtensions: ['js', 'md', 'mdoc'], @@ -278,18 +355,18 @@ test('Turbopack configuration', () => { rules: {}, }, }); - + expect(config.turbopack).toBeDefined(); expect(config.turbopack.rules).toBeDefined(); expect(config.turbopack.rules['*.md']).toBeDefined(); expect(config.turbopack.rules['*.mdoc']).toBeDefined(); - + // Verify rule structure const mdRule = config.turbopack.rules['*.md']; expect(mdRule.loaders).toHaveLength(1); expect(mdRule.loaders[0].loader).toContain('loader'); expect(mdRule.as).toBe('*.js'); - + // Test that existing turbopack config is preserved const configWithExisting = withMarkdoc()({ pageExtensions: ['js', 'md'], @@ -302,10 +379,10 @@ test('Turbopack configuration', () => { }, }, }); - + expect(configWithExisting.turbopack.rules['*.svg']).toBeDefined(); expect(configWithExisting.turbopack.rules['*.md']).toBeDefined(); - + // Test custom extension const configWithCustomExt = withMarkdoc({ extension: /\.(markdown|mdx)$/, @@ -315,7 +392,12 @@ test('Turbopack configuration', () => { rules: {}, }, }); - + expect(configWithCustomExt.turbopack.rules['*.markdown']).toBeDefined(); expect(configWithCustomExt.turbopack.rules['*.mdx']).toBeDefined(); }); + +afterAll(() => { + consoleErrorMock.mockRestore(); + consoleDebugMock.mockRestore(); +});