Skip to content

Commit

Permalink
Change frontmatter injection ordering (#5687)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
bholmesdev committed Jan 3, 2023
1 parent 16c7d0b commit e2019be
Show file tree
Hide file tree
Showing 29 changed files with 234 additions and 204 deletions.
47 changes: 47 additions & 0 deletions .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.
79 changes: 40 additions & 39 deletions examples/with-content/src/content/types.generated.d.ts
Expand Up @@ -37,49 +37,50 @@ declare module 'astro:content' {
render(): Promise<{
Content: import('astro').MarkdownInstance<{}>['Content'];
headings: import('astro').MarkdownHeading[];
injectedFrontmatter: Record<string, any>;
remarkPluginFrontmatter: Record<string, any>;
}>;
};

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");
}
4 changes: 0 additions & 4 deletions packages/astro/src/@types/astro.ts
Expand Up @@ -1464,10 +1464,6 @@ export interface SSRResult {
_metadata: SSRMetadata;
}

export type MarkdownAstroData = {
frontmatter: MD['frontmatter'];
};

/* Preview server stuff */
export interface PreviewServer {
host?: string;
Expand Down
5 changes: 1 addition & 4 deletions packages/astro/src/content/internal.ts
Expand Up @@ -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,
};
}
2 changes: 1 addition & 1 deletion packages/astro/src/content/template/types.generated.d.ts
Expand Up @@ -37,7 +37,7 @@ declare module 'astro:content' {
render(): Promise<{
Content: import('astro').MarkdownInstance<{}>['Content'];
headings: import('astro').MarkdownHeading[];
injectedFrontmatter: Record<string, any>;
remarkPluginFrontmatter: Record<string, any>;
}>;
};

Expand Down
2 changes: 1 addition & 1 deletion packages/astro/src/content/vite-plugin-content-assets.ts
Expand Up @@ -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)};
`;
Expand Down
14 changes: 14 additions & 0 deletions packages/astro/src/core/errors/errors-data.ts
Expand Up @@ -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.',
Expand Down
28 changes: 13 additions & 15 deletions packages/astro/src/vite-plugin-markdown/index.ts
@@ -1,21 +1,21 @@
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';
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;
Expand Down Expand Up @@ -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) {
Expand All @@ -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)};
Expand Down
30 changes: 1 addition & 29 deletions 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,
Expand Down Expand Up @@ -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:
*
Expand Down
11 changes: 4 additions & 7 deletions packages/astro/test/astro-markdown-frontmatter-injection.test.js
Expand Up @@ -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');
});
});
@@ -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],
}
});
Expand Up @@ -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}`
};
}
@@ -1,3 +1,7 @@
---
description: 'Page 1 description'
---

# Page 1

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

# Page 2

## Table of contents
Expand Down

This file was deleted.

0 comments on commit e2019be

Please sign in to comment.