From 6bc0f4d1ec59cd8cf595cf7b15aa9d0b09b1911d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Thu, 28 Mar 2024 12:55:25 +0100 Subject: [PATCH 1/2] support opengraph --- docs/opengraph.md | 7 +++++++ observablehq.config.ts | 36 +++++++++++++++++++++++++----------- src/config.ts | 42 +++++++++++++++++++++++++++++++----------- src/frontMatter.ts | 8 ++++---- src/html.ts | 14 ++++++++++---- src/markdown.ts | 22 +++++++++++----------- 6 files changed, 88 insertions(+), 41 deletions(-) create mode 100644 docs/opengraph.md diff --git a/docs/opengraph.md b/docs/opengraph.md new file mode 100644 index 000000000..0b4320c3f --- /dev/null +++ b/docs/opengraph.md @@ -0,0 +1,7 @@ +--- +thumbnail: favicon.png +--- + +# Hello, opengraph + +This generates an opengraph card at the top of the page. diff --git a/observablehq.config.ts b/observablehq.config.ts index 7c58b4cbd..7c38b6f32 100644 --- a/observablehq.config.ts +++ b/observablehq.config.ts @@ -1,4 +1,5 @@ import {formatPrefix} from "d3-format"; +import type {Config} from "./src/config.js"; let stargazers_count: number; try { @@ -8,6 +9,28 @@ try { stargazers_count = NaN; } +const headprod = process.env.CI + ? ` + + +` + : ""; + +function openGraph(path, data, config) { + return ` +${ + data.thumbnail ? `\n` : "" + } +`; +} + +const head = function (path: string, data: any /* FrontMatter */, config: Config) { + const og = openGraph(path, data, config); + return ` + +${og}${headprod} - -` - : "" - } -`, + origin: "https://observablehq.com/framework", + head, header: `
diff --git a/src/config.ts b/src/config.ts index 9a34962d6..444a661cb 100644 --- a/src/config.ts +++ b/src/config.ts @@ -9,6 +9,7 @@ import type MarkdownIt from "markdown-it"; import {LoaderResolver} from "./dataloader.js"; import {visitMarkdownFiles} from "./files.js"; import {formatIsoDate, formatLocaleDate} from "./format.js"; +import type {FrontMatter} from "./frontMatter.js"; import {createMarkdownIt, parseMarkdownMetadata} from "./markdown.js"; import {isAssetPath, parseRelativeUrl, resolvePath} from "./path.js"; import {resolveTheme} from "./theme.js"; @@ -39,18 +40,21 @@ export interface Script { type: string | null; } +type HtmlFormatter = (path: string, data: FrontMatter, options: Config) => string; + export interface Config { root: string; // defaults to docs output: string; // defaults to dist - base: string; // defaults to "/" + base: string; // e.g. "/framework/"; defaults to "/" + origin: string; // e.g. "https://observablehq.com"; defaults to "" title?: string; sidebar: boolean; // defaults to true if pages isn’t empty pages: (Page | Section)[]; pager: boolean; // defaults to true scripts: Script[]; // defaults to empty array - head: string; // defaults to empty string - header: string; // defaults to empty string - footer: string; // defaults to “Built with Observable on [date].” + head: HtmlFormatter; // defaults to empty string + header: HtmlFormatter; // defaults to empty string + footer: HtmlFormatter; // defaults to “Built with Observable on [date].” toc: TableOfContents; style: null | Style; // defaults to {theme: ["light", "dark"]} deploy: null | {workspace: string; project: string}; @@ -133,7 +137,8 @@ export function normalizeConfig(spec: any = {}, defaultRoot = "docs", watchPath? let { root = defaultRoot, output = "dist", - base = "/", + base, + origin = "", sidebar, style, theme = "default", @@ -149,7 +154,7 @@ export function normalizeConfig(spec: any = {}, defaultRoot = "docs", watchPath? } = spec; root = String(root); output = String(output); - base = normalizeBase(base); + ({origin, base} = normalizeOrigin(origin, base)); if (style === null) style = null; else if (style !== undefined) style = {path: String(style)}; else style = {theme: (theme = normalizeTheme(theme))}; @@ -160,9 +165,9 @@ export function normalizeConfig(spec: any = {}, defaultRoot = "docs", watchPath? if (sidebar !== undefined) sidebar = Boolean(sidebar); pager = Boolean(pager); scripts = Array.from(scripts, normalizeScript); - head = String(head); - header = String(header); - footer = String(footer); + head = htmlFormatter(head); + header = htmlFormatter(header); + footer = htmlFormatter(footer); toc = normalizeToc(toc); deploy = deploy ? {workspace: String(deploy.workspace).replace(/^@+/, ""), project: String(deploy.project)} : null; search = Boolean(search); @@ -170,6 +175,7 @@ export function normalizeConfig(spec: any = {}, defaultRoot = "docs", watchPath? const config = { root, output, + origin, base, title, sidebar, @@ -193,11 +199,19 @@ export function normalizeConfig(spec: any = {}, defaultRoot = "docs", watchPath? return config; } -function normalizeBase(base: any): string { +function normalizeOrigin(origin: any, base: any): {origin: string; base: string} { + origin = String(origin); + if (!origin) { + if (base === undefined) base = "/"; + else base = String(base); + } else { + if (!/^\w+:/.test(origin)) throw new Error(`origin must start with protocol (https:) ${origin}`); + if (base === undefined) ({origin, pathname: base} = new URL(origin)); + } base = String(base); if (!base.startsWith("/")) throw new Error(`base must start with slash: ${base}`); if (!base.endsWith("/")) base += "/"; - return base; + return {origin, base}; } export function normalizeTheme(spec: any): string[] { @@ -217,6 +231,12 @@ function normalizePageOrSection(spec: any): Page | Section { return ("pages" in spec ? normalizeSection : normalizePage)(spec); } +function htmlFormatter(spec: any): HtmlFormatter { + return typeof spec === "function" + ? (...args) => String(spec(...args)) + : ((spec = String(spec) as string), () => spec); +} + function normalizeSection(spec: any): Section { let {name, open = true, pages} = spec; name = String(name); diff --git a/src/frontMatter.ts b/src/frontMatter.ts index fc91fbece..965683990 100644 --- a/src/frontMatter.ts +++ b/src/frontMatter.ts @@ -15,6 +15,7 @@ export interface FrontMatter { draft?: boolean; sidebar?: boolean; sql?: {[key: string]: string}; + [key: string]: any; } export function readFrontMatter(input: string): {content: string; data: FrontMatter} { @@ -31,9 +32,8 @@ export function readFrontMatter(input: string): {content: string; data: FrontMat } 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, head, header, footer, style, theme} = spec; + if (spec == null || typeof spec !== "object") return {} as FrontMatter; + const {title, sidebar, toc, index, keywords, draft, sql, head, header, footer, style, theme, ...frontMatter} = spec; if (title !== undefined) frontMatter.title = stringOrNull(title); if (sidebar !== undefined) frontMatter.sidebar = Boolean(sidebar); if (toc !== undefined) frontMatter.toc = normalizeToc(toc); @@ -46,7 +46,7 @@ export function normalizeFrontMatter(spec: any = {}): FrontMatter { if (footer !== undefined) frontMatter.footer = stringOrNull(footer); if (style !== undefined) frontMatter.style = stringOrNull(style); if (theme !== undefined) frontMatter.theme = normalizeTheme(theme); - return frontMatter; + return frontMatter as FrontMatter; } function stringOrNull(spec: unknown): string | null { diff --git a/src/html.ts b/src/html.ts index 3b0f72ad5..78c93fa1d 100644 --- a/src/html.ts +++ b/src/html.ts @@ -5,6 +5,8 @@ import type {DOMWindow} from "jsdom"; import {JSDOM, VirtualConsole} from "jsdom"; import {isAssetPath, relativePath, resolveLocalPath} from "./path.js"; +const ABSOLUTE_PATH_ATTRIBUTES: readonly [selector: string, src: string][] = [["meta[property='og:image']", "content"]]; + const ASSET_ATTRIBUTES: readonly [selector: string, src: string][] = [ ["a[href][download]", "href"], ["audio source[src]", "src"], @@ -14,7 +16,8 @@ const ASSET_ATTRIBUTES: readonly [selector: string, src: string][] = [ ["link[href]", "href"], ["picture source[srcset]", "srcset"], ["video source[src]", "src"], - ["video[src]", "src"] + ["video[src]", "src"], + ...ABSOLUTE_PATH_ATTRIBUTES ]; const PATH_ATTRIBUTES: readonly [selector: string, src: string][] = [ @@ -26,7 +29,8 @@ const PATH_ATTRIBUTES: readonly [selector: string, src: string][] = [ ["link[href]", "href"], ["picture source[srcset]", "srcset"], ["video source[src]", "src"], - ["video[src]", "src"] + ["video[src]", "src"], + ...ABSOLUTE_PATH_ATTRIBUTES ]; export function isJavaScript({type}: HTMLScriptElement): boolean { @@ -135,8 +139,10 @@ export function rewriteHtml( 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)); + let source = decodeURI(element.getAttribute(src)!); + source = src === "srcset" ? resolveSrcset(source, maybeResolveFile) : maybeResolveFile(source); + console.warn({selector, src, source, html}); + element.setAttribute(src, source); } } diff --git a/src/markdown.ts b/src/markdown.ts index 4774c17e6..139f73713 100644 --- a/src/markdown.ts +++ b/src/markdown.ts @@ -325,20 +325,23 @@ export function createMarkdownIt({ return markdownIt === undefined ? md : markdownIt(md); } -export function parseMarkdown(input: string, options: ParseOptions): MarkdownPage { +export function parseMarkdown(input: string, options: ParseOptions & Config): MarkdownPage { const {md, path} = options; const {content, data} = readFrontMatter(input); const code: MarkdownCode[] = []; const context: ParseContext = {code, startLine: 0, currentLine: 0, path}; const tokens = md.parse(content, context); const body = md.renderer.render(tokens, md.options, context); // Note: mutates code! + const title = data.title !== undefined ? data.title : findTitle(tokens); + console.warn({data}); + const meta = {...data, title}; return { - head: getHtml("head", data, options), - header: getHtml("header", data, options), + head: getHtml("head", meta, options), + header: getHtml("header", meta, options), body, - footer: getHtml("footer", data, options), + footer: getHtml("footer", meta, options), data, - title: data.title !== undefined ? data.title : findTitle(tokens), + title, style: getStyle(data, options), code }; @@ -357,17 +360,14 @@ export function parseMarkdownMetadata(input: string, options: ParseOptions): Pic }; } -function getHtml( - key: "head" | "header" | "footer", - data: FrontMatter, - {path, [key]: defaultValue}: ParseOptions -): string | null { +function getHtml(key: "head" | "header" | "footer", data: FrontMatter, config: Config & ParseOptions): string | null { + const {path, [key]: defaultValue} = config; return data[key] !== undefined ? data[key] ? String(data[key]) : null : defaultValue != null - ? rewriteHtmlPaths(defaultValue, path) + ? rewriteHtmlPaths(defaultValue(path, data, config), path) : null; } From b51635bfc2a2571a8cc62acf2dfc21773b7b6f92 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Philippe=20Rivi=C3=A8re?= Date: Thu, 28 Mar 2024 13:55:42 +0100 Subject: [PATCH 2/2] document --- docs/config.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/config.md b/docs/config.md index d5b53209b..c37ac1b31 100644 --- a/docs/config.md +++ b/docs/config.md @@ -174,6 +174,10 @@ export default { The base path when serving the site. Currently this only affects the custom 404 page, if any. +## origin + +The site’s [origin](https://developer.mozilla.org/en-US/docs/Web/API/URL/origin). As a shorthand, specify a complete URL of the site’s home page to define both origin and base. + ## cleanUrls Whether page links should be “clean”, _i.e._, formatted without a `.html` extension. Defaults to true. If true, a link to `config.html` will be formatted as `config`. Regardless of this setting, a link to an index page will drop the implied `index.html`; for example `foo/index.html` will be formatted as `foo/`.