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/`.
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;
}