diff --git a/observablehq.config.ts b/observablehq.config.ts index a3d966128..0adf38aa0 100644 --- a/observablehq.config.ts +++ b/observablehq.config.ts @@ -1,5 +1,6 @@ export default { title: "Observable CLI", + echo: false, pages: [ {name: "Getting started", path: "/getting-started"}, {name: "Routing", path: "/routing"}, diff --git a/src/config.ts b/src/config.ts index 43c8bad95..5f08a282a 100644 --- a/src/config.ts +++ b/src/config.ts @@ -31,6 +31,7 @@ export interface Config { output: string; // defaults to dist title?: string; pages: (Page | Section)[]; // TODO rename to sidebar? + echo: null | string; // defaults to null pager: boolean; // defaults to true footer: string; toc: TableOfContents; @@ -60,7 +61,7 @@ async function readPages(root: string): Promise { const pages: Page[] = []; for await (const file of visitFiles(root)) { if (file === "index.md" || file === "404.md" || extname(file) !== ".md") continue; - const parsed = await parseMarkdown(await readFile(join(root, file), "utf-8"), root, file); + const parsed = await parseMarkdown(await readFile(join(root, file), "utf-8"), root, file, null); const name = basename(file, ".md"); const page = {path: join("/", dirname(file), name), name: parsed.title ?? "Untitled"}; if (name === "index") pages.unshift(page); @@ -91,14 +92,15 @@ export async function normalizeConfig(spec: any = {}, defaultRoot = "docs"): Pro if (style === null) style = null; else if (style !== undefined) style = {path: String(style)}; else style = {theme: (theme = normalizeTheme(theme))}; - let {title, pages = await readPages(root), pager = true, toc = true} = spec; + let {title, pages = await readPages(root), pager = true, toc = true, echo = null} = spec; if (title !== undefined) title = String(title); pages = Array.from(pages, normalizePageOrSection); pager = Boolean(pager); + echo = echo !== null ? String(echo) : null; footer = String(footer); toc = normalizeToc(toc); deploy = deploy ? {workspace: String(deploy.workspace).replace(/^@+/, ""), project: String(deploy.project)} : null; - return {root, output, title, pages, pager, footer, toc, style, deploy}; + return {root, output, title, pages, echo, pager, footer, toc, style, deploy}; } function normalizeTheme(spec: any): string[] { diff --git a/src/markdown.ts b/src/markdown.ts index 4477c5480..3b1ad11bc 100644 --- a/src/markdown.ts +++ b/src/markdown.ts @@ -104,7 +104,7 @@ function getLiveSource(content: string, tag: string): string | undefined { : undefined; } -function makeFenceRenderer(root: string, baseRenderer: RenderRule, sourcePath: string): RenderRule { +function makeFenceRenderer(root: string, baseRenderer: RenderRule, sourcePath: string, echo: boolean): RenderRule { return (tokens, idx, options, context: ParseContext, self) => { const token = tokens[idx]; const {tag, attributes} = parseInfo(token.info); @@ -129,7 +129,8 @@ function makeFenceRenderer(root: string, baseRenderer: RenderRule, sourcePath: s }">\n`; count++; } - if (attributes.echo == null ? source == null : !isFalse(attributes.echo)) { + // Conditionally render fanced block source. + if (attributes.echo == null ? echo || source == null : !isFalse(attributes.echo)) { result += baseRenderer(tokens, idx, options, context, self); count++; } @@ -418,16 +419,31 @@ async function toParseCells(pieces: RenderPiece[]): Promise { return cellPieces; } +// Combine project and page level echo configuration +function isEcho(project: null | string, page: undefined | boolean): boolean { + return page == null ? project != null && !isFalse(project) : page; +} + // TODO We need to know what line in the source the markdown starts on and pass // that as startLine in the parse context below. -export async function parseMarkdown(source: string, root: string, sourcePath: string): Promise { +export async function parseMarkdown( + source: string, + root: string, + sourcePath: string, + echo: null | string +): Promise { const parts = matter(source, {}); const md = MarkdownIt({html: true}); md.use(MarkdownItAnchor, {permalink: MarkdownItAnchor.permalink.headerLink({class: "observablehq-header-anchor"})}); md.inline.ruler.push("placeholder", transformPlaceholderInline); md.core.ruler.before("linkify", "placeholder", transformPlaceholderCore); md.renderer.rules.placeholder = makePlaceholderRenderer(root, sourcePath); - md.renderer.rules.fence = makeFenceRenderer(root, md.renderer.rules.fence!, sourcePath); + md.renderer.rules.fence = makeFenceRenderer( + root, + md.renderer.rules.fence!, + sourcePath, + isEcho(echo, parts.data?.echo) + ); md.renderer.rules.softbreak = makeSoftbreakRenderer(md.renderer.rules.softbreak!); md.renderer.render = renderIntoPieces(md.renderer, root, sourcePath); const context: ParseContext = {files: [], imports: [], pieces: [], startLine: 0, currentLine: 0}; @@ -538,8 +554,8 @@ export function diffMarkdown({parse: prevParse}: ReadMarkdownResult, {parse: nex .map(diffReducer); } -export async function readMarkdown(path: string, root: string): Promise { +export async function readMarkdown(path: string, root: string, echo: null | string): Promise { const contents = await readFile(join(root, path), "utf-8"); - const parse = await parseMarkdown(contents, root, path); + const parse = await parseMarkdown(contents, root, path, echo); return {contents, parse}; } diff --git a/src/preview.ts b/src/preview.ts index 9e57d6f6f..4f1dbfa44 100644 --- a/src/preview.ts +++ b/src/preview.ts @@ -276,7 +276,7 @@ export function getPreviewStylesheet(path: string, data: ParseResult["data"], st : relativeUrl(path, `/_observablehq/theme-${style.theme.join(",")}.css`); } -function handleWatch(socket: WebSocket, req: IncomingMessage, {root, style: defaultStyle}: Config) { +function handleWatch(socket: WebSocket, req: IncomingMessage, {root, echo, style: defaultStyle}: Config) { let path: string | null = null; let current: ReadMarkdownResult | null = null; let stylesheets: Set | null = null; @@ -322,7 +322,7 @@ function handleWatch(socket: WebSocket, req: IncomingMessage, {root, style: defa break; } case "change": { - const updated = await readMarkdown(path, root); + const updated = await readMarkdown(path, root, echo); if (current.parse.hash === updated.parse.hash) break; const updatedStylesheets = await getStylesheets(updated.parse); for (const href of difference(stylesheets, updatedStylesheets)) send({type: "remove-stylesheet", href}); @@ -344,7 +344,7 @@ function handleWatch(socket: WebSocket, req: IncomingMessage, {root, style: defa if (!(path = normalize(path)).startsWith("/")) throw new Error("Invalid path: " + initialPath); if (path.endsWith("/")) path += "index"; path += ".md"; - current = await readMarkdown(path, root); + current = await readMarkdown(path, root, echo); if (current.parse.hash !== initialHash) return void send({type: "reload"}); stylesheets = await getStylesheets(current.parse); attachmentWatcher = await FileWatchers.of(root, path, getWatchPaths(current.parse), refreshAttachment); diff --git a/src/render.ts b/src/render.ts index a7f252adc..9a4578b96 100644 --- a/src/render.ts +++ b/src/render.ts @@ -23,10 +23,11 @@ export interface Render { export interface RenderOptions extends Config { root: string; path: string; + echo: string | null; } export async function renderPreview(source: string, options: RenderOptions): Promise { - const parseResult = await parseMarkdown(source, options.root, options.path); + const parseResult = await parseMarkdown(source, options.root, options.path, options.echo); return { html: await render(parseResult, {...options, preview: true}), files: parseResult.files, @@ -36,7 +37,7 @@ export async function renderPreview(source: string, options: RenderOptions): Pro } export async function renderServerless(source: string, options: RenderOptions): Promise { - const parseResult = await parseMarkdown(source, options.root, options.path); + const parseResult = await parseMarkdown(source, options.root, options.path, options.echo); return { html: await render(parseResult, options), files: parseResult.files, diff --git a/test/config-test.ts b/test/config-test.ts index 04ba3a7cb..f2394098d 100644 --- a/test/config-test.ts +++ b/test/config-test.ts @@ -21,6 +21,7 @@ describe("readConfig(undefined, root)", () => { pager: true, footer: 'Built with Observable on Jan 11, 2024.', + echo: null, deploy: { workspace: "acme", project: "bi" @@ -38,6 +39,7 @@ describe("readConfig(undefined, root)", () => { pager: true, footer: 'Built with Observable on Jan 11, 2024.', + echo: null, deploy: null }); }); diff --git a/test/input/build/echo/fenced-code-options-with-echo.md b/test/input/build/echo/fenced-code-options-with-echo.md new file mode 100644 index 000000000..8cfa18054 --- /dev/null +++ b/test/input/build/echo/fenced-code-options-with-echo.md @@ -0,0 +1,39 @@ +--- +echo: true +--- + +# Fenced code options + +```js echo +function add(a, b) { + return a + b; +} +``` + +```js +function bareJs() { + return 1; +} +``` + +```js echo +function langOutside() { + return 1; +} +``` + +```js echo=false whatever +function langAndAttributes() { + return 1; +} +``` + +```js echo=false run=false +function langAndAttributes() { + return 1; +} +``` + +```py echo=false +1 + 2 +``` diff --git a/test/input/build/echo/fenced-code-options.md b/test/input/build/echo/fenced-code-options.md new file mode 100644 index 000000000..75215c155 --- /dev/null +++ b/test/input/build/echo/fenced-code-options.md @@ -0,0 +1,35 @@ +# Fenced code options + +```js echo +function add(a, b) { + return a + b; +} +``` + +```js +function bareJs() { + return 1; +} +``` + +```js echo +function langOutside() { + return 1; +} +``` + +```js echo=false whatever +function langAndAttributes() { + return 1; +} +``` + +```js echo=false run=false +function langAndAttributes() { + return 1; +} +``` + +```py echo=false +1 + 2 +``` diff --git a/test/input/build/echo/observablehq.config.ts b/test/input/build/echo/observablehq.config.ts new file mode 100644 index 000000000..fce7dc401 --- /dev/null +++ b/test/input/build/echo/observablehq.config.ts @@ -0,0 +1,3 @@ +export default { + echo: "show" +}; diff --git a/test/input/fenced-code-options-with-blocks.md b/test/input/fenced-code-options-with-blocks.md new file mode 100644 index 000000000..69c947c15 --- /dev/null +++ b/test/input/fenced-code-options-with-blocks.md @@ -0,0 +1,39 @@ +--- +blocks: show +--- + +# Fenced code options + +```js echo +function add(a, b) { + return a + b; +} +``` + +```js +function bareJs() { + return 1; +} +``` + +```js echo +function langOutside() { + return 1; +} +``` + +```js echo=false whatever +function langAndAttributes() { + return 1; +} +``` + +```js echo=false run=false +function langAndAttributes() { + return 1; +} +``` + +```py echo=false +1 + 2 +``` diff --git a/test/markdown-test.ts b/test/markdown-test.ts index c17cf8162..678ca0e9d 100644 --- a/test/markdown-test.ts +++ b/test/markdown-test.ts @@ -22,7 +22,7 @@ describe("parseMarkdown(input)", () => { const outname = only || skip ? name.slice(5) : name; (only ? it.only : skip ? it.skip : it)(`test/input/${name}`, async () => { - const snapshot = await parseMarkdown(await readFile(path, "utf8"), "test/input", name); + const snapshot = await parseMarkdown(await readFile(path, "utf8"), "test/input", name, null); let allequal = true; for (const ext of ["html", "json"]) { const actual = ext === "json" ? jsonMeta(snapshot) : snapshot[ext]; diff --git a/test/output/build/echo/fenced-code-options-with-echo.html b/test/output/build/echo/fenced-code-options-with-echo.html new file mode 100644 index 000000000..390f5b42d --- /dev/null +++ b/test/output/build/echo/fenced-code-options-with-echo.html @@ -0,0 +1,87 @@ + + + +Fenced code options + + + + + + + + + + + + + + +
+
+

Fenced code options

+
+
function add(a, b) {
+  return a + b;
+}
+
+
+
function bareJs() {
+  return 1;
+}
+
+
+
function langOutside() {
+  return 1;
+}
+
+
+
+ +
diff --git a/test/output/build/echo/fenced-code-options.html b/test/output/build/echo/fenced-code-options.html new file mode 100644 index 000000000..2ff764b85 --- /dev/null +++ b/test/output/build/echo/fenced-code-options.html @@ -0,0 +1,87 @@ + + + +Fenced code options + + + + + + + + + + + + + + +
+
+

Fenced code options

+
+
function add(a, b) {
+  return a + b;
+}
+
+
+
function bareJs() {
+  return 1;
+}
+
+
+
function langOutside() {
+  return 1;
+}
+
+
+
+ +
diff --git a/test/output/fenced-code-options-with-blocks.html b/test/output/fenced-code-options-with-blocks.html new file mode 100644 index 000000000..4f8f13b23 --- /dev/null +++ b/test/output/fenced-code-options-with-blocks.html @@ -0,0 +1,14 @@ +

Fenced code options

+
+
function add(a, b) {
+  return a + b;
+}
+
+
+
+
function langOutside() {
+  return 1;
+}
+
+
+ \ No newline at end of file diff --git a/test/output/fenced-code-options-with-blocks.json b/test/output/fenced-code-options-with-blocks.json new file mode 100644 index 000000000..328a9e93f --- /dev/null +++ b/test/output/fenced-code-options-with-blocks.json @@ -0,0 +1,99 @@ +{ + "data": { + "blocks": "show" + }, + "title": "Fenced code options", + "files": [], + "imports": [], + "pieces": [ + { + "type": "html", + "id": "", + "cellIds": [], + "html": "

Fenced code options

\n" + }, + { + "type": "html", + "id": "", + "cellIds": [ + "fe9e095e" + ], + "html": "
\n
function add(a, b) {\n  return a + b;\n}\n
\n
" + }, + { + "type": "html", + "id": "", + "cellIds": [ + "4bf815ab" + ], + "html": "
\n" + }, + { + "type": "html", + "id": "", + "cellIds": [ + "d41a5631" + ], + "html": "
\n
function langOutside() {\n  return 1;\n}\n
\n
" + }, + { + "type": "html", + "id": "", + "cellIds": [ + "2f4eff36" + ], + "html": "
\n" + }, + { + "type": "html", + "id": "", + "cellIds": [], + "html": "" + }, + { + "type": "html", + "id": "", + "cellIds": [], + "html": "" + } + ], + "cells": [ + { + "type": "cell", + "id": "fe9e095e", + "expression": false, + "outputs": [ + "add" + ], + "body": "() => {\nfunction add(a, b) {\n return a + b;\n}\nreturn {add};\n}" + }, + { + "type": "cell", + "id": "4bf815ab", + "expression": false, + "outputs": [ + "bareJs" + ], + "body": "() => {\nfunction bareJs() {\n return 1;\n}\nreturn {bareJs};\n}" + }, + { + "type": "cell", + "id": "d41a5631", + "expression": false, + "outputs": [ + "langOutside" + ], + "body": "() => {\nfunction langOutside() {\n return 1;\n}\nreturn {langOutside};\n}" + }, + { + "type": "cell", + "id": "2f4eff36", + "expression": false, + "outputs": [ + "langAndAttributes" + ], + "body": "() => {\nfunction langAndAttributes() {\n return 1;\n}\nreturn {langAndAttributes};\n}" + } + ], + "hash": "ec47fece52c6cf6f693413531f76f7d94d94950abe774b2a79c2ae14bef6cb26" +} \ No newline at end of file