From d0141a4de5277e181fdcd69907f0730ba353dbf6 Mon Sep 17 00:00:00 2001 From: Marcos Caceres Date: Sun, 10 May 2026 17:35:17 +1000 Subject: [PATCH 1/4] feat(core/grammar-boxes): add styled ABNF, EBNF, and BNF pre blocks Adds `src/core/grammar-boxes.js` which processes `
`,
`
`, and `
` blocks, giving each a
header badge (matching the WebIDL/CDDL pattern) with a self-link and
copy button. Content is wrapped in a `` element so that the
existing `core/highlight` module can apply syntax highlighting via
highlight.js. Registers `ebnf` and `bnf` languages in the highlight
worker (ABNF was already registered).

Use `data-no-grammar` to opt out of processing for a specific block.

Closes #3523
Closes #3525
---
 profiles/w3c.js                       |   1 +
 src/core/grammar-boxes.js             |  89 ++++++++++++
 src/styles/grammar.css.js             |  67 +++++++++
 tests/spec/core/grammar-boxes-spec.js | 193 ++++++++++++++++++++++++++
 worker/respec-highlight.js            |  16 ++-
 5 files changed, 365 insertions(+), 1 deletion(-)
 create mode 100644 src/core/grammar-boxes.js
 create mode 100644 src/styles/grammar.css.js
 create mode 100644 tests/spec/core/grammar-boxes-spec.js

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..b81c05ff5b
--- /dev/null
+++ b/src/core/grammar-boxes.js
@@ -0,0 +1,89 @@
+// @ts-check
+// Module core/grammar-boxes
+//  Adds styled header badges to ABNF, EBNF, and BNF pre blocks,
+//  mirroring the WebIDL and CDDL block pattern.
+//  Syntax highlighting is handled by core/highlight via highlight.js,
+//  which already has ABNF registered and will use auto-detection for EBNF/BNF.
+
+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) {
+  // Wrap existing text content in  for consistent structure and so that
+  // highlight.js can pick it up via the `pre > code` selector.
+  // Note: pre.textContent is cleared before addHashId, but addHashId reads
+  // from the subtree — pre.textContent returns code.textContent (the original
+  // grammar text), so the hash is computed from the real content.
+  const code = document.createElement("code");
+  code.className = lang;
+  code.textContent = pre.textContent;
+  pre.textContent = "";
+  pre.append(code);
+
+  // Add an id so the self-link and copy button work.
+  addHashId(pre, `${lang}-block`);
+
+  // Build the header badge.
+  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..f079ef0448
--- /dev/null
+++ b/src/styles/grammar.css.js
@@ -0,0 +1,67 @@
+/* --- Grammar Boxes (ABNF, EBNF, BNF) --- */
+const css = String.raw;
+
+// prettier-ignore
+export default css`
+:root {
+  --grammar-header-bg: var(--def-border, #8ccbf2);
+  --grammar-header-color: #fff;
+  --grammar-focus: #51a7e8;
+}
+
+@media (prefers-color-scheme: dark) {
+  :root {
+    --grammar-header-bg: #3a6da0;
+    --grammar-header-color: #fff;
+    --grammar-focus: #51a7e8;
+  }
+}
+
+pre.abnf,
+pre.ebnf,
+pre.bnf {
+  padding: 1em;
+  position: relative;
+}
+
+pre.abnf > code,
+pre.ebnf > code,
+pre.bnf > code {
+  color: var(--text, black);
+}
+
+@media print {
+  pre.abnf,
+  pre.ebnf,
+  pre.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.self-link:focus-visible,
+pre.abnf a:focus-visible,
+pre.ebnf a:focus-visible,
+pre.bnf 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); From 31b5b04b760f74aa39010241e310b60d23cb2455 Mon Sep 17 00:00:00 2001 From: Marcos Caceres Date: Sun, 10 May 2026 18:48:25 +1000 Subject: [PATCH 2/4] refactor(core/grammar-boxes): fix contrast, highlight, and CSS - Add .highlight class to processed pre elements (prevents double-highlighting) - Fix light mode contrast: use #005a9c (dark blue) text on light blue badge - Remove duplicate --grammar-focus in dark mode block - Collapse CSS selectors with :is(.abnf, .ebnf, .bnf) - Scope focus-visible to .grammarHeader anchors only - Remove stale module comment about auto-detection --- src/core/grammar-boxes.js | 14 +------------- src/styles/grammar.css.js | 21 +++++---------------- 2 files changed, 6 insertions(+), 29 deletions(-) diff --git a/src/core/grammar-boxes.js b/src/core/grammar-boxes.js index b81c05ff5b..d8e90bbb0b 100644 --- a/src/core/grammar-boxes.js +++ b/src/core/grammar-boxes.js @@ -1,10 +1,4 @@ // @ts-check -// Module core/grammar-boxes -// Adds styled header badges to ABNF, EBNF, and BNF pre blocks, -// mirroring the WebIDL and CDDL block pattern. -// Syntax highlighting is handled by core/highlight via highlight.js, -// which already has ABNF registered and will use auto-detection for EBNF/BNF. - import { createCopyButton, injectCopyScript } from "./clipboard.js"; import { addHashId } from "./utils.js"; import css from "../styles/grammar.css.js"; @@ -28,21 +22,15 @@ const GRAMMARS = new Map([ * @param {string} lang - grammar class name, e.g. "abnf" */ function processGrammarBlock(pre, label, lang) { - // Wrap existing text content in for consistent structure and so that - // highlight.js can pick it up via the `pre > code` selector. - // Note: pre.textContent is cleared before addHashId, but addHashId reads - // from the subtree — pre.textContent returns code.textContent (the original - // grammar text), so the hash is computed from the real content. const code = document.createElement("code"); code.className = lang; code.textContent = pre.textContent; pre.textContent = ""; pre.append(code); + pre.classList.add("def", "highlight"); - // Add an id so the self-link and copy button work. addHashId(pre, `${lang}-block`); - // Build the header badge. const header = document.createElement("span"); header.className = "grammarHeader"; const selfLink = document.createElement("a"); diff --git a/src/styles/grammar.css.js b/src/styles/grammar.css.js index f079ef0448..c16b1aa18d 100644 --- a/src/styles/grammar.css.js +++ b/src/styles/grammar.css.js @@ -1,11 +1,10 @@ -/* --- Grammar Boxes (ABNF, EBNF, BNF) --- */ const css = String.raw; // prettier-ignore export default css` :root { --grammar-header-bg: var(--def-border, #8ccbf2); - --grammar-header-color: #fff; + --grammar-header-color: #005a9c; --grammar-focus: #51a7e8; } @@ -13,27 +12,20 @@ export default css` :root { --grammar-header-bg: #3a6da0; --grammar-header-color: #fff; - --grammar-focus: #51a7e8; } } -pre.abnf, -pre.ebnf, -pre.bnf { +pre:is(.abnf, .ebnf, .bnf) { padding: 1em; position: relative; } -pre.abnf > code, -pre.ebnf > code, -pre.bnf > code { +pre:is(.abnf, .ebnf, .bnf) > code { color: var(--text, black); } @media print { - pre.abnf, - pre.ebnf, - pre.bnf { + pre:is(.abnf, .ebnf, .bnf) { white-space: pre-wrap; } } @@ -57,10 +49,7 @@ pre.bnf > code { color: inherit; } -.grammarHeader a.self-link:focus-visible, -pre.abnf a:focus-visible, -pre.ebnf a:focus-visible, -pre.bnf a:focus-visible { +.grammarHeader a:focus-visible { outline: 2px solid var(--grammar-focus); outline-offset: 2px; } From 49416c99b2a33022247058c62c5a8045e78f5315 Mon Sep 17 00:00:00 2001 From: Marcos Caceres Date: Sun, 10 May 2026 20:13:54 +1000 Subject: [PATCH 3/4] fix(core/grammar-boxes): call addHashId before clearing pre.textContent --- src/core/grammar-boxes.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/core/grammar-boxes.js b/src/core/grammar-boxes.js index d8e90bbb0b..02f6eca900 100644 --- a/src/core/grammar-boxes.js +++ b/src/core/grammar-boxes.js @@ -22,6 +22,8 @@ const GRAMMARS = new Map([ * @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; @@ -29,8 +31,6 @@ function processGrammarBlock(pre, label, lang) { pre.append(code); pre.classList.add("def", "highlight"); - addHashId(pre, `${lang}-block`); - const header = document.createElement("span"); header.className = "grammarHeader"; const selfLink = document.createElement("a"); From 537539d7b0e66a93f63d0ef2a3595f228f0edcc7 Mon Sep 17 00:00:00 2001 From: Marcos Caceres Date: Sun, 10 May 2026 20:23:08 +1000 Subject: [PATCH 4/4] feat(profiles): register grammar-boxes in geonovum, aom, and dini profiles --- profiles/aom.js | 1 + profiles/dini.js | 1 + profiles/geonovum.js | 1 + 3 files changed, 3 insertions(+) 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 */