Skip to content

Commit 9d1e46f

Browse files
Filmbostock
andauthored
validate front matter (#1054)
* validate front matter * normalizeFrontMatter * fix title: null * pretty error in preview, crash on build * simplify * simplify 2 * treat invalid frontmatter as content --------- Co-authored-by: Mike Bostock <[email protected]>
1 parent c179b35 commit 9d1e46f

35 files changed

+230
-70
lines changed

src/build.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ export async function build(
7373
const start = performance.now();
7474
const source = await readFile(sourcePath, "utf8");
7575
const page = parseMarkdown(source, options);
76-
if (page?.data?.draft) {
76+
if (page.data.draft) {
7777
effects.logger.log(faint("(skipped)"));
7878
continue;
7979
}

src/config.ts

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -104,10 +104,10 @@ function readPages(root: string, md: MarkdownIt): Page[] {
104104
if (cachedPages?.key === key) return cachedPages.pages;
105105
const pages: Page[] = [];
106106
for (const {file, source} of files) {
107-
const parsed = parseMarkdownMetadata(source, {path: file, md});
108-
if (parsed?.data?.draft) continue;
107+
const {data, title} = parseMarkdownMetadata(source, {path: file, md});
108+
if (data.draft) continue;
109109
const name = basename(file, ".md");
110-
const page = {path: join("/", dirname(file), name), name: parsed.title ?? "Untitled"};
110+
const page = {path: join("/", dirname(file), name), name: title ?? "Untitled"};
111111
if (name === "index") pages.unshift(page);
112112
else pages.push(page);
113113
}
@@ -199,7 +199,7 @@ function normalizeBase(base: any): string {
199199
return base;
200200
}
201201

202-
function normalizeTheme(spec: any): string[] {
202+
export function normalizeTheme(spec: any): string[] {
203203
return resolveTheme(typeof spec === "string" ? [spec] : spec === null ? [] : Array.from(spec, String));
204204
}
205205

@@ -254,19 +254,24 @@ function normalizeToc(spec: any): TableOfContents {
254254
return {label, show};
255255
}
256256

257-
export function mergeToc(spec: any, toc: TableOfContents): TableOfContents {
258-
let {label = toc.label, show = toc.show} = typeof spec !== "object" ? {show: spec} : spec ?? {};
259-
label = String(label);
260-
show = Boolean(show);
257+
export function mergeToc(spec: Partial<TableOfContents> = {}, toc: TableOfContents): TableOfContents {
258+
const {label = toc.label, show = toc.show} = spec;
261259
return {label, show};
262260
}
263261

264-
export function mergeStyle(path: string, style: any, theme: any, defaultStyle: null | Style): null | Style {
262+
export function mergeStyle(
263+
path: string,
264+
style: string | null | undefined,
265+
theme: string[] | undefined,
266+
defaultStyle: null | Style
267+
): null | Style {
265268
return style === undefined && theme === undefined
266269
? defaultStyle
267270
: style === null
268271
? null // disable
269272
: style !== undefined
270-
? {path: resolvePath(path, String(style))}
271-
: {theme: normalizeTheme(theme)};
273+
? {path: resolvePath(path, style)}
274+
: theme === undefined
275+
? defaultStyle
276+
: {theme};
272277
}

src/frontMatter.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import matter from "gray-matter";
2+
import {normalizeTheme} from "./config.js";
3+
import {yellow} from "./tty.js";
4+
5+
export interface FrontMatter {
6+
title?: string | null;
7+
toc?: {show?: boolean; label?: string};
8+
style?: string | null;
9+
theme?: string[];
10+
index?: boolean;
11+
keywords?: string[];
12+
draft?: boolean;
13+
sidebar?: boolean;
14+
sql?: {[key: string]: string};
15+
}
16+
17+
export function readFrontMatter(input: string): {content: string; data: FrontMatter} {
18+
try {
19+
const {content, data} = matter(input, {});
20+
return {content, data: normalizeFrontMatter(data)};
21+
} catch (error: any) {
22+
if ("mark" in error) {
23+
console.warn(`${yellow("Invalid front matter")}: ${error.reason}`);
24+
return {data: {}, content: input};
25+
}
26+
throw error;
27+
}
28+
}
29+
30+
export function normalizeFrontMatter(spec: any = {}): FrontMatter {
31+
const frontMatter: FrontMatter = {};
32+
if (spec == null || typeof spec !== "object") return frontMatter;
33+
const {title, sidebar, toc, index, keywords, draft, sql, style, theme} = spec;
34+
if (title !== undefined) frontMatter.title = stringOrNull(title);
35+
if (sidebar !== undefined) frontMatter.sidebar = Boolean(sidebar);
36+
if (toc !== undefined) frontMatter.toc = normalizeToc(toc);
37+
if (index !== undefined) frontMatter.index = Boolean(index);
38+
if (keywords !== undefined) frontMatter.keywords = normalizeKeywords(keywords);
39+
if (draft !== undefined) frontMatter.draft = Boolean(draft);
40+
if (sql !== undefined) frontMatter.sql = normalizeSql(sql);
41+
if (style !== undefined) frontMatter.style = stringOrNull(style);
42+
if (theme !== undefined) frontMatter.theme = normalizeTheme(theme);
43+
return frontMatter;
44+
}
45+
46+
function stringOrNull(spec: unknown): string | null {
47+
return spec == null ? null : String(spec);
48+
}
49+
50+
function normalizeToc(spec: unknown): {show?: boolean; label?: string} {
51+
if (spec == null) return {show: false};
52+
if (typeof spec !== "object") return {show: Boolean(spec)};
53+
const {show, label} = spec as {show: unknown; label: unknown};
54+
const toc: FrontMatter["toc"] = {};
55+
if (show !== undefined) toc.show = Boolean(show);
56+
if (label !== undefined) toc.label = String(label);
57+
return toc;
58+
}
59+
60+
function normalizeKeywords(spec: unknown): string[] {
61+
return spec == null ? [] : typeof spec === "string" ? [spec] : Array.from(spec as any, String);
62+
}
63+
64+
function normalizeSql(spec: unknown): {[key: string]: string} {
65+
if (spec == null || typeof spec !== "object") return {};
66+
const sql: {[key: string]: string} = {};
67+
for (const key in spec) sql[key] = String(spec[key]);
68+
return sql;
69+
}

src/markdown.ts

Lines changed: 16 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
/* eslint-disable import/no-named-as-default-member */
22
import {createHash} from "node:crypto";
33
import {extname} from "node:path/posix";
4-
import matter from "gray-matter";
54
import he from "he";
65
import MarkdownIt from "markdown-it";
76
import type {RuleCore} from "markdown-it/lib/parser_core.js";
@@ -10,6 +9,8 @@ import type {RenderRule} from "markdown-it/lib/renderer.js";
109
import MarkdownItAnchor from "markdown-it-anchor";
1110
import type {Config} from "./config.js";
1211
import {mergeStyle} from "./config.js";
12+
import type {FrontMatter} from "./frontMatter.js";
13+
import {readFrontMatter} from "./frontMatter.js";
1314
import {rewriteHtmlPaths} from "./html.js";
1415
import {parseInfo} from "./info.js";
1516
import type {JavaScriptNode} from "./javascript/parse.js";
@@ -31,12 +32,12 @@ export interface MarkdownPage {
3132
header: string | null;
3233
body: string;
3334
footer: string | null;
34-
data: {[key: string]: any} | null;
35+
data: FrontMatter;
3536
style: string | null;
3637
code: MarkdownCode[];
3738
}
3839

39-
export interface ParseContext {
40+
interface ParseContext {
4041
code: MarkdownCode[];
4142
startLine: number;
4243
currentLine: number;
@@ -326,7 +327,7 @@ export function createMarkdownIt({
326327

327328
export function parseMarkdown(input: string, options: ParseOptions): MarkdownPage {
328329
const {md, path} = options;
329-
const {content, data} = matter(input, {});
330+
const {content, data} = readFrontMatter(input);
330331
const code: MarkdownCode[] = [];
331332
const context: ParseContext = {code, startLine: 0, currentLine: 0, path};
332333
const tokens = md.parse(content, context);
@@ -336,8 +337,8 @@ export function parseMarkdown(input: string, options: ParseOptions): MarkdownPag
336337
header: getHtml("header", data, options),
337338
body,
338339
footer: getHtml("footer", data, options),
339-
data: isEmpty(data) ? null : data,
340-
title: data.title ?? findTitle(tokens) ?? null,
340+
data,
341+
title: data.title !== undefined ? data.title : findTitle(tokens),
341342
style: getStyle(data, options),
342343
code
343344
};
@@ -346,10 +347,13 @@ export function parseMarkdown(input: string, options: ParseOptions): MarkdownPag
346347
/** Like parseMarkdown, but optimized to return only metadata. */
347348
export function parseMarkdownMetadata(input: string, options: ParseOptions): Pick<MarkdownPage, "data" | "title"> {
348349
const {md, path} = options;
349-
const {content, data} = matter(input, {});
350+
const {content, data} = readFrontMatter(input);
350351
return {
351-
data: isEmpty(data) ? null : data,
352-
title: data.title ?? findTitle(md.parse(content, {code: [], startLine: 0, currentLine: 0, path})) ?? null
352+
data,
353+
title:
354+
data.title !== undefined
355+
? data.title
356+
: findTitle(md.parse(content, {code: [], startLine: 0, currentLine: 0, path}))
353357
};
354358
}
355359

@@ -367,7 +371,7 @@ function getHtml(
367371
: null;
368372
}
369373

370-
function getStyle(data: Record<string, any>, {path, style = null}: ParseOptions): string | null {
374+
function getStyle(data: FrontMatter, {path, style = null}: ParseOptions): string | null {
371375
try {
372376
style = mergeStyle(path, data.style, data.theme, style);
373377
} catch (error) {
@@ -382,14 +386,8 @@ function getStyle(data: Record<string, any>, {path, style = null}: ParseOptions)
382386
: `observablehq:theme-${style.theme.join(",")}.css`;
383387
}
384388

385-
// TODO Use gray-matter’s parts.isEmpty, but only when it’s accurate.
386-
function isEmpty(object) {
387-
for (const key in object) return false;
388-
return true;
389-
}
390-
391389
// TODO Make this smarter.
392-
function findTitle(tokens: ReturnType<MarkdownIt["parse"]>): string | undefined {
390+
function findTitle(tokens: ReturnType<MarkdownIt["parse"]>): string | null {
393391
for (const [i, token] of tokens.entries()) {
394392
if (token.type === "heading_open" && token.tag === "h1") {
395393
const next = tokens[i + 1];
@@ -404,4 +402,5 @@ function findTitle(tokens: ReturnType<MarkdownIt["parse"]>): string | undefined
404402
}
405403
}
406404
}
405+
return null;
407406
}

src/preview.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -435,7 +435,7 @@ function getFiles({files, resolveFile}: Resolvers): Map<string, string> {
435435
}
436436

437437
function getTables({data}: MarkdownPage): Map<string, string> {
438-
return new Map(Object.entries(data?.sql ?? {}));
438+
return new Map(Object.entries(data.sql ?? {}));
439439
}
440440

441441
type CodePatch = {removed: string[]; added: string[]};

src/render.ts

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,8 @@ export async function renderPage(page: MarkdownPage, options: RenderOptions & Re
2727
const {data} = page;
2828
const {base, path, title, preview} = options;
2929
const {loaders, resolvers = await getResolvers(page, options)} = options;
30-
const sidebar = data?.sidebar !== undefined ? Boolean(data.sidebar) : options.sidebar;
31-
const toc = mergeToc(data?.toc, options.toc);
32-
const draft = Boolean(data?.draft);
30+
const {draft = false, sidebar = options.sidebar} = data;
31+
const toc = mergeToc(data.toc, options.toc);
3332
const {files, resolveFile, resolveImport} = resolvers;
3433
return String(html`<!DOCTYPE html>
3534
<meta charset="utf-8">${path === "/404" ? html`\n<base href="${preview ? "/" : base}">` : ""}
@@ -86,13 +85,13 @@ ${html.unsafe(rewriteHtml(page.body, resolvers))}</main>${renderFooter(page.foot
8685
`);
8786
}
8887

89-
function registerTables(sql: Record<string, any>, options: RenderOptions): string {
88+
function registerTables(sql: Record<string, string>, options: RenderOptions): string {
9089
return Object.entries(sql)
9190
.map(([name, source]) => registerTable(name, source, options))
9291
.join("\n");
9392
}
9493

95-
function registerTable(name: string, source: any, {path}: RenderOptions): string {
94+
function registerTable(name: string, source: string, {path}: RenderOptions): string {
9695
return `registerTable(${JSON.stringify(name)}, ${
9796
isAssetPath(source)
9897
? `FileAttachment(${JSON.stringify(resolveRelativePath(path, source))})`

src/resolvers.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ export async function getResolvers(
112112
}
113113

114114
// Add SQL sources.
115-
if (page.data?.sql) {
115+
if (page.data.sql) {
116116
for (const source of Object.values(page.data.sql)) {
117117
files.add(String(source));
118118
}

test/config-test.ts

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -178,11 +178,9 @@ describe("mergeToc(spec, toc)", () => {
178178
const toc = config({pages: [], toc: true}, root).toc;
179179
assert.deepStrictEqual(mergeToc({show: false}, toc), {label: "Contents", show: false});
180180
assert.deepStrictEqual(mergeToc({label: "On this page"}, toc), {label: "On this page", show: true});
181-
assert.deepStrictEqual(mergeToc(false, toc), {label: "Contents", show: false});
182-
assert.deepStrictEqual(mergeToc(true, toc), {label: "Contents", show: true});
183-
assert.deepStrictEqual(mergeToc(undefined, toc), {label: "Contents", show: true});
184-
assert.deepStrictEqual(mergeToc(null, toc), {label: "Contents", show: true});
185-
assert.deepStrictEqual(mergeToc(0, toc), {label: "Contents", show: false});
186-
assert.deepStrictEqual(mergeToc(1, toc), {label: "Contents", show: true});
181+
assert.deepStrictEqual(mergeToc({label: undefined}, toc), {label: "Contents", show: true});
182+
assert.deepStrictEqual(mergeToc({show: true}, toc), {label: "Contents", show: true});
183+
assert.deepStrictEqual(mergeToc({show: undefined}, toc), {label: "Contents", show: true});
184+
assert.deepStrictEqual(mergeToc({}, toc), {label: "Contents", show: true});
187185
});
188186
});

test/frontMatter-test.ts

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import assert from "node:assert";
2+
import {normalizeFrontMatter} from "../src/frontMatter.js";
3+
4+
describe("normalizeFrontMatter(spec)", () => {
5+
it("returns the empty object for an undefined, null, empty spec", () => {
6+
assert.deepStrictEqual(normalizeFrontMatter(), {});
7+
assert.deepStrictEqual(normalizeFrontMatter(undefined), {});
8+
assert.deepStrictEqual(normalizeFrontMatter(null), {});
9+
assert.deepStrictEqual(normalizeFrontMatter(false), {});
10+
assert.deepStrictEqual(normalizeFrontMatter(true), {});
11+
assert.deepStrictEqual(normalizeFrontMatter({}), {});
12+
assert.deepStrictEqual(normalizeFrontMatter(42), {});
13+
});
14+
it("coerces the title to a string or null", () => {
15+
assert.deepStrictEqual(normalizeFrontMatter({title: 42}), {title: "42"});
16+
assert.deepStrictEqual(normalizeFrontMatter({title: undefined}), {});
17+
assert.deepStrictEqual(normalizeFrontMatter({title: null}), {title: null});
18+
assert.deepStrictEqual(normalizeFrontMatter({title: ""}), {title: ""});
19+
assert.deepStrictEqual(normalizeFrontMatter({title: "foo"}), {title: "foo"});
20+
assert.deepStrictEqual(normalizeFrontMatter({title: {toString: () => "foo"}}), {title: "foo"});
21+
});
22+
it("coerces the toc to {show?, label?}", () => {
23+
assert.deepStrictEqual(normalizeFrontMatter({toc: false}), {toc: {show: false}});
24+
assert.deepStrictEqual(normalizeFrontMatter({toc: true}), {toc: {show: true}});
25+
assert.deepStrictEqual(normalizeFrontMatter({toc: null}), {toc: {show: false}});
26+
assert.deepStrictEqual(normalizeFrontMatter({toc: ""}), {toc: {show: false}});
27+
assert.deepStrictEqual(normalizeFrontMatter({toc: 42}), {toc: {show: true}});
28+
assert.deepStrictEqual(normalizeFrontMatter({toc: {}}), {toc: {}});
29+
assert.deepStrictEqual(normalizeFrontMatter({toc: {show: 1}}), {toc: {show: true}});
30+
assert.deepStrictEqual(normalizeFrontMatter({toc: {show: 0}}), {toc: {show: false}});
31+
assert.deepStrictEqual(normalizeFrontMatter({toc: {show: null}}), {toc: {show: false}});
32+
assert.deepStrictEqual(normalizeFrontMatter({toc: {show: undefined}}), {toc: {}});
33+
assert.deepStrictEqual(normalizeFrontMatter({toc: {label: null}}), {toc: {label: "null"}});
34+
assert.deepStrictEqual(normalizeFrontMatter({toc: {label: false}}), {toc: {label: "false"}});
35+
assert.deepStrictEqual(normalizeFrontMatter({toc: {label: 42}}), {toc: {label: "42"}});
36+
assert.deepStrictEqual(normalizeFrontMatter({toc: {label: {toString: () => "foo"}}}), {toc: {label: "foo"}});
37+
});
38+
it("coerces index to a boolean", () => {
39+
assert.deepStrictEqual(normalizeFrontMatter({index: undefined}), {});
40+
assert.deepStrictEqual(normalizeFrontMatter({index: null}), {index: false});
41+
assert.deepStrictEqual(normalizeFrontMatter({index: 0}), {index: false});
42+
assert.deepStrictEqual(normalizeFrontMatter({index: 1}), {index: true});
43+
assert.deepStrictEqual(normalizeFrontMatter({index: true}), {index: true});
44+
assert.deepStrictEqual(normalizeFrontMatter({index: false}), {index: false});
45+
});
46+
it("coerces sidebar to a boolean", () => {
47+
assert.deepStrictEqual(normalizeFrontMatter({sidebar: undefined}), {});
48+
assert.deepStrictEqual(normalizeFrontMatter({sidebar: null}), {sidebar: false});
49+
assert.deepStrictEqual(normalizeFrontMatter({sidebar: 0}), {sidebar: false});
50+
assert.deepStrictEqual(normalizeFrontMatter({sidebar: 1}), {sidebar: true});
51+
assert.deepStrictEqual(normalizeFrontMatter({sidebar: true}), {sidebar: true});
52+
assert.deepStrictEqual(normalizeFrontMatter({sidebar: false}), {sidebar: false});
53+
});
54+
it("coerces draft to a boolean", () => {
55+
assert.deepStrictEqual(normalizeFrontMatter({draft: undefined}), {});
56+
assert.deepStrictEqual(normalizeFrontMatter({draft: null}), {draft: false});
57+
assert.deepStrictEqual(normalizeFrontMatter({draft: 0}), {draft: false});
58+
assert.deepStrictEqual(normalizeFrontMatter({draft: 1}), {draft: true});
59+
assert.deepStrictEqual(normalizeFrontMatter({draft: true}), {draft: true});
60+
assert.deepStrictEqual(normalizeFrontMatter({draft: false}), {draft: false});
61+
});
62+
it("coerces keywords to an array of strings", () => {
63+
assert.deepStrictEqual(normalizeFrontMatter({keywords: undefined}), {});
64+
assert.deepStrictEqual(normalizeFrontMatter({keywords: null}), {keywords: []});
65+
assert.deepStrictEqual(normalizeFrontMatter({keywords: []}), {keywords: []});
66+
assert.deepStrictEqual(normalizeFrontMatter({keywords: [1, 2]}), {keywords: ["1", "2"]});
67+
assert.deepStrictEqual(normalizeFrontMatter({keywords: "test"}), {keywords: ["test"]});
68+
assert.deepStrictEqual(normalizeFrontMatter({keywords: ""}), {keywords: [""]});
69+
assert.deepStrictEqual(normalizeFrontMatter({keywords: "foo, bar"}), {keywords: ["foo, bar"]});
70+
assert.deepStrictEqual(normalizeFrontMatter({keywords: [1, "foo"]}), {keywords: ["1", "foo"]});
71+
assert.deepStrictEqual(normalizeFrontMatter({keywords: new Set([1, "foo"])}), {keywords: ["1", "foo"]});
72+
});
73+
it("coerces sql to a Record<string, string>", () => {
74+
assert.deepStrictEqual(normalizeFrontMatter({sql: undefined}), {});
75+
assert.deepStrictEqual(normalizeFrontMatter({sql: null}), {sql: {}});
76+
assert.deepStrictEqual(normalizeFrontMatter({sql: 0}), {sql: {}});
77+
assert.deepStrictEqual(normalizeFrontMatter({sql: 1}), {sql: {}});
78+
assert.deepStrictEqual(normalizeFrontMatter({sql: false}), {sql: {}});
79+
assert.deepStrictEqual(normalizeFrontMatter({sql: {foo: 1}}), {sql: {foo: "1"}});
80+
assert.deepStrictEqual(normalizeFrontMatter({sql: {foo: null}}), {sql: {foo: "null"}});
81+
assert.deepStrictEqual(normalizeFrontMatter({sql: {foo: "bar"}}), {sql: {foo: "bar"}});
82+
assert.deepStrictEqual(normalizeFrontMatter({sql: {foo: []}}), {sql: {foo: ""}});
83+
assert.deepStrictEqual(normalizeFrontMatter({sql: {foo: {toString: () => "bar"}}}), {sql: {foo: "bar"}});
84+
});
85+
it("ignores unknown properties", () => {
86+
assert.deepStrictEqual(normalizeFrontMatter({foo: 42}), {});
87+
});
88+
});

test/input/yaml-frontmatter.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
---
22
title: YAML
3-
style:
3+
style: custom.css
4+
keywords:
45
- one
56
- two
67
---

0 commit comments

Comments
 (0)