Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

validate front matter #1054

Merged
merged 8 commits into from
Mar 25, 2024
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ export async function build(
const start = performance.now();
const source = await readFile(sourcePath, "utf8");
const page = parseMarkdown(source, options);
if (page?.data?.draft) {
if (page.data.draft) {
effects.logger.log(faint("(skipped)"));
continue;
}
Expand Down
27 changes: 16 additions & 11 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,10 +104,10 @@ function readPages(root: string, md: MarkdownIt): Page[] {
if (cachedPages?.key === key) return cachedPages.pages;
const pages: Page[] = [];
for (const {file, source} of files) {
const parsed = parseMarkdownMetadata(source, {path: file, md});
if (parsed?.data?.draft) continue;
const {data, title} = parseMarkdownMetadata(source, {path: file, md});
if (data.draft) continue;
const name = basename(file, ".md");
const page = {path: join("/", dirname(file), name), name: parsed.title ?? "Untitled"};
const page = {path: join("/", dirname(file), name), name: title ?? "Untitled"};
if (name === "index") pages.unshift(page);
else pages.push(page);
}
Expand Down Expand Up @@ -199,7 +199,7 @@ function normalizeBase(base: any): string {
return base;
}

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

Expand Down Expand Up @@ -254,19 +254,24 @@ function normalizeToc(spec: any): TableOfContents {
return {label, show};
}

export function mergeToc(spec: any, toc: TableOfContents): TableOfContents {
let {label = toc.label, show = toc.show} = typeof spec !== "object" ? {show: spec} : spec ?? {};
label = String(label);
show = Boolean(show);
export function mergeToc(spec: Partial<TableOfContents> = {}, toc: TableOfContents): TableOfContents {
const {label = toc.label, show = toc.show} = spec;
return {label, show};
}

export function mergeStyle(path: string, style: any, theme: any, defaultStyle: null | Style): null | Style {
export function mergeStyle(
path: string,
style: string | null | undefined,
theme: string[] | undefined,
defaultStyle: null | Style
): null | Style {
return style === undefined && theme === undefined
? defaultStyle
: style === null
? null // disable
: style !== undefined
? {path: resolvePath(path, String(style))}
: {theme: normalizeTheme(theme)};
? {path: resolvePath(path, style)}
: theme === undefined
? defaultStyle
: {theme};
}
54 changes: 54 additions & 0 deletions src/frontMatter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import {normalizeTheme} from "./config.js";

export interface FrontMatter {
title?: string | null;
toc?: {show?: boolean; label?: string};
style?: string | null;
theme?: string[];
index?: boolean;
keywords?: string[];
draft?: boolean;
sidebar?: boolean;
sql?: {[key: string]: string};
}

export function normalizeFrontMatter(spec: any = {}): FrontMatter {
const frontMatter: FrontMatter = {};
if (spec == null || typeof spec !== "object") return frontMatter;
const {title, sidebar, toc, index, keywords, draft, sql, style, theme} = spec;
if (title !== undefined) frontMatter.title = stringOrNull(title);
if (sidebar !== undefined) frontMatter.sidebar = Boolean(sidebar);
if (toc !== undefined) frontMatter.toc = normalizeToc(toc);
if (index !== undefined) frontMatter.index = Boolean(index);
if (keywords !== undefined) frontMatter.keywords = normalizeKeywords(keywords);
if (draft !== undefined) frontMatter.draft = Boolean(draft);
if (sql !== undefined) frontMatter.sql = normalizeSql(sql);
if (style !== undefined) frontMatter.style = stringOrNull(style);
if (theme !== undefined) frontMatter.theme = normalizeTheme(theme);
return frontMatter;
}

function stringOrNull(spec: unknown): string | null {
return spec == null ? null : String(spec);
}

function normalizeToc(spec: unknown): {show?: boolean; label?: string} {
if (spec == null) return {show: false};
if (typeof spec !== "object") return {show: Boolean(spec)};
const {show, label} = spec as {show: unknown; label: unknown};
const toc: FrontMatter["toc"] = {};
if (show !== undefined) toc.show = Boolean(show);
if (label !== undefined) toc.label = String(label);
return toc;
}

function normalizeKeywords(spec: unknown): string[] {
return spec == null ? [] : typeof spec === "string" ? [spec] : Array.from(spec as any, String);
}

function normalizeSql(spec: unknown): {[key: string]: string} {
if (spec == null || typeof spec !== "object") return {};
const sql: {[key: string]: string} = {};
for (const key in spec) sql[key] = String(spec[key]);
return sql;
}
22 changes: 10 additions & 12 deletions src/markdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import type {RenderRule} from "markdown-it/lib/renderer.js";
import MarkdownItAnchor from "markdown-it-anchor";
import type {Config} from "./config.js";
import {mergeStyle} from "./config.js";
import type {FrontMatter} from "./frontMatter.js";
import {normalizeFrontMatter} from "./frontMatter.js";
import {rewriteHtmlPaths} from "./html.js";
import {parseInfo} from "./info.js";
import type {JavaScriptNode} from "./javascript/parse.js";
Expand All @@ -31,7 +33,7 @@ export interface MarkdownPage {
header: string | null;
body: string;
footer: string | null;
data: {[key: string]: any} | null;
data: FrontMatter;
style: string | null;
code: MarkdownCode[];
}
Expand Down Expand Up @@ -326,7 +328,8 @@ export function createMarkdownIt({

export function parseMarkdown(input: string, options: ParseOptions): MarkdownPage {
const {md, path} = options;
const {content, data} = matter(input, {});
const {content, data: frontMatter} = matter(input, {});
const data = normalizeFrontMatter(frontMatter);
const code: MarkdownCode[] = [];
const context: ParseContext = {code, startLine: 0, currentLine: 0, path};
const tokens = md.parse(content, context);
Expand All @@ -336,7 +339,7 @@ export function parseMarkdown(input: string, options: ParseOptions): MarkdownPag
header: getHtml("header", data, options),
body,
footer: getHtml("footer", data, options),
data: isEmpty(data) ? null : data,
data,
title: data.title ?? findTitle(tokens) ?? null,
style: getStyle(data, options),
code
Expand All @@ -346,9 +349,10 @@ export function parseMarkdown(input: string, options: ParseOptions): MarkdownPag
/** Like parseMarkdown, but optimized to return only metadata. */
export function parseMarkdownMetadata(input: string, options: ParseOptions): Pick<MarkdownPage, "data" | "title"> {
const {md, path} = options;
const {content, data} = matter(input, {});
const {content, data: frontMatter} = matter(input, {});
const data = normalizeFrontMatter(frontMatter);
return {
data: isEmpty(data) ? null : data,
data,
title: data.title ?? findTitle(md.parse(content, {code: [], startLine: 0, currentLine: 0, path})) ?? null
};
}
Expand All @@ -367,7 +371,7 @@ function getHtml(
: null;
}

function getStyle(data: Record<string, any>, {path, style = null}: ParseOptions): string | null {
function getStyle(data: FrontMatter, {path, style = null}: ParseOptions): string | null {
try {
style = mergeStyle(path, data.style, data.theme, style);
} catch (error) {
Expand All @@ -382,12 +386,6 @@ function getStyle(data: Record<string, any>, {path, style = null}: ParseOptions)
: `observablehq:theme-${style.theme.join(",")}.css`;
}

// TODO Use gray-matter’s parts.isEmpty, but only when it’s accurate.
function isEmpty(object) {
for (const key in object) return false;
return true;
}

// TODO Make this smarter.
function findTitle(tokens: ReturnType<MarkdownIt["parse"]>): string | undefined {
for (const [i, token] of tokens.entries()) {
Expand Down
2 changes: 1 addition & 1 deletion src/preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -435,7 +435,7 @@ function getFiles({files, resolveFile}: Resolvers): Map<string, string> {
}

function getTables({data}: MarkdownPage): Map<string, string> {
return new Map(Object.entries(data?.sql ?? {}));
return new Map(Object.entries(data.sql ?? {}));
}

type CodePatch = {removed: string[]; added: string[]};
Expand Down
9 changes: 4 additions & 5 deletions src/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,8 @@ export async function renderPage(page: MarkdownPage, options: RenderOptions & Re
const {data} = page;
const {base, path, title, preview} = options;
const {loaders, resolvers = await getResolvers(page, options)} = options;
const sidebar = data?.sidebar !== undefined ? Boolean(data.sidebar) : options.sidebar;
const toc = mergeToc(data?.toc, options.toc);
const draft = Boolean(data?.draft);
const {draft = false, sidebar = options.sidebar} = data;
const toc = mergeToc(data.toc, options.toc);
const {files, resolveFile, resolveImport} = resolvers;
return String(html`<!DOCTYPE html>
<meta charset="utf-8">${path === "/404" ? html`\n<base href="${preview ? "/" : base}">` : ""}
Expand Down Expand Up @@ -86,13 +85,13 @@ ${html.unsafe(rewriteHtml(page.body, resolvers))}</main>${renderFooter(page.foot
`);
}

function registerTables(sql: Record<string, any>, options: RenderOptions): string {
function registerTables(sql: Record<string, string>, options: RenderOptions): string {
return Object.entries(sql)
.map(([name, source]) => registerTable(name, source, options))
.join("\n");
}

function registerTable(name: string, source: any, {path}: RenderOptions): string {
function registerTable(name: string, source: string, {path}: RenderOptions): string {
return `registerTable(${JSON.stringify(name)}, ${
isAssetPath(source)
? `FileAttachment(${JSON.stringify(resolveRelativePath(path, source))})`
Expand Down
2 changes: 1 addition & 1 deletion src/resolvers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ export async function getResolvers(
}

// Add SQL sources.
if (page.data?.sql) {
if (page.data.sql) {
for (const source of Object.values(page.data.sql)) {
files.add(String(source));
}
Expand Down
10 changes: 4 additions & 6 deletions test/config-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,11 +178,9 @@ describe("mergeToc(spec, toc)", () => {
const toc = config({pages: [], toc: true}, root).toc;
assert.deepStrictEqual(mergeToc({show: false}, toc), {label: "Contents", show: false});
assert.deepStrictEqual(mergeToc({label: "On this page"}, toc), {label: "On this page", show: true});
assert.deepStrictEqual(mergeToc(false, toc), {label: "Contents", show: false});
assert.deepStrictEqual(mergeToc(true, toc), {label: "Contents", show: true});
assert.deepStrictEqual(mergeToc(undefined, toc), {label: "Contents", show: true});
assert.deepStrictEqual(mergeToc(null, toc), {label: "Contents", show: true});
assert.deepStrictEqual(mergeToc(0, toc), {label: "Contents", show: false});
assert.deepStrictEqual(mergeToc(1, toc), {label: "Contents", show: true});
assert.deepStrictEqual(mergeToc({label: undefined}, toc), {label: "Contents", show: true});
assert.deepStrictEqual(mergeToc({show: true}, toc), {label: "Contents", show: true});
assert.deepStrictEqual(mergeToc({show: undefined}, toc), {label: "Contents", show: true});
assert.deepStrictEqual(mergeToc({}, toc), {label: "Contents", show: true});
});
});
88 changes: 88 additions & 0 deletions test/frontMatter-test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import assert from "node:assert";
import {normalizeFrontMatter} from "../src/frontMatter.js";

describe("normalizeFrontMatter(spec)", () => {
it("returns the empty object for an undefined, null, empty spec", () => {
assert.deepStrictEqual(normalizeFrontMatter(), {});
assert.deepStrictEqual(normalizeFrontMatter(undefined), {});
assert.deepStrictEqual(normalizeFrontMatter(null), {});
assert.deepStrictEqual(normalizeFrontMatter(false), {});
assert.deepStrictEqual(normalizeFrontMatter(true), {});
assert.deepStrictEqual(normalizeFrontMatter({}), {});
assert.deepStrictEqual(normalizeFrontMatter(42), {});
});
it("coerces the title to a string or null", () => {
assert.deepStrictEqual(normalizeFrontMatter({title: 42}), {title: "42"});
assert.deepStrictEqual(normalizeFrontMatter({title: undefined}), {});
assert.deepStrictEqual(normalizeFrontMatter({title: null}), {title: null});
assert.deepStrictEqual(normalizeFrontMatter({title: ""}), {title: ""});
assert.deepStrictEqual(normalizeFrontMatter({title: "foo"}), {title: "foo"});
assert.deepStrictEqual(normalizeFrontMatter({title: {toString: () => "foo"}}), {title: "foo"});
});
it("coerces the toc to {show?, label?}", () => {
assert.deepStrictEqual(normalizeFrontMatter({toc: false}), {toc: {show: false}});
assert.deepStrictEqual(normalizeFrontMatter({toc: true}), {toc: {show: true}});
assert.deepStrictEqual(normalizeFrontMatter({toc: null}), {toc: {show: false}});
assert.deepStrictEqual(normalizeFrontMatter({toc: ""}), {toc: {show: false}});
assert.deepStrictEqual(normalizeFrontMatter({toc: 42}), {toc: {show: true}});
assert.deepStrictEqual(normalizeFrontMatter({toc: {}}), {toc: {}});
assert.deepStrictEqual(normalizeFrontMatter({toc: {show: 1}}), {toc: {show: true}});
assert.deepStrictEqual(normalizeFrontMatter({toc: {show: 0}}), {toc: {show: false}});
assert.deepStrictEqual(normalizeFrontMatter({toc: {show: null}}), {toc: {show: false}});
assert.deepStrictEqual(normalizeFrontMatter({toc: {show: undefined}}), {toc: {}});
assert.deepStrictEqual(normalizeFrontMatter({toc: {label: null}}), {toc: {label: "null"}});
assert.deepStrictEqual(normalizeFrontMatter({toc: {label: false}}), {toc: {label: "false"}});
assert.deepStrictEqual(normalizeFrontMatter({toc: {label: 42}}), {toc: {label: "42"}});
assert.deepStrictEqual(normalizeFrontMatter({toc: {label: {toString: () => "foo"}}}), {toc: {label: "foo"}});
});
it("coerces index to a boolean", () => {
assert.deepStrictEqual(normalizeFrontMatter({index: undefined}), {});
assert.deepStrictEqual(normalizeFrontMatter({index: null}), {index: false});
assert.deepStrictEqual(normalizeFrontMatter({index: 0}), {index: false});
assert.deepStrictEqual(normalizeFrontMatter({index: 1}), {index: true});
assert.deepStrictEqual(normalizeFrontMatter({index: true}), {index: true});
assert.deepStrictEqual(normalizeFrontMatter({index: false}), {index: false});
});
it("coerces sidebar to a boolean", () => {
assert.deepStrictEqual(normalizeFrontMatter({sidebar: undefined}), {});
assert.deepStrictEqual(normalizeFrontMatter({sidebar: null}), {sidebar: false});
assert.deepStrictEqual(normalizeFrontMatter({sidebar: 0}), {sidebar: false});
assert.deepStrictEqual(normalizeFrontMatter({sidebar: 1}), {sidebar: true});
assert.deepStrictEqual(normalizeFrontMatter({sidebar: true}), {sidebar: true});
assert.deepStrictEqual(normalizeFrontMatter({sidebar: false}), {sidebar: false});
});
it("coerces draft to a boolean", () => {
assert.deepStrictEqual(normalizeFrontMatter({draft: undefined}), {});
assert.deepStrictEqual(normalizeFrontMatter({draft: null}), {draft: false});
assert.deepStrictEqual(normalizeFrontMatter({draft: 0}), {draft: false});
assert.deepStrictEqual(normalizeFrontMatter({draft: 1}), {draft: true});
assert.deepStrictEqual(normalizeFrontMatter({draft: true}), {draft: true});
assert.deepStrictEqual(normalizeFrontMatter({draft: false}), {draft: false});
});
it("coerces keywords to an array of strings", () => {
assert.deepStrictEqual(normalizeFrontMatter({keywords: undefined}), {});
assert.deepStrictEqual(normalizeFrontMatter({keywords: null}), {keywords: []});
assert.deepStrictEqual(normalizeFrontMatter({keywords: []}), {keywords: []});
assert.deepStrictEqual(normalizeFrontMatter({keywords: [1, 2]}), {keywords: ["1", "2"]});
assert.deepStrictEqual(normalizeFrontMatter({keywords: "test"}), {keywords: ["test"]});
assert.deepStrictEqual(normalizeFrontMatter({keywords: ""}), {keywords: [""]});
assert.deepStrictEqual(normalizeFrontMatter({keywords: "foo, bar"}), {keywords: ["foo, bar"]});
assert.deepStrictEqual(normalizeFrontMatter({keywords: [1, "foo"]}), {keywords: ["1", "foo"]});
assert.deepStrictEqual(normalizeFrontMatter({keywords: new Set([1, "foo"])}), {keywords: ["1", "foo"]});
});
it("coerces sql to a Record<string, string>", () => {
assert.deepStrictEqual(normalizeFrontMatter({sql: undefined}), {});
assert.deepStrictEqual(normalizeFrontMatter({sql: null}), {sql: {}});
assert.deepStrictEqual(normalizeFrontMatter({sql: 0}), {sql: {}});
assert.deepStrictEqual(normalizeFrontMatter({sql: 1}), {sql: {}});
assert.deepStrictEqual(normalizeFrontMatter({sql: false}), {sql: {}});
assert.deepStrictEqual(normalizeFrontMatter({sql: {foo: 1}}), {sql: {foo: "1"}});
assert.deepStrictEqual(normalizeFrontMatter({sql: {foo: null}}), {sql: {foo: "null"}});
assert.deepStrictEqual(normalizeFrontMatter({sql: {foo: "bar"}}), {sql: {foo: "bar"}});
assert.deepStrictEqual(normalizeFrontMatter({sql: {foo: []}}), {sql: {foo: ""}});
assert.deepStrictEqual(normalizeFrontMatter({sql: {foo: {toString: () => "bar"}}}), {sql: {foo: "bar"}});
});
it("ignores unknown properties", () => {
assert.deepStrictEqual(normalizeFrontMatter({foo: 42}), {});
});
});
3 changes: 2 additions & 1 deletion test/input/yaml-frontmatter.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
---
title: YAML
style:
style: custom.css
keywords:
- one
- two
---
Expand Down
2 changes: 1 addition & 1 deletion test/output/block-expression.md.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"data": null,
"data": {},
"title": null,
"style": null,
"code": [
Expand Down
2 changes: 1 addition & 1 deletion test/output/comment.md.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"data": null,
"data": {},
"title": null,
"style": null,
"code": []
Expand Down
2 changes: 1 addition & 1 deletion test/output/dollar-expression.md.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"data": null,
"data": {},
"title": null,
"style": null,
"code": [
Expand Down
2 changes: 1 addition & 1 deletion test/output/dot-graphviz.md.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"data": null,
"data": {},
"title": null,
"style": null,
"code": [
Expand Down
2 changes: 1 addition & 1 deletion test/output/double-quote-expression.md.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"data": null,
"data": {},
"title": null,
"style": null,
"code": [
Expand Down
2 changes: 1 addition & 1 deletion test/output/embedded-expression.md.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"data": null,
"data": {},
"title": "Embedded expression",
"style": null,
"code": [
Expand Down
2 changes: 1 addition & 1 deletion test/output/escaped-expression.md.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"data": null,
"data": {},
"title": null,
"style": null,
"code": []
Expand Down
Loading