Skip to content
Draft
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions profiles/w3c.js
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Comment thread
marcoscaceres marked this conversation as resolved.
import("../src/core/xref.js"),
Expand Down
77 changes: 77 additions & 0 deletions src/core/grammar-boxes.js
Original file line number Diff line number Diff line change
@@ -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<string, string>} */
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 <code> 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) {
const code = document.createElement("code");
code.className = lang;
code.textContent = pre.textContent;
pre.textContent = "";
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");
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 });
Comment thread
marcoscaceres marked this conversation as resolved.
});
});

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();
}
56 changes: 56 additions & 0 deletions src/styles/grammar.css.js
Original file line number Diff line number Diff line change
@@ -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;
}
`;
193 changes: 193 additions & 0 deletions tests/spec/core/grammar-boxes-spec.js
Original file line number Diff line number Diff line change
@@ -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 <code> element", async () => {
const body = `
<pre class="abnf">
rulename = "value"
</pre>
`;
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 = `
<pre class="abnf">
rulename = "value"
</pre>
`;
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 = `
<pre class="abnf">
rulename = "value"
</pre>
`;
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 = `
<pre class="abnf">
rulename = "value"
</pre>
`;
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 = `
<pre id="skipped-abnf" class="abnf" data-no-grammar>
rulename = "value"
</pre>
`;
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 <code class="abnf"> (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 = `
<pre class="abnf">
rulename = "value"
</pre>
`;
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 <code> element", async () => {
const body = `
<pre class="ebnf">
rule = "token" , rule
</pre>
`;
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 = `
<pre class="ebnf">
rule = "token" , rule
</pre>
`;
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 <code> element", async () => {
const body = `
<pre class="bnf">
&lt;term&gt; ::= "value"
</pre>
`;
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 = `
<pre class="bnf">
&lt;term&gt; ::= "value"
</pre>
`;
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 = `
<pre class="abnf">
rulename = "value"
</pre>
`;
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 = `<p>No grammar blocks here.</p>`;
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("<code> element language class", () => {
it("puts the grammar language class on the <code> element", async () => {
const body = `
<pre class="abnf">
rulename = "value"
</pre>
`;
const ops = makeStandardOps(null, body);
const doc = await makeRSDoc(ops);
const code = doc.querySelector("pre.abnf > code");
expect(code.classList.contains("abnf")).toBe(true);
});
});
});
16 changes: 15 additions & 1 deletion worker/respec-highlight.js
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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);
Expand Down