diff --git a/.changeset/beige-pumpkins-pump.md b/.changeset/beige-pumpkins-pump.md new file mode 100644 index 000000000000..54b33a6195c6 --- /dev/null +++ b/.changeset/beige-pumpkins-pump.md @@ -0,0 +1,47 @@ +--- +'astro': major +'@astrojs/markdown-remark': major +'@astrojs/mdx': minor +--- + +Give remark and rehype plugins access to user frontmatter via frontmatter injection. This means `data.astro.frontmatter` is now the _complete_ Markdown or MDX document's frontmatter, rather than an empty object. + +This allows plugin authors to modify existing frontmatter, or compute new properties based on other properties. For example, say you want to compute a full image URL based on an `imageSrc` slug in your document frontmatter: + +```ts +export function remarkInjectSocialImagePlugin() { + return function (tree, file) { + const { frontmatter } = file.data.astro; + frontmatter.socialImageSrc = new URL( + frontmatter.imageSrc, + 'https://my-blog.com/', + ).pathname; + } +} +``` + +#### Content Collections - new `remarkPluginFrontmatter` property + +We have changed _inject_ frontmatter to _modify_ frontmatter in our docs to improve discoverability. This is based on support forum feedback, where "injection" is rarely the term used. + +To reflect this, the `injectedFrontmatter` property has been renamed to `remarkPluginFrontmatter`. This should clarify this plugin is still separate from the `data` export Content Collections expose today. + + +#### Migration instructions + +Plugin authors should now **check for user frontmatter when applying defaults.** + +For example, say a remark plugin wants to apply a default `title` if none is present. Add a conditional to check if the property is present, and update if none exists: + +```diff +export function remarkInjectTitlePlugin() { + return function (tree, file) { + const { frontmatter } = file.data.astro; ++ if (!frontmatter.title) { + frontmatter.title = 'Default title'; ++ } + } +} +``` + +This differs from previous behavior, where a Markdown file's frontmatter would _always_ override frontmatter injected via remark or reype. diff --git a/examples/with-content/src/content/types.generated.d.ts b/examples/with-content/src/content/types.generated.d.ts index 7f0f0df84968..ef8bd420e478 100644 --- a/examples/with-content/src/content/types.generated.d.ts +++ b/examples/with-content/src/content/types.generated.d.ts @@ -37,49 +37,50 @@ declare module 'astro:content' { render(): Promise<{ Content: import('astro').MarkdownInstance<{}>['Content']; headings: import('astro').MarkdownHeading[]; - injectedFrontmatter: Record; + remarkPluginFrontmatter: Record; }>; }; const entryMap: { - blog: { - 'first-post.md': { - id: 'first-post.md'; - slug: 'first-post'; - body: string; - collection: 'blog'; - data: InferEntrySchema<'blog'>; - }; - 'markdown-style-guide.md': { - id: 'markdown-style-guide.md'; - slug: 'markdown-style-guide'; - body: string; - collection: 'blog'; - data: InferEntrySchema<'blog'>; - }; - 'second-post.md': { - id: 'second-post.md'; - slug: 'second-post'; - body: string; - collection: 'blog'; - data: InferEntrySchema<'blog'>; - }; - 'third-post.md': { - id: 'third-post.md'; - slug: 'third-post'; - body: string; - collection: 'blog'; - data: InferEntrySchema<'blog'>; - }; - 'using-mdx.mdx': { - id: 'using-mdx.mdx'; - slug: 'using-mdx'; - body: string; - collection: 'blog'; - data: InferEntrySchema<'blog'>; - }; - }; + "blog": { +"first-post.md": { + id: "first-post.md", + slug: "first-post", + body: string, + collection: "blog", + data: InferEntrySchema<"blog"> +}, +"markdown-style-guide.md": { + id: "markdown-style-guide.md", + slug: "markdown-style-guide", + body: string, + collection: "blog", + data: InferEntrySchema<"blog"> +}, +"second-post.md": { + id: "second-post.md", + slug: "second-post", + body: string, + collection: "blog", + data: InferEntrySchema<"blog"> +}, +"third-post.md": { + id: "third-post.md", + slug: "third-post", + body: string, + collection: "blog", + data: InferEntrySchema<"blog"> +}, +"using-mdx.mdx": { + id: "using-mdx.mdx", + slug: "using-mdx", + body: string, + collection: "blog", + data: InferEntrySchema<"blog"> +}, +}, + }; - type ContentConfig = typeof import('./config'); + type ContentConfig = typeof import("./config"); } diff --git a/packages/astro/src/@types/astro.ts b/packages/astro/src/@types/astro.ts index 1206c3431bfc..41a7cd416484 100644 --- a/packages/astro/src/@types/astro.ts +++ b/packages/astro/src/@types/astro.ts @@ -1464,10 +1464,6 @@ export interface SSRResult { _metadata: SSRMetadata; } -export type MarkdownAstroData = { - frontmatter: MD['frontmatter']; -}; - /* Preview server stuff */ export interface PreviewServer { host?: string; diff --git a/packages/astro/src/content/internal.ts b/packages/astro/src/content/internal.ts index f49dd8d22c29..d3af7f8f92a5 100644 --- a/packages/astro/src/content/internal.ts +++ b/packages/astro/src/content/internal.ts @@ -137,12 +137,9 @@ async function render({ propagation: 'self', }); - if (!mod._internal && id.endsWith('.mdx')) { - throw new Error(`[Content] Failed to render MDX entry. Try installing @astrojs/mdx@latest`); - } return { Content, headings: mod.getHeadings(), - injectedFrontmatter: mod._internal.injectedFrontmatter, + remarkPluginFrontmatter: mod.frontmatter, }; } diff --git a/packages/astro/src/content/template/types.generated.d.ts b/packages/astro/src/content/template/types.generated.d.ts index fc1d489f0f16..0edf3dcd2307 100644 --- a/packages/astro/src/content/template/types.generated.d.ts +++ b/packages/astro/src/content/template/types.generated.d.ts @@ -37,7 +37,7 @@ declare module 'astro:content' { render(): Promise<{ Content: import('astro').MarkdownInstance<{}>['Content']; headings: import('astro').MarkdownHeading[]; - injectedFrontmatter: Record; + remarkPluginFrontmatter: Record; }>; }; diff --git a/packages/astro/src/content/vite-plugin-content-assets.ts b/packages/astro/src/content/vite-plugin-content-assets.ts index a76dcead803c..d886765bc89b 100644 --- a/packages/astro/src/content/vite-plugin-content-assets.ts +++ b/packages/astro/src/content/vite-plugin-content-assets.ts @@ -34,7 +34,7 @@ export function astroDelayedAssetPlugin({ mode }: { mode: string }): Plugin { if (isDelayedAsset(id)) { const basePath = id.split('?')[0]; const code = ` - export { Content, getHeadings, _internal } from ${JSON.stringify(basePath)}; + export { Content, getHeadings } from ${JSON.stringify(basePath)}; export const collectedLinks = ${JSON.stringify(LINKS_PLACEHOLDER)}; export const collectedStyles = ${JSON.stringify(STYLES_PLACEHOLDER)}; `; diff --git a/packages/astro/src/core/errors/errors-data.ts b/packages/astro/src/core/errors/errors-data.ts index 57cf9f4d6831..805cb0abe844 100644 --- a/packages/astro/src/core/errors/errors-data.ts +++ b/packages/astro/src/core/errors/errors-data.ts @@ -520,6 +520,20 @@ See https://docs.astro.build/en/guides/server-side-rendering/ for more informati }, hint: 'See https://docs.astro.build/en/guides/content-collections/ for more information on content schemas.', }, + /** + * @docs + * @see + * - [Frontmatter injection](https://docs.astro.build/en/guides/markdown-content/#example-injecting-frontmatter) + * @description + * A remark or rehype plugin attempted to inject invalid frontmatter. This occurs when "astro.frontmatter" is set to `null`, `undefined`, or an invalid JSON object. + */ + InvalidFrontmatterInjectionError: { + title: 'Invalid frontmatter injection.', + code: 6003, + message: + 'A remark or rehype plugin attempted to inject invalid frontmatter. Ensure "astro.frontmatter" is set to a valid JSON object that is not `null` or `undefined`.', + hint: 'See the frontmatter injection docs https://docs.astro.build/en/guides/markdown-content/#example-injecting-frontmatter for more information.', + }, // Config Errors - 7xxx UnknownConfigError: { title: 'Unknown configuration error.', diff --git a/packages/astro/src/vite-plugin-markdown/index.ts b/packages/astro/src/vite-plugin-markdown/index.ts index b879be70b773..53b482d8e5d6 100644 --- a/packages/astro/src/vite-plugin-markdown/index.ts +++ b/packages/astro/src/vite-plugin-markdown/index.ts @@ -1,4 +1,8 @@ import { renderMarkdown } from '@astrojs/markdown-remark'; +import { + safelyGetAstroData, + InvalidAstroDataError, +} from '@astrojs/markdown-remark/dist/internal.js'; import fs from 'fs'; import matter from 'gray-matter'; import { fileURLToPath } from 'node:url'; @@ -6,16 +10,12 @@ import type { Plugin } from 'vite'; import { normalizePath } from 'vite'; import type { AstroSettings } from '../@types/astro'; import { getContentPaths } from '../content/index.js'; -import { AstroErrorData, MarkdownError } from '../core/errors/index.js'; +import { AstroError, AstroErrorData, MarkdownError } from '../core/errors/index.js'; import type { LogOptions } from '../core/logger/core.js'; import { warn } from '../core/logger/core.js'; import { isMarkdownFile } from '../core/util.js'; import type { PluginMetadata } from '../vite-plugin-astro/types.js'; -import { - escapeViteEnvReferences, - getFileInfo, - safelyGetAstroData, -} from '../vite-plugin-utils/index.js'; +import { escapeViteEnvReferences, getFileInfo } from '../vite-plugin-utils/index.js'; interface AstroPluginOptions { settings: AstroSettings; @@ -74,16 +74,17 @@ export default function markdown({ settings, logging }: AstroPluginOptions): Plu isAstroFlavoredMd: false, isExperimentalContentCollections: settings.config.experimental.contentCollections, contentDir: getContentPaths(settings.config).contentDir, - } as any); + frontmatter: raw.data, + }); const html = renderResult.code; const { headings } = renderResult.metadata; - const { frontmatter: injectedFrontmatter } = safelyGetAstroData(renderResult.vfile.data); - const frontmatter = { - ...injectedFrontmatter, - ...raw.data, - } as any; + const astroData = safelyGetAstroData(renderResult.vfile.data); + if (astroData instanceof InvalidAstroDataError) { + throw new AstroError(AstroErrorData.InvalidFrontmatterInjectionError); + } + const { frontmatter } = astroData; const { layout } = frontmatter; if (frontmatter.setup) { @@ -100,9 +101,6 @@ export default function markdown({ settings, logging }: AstroPluginOptions): Plu const html = ${JSON.stringify(html)}; - export const _internal = { - injectedFrontmatter: ${JSON.stringify(injectedFrontmatter)}, - } export const frontmatter = ${JSON.stringify(frontmatter)}; export const file = ${JSON.stringify(fileId)}; export const url = ${JSON.stringify(fileUrl)}; diff --git a/packages/astro/src/vite-plugin-utils/index.ts b/packages/astro/src/vite-plugin-utils/index.ts index 51479380eecf..17882e602095 100644 --- a/packages/astro/src/vite-plugin-utils/index.ts +++ b/packages/astro/src/vite-plugin-utils/index.ts @@ -1,6 +1,5 @@ import ancestor from 'common-ancestor-path'; -import type { Data } from 'vfile'; -import type { AstroConfig, MarkdownAstroData } from '../@types/astro'; +import type { AstroConfig } from '../@types/astro'; import { appendExtension, appendForwardSlash, @@ -36,33 +35,6 @@ 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; -} - /** * Normalizes different file names like: * diff --git a/packages/astro/test/astro-markdown-frontmatter-injection.test.js b/packages/astro/test/astro-markdown-frontmatter-injection.test.js index 4363663552df..6616ed513cb9 100644 --- a/packages/astro/test/astro-markdown-frontmatter-injection.test.js +++ b/packages/astro/test/astro-markdown-frontmatter-injection.test.js @@ -32,13 +32,10 @@ describe('Astro Markdown - frontmatter injection', () => { } }); - it('overrides injected frontmatter with user frontmatter', async () => { + it('allow user frontmatter mutation', 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'); + const descriptions = frontmatterByPage.map((frontmatter = {}) => frontmatter.description); + expect(descriptions).to.contain('Processed by remarkDescription plugin: Page 1 description'); + expect(descriptions).to.contain('Processed by remarkDescription plugin: Page 2 description'); }); }); 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 index 6ff9e1eedcf2..18cde7c50ac8 100644 --- a/packages/astro/test/fixtures/astro-markdown-frontmatter-injection/astro.config.mjs +++ b/packages/astro/test/fixtures/astro-markdown-frontmatter-injection/astro.config.mjs @@ -1,11 +1,11 @@ import { defineConfig } from 'astro/config'; -import { rehypeReadingTime, remarkTitle } from './src/markdown-plugins.mjs' +import { rehypeReadingTime, remarkTitle, remarkDescription } from './src/markdown-plugins.mjs' // https://astro.build/config export default defineConfig({ site: 'https://astro.build/', markdown: { - remarkPlugins: [remarkTitle], + remarkPlugins: [remarkTitle, remarkDescription], rehypePlugins: [rehypeReadingTime], } }); 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 index c0d5f7b2e339..d5e1a948ab76 100644 --- 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 @@ -18,3 +18,9 @@ export function remarkTitle() { }); }; } + +export function remarkDescription() { + return function (tree, { data }) { + data.astro.frontmatter.description = `Processed by remarkDescription plugin: ${data.astro.frontmatter.description}` + }; +} 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 index 2fcd655ec05a..3705f6ab167e 100644 --- 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 @@ -1,3 +1,7 @@ +--- +description: 'Page 1 description' +--- + # 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 index 4a6b9adddf81..dc37bedc7e09 100644 --- 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 @@ -1,3 +1,7 @@ +--- +description: 'Page 2 description' +--- + # Page 2 ## Table of contents 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 deleted file mode 100644 index 4e11c1c37e3b..000000000000 --- a/packages/astro/test/fixtures/astro-markdown-frontmatter-injection/src/pages/with-overrides.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -title: 'Overridden title' -injectedReadingTime: - text: '1000 min read' ---- - -# Working! diff --git a/packages/integrations/mdx/src/index.ts b/packages/integrations/mdx/src/index.ts index d788a171a5a5..e9d81ca1f32e 100644 --- a/packages/integrations/mdx/src/index.ts +++ b/packages/integrations/mdx/src/index.ts @@ -1,3 +1,4 @@ +import { toRemarkInitializeAstroData } from '@astrojs/markdown-remark/dist/internal.js'; import { compile as mdxCompile } from '@mdx-js/mdx'; import { PluggableList } from '@mdx-js/mdx/lib/core.js'; import mdxPlugin, { Options as MdxRollupPluginOptions } from '@mdx-js/rollup'; @@ -7,12 +8,7 @@ import fs from 'node:fs/promises'; import type { Options as RemarkRehypeOptions } from 'remark-rehype'; import { VFile } from 'vfile'; import type { Plugin as VitePlugin } from 'vite'; -import { - getRehypePlugins, - getRemarkPlugins, - recmaInjectImportMetaEnvPlugin, - rehypeApplyFrontmatterExport, -} from './plugins.js'; +import { getRehypePlugins, getRemarkPlugins, recmaInjectImportMetaEnvPlugin } from './plugins.js'; import { getFileInfo, parseFrontmatter } from './utils.js'; const RAW_CONTENT_ERROR = @@ -86,9 +82,10 @@ export default function mdx(mdxOptions: MdxOptions = {}): AstroIntegration { const { data: frontmatter, content: pageContent } = parseFrontmatter(code, id); const compiled = await mdxCompile(new VFile({ value: pageContent, path: id }), { ...mdxPluginOpts, - rehypePlugins: [ - ...(mdxPluginOpts.rehypePlugins ?? []), - () => rehypeApplyFrontmatterExport(frontmatter), + remarkPlugins: [ + // Ensure `data.astro` is available to all remark plugins + toRemarkInitializeAstroData({ userFrontmatter: frontmatter }), + ...(mdxPluginOpts.remarkPlugins ?? []), ], recmaPlugins: [ ...(mdxPluginOpts.recmaPlugins ?? []), diff --git a/packages/integrations/mdx/src/plugins.ts b/packages/integrations/mdx/src/plugins.ts index c823c6e7cd85..4701b1679539 100644 --- a/packages/integrations/mdx/src/plugins.ts +++ b/packages/integrations/mdx/src/plugins.ts @@ -2,7 +2,11 @@ import { rehypeHeadingIds } from '@astrojs/markdown-remark'; import { nodeTypes } from '@mdx-js/mdx'; import type { PluggableList } from '@mdx-js/mdx/lib/core.js'; import type { Options as MdxRollupPluginOptions } from '@mdx-js/rollup'; -import type { AstroConfig, MarkdownAstroData } from 'astro'; +import type { AstroConfig } from 'astro'; +import { + safelyGetAstroData, + InvalidAstroDataError, +} from '@astrojs/markdown-remark/dist/internal.js'; import type { Literal, MemberExpression } from 'estree'; import { visit as estreeVisit } from 'estree-util-visit'; import { bold, yellow } from 'kleur/colors'; @@ -47,26 +51,18 @@ export function recmaInjectImportMetaEnvPlugin({ }; } -export function remarkInitializeAstroData() { +export function rehypeApplyFrontmatterExport() { return function (tree: any, vfile: VFile) { - if (!vfile.data.astro) { - vfile.data.astro = { frontmatter: {} }; - } - }; -} - -export function rehypeApplyFrontmatterExport(pageFrontmatter: Record) { - return function (tree: any, vfile: VFile) { - const { frontmatter: injectedFrontmatter } = safelyGetAstroData(vfile.data); - const frontmatter = { ...injectedFrontmatter, ...pageFrontmatter }; + const astroData = safelyGetAstroData(vfile.data); + if (astroData instanceof InvalidAstroDataError) + throw new Error( + // Copied from Astro core `errors-data` + // TODO: find way to import error data from core + '[MDX] A remark or rehype plugin attempted to inject invalid frontmatter. Ensure "astro.frontmatter" is set to a valid JSON object that is not `null` or `undefined`.' + ); + const { frontmatter } = astroData; const exportNodes = [ - jsToTreeNode( - `export const frontmatter = ${JSON.stringify( - frontmatter - )};\nexport const _internal = { injectedFrontmatter: ${JSON.stringify( - injectedFrontmatter - )} };` - ), + jsToTreeNode(`export const frontmatter = ${JSON.stringify(frontmatter)};`), ]; if (frontmatter.layout) { // NOTE(bholmesdev) 08-22-2022 @@ -151,10 +147,7 @@ export async function getRemarkPlugins( mdxOptions: MdxOptions, config: AstroConfig ): Promise { - let remarkPlugins: PluggableList = [ - // Set "vfile.data.astro" for plugins to inject frontmatter - remarkInitializeAstroData, - ]; + let remarkPlugins: PluggableList = []; switch (mdxOptions.extendPlugins) { case false: break; @@ -217,6 +210,8 @@ export function getRehypePlugins( // We run `rehypeHeadingIds` _last_ to respect any custom IDs set by user plugins. rehypeHeadingIds, rehypeInjectHeadingsExport, + // computed from `astro.data.frontmatter` in VFile data + rehypeApplyFrontmatterExport, ]; return rehypePlugins; } @@ -250,41 +245,6 @@ function ignoreStringPlugins(plugins: any[]) { return validPlugins; } -/** - * 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" - */ -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; -} - /** * Check if estree entry is "import.meta.env.VARIABLE" * If it is, return the variable name (i.e. "VARIABLE") 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 index fc15686c2998..5335fac877eb 100644 --- a/packages/integrations/mdx/test/fixtures/mdx-frontmatter-injection/astro.config.mjs +++ b/packages/integrations/mdx/test/fixtures/mdx-frontmatter-injection/astro.config.mjs @@ -1,12 +1,12 @@ import { defineConfig } from 'astro/config'; import mdx from '@astrojs/mdx'; -import { rehypeReadingTime, remarkTitle } from './src/markdown-plugins.mjs'; +import { rehypeReadingTime, remarkDescription, remarkTitle } from './src/markdown-plugins.mjs'; // https://astro.build/config export default defineConfig({ site: 'https://astro.build/', integrations: [mdx({ - remarkPlugins: [remarkTitle], + remarkPlugins: [remarkTitle, remarkDescription], rehypePlugins: [rehypeReadingTime], })], }); 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 index c0d5f7b2e339..35b415787a45 100644 --- 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 @@ -18,3 +18,10 @@ export function remarkTitle() { }); }; } + +export function remarkDescription() { + return function (tree, vfile) { + const { frontmatter } = vfile.data.astro; + frontmatter.description = `Processed by remarkDescription plugin: ${frontmatter.description}` + }; +} 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 index 1092099f00f3..0d96d95b9604 100644 --- 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 @@ -1,5 +1,6 @@ --- layout: '../layouts/Base.astro' +description: Page 1 description --- # Page 1 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 index c82eb97c2f01..fe6a8286b36e 100644 --- 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 @@ -1,5 +1,6 @@ --- layout: '../layouts/Base.astro' +description: Page 2 description --- # Page 2 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 deleted file mode 100644 index 4e11c1c37e3b..000000000000 --- a/packages/integrations/mdx/test/fixtures/mdx-frontmatter-injection/src/pages/with-overrides.mdx +++ /dev/null @@ -1,7 +0,0 @@ ---- -title: 'Overridden title' -injectedReadingTime: - text: '1000 min read' ---- - -# Working! diff --git a/packages/integrations/mdx/test/mdx-frontmatter-injection.test.js b/packages/integrations/mdx/test/mdx-frontmatter-injection.test.js index 780f7252c77d..8f598b78ebc3 100644 --- a/packages/integrations/mdx/test/mdx-frontmatter-injection.test.js +++ b/packages/integrations/mdx/test/mdx-frontmatter-injection.test.js @@ -33,14 +33,11 @@ describe('MDX frontmatter injection', () => { } }); - it('overrides injected frontmatter with user frontmatter', async () => { + it('allow user frontmatter mutation', 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'); + const descriptions = frontmatterByPage.map((frontmatter = {}) => frontmatter.description); + expect(descriptions).to.contain('Processed by remarkDescription plugin: Page 1 description'); + expect(descriptions).to.contain('Processed by remarkDescription plugin: Page 2 description'); }); it('passes injected frontmatter to layouts', async () => { diff --git a/packages/markdown/remark/package.json b/packages/markdown/remark/package.json index 6549848df157..5491dd0feee8 100644 --- a/packages/markdown/remark/package.json +++ b/packages/markdown/remark/package.json @@ -13,7 +13,8 @@ "homepage": "https://astro.build", "main": "./dist/index.js", "exports": { - ".": "./dist/index.js" + ".": "./dist/index.js", + "./dist/internal.js": "./dist/internal.js" }, "scripts": { "prepublish": "pnpm build", diff --git a/packages/markdown/remark/src/frontmatter-injection.ts b/packages/markdown/remark/src/frontmatter-injection.ts new file mode 100644 index 000000000000..921d01297e27 --- /dev/null +++ b/packages/markdown/remark/src/frontmatter-injection.ts @@ -0,0 +1,41 @@ +import type { Data, VFile } from 'vfile'; +import type { MarkdownAstroData } from './types.js'; + +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 class InvalidAstroDataError extends TypeError {} + +export function safelyGetAstroData(vfileData: Data): MarkdownAstroData | InvalidAstroDataError { + const { astro } = vfileData; + + if (!astro || !isValidAstroData(astro)) { + return new InvalidAstroDataError(); + } + + return astro; +} + +export function toRemarkInitializeAstroData({ + userFrontmatter, +}: { + userFrontmatter: Record; +}) { + return () => + function (tree: any, vfile: VFile) { + if (!vfile.data.astro) { + vfile.data.astro = { frontmatter: userFrontmatter }; + } + }; +} diff --git a/packages/markdown/remark/src/index.ts b/packages/markdown/remark/src/index.ts index 66889108e083..480cc9e38332 100644 --- a/packages/markdown/remark/src/index.ts +++ b/packages/markdown/remark/src/index.ts @@ -8,7 +8,7 @@ import rehypeIslands from './rehype-islands.js'; import rehypeJsx from './rehype-jsx.js'; import toRemarkContentRelImageError from './remark-content-rel-image-error.js'; import remarkEscape from './remark-escape.js'; -import { remarkInitializeAstroData } from './remark-initialize-astro-data.js'; +import { toRemarkInitializeAstroData } from './frontmatter-injection.js'; import remarkMarkAndUnravel from './remark-mark-and-unravel.js'; import remarkMdxish from './remark-mdxish.js'; import remarkPrism from './remark-prism.js'; @@ -45,13 +45,14 @@ export async function renderMarkdown( isAstroFlavoredMd = false, isExperimentalContentCollections = false, contentDir, + frontmatter: userFrontmatter = {}, } = opts; const input = new VFile({ value: content, path: fileURL }); const scopedClassName = opts.$?.scopedClassName; let parser = unified() .use(markdown) - .use(remarkInitializeAstroData) + .use(toRemarkInitializeAstroData({ userFrontmatter })) .use(isAstroFlavoredMd ? [remarkMdxish, remarkMarkAndUnravel, remarkUnwrap, remarkEscape] : []); if (extendDefaultPlugins || (remarkPlugins.length === 0 && rehypePlugins.length === 0)) { diff --git a/packages/markdown/remark/src/internal.ts b/packages/markdown/remark/src/internal.ts new file mode 100644 index 000000000000..0ab7e34bb394 --- /dev/null +++ b/packages/markdown/remark/src/internal.ts @@ -0,0 +1,5 @@ +export { + InvalidAstroDataError, + safelyGetAstroData, + toRemarkInitializeAstroData, +} from './frontmatter-injection.js'; diff --git a/packages/markdown/remark/src/remark-initialize-astro-data.ts b/packages/markdown/remark/src/remark-initialize-astro-data.ts deleted file mode 100644 index 37af8aeaff6d..000000000000 --- a/packages/markdown/remark/src/remark-initialize-astro-data.ts +++ /dev/null @@ -1,9 +0,0 @@ -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 15465d95008f..f52fd4bf5670 100644 --- a/packages/markdown/remark/src/types.ts +++ b/packages/markdown/remark/src/types.ts @@ -11,6 +11,10 @@ import type { VFile } from 'vfile'; export type { Node } from 'unist'; +export type MarkdownAstroData = { + frontmatter: Record; +}; + export type RemarkPlugin = unified.Plugin< PluginParameters, mdast.Root @@ -58,6 +62,8 @@ export interface MarkdownRenderingOptions extends AstroMarkdownOptions { isExperimentalContentCollections?: boolean; /** Used to prevent relative image imports from `src/content/` */ contentDir: URL; + /** Used for frontmatter injection plugins */ + frontmatter?: Record; } export interface MarkdownHeading {