Skip to content

Commit

Permalink
normalizeFrontMatter
Browse files Browse the repository at this point in the history
  • Loading branch information
mbostock committed Mar 24, 2024
1 parent 885534d commit bf91528
Show file tree
Hide file tree
Showing 35 changed files with 209 additions and 124 deletions.
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;
}
81 changes: 10 additions & 71 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 All @@ -43,15 +45,6 @@ export interface ParseContext {
path: string;
}

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

function uniqueCodeId(context: ParseContext, content: string): string {
const hash = createHash("sha256").update(content).digest("hex").slice(0, 8);
let id = hash;
Expand Down Expand Up @@ -335,7 +328,8 @@ export function createMarkdownIt({

export function parseMarkdown(input: string, options: ParseOptions): MarkdownPage {
const {md, path} = options;
const {content, data} = getContents(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 @@ -345,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 @@ -355,63 +349,14 @@ 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
};
}

function getContents(input: string): {content: string; data: FrontMatter & {[key: string]: any}} {
try {
const {data, content} = matter(input, {});
if ("title" in data) data.title = normalizeString(data.title);
if ("toc" in data) data.toc = normalizeToc(data.toc);
if ("index" in data) data.index = normalizeBoolean(data.index, "index");
if ("keywords" in data) data.keywords = normalizeStringArray(data.keywords);
if ("draft" in data) data.draft = normalizeBoolean(data.draft, "draft");
if ("sql" in data) data.sql = normalizeSqlData(data.sql);
return {data, content};
} catch (error) {
return {
data: {},
content: '<div class="observablehq--inspect observablehq--error">Invalid front matter</div>'
};
}
}

function normalizeString(value: any): string {
return value == null ? "" : String(value);
}

function normalizeToc(toc: any): FrontMatter["toc"] {
if (toc == null || typeof toc === "boolean") return toc;
if (typeof toc !== "object" || Array.isArray(toc)) console.warn(`Invalid toc format: ${toc}`);
if ("show" in toc) toc.show = normalizeBoolean(toc.show, "toc.show");
if ("label" in toc) toc.label = normalizeString(toc.label);
return toc;
}

function normalizeBoolean(value: any, name: string): boolean {
if (typeof value !== "boolean")
console.warn(`the ${name} option should be boolean, ${value} (${typeof value} found instead)`);
return Boolean(value);
}

function normalizeStringArray(value: any): string[] {
return value == null ? [] : typeof value === "string" ? [value] : Array.from(value, String);
}

function normalizeSqlData(sql: any): {[key: string]: string} {
if (!sql || typeof sql !== "object" || Array.isArray(sql)) return console.warn("Unsupported sql definition", sql), {};
const tables = new Map<string, string>();
for (const [name, source] of Object.entries(sql)) {
if (typeof source !== "string") return console.warn("Unsupported sql table source definition", source), {};
tables.set(name, source);
}
return Object.fromEntries(tables);
}

function getHtml(
key: "head" | "header" | "footer",
data: Record<string, any>,
Expand All @@ -426,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 @@ -441,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});
});
});

0 comments on commit bf91528

Please sign in to comment.