From 9a6f2a02ce2d3f54e85437f709fb12194532987b Mon Sep 17 00:00:00 2001 From: Wiltse Carpenter Date: Thu, 26 Oct 2023 12:10:22 -0700 Subject: [PATCH 01/13] Add { } attribute support on fenced code blocks --- docs/javascript.md | 22 +++--- src/markdown.ts | 170 ++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 178 insertions(+), 14 deletions(-) diff --git a/docs/javascript.md b/docs/javascript.md index 7affbed8a..40d8deada 100644 --- a/docs/javascript.md +++ b/docs/javascript.md @@ -6,7 +6,7 @@ Observable Markdown supports reactive JavaScript as both fenced code blocks and A top-level variable declared in a JavaScript fenced code block can be referenced in another code block or inline expression on the same page. So if you say: -```js show +```{js show} const x = 1, y = 2; ``` @@ -14,7 +14,7 @@ Then you can reference `x` and `y` elsewhere on the page (with values ${x} and $ To prevent variables from being visible outside the current block, make them local with a block statement: -```js show +```{js show} { const z = 3; } @@ -24,7 +24,7 @@ To prevent variables from being visible outside the current block, make them loc References to top-level variables in other code blocks are reactive: promises are implicitly awaited and generators are implicitly consumed. For example, within the block below, `hello` is a promise. If you reference `hello` from another block, the other block won’t run until `hello` resolves and it will see a string. -```js show +```{js show} const hello = new Promise((resolve) => { setTimeout(() => { resolve("hello"); @@ -36,7 +36,7 @@ Hello is: ${hello}. Values that change over time, such as interactive inputs and animation parameters, are represented as [async generators](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Generator). You won’t typically implement a generator directly; instead you’ll use a built-in implementation such as the `view` function. You can also use [Observable Inputs](https://github.com/observablehq/inputs) to quickly construct HTML input elements. Try entering your name into the box below: -```js show +```{js show} const name = view(Inputs.text({label: "Name", placeholder: "Enter your name"})); ``` @@ -44,7 +44,7 @@ Name is: ${name}. The `view` function calls `Generators.input` under the hood, which takes an input element and returns a generator that yields the input’s value whenever it changes. The code above can be written more explicitly as: -```js no-run +```{js no-run} const nameInput = Inputs.text({label: "Name", placeholder: "Enter your name"}); const name = Generators.input(nameInput); @@ -53,7 +53,7 @@ display(nameInput); As another example, you can use the built-in `Generators.observe` to represent the current pointer coordinates: -```js show +```{js show} const pointer = Generators.observe((notify) => { const pointermoved = (event) => notify([event.clientX, event.clientY]); addEventListener("pointermove", pointermoved); @@ -68,7 +68,7 @@ Pointer is: ${pointer.map(Math.round).join(", ")}. Normally, only the cell that declares a value can define it or assign to it. (This constraint may helpfully encourage you to decouple code.) You can however use the `Mutable` function to declare a mutable generator, allowing other cells to mutate the generator’s value. This approach is akin to React’s `useState` hook. For example: -```js show +```{js show} const count = Mutable(0); const increment = () => ++count.value; const reset = () => count.value = 0; @@ -76,7 +76,7 @@ const reset = () => count.value = 0; In another cell, you can now create buttons to increment and reset the count like so: -```js show +```{js show} Inputs.button([["Increment", increment], ["Reset", reset]]) ``` @@ -104,13 +104,13 @@ As described above, this function displays the given `input` and then returns it You can import a library from npm like so: -```js show +```{js show} import confetti from "npm:canvas-confetti"; ``` Now you can reference the imported `confetti` anywhere on the page. -```js show +```{js show} Inputs.button("Throw confetti!", {reduce: () => confetti()}) ``` @@ -120,7 +120,7 @@ You can also import JavaScript from local ES modules. This allows you to move co You can load files using the built-in `FileAttachment` function. -```js show +```{js show} const gistemp = FileAttachment("gistemp.csv").csv({typed: true}); ``` diff --git a/src/markdown.ts b/src/markdown.ts index d0b7635a8..babb4b92b 100644 --- a/src/markdown.ts +++ b/src/markdown.ts @@ -88,10 +88,11 @@ function uniqueCodeId(context: ParseContext, content: string): string { function makeFenceRenderer(root: string, baseRenderer: RenderRule): RenderRule { return (tokens, idx, options, context: ParseContext, self) => { const token = tokens[idx]; - const [language, option] = token.info.split(" "); + // TODO: Pass context to parseCodeInfo and blend in defaults + const {language, attributes} = parseCodeInfo(token.info); let result = ""; let count = 0; - if (language === "js" && option !== "no-run") { + if (language === "js" && !optionEnabled(attributes, "no-run")) { const id = uniqueCodeId(context, token.content); const transpile = transpileJavaScript(token.content, { id, @@ -104,7 +105,7 @@ function makeFenceRenderer(root: string, baseRenderer: RenderRule): RenderRule { result += `
\n`; count++; } - if (language !== "js" || option === "show" || option === "no-run") { + if (language !== "js" || optionEnabled(attributes, "show") || optionEnabled(attributes, "no-run")) { result += baseRenderer(tokens, idx, options, context, self); count++; } @@ -121,6 +122,12 @@ const CODE_BACKSLASH = 92; const CODE_QUOTE = 34; const CODE_SINGLE_QUOTE = 39; const CODE_BACKTICK = 96; +const CODE_EQUALS = 61; +const CODE_PERIOD = 46; +const CODE_POUND = 35; +const CODE_DASH = 45; +const CODE_UNDERSCORE = 95; +const CODE_COLON = 58; function parsePlaceholder(content: string, replacer: (i: number, j: number) => void) { let afterDollar = false; @@ -173,6 +180,163 @@ function parsePlaceholder(content: string, replacer: (i: number, j: number) => v } } +function isSpace(c: number) { + return c === 0x20 || c === 0x09; +} + +function isAlpha(c: number) { + return (c >= 65 && c <= 90) || (c >= 97 && c <= 122); +} + +function isAlphaNum(c: number) { + return (c >= 65 && c <= 90) || (c >= 97 && c <= 122) || (c >= 48 && c <= 57); +} + +function isIdentifier(c: number) { + return isAlphaNum(c) || c === CODE_DASH || c === CODE_UNDERSCORE || c === CODE_COLON || c === CODE_PERIOD; +} + +interface Attribute { + name: string; + value?: string | boolean; +} + +function parseAttributes(content: string): Attribute[] { + const tokens: Attribute[] = []; + + if (content.charAt(0) !== "{") return []; + + let start = 0; + let state = "begin"; + let inQuote = 0; + let quotedValue = ""; + + for (let j = 1, n = content.length; j < n; ++j) { + const cj = content.charCodeAt(j); + const cjj = j + 1 < n ? content.charCodeAt(j + 1) : 0; + if ((state === "begin" || state === "skip") && cj === CODE_BRACER) break; + switch (state) { + case "begin": + if (isSpace(cj)) continue; + start = j--; + state = "start"; + break; + + case "skip": + // Ignore stray punctuation and numbers + if (isSpace(cj)) state = "begin"; + break; + + case "start": + if (isAlpha(cj)) { + state = "key"; + } else if (isAlpha(cj) || ((cj === CODE_PERIOD || cj === CODE_POUND) && isAlpha(cjj))) { + state = "classid"; + } else { + state = "skip"; + } + break; + + case "quote": + if (cj === CODE_BACKSLASH) { + quotedValue += content.slice(start + 1, j); + start = j++; + } else if (cj === inQuote) { + quotedValue += content.slice(start + 1, j); + if (tokens.length > 0) { + tokens[tokens.length - 1] = {...tokens[tokens.length - 1], value: quotedValue}; + } + quotedValue = ""; + state = "begin"; + } + break; + + case "classid": + if (!isIdentifier(cj)) { + tokens.push({name: content.slice(start, j--)}); + state = "begin"; + j--; + } + break; + + case "key": + if (!isIdentifier(cj)) { + tokens.push({name: content.slice(start, j), value: true}); + if (cj === CODE_EQUALS) { + start = j + 1; + state = "valuestart"; + } else { + state = "begin"; + j--; + } + } + break; + + case "valuestart": + if (cj === CODE_QUOTE || cj === CODE_SINGLE_QUOTE) { + inQuote = cj; + state = "quote"; + break; + } else if (isIdentifier(cj)) { + state = "value"; + j--; + } else { + state = "skip"; + } + break; + + case "value": + if (!isIdentifier(cj)) { + if (tokens.length > 0) { + let value: string | boolean = content.slice(start, j); + if (value.toLowerCase() === "true") { + value = true; + } else if (value.toLowerCase() === "false") { + value = false; + } + tokens[tokens.length - 1].value = value; + } + j--; + state = "begin"; + } + } + } + if (state === "key" || state === "classid") tokens.push({name: content.slice(start)}); + return tokens; +} + +interface CodeInfo { + language?: string; + classes?: string[]; + id?: string; + attributes?: Attribute[]; +} + +function parseCodeInfo(info: string): CodeInfo { + const match = /^(?\w*)(?:\s*(?{.*}))?$/.exec(info); + if (!match) return {}; + let language = match.groups?.language; + let attributes: Attribute[] = []; + let classes: string[] = []; + let id: string | undefined; + if (match.groups?.attributes) { + attributes = parseAttributes(match.groups?.attributes); + if (!language && attributes.length) { + language = attributes.shift()?.name; + } + classes = attributes.filter((attr) => attr.name.charCodeAt(0) === CODE_PERIOD).map((attr) => attr.name.slice(1)); + id = attributes.findLast((attr) => attr.name.charCodeAt(0) === CODE_POUND)?.name.slice(1); + attributes = attributes.filter( + (attr) => attr.name.charCodeAt(0) !== CODE_PERIOD && attr.name.charCodeAt(0) !== CODE_POUND + ); + } + return {language, classes, id, attributes}; +} + +function optionEnabled(attributes: Attribute[] | undefined, name: string) { + return attributes && attributes.some((attr) => attr.name === name && attr.value === true); +} + function transformPlaceholderBlock(token) { const input = token.content; if (/^\s*)/.test(input)) return [token]; // ignore