Skip to content

Commit 34e284e

Browse files
committed
head, header and footer can be specified as a function
that receives the page’s meta data (path, title and unrestricted front matter), and returns an HTML fragment (string). closes #56 partially addresses #1036
1 parent 90b23be commit 34e284e

File tree

8 files changed

+76
-14
lines changed

8 files changed

+76
-14
lines changed

docs/config.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,8 @@ An HTML fragment to add to the header. Defaults to the empty string.
162162

163163
An HTML fragment to add to the footer. Defaults to “Built with Observable.”
164164

165+
head, header and footer can be specified as strings, or as functions that receive the page’s meta data (path, title and front matter values) as the first argument, and return a string.
166+
165167
## scripts
166168

167169
Additional scripts to add to the head, such as for analytics. Unlike the **head** option, this allows you to reference a local script in the source root.

src/config.ts

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import type MarkdownIt from "markdown-it";
99
import {LoaderResolver} from "./dataloader.js";
1010
import {visitMarkdownFiles} from "./files.js";
1111
import {formatIsoDate, formatLocaleDate} from "./format.js";
12+
import type {FrontMatter} from "./frontMatter.js";
1213
import {createMarkdownIt, parseMarkdownMetadata} from "./markdown.js";
1314
import {isAssetPath, parseRelativeUrl, resolvePath} from "./path.js";
1415
import {resolveTheme} from "./theme.js";
@@ -40,6 +41,11 @@ export interface Script {
4041
type: string | null;
4142
}
4243

44+
/**
45+
* A function that generates a page fragment such as head, header or footer.
46+
*/
47+
type PageFragmentFunction = (meta: FrontMatter & {title: string | null; path: string}) => string;
48+
4349
export interface Config {
4450
root: string; // defaults to docs
4551
output: string; // defaults to dist
@@ -49,9 +55,9 @@ export interface Config {
4955
pages: (Page | Section<Page>)[];
5056
pager: boolean; // defaults to true
5157
scripts: Script[]; // defaults to empty array
52-
head: string | null; // defaults to null
53-
header: string | null; // defaults to null
54-
footer: string | null; // defaults to “Built with Observable on [date].”
58+
head: string | PageFragmentFunction | null; // defaults to null
59+
header: string | PageFragmentFunction | null; // defaults to null
60+
footer: string | PageFragmentFunction | null; // defaults to “Built with Observable on [date].”
5561
toc: TableOfContents;
5662
style: null | Style; // defaults to {theme: ["light", "dark"]}
5763
deploy: null | {workspace: string; project: string};
@@ -161,9 +167,9 @@ export function normalizeConfig(spec: any = {}, defaultRoot = "docs", watchPath?
161167
if (sidebar !== undefined) sidebar = Boolean(sidebar);
162168
pager = Boolean(pager);
163169
scripts = Array.from(scripts, normalizeScript);
164-
head = stringOrNull(head);
165-
header = stringOrNull(header);
166-
footer = stringOrNull(footer);
170+
head = pageFragment(head);
171+
header = pageFragment(header);
172+
footer = pageFragment(footer);
167173
toc = normalizeToc(toc);
168174
deploy = deploy ? {workspace: String(deploy.workspace).replace(/^@+/, ""), project: String(deploy.project)} : null;
169175
search = Boolean(search);
@@ -281,6 +287,10 @@ export function mergeStyle(
281287
: {theme};
282288
}
283289

290+
function pageFragment(spec: unknown) {
291+
return typeof spec === "function" ? spec : stringOrNull(spec);
292+
}
293+
284294
export function stringOrNull(spec: unknown): string | null {
285295
return spec == null || spec === false ? null : String(spec);
286296
}

src/frontMatter.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export interface FrontMatter {
1515
draft?: boolean;
1616
sidebar?: boolean;
1717
sql?: {[key: string]: string};
18+
[key: string]: any;
1819
}
1920

2021
export function readFrontMatter(input: string): {content: string; data: FrontMatter} {
@@ -33,7 +34,7 @@ export function readFrontMatter(input: string): {content: string; data: FrontMat
3334
export function normalizeFrontMatter(spec: any = {}): FrontMatter {
3435
const frontMatter: FrontMatter = {};
3536
if (spec == null || typeof spec !== "object") return frontMatter;
36-
const {title, sidebar, toc, index, keywords, draft, sql, head, header, footer, style, theme} = spec;
37+
const {title, sidebar, toc, index, keywords, draft, sql, head, header, footer, style, theme, ...rest} = spec;
3738
if (title !== undefined) frontMatter.title = stringOrNull(title);
3839
if (sidebar !== undefined) frontMatter.sidebar = Boolean(sidebar);
3940
if (toc !== undefined) frontMatter.toc = normalizeToc(toc);
@@ -46,7 +47,7 @@ export function normalizeFrontMatter(spec: any = {}): FrontMatter {
4647
if (footer !== undefined) frontMatter.footer = stringOrNull(footer);
4748
if (style !== undefined) frontMatter.style = stringOrNull(style);
4849
if (theme !== undefined) frontMatter.theme = normalizeTheme(theme);
49-
return frontMatter;
50+
return {...frontMatter, ...rest};
5051
}
5152

5253
function normalizeToc(spec: unknown): {show?: boolean; label?: string} {

src/markdown.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -332,13 +332,14 @@ export function parseMarkdown(input: string, options: ParseOptions): MarkdownPag
332332
const context: ParseContext = {code, startLine: 0, currentLine: 0, path};
333333
const tokens = md.parse(content, context);
334334
const body = md.renderer.render(tokens, md.options, context); // Note: mutates code!
335+
const title = data.title !== undefined ? data.title : findTitle(tokens);
335336
return {
336-
head: getHtml("head", data, options),
337-
header: getHtml("header", data, options),
337+
head: getHtml("head", data, title, options),
338+
header: getHtml("header", data, title, options),
338339
body,
339-
footer: getHtml("footer", data, options),
340+
footer: getHtml("footer", data, title, options),
340341
data,
341-
title: data.title !== undefined ? data.title : findTitle(tokens),
342+
title,
342343
style: getStyle(data, options),
343344
code
344345
};
@@ -360,12 +361,15 @@ export function parseMarkdownMetadata(input: string, options: ParseOptions): Pic
360361
function getHtml(
361362
key: "head" | "header" | "footer",
362363
data: FrontMatter,
364+
title: string | null,
363365
{path, [key]: defaultValue}: ParseOptions
364366
): string | null {
365367
return data[key] !== undefined
366368
? data[key]
367369
? String(data[key])
368370
: null
371+
: typeof defaultValue === "function"
372+
? defaultValue({...data, path, title})
369373
: defaultValue != null
370374
? rewriteHtmlPaths(defaultValue, path)
371375
: null;

test/frontMatter-test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ describe("normalizeFrontMatter(spec)", () => {
8484
assert.deepStrictEqual(normalizeFrontMatter({sql: {foo: []}}), {sql: {foo: ""}});
8585
assert.deepStrictEqual(normalizeFrontMatter({sql: {foo: {toString: () => "bar"}}}), {sql: {foo: "bar"}});
8686
});
87-
it("ignores unknown properties", () => {
88-
assert.deepStrictEqual(normalizeFrontMatter({foo: 42}), {});
87+
it("passes unknown properties unchanged", () => {
88+
assert.deepStrictEqual(normalizeFrontMatter({foo: 42}), {foo: 42});
8989
});
9090
});

test/input/build/fragments/index.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
hello: front-matter
3+
title: Testing fragment functions
4+
meta: ["very", "much"]
5+
---
6+
7+
Contents.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
export default {
2+
head: (data) => JSON.stringify({hello: "head", data}),
3+
header: (data) => JSON.stringify({hello: "header", data}),
4+
footer: (data) => JSON.stringify({hello: "footer", data}),
5+
};
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
<!DOCTYPE html>
2+
<meta charset="utf-8">
3+
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
4+
<title>Testing fragment functions</title>
5+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
6+
<link rel="preload" as="style" href="https://fonts.googleapis.com/css2?family=Source+Serif+Pro:ital,wght@0,400;0,600;0,700;1,400;1,600;1,700&amp;display=swap" crossorigin>
7+
<link rel="preload" as="style" href="./_observablehq/theme-air,near-midnight.css">
8+
<link rel="stylesheet" type="text/css" href="https://fonts.googleapis.com/css2?family=Source+Serif+Pro:ital,wght@0,400;0,600;0,700;1,400;1,600;1,700&amp;display=swap" crossorigin>
9+
<link rel="stylesheet" type="text/css" href="./_observablehq/theme-air,near-midnight.css">
10+
<link rel="modulepreload" href="./_observablehq/client.js">
11+
<link rel="modulepreload" href="./_observablehq/runtime.js">
12+
<link rel="modulepreload" href="./_observablehq/stdlib.js">
13+
{"hello":"head","data":{"title":"Testing fragment functions","hello":"front-matter","meta":["very","much"],"path":"/index"}}
14+
<script type="module">
15+
16+
import "./_observablehq/client.js";
17+
18+
</script>
19+
<aside id="observablehq-toc" data-selector="h1:not(:first-of-type), h2:first-child, :not(h1) + h2">
20+
<nav>
21+
</nav>
22+
</aside>
23+
<div id="observablehq-center">
24+
<header id="observablehq-header">
25+
{"hello":"header","data":{"title":"Testing fragment functions","hello":"front-matter","meta":["very","much"],"path":"/index"}}
26+
</header>
27+
<main id="observablehq-main" class="observablehq">
28+
<p>Contents.</p>
29+
</main>
30+
<footer id="observablehq-footer">
31+
<div>{"hello":"footer","data":{"title":"Testing fragment functions","hello":"front-matter","meta":["very","much"],"path":"/index"}}</div>
32+
</footer>
33+
</div>

0 commit comments

Comments
 (0)