diff --git a/.changeset/lazy-pandas-love.md b/.changeset/lazy-pandas-love.md new file mode 100644 index 000000000000..1549d8bcecc2 --- /dev/null +++ b/.changeset/lazy-pandas-love.md @@ -0,0 +1,7 @@ +--- +'@astrojs/markdown-remark': minor +--- + +Add support for TOML frontmatter in .md and .mdx files + +Uses the standard +++ as a delimiter diff --git a/packages/markdown/remark/package.json b/packages/markdown/remark/package.json index 2d133873b544..410705c7ab0c 100644 --- a/packages/markdown/remark/package.json +++ b/packages/markdown/remark/package.json @@ -46,6 +46,7 @@ "remark-rehype": "^11.1.1", "remark-smartypants": "^3.0.2", "shiki": "^1.23.1", + "smol-toml": "^1.3.1", "unified": "^11.0.5", "unist-util-remove-position": "^5.0.0", "unist-util-visit": "^5.0.0", diff --git a/packages/markdown/remark/src/frontmatter.ts b/packages/markdown/remark/src/frontmatter.ts index fe878df662b5..e641effca359 100644 --- a/packages/markdown/remark/src/frontmatter.ts +++ b/packages/markdown/remark/src/frontmatter.ts @@ -1,4 +1,5 @@ import yaml from 'js-yaml'; +import * as toml from 'smol-toml'; export function isFrontmatterValid(frontmatter: Record) { try { @@ -10,15 +11,19 @@ export function isFrontmatterValid(frontmatter: Record) { return typeof frontmatter === 'object' && frontmatter !== null; } -// Capture frontmatter wrapped with `---`, including any characters and new lines within it. -// Only capture if `---` exists near the top of the file, including: +// Capture frontmatter wrapped with `---` or `+++`, including any characters and new lines within it. +// Only capture if `---` or `+++` exists near the top of the file, including: // 1. Start of file (including if has BOM encoding) -// 2. Start of file with any whitespace (but `---` must still start on a new line) -const frontmatterRE = /(?:^\uFEFF?|^\s*\n)---([\s\S]*?\n)---/; +// 2. Start of file with any whitespace (but `---` or `+++` must still start on a new line) +const frontmatterRE = /(?:^\uFEFF?|^\s*\n)(?:---|\+\+\+)([\s\S]*?\n)(?:---|\+\+\+)/; +const frontmatterTypeRE = /(?:^\uFEFF?|^\s*\n)(---|\+\+\+)/; export function extractFrontmatter(code: string): string | undefined { return frontmatterRE.exec(code)?.[1]; } +function getFrontmatterParser(code: string): [string, (str: string) => unknown] { + return frontmatterTypeRE.exec(code)?.[1] === '+++' ? ['+++', toml.parse] : ['---', yaml.load]; +} export interface ParseFrontmatterOptions { /** * How the frontmatter should be handled in the returned `content` string. @@ -47,8 +52,8 @@ export function parseFrontmatter( if (rawFrontmatter == null) { return { frontmatter: {}, rawFrontmatter: '', content: code }; } - - const parsed = yaml.load(rawFrontmatter); + const [delims, parser] = getFrontmatterParser(code); + const parsed = parser(rawFrontmatter); const frontmatter = (parsed && typeof parsed === 'object' ? parsed : {}) as Record; let content: string; @@ -57,16 +62,16 @@ export function parseFrontmatter( content = code; break; case 'remove': - content = code.replace(`---${rawFrontmatter}---`, ''); + content = code.replace(`${delims}${rawFrontmatter}${delims}`, ''); break; case 'empty-with-spaces': content = code.replace( - `---${rawFrontmatter}---`, + `${delims}${rawFrontmatter}${delims}`, ` ${rawFrontmatter.replace(/[^\r\n]/g, ' ')} `, ); break; case 'empty-with-lines': - content = code.replace(`---${rawFrontmatter}---`, rawFrontmatter.replace(/[^\r\n]/g, '')); + content = code.replace(`${delims}${rawFrontmatter}${delims}`, rawFrontmatter.replace(/[^\r\n]/g, '')); break; } diff --git a/packages/markdown/remark/test/frontmatter.test.js b/packages/markdown/remark/test/frontmatter.test.js index 155368e59f2e..336245106d1e 100644 --- a/packages/markdown/remark/test/frontmatter.test.js +++ b/packages/markdown/remark/test/frontmatter.test.js @@ -5,7 +5,7 @@ import { extractFrontmatter, parseFrontmatter } from '../dist/index.js'; const bom = '\uFEFF'; describe('extractFrontmatter', () => { - it('works', () => { + it('handles YAML', () => { const yaml = `\nfoo: bar\n`; assert.equal(extractFrontmatter(`---${yaml}---`), yaml); assert.equal(extractFrontmatter(`${bom}---${yaml}---`), yaml); @@ -19,10 +19,25 @@ describe('extractFrontmatter', () => { assert.equal(extractFrontmatter(`---${yaml} ---`), undefined); assert.equal(extractFrontmatter(`text\n---${yaml}---\n\ncontent`), undefined); }); + + it('handles TOML', () => { + const toml = `\nfoo = "bar"\n`; + assert.equal(extractFrontmatter(`+++${toml}+++`), toml); + assert.equal(extractFrontmatter(`${bom}+++${toml}+++`), toml); + assert.equal(extractFrontmatter(`\n+++${toml}+++`), toml); + assert.equal(extractFrontmatter(`\n \n+++${toml}+++`), toml); + assert.equal(extractFrontmatter(`+++${toml}+++\ncontent`), toml); + assert.equal(extractFrontmatter(`${bom}+++${toml}+++\ncontent`), toml); + assert.equal(extractFrontmatter(`\n\n+++${toml}+++\n\ncontent`), toml); + assert.equal(extractFrontmatter(`\n \n+++${toml}+++\n\ncontent`), toml); + assert.equal(extractFrontmatter(` +++${toml}+++`), undefined); + assert.equal(extractFrontmatter(`+++${toml} +++`), undefined); + assert.equal(extractFrontmatter(`text\n+++${toml}+++\n\ncontent`), undefined); + }); }); describe('parseFrontmatter', () => { - it('works', () => { + it('works for YAML', () => { const yaml = `\nfoo: bar\n`; assert.deepEqual(parseFrontmatter(`---${yaml}---`), { frontmatter: { foo: 'bar' }, @@ -81,7 +96,66 @@ describe('parseFrontmatter', () => { }); }); - it('frontmatter style', () => { + it('works for TOML', () => { + const toml = `\nfoo = "bar"\n`; + assert.deepEqual(parseFrontmatter(`+++${toml}+++`), { + frontmatter: { foo: 'bar' }, + rawFrontmatter: toml, + content: '', + }); + assert.deepEqual(parseFrontmatter(`${bom}+++${toml}+++`), { + frontmatter: { foo: 'bar' }, + rawFrontmatter: toml, + content: bom, + }); + assert.deepEqual(parseFrontmatter(`\n+++${toml}+++`), { + frontmatter: { foo: 'bar' }, + rawFrontmatter: toml, + content: '\n', + }); + assert.deepEqual(parseFrontmatter(`\n \n+++${toml}+++`), { + frontmatter: { foo: 'bar' }, + rawFrontmatter: toml, + content: '\n \n', + }); + assert.deepEqual(parseFrontmatter(`+++${toml}+++\ncontent`), { + frontmatter: { foo: 'bar' }, + rawFrontmatter: toml, + content: '\ncontent', + }); + assert.deepEqual(parseFrontmatter(`${bom}+++${toml}+++\ncontent`), { + frontmatter: { foo: 'bar' }, + rawFrontmatter: toml, + content: `${bom}\ncontent`, + }); + assert.deepEqual(parseFrontmatter(`\n\n+++${toml}+++\n\ncontent`), { + frontmatter: { foo: 'bar' }, + rawFrontmatter: toml, + content: '\n\n\n\ncontent', + }); + assert.deepEqual(parseFrontmatter(`\n \n+++${toml}+++\n\ncontent`), { + frontmatter: { foo: 'bar' }, + rawFrontmatter: toml, + content: '\n \n\n\ncontent', + }); + assert.deepEqual(parseFrontmatter(` +++${toml}+++`), { + frontmatter: {}, + rawFrontmatter: '', + content: ` +++${toml}+++`, + }); + assert.deepEqual(parseFrontmatter(`+++${toml} +++`), { + frontmatter: {}, + rawFrontmatter: '', + content: `+++${toml} +++`, + }); + assert.deepEqual(parseFrontmatter(`text\n+++${toml}+++\n\ncontent`), { + frontmatter: {}, + rawFrontmatter: '', + content: `text\n+++${toml}+++\n\ncontent`, + }); + }); + + it('frontmatter style for YAML', () => { const yaml = `\nfoo: bar\n`; const parse1 = (style) => parseFrontmatter(`---${yaml}---`, { frontmatter: style }).content; assert.deepEqual(parse1('preserve'), `---${yaml}---`); @@ -96,4 +170,20 @@ describe('parseFrontmatter', () => { assert.deepEqual(parse2('empty-with-spaces'), `\n \n \n \n \n\ncontent`); assert.deepEqual(parse2('empty-with-lines'), `\n \n\n\n\n\ncontent`); }); + + it('frontmatter style for TOML', () => { + const toml = `\nfoo = "bar"\n`; + const parse1 = (style) => parseFrontmatter(`+++${toml}+++`, { frontmatter: style }).content; + assert.deepEqual(parse1('preserve'), `+++${toml}+++`); + assert.deepEqual(parse1('remove'), ''); + assert.deepEqual(parse1('empty-with-spaces'), ` \n \n `); + assert.deepEqual(parse1('empty-with-lines'), `\n\n`); + + const parse2 = (style) => + parseFrontmatter(`\n \n+++${toml}+++\n\ncontent`, { frontmatter: style }).content; + assert.deepEqual(parse2('preserve'), `\n \n+++${toml}+++\n\ncontent`); + assert.deepEqual(parse2('remove'), '\n \n\n\ncontent'); + assert.deepEqual(parse2('empty-with-spaces'), `\n \n \n \n \n\ncontent`); + assert.deepEqual(parse2('empty-with-lines'), `\n \n\n\n\n\ncontent`); + }); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9f8205778d08..5b4fdeff3b71 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5420,6 +5420,9 @@ importers: shiki: specifier: ^1.23.1 version: 1.24.0 + smol-toml: + specifier: ^1.3.1 + version: 1.3.1 unified: specifier: ^11.0.5 version: 11.0.5 @@ -10573,6 +10576,10 @@ packages: resolution: {integrity: sha512-TzobUYoEft/xBtb2voRPryAUIvYguG0V7Tt3de79I1WfXgCwelqVsGuZSnu3GFGRZhXR90AeEYIM+icuB/S06Q==} hasBin: true + smol-toml@1.3.1: + resolution: {integrity: sha512-tEYNll18pPKHroYSmLLrksq233j021G0giwW7P3D24jC54pQ5W5BXMsQ/Mvw1OJCmEYDgY+lrzT+3nNUtoNfXQ==} + engines: {node: '>= 18'} + solid-js@1.9.3: resolution: {integrity: sha512-5ba3taPoZGt9GY3YlsCB24kCg0Lv/rie/HTD4kG6h4daZZz7+yK02xn8Vx8dLYBc9i6Ps5JwAbEiqjmKaLB3Ag==} @@ -17362,6 +17369,8 @@ snapshots: smartypants@0.2.2: {} + smol-toml@1.3.1: {} + solid-js@1.9.3: dependencies: csstype: 3.1.3