Change frontmatter injection ordering (#5687)

* feat: make user frontmatter accessible in md

* test: new frontmatter injection

* refactor: move injection utils to remark pkg

* fix: add dist/internal to remark exports

* feat: update frontmater injection in mdx

* tests: new mdx injection

* chore: changeset

* chore: simplify frontmatter destructuring

* fix: remove old _internal references

* refactor: injectedFrontmatter -> remarkPluginFrontmatter

* docs: add content collections change

* chore: changeset heading levels
This commit is contained in:
Ben Holmes 2023-01-03 16:31:19 -05:00 committed by GitHub
parent 16c7d0bfd4
commit e2019be6ff
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
29 changed files with 234 additions and 204 deletions

View file

@ -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.

View file

@ -37,49 +37,50 @@ declare module 'astro:content' {
render(): Promise<{ render(): Promise<{
Content: import('astro').MarkdownInstance<{}>['Content']; Content: import('astro').MarkdownInstance<{}>['Content'];
headings: import('astro').MarkdownHeading[]; headings: import('astro').MarkdownHeading[];
injectedFrontmatter: Record<string, any>; remarkPluginFrontmatter: Record<string, any>;
}>; }>;
}; };
const entryMap: { const entryMap: {
blog: { "blog": {
'first-post.md': { "first-post.md": {
id: 'first-post.md'; id: "first-post.md",
slug: 'first-post'; slug: "first-post",
body: string; body: string,
collection: 'blog'; collection: "blog",
data: InferEntrySchema<'blog'>; data: InferEntrySchema<"blog">
}; },
'markdown-style-guide.md': { "markdown-style-guide.md": {
id: 'markdown-style-guide.md'; id: "markdown-style-guide.md",
slug: 'markdown-style-guide'; slug: "markdown-style-guide",
body: string; body: string,
collection: 'blog'; collection: "blog",
data: InferEntrySchema<'blog'>; data: InferEntrySchema<"blog">
}; },
'second-post.md': { "second-post.md": {
id: 'second-post.md'; id: "second-post.md",
slug: 'second-post'; slug: "second-post",
body: string; body: string,
collection: 'blog'; collection: "blog",
data: InferEntrySchema<'blog'>; data: InferEntrySchema<"blog">
}; },
'third-post.md': { "third-post.md": {
id: 'third-post.md'; id: "third-post.md",
slug: 'third-post'; slug: "third-post",
body: string; body: string,
collection: 'blog'; collection: "blog",
data: InferEntrySchema<'blog'>; data: InferEntrySchema<"blog">
}; },
'using-mdx.mdx': { "using-mdx.mdx": {
id: 'using-mdx.mdx'; id: "using-mdx.mdx",
slug: 'using-mdx'; slug: "using-mdx",
body: string; body: string,
collection: 'blog'; collection: "blog",
data: InferEntrySchema<'blog'>; data: InferEntrySchema<"blog">
}; },
}; },
}; };
type ContentConfig = typeof import('./config'); type ContentConfig = typeof import("./config");
} }

View file

@ -1464,10 +1464,6 @@ export interface SSRResult {
_metadata: SSRMetadata; _metadata: SSRMetadata;
} }
export type MarkdownAstroData = {
frontmatter: MD['frontmatter'];
};
/* Preview server stuff */ /* Preview server stuff */
export interface PreviewServer { export interface PreviewServer {
host?: string; host?: string;

View file

@ -137,12 +137,9 @@ async function render({
propagation: 'self', propagation: 'self',
}); });
if (!mod._internal && id.endsWith('.mdx')) {
throw new Error(`[Content] Failed to render MDX entry. Try installing @astrojs/mdx@latest`);
}
return { return {
Content, Content,
headings: mod.getHeadings(), headings: mod.getHeadings(),
injectedFrontmatter: mod._internal.injectedFrontmatter, remarkPluginFrontmatter: mod.frontmatter,
}; };
} }

View file

@ -37,7 +37,7 @@ declare module 'astro:content' {
render(): Promise<{ render(): Promise<{
Content: import('astro').MarkdownInstance<{}>['Content']; Content: import('astro').MarkdownInstance<{}>['Content'];
headings: import('astro').MarkdownHeading[]; headings: import('astro').MarkdownHeading[];
injectedFrontmatter: Record<string, any>; remarkPluginFrontmatter: Record<string, any>;
}>; }>;
}; };

View file

@ -34,7 +34,7 @@ export function astroDelayedAssetPlugin({ mode }: { mode: string }): Plugin {
if (isDelayedAsset(id)) { if (isDelayedAsset(id)) {
const basePath = id.split('?')[0]; const basePath = id.split('?')[0];
const code = ` 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 collectedLinks = ${JSON.stringify(LINKS_PLACEHOLDER)};
export const collectedStyles = ${JSON.stringify(STYLES_PLACEHOLDER)}; export const collectedStyles = ${JSON.stringify(STYLES_PLACEHOLDER)};
`; `;

View file

@ -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.', 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 // Config Errors - 7xxx
UnknownConfigError: { UnknownConfigError: {
title: 'Unknown configuration error.', title: 'Unknown configuration error.',

View file

@ -1,4 +1,8 @@
import { renderMarkdown } from '@astrojs/markdown-remark'; import { renderMarkdown } from '@astrojs/markdown-remark';
import {
safelyGetAstroData,
InvalidAstroDataError,
} from '@astrojs/markdown-remark/dist/internal.js';
import fs from 'fs'; import fs from 'fs';
import matter from 'gray-matter'; import matter from 'gray-matter';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
@ -6,16 +10,12 @@ import type { Plugin } from 'vite';
import { normalizePath } from 'vite'; import { normalizePath } from 'vite';
import type { AstroSettings } from '../@types/astro'; import type { AstroSettings } from '../@types/astro';
import { getContentPaths } from '../content/index.js'; 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 type { LogOptions } from '../core/logger/core.js';
import { warn } from '../core/logger/core.js'; import { warn } from '../core/logger/core.js';
import { isMarkdownFile } from '../core/util.js'; import { isMarkdownFile } from '../core/util.js';
import type { PluginMetadata } from '../vite-plugin-astro/types.js'; import type { PluginMetadata } from '../vite-plugin-astro/types.js';
import { import { escapeViteEnvReferences, getFileInfo } from '../vite-plugin-utils/index.js';
escapeViteEnvReferences,
getFileInfo,
safelyGetAstroData,
} from '../vite-plugin-utils/index.js';
interface AstroPluginOptions { interface AstroPluginOptions {
settings: AstroSettings; settings: AstroSettings;
@ -74,16 +74,17 @@ export default function markdown({ settings, logging }: AstroPluginOptions): Plu
isAstroFlavoredMd: false, isAstroFlavoredMd: false,
isExperimentalContentCollections: settings.config.experimental.contentCollections, isExperimentalContentCollections: settings.config.experimental.contentCollections,
contentDir: getContentPaths(settings.config).contentDir, contentDir: getContentPaths(settings.config).contentDir,
} as any); frontmatter: raw.data,
});
const html = renderResult.code; const html = renderResult.code;
const { headings } = renderResult.metadata; const { headings } = renderResult.metadata;
const { frontmatter: injectedFrontmatter } = safelyGetAstroData(renderResult.vfile.data); const astroData = safelyGetAstroData(renderResult.vfile.data);
const frontmatter = { if (astroData instanceof InvalidAstroDataError) {
...injectedFrontmatter, throw new AstroError(AstroErrorData.InvalidFrontmatterInjectionError);
...raw.data, }
} as any;
const { frontmatter } = astroData;
const { layout } = frontmatter; const { layout } = frontmatter;
if (frontmatter.setup) { if (frontmatter.setup) {
@ -100,9 +101,6 @@ export default function markdown({ settings, logging }: AstroPluginOptions): Plu
const html = ${JSON.stringify(html)}; const html = ${JSON.stringify(html)};
export const _internal = {
injectedFrontmatter: ${JSON.stringify(injectedFrontmatter)},
}
export const frontmatter = ${JSON.stringify(frontmatter)}; export const frontmatter = ${JSON.stringify(frontmatter)};
export const file = ${JSON.stringify(fileId)}; export const file = ${JSON.stringify(fileId)};
export const url = ${JSON.stringify(fileUrl)}; export const url = ${JSON.stringify(fileUrl)};

View file

@ -1,6 +1,5 @@
import ancestor from 'common-ancestor-path'; import ancestor from 'common-ancestor-path';
import type { Data } from 'vfile'; import type { AstroConfig } from '../@types/astro';
import type { AstroConfig, MarkdownAstroData } from '../@types/astro';
import { import {
appendExtension, appendExtension,
appendForwardSlash, appendForwardSlash,
@ -36,33 +35,6 @@ export function getFileInfo(id: string, config: AstroConfig) {
return { fileId, fileUrl }; 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: * Normalizes different file names like:
* *

View file

@ -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 frontmatterByPage = JSON.parse(await fixture.readFile('/glob.json'));
const readingTimes = frontmatterByPage.map( const descriptions = frontmatterByPage.map((frontmatter = {}) => frontmatter.description);
(frontmatter = {}) => frontmatter.injectedReadingTime?.text expect(descriptions).to.contain('Processed by remarkDescription plugin: Page 1 description');
); expect(descriptions).to.contain('Processed by remarkDescription plugin: Page 2 description');
const titles = frontmatterByPage.map((frontmatter = {}) => frontmatter.title);
expect(titles).to.contain('Overridden title');
expect(readingTimes).to.contain('1000 min read');
}); });
}); });

View file

@ -1,11 +1,11 @@
import { defineConfig } from 'astro/config'; 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 // https://astro.build/config
export default defineConfig({ export default defineConfig({
site: 'https://astro.build/', site: 'https://astro.build/',
markdown: { markdown: {
remarkPlugins: [remarkTitle], remarkPlugins: [remarkTitle, remarkDescription],
rehypePlugins: [rehypeReadingTime], rehypePlugins: [rehypeReadingTime],
} }
}); });

View file

@ -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}`
};
}

View file

@ -1,3 +1,7 @@
---
description: 'Page 1 description'
---
# Page 1 # Page 1
Look at that! Look at that!

View file

@ -1,3 +1,7 @@
---
description: 'Page 2 description'
---
# Page 2 # Page 2
## Table of contents ## Table of contents

View file

@ -1,7 +0,0 @@
---
title: 'Overridden title'
injectedReadingTime:
text: '1000 min read'
---
# Working!

View file

@ -1,3 +1,4 @@
import { toRemarkInitializeAstroData } from '@astrojs/markdown-remark/dist/internal.js';
import { compile as mdxCompile } from '@mdx-js/mdx'; import { compile as mdxCompile } from '@mdx-js/mdx';
import { PluggableList } from '@mdx-js/mdx/lib/core.js'; import { PluggableList } from '@mdx-js/mdx/lib/core.js';
import mdxPlugin, { Options as MdxRollupPluginOptions } from '@mdx-js/rollup'; 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 type { Options as RemarkRehypeOptions } from 'remark-rehype';
import { VFile } from 'vfile'; import { VFile } from 'vfile';
import type { Plugin as VitePlugin } from 'vite'; import type { Plugin as VitePlugin } from 'vite';
import { import { getRehypePlugins, getRemarkPlugins, recmaInjectImportMetaEnvPlugin } from './plugins.js';
getRehypePlugins,
getRemarkPlugins,
recmaInjectImportMetaEnvPlugin,
rehypeApplyFrontmatterExport,
} from './plugins.js';
import { getFileInfo, parseFrontmatter } from './utils.js'; import { getFileInfo, parseFrontmatter } from './utils.js';
const RAW_CONTENT_ERROR = const RAW_CONTENT_ERROR =
@ -86,9 +82,10 @@ export default function mdx(mdxOptions: MdxOptions = {}): AstroIntegration {
const { data: frontmatter, content: pageContent } = parseFrontmatter(code, id); const { data: frontmatter, content: pageContent } = parseFrontmatter(code, id);
const compiled = await mdxCompile(new VFile({ value: pageContent, path: id }), { const compiled = await mdxCompile(new VFile({ value: pageContent, path: id }), {
...mdxPluginOpts, ...mdxPluginOpts,
rehypePlugins: [ remarkPlugins: [
...(mdxPluginOpts.rehypePlugins ?? []), // Ensure `data.astro` is available to all remark plugins
() => rehypeApplyFrontmatterExport(frontmatter), toRemarkInitializeAstroData({ userFrontmatter: frontmatter }),
...(mdxPluginOpts.remarkPlugins ?? []),
], ],
recmaPlugins: [ recmaPlugins: [
...(mdxPluginOpts.recmaPlugins ?? []), ...(mdxPluginOpts.recmaPlugins ?? []),

View file

@ -2,7 +2,11 @@ import { rehypeHeadingIds } from '@astrojs/markdown-remark';
import { nodeTypes } from '@mdx-js/mdx'; import { nodeTypes } from '@mdx-js/mdx';
import type { PluggableList } from '@mdx-js/mdx/lib/core.js'; import type { PluggableList } from '@mdx-js/mdx/lib/core.js';
import type { Options as MdxRollupPluginOptions } from '@mdx-js/rollup'; 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 type { Literal, MemberExpression } from 'estree';
import { visit as estreeVisit } from 'estree-util-visit'; import { visit as estreeVisit } from 'estree-util-visit';
import { bold, yellow } from 'kleur/colors'; 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) { return function (tree: any, vfile: VFile) {
if (!vfile.data.astro) { const astroData = safelyGetAstroData(vfile.data);
vfile.data.astro = { frontmatter: {} }; 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`.'
export function rehypeApplyFrontmatterExport(pageFrontmatter: Record<string, any>) { );
return function (tree: any, vfile: VFile) { const { frontmatter } = astroData;
const { frontmatter: injectedFrontmatter } = safelyGetAstroData(vfile.data);
const frontmatter = { ...injectedFrontmatter, ...pageFrontmatter };
const exportNodes = [ const exportNodes = [
jsToTreeNode( jsToTreeNode(`export const frontmatter = ${JSON.stringify(frontmatter)};`),
`export const frontmatter = ${JSON.stringify(
frontmatter
)};\nexport const _internal = { injectedFrontmatter: ${JSON.stringify(
injectedFrontmatter
)} };`
),
]; ];
if (frontmatter.layout) { if (frontmatter.layout) {
// NOTE(bholmesdev) 08-22-2022 // NOTE(bholmesdev) 08-22-2022
@ -151,10 +147,7 @@ export async function getRemarkPlugins(
mdxOptions: MdxOptions, mdxOptions: MdxOptions,
config: AstroConfig config: AstroConfig
): Promise<MdxRollupPluginOptions['remarkPlugins']> { ): Promise<MdxRollupPluginOptions['remarkPlugins']> {
let remarkPlugins: PluggableList = [ let remarkPlugins: PluggableList = [];
// Set "vfile.data.astro" for plugins to inject frontmatter
remarkInitializeAstroData,
];
switch (mdxOptions.extendPlugins) { switch (mdxOptions.extendPlugins) {
case false: case false:
break; break;
@ -217,6 +210,8 @@ export function getRehypePlugins(
// We run `rehypeHeadingIds` _last_ to respect any custom IDs set by user plugins. // We run `rehypeHeadingIds` _last_ to respect any custom IDs set by user plugins.
rehypeHeadingIds, rehypeHeadingIds,
rehypeInjectHeadingsExport, rehypeInjectHeadingsExport,
// computed from `astro.data.frontmatter` in VFile data
rehypeApplyFrontmatterExport,
]; ];
return rehypePlugins; return rehypePlugins;
} }
@ -250,41 +245,6 @@ function ignoreStringPlugins(plugins: any[]) {
return validPlugins; 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" * Check if estree entry is "import.meta.env.VARIABLE"
* If it is, return the variable name (i.e. "VARIABLE") * If it is, return the variable name (i.e. "VARIABLE")

View file

@ -1,12 +1,12 @@
import { defineConfig } from 'astro/config'; import { defineConfig } from 'astro/config';
import mdx from '@astrojs/mdx'; 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 // https://astro.build/config
export default defineConfig({ export default defineConfig({
site: 'https://astro.build/', site: 'https://astro.build/',
integrations: [mdx({ integrations: [mdx({
remarkPlugins: [remarkTitle], remarkPlugins: [remarkTitle, remarkDescription],
rehypePlugins: [rehypeReadingTime], rehypePlugins: [rehypeReadingTime],
})], })],
}); });

View file

@ -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}`
};
}

View file

@ -1,5 +1,6 @@
--- ---
layout: '../layouts/Base.astro' layout: '../layouts/Base.astro'
description: Page 1 description
--- ---
# Page 1 # Page 1

View file

@ -1,5 +1,6 @@
--- ---
layout: '../layouts/Base.astro' layout: '../layouts/Base.astro'
description: Page 2 description
--- ---
# Page 2 # Page 2

View file

@ -1,7 +0,0 @@
---
title: 'Overridden title'
injectedReadingTime:
text: '1000 min read'
---
# Working!

View file

@ -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 frontmatterByPage = JSON.parse(await fixture.readFile('/glob.json'));
const readingTimes = frontmatterByPage.map( const descriptions = frontmatterByPage.map((frontmatter = {}) => frontmatter.description);
(frontmatter = {}) => frontmatter.injectedReadingTime?.text expect(descriptions).to.contain('Processed by remarkDescription plugin: Page 1 description');
); expect(descriptions).to.contain('Processed by remarkDescription plugin: Page 2 description');
const titles = frontmatterByPage.map((frontmatter = {}) => frontmatter.title);
expect(titles).to.contain('Overridden title');
expect(readingTimes).to.contain('1000 min read');
}); });
it('passes injected frontmatter to layouts', async () => { it('passes injected frontmatter to layouts', async () => {

View file

@ -13,7 +13,8 @@
"homepage": "https://astro.build", "homepage": "https://astro.build",
"main": "./dist/index.js", "main": "./dist/index.js",
"exports": { "exports": {
".": "./dist/index.js" ".": "./dist/index.js",
"./dist/internal.js": "./dist/internal.js"
}, },
"scripts": { "scripts": {
"prepublish": "pnpm build", "prepublish": "pnpm build",

View file

@ -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<string, any>;
}) {
return () =>
function (tree: any, vfile: VFile) {
if (!vfile.data.astro) {
vfile.data.astro = { frontmatter: userFrontmatter };
}
};
}

View file

@ -8,7 +8,7 @@ import rehypeIslands from './rehype-islands.js';
import rehypeJsx from './rehype-jsx.js'; import rehypeJsx from './rehype-jsx.js';
import toRemarkContentRelImageError from './remark-content-rel-image-error.js'; import toRemarkContentRelImageError from './remark-content-rel-image-error.js';
import remarkEscape from './remark-escape.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 remarkMarkAndUnravel from './remark-mark-and-unravel.js';
import remarkMdxish from './remark-mdxish.js'; import remarkMdxish from './remark-mdxish.js';
import remarkPrism from './remark-prism.js'; import remarkPrism from './remark-prism.js';
@ -45,13 +45,14 @@ export async function renderMarkdown(
isAstroFlavoredMd = false, isAstroFlavoredMd = false,
isExperimentalContentCollections = false, isExperimentalContentCollections = false,
contentDir, contentDir,
frontmatter: userFrontmatter = {},
} = opts; } = opts;
const input = new VFile({ value: content, path: fileURL }); const input = new VFile({ value: content, path: fileURL });
const scopedClassName = opts.$?.scopedClassName; const scopedClassName = opts.$?.scopedClassName;
let parser = unified() let parser = unified()
.use(markdown) .use(markdown)
.use(remarkInitializeAstroData) .use(toRemarkInitializeAstroData({ userFrontmatter }))
.use(isAstroFlavoredMd ? [remarkMdxish, remarkMarkAndUnravel, remarkUnwrap, remarkEscape] : []); .use(isAstroFlavoredMd ? [remarkMdxish, remarkMarkAndUnravel, remarkUnwrap, remarkEscape] : []);
if (extendDefaultPlugins || (remarkPlugins.length === 0 && rehypePlugins.length === 0)) { if (extendDefaultPlugins || (remarkPlugins.length === 0 && rehypePlugins.length === 0)) {

View file

@ -0,0 +1,5 @@
export {
InvalidAstroDataError,
safelyGetAstroData,
toRemarkInitializeAstroData,
} from './frontmatter-injection.js';

View file

@ -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: {} };
}
};
}

View file

@ -11,6 +11,10 @@ import type { VFile } from 'vfile';
export type { Node } from 'unist'; export type { Node } from 'unist';
export type MarkdownAstroData = {
frontmatter: Record<string, any>;
};
export type RemarkPlugin<PluginParameters extends any[] = any[]> = unified.Plugin< export type RemarkPlugin<PluginParameters extends any[] = any[]> = unified.Plugin<
PluginParameters, PluginParameters,
mdast.Root mdast.Root
@ -58,6 +62,8 @@ export interface MarkdownRenderingOptions extends AstroMarkdownOptions {
isExperimentalContentCollections?: boolean; isExperimentalContentCollections?: boolean;
/** Used to prevent relative image imports from `src/content/` */ /** Used to prevent relative image imports from `src/content/` */
contentDir: URL; contentDir: URL;
/** Used for frontmatter injection plugins */
frontmatter?: Record<string, any>;
} }
export interface MarkdownHeading { export interface MarkdownHeading {