Skip to content

Commit 83397e1

Browse files
Filmbostock
andauthored
head, header and footer can be specified as a function (#1255)
* rebase & simplify * Update src/markdown.ts Co-authored-by: Mike Bostock <[email protected]> * Update src/config.ts Co-authored-by: Mike Bostock <[email protected]> * no quotes necessary * prettier * combine arguments * document * Update src/config.ts * ignore null and undefined --------- Co-authored-by: Mike Bostock <[email protected]>
1 parent 8d50e57 commit 83397e1

File tree

6 files changed

+98
-20
lines changed

6 files changed

+98
-20
lines changed

docs/config.md

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -154,15 +154,21 @@ Whether to show the previous & next links in the footer; defaults to true. The p
154154

155155
## head
156156

157-
An HTML fragment to add to the head. Defaults to the empty string.
157+
An HTML fragment to add to the head. Defaults to the empty string. If specified as a function, receives an object with the page’s `title`, (front-matter) `data`, and `path`, and must return a string.
158158

159159
## header
160160

161-
An HTML fragment to add to the header. Defaults to the empty string.
161+
An HTML fragment to add to the header. Defaults to the empty string. If specified as a function, receives an object with the page’s `title`, (front-matter) `data`, and `path`, and must return a string.
162162

163163
## footer
164164

165-
An HTML fragment to add to the footer. Defaults to “Built with Observable.”
165+
An HTML fragment to add to the footer. Defaults to “Built with Observable.” If specified as a function, receives an object with the page’s `title`, (front-matter) `data`, and `path`, and must return a string.
166+
167+
For example, the following adds a link to the bottom of each page:
168+
169+
```js run=false
170+
footer: ({path}) => `<a href="https://github.com/example/test/blob/main/src${path}.md?plain=1">view source</a>`,
171+
```
166172

167173
## base
168174

src/config.ts

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import wrapAnsi from "wrap-ansi";
1010
import {LoaderResolver} from "./dataloader.js";
1111
import {visitMarkdownFiles} from "./files.js";
1212
import {formatIsoDate, formatLocaleDate} from "./format.js";
13+
import type {FrontMatter} from "./frontMatter.js";
1314
import {createMarkdownIt, parseMarkdownMetadata} from "./markdown.js";
1415
import {isAssetPath, parseRelativeUrl, resolvePath} from "./path.js";
1516
import {resolveTheme} from "./theme.js";
@@ -43,6 +44,19 @@ export interface Script {
4344
type: string | null;
4445
}
4546

47+
/**
48+
* A function that generates a page fragment such as head, header or footer.
49+
*/
50+
export type PageFragmentFunction = ({
51+
title,
52+
data,
53+
path
54+
}: {
55+
title: string | null;
56+
data: FrontMatter;
57+
path: string;
58+
}) => string | null;
59+
4660
export interface Config {
4761
root: string; // defaults to src
4862
output: string; // defaults to dist
@@ -52,9 +66,9 @@ export interface Config {
5266
pages: (Page | Section<Page>)[];
5367
pager: boolean; // defaults to true
5468
scripts: Script[]; // deprecated; defaults to empty array
55-
head: string | null; // defaults to null
56-
header: string | null; // defaults to null
57-
footer: string | null; // defaults to “Built with Observable on [date].”
69+
head: PageFragmentFunction | string | null; // defaults to null
70+
header: PageFragmentFunction | string | null; // defaults to null
71+
footer: PageFragmentFunction | string | null; // defaults to “Built with Observable on [date].”
5872
toc: TableOfContents;
5973
style: null | Style; // defaults to {theme: ["light", "dark"]}
6074
search: boolean; // default to false
@@ -205,9 +219,9 @@ export function normalizeConfig(spec: ConfigSpec = {}, defaultRoot?: string, wat
205219
const toc = normalizeToc(spec.toc as any);
206220
const sidebar = spec.sidebar === undefined ? undefined : Boolean(spec.sidebar);
207221
const scripts = spec.scripts === undefined ? [] : normalizeScripts(spec.scripts);
208-
const head = spec.head === undefined ? "" : stringOrNull(spec.head);
209-
const header = spec.header === undefined ? "" : stringOrNull(spec.header);
210-
const footer = spec.footer === undefined ? defaultFooter() : stringOrNull(spec.footer);
222+
const head = pageFragment(spec.head === undefined ? "" : spec.head);
223+
const header = pageFragment(spec.header === undefined ? "" : spec.header);
224+
const footer = pageFragment(spec.footer === undefined ? defaultFooter() : spec.footer);
211225
const search = Boolean(spec.search);
212226
const interpreters = normalizeInterpreters(spec.interpreters as any);
213227
const config: Config = {
@@ -247,6 +261,10 @@ function getPathNormalizer(spec: unknown = true): (path: string) => string {
247261
};
248262
}
249263

264+
function pageFragment(spec: unknown): PageFragmentFunction | string | null {
265+
return typeof spec === "function" ? (spec as PageFragmentFunction) : stringOrNull(spec);
266+
}
267+
250268
function defaultFooter(): string {
251269
const date = currentDate ?? new Date();
252270
return `Built with <a href="https://observablehq.com/" target="_blank">Observable</a> on <a title="${formatIsoDate(

src/markdown.ts

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -319,13 +319,14 @@ export function parseMarkdown(input: string, options: ParseOptions): MarkdownPag
319319
const context: ParseContext = {code, startLine: 0, currentLine: 0, path};
320320
const tokens = md.parse(content, context);
321321
const body = md.renderer.render(tokens, md.options, context); // Note: mutates code!
322+
const title = data.title !== undefined ? data.title : findTitle(tokens);
322323
return {
323-
head: getHead(data, options),
324-
header: getHeader(data, options),
324+
head: getHead(title, data, options),
325+
header: getHeader(title, data, options),
325326
body,
326-
footer: getFooter(data, options),
327+
footer: getFooter(title, data, options),
327328
data,
328-
title: data.title !== undefined ? data.title : findTitle(tokens),
329+
title,
329330
style: getStyle(data, options),
330331
code
331332
};
@@ -344,9 +345,9 @@ export function parseMarkdownMetadata(input: string, options: ParseOptions): Pic
344345
};
345346
}
346347

347-
function getHead(data: FrontMatter, options: ParseOptions): string | null {
348+
function getHead(title: string | null, data: FrontMatter, options: ParseOptions): string | null {
348349
const {scripts, path} = options;
349-
let head = getHtml("head", data, options);
350+
let head = getHtml("head", title, data, options);
350351
if (scripts?.length) {
351352
head ??= "";
352353
for (const {type, async, src} of scripts) {
@@ -358,16 +359,17 @@ function getHead(data: FrontMatter, options: ParseOptions): string | null {
358359
return head;
359360
}
360361

361-
function getHeader(data: FrontMatter, options: ParseOptions): string | null {
362-
return getHtml("header", data, options);
362+
function getHeader(title: string | null, data: FrontMatter, options: ParseOptions): string | null {
363+
return getHtml("header", title, data, options);
363364
}
364365

365-
function getFooter(data: FrontMatter, options: ParseOptions): string | null {
366-
return getHtml("footer", data, options);
366+
function getFooter(title: string | null, data: FrontMatter, options: ParseOptions): string | null {
367+
return getHtml("footer", title, data, options);
367368
}
368369

369370
function getHtml(
370371
key: "head" | "header" | "footer",
372+
title: string | null,
371373
data: FrontMatter,
372374
{path, [key]: defaultValue}: ParseOptions
373375
): string | null {
@@ -376,7 +378,10 @@ function getHtml(
376378
? String(data[key])
377379
: null
378380
: defaultValue != null
379-
? rewriteHtmlPaths(defaultValue, path)
381+
? rewriteHtmlPaths(
382+
typeof defaultValue === "function" ? defaultValue({title, data, path}) ?? "" : defaultValue,
383+
path
384+
)
380385
: null;
381386
}
382387

test/input/build/fragments/index.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
author: Ignored Anonymous
3+
title: Testing fragment functions
4+
date: 2024-04-18
5+
keywords: [very, much]
6+
---
7+
8+
# Display title
9+
10+
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({fragment: "head", data})} -->`,
3+
header: (data) => `<!-- ${JSON.stringify({fragment: "header", data})} -->`,
4+
footer: (data) => `<!-- ${JSON.stringify({fragment: "footer", data})} -->`
5+
};
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
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+
<!-- {"fragment":"head","data":{"title":"Testing fragment functions","data":{"title":"Testing fragment functions","keywords":["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)[id], h2:first-child[id], :not(h1) + h2[id]">
20+
<nav>
21+
</nav>
22+
</aside>
23+
<div id="observablehq-center">
24+
<header id="observablehq-header">
25+
<!-- {"fragment":"header","data":{"title":"Testing fragment functions","data":{"title":"Testing fragment functions","keywords":["very","much"]},"path":"/index"}} -->
26+
</header>
27+
<main id="observablehq-main" class="observablehq">
28+
<h1 id="display-title" tabindex="-1"><a class="observablehq-header-anchor" href="#display-title">Display title</a></h1>
29+
<p>Contents.</p>
30+
</main>
31+
<footer id="observablehq-footer">
32+
<div><!-- {"fragment":"footer","data":{"title":"Testing fragment functions","data":{"title":"Testing fragment functions","keywords":["very","much"]},"path":"/index"}} --></div>
33+
</footer>
34+
</div>

0 commit comments

Comments
 (0)