Skip to content

rewrite head, header, footer #1083

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

Merged
merged 6 commits into from
Mar 18, 2024
Merged
Show file tree
Hide file tree
Changes from all 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
Binary file added docs/favicon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 3 additions & 3 deletions observablehq.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,8 +86,8 @@ export default {
{name: "Contributing", path: "/contributing"}
],
base: "/framework",
head: `<link rel="apple-touch-icon" href="https://static.observablehq.com/favicon-512.0667824687f99c942a02e06e2db1a060911da0bf3606671676a255b1cf97b4fe.png">
<link rel="icon" type="image/png" href="https://static.observablehq.com/favicon-512.0667824687f99c942a02e06e2db1a060911da0bf3606671676a255b1cf97b4fe.png" sizes="512x512">${
head: `<link rel="apple-touch-icon" href="/favicon.png">
<link rel="icon" type="image/png" href="/favicon.png" sizes="512x512">${
process.env.CI
? `
<script type="module" async src="https://events.observablehq.com/client.js"></script>
Expand All @@ -103,7 +103,7 @@ export default {
</svg>
</a>
<div style="display: flex; flex-grow: 1; justify-content: space-between; align-items: baseline;">
<a href="https://observablehq.com/framework/">
<a href="/">
<span class="hide-if-small">Observable</span> Framework
</a>
<span style="display: flex; align-items: baseline; gap: 0.5rem; font-size: 14px;">
Expand Down
60 changes: 48 additions & 12 deletions src/html.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import type {DOMWindow} from "jsdom";
import {JSDOM, VirtualConsole} from "jsdom";
import {isAssetPath, relativePath, resolveLocalPath} from "./path.js";

const ASSET_PROPERTIES: readonly [selector: string, src: string][] = [
const ASSET_ATTRIBUTES: readonly [selector: string, src: string][] = [
["a[href][download]", "href"],
["audio source[src]", "src"],
["audio[src]", "src"],
Expand All @@ -17,6 +17,18 @@ const ASSET_PROPERTIES: readonly [selector: string, src: string][] = [
["video[src]", "src"]
];

const PATH_ATTRIBUTES: readonly [selector: string, src: string][] = [
["a[href]", "href"],
["audio source[src]", "src"],
["audio[src]", "src"],
["img[src]", "src"],
["img[srcset]", "srcset"],
["link[href]", "href"],
["picture source[srcset]", "srcset"],
["video source[src]", "src"],
["video[src]", "src"]
];

export function isJavaScript({type}: HTMLScriptElement): boolean {
if (!type) return true;
type = type.toLowerCase();
Expand All @@ -42,13 +54,16 @@ export function findAssets(html: string, path: string): Assets {
const staticImports = new Set<string>();

const maybeFile = (specifier: string): void => {
if (!isAssetPath(specifier)) return;
const localPath = resolveLocalPath(path, specifier);
if (!localPath) return console.warn(`non-local asset path: ${specifier}`);
files.add(relativePath(path, localPath));
if (isAssetPath(specifier)) {
const localPath = resolveLocalPath(path, specifier);
if (!localPath) return console.warn(`non-local asset path: ${specifier}`);
files.add(relativePath(path, localPath));
} else {
globalImports.add(specifier);
}
};

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

interface HtmlResolvers {
resolveFile?: (specifier: string) => string;
resolveScript?: (specifier: string) => string;
export function rewriteHtmlPaths(html: string, path: string): string {
const {document} = parseHtml(html);

const resolvePath = (specifier: string): string => {
return isAssetPath(specifier) ? relativePath(path, specifier) : specifier;
};

for (const [selector, src] of PATH_ATTRIBUTES) {
for (const element of document.querySelectorAll(selector)) {
const source = decodeURI(element.getAttribute(src)!);
element.setAttribute(src, src === "srcset" ? resolveSrcset(source, resolvePath) : resolvePath(source));
}
}

return document.body.innerHTML;
}

export interface HtmlResolvers {
resolveFile: (specifier: string) => string;
resolveImport: (specifier: string) => string;
resolveScript: (specifier: string) => string;
}

export function rewriteHtml(html: string, {resolveFile = String, resolveScript = String}: HtmlResolvers): string {
export function rewriteHtml(
html: string,
{resolveFile = String, resolveImport = String, resolveScript = String}: Partial<HtmlResolvers>
): string {
const {document} = parseHtml(html);

const maybeResolveFile = (specifier: string): string => {
return isAssetPath(specifier) ? resolveFile(specifier) : specifier;
return isAssetPath(specifier) ? resolveFile(specifier) : resolveImport(specifier);
};

for (const [selector, src] of ASSET_PROPERTIES) {
for (const [selector, src] of ASSET_ATTRIBUTES) {
for (const element of document.querySelectorAll(selector)) {
const source = decodeURI(element.getAttribute(src)!);
element.setAttribute(src, src === "srcset" ? resolveSrcset(source, maybeResolveFile) : maybeResolveFile(source));
Expand Down
42 changes: 33 additions & 9 deletions src/markdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ 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 {rewriteHtmlPaths} from "./html.js";
import {parseInfo} from "./info.js";
import type {JavaScriptNode} from "./javascript/parse.js";
import {parseJavaScript} from "./javascript/parse.js";
Expand All @@ -26,7 +27,10 @@ export interface MarkdownCode {

export interface MarkdownPage {
title: string | null;
html: string;
head: string | null;
header: string | null;
body: string;
footer: string | null;
data: {[key: string]: any} | null;
style: string | null;
code: MarkdownCode[];
Expand Down Expand Up @@ -308,6 +312,9 @@ export interface ParseOptions {
md: MarkdownIt;
path: string;
style?: Config["style"];
head?: Config["head"];
header?: Config["header"];
footer?: Config["footer"];
}

export function createMarkdownIt({
Expand All @@ -329,25 +336,42 @@ export function createMarkdownIt({
return markdownIt === undefined ? md : markdownIt(md);
}

export function parseMarkdown(input: string, {md, path, style: configStyle}: ParseOptions): MarkdownPage {
export function parseMarkdown(input: string, options: ParseOptions): MarkdownPage {
const {md, path} = options;
const {content, data} = matter(input, {});
const code: MarkdownCode[] = [];
const context: ParseContext = {code, startLine: 0, currentLine: 0, path};
const tokens = md.parse(content, context);
const html = md.renderer.render(tokens, md.options, context); // Note: mutates code!
const style = getStylesheet(path, data, configStyle);
const body = md.renderer.render(tokens, md.options, context); // Note: mutates code!
return {
html,
head: getHtml("head", data, options),
header: getHtml("header", data, options),
body,
footer: getHtml("footer", data, options),
data: isEmpty(data) ? null : data,
title: data?.title ?? findTitle(tokens) ?? null,
style,
title: data.title ?? findTitle(tokens) ?? null,
style: getStyle(data, options),
code
};
}

function getStylesheet(path: string, data: MarkdownPage["data"], style: Config["style"] = null): string | null {
function getHtml(
key: "head" | "header" | "footer",
data: Record<string, any>,
{path, [key]: defaultValue}: ParseOptions
): string | null {
return data[key] !== undefined
? data[key]
? String(data[key])
: null
: defaultValue != null
? rewriteHtmlPaths(defaultValue, path)
: null;
}

function getStyle(data: Record<string, any>, {path, style = null}: ParseOptions): string | null {
try {
style = mergeStyle(path, data?.style, data?.theme, style);
style = mergeStyle(path, data.style, data.theme, style);
} catch (error) {
if (!(error instanceof InvalidThemeError)) throw error;
console.error(red(String(error))); // TODO error during build
Expand Down
6 changes: 3 additions & 3 deletions src/preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -290,7 +290,7 @@ function handleWatch(socket: WebSocket, req: IncomingMessage, config: Config) {
const source = await readFile(join(root, path), "utf8");
const page = parseMarkdown(source, {path, ...config});
// delay to avoid a possibly-empty file
if (!force && page.html === "") {
if (!force && page.body === "") {
if (!emptyTimeout) {
emptyTimeout = setTimeout(() => {
emptyTimeout = null;
Expand Down Expand Up @@ -390,8 +390,8 @@ function handleWatch(socket: WebSocket, req: IncomingMessage, config: Config) {
}
}

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

function getCode({code}: MarkdownPage, resolvers: Resolvers): Map<string, string> {
Expand Down
77 changes: 35 additions & 42 deletions src/render.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import mime from "mime";
import type {Config, Page, Script, Section} from "./config.js";
import type {Config, Page, Script} from "./config.js";
import {mergeToc} from "./config.js";
import {getClientPath} from "./files.js";
import type {Html} from "./html.js";
import type {Html, HtmlResolvers} from "./html.js";
import {html, parseHtml, rewriteHtml} from "./html.js";
import {transpileJavaScript} from "./javascript/transpile.js";
import type {MarkdownPage} from "./markdown.js";
Expand All @@ -25,9 +25,8 @@ type RenderInternalOptions =

export async function renderPage(page: MarkdownPage, options: RenderOptions & RenderInternalOptions): Promise<string> {
const {data} = page;
const {root, md, base, path, pages, title, preview, search} = options;
const {base, path, title, preview} = options;
const {loaders, resolvers = await getResolvers(page, options)} = options;
const {normalizeLink} = md;
const sidebar = data?.sidebar !== undefined ? Boolean(data.sidebar) : options.sidebar;
const toc = mergeToc(data?.toc, options.toc);
const draft = Boolean(data?.draft);
Expand All @@ -41,7 +40,7 @@ ${
.filter((title): title is string => !!title)
.join(" | ")}</title>\n`
: ""
}${renderHead(page, resolvers, options)}${
}${renderHead(page.head, resolvers, options)}${
path === "/404"
? html.unsafe(`\n<script type="module">

Expand Down Expand Up @@ -69,8 +68,8 @@ import ${preview || page.code.length ? `{${preview ? "open, " : ""}define} from
files,
resolveFile,
preview
? (name: string) => loaders.getSourceLastModified(resolvePath(path, name))
: (name: string) => loaders.getOutputLastModified(resolvePath(path, name))
? (name) => loaders.getSourceLastModified(resolvePath(path, name))
: (name) => loaders.getOutputLastModified(resolvePath(path, name))
)}`
: ""
}${
Expand All @@ -83,24 +82,32 @@ import ${preview || page.code.length ? `{${preview ? "open, " : ""}define} from
${preview ? `\nopen({hash: ${JSON.stringify(resolvers.hash)}, eval: (body) => eval(body)});\n` : ""}${page.code
.map(({node, id}) => `\n${transpileJavaScript(node, {id, path, resolveImport})}`)
.join("")}`)}
</script>${sidebar ? html`\n${await renderSidebar(title, pages, root, path, search, normalizeLink)}` : ""}${
</script>${sidebar ? html`\n${await renderSidebar(options)}` : ""}${
toc.show ? html`\n${renderToc(findHeaders(page), toc.label)}` : ""
}
<div id="observablehq-center">${renderHeader(options, data)}
<div id="observablehq-center">${renderHeader(page.header, resolvers)}
<main id="observablehq-main" class="observablehq${draft ? " observablehq--draft" : ""}">
${html.unsafe(rewriteHtml(page.html, resolvers))}</main>${renderFooter(path, options, data, normalizeLink)}
${html.unsafe(rewriteHtml(page.body, resolvers))}</main>${renderFooter(page.footer, resolvers, options)}
</div>
`);
}

function renderFiles(files: Iterable<string>, resolve: (name: string) => string, getLastModified): string {
function renderFiles(
files: Iterable<string>,
resolve: (name: string) => string,
getLastModified: (name: string) => number | undefined
): string {
return Array.from(files)
.sort()
.map((f) => renderFile(f, resolve, getLastModified))
.join("");
}

function renderFile(name: string, resolve: (name: string) => string, getLastModified): string {
function renderFile(
name: string,
resolve: (name: string) => string,
getLastModified: (name: string) => number | undefined
): string {
return `\nregisterFile(${JSON.stringify(name)}, ${JSON.stringify({
name,
mimeType: mime.getType(name) ?? undefined,
Expand All @@ -109,22 +116,17 @@ function renderFile(name: string, resolve: (name: string) => string, getLastModi
})});`;
}

async function renderSidebar(
title = "Home",
pages: (Page | Section)[],
root: string,
path: string,
search: boolean,
normalizeLink: (href: string) => string
): Promise<Html> {
async function renderSidebar(options: RenderOptions): Promise<Html> {
const {title = "Home", pages, root, path, search, md} = options;
const {normalizeLink} = md;
return html`<input id="observablehq-sidebar-toggle" type="checkbox" title="Toggle sidebar">
<label id="observablehq-sidebar-backdrop" for="observablehq-sidebar-toggle"></label>
<nav id="observablehq-sidebar">
<ol>
<label id="observablehq-sidebar-close" for="observablehq-sidebar-toggle"></label>
<li class="observablehq-link${
normalizePath(path) === "/index" ? " observablehq-link-active" : ""
}"><a href="${normalizeLink(relativePath(path, "/"))}">${title}</a></li>
}"><a href="${md.normalizeLink(relativePath(path, "/"))}">${title}</a></li>
</ol>${
search
? html`\n <div id="observablehq-search"><input type="search" placeholder="Search"></div>
Expand Down Expand Up @@ -171,7 +173,7 @@ interface Header {
const tocSelector = "h1:not(:first-of-type), h2:first-child, :not(h1) + h2";

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

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

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

function renderFooter(
path: string,
options: Pick<Config, "pages" | "pager" | "title" | "footer">,
data: MarkdownPage["data"],
normalizeLink: (href: string) => string
): Html | null {
let footer = options.footer;
if (data?.footer !== undefined) footer = data?.footer;
function renderFooter(footer: MarkdownPage["footer"], resolvers: HtmlResolvers, options: RenderOptions): Html | null {
const {path, md} = options;
const link = options.pager ? findLink(path, options) : null;
return link || footer
? html`\n<footer id="observablehq-footer">${link ? renderPager(path, link, normalizeLink) : ""}${
footer ? html`\n<div>${html.unsafe(footer)}</div>` : ""
? html`\n<footer id="observablehq-footer">${link ? renderPager(path, link, md.normalizeLink) : ""}${
footer ? html`\n<div>${html.unsafe(rewriteHtml(footer, resolvers))}</div>` : ""
}
</footer>`
: null;
Expand Down
Loading