Skip to content

Commit db252e0

Browse files
authored
feat(markdown): add support for TOML frontmatter in Markdown files. (#12850)
1 parent 0879cc2 commit db252e0

File tree

5 files changed

+141
-12
lines changed

5 files changed

+141
-12
lines changed

.changeset/lazy-pandas-love.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
---
2+
'@astrojs/markdown-remark': minor
3+
---
4+
5+
Adds support for TOML frontmatter in `.md` and `.mdx` files
6+
7+
Astro 5.2 automatically identifies the format of your Markdown and MDX frontmatter based on the delimiter used. With `+++` as a delimiter (instead of the `---` YAML code fence), your frontmatter will automatically be recognized and parsed as [TOML](https://toml.io).
8+
9+
This is useful for adding existing content files with TOML frontmatter to your project from another framework such as Hugo.
10+
11+
TOML frontmatter can also be used with [content collections](https://docs.astro.build/guides/content-collections/), and files with different frontmatter languages can live together in the same project.
12+
13+
No configuration is required to use TOML frontmatter in your content files. Your delimiter will indicate your chosen frontmatter language:
14+
15+
```md
16+
+++
17+
date = 2025-01-30
18+
title = 'Use TOML frontmatter in Astro!'
19+
[author]
20+
name = 'Colin Bate'
21+
+++
22+
23+
# Support for TOML frontmatter is here!
24+
```

packages/markdown/remark/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
"remark-rehype": "^11.1.1",
4747
"remark-smartypants": "^3.0.2",
4848
"shiki": "^1.29.1",
49+
"smol-toml": "^1.3.1",
4950
"unified": "^11.0.5",
5051
"unist-util-remove-position": "^5.0.0",
5152
"unist-util-visit": "^5.0.0",

packages/markdown/remark/src/frontmatter.ts

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import yaml from 'js-yaml';
2+
import * as toml from 'smol-toml';
23

34
export function isFrontmatterValid(frontmatter: Record<string, any>) {
45
try {
@@ -10,15 +11,19 @@ export function isFrontmatterValid(frontmatter: Record<string, any>) {
1011
return typeof frontmatter === 'object' && frontmatter !== null;
1112
}
1213

13-
// Capture frontmatter wrapped with `---`, including any characters and new lines within it.
14-
// Only capture if `---` exists near the top of the file, including:
14+
// Capture frontmatter wrapped with `---` or `+++`, including any characters and new lines within it.
15+
// Only capture if `---` or `+++` exists near the top of the file, including:
1516
// 1. Start of file (including if has BOM encoding)
16-
// 2. Start of file with any whitespace (but `---` must still start on a new line)
17-
const frontmatterRE = /(?:^\uFEFF?|^\s*\n)---([\s\S]*?\n)---/;
17+
// 2. Start of file with any whitespace (but `---` or `+++` must still start on a new line)
18+
const frontmatterRE = /(?:^\uFEFF?|^\s*\n)(?:---|\+\+\+)([\s\S]*?\n)(?:---|\+\+\+)/;
19+
const frontmatterTypeRE = /(?:^\uFEFF?|^\s*\n)(---|\+\+\+)/;
1820
export function extractFrontmatter(code: string): string | undefined {
1921
return frontmatterRE.exec(code)?.[1];
2022
}
2123

24+
function getFrontmatterParser(code: string): [string, (str: string) => unknown] {
25+
return frontmatterTypeRE.exec(code)?.[1] === '+++' ? ['+++', toml.parse] : ['---', yaml.load];
26+
}
2227
export interface ParseFrontmatterOptions {
2328
/**
2429
* How the frontmatter should be handled in the returned `content` string.
@@ -47,8 +52,8 @@ export function parseFrontmatter(
4752
if (rawFrontmatter == null) {
4853
return { frontmatter: {}, rawFrontmatter: '', content: code };
4954
}
50-
51-
const parsed = yaml.load(rawFrontmatter);
55+
const [delims, parser] = getFrontmatterParser(code);
56+
const parsed = parser(rawFrontmatter);
5257
const frontmatter = (parsed && typeof parsed === 'object' ? parsed : {}) as Record<string, any>;
5358

5459
let content: string;
@@ -57,16 +62,16 @@ export function parseFrontmatter(
5762
content = code;
5863
break;
5964
case 'remove':
60-
content = code.replace(`---${rawFrontmatter}---`, '');
65+
content = code.replace(`${delims}${rawFrontmatter}${delims}`, '');
6166
break;
6267
case 'empty-with-spaces':
6368
content = code.replace(
64-
`---${rawFrontmatter}---`,
69+
`${delims}${rawFrontmatter}${delims}`,
6570
` ${rawFrontmatter.replace(/[^\r\n]/g, ' ')} `,
6671
);
6772
break;
6873
case 'empty-with-lines':
69-
content = code.replace(`---${rawFrontmatter}---`, rawFrontmatter.replace(/[^\r\n]/g, ''));
74+
content = code.replace(`${delims}${rawFrontmatter}${delims}`, rawFrontmatter.replace(/[^\r\n]/g, ''));
7075
break;
7176
}
7277

packages/markdown/remark/test/frontmatter.test.js

Lines changed: 93 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { extractFrontmatter, parseFrontmatter } from '../dist/index.js';
55
const bom = '\uFEFF';
66

77
describe('extractFrontmatter', () => {
8-
it('works', () => {
8+
it('handles YAML', () => {
99
const yaml = `\nfoo: bar\n`;
1010
assert.equal(extractFrontmatter(`---${yaml}---`), yaml);
1111
assert.equal(extractFrontmatter(`${bom}---${yaml}---`), yaml);
@@ -19,10 +19,25 @@ describe('extractFrontmatter', () => {
1919
assert.equal(extractFrontmatter(`---${yaml} ---`), undefined);
2020
assert.equal(extractFrontmatter(`text\n---${yaml}---\n\ncontent`), undefined);
2121
});
22+
23+
it('handles TOML', () => {
24+
const toml = `\nfoo = "bar"\n`;
25+
assert.equal(extractFrontmatter(`+++${toml}+++`), toml);
26+
assert.equal(extractFrontmatter(`${bom}+++${toml}+++`), toml);
27+
assert.equal(extractFrontmatter(`\n+++${toml}+++`), toml);
28+
assert.equal(extractFrontmatter(`\n \n+++${toml}+++`), toml);
29+
assert.equal(extractFrontmatter(`+++${toml}+++\ncontent`), toml);
30+
assert.equal(extractFrontmatter(`${bom}+++${toml}+++\ncontent`), toml);
31+
assert.equal(extractFrontmatter(`\n\n+++${toml}+++\n\ncontent`), toml);
32+
assert.equal(extractFrontmatter(`\n \n+++${toml}+++\n\ncontent`), toml);
33+
assert.equal(extractFrontmatter(` +++${toml}+++`), undefined);
34+
assert.equal(extractFrontmatter(`+++${toml} +++`), undefined);
35+
assert.equal(extractFrontmatter(`text\n+++${toml}+++\n\ncontent`), undefined);
36+
});
2237
});
2338

2439
describe('parseFrontmatter', () => {
25-
it('works', () => {
40+
it('works for YAML', () => {
2641
const yaml = `\nfoo: bar\n`;
2742
assert.deepEqual(parseFrontmatter(`---${yaml}---`), {
2843
frontmatter: { foo: 'bar' },
@@ -81,7 +96,66 @@ describe('parseFrontmatter', () => {
8196
});
8297
});
8398

84-
it('frontmatter style', () => {
99+
it('works for TOML', () => {
100+
const toml = `\nfoo = "bar"\n`;
101+
assert.deepEqual(parseFrontmatter(`+++${toml}+++`), {
102+
frontmatter: { foo: 'bar' },
103+
rawFrontmatter: toml,
104+
content: '',
105+
});
106+
assert.deepEqual(parseFrontmatter(`${bom}+++${toml}+++`), {
107+
frontmatter: { foo: 'bar' },
108+
rawFrontmatter: toml,
109+
content: bom,
110+
});
111+
assert.deepEqual(parseFrontmatter(`\n+++${toml}+++`), {
112+
frontmatter: { foo: 'bar' },
113+
rawFrontmatter: toml,
114+
content: '\n',
115+
});
116+
assert.deepEqual(parseFrontmatter(`\n \n+++${toml}+++`), {
117+
frontmatter: { foo: 'bar' },
118+
rawFrontmatter: toml,
119+
content: '\n \n',
120+
});
121+
assert.deepEqual(parseFrontmatter(`+++${toml}+++\ncontent`), {
122+
frontmatter: { foo: 'bar' },
123+
rawFrontmatter: toml,
124+
content: '\ncontent',
125+
});
126+
assert.deepEqual(parseFrontmatter(`${bom}+++${toml}+++\ncontent`), {
127+
frontmatter: { foo: 'bar' },
128+
rawFrontmatter: toml,
129+
content: `${bom}\ncontent`,
130+
});
131+
assert.deepEqual(parseFrontmatter(`\n\n+++${toml}+++\n\ncontent`), {
132+
frontmatter: { foo: 'bar' },
133+
rawFrontmatter: toml,
134+
content: '\n\n\n\ncontent',
135+
});
136+
assert.deepEqual(parseFrontmatter(`\n \n+++${toml}+++\n\ncontent`), {
137+
frontmatter: { foo: 'bar' },
138+
rawFrontmatter: toml,
139+
content: '\n \n\n\ncontent',
140+
});
141+
assert.deepEqual(parseFrontmatter(` +++${toml}+++`), {
142+
frontmatter: {},
143+
rawFrontmatter: '',
144+
content: ` +++${toml}+++`,
145+
});
146+
assert.deepEqual(parseFrontmatter(`+++${toml} +++`), {
147+
frontmatter: {},
148+
rawFrontmatter: '',
149+
content: `+++${toml} +++`,
150+
});
151+
assert.deepEqual(parseFrontmatter(`text\n+++${toml}+++\n\ncontent`), {
152+
frontmatter: {},
153+
rawFrontmatter: '',
154+
content: `text\n+++${toml}+++\n\ncontent`,
155+
});
156+
});
157+
158+
it('frontmatter style for YAML', () => {
85159
const yaml = `\nfoo: bar\n`;
86160
const parse1 = (style) => parseFrontmatter(`---${yaml}---`, { frontmatter: style }).content;
87161
assert.deepEqual(parse1('preserve'), `---${yaml}---`);
@@ -96,4 +170,20 @@ describe('parseFrontmatter', () => {
96170
assert.deepEqual(parse2('empty-with-spaces'), `\n \n \n \n \n\ncontent`);
97171
assert.deepEqual(parse2('empty-with-lines'), `\n \n\n\n\n\ncontent`);
98172
});
173+
174+
it('frontmatter style for TOML', () => {
175+
const toml = `\nfoo = "bar"\n`;
176+
const parse1 = (style) => parseFrontmatter(`+++${toml}+++`, { frontmatter: style }).content;
177+
assert.deepEqual(parse1('preserve'), `+++${toml}+++`);
178+
assert.deepEqual(parse1('remove'), '');
179+
assert.deepEqual(parse1('empty-with-spaces'), ` \n \n `);
180+
assert.deepEqual(parse1('empty-with-lines'), `\n\n`);
181+
182+
const parse2 = (style) =>
183+
parseFrontmatter(`\n \n+++${toml}+++\n\ncontent`, { frontmatter: style }).content;
184+
assert.deepEqual(parse2('preserve'), `\n \n+++${toml}+++\n\ncontent`);
185+
assert.deepEqual(parse2('remove'), '\n \n\n\ncontent');
186+
assert.deepEqual(parse2('empty-with-spaces'), `\n \n \n \n \n\ncontent`);
187+
assert.deepEqual(parse2('empty-with-lines'), `\n \n\n\n\n\ncontent`);
188+
});
99189
});

pnpm-lock.yaml

Lines changed: 9 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)