Skip to content
Draft
Show file tree
Hide file tree
Changes from all 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/aom.js
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
1 change: 1 addition & 0 deletions profiles/dini.js
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
1 change: 1 addition & 0 deletions profiles/geonovum.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down
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) {
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 });
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
Loading