Skip to content

Commit

Permalink
Frontmatter injection for MD and MDX (#4176)
Browse files Browse the repository at this point in the history
* feat: inject vfile data as exports

* feat: add vfile to renderMarkdown output

* feat: add safe astroExports parser to utils

* refactor: expose vite-plugin-utils on astro package

* feat: handle astroExports in mdx

* deps: vfile

* chore: lockfile

* test: astroExports in mdx

* refactor: merge plugin exports into forntmatter

* refactor: astroExports -> astro.frontmatter

* refactor: md astroExports -> astro.frontmatter

* feat: astro.frontmatter vite-plugin-markdown

* chore: remove unused import

* fix: inline safelyGetAstroData in MDX integration

* chore: check that frontmatter export is valid export name

* chore: error log naming

* test: mdx remark frontmatter injection

* fix: inconsistent shiki mod resolution

* fix: add new frontmatter and heading props

* test: remark vdata

* fix: spread astro.data.frontmatter

* test deps: mdast-util-to-string, reading-time

* fix: astro-md test package name

* test: md frontmatter injection

* fix: layouts

* deps: remove vite-plugin-utils export

* fix: package lock

* chore: remove dup import

* chore: changeset

* chore: add comment on safelyGetAstroData source

* deps: move mdast-util-to-string + reading-time to test fixture

* chore: move remark plugins to test fixture

* fix: override plugin frontmatter with user frontmatter

* test: md injected frontmatter overrides

* test: frontmatter injection overrides mdx
  • Loading branch information
bholmesdev authored Aug 5, 2022
1 parent 4678a3f commit 2675b86
Show file tree
Hide file tree
Showing 32 changed files with 491 additions and 71 deletions.
7 changes: 7 additions & 0 deletions .changeset/cool-crabs-trade.md
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions packages/astro/src/@types/astro.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1110,3 +1110,5 @@ export interface SSRResult {
response: ResponseInit;
_metadata: SSRMetadata;
}

export type MarkdownAstroData = { frontmatter: object };
12 changes: 10 additions & 2 deletions packages/astro/src/vite-plugin-markdown/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -94,6 +101,7 @@ export default function markdown({ config, logging }: AstroPluginOptions): Plugi
}
export default Content;
`);

return {
code,
meta: {
Expand Down
30 changes: 29 additions & 1 deletion packages/astro/src/vite-plugin-utils/index.ts
Original file line number Diff line number Diff line change
@@ -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) {
Expand All @@ -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;
}
40 changes: 40 additions & 0 deletions packages/astro/test/astro-markdown-frontmatter-injection.test.js
Original file line number Diff line number Diff line change
@@ -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');
});
});
Original file line number Diff line number Diff line change
@@ -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],
}
});
Original file line number Diff line number Diff line change
@@ -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"
}
}
Original file line number Diff line number Diff line change
@@ -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);
}
});
};
}
Original file line number Diff line number Diff line change
@@ -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)),
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Page 1

Look at that!
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
title: 'Overridden title'
injectedReadingTime:
text: '1000 min read'
---

# Working!
2 changes: 1 addition & 1 deletion packages/astro/test/fixtures/astro-markdown/package.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"name": "@test/astro-markdown-md-mode",
"name": "@test/astro-markdown",
"version": "0.0.0",
"private": true,
"dependencies": {
Expand Down
3 changes: 2 additions & 1 deletion packages/integrations/mdx/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
82 changes: 82 additions & 0 deletions packages/integrations/mdx/src/astro-data-utils.ts
Original file line number Diff line number Diff line change
@@ -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<string, any>,
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;
}
Loading

0 comments on commit 2675b86

Please sign in to comment.