Repository: markdoc/next.js Branch: main Commit: 975d56119f1a Files: 23 Total size: 39.0 KB Directory structure: gitextract_znlnuj1a/ ├── .github/ │ └── workflows/ │ ├── release.yml │ └── validate.yml ├── .gitignore ├── LICENSE ├── README.md ├── babel.config.json ├── package.json ├── src/ │ ├── index.d.ts │ ├── index.js │ ├── loader.js │ ├── runtime.js │ └── tags.js └── tests/ ├── __snapshots__/ │ └── index.test.js.snap ├── fixture.md ├── index.test.js └── schemas/ ├── ejectedConfig/ │ ├── config.js │ └── tags.ts ├── files/ │ ├── nodes.js │ └── tags.js ├── folders/ │ ├── nodes/ │ │ └── index.js │ └── tags/ │ └── index.js ├── partials/ │ └── partials/ │ └── footer.md └── typescript/ └── tags.ts ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/workflows/release.yml ================================================ name: Publish Package to NPM on: release: types: [created] jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - uses: actions/setup-node@v2 with: node-version: 16.x registry-url: 'https://registry.npmjs.org' - run: npm ci - run: npm run build --if-present - run: npm publish --access public env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} ================================================ FILE: .github/workflows/validate.yml ================================================ name: CI on: [push] jobs: build: strategy: matrix: platform: [ubuntu-latest, windows-latest] runs-on: ${{ matrix.platform }} steps: - uses: actions/checkout@v2 - uses: actions/setup-node@v1 with: node-version: 16.x - name: npm install, build, and test run: | npm ci npm run build --if-present npm run test ================================================ FILE: .gitignore ================================================ node_modules .DS_Store *.log yarn.lock ================================================ FILE: LICENSE ================================================ The MIT License Copyright (c) 2021- Stripe, Inc. (https://stripe.com) Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ # `@markdoc/next.js` > **Note**: this plugin will be treated as a beta version until `v1.0.0` is released. Using the `@markdoc/next.js` plugin allows you to create custom `.md` and `.mdoc` pages in your Next.js apps, and automatically render them with [`markdoc`](https://github.com/markdoc/markdoc). ## Setup The first thing you'll need to do is install `@markdoc/next.js` and add it to your project's config. 1. From your project, run this command to install `@markdoc/next.js`: ```sh npm install @markdoc/next.js @markdoc/markdoc ``` 2. Open `next.config.js` and add the following code: When using Webpack: ```js // next.config.js const withMarkdoc = require('@markdoc/next.js'); module.exports = withMarkdoc(/* options */)({ pageExtensions: ['js', 'md'], }); ``` For [Turbopack support](https://nextjs.org/docs/app/api-reference/turbopack), add the following configuration: ```js // next.config.js module.exports = withMarkdoc({ dir: process.cwd(), // Required for Turbopack file resolution })({ pageExtensions: ['js', 'md'], }); ``` 3. Create a new Markdoc file in `pages/docs` named `getting-started.md`. ``` pages ├── _app.js ├── docs │ └── getting-started.md ├── index.js ``` 4. Add some content to `getting-started.md`: ```md --- title: Get started with Markdoc description: How to get started with Markdoc --- # Get started with Markdoc ``` See [our docs](https://markdoc.dev/docs/nextjs) for more options. ## Contributing Contributions and feedback are welcomed and encouraged. Feel free to open PRs here, or open issues in the [Markdoc core repo](https://github.com/markdoc/markdoc). Follow these steps to set up the project: 1. Run `npm install` 1. Run `npm test` ## Code of conduct This project has adopted the Stripe [Code of conduct](https://github.com/markdoc/markdoc/blob/main/.github/CODE_OF_CONDUCT.md). ## License This project uses the [MIT license](LICENSE). ================================================ FILE: babel.config.json ================================================ { "env": { "test": { "plugins": [ "@babel/plugin-transform-modules-commonjs" ] } } } ================================================ FILE: package.json ================================================ { "name": "@markdoc/next.js", "version": "0.5.0", "author": "Stripe, Inc.", "description": "Markdoc plugin for Next.js", "license": "MIT", "main": "./src/index.js", "types": "./src/index.d.ts", "exports": { ".": "./src/index.js", "./*": "./src/*.js" }, "scripts": { "test": "jest" }, "dependencies": { "js-yaml": "^4.1.0" }, "devDependencies": { "@babel/core": "^7.16.12", "@babel/plugin-transform-modules-commonjs": "^7.16.8", "@markdoc/markdoc": "*", "@types/jest": "^27.4.1", "enhanced-resolve": "^5.10.0", "jest": "^27.5.1", "next": "*", "react": "*", "ts-jest": "^27.1.3", "typescript": "4.6.2" }, "peerDependencies": { "@markdoc/markdoc": "*", "next": "*", "react": "*" }, "directories": { "test": "tests" }, "repository": { "type": "git", "url": "git+https://github.com/markdoc/next.js.git" }, "bugs": { "url": "https://github.com/markdoc/next.js/issues" }, "homepage": "https://markdoc.dev/docs/nextjs", "jest": { "preset": "ts-jest/presets/js-with-babel" } } ================================================ FILE: src/index.d.ts ================================================ import type {ElementType} from 'react'; import type {NextConfig} from 'next'; import type {Config, RenderableTreeNodes, Schema} from '@markdoc/markdoc'; import type {RuleSetConditionAbsolute} from 'webpack'; export type MarkdocNextJsPageProps = { markdoc?: { content: RenderableTreeNodes; frontmatter: Record; file: { path: string; }; }; }; export type MarkdocNextJsConfig = Config & {readonly source: string}; export type MarkdocNextJsSchema = Schema< O & MarkdocNextJsConfig, ElementType >; export interface MarkdocNextJsOptions { extension?: RuleSetConditionAbsolute; mode?: 'static' | 'server'; options?: { slots?: boolean; allowComments?: boolean; }; schemaPath?: string; dir?: string; } declare function createMarkdocPlugin( options?: MarkdocNextJsOptions ): (config: NextConfig) => NextConfig; export = createMarkdocPlugin; ================================================ FILE: src/index.js ================================================ function createTurbopackConfig(nextConfig, pluginOptions) { const turbopack = nextConfig.turbopack; if (!turbopack) { return; } const extension = pluginOptions.extension || /\.(md|mdoc)$/; // Extract file extensions from regex pattern like /\.(md|mdoc)$/ to create glob patterns for Turbopack const extensionPatterns = extension instanceof RegExp ? extension.source .match(/\\\.\(([^)]+)\)\$?/)?.[1] ?.split('|') .map((e) => `*.${e}`) || ['*.md', '*.mdoc'] : [extension]; const rules = extensionPatterns.reduce((acc, pattern) => { acc[pattern] = { loaders: [ { loader: require.resolve('./loader'), options: { ...pluginOptions, }, }, ], as: '*.js', }; return acc; }, {}); return { ...nextConfig.turbopack, rules: { ...nextConfig.turbopack.rules, ...rules, }, }; } const withMarkdoc = (pluginOptions = {}) => (nextConfig = {}) => { const extension = pluginOptions.extension || /\.(md|mdoc)$/; return Object.assign({}, nextConfig, { webpack(config, options) { config.module.rules.push({ test: extension, use: [ // Adding the babel loader enables fast refresh options.defaultLoaders.babel, { loader: require.resolve('./loader'), options: { appDir: options.defaultLoaders.babel.options.appDir, pagesDir: options.defaultLoaders.babel.options.pagesDir, ...pluginOptions, dir: options.dir, }, }, ], }); if (typeof nextConfig.webpack === 'function') { return nextConfig.webpack(config, options); } return config; }, turbopack: createTurbopackConfig(nextConfig, pluginOptions), }); }; module.exports = withMarkdoc; ================================================ FILE: src/loader.js ================================================ 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, pagesDir, } = 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); // Determine if this is a page file by checking if it starts with the provided directories const isPage = (appDir && this.resourcePath.startsWith(appDir)) || (pagesDir && this.resourcePath.startsWith(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 // all pages will be located under either {app,pages}/ or src/{app,pages}/ // https://nextjs.org/docs/app/building-your-application/configuring/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 '@markdoc/next.js/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) ${isPage ? '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 = ${isPage ? 'await ' : ''}Markdoc.transform(ast, cfg); // Removes undefined return JSON.parse( JSON.stringify({ content, frontmatter, file: { path: filepath, }, }) ); } ${ appDir || !isPage ? '' : `export async function ${dataFetchingFunction}(context) { return { props: { markdoc: await getMarkdocData(context), }, }; }` } ${appDir && isPage ? nextjsExportsCode : ''} export const markdoc = {frontmatter}; 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: { ...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); } }; ================================================ FILE: src/runtime.js ================================================ // IDEA: explore better displayName functions function displayName(name) { // Pascal case return name .match(/[a-z]+/gi) .map((word) => word.charAt(0).toUpperCase() + word.substr(1).toLowerCase()) .join(''); } function transformRecord(config) { const output = {}; const components = {}; if (config) { Object.entries(config).forEach(([name, registration]) => { if (output[name]) { throw new Error(`"${name}" has already been declared`); } const componentName = registration.render ? displayName(name) : undefined; output[name] = { ...registration, render: componentName, }; if (componentName) { components[componentName] = registration.render; } }); } return {output, components}; } exports.getSchema = function getSchema(schema) { const {output: tags, components: tagComponents} = transformRecord( schema.tags ); const {output: nodes, components: nodeComponents} = transformRecord( schema.nodes ); return { ...schema, tags, nodes, components: { ...tagComponents, ...nodeComponents, }, }; }; exports.defaultObject = function defaultObject(o) { if (Object.prototype.hasOwnProperty.call(o, 'default')) return o.default; return o || {}; }; ================================================ FILE: src/tags.js ================================================ const Head = require('next/head'); const Image = require('next/image'); const Link = require('next/link'); const Script = require('next/script'); exports.comment = { description: 'Use to comment the content itself', attributes: {}, transform() { return []; }, }; exports.head = { render: Head, description: 'Renders a Next.js head tag', attributes: {}, }; exports.image = { render: Image, description: 'Renders a Next.js image tag', // https://nextjs.org/docs/app/api-reference/components/image attributes: { src: { type: String, required: true, }, alt: { type: String, required: true, }, width: { type: Number, required: true, }, height: { type: Number, required: true, }, fill: { type: Boolean, }, sizes: { type: String, }, quality: { type: Number, }, priority: { type: Boolean, }, placeholder: { type: String, matches: ['blur', 'empty'], }, loading: { type: String, matches: ['lazy', 'eager'], }, blurDataURL: { type: String, }, }, }; exports.link = { render: Link, description: 'Displays a Next.js link', attributes: { href: { description: 'The path or URL to navigate to.', type: String, errorLevel: 'critical', required: true, }, as: { description: 'Optional decorator for the path that will be shown in the browser URL bar.', type: String, }, passHref: { description: 'Forces Link to send the href property to its child.', type: Boolean, default: false, }, prefetch: { description: 'Prefetch the page in the background.', type: Boolean, }, replace: { description: 'Replace the current history state instead of adding a new url into the stack.', type: Boolean, default: false, }, scroll: { description: 'Scroll to the top of the page after a navigation.', type: Boolean, default: true, }, shallow: { description: 'Update the path of the current page without rerunning getStaticProps, getServerSideProps or getInitialProps.', type: Boolean, default: true, }, locale: { description: 'The active locale is automatically prepended.', type: Boolean, }, target: { description: 'HTML attribute anchor target ("_self", "_blank", "_parent", "_top")', type: String, } }, }; exports.script = { render: Script, description: 'Renders a Next.js script tag', attributes: { src: { type: String, errorLevel: 'critical', required: true, }, strategy: { type: String, matches: ['beforeInteractive', 'afterInteractive', 'lazyOnload'], }, }, }; ================================================ FILE: tests/__snapshots__/index.test.js.snap ================================================ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`HMR 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 '@markdoc/next.js/runtime'; /** * 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 = \\"/test/index.md\\"; 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) async 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 = await Markdoc.transform(ast, cfg); // Removes undefined return JSON.parse( JSON.stringify({ content, frontmatter, file: { path: filepath, }, }) ); } export async function getStaticProps(context) { return { props: { markdoc: await getMarkdocData(context), }, }; } export const markdoc = {frontmatter}; export default function MarkdocComponent(props) { const markdoc = 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, }, }); } " `; exports[`app router 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 '@markdoc/next.js/runtime'; /** * 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 = \\"/test/index.md\\"; 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) async 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 = await Markdoc.transform(ast, cfg); // Removes undefined return JSON.parse( JSON.stringify({ content, frontmatter, file: { path: filepath, }, }) ); } export const metadata = frontmatter.nextjs?.metadata; export const revalidate = frontmatter.nextjs?.revalidate; export const markdoc = {frontmatter}; export default async function MarkdocComponent(props) { const markdoc = await 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, }, }); } " `; exports[`file output is correct 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 '@markdoc/next.js/runtime'; /** * 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 = \\"/test/index.md\\"; 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) async 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 = await Markdoc.transform(ast, cfg); // Removes undefined return JSON.parse( JSON.stringify({ content, frontmatter, file: { path: filepath, }, }) ); } export async function getStaticProps(context) { return { props: { markdoc: await getMarkdocData(context), }, }; } export const markdoc = {frontmatter}; export default function MarkdocComponent(props) { const markdoc = 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, }, }); } " `; 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 '@markdoc/next.js/runtime'; /** * 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, }, }); } " `; ================================================ FILE: tests/fixture.md ================================================ --- title: Custom title --- # {% $markdoc.frontmatter.title %} {% tag /%} ================================================ FILE: tests/index.test.js ================================================ const vm = require('vm'); const fs = require('fs'); const path = require('path'); const babel = require('@babel/core'); const React = require('react'); const enhancedResolve = require('enhanced-resolve'); const loader = require('../src/loader'); // Mock the runtime module using Jest jest.mock('@markdoc/next.js/runtime', () => require('../src/runtime'), {virtual: true}); const source = fs.readFileSync(require.resolve('./fixture.md'), 'utf-8'); // https://stackoverflow.com/questions/53799385/how-can-i-convert-a-windows-path-to-posix-path-using-node-path function normalizeAbsolutePath(s) { return s .replace(/^[a-zA-Z]:/, '') // replace C: for Windows .split(path.sep) .join(path.posix.sep); } function normalizeOperatingSystemPaths(s) { return s .replace(normalizeAbsolutePath(process.cwd()), '.') .split(path.sep) .join(path.posix.sep) .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 = '.') => { const files = []; function readDirectory(directory) { fs.readdirSync(directory).forEach((file) => { const fullPath = path.resolve(directory, file); if (fs.statSync(fullPath).isDirectory()) { readDirectory(fullPath); } files.push(fullPath); }); } readDirectory(path.resolve(__dirname, base)); return Object.assign(require, {keys: () => files}); }; vm.runInNewContext(code, { exports, require, console, }); return exports; } function options(config = {}) { const dir = `${'/Users/someone/a-next-js-repo'}/${config.appDir ? 'app' : 'pages'}`; const webpackThis = { context: __dirname, getOptions() { return { ...config, dir: __dirname, nextRuntime: 'nodejs', appDir: config.appDir ? dir : undefined, pagesDir: config.appDir ? undefined : dir, }; }, getLogger() { return console; }, addDependency() {}, addContextDependency() {}, getResolve: (options) => { const resolve = enhancedResolve.create(options); return async (context, file) => new Promise((res, rej) => resolve(context, file, (err, result) => (err ? rej(err) : res(result))) ).then(normalizeAbsolutePath); }, resourcePath: dir + '/test/index.md', }; return webpackThis; } async function callLoader(config, source) { return new Promise((res, rej) => { config.async = () => (error, result) => { if (error) { rej(error); } else { res(result); } }; loader.call(config, source); }); } test('should not fail build if default `schemaPath` is used', async () => { 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'" ); }); test('file output is correct', async () => { const output = await callLoader(options(), source); expect(normalizeOperatingSystemPaths(output)).toMatchSnapshot(); const page = evaluate(output); expect(evaluate(output)).toEqual({ default: expect.any(Function), getStaticProps: expect.any(Function), markdoc: { frontmatter: { title: 'Custom title', }, }, }); const data = await page.getStaticProps({}); expect(data.props.markdoc).toEqual({ content: { $$mdtype: 'Tag', name: 'article', attributes: {}, children: [ { $$mdtype: 'Tag', name: 'h1', attributes: {}, children: ['Custom title'], }, ], }, frontmatter: { title: 'Custom title', }, file: { path: '/test/index.md', }, }); expect(page.default(data.props)).toEqual( React.createElement('article', undefined, React.createElement('h1', undefined, 'Custom title')) ); }); test('app router', async () => { const output = await callLoader(options({appDir: true}), source); expect(normalizeOperatingSystemPaths(output)).toMatchSnapshot(); const page = evaluate(output); expect(evaluate(output)).toEqual({ default: expect.any(Function), markdoc: { frontmatter: { title: 'Custom title', }, }, }); expect(await page.default({})).toEqual( React.createElement('article', undefined, React.createElement('h1', undefined, 'Custom title')) ); }); test('app router metadata', async () => { const output = await callLoader( options({appDir: true}), source.replace('---', '---\nmetadata:\n title: Metadata title') ); expect(output).toContain('export const metadata = frontmatter.nextjs?.metadata;'); }); test.each([ [undefined, undefined], ['./schemas/folders', 'markdoc1'], ['./schemas/folders/', 'markdoc1'], ['./schemas/files', 'markdoc2'], ['schemas/files', 'markdoc2'], ['schemas/typescript', source], ])('Custom schema path ("%s")', async (schemaPath, expectedChild) => { const output = await callLoader(options({schemaPath}), source); 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[1]).toEqual(expectedChild); }); test('Partials', async () => { const output = await callLoader( options({schemaPath: './schemas/partials'}), `${source}\n{% partial file="footer.md" /%}` ); const page = evaluate(output); const data = await page.getStaticProps({}); expect(data.props.markdoc.content.children[1].children[0]).toEqual('footer'); }); test('Ejected config', async () => { const output = await callLoader( options({schemaPath: './schemas/ejectedConfig'}), `${source}\n{% $product %}` ); const page = evaluate(output); 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('HMR', async () => { const output = await callLoader( { ...options(), hot: true, }, source ); expect(normalizeOperatingSystemPaths(output)).toMatchSnapshot(); }); test('mode="server"', async () => { const output = await callLoader(options({mode: 'server'}), source); expect(evaluate(output)).toEqual({ default: expect.any(Function), getServerSideProps: expect.any(Function), markdoc: { frontmatter: { title: 'Custom title', }, }, }); }); 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(); }); test('Turbopack configuration', () => { const withMarkdoc = require('../src/index.js'); // Test basic Turbopack configuration const config = withMarkdoc()({ pageExtensions: ['js', 'md', 'mdoc'], turbopack: { 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'], turbopack: { rules: { '*.svg': { loaders: ['@svgr/webpack'], as: '*.js', }, }, }, }); expect(configWithExisting.turbopack.rules['*.svg']).toBeDefined(); expect(configWithExisting.turbopack.rules['*.md']).toBeDefined(); // Test custom extension const configWithCustomExt = withMarkdoc({ extension: /\.(markdown|mdx)$/, })({ pageExtensions: ['js', 'markdown', 'mdx'], turbopack: { rules: {}, }, }); expect(configWithCustomExt.turbopack.rules['*.markdown']).toBeDefined(); expect(configWithCustomExt.turbopack.rules['*.mdx']).toBeDefined(); }); ================================================ FILE: tests/schemas/ejectedConfig/config.js ================================================ export default { extraValue: 'Extra value', variables: { product: 'meal', }, }; ================================================ FILE: tests/schemas/ejectedConfig/tags.ts ================================================ import type {MarkdocNextJsSchema} from '../../../src'; export const tag: MarkdocNextJsSchema<{extraValue: string}> = { transform(node, config: any) { return config.extraValue; }, }; ================================================ FILE: tests/schemas/files/nodes.js ================================================ export const link = { transform() { return 'link'; }, }; ================================================ FILE: tests/schemas/files/tags.js ================================================ export const tag = { transform() { return 'markdoc2'; }, }; ================================================ FILE: tests/schemas/folders/nodes/index.js ================================================ export const link = { transform() { return 'link'; }, }; ================================================ FILE: tests/schemas/folders/tags/index.js ================================================ export const tag = { transform() { return 'markdoc1'; }, }; ================================================ FILE: tests/schemas/partials/partials/footer.md ================================================ footer ================================================ FILE: tests/schemas/typescript/tags.ts ================================================ import type {MarkdocNextJsSchema} from '../../../src'; export const tag: MarkdocNextJsSchema = { transform(node, config) { return config.source; }, };