Skip to content

Commit a631835

Browse files
authored
rewrite head, header, footer (#1083)
* rewrite header * find header assets * rewrite head & footer; self-host favicon * fix header: false, again * resolve npm: assets
1 parent 00639a0 commit a631835

File tree

9 files changed

+135
-79
lines changed

9 files changed

+135
-79
lines changed

docs/favicon.png

15.2 KB
Loading

observablehq.config.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -86,8 +86,8 @@ export default {
8686
{name: "Contributing", path: "/contributing"}
8787
],
8888
base: "/framework",
89-
head: `<link rel="apple-touch-icon" href="https://static.observablehq.com/favicon-512.0667824687f99c942a02e06e2db1a060911da0bf3606671676a255b1cf97b4fe.png">
90-
<link rel="icon" type="image/png" href="https://static.observablehq.com/favicon-512.0667824687f99c942a02e06e2db1a060911da0bf3606671676a255b1cf97b4fe.png" sizes="512x512">${
89+
head: `<link rel="apple-touch-icon" href="/favicon.png">
90+
<link rel="icon" type="image/png" href="/favicon.png" sizes="512x512">${
9191
process.env.CI
9292
? `
9393
<script type="module" async src="https://events.observablehq.com/client.js"></script>
@@ -103,7 +103,7 @@ export default {
103103
</svg>
104104
</a>
105105
<div style="display: flex; flex-grow: 1; justify-content: space-between; align-items: baseline;">
106-
<a href="https://observablehq.com/framework/">
106+
<a href="/">
107107
<span class="hide-if-small">Observable</span> Framework
108108
</a>
109109
<span style="display: flex; align-items: baseline; gap: 0.5rem; font-size: 14px;">

src/html.ts

Lines changed: 48 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import type {DOMWindow} from "jsdom";
55
import {JSDOM, VirtualConsole} from "jsdom";
66
import {isAssetPath, relativePath, resolveLocalPath} from "./path.js";
77

8-
const ASSET_PROPERTIES: readonly [selector: string, src: string][] = [
8+
const ASSET_ATTRIBUTES: readonly [selector: string, src: string][] = [
99
["a[href][download]", "href"],
1010
["audio source[src]", "src"],
1111
["audio[src]", "src"],
@@ -17,6 +17,18 @@ const ASSET_PROPERTIES: readonly [selector: string, src: string][] = [
1717
["video[src]", "src"]
1818
];
1919

20+
const PATH_ATTRIBUTES: readonly [selector: string, src: string][] = [
21+
["a[href]", "href"],
22+
["audio source[src]", "src"],
23+
["audio[src]", "src"],
24+
["img[src]", "src"],
25+
["img[srcset]", "srcset"],
26+
["link[href]", "href"],
27+
["picture source[srcset]", "srcset"],
28+
["video source[src]", "src"],
29+
["video[src]", "src"]
30+
];
31+
2032
export function isJavaScript({type}: HTMLScriptElement): boolean {
2133
if (!type) return true;
2234
type = type.toLowerCase();
@@ -42,13 +54,16 @@ export function findAssets(html: string, path: string): Assets {
4254
const staticImports = new Set<string>();
4355

4456
const maybeFile = (specifier: string): void => {
45-
if (!isAssetPath(specifier)) return;
46-
const localPath = resolveLocalPath(path, specifier);
47-
if (!localPath) return console.warn(`non-local asset path: ${specifier}`);
48-
files.add(relativePath(path, localPath));
57+
if (isAssetPath(specifier)) {
58+
const localPath = resolveLocalPath(path, specifier);
59+
if (!localPath) return console.warn(`non-local asset path: ${specifier}`);
60+
files.add(relativePath(path, localPath));
61+
} else {
62+
globalImports.add(specifier);
63+
}
4964
};
5065

51-
for (const [selector, src] of ASSET_PROPERTIES) {
66+
for (const [selector, src] of ASSET_ATTRIBUTES) {
5267
for (const element of document.querySelectorAll(selector)) {
5368
const source = decodeURI(element.getAttribute(src)!);
5469
if (src === "srcset") {
@@ -85,19 +100,40 @@ export function findAssets(html: string, path: string): Assets {
85100
return {files, localImports, globalImports, staticImports};
86101
}
87102

88-
interface HtmlResolvers {
89-
resolveFile?: (specifier: string) => string;
90-
resolveScript?: (specifier: string) => string;
103+
export function rewriteHtmlPaths(html: string, path: string): string {
104+
const {document} = parseHtml(html);
105+
106+
const resolvePath = (specifier: string): string => {
107+
return isAssetPath(specifier) ? relativePath(path, specifier) : specifier;
108+
};
109+
110+
for (const [selector, src] of PATH_ATTRIBUTES) {
111+
for (const element of document.querySelectorAll(selector)) {
112+
const source = decodeURI(element.getAttribute(src)!);
113+
element.setAttribute(src, src === "srcset" ? resolveSrcset(source, resolvePath) : resolvePath(source));
114+
}
115+
}
116+
117+
return document.body.innerHTML;
118+
}
119+
120+
export interface HtmlResolvers {
121+
resolveFile: (specifier: string) => string;
122+
resolveImport: (specifier: string) => string;
123+
resolveScript: (specifier: string) => string;
91124
}
92125

93-
export function rewriteHtml(html: string, {resolveFile = String, resolveScript = String}: HtmlResolvers): string {
126+
export function rewriteHtml(
127+
html: string,
128+
{resolveFile = String, resolveImport = String, resolveScript = String}: Partial<HtmlResolvers>
129+
): string {
94130
const {document} = parseHtml(html);
95131

96132
const maybeResolveFile = (specifier: string): string => {
97-
return isAssetPath(specifier) ? resolveFile(specifier) : specifier;
133+
return isAssetPath(specifier) ? resolveFile(specifier) : resolveImport(specifier);
98134
};
99135

100-
for (const [selector, src] of ASSET_PROPERTIES) {
136+
for (const [selector, src] of ASSET_ATTRIBUTES) {
101137
for (const element of document.querySelectorAll(selector)) {
102138
const source = decodeURI(element.getAttribute(src)!);
103139
element.setAttribute(src, src === "srcset" ? resolveSrcset(source, maybeResolveFile) : maybeResolveFile(source));

src/markdown.ts

Lines changed: 33 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import type {RenderRule} from "markdown-it/lib/renderer.js";
1010
import MarkdownItAnchor from "markdown-it-anchor";
1111
import type {Config} from "./config.js";
1212
import {mergeStyle} from "./config.js";
13+
import {rewriteHtmlPaths} from "./html.js";
1314
import {parseInfo} from "./info.js";
1415
import type {JavaScriptNode} from "./javascript/parse.js";
1516
import {parseJavaScript} from "./javascript/parse.js";
@@ -26,7 +27,10 @@ export interface MarkdownCode {
2627

2728
export interface MarkdownPage {
2829
title: string | null;
29-
html: string;
30+
head: string | null;
31+
header: string | null;
32+
body: string;
33+
footer: string | null;
3034
data: {[key: string]: any} | null;
3135
style: string | null;
3236
code: MarkdownCode[];
@@ -308,6 +312,9 @@ export interface ParseOptions {
308312
md: MarkdownIt;
309313
path: string;
310314
style?: Config["style"];
315+
head?: Config["head"];
316+
header?: Config["header"];
317+
footer?: Config["footer"];
311318
}
312319

313320
export function createMarkdownIt({
@@ -329,25 +336,42 @@ export function createMarkdownIt({
329336
return markdownIt === undefined ? md : markdownIt(md);
330337
}
331338

332-
export function parseMarkdown(input: string, {md, path, style: configStyle}: ParseOptions): MarkdownPage {
339+
export function parseMarkdown(input: string, options: ParseOptions): MarkdownPage {
340+
const {md, path} = options;
333341
const {content, data} = matter(input, {});
334342
const code: MarkdownCode[] = [];
335343
const context: ParseContext = {code, startLine: 0, currentLine: 0, path};
336344
const tokens = md.parse(content, context);
337-
const html = md.renderer.render(tokens, md.options, context); // Note: mutates code!
338-
const style = getStylesheet(path, data, configStyle);
345+
const body = md.renderer.render(tokens, md.options, context); // Note: mutates code!
339346
return {
340-
html,
347+
head: getHtml("head", data, options),
348+
header: getHtml("header", data, options),
349+
body,
350+
footer: getHtml("footer", data, options),
341351
data: isEmpty(data) ? null : data,
342-
title: data?.title ?? findTitle(tokens) ?? null,
343-
style,
352+
title: data.title ?? findTitle(tokens) ?? null,
353+
style: getStyle(data, options),
344354
code
345355
};
346356
}
347357

348-
function getStylesheet(path: string, data: MarkdownPage["data"], style: Config["style"] = null): string | null {
358+
function getHtml(
359+
key: "head" | "header" | "footer",
360+
data: Record<string, any>,
361+
{path, [key]: defaultValue}: ParseOptions
362+
): string | null {
363+
return data[key] !== undefined
364+
? data[key]
365+
? String(data[key])
366+
: null
367+
: defaultValue != null
368+
? rewriteHtmlPaths(defaultValue, path)
369+
: null;
370+
}
371+
372+
function getStyle(data: Record<string, any>, {path, style = null}: ParseOptions): string | null {
349373
try {
350-
style = mergeStyle(path, data?.style, data?.theme, style);
374+
style = mergeStyle(path, data.style, data.theme, style);
351375
} catch (error) {
352376
if (!(error instanceof InvalidThemeError)) throw error;
353377
console.error(red(String(error))); // TODO error during build

src/preview.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -290,7 +290,7 @@ function handleWatch(socket: WebSocket, req: IncomingMessage, config: Config) {
290290
const source = await readFile(join(root, path), "utf8");
291291
const page = parseMarkdown(source, {path, ...config});
292292
// delay to avoid a possibly-empty file
293-
if (!force && page.html === "") {
293+
if (!force && page.body === "") {
294294
if (!emptyTimeout) {
295295
emptyTimeout = setTimeout(() => {
296296
emptyTimeout = null;
@@ -390,8 +390,8 @@ function handleWatch(socket: WebSocket, req: IncomingMessage, config: Config) {
390390
}
391391
}
392392

393-
function getHtml({html}: MarkdownPage, resolvers: Resolvers): string[] {
394-
return Array.from(parseHtml(rewriteHtml(html, resolvers)).document.body.children, (d) => d.outerHTML);
393+
function getHtml({body}: MarkdownPage, resolvers: Resolvers): string[] {
394+
return Array.from(parseHtml(rewriteHtml(body, resolvers)).document.body.children, (d) => d.outerHTML);
395395
}
396396

397397
function getCode({code}: MarkdownPage, resolvers: Resolvers): Map<string, string> {

src/render.ts

Lines changed: 35 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import mime from "mime";
2-
import type {Config, Page, Script, Section} from "./config.js";
2+
import type {Config, Page, Script} from "./config.js";
33
import {mergeToc} from "./config.js";
44
import {getClientPath} from "./files.js";
5-
import type {Html} from "./html.js";
5+
import type {Html, HtmlResolvers} from "./html.js";
66
import {html, parseHtml, rewriteHtml} from "./html.js";
77
import {transpileJavaScript} from "./javascript/transpile.js";
88
import type {MarkdownPage} from "./markdown.js";
@@ -25,9 +25,8 @@ type RenderInternalOptions =
2525

2626
export async function renderPage(page: MarkdownPage, options: RenderOptions & RenderInternalOptions): Promise<string> {
2727
const {data} = page;
28-
const {root, md, base, path, pages, title, preview, search} = options;
28+
const {base, path, title, preview} = options;
2929
const {loaders, resolvers = await getResolvers(page, options)} = options;
30-
const {normalizeLink} = md;
3130
const sidebar = data?.sidebar !== undefined ? Boolean(data.sidebar) : options.sidebar;
3231
const toc = mergeToc(data?.toc, options.toc);
3332
const draft = Boolean(data?.draft);
@@ -41,7 +40,7 @@ ${
4140
.filter((title): title is string => !!title)
4241
.join(" | ")}</title>\n`
4342
: ""
44-
}${renderHead(page, resolvers, options)}${
43+
}${renderHead(page.head, resolvers, options)}${
4544
path === "/404"
4645
? html.unsafe(`\n<script type="module">
4746
@@ -69,8 +68,8 @@ import ${preview || page.code.length ? `{${preview ? "open, " : ""}define} from
6968
files,
7069
resolveFile,
7170
preview
72-
? (name: string) => loaders.getSourceLastModified(resolvePath(path, name))
73-
: (name: string) => loaders.getOutputLastModified(resolvePath(path, name))
71+
? (name) => loaders.getSourceLastModified(resolvePath(path, name))
72+
: (name) => loaders.getOutputLastModified(resolvePath(path, name))
7473
)}`
7574
: ""
7675
}${
@@ -83,24 +82,32 @@ import ${preview || page.code.length ? `{${preview ? "open, " : ""}define} from
8382
${preview ? `\nopen({hash: ${JSON.stringify(resolvers.hash)}, eval: (body) => eval(body)});\n` : ""}${page.code
8483
.map(({node, id}) => `\n${transpileJavaScript(node, {id, path, resolveImport})}`)
8584
.join("")}`)}
86-
</script>${sidebar ? html`\n${await renderSidebar(title, pages, root, path, search, normalizeLink)}` : ""}${
85+
</script>${sidebar ? html`\n${await renderSidebar(options)}` : ""}${
8786
toc.show ? html`\n${renderToc(findHeaders(page), toc.label)}` : ""
8887
}
89-
<div id="observablehq-center">${renderHeader(options, data)}
88+
<div id="observablehq-center">${renderHeader(page.header, resolvers)}
9089
<main id="observablehq-main" class="observablehq${draft ? " observablehq--draft" : ""}">
91-
${html.unsafe(rewriteHtml(page.html, resolvers))}</main>${renderFooter(path, options, data, normalizeLink)}
90+
${html.unsafe(rewriteHtml(page.body, resolvers))}</main>${renderFooter(page.footer, resolvers, options)}
9291
</div>
9392
`);
9493
}
9594

96-
function renderFiles(files: Iterable<string>, resolve: (name: string) => string, getLastModified): string {
95+
function renderFiles(
96+
files: Iterable<string>,
97+
resolve: (name: string) => string,
98+
getLastModified: (name: string) => number | undefined
99+
): string {
97100
return Array.from(files)
98101
.sort()
99102
.map((f) => renderFile(f, resolve, getLastModified))
100103
.join("");
101104
}
102105

103-
function renderFile(name: string, resolve: (name: string) => string, getLastModified): string {
106+
function renderFile(
107+
name: string,
108+
resolve: (name: string) => string,
109+
getLastModified: (name: string) => number | undefined
110+
): string {
104111
return `\nregisterFile(${JSON.stringify(name)}, ${JSON.stringify({
105112
name,
106113
mimeType: mime.getType(name) ?? undefined,
@@ -109,22 +116,17 @@ function renderFile(name: string, resolve: (name: string) => string, getLastModi
109116
})});`;
110117
}
111118

112-
async function renderSidebar(
113-
title = "Home",
114-
pages: (Page | Section)[],
115-
root: string,
116-
path: string,
117-
search: boolean,
118-
normalizeLink: (href: string) => string
119-
): Promise<Html> {
119+
async function renderSidebar(options: RenderOptions): Promise<Html> {
120+
const {title = "Home", pages, root, path, search, md} = options;
121+
const {normalizeLink} = md;
120122
return html`<input id="observablehq-sidebar-toggle" type="checkbox" title="Toggle sidebar">
121123
<label id="observablehq-sidebar-backdrop" for="observablehq-sidebar-toggle"></label>
122124
<nav id="observablehq-sidebar">
123125
<ol>
124126
<label id="observablehq-sidebar-close" for="observablehq-sidebar-toggle"></label>
125127
<li class="observablehq-link${
126128
normalizePath(path) === "/index" ? " observablehq-link-active" : ""
127-
}"><a href="${normalizeLink(relativePath(path, "/"))}">${title}</a></li>
129+
}"><a href="${md.normalizeLink(relativePath(path, "/"))}">${title}</a></li>
128130
</ol>${
129131
search
130132
? html`\n <div id="observablehq-search"><input type="search" placeholder="Search"></div>
@@ -171,7 +173,7 @@ interface Header {
171173
const tocSelector = "h1:not(:first-of-type), h2:first-child, :not(h1) + h2";
172174

173175
function findHeaders(page: MarkdownPage): Header[] {
174-
return Array.from(parseHtml(page.html).document.querySelectorAll(tocSelector))
176+
return Array.from(parseHtml(page.body).document.querySelectorAll(tocSelector))
175177
.map((node) => ({label: node.textContent, href: node.firstElementChild?.getAttribute("href")}))
176178
.filter((d): d is Header => !!d.label && !!d.href);
177179
}
@@ -198,12 +200,8 @@ function renderListItem(page: Page, path: string, normalizeLink: (href: string)
198200
}"><a href="${normalizeLink(relativePath(path, page.path))}">${page.name}</a></li>`;
199201
}
200202

201-
function renderHead(
202-
parse: MarkdownPage,
203-
{stylesheets, staticImports, resolveImport, resolveStylesheet}: Resolvers,
204-
{scripts, head, root}: RenderOptions
205-
): Html {
206-
if (parse.data?.head !== undefined) head = parse.data.head;
203+
function renderHead(head: MarkdownPage["head"], resolvers: Resolvers, {scripts, root}: RenderOptions): Html {
204+
const {stylesheets, staticImports, resolveImport, resolveStylesheet} = resolvers;
207205
const resolveScript = (src: string) => (/^\w+:/.test(src) ? src : resolveImport(relativePath(root, src)));
208206
return html`<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>${
209207
Array.from(new Set(Array.from(stylesheets, (i) => resolveStylesheet(i))), renderStylesheetPreload) // <link rel=preload as=style>
@@ -212,7 +210,7 @@ function renderHead(
212210
}${
213211
Array.from(new Set(Array.from(staticImports, (i) => resolveImport(i))), renderModulePreload) // <link rel=modulepreload>
214212
}${
215-
head ? html`\n${html.unsafe(head)}` : null // arbitrary user content
213+
head ? html`\n${html.unsafe(rewriteHtml(head, resolvers))}` : null // arbitrary user content
216214
}${
217215
Array.from(scripts, (s) => renderScript(s, resolveScript)) // <script src>
218216
}`;
@@ -236,23 +234,18 @@ function renderModulePreload(href: string): Html {
236234
return html`\n<link rel="modulepreload" href="${href}">`;
237235
}
238236

239-
function renderHeader({header}: Pick<Config, "header">, data: MarkdownPage["data"]): Html | null {
240-
if (data?.header !== undefined) header = data?.header;
241-
return header ? html`\n<header id="observablehq-header">\n${html.unsafe(header)}\n</header>` : null;
237+
function renderHeader(header: MarkdownPage["header"], resolvers: HtmlResolvers): Html | null {
238+
return header
239+
? html`\n<header id="observablehq-header">\n${html.unsafe(rewriteHtml(header, resolvers))}\n</header>`
240+
: null;
242241
}
243242

244-
function renderFooter(
245-
path: string,
246-
options: Pick<Config, "pages" | "pager" | "title" | "footer">,
247-
data: MarkdownPage["data"],
248-
normalizeLink: (href: string) => string
249-
): Html | null {
250-
let footer = options.footer;
251-
if (data?.footer !== undefined) footer = data?.footer;
243+
function renderFooter(footer: MarkdownPage["footer"], resolvers: HtmlResolvers, options: RenderOptions): Html | null {
244+
const {path, md} = options;
252245
const link = options.pager ? findLink(path, options) : null;
253246
return link || footer
254-
? html`\n<footer id="observablehq-footer">${link ? renderPager(path, link, normalizeLink) : ""}${
255-
footer ? html`\n<div>${html.unsafe(footer)}</div>` : ""
247+
? html`\n<footer id="observablehq-footer">${link ? renderPager(path, link, md.normalizeLink) : ""}${
248+
footer ? html`\n<div>${html.unsafe(rewriteHtml(footer, resolvers))}</div>` : ""
256249
}
257250
</footer>`
258251
: null;

0 commit comments

Comments
 (0)