diff --git a/.changeset/cool-crabs-trade.md b/.changeset/cool-crabs-trade.md new file mode 100644 index 000000000000..14f5cc990af8 --- /dev/null +++ b/.changeset/cool-crabs-trade.md @@ -0,0 +1,7 @@ +--- +'astro': minor +'@astrojs/mdx': minor +'@astrojs/markdown-remark': patch +--- + +Support frontmatter injection for MD and MDX using remark and rehype plugins diff --git a/packages/astro/src/@types/astro.ts b/packages/astro/src/@types/astro.ts index 24f1fc8ee741..7051cec2bc25 100644 --- a/packages/astro/src/@types/astro.ts +++ b/packages/astro/src/@types/astro.ts @@ -1110,3 +1110,5 @@ export interface SSRResult { response: ResponseInit; _metadata: SSRMetadata; } + +export type MarkdownAstroData = { frontmatter: object }; diff --git a/packages/astro/src/vite-plugin-markdown/index.ts b/packages/astro/src/vite-plugin-markdown/index.ts index ba622d650a1b..3279e573c1fd 100644 --- a/packages/astro/src/vite-plugin-markdown/index.ts +++ b/packages/astro/src/vite-plugin-markdown/index.ts @@ -7,7 +7,7 @@ import { collectErrorMetadata } from '../core/errors.js'; import type { LogOptions } from '../core/logger/core.js'; import { warn } from '../core/logger/core.js'; import type { PluginMetadata } from '../vite-plugin-astro/types.js'; -import { getFileInfo } from '../vite-plugin-utils/index.js'; +import { getFileInfo, safelyGetAstroData } from '../vite-plugin-utils/index.js'; interface AstroPluginOptions { config: AstroConfig; @@ -44,7 +44,14 @@ export default function markdown({ config, logging }: AstroPluginOptions): Plugi const html = renderResult.code; const { headings } = renderResult.metadata; - const frontmatter = { ...raw.data, url: fileUrl, file: fileId } as any; + const { frontmatter: injectedFrontmatter } = safelyGetAstroData(renderResult.vfile.data); + const frontmatter = { + ...injectedFrontmatter, + ...raw.data, + url: fileUrl, + file: fileId, + } as any; + const { layout } = frontmatter; if (frontmatter.setup) { @@ -94,6 +101,7 @@ export default function markdown({ config, logging }: AstroPluginOptions): Plugi } export default Content; `); + return { code, meta: { diff --git a/packages/astro/src/vite-plugin-utils/index.ts b/packages/astro/src/vite-plugin-utils/index.ts index 32d896e15782..9beadcbfd4aa 100644 --- a/packages/astro/src/vite-plugin-utils/index.ts +++ b/packages/astro/src/vite-plugin-utils/index.ts @@ -1,4 +1,5 @@ -import type { AstroConfig } from '../@types/astro'; +import { Data } from 'vfile'; +import type { AstroConfig, MarkdownAstroData } from '../@types/astro'; import { appendForwardSlash } from '../core/path.js'; export function getFileInfo(id: string, config: AstroConfig) { @@ -15,3 +16,30 @@ export function getFileInfo(id: string, config: AstroConfig) { } return { fileId, fileUrl }; } + +function isValidAstroData(obj: unknown): obj is MarkdownAstroData { + if (typeof obj === 'object' && obj !== null && obj.hasOwnProperty('frontmatter')) { + const { frontmatter } = obj as any; + try { + // ensure frontmatter is JSON-serializable + JSON.stringify(frontmatter); + } catch { + return false; + } + return typeof frontmatter === 'object' && frontmatter !== null; + } + return false; +} + +export function safelyGetAstroData(vfileData: Data): MarkdownAstroData { + const { astro } = vfileData; + + if (!astro) return { frontmatter: {} }; + if (!isValidAstroData(astro)) { + throw Error( + `[Markdown] A remark or rehype plugin tried to add invalid frontmatter. Ensure "astro.frontmatter" is a JSON object!` + ); + } + + return astro; +} diff --git a/packages/astro/test/astro-markdown-frontmatter-injection.test.js b/packages/astro/test/astro-markdown-frontmatter-injection.test.js new file mode 100644 index 000000000000..581c727337bc --- /dev/null +++ b/packages/astro/test/astro-markdown-frontmatter-injection.test.js @@ -0,0 +1,40 @@ +import { expect } from 'chai'; +import { loadFixture } from './test-utils.js'; + +const FIXTURE_ROOT = './fixtures/astro-markdown-frontmatter-injection/'; + +describe('Astro Markdown - frontmatter injection', () => { + let fixture; + + before(async () => { + fixture = await loadFixture({ + root: FIXTURE_ROOT, + }); + await fixture.build(); + }); + + it('remark supports custom vfile data - get title', async () => { + const frontmatterByPage = JSON.parse(await fixture.readFile('/glob.json')); + const titles = frontmatterByPage.map((frontmatter = {}) => frontmatter.title); + expect(titles).to.contain('Page 1'); + expect(titles).to.contain('Page 2'); + }); + + it('rehype supports custom vfile data - reading time', async () => { + const frontmatterByPage = JSON.parse(await fixture.readFile('/glob.json')); + const readingTimes = frontmatterByPage.map((frontmatter = {}) => frontmatter.injectedReadingTime); + expect(readingTimes.length).to.be.greaterThan(0); + for (let readingTime of readingTimes) { + expect(readingTime).to.not.be.null; + expect(readingTime.text).match(/^\d+ min read/); + } + }); + + it('overrides injected frontmatter with user frontmatter', async () => { + const frontmatterByPage = JSON.parse(await fixture.readFile('/glob.json')); + const readingTimes = frontmatterByPage.map((frontmatter = {}) => frontmatter.injectedReadingTime?.text); + const titles = frontmatterByPage.map((frontmatter = {}) => frontmatter.title); + expect(titles).to.contain('Overridden title'); + expect(readingTimes).to.contain('1000 min read'); + }); +}); diff --git a/packages/astro/test/fixtures/astro-markdown-frontmatter-injection/astro.config.mjs b/packages/astro/test/fixtures/astro-markdown-frontmatter-injection/astro.config.mjs new file mode 100644 index 000000000000..6ff9e1eedcf2 --- /dev/null +++ b/packages/astro/test/fixtures/astro-markdown-frontmatter-injection/astro.config.mjs @@ -0,0 +1,11 @@ +import { defineConfig } from 'astro/config'; +import { rehypeReadingTime, remarkTitle } from './src/markdown-plugins.mjs' + +// https://astro.build/config +export default defineConfig({ + site: 'https://astro.build/', + markdown: { + remarkPlugins: [remarkTitle], + rehypePlugins: [rehypeReadingTime], + } +}); diff --git a/packages/astro/test/fixtures/astro-markdown-frontmatter-injection/package.json b/packages/astro/test/fixtures/astro-markdown-frontmatter-injection/package.json new file mode 100644 index 000000000000..1b7fcc25b7a3 --- /dev/null +++ b/packages/astro/test/fixtures/astro-markdown-frontmatter-injection/package.json @@ -0,0 +1,11 @@ +{ + "name": "@test/astro-markdown-frontmatter-injection", + "version": "0.0.0", + "private": true, + "dependencies": { + "astro": "workspace:*", + "mdast-util-to-string": "^3.1.0", + "reading-time": "^1.5.0", + "unist-util-visit": "^4.1.0" + } +} diff --git a/packages/astro/test/fixtures/astro-markdown-frontmatter-injection/src/markdown-plugins.mjs b/packages/astro/test/fixtures/astro-markdown-frontmatter-injection/src/markdown-plugins.mjs new file mode 100644 index 000000000000..c0d5f7b2e339 --- /dev/null +++ b/packages/astro/test/fixtures/astro-markdown-frontmatter-injection/src/markdown-plugins.mjs @@ -0,0 +1,20 @@ +import getReadingTime from 'reading-time'; +import { toString } from 'mdast-util-to-string'; +import { visit } from 'unist-util-visit'; + +export function rehypeReadingTime() { + return function (tree, { data }) { + const readingTime = getReadingTime(toString(tree)); + data.astro.frontmatter.injectedReadingTime = readingTime; + }; +} + +export function remarkTitle() { + return function (tree, { data }) { + visit(tree, ['heading'], (node) => { + if (node.depth === 1) { + data.astro.frontmatter.title = toString(node.children); + } + }); + }; +} diff --git a/packages/astro/test/fixtures/astro-markdown-frontmatter-injection/src/pages/glob.json.js b/packages/astro/test/fixtures/astro-markdown-frontmatter-injection/src/pages/glob.json.js new file mode 100644 index 000000000000..a56f5306fd00 --- /dev/null +++ b/packages/astro/test/fixtures/astro-markdown-frontmatter-injection/src/pages/glob.json.js @@ -0,0 +1,6 @@ +export async function get() { + const docs = await import.meta.glob('./*.md', { eager: true }); + return { + body: JSON.stringify(Object.values(docs).map(doc => doc.frontmatter)), + } +} diff --git a/packages/astro/test/fixtures/astro-markdown-frontmatter-injection/src/pages/page-1.md b/packages/astro/test/fixtures/astro-markdown-frontmatter-injection/src/pages/page-1.md new file mode 100644 index 000000000000..2fcd655ec05a --- /dev/null +++ b/packages/astro/test/fixtures/astro-markdown-frontmatter-injection/src/pages/page-1.md @@ -0,0 +1,3 @@ +# Page 1 + +Look at that! diff --git a/packages/astro/test/fixtures/astro-markdown-frontmatter-injection/src/pages/page-2.md b/packages/astro/test/fixtures/astro-markdown-frontmatter-injection/src/pages/page-2.md new file mode 100644 index 000000000000..4a6b9adddf81 --- /dev/null +++ b/packages/astro/test/fixtures/astro-markdown-frontmatter-injection/src/pages/page-2.md @@ -0,0 +1,19 @@ +# Page 2 + +## Table of contents + +## Section 1 + +Some text! + +### Subsection 1 + +Some subsection test! + +### Subsection 2 + +Oh cool, more text! + +## Section 2 + +More content diff --git a/packages/astro/test/fixtures/astro-markdown-frontmatter-injection/src/pages/with-overrides.md b/packages/astro/test/fixtures/astro-markdown-frontmatter-injection/src/pages/with-overrides.md new file mode 100644 index 000000000000..4e11c1c37e3b --- /dev/null +++ b/packages/astro/test/fixtures/astro-markdown-frontmatter-injection/src/pages/with-overrides.md @@ -0,0 +1,7 @@ +--- +title: 'Overridden title' +injectedReadingTime: + text: '1000 min read' +--- + +# Working! diff --git a/packages/astro/test/fixtures/astro-markdown/package.json b/packages/astro/test/fixtures/astro-markdown/package.json index 48a6c6816316..c1903a941ea1 100644 --- a/packages/astro/test/fixtures/astro-markdown/package.json +++ b/packages/astro/test/fixtures/astro-markdown/package.json @@ -1,5 +1,5 @@ { - "name": "@test/astro-markdown-md-mode", + "name": "@test/astro-markdown", "version": "0.0.0", "private": true, "dependencies": { diff --git a/packages/integrations/mdx/package.json b/packages/integrations/mdx/package.json index 2af07ab03be3..3b3d3dfd7751 100644 --- a/packages/integrations/mdx/package.json +++ b/packages/integrations/mdx/package.json @@ -43,7 +43,8 @@ "remark-shiki-twoslash": "^3.1.0", "remark-smartypants": "^2.0.0", "shiki": "^0.10.1", - "unist-util-visit": "^4.1.0" + "unist-util-visit": "^4.1.0", + "vfile": "^5.3.2" }, "devDependencies": { "@types/chai": "^4.3.1", diff --git a/packages/integrations/mdx/src/astro-data-utils.ts b/packages/integrations/mdx/src/astro-data-utils.ts new file mode 100644 index 000000000000..bfbc7446142d --- /dev/null +++ b/packages/integrations/mdx/src/astro-data-utils.ts @@ -0,0 +1,82 @@ +import { name as isValidIdentifierName } from 'estree-util-is-identifier-name'; +import type { VFile } from 'vfile'; +import type { MdxjsEsm } from 'mdast-util-mdx'; +import type { MarkdownAstroData } from 'astro'; +import type { Data } from 'vfile'; +import { jsToTreeNode } from './utils.js'; + +export function remarkInitializeAstroData() { + return function (tree: any, vfile: VFile) { + if (!vfile.data.astro) { + vfile.data.astro = { frontmatter: {} }; + } + }; +} + +export function rehypeApplyFrontmatterExport( + pageFrontmatter: Record, + exportName = 'frontmatter' +) { + return function (tree: any, vfile: VFile) { + if (!isValidIdentifierName(exportName)) { + throw new Error( + `[MDX] ${JSON.stringify( + exportName + )} is not a valid frontmatter export name! Make sure "frontmatterOptions.name" could be used as a JS export (i.e. "export const frontmatterName = ...")` + ); + } + const { frontmatter: injectedFrontmatter } = safelyGetAstroData(vfile.data); + const frontmatter = { ...injectedFrontmatter, ...pageFrontmatter }; + let exportNodes: MdxjsEsm[] = []; + if (!exportName) { + exportNodes = Object.entries(frontmatter).map(([k, v]) => { + if (!isValidIdentifierName(k)) { + throw new Error( + `[MDX] A remark or rehype plugin tried to inject ${JSON.stringify( + k + )} as a top-level export, which is not a valid export name.` + ); + } + return jsToTreeNode(`export const ${k} = ${JSON.stringify(v)};`); + }); + } else { + exportNodes = [jsToTreeNode(`export const ${exportName} = ${JSON.stringify(frontmatter)};`)]; + } + tree.children = exportNodes.concat(tree.children); + }; +} + +/** + * Copied from markdown utils + * @see "vite-plugin-utils" + */ +function isValidAstroData(obj: unknown): obj is MarkdownAstroData { + if (typeof obj === 'object' && obj !== null && obj.hasOwnProperty('frontmatter')) { + const { frontmatter } = obj as any; + try { + // ensure frontmatter is JSON-serializable + JSON.stringify(frontmatter); + } catch { + return false; + } + return typeof frontmatter === 'object' && frontmatter !== null; + } + return false; +} + +/** + * Copied from markdown utils + * @see "vite-plugin-utils" + */ +export function safelyGetAstroData(vfileData: Data): MarkdownAstroData { + const { astro } = vfileData; + + if (!astro) return { frontmatter: {} }; + if (!isValidAstroData(astro)) { + throw Error( + `[MDX] A remark or rehype plugin tried to add invalid frontmatter. Ensure "astro.frontmatter" is a JSON object!` + ); + } + + return astro; +} diff --git a/packages/integrations/mdx/src/index.ts b/packages/integrations/mdx/src/index.ts index a7abb0c33a3f..3b1ceaa4c8bb 100644 --- a/packages/integrations/mdx/src/index.ts +++ b/packages/integrations/mdx/src/index.ts @@ -1,19 +1,18 @@ import { compile as mdxCompile, nodeTypes } from '@mdx-js/mdx'; import mdxPlugin, { Options as MdxRollupPluginOptions } from '@mdx-js/rollup'; -import type { AstroIntegration } from 'astro'; +import type { AstroIntegration, AstroConfig } from 'astro'; +import { remarkInitializeAstroData, rehypeApplyFrontmatterExport } from './astro-data-utils.js'; import { parse as parseESM } from 'es-module-lexer'; import rehypeRaw from 'rehype-raw'; -import remarkFrontmatter from 'remark-frontmatter'; import remarkGfm from 'remark-gfm'; import type { RemarkMdxFrontmatterOptions } from 'remark-mdx-frontmatter'; -import remarkMdxFrontmatter from 'remark-mdx-frontmatter'; import remarkShikiTwoslash from 'remark-shiki-twoslash'; import remarkSmartypants from 'remark-smartypants'; import { VFile } from 'vfile'; import type { Plugin as VitePlugin } from 'vite'; import rehypeCollectHeadings from './rehype-collect-headings.js'; import remarkPrism from './remark-prism.js'; -import { getFileInfo, getFrontmatter } from './utils.js'; +import { getFileInfo, parseFrontmatter } from './utils.js'; type WithExtends = T | { extends: T }; @@ -37,44 +36,52 @@ function handleExtends(config: WithExtends, defaults: T[] = return [...defaults, ...(config?.extends ?? [])]; } +function getRemarkPlugins( + mdxOptions: MdxOptions, + config: AstroConfig +): MdxRollupPluginOptions['remarkPlugins'] { + let remarkPlugins = [ + // Initialize vfile.data.astroExports before all plugins are run + remarkInitializeAstroData, + ...handleExtends(mdxOptions.remarkPlugins, DEFAULT_REMARK_PLUGINS), + ]; + if (config.markdown.syntaxHighlight === 'shiki') { + // Default export still requires ".default" chaining for some reason + // Workarounds tried: + // - "import * as remarkShikiTwoslash" + // - "import { default as remarkShikiTwoslash }" + const shikiTwoslash = (remarkShikiTwoslash as any).default ?? remarkShikiTwoslash; + remarkPlugins.push([shikiTwoslash, config.markdown.shikiConfig]); + } + if (config.markdown.syntaxHighlight === 'prism') { + remarkPlugins.push(remarkPrism); + } + return remarkPlugins; +} + +function getRehypePlugins( + mdxOptions: MdxOptions, + config: AstroConfig +): MdxRollupPluginOptions['rehypePlugins'] { + let rehypePlugins = handleExtends(mdxOptions.rehypePlugins, DEFAULT_REHYPE_PLUGINS); + + if (config.markdown.syntaxHighlight === 'shiki' || config.markdown.syntaxHighlight === 'prism') { + rehypePlugins.push([rehypeRaw, { passThrough: nodeTypes }]); + } + + return rehypePlugins; +} + export default function mdx(mdxOptions: MdxOptions = {}): AstroIntegration { return { name: '@astrojs/mdx', hooks: { 'astro:config:setup': ({ updateConfig, config, addPageExtension, command }: any) => { addPageExtension('.mdx'); - let remarkPlugins = handleExtends(mdxOptions.remarkPlugins, DEFAULT_REMARK_PLUGINS); - let rehypePlugins = handleExtends(mdxOptions.rehypePlugins, DEFAULT_REHYPE_PLUGINS); - - if (config.markdown.syntaxHighlight === 'shiki') { - remarkPlugins.push([ - // Default export still requires ".default" chaining for some reason - // Workarounds tried: - // - "import * as remarkShikiTwoslash" - // - "import { default as remarkShikiTwoslash }" - (remarkShikiTwoslash as any).default ?? remarkShikiTwoslash, - config.markdown.shikiConfig, - ]); - rehypePlugins.push([rehypeRaw, { passThrough: nodeTypes }]); - } - - if (config.markdown.syntaxHighlight === 'prism') { - remarkPlugins.push(remarkPrism); - rehypePlugins.push([rehypeRaw, { passThrough: nodeTypes }]); - } - - remarkPlugins.push(remarkFrontmatter); - remarkPlugins.push([ - remarkMdxFrontmatter, - { - name: 'frontmatter', - ...mdxOptions.frontmatterOptions, - }, - ]); const mdxPluginOpts: MdxRollupPluginOptions = { - remarkPlugins, - rehypePlugins, + remarkPlugins: getRemarkPlugins(mdxOptions, config), + rehypePlugins: getRehypePlugins(mdxOptions, config), jsx: true, jsxImportSource: 'astro', // Note: disable `.md` support @@ -93,24 +100,27 @@ export default function mdx(mdxOptions: MdxOptions = {}): AstroIntegration { async transform(code, id) { if (!id.endsWith('mdx')) return; - // If user overrides our default YAML parser, - // do not attempt to parse the `layout` via gray-matter - if (!mdxOptions.frontmatterOptions?.parsers) { - const frontmatter = getFrontmatter(code, id); - if (frontmatter.layout) { - const { layout, ...content } = frontmatter; - code += `\n\nexport default async function({ children }) {\nconst Layout = (await import(${JSON.stringify( - frontmatter.layout - )})).default;\nconst frontmatter=${JSON.stringify( - content - )};\nreturn {children} }`; - } + let { data: frontmatter, content: pageContent } = parseFrontmatter(code, id); + if (frontmatter.layout) { + const { layout, ...contentProp } = frontmatter; + pageContent += `\n\nexport default async function({ children }) {\nconst Layout = (await import(${JSON.stringify( + frontmatter.layout + )})).default;\nconst frontmatter=${JSON.stringify( + contentProp + )};\nreturn {children} }`; } - const compiled = await mdxCompile( - new VFile({ value: code, path: id }), - mdxPluginOpts - ); + const compiled = await mdxCompile(new VFile({ value: pageContent, path: id }), { + ...mdxPluginOpts, + rehypePlugins: [ + ...(mdxPluginOpts.rehypePlugins ?? []), + () => + rehypeApplyFrontmatterExport( + frontmatter, + mdxOptions.frontmatterOptions?.name + ), + ], + }); return { code: String(compiled.value), diff --git a/packages/integrations/mdx/src/utils.ts b/packages/integrations/mdx/src/utils.ts index b5f7082dc0f1..f5135ebc27ef 100644 --- a/packages/integrations/mdx/src/utils.ts +++ b/packages/integrations/mdx/src/utils.ts @@ -47,9 +47,9 @@ export function getFileInfo(id: string, config: AstroConfig): FileInfo { * Match YAML exception handling from Astro core errors * @see 'astro/src/core/errors.ts' */ -export function getFrontmatter(code: string, id: string) { +export function parseFrontmatter(code: string, id: string) { try { - return matter(code).data; + return matter(code); } catch (e: any) { if (e.name === 'YAMLException') { const err: SSRError = e; diff --git a/packages/integrations/mdx/test/fixtures/mdx-frontmatter-injection/astro.config.mjs b/packages/integrations/mdx/test/fixtures/mdx-frontmatter-injection/astro.config.mjs new file mode 100644 index 000000000000..fc15686c2998 --- /dev/null +++ b/packages/integrations/mdx/test/fixtures/mdx-frontmatter-injection/astro.config.mjs @@ -0,0 +1,12 @@ +import { defineConfig } from 'astro/config'; +import mdx from '@astrojs/mdx'; +import { rehypeReadingTime, remarkTitle } from './src/markdown-plugins.mjs'; + +// https://astro.build/config +export default defineConfig({ + site: 'https://astro.build/', + integrations: [mdx({ + remarkPlugins: [remarkTitle], + rehypePlugins: [rehypeReadingTime], + })], +}); diff --git a/packages/integrations/mdx/test/fixtures/mdx-frontmatter-injection/package.json b/packages/integrations/mdx/test/fixtures/mdx-frontmatter-injection/package.json new file mode 100644 index 000000000000..8affcbbf622c --- /dev/null +++ b/packages/integrations/mdx/test/fixtures/mdx-frontmatter-injection/package.json @@ -0,0 +1,12 @@ +{ + "name": "@test/mdx-frontmatter-injection", + "version": "0.0.0", + "private": true, + "dependencies": { + "astro": "workspace:*", + "@astrojs/mdx": "workspace:*", + "mdast-util-to-string": "^3.1.0", + "reading-time": "^1.5.0", + "unist-util-visit": "^4.1.0" + } +} diff --git a/packages/integrations/mdx/test/fixtures/mdx-frontmatter-injection/src/markdown-plugins.mjs b/packages/integrations/mdx/test/fixtures/mdx-frontmatter-injection/src/markdown-plugins.mjs new file mode 100644 index 000000000000..c0d5f7b2e339 --- /dev/null +++ b/packages/integrations/mdx/test/fixtures/mdx-frontmatter-injection/src/markdown-plugins.mjs @@ -0,0 +1,20 @@ +import getReadingTime from 'reading-time'; +import { toString } from 'mdast-util-to-string'; +import { visit } from 'unist-util-visit'; + +export function rehypeReadingTime() { + return function (tree, { data }) { + const readingTime = getReadingTime(toString(tree)); + data.astro.frontmatter.injectedReadingTime = readingTime; + }; +} + +export function remarkTitle() { + return function (tree, { data }) { + visit(tree, ['heading'], (node) => { + if (node.depth === 1) { + data.astro.frontmatter.title = toString(node.children); + } + }); + }; +} diff --git a/packages/integrations/mdx/test/fixtures/mdx-frontmatter-injection/src/pages/glob.json.js b/packages/integrations/mdx/test/fixtures/mdx-frontmatter-injection/src/pages/glob.json.js new file mode 100644 index 000000000000..b73cd234d75d --- /dev/null +++ b/packages/integrations/mdx/test/fixtures/mdx-frontmatter-injection/src/pages/glob.json.js @@ -0,0 +1,6 @@ +export async function get() { + const docs = await import.meta.glob('./*.mdx', { eager: true }); + return { + body: JSON.stringify(Object.values(docs).map(doc => doc.frontmatter)), + } +} diff --git a/packages/integrations/mdx/test/fixtures/mdx-frontmatter-injection/src/pages/page-1.mdx b/packages/integrations/mdx/test/fixtures/mdx-frontmatter-injection/src/pages/page-1.mdx new file mode 100644 index 000000000000..2fcd655ec05a --- /dev/null +++ b/packages/integrations/mdx/test/fixtures/mdx-frontmatter-injection/src/pages/page-1.mdx @@ -0,0 +1,3 @@ +# Page 1 + +Look at that! diff --git a/packages/integrations/mdx/test/fixtures/mdx-frontmatter-injection/src/pages/page-2.mdx b/packages/integrations/mdx/test/fixtures/mdx-frontmatter-injection/src/pages/page-2.mdx new file mode 100644 index 000000000000..4a6b9adddf81 --- /dev/null +++ b/packages/integrations/mdx/test/fixtures/mdx-frontmatter-injection/src/pages/page-2.mdx @@ -0,0 +1,19 @@ +# Page 2 + +## Table of contents + +## Section 1 + +Some text! + +### Subsection 1 + +Some subsection test! + +### Subsection 2 + +Oh cool, more text! + +## Section 2 + +More content diff --git a/packages/integrations/mdx/test/fixtures/mdx-frontmatter-injection/src/pages/with-overrides.mdx b/packages/integrations/mdx/test/fixtures/mdx-frontmatter-injection/src/pages/with-overrides.mdx new file mode 100644 index 000000000000..4e11c1c37e3b --- /dev/null +++ b/packages/integrations/mdx/test/fixtures/mdx-frontmatter-injection/src/pages/with-overrides.mdx @@ -0,0 +1,7 @@ +--- +title: 'Overridden title' +injectedReadingTime: + text: '1000 min read' +--- + +# Working! diff --git a/packages/integrations/mdx/test/fixtures/mdx-rehype-plugins/src/pages/reading-time.json.js b/packages/integrations/mdx/test/fixtures/mdx-rehype-plugins/src/pages/reading-time.json.js index 60f7cb1be997..e31c57983e92 100644 --- a/packages/integrations/mdx/test/fixtures/mdx-rehype-plugins/src/pages/reading-time.json.js +++ b/packages/integrations/mdx/test/fixtures/mdx-rehype-plugins/src/pages/reading-time.json.js @@ -1,7 +1,7 @@ -import { readingTime } from './space-ipsum.mdx'; +import * as exps from './space-ipsum.mdx'; export function get() { return { - body: JSON.stringify(readingTime), + body: JSON.stringify(exps), } } diff --git a/packages/integrations/mdx/test/fixtures/mdx-remark-plugins/src/pages/headings-glob.json.js b/packages/integrations/mdx/test/fixtures/mdx-remark-plugins/src/pages/headings-glob.json.js new file mode 100644 index 000000000000..b73cd234d75d --- /dev/null +++ b/packages/integrations/mdx/test/fixtures/mdx-remark-plugins/src/pages/headings-glob.json.js @@ -0,0 +1,6 @@ +export async function get() { + const docs = await import.meta.glob('./*.mdx', { eager: true }); + return { + body: JSON.stringify(Object.values(docs).map(doc => doc.frontmatter)), + } +} diff --git a/packages/integrations/mdx/test/mdx-frontmatter-injection.test.js b/packages/integrations/mdx/test/mdx-frontmatter-injection.test.js new file mode 100644 index 000000000000..ae1d485bbb10 --- /dev/null +++ b/packages/integrations/mdx/test/mdx-frontmatter-injection.test.js @@ -0,0 +1,40 @@ +import { expect } from 'chai'; +import { loadFixture } from '../../../astro/test/test-utils.js'; + +const FIXTURE_ROOT = new URL('./fixtures/mdx-frontmatter-injection/', import.meta.url); + +describe('MDX frontmatter injection', () => { + let fixture; + + before(async () => { + fixture = await loadFixture({ + root: FIXTURE_ROOT, + }); + await fixture.build(); + }); + + it('remark supports custom vfile data - get title', async () => { + const frontmatterByPage = JSON.parse(await fixture.readFile('/glob.json')); + const titles = frontmatterByPage.map((frontmatter = {}) => frontmatter.title); + expect(titles).to.contain('Page 1'); + expect(titles).to.contain('Page 2'); + }); + + it('rehype supports custom vfile data - reading time', async () => { + const frontmatterByPage = JSON.parse(await fixture.readFile('/glob.json')); + const readingTimes = frontmatterByPage.map((frontmatter = {}) => frontmatter.injectedReadingTime); + expect(readingTimes.length).to.be.greaterThan(0); + for (let readingTime of readingTimes) { + expect(readingTime).to.not.be.null; + expect(readingTime.text).match(/^\d+ min read/); + } + }); + + it('overrides injected frontmatter with user frontmatter', async () => { + const frontmatterByPage = JSON.parse(await fixture.readFile('/glob.json')); + const readingTimes = frontmatterByPage.map((frontmatter = {}) => frontmatter.injectedReadingTime?.text); + const titles = frontmatterByPage.map((frontmatter = {}) => frontmatter.title); + expect(titles).to.contain('Overridden title'); + expect(readingTimes).to.contain('1000 min read'); + }); +}); diff --git a/packages/integrations/mdx/test/mdx-rehype-plugins.test.js b/packages/integrations/mdx/test/mdx-rehype-plugins.test.js index d8761b9fbb11..d60c09a07acb 100644 --- a/packages/integrations/mdx/test/mdx-rehype-plugins.test.js +++ b/packages/integrations/mdx/test/mdx-rehype-plugins.test.js @@ -1,15 +1,15 @@ import mdx from '@astrojs/mdx'; -import { jsToTreeNode } from '../dist/utils.js'; -import { expect } from 'chai'; -import { parseHTML } from 'linkedom'; import getReadingTime from 'reading-time'; import { toString } from 'mdast-util-to-string'; +import { expect } from 'chai'; +import { parseHTML } from 'linkedom'; +import { jsToTreeNode } from '../dist/utils.js'; import { loadFixture } from '../../../astro/test/test-utils.js'; -export function rehypeReadingTime() { - return function (tree) { +function rehypeReadingTime() { + return function (tree, { data }) { const readingTime = getReadingTime(toString(tree)); tree.children.unshift( jsToTreeNode(`export const readingTime = ${JSON.stringify(readingTime)}`) @@ -46,7 +46,7 @@ describe('MDX rehype plugins', () => { }); it('supports custom rehype plugins - reading time', async () => { - const readingTime = JSON.parse(await fixture.readFile('/reading-time.json')); + const { readingTime } = JSON.parse(await fixture.readFile('/reading-time.json')); expect(readingTime).to.not.be.null; expect(readingTime.text).to.match(/^\d+ min read/); diff --git a/packages/markdown/remark/src/index.ts b/packages/markdown/remark/src/index.ts index 4dfd7240f153..0bb827c30fa1 100644 --- a/packages/markdown/remark/src/index.ts +++ b/packages/markdown/remark/src/index.ts @@ -13,6 +13,7 @@ import remarkPrism from './remark-prism.js'; import scopedStyles from './remark-scoped-styles.js'; import remarkShiki from './remark-shiki.js'; import remarkUnwrap from './remark-unwrap.js'; +import { remarkInitializeAstroData } from './remark-initialize-astro-data.js'; import rehypeRaw from 'rehype-raw'; import rehypeStringify from 'rehype-stringify'; @@ -45,6 +46,7 @@ export async function renderMarkdown( let parser = unified() .use(markdown) + .use(remarkInitializeAstroData) .use(isAstroFlavoredMd ? [remarkMdxish, remarkMarkAndUnravel, remarkUnwrap, remarkEscape] : []); if (remarkPlugins.length === 0 && rehypePlugins.length === 0) { @@ -99,10 +101,9 @@ export async function renderMarkdown( ) .use(rehypeStringify, { allowDangerousHtml: true }); - let result: string; + let vfile: VFile; try { - const vfile = await parser.process(input); - result = vfile.toString(); + vfile = await parser.process(input); } catch (err) { // Ensure that the error message contains the input filename // to make it easier for the user to fix the issue @@ -113,8 +114,9 @@ export async function renderMarkdown( } return { - metadata: { headings, source: content, html: result.toString() }, - code: result.toString(), + metadata: { headings, source: content, html: String(vfile.value) }, + code: String(vfile.value), + vfile, }; } diff --git a/packages/markdown/remark/src/remark-initialize-astro-data.ts b/packages/markdown/remark/src/remark-initialize-astro-data.ts new file mode 100644 index 000000000000..37af8aeaff6d --- /dev/null +++ b/packages/markdown/remark/src/remark-initialize-astro-data.ts @@ -0,0 +1,9 @@ +import type { VFile } from 'vfile'; + +export function remarkInitializeAstroData() { + return function (tree: any, vfile: VFile) { + if (!vfile.data.astro) { + vfile.data.astro = { frontmatter: {} }; + } + }; +} diff --git a/packages/markdown/remark/src/types.ts b/packages/markdown/remark/src/types.ts index 9d09ef294e87..3569e8d04148 100644 --- a/packages/markdown/remark/src/types.ts +++ b/packages/markdown/remark/src/types.ts @@ -2,6 +2,7 @@ import type * as hast from 'hast'; import type * as mdast from 'mdast'; import type { ILanguageRegistration, IThemeRegistration, Theme } from 'shiki'; import type * as unified from 'unified'; +import type { VFile } from 'vfile'; export type { Node } from 'unist'; @@ -58,5 +59,6 @@ export interface MarkdownMetadata { export interface MarkdownRenderingResult { metadata: MarkdownMetadata; + vfile: VFile; code: string; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 85a6b95ed308..a44322e1e500 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1231,6 +1231,18 @@ importers: dependencies: astro: link:../../.. + packages/astro/test/fixtures/astro-markdown-frontmatter-injection: + specifiers: + astro: workspace:* + mdast-util-to-string: ^3.1.0 + reading-time: ^1.5.0 + unist-util-visit: ^4.1.0 + dependencies: + astro: link:../../.. + mdast-util-to-string: 3.1.0 + reading-time: 1.5.0 + unist-util-visit: 4.1.0 + packages/astro/test/fixtures/astro-markdown-plugins: specifiers: '@astrojs/preact': workspace:* @@ -2183,6 +2195,7 @@ importers: remark-toc: ^8.0.1 shiki: ^0.10.1 unist-util-visit: ^4.1.0 + vfile: ^5.3.2 dependencies: '@astrojs/prism': link:../../astro-prism '@mdx-js/mdx': 2.1.2 @@ -2199,6 +2212,7 @@ importers: remark-smartypants: 2.0.0 shiki: 0.10.1 unist-util-visit: 4.1.0 + vfile: 5.3.4 devDependencies: '@types/chai': 4.3.1 '@types/mocha': 9.1.1 @@ -2212,6 +2226,20 @@ importers: reading-time: 1.5.0 remark-toc: 8.0.1 + packages/integrations/mdx/test/fixtures/mdx-frontmatter-injection: + specifiers: + '@astrojs/mdx': workspace:* + astro: workspace:* + mdast-util-to-string: ^3.1.0 + reading-time: ^1.5.0 + unist-util-visit: ^4.1.0 + dependencies: + '@astrojs/mdx': link:../../.. + astro: link:../../../../../astro + mdast-util-to-string: 3.1.0 + reading-time: 1.5.0 + unist-util-visit: 4.1.0 + packages/integrations/mdx/test/fixtures/mdx-page: specifiers: '@astrojs/mdx': workspace:* @@ -14700,7 +14728,6 @@ packages: /reading-time/1.5.0: resolution: {integrity: sha512-onYyVhBNr4CmAxFsKS7bz+uTLRakypIe4R+5A824vBSkQy/hB3fZepoVEf8OVAxzLvK+H/jm9TzpI3ETSm64Kg==} - dev: true /recast/0.20.5: resolution: {integrity: sha512-E5qICoPoNL4yU0H0NoBDntNB0Q5oMSNh9usFctYniLBluTthi3RsQVBXIJNbApOlvSwW/RGxIuokPcAc59J5fQ==}