Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
f6ee3c5
feat(inlines): support [[[SPEC#id]]] for cross-spec section links
marcoscaceres Mar 28, 2026
4f4da5e
fix(inlines): prevent [[[#id#invalid]]] double-hash from matching
marcoscaceres Mar 28, 2026
90209eb
feat(inlines): heading text expansion + alias support for [[[SPEC#id]]]
marcoscaceres Apr 13, 2026
c2226c0
fix(data-cite): remove dead code, avoid double toCiteDetails, use loc…
marcoscaceres Apr 13, 2026
05d33f5
test(inlines): add tests for [[[SPEC#id|alias]]] and [[[#id|alias]]] …
marcoscaceres Apr 13, 2026
ed0cfc1
test(inlines): add test for [[[SPEC|text]]] alias syntax without frag…
Copilot Apr 13, 2026
4755195
fix(data-cite): only apply data-lt alias when element is empty to avo…
Copilot Apr 15, 2026
a1ed58d
fix(data-cite): remove premature heading API, cite sibling, and netwo…
Copilot Apr 16, 2026
28a2960
fix(data-cite): capture originalKey before toCiteDetails pre-computation
marcoscaceres Apr 17, 2026
b4b7769
docs(inlines): update inlineExpansion comment to list all supported p…
Copilot Apr 17, 2026
c5b9e0c
Merge branch 'main' into feat/inlines-cross-spec
marcoscaceres Apr 17, 2026
1fd4f63
fix(data-cite): preserve empty-string enum values in data-lt expansion
marcoscaceres Apr 17, 2026
b55e624
Merge branch 'main' into feat/inlines-cross-spec
marcoscaceres Apr 18, 2026
b16f661
test(inlines): add coverage for normative/informative cross-spec links
marcoscaceres Apr 18, 2026
d8e5c53
test(inlines): add coverage for normative/informative cross-spec link…
marcoscaceres Apr 18, 2026
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
24 changes: 22 additions & 2 deletions src/core/data-cite.js
Original file line number Diff line number Diff line change
Expand Up @@ -184,11 +184,31 @@ export async function run() {

await updateBiblio([...elems]);

// Capture original keys before toCiteDetails mutates dataset.cite.
const originalKeys = new Map();
const citeDetailsMap = new Map();
for (const elem of elems) {
Comment thread
marcoscaceres marked this conversation as resolved.
const originalKey = elem.dataset.cite;
const citeDetails = toCiteDetails(elem);
originalKeys.set(elem, elem.dataset.cite);
citeDetailsMap.set(elem, toCiteDetails(elem));
}

for (const elem of elems) {
const originalKey = originalKeys.get(elem);
const citeDetails = citeDetailsMap.get(elem);
const linkProps = await getLinkProps(citeDetails);
if (linkProps) {
// Use alias text (data-lt) if present and element is empty.
// Only applies to [[[...]]] triple-bracket expansions (which set data-lt for
// alias text). IDL references also use data-lt as a lookup term but already
// have child content, so the textContent check prevents corrupting them.
if (
elem.dataset.lt &&
elem.dataset.lt !== "the-empty-string" &&
elem.textContent === ""
) {
elem.textContent = elem.dataset.lt;
delete elem.dataset.lt;
}
linkElem(elem, linkProps, citeDetails);
} else {
const msg = `Couldn't find a match for "${originalKey}"`;
Expand Down
19 changes: 15 additions & 4 deletions src/core/inlines.js
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,8 @@ const inlineCodeRegExp = /(?:`[^`]+`)(?!`)/; // `code`
const inlineIdlReference = /(?:{{[^}]+\?*}})/; // {{ WebIDLThing }}, {{ WebIDLThing? }}
const inlineVariable = /\B\|\w[\w\s]*(?:\s*:[\w\s&;"?<>]+\??)?\|\B/; // |var : Type?|
const inlineCitation = /(?:\[\[(?:!|\\|\?)?[\w.-]+(?:|[^\]]+)?\]\])/; // [[citation]]
const inlineExpansion = /(?:\[\[\[(?:!|\\|\?)?#?[\w-.]+\]\]\])/; // [[[expand]]]
const inlineExpansion =
/(?:\[\[\[(?:!|\\|\?)?(?:#[\w-.]+|[\w-.]+(?:#[\w-.]+)?)(?:\|[^\]]+)?\]\]\])/; // [[[expand]]], [[[#id]]], [[[#id|text]]], [[[SPEC]]], [[[SPEC|text]]], [[[SPEC#id]]], or [[[SPEC#id|text]]]
const inlineAnchor = /(?:\[=[^=]+=\])/; // Inline [= For/link =]
const inlineElement = /(?:\[\^[^^]+\^\])/; // Inline [^element^]

Expand Down Expand Up @@ -128,11 +129,21 @@ function inlineRFC2119Matches(matched) {
*/
function inlineRefMatches(matched) {
// slices "[[[" at the beginning and "]]]" at the end
const ref = matched.slice(3, -3).trim();
let ref = matched.slice(3, -3).trim();
const pipeIdx = ref.indexOf("|");
const linkText = pipeIdx !== -1 ? ref.slice(pipeIdx + 1).trim() : null;
if (pipeIdx !== -1) ref = ref.slice(0, pipeIdx).trim();

if (!ref.startsWith("#")) {
return html`<a data-cite="${ref}" data-matched-text="${matched}"></a>`;
return html`<a
data-cite="${ref}"
data-matched-text="${matched}"
data-lt="${linkText || null}"
></a>`;
}
return html`<a href="${ref}" data-matched-text="${matched}"></a>`;
return linkText
? html`<a href="${ref}" data-matched-text="${matched}">${linkText}</a>`
: html`<a href="${ref}" data-matched-text="${matched}"></a>`;
}

/**
Expand Down
199 changes: 199 additions & 0 deletions tests/spec/core/inlines-spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,194 @@
expect(notFound.textContent).toBe("[[[not-found]]]");
});

it("classifies [[[!SPEC#id]]] as normative and [[[?SPEC#id]]] as informative", async () => {
const config = {
localBiblio: {
"the-spec": {
id: "the-spec",
title: "The Spec",
href: "https://example.com/",
},
"other-spec": {
id: "other-spec",
title: "Other Spec",
href: "https://example.com/other",
},
},
};
const body = `
<section id="test">
<section class="informative">
<p id="norm-frag">[[[!the-spec#some-id]]]</p>
</section>
<section id="conformance">
<p id="inform-frag">[[[?other-spec#some-id]]]</p>
</section>
</section>
`;
const doc = await makeRSDoc(makeStandardOps(config, body));

// [[[!the-spec#some-id]]] forces normative even in informative section
const normAnchor = doc.querySelector("#norm-frag a");
expect(normAnchor).toBeTruthy();
expect(normAnchor.textContent).toBe("The Spec");

const norm = [...doc.querySelectorAll("#normative-references dt")];
expect(norm.map(el => el.textContent)).toContain("[the-spec]");

Check failure on line 320 in tests/spec/core/inlines-spec.js

View workflow job for this annotation

GitHub Actions / Karma Unit Tests (ChromeHeadless)

Core - Inlines classifies [[[!SPEC#id]]] as normative and [[[?SPEC#id]]] as informative Expected [ ] to contain '[the-spec]'.

Check failure on line 320 in tests/spec/core/inlines-spec.js

View workflow job for this annotation

GitHub Actions / Karma Unit Tests (FirefoxHeadless)

Core - Inlines classifies [[[!SPEC#id]]] as normative and [[[?SPEC#id]]] as informative Expected [ ] to contain '[the-spec]'.

// [[[?other-spec#some-id]]] forces informative even in normative section
const informAnchor = doc.querySelector("#inform-frag a");
expect(informAnchor).toBeTruthy();
expect(informAnchor.textContent).toBe("Other Spec");

const inform = [...doc.querySelectorAll("#informative-references dt")];
expect(inform.map(el => el.textContent)).toContain("[other-spec]");
});

it("supports [[[?SPEC#id|alias]]] and [[[?SPEC|alias]]] with informative classification", async () => {
const config = {
localBiblio: {
"the-spec": {
id: "the-spec",
title: "The Spec",
href: "https://example.com/",
},
"other-spec": {
id: "other-spec",
title: "Other Spec",
href: "https://example.com/other",
},
},
};
const body = `
<section id="test">
<section id="conformance">
<p id="inform-alias">[[[?the-spec#some-id|custom link text]]]</p>
<p id="inform-no-frag">[[[?other-spec|just the spec]]]</p>
</section>
</section>
`;
const doc = await makeRSDoc(makeStandardOps(config, body));

// Alias text should be used instead of spec title
const aliasAnchor = doc.querySelector("#inform-alias a");
expect(aliasAnchor).toBeTruthy();
expect(aliasAnchor.textContent).toBe("custom link text");

const noFragAnchor = doc.querySelector("#inform-no-frag a");
expect(noFragAnchor).toBeTruthy();
expect(noFragAnchor.textContent).toBe("just the spec");

// Both should be classified as informative
const inform = [...doc.querySelectorAll("#informative-references dt")];
expect(inform.map(el => el.textContent)).toContain("[the-spec]");
expect(inform.map(el => el.textContent)).toContain("[other-spec]");
});

it("shows matched text as fallback for [[[not-found|Custom Text]]]", async () => {
const body = `
<section id="test" class="informative">
<p id="output">[[[not-found|Custom Text]]]</p>
</section>
`;
const doc = await makeRSDoc(makeStandardOps({}, body));
const output = doc.querySelector("#output");
expect(output).toBeTruthy();
// When the spec is not found, the original matched text is shown
expect(output.textContent.trim()).toBe("[[[not-found|Custom Text]]]");
});

it("links to specific section of another spec using [[[SPEC#id]]] syntax", async () => {
const config = {
localBiblio: {
fetch: {
Comment thread
marcoscaceres marked this conversation as resolved.
title: "Fetch Standard",
href: "https://fetch.spec.whatwg.org/",
},
},
};
const body = `
<section id="test">
<p id="output">[[[fetch#data-fetch]]]</p>
</section>
`;
const doc = await makeRSDoc(makeStandardOps(config, body));
const anchor = doc.querySelector("#output a[href]");
expect(anchor).toBeTruthy();
expect(anchor.href).toBe("https://fetch.spec.whatwg.org/#data-fetch");
expect(anchor.textContent).toBe("Fetch Standard");
Comment thread
marcoscaceres marked this conversation as resolved.
});

it("supports alias text with [[[SPEC#id|text]]] syntax", async () => {
const config = {
localBiblio: {
fetch: {
title: "Fetch Standard",
href: "https://fetch.spec.whatwg.org/",
},
},
};
const body = `
<section id="test">
<p id="alias">[[[fetch#data-fetch|fetching data]]]</p>
<p id="no-alias">[[[fetch#data-fetch]]]</p>
</section>
`;
const doc = await makeRSDoc(makeStandardOps(config, body));
const aliasAnchor = doc.querySelector("#alias a[href]");
expect(aliasAnchor).toBeTruthy();
expect(aliasAnchor.href).toBe("https://fetch.spec.whatwg.org/#data-fetch");
expect(aliasAnchor.textContent).toBe("fetching data");

const noAliasAnchor = doc.querySelector("#no-alias a[href]");
expect(noAliasAnchor).toBeTruthy();
// spec title is used as link text when no alias is provided
expect(noAliasAnchor.textContent).toBe("Fetch Standard");
});

it("supports alias text with [[[SPEC|text]]] syntax (no fragment)", async () => {
const config = {
localBiblio: {
fetch: {
title: "Fetch Standard",
href: "https://fetch.spec.whatwg.org/",
},
},
};
const body = `
<section id="test">
<p id="alias">[[[fetch|Custom Fetch Link]]]</p>
<p id="no-alias">[[[fetch]]]</p>
</section>
`;
const doc = await makeRSDoc(makeStandardOps(config, body));
const aliasAnchor = doc.querySelector("#alias a[href]");
expect(aliasAnchor).toBeTruthy();
expect(aliasAnchor.href).toBe("https://fetch.spec.whatwg.org/");
expect(aliasAnchor.textContent).toBe("Custom Fetch Link");

const noAliasAnchor = doc.querySelector("#no-alias a[href]");
expect(noAliasAnchor).toBeTruthy();
expect(noAliasAnchor.textContent).toBe("Fetch Standard");
});

it("supports alias text with [[[#id|text]]] for in-document links", async () => {
const body = `
<section id="my-section">
<h2>My Section Heading</h2>
<p>Some content.</p>
</section>
<section>
<h2>References</h2>
<p id="output">[[[#my-section|see this section]]]</p>
</section>
`;
const doc = await makeRSDoc(makeStandardOps(null, body));
const anchor = doc.querySelector("#output a[href='#my-section']");
expect(anchor).toBeTruthy();
expect(anchor.textContent).toBe("see this section");
});

it("allows [[[#...]]] to be a general expander for ids in document", async () => {
/** @param {string} text */
function generateDataIncludeUrl(text) {
Expand Down Expand Up @@ -329,6 +517,17 @@
expect(badOne.textContent).toBe("#does-not-exist");
});

it("does not process [[[#id#invalid]]] with multiple hash fragments", async () => {
const body = `
<section id="section"><h2>Section</h2></section>
<p id="output">[[[#section#invalid]]]</p>
`;
const doc = await makeRSDoc(makeStandardOps(null, body));
const output = doc.getElementById("output");
expect(output.querySelector("a")).toBeNull();
expect(output.textContent.trim()).toBe("[[[#section#invalid]]]");
});

it("proceseses backticks inside [= =] inline links", async () => {
const body = `
<section>
Expand Down
Loading