diff --git a/src/config.ts b/src/config.ts index 8e53688ce..98c76c157 100644 --- a/src/config.ts +++ b/src/config.ts @@ -94,6 +94,12 @@ interface DuckDBExtensionConfigSpec { load: unknown; } +export type CodeExtensionFunction = (content: string, attributes: Record) => string; + +export interface CodeExtensions { + [tag: string]: CodeExtensionFunction; +} + export interface Config { root: string; // defaults to src output: string; // defaults to dist @@ -117,6 +123,7 @@ export interface Config { loaders: LoaderResolver; watchPath?: string; duckdb: DuckDBConfig; + codeExtensions: CodeExtensions; // defaults to {} } export interface ConfigSpec { @@ -147,6 +154,7 @@ export interface ConfigSpec { preserveExtension?: unknown; markdownIt?: unknown; duckdb?: unknown; + codeExtensions?: unknown; } interface ScriptSpec { @@ -283,6 +291,7 @@ export function normalizeConfig(spec: ConfigSpec = {}, defaultRoot?: string, wat const interpreters = normalizeInterpreters(spec.interpreters as any); const normalizePath = getPathNormalizer(spec); const duckdb = normalizeDuckDB(spec.duckdb); + const codeExtensions = normalizeCodeExtensions(spec.codeExtensions); // If this path ends with a slash, then add an implicit /index to the // end of the path. Otherwise, remove the .html extension (we use clean @@ -334,7 +343,8 @@ export function normalizeConfig(spec: ConfigSpec = {}, defaultRoot?: string, wat normalizePath, loaders: new LoaderResolver({root, interpreters}), watchPath, - duckdb + duckdb, + codeExtensions }; if (pages === undefined) Object.defineProperty(config, "pages", {get: () => readPages(root, md)}); if (sidebar === undefined) Object.defineProperty(config, "sidebar", {get: () => config.pages.length > 0}); @@ -489,6 +499,17 @@ function normalizeInterpreters(spec: {[key: string]: unknown} = {}): {[key: stri ); } +function normalizeCodeExtensions(spec: unknown): CodeExtensions { + if (spec == null) return {}; + if (typeof spec !== "object") throw new Error("codeExtensions must be an object"); + const extensions: CodeExtensions = {}; + for (const [tag, fn] of Object.entries(spec)) { + if (typeof fn !== "function") throw new Error(`codeExtensions.${tag} must be a function`); + extensions[tag] = fn as CodeExtensionFunction; + } + return extensions; +} + function normalizeToc(spec: TableOfContentsSpec | boolean = true): TableOfContents { const toc = typeof spec === "boolean" ? {show: spec} : (spec as TableOfContentsSpec); const label = toc.label === undefined ? "Contents" : String(toc.label); diff --git a/src/markdown.ts b/src/markdown.ts index cb43b5726..b5eaec2f8 100644 --- a/src/markdown.ts +++ b/src/markdown.ts @@ -8,7 +8,7 @@ import type {RuleInline} from "markdown-it/lib/parser_inline.mjs"; import type {RenderRule} from "markdown-it/lib/renderer.mjs"; import type Token from "markdown-it/lib/token.mjs"; import MarkdownItAnchor from "markdown-it-anchor"; -import type {Config} from "./config.js"; +import type {CodeExtensions, Config} from "./config.js"; import {mergeStyle} from "./config.js"; import type {FrontMatter} from "./frontMatter.js"; import {readFrontMatter} from "./frontMatter.js"; @@ -50,6 +50,7 @@ interface ParseContext { currentLine: number; path: string; params?: Params; + codeExtensions: CodeExtensions; } function uniqueCodeId(context: ParseContext, content: string): string { @@ -72,7 +73,14 @@ function transpileJavaScript(content: string, tag: "ts" | "jsx" | "tsx"): string } } -function getLiveSource(content: string, tag: string, attributes: Record): string | undefined { +function getLiveSource( + content: string, + tag: string, + attributes: Record, + codeExtensions: CodeExtensions = {} +): string | undefined { + const codeExtension = codeExtensions[tag]; + if (codeExtension) return codeExtension(content, attributes); return tag === "js" ? content : tag === "ts" || tag === "jsx" || tag === "tsx" @@ -112,14 +120,14 @@ function getLiveSource(content: string, tag: string, attributes: Record { - const {path, params} = context; + const {path, params, codeExtensions} = context; const token = tokens[idx]; const {tag, attributes} = parseInfo(token.info); token.info = tag; let html = ""; let source: string | undefined; try { - source = isFalse(attributes.run) ? undefined : getLiveSource(token.content, tag, attributes); + source = isFalse(attributes.run) ? undefined : getLiveSource(token.content, tag, attributes, codeExtensions); if (source != null) { const id = uniqueCodeId(context, source); // TODO const sourceLine = context.startLine + context.currentLine; @@ -219,6 +227,7 @@ export interface ParseOptions { footer?: Config["footer"]; source?: string; params?: Params; + codeExtensions?: Config["codeExtensions"]; } export function createMarkdownIt({ @@ -244,10 +253,10 @@ export function createMarkdownIt({ } export function parseMarkdown(input: string, options: ParseOptions): MarkdownPage { - const {md, path, source = path, params} = options; + const {md, path, source = path, params, codeExtensions = {}} = options; const {content, data} = readFrontMatter(input); const code: MarkdownCode[] = []; - const context: ParseContext = {code, startLine: 0, currentLine: 0, path, params}; + const context: ParseContext = {code, startLine: 0, currentLine: 0, path, params, codeExtensions}; 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); diff --git a/test/code-extensions-test.ts b/test/code-extensions-test.ts new file mode 100644 index 000000000..c8cfc20ea --- /dev/null +++ b/test/code-extensions-test.ts @@ -0,0 +1,23 @@ +import assert from "node:assert"; +import {normalizeConfig} from "../src/config.js"; +import {parseMarkdown} from "../src/markdown.js"; + +describe("codeExtensions", () => { + it("transforms content with custom extension", () => { + const {md, codeExtensions} = normalizeConfig({ + root: "docs", + codeExtensions: { + uppercase: (content: string) => { + return `"${content.trim().toUpperCase()}"`; + } + } + }); + + const input = "```uppercase\nhello world\n```"; + const page = parseMarkdown(input, {path: "test.md", md, codeExtensions}); + + assert.strictEqual(page.code.length, 1, "should have one code block"); + const source = page.code[0].node.input; + assert.ok(source.includes("HELLO WORLD"), "should transform content to uppercase"); + }); +}); diff --git a/test/config-test.ts b/test/config-test.ts index 82c81daac..e54ce41b4 100644 --- a/test/config-test.ts +++ b/test/config-test.ts @@ -65,7 +65,8 @@ describe("readConfig(undefined, root)", () => { 'Built with Observable on Jan 10, 2024.', search: null, watchPath: resolve("test/input/build/config/observablehq.config.js"), - duckdb: DUCKDB_DEFAULTS + duckdb: DUCKDB_DEFAULTS, + codeExtensions: {} }); }); it("returns the default config if no config file is found", async () => { @@ -94,7 +95,8 @@ describe("readConfig(undefined, root)", () => { 'Built with Observable on Jan 10, 2024.', search: null, watchPath: undefined, - duckdb: DUCKDB_DEFAULTS + duckdb: DUCKDB_DEFAULTS, + codeExtensions: {} }); }); });