diff --git a/profiles/aom.js b/profiles/aom.js index db075aa1d6..fa17e2fb5c 100644 --- a/profiles/aom.js +++ b/profiles/aom.js @@ -42,6 +42,7 @@ const modules = [ import("../src/core/highlight-vars.js"), import("../src/core/data-type.js"), import("../src/core/anchor-expander.js"), + import("../src/core/grammar-boxes.js"), import("../src/core/dfn-panel.js"), import("../src/core/custom-elements/index.js"), import("../src/core/dfn-contract.js"), diff --git a/profiles/dini.js b/profiles/dini.js index 0315e6d46e..9f6e6649fb 100644 --- a/profiles/dini.js +++ b/profiles/dini.js @@ -42,6 +42,7 @@ const modules = [ import("../src/core/highlight-vars.js"), import("../src/core/data-type.js"), import("../src/core/anchor-expander.js"), + import("../src/core/grammar-boxes.js"), import("../src/core/dfn-panel.js"), import("../src/core/custom-elements/index.js"), import("../src/core/dfn-contract.js"), diff --git a/profiles/geonovum.js b/profiles/geonovum.js index 798a323b35..238e9a7d4d 100644 --- a/profiles/geonovum.js +++ b/profiles/geonovum.js @@ -41,6 +41,7 @@ const modules = [ import("../src/core/list-sorter.js"), import("../src/core/highlight-vars.js"), import("../src/core/anchor-expander.js"), + import("../src/core/grammar-boxes.js"), import("../src/core/dfn-panel.js"), import("../src/core/dfn-contract.js"), /* Linter must be the last thing to run */ diff --git a/profiles/w3c.js b/profiles/w3c.js index cdf270af71..ee8e8c3588 100644 --- a/profiles/w3c.js +++ b/profiles/w3c.js @@ -30,6 +30,7 @@ const modules = [ import("../src/core/tables.js"), import("../src/core/webidl.js"), import("../src/core/cddl.js"), + import("../src/core/grammar-boxes.js"), import("../src/core/biblio.js"), import("../src/core/link-to-dfn.js"), import("../src/core/xref.js"), diff --git a/src/core/grammar-boxes.js b/src/core/grammar-boxes.js new file mode 100644 index 0000000000..02f6eca900 --- /dev/null +++ b/src/core/grammar-boxes.js @@ -0,0 +1,77 @@ +// @ts-check +import { createCopyButton, injectCopyScript } from "./clipboard.js"; +import { addHashId } from "./utils.js"; +import css from "../styles/grammar.css.js"; + +export const name = "core/grammar-boxes"; + +/** @type {ReadonlyMap} */ +const GRAMMARS = new Map([ + ["abnf", "ABNF"], + ["ebnf", "EBNF"], + ["bnf", "BNF"], +]); + +/** + * Add a header badge and copy button to a single grammar pre block. + * Wraps the block content in a element (matching WebIDL/CDDL pattern) + * so that core/highlight can pick it up via the `pre > code` selector. + * + * @param {HTMLPreElement} pre + * @param {string} label - display label, e.g. "ABNF" + * @param {string} lang - grammar class name, e.g. "abnf" + */ +function processGrammarBlock(pre, label, lang) { + addHashId(pre, `${lang}-block`); + + const code = document.createElement("code"); + code.className = lang; + code.textContent = pre.textContent; + pre.textContent = ""; + pre.append(code); + pre.classList.add("def", "highlight"); + + const header = document.createElement("span"); + header.className = "grammarHeader"; + const selfLink = document.createElement("a"); + selfLink.className = "self-link"; + selfLink.href = `#${pre.id}`; + selfLink.textContent = label; + header.append(selfLink); + + const copyButton = createCopyButton(".grammarHeader"); + header.append(copyButton); + + pre.prepend(header); +} + +export async function run() { + /** @type {Array<{pre: HTMLPreElement, label: string, lang: string}>} */ + const blocks = []; + GRAMMARS.forEach((label, lang) => { + document + .querySelectorAll(`pre.${lang}:not([data-no-grammar])`) + .forEach(pre => { + blocks.push({ pre: /** @type {HTMLPreElement} */ (pre), label, lang }); + }); + }); + + if (!blocks.length) return; + + // Inject CSS once. + const style = document.createElement("style"); + style.textContent = css; + const anchor = document.querySelector("head link, head > *:last-child"); + if (anchor) { + anchor.before(style); + } else { + document.head.append(style); + } + + blocks.forEach(({ pre, label, lang }) => + processGrammarBlock(pre, label, lang) + ); + + // Inject the runtime copy-paste script (survives document export). + injectCopyScript(); +} diff --git a/src/styles/grammar.css.js b/src/styles/grammar.css.js new file mode 100644 index 0000000000..c16b1aa18d --- /dev/null +++ b/src/styles/grammar.css.js @@ -0,0 +1,56 @@ +const css = String.raw; + +// prettier-ignore +export default css` +:root { + --grammar-header-bg: var(--def-border, #8ccbf2); + --grammar-header-color: #005a9c; + --grammar-focus: #51a7e8; +} + +@media (prefers-color-scheme: dark) { + :root { + --grammar-header-bg: #3a6da0; + --grammar-header-color: #fff; + } +} + +pre:is(.abnf, .ebnf, .bnf) { + padding: 1em; + position: relative; +} + +pre:is(.abnf, .ebnf, .bnf) > code { + color: var(--text, black); +} + +@media print { + pre:is(.abnf, .ebnf, .bnf) { + white-space: pre-wrap; + } +} + +.grammarHeader { + display: block; + width: 150px; + background: var(--grammar-header-bg); + color: var(--grammar-header-color); + font-family: sans-serif; + font-weight: bold; + margin: -1em 0 1em -1em; + height: 1.75em; + line-height: 1.75em; +} + +.grammarHeader a.self-link { + margin-left: 0.5em; + text-decoration: none; + border-bottom: none; + color: inherit; +} + +.grammarHeader a:focus-visible { + outline: 2px solid var(--grammar-focus); + outline-offset: 2px; +} +`; diff --git a/tests/spec/core/grammar-boxes-spec.js b/tests/spec/core/grammar-boxes-spec.js new file mode 100644 index 0000000000..34c3d42a7a --- /dev/null +++ b/tests/spec/core/grammar-boxes-spec.js @@ -0,0 +1,193 @@ +"use strict"; + +import { flushIframes, makeRSDoc, makeStandardOps } from "../SpecHelper.js"; + +describe("Core — Grammar Boxes", () => { + afterAll(flushIframes); + + describe("ABNF blocks", () => { + it("wraps ABNF content in a element", async () => { + const body = ` +
+          rulename = "value"
+        
+ `; + const ops = makeStandardOps(null, body); + const doc = await makeRSDoc(ops); + const code = doc.querySelector("pre.abnf > code"); + expect(code).toBeTruthy(); + expect(code.textContent).toContain("rulename"); + }); + + it("adds an ABNF header badge", async () => { + const body = ` +
+          rulename = "value"
+        
+ `; + const ops = makeStandardOps(null, body); + const doc = await makeRSDoc(ops); + const header = doc.querySelector("pre.abnf .grammarHeader"); + expect(header).toBeTruthy(); + const link = header.querySelector("a.self-link"); + expect(link).toBeTruthy(); + expect(link.textContent).toBe("ABNF"); + }); + + it("adds an id to the pre element for self-linking", async () => { + const body = ` +
+          rulename = "value"
+        
+ `; + const ops = makeStandardOps(null, body); + const doc = await makeRSDoc(ops); + const pre = doc.querySelector("pre.abnf"); + expect(pre.id).toBeTruthy(); + expect(pre.id).toMatch(/^abnf-block-/); + }); + + it("self-link href matches the pre element id", async () => { + const body = ` +
+          rulename = "value"
+        
+ `; + const ops = makeStandardOps(null, body); + const doc = await makeRSDoc(ops); + const pre = doc.querySelector("pre.abnf"); + const link = doc.querySelector("pre.abnf .grammarHeader a.self-link"); + expect(link.getAttribute("href")).toBe(`#${pre.id}`); + }); + + it("skips blocks with data-no-grammar attribute", async () => { + const body = ` +
+          rulename = "value"
+        
+ `; + const ops = makeStandardOps(null, body); + const doc = await makeRSDoc(ops); + const pre = doc.getElementById("skipped-abnf"); + expect(pre).toBeTruthy(); + // grammar-boxes must not have added a header badge + expect(pre.querySelector(".grammarHeader")).toBeNull(); + // grammar-boxes must not have added its own (no hljs class) + const code = pre.querySelector("code"); + if (code) { + // If highlight.js ran, the code element has the "hljs" class + expect(code.classList.contains("hljs")).toBe(true); + } + }); + + it("adds a copy button inside the header", async () => { + const body = ` +
+          rulename = "value"
+        
+ `; + const ops = makeStandardOps(null, body); + const doc = await makeRSDoc(ops); + const header = doc.querySelector("pre.abnf .grammarHeader"); + const copyButton = header.querySelector( + "button.respec-button-copy-paste" + ); + expect(copyButton).toBeTruthy(); + }); + }); + + describe("EBNF blocks", () => { + it("wraps EBNF content in a element", async () => { + const body = ` +
+          rule = "token" , rule
+        
+ `; + const ops = makeStandardOps(null, body); + const doc = await makeRSDoc(ops); + const code = doc.querySelector("pre.ebnf > code"); + expect(code).toBeTruthy(); + expect(code.textContent).toContain("rule"); + }); + + it("adds an EBNF header badge", async () => { + const body = ` +
+          rule = "token" , rule
+        
+ `; + const ops = makeStandardOps(null, body); + const doc = await makeRSDoc(ops); + const link = doc.querySelector("pre.ebnf .grammarHeader a.self-link"); + expect(link).toBeTruthy(); + expect(link.textContent).toBe("EBNF"); + }); + }); + + describe("BNF blocks", () => { + it("wraps BNF content in a element", async () => { + const body = ` +
+          <term> ::= "value"
+        
+ `; + const ops = makeStandardOps(null, body); + const doc = await makeRSDoc(ops); + const code = doc.querySelector("pre.bnf > code"); + expect(code).toBeTruthy(); + }); + + it("adds a BNF header badge", async () => { + const body = ` +
+          <term> ::= "value"
+        
+ `; + const ops = makeStandardOps(null, body); + const doc = await makeRSDoc(ops); + const link = doc.querySelector("pre.bnf .grammarHeader a.self-link"); + expect(link).toBeTruthy(); + expect(link.textContent).toBe("BNF"); + }); + }); + + describe("CSS injection", () => { + it("injects grammar CSS when at least one grammar block exists", async () => { + const body = ` +
+          rulename = "value"
+        
+ `; + const ops = makeStandardOps(null, body); + const doc = await makeRSDoc(ops); + // The CSS must define .grammarHeader — check via computed style or + // inspect that the style element was injected. + const styles = [...doc.querySelectorAll("style")].map(s => s.textContent); + const hasGrammarCSS = styles.some(s => s.includes("grammarHeader")); + expect(hasGrammarCSS).toBe(true); + }); + + it("does not inject CSS when no grammar blocks are present", async () => { + const body = `

No grammar blocks here.

`; + const ops = makeStandardOps(null, body); + const doc = await makeRSDoc(ops); + const styles = [...doc.querySelectorAll("style")].map(s => s.textContent); + const hasGrammarCSS = styles.some(s => s.includes("grammarHeader")); + expect(hasGrammarCSS).toBe(false); + }); + }); + + describe(" element language class", () => { + it("puts the grammar language class on the element", async () => { + const body = ` +
+          rulename = "value"
+        
+ `; + const ops = makeStandardOps(null, body); + const doc = await makeRSDoc(ops); + const code = doc.querySelector("pre.abnf > code"); + expect(code.classList.contains("abnf")).toBe(true); + }); + }); +}); diff --git a/worker/respec-highlight.js b/worker/respec-highlight.js index fcc007d3b5..4799b97e57 100644 --- a/worker/respec-highlight.js +++ b/worker/respec-highlight.js @@ -1,7 +1,9 @@ /* eslint sort-imports: "off" */ import highlight from "highlight.js/lib/core"; import abnf from "highlight.js/lib/languages/abnf"; +import bnf from "highlight.js/lib/languages/bnf"; import css from "highlight.js/lib/languages/css"; +import ebnf from "highlight.js/lib/languages/ebnf"; import http from "highlight.js/lib/languages/http"; import javascript from "highlight.js/lib/languages/javascript"; import json from "highlight.js/lib/languages/json"; @@ -10,11 +12,23 @@ import yaml from "highlight.js/lib/languages/yaml"; highlight.configure({ tabReplace: " ", // 2 spaces - languages: ["abnf", "css", "http", "javascript", "json", "xml", "yaml"], + languages: [ + "abnf", + "bnf", + "css", + "ebnf", + "http", + "javascript", + "json", + "xml", + "yaml", + ], }); highlight.registerLanguage("abnf", abnf); +highlight.registerLanguage("bnf", bnf); highlight.registerLanguage("css", css); +highlight.registerLanguage("ebnf", ebnf); highlight.registerLanguage("http", http); highlight.registerLanguage("javascript", javascript); highlight.registerLanguage("json", json);