Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
53 commits
Select commit Hold shift + click to select a range
c08bf0b
feat(inlines): support [[[SPEC#id]]] for cross-spec section links
marcoscaceres Mar 28, 2026
02aa35f
fix(inlines): prevent [[[#id#invalid]]] double-hash from matching
marcoscaceres Mar 28, 2026
1d9f970
feat(inlines): heading text expansion + alias support for [[[SPEC#id]]]
marcoscaceres Apr 13, 2026
8ca5c91
fix(data-cite): remove dead code, avoid double toCiteDetails, use loc…
marcoscaceres Apr 13, 2026
d764134
test(inlines): add tests for [[[SPEC#id|alias]]] and [[[#id|alias]]] …
marcoscaceres Apr 13, 2026
dd27a1f
test(inlines): add test for [[[SPEC|text]]] alias syntax without frag…
Copilot Apr 13, 2026
cce9c48
fix(data-cite): only apply data-lt alias when element is empty to avo…
Copilot Apr 15, 2026
093f929
fix(data-cite): remove premature heading API, cite sibling, and netwo…
Copilot Apr 16, 2026
1d6765c
fix(data-cite): capture originalKey before toCiteDetails pre-computation
marcoscaceres Apr 17, 2026
07265b9
docs(inlines): update inlineExpansion comment to list all supported p…
Copilot Apr 17, 2026
3fdfb90
fix(data-cite): preserve empty-string enum values in data-lt expansion
marcoscaceres Apr 17, 2026
ae537ad
test(inlines): add coverage for normative/informative cross-spec links
marcoscaceres Apr 18, 2026
0b94b49
test(inlines): add coverage for normative/informative cross-spec link…
marcoscaceres Apr 18, 2026
ac1f5a7
fix(inlines): fix failing CI test for !SPEC#id normative classification
Copilot Apr 19, 2026
48dbbee
refactor(core/inlines): simplify expansion regex, validate in handler
marcoscaceres Apr 20, 2026
2dbb150
fix(core/inlines): add param type for inlineRefMatches
marcoscaceres Apr 21, 2026
c6946f2
refactor(core/inlines): improve expansion regex naming and docs
marcoscaceres Apr 26, 2026
981e9cc
fix(core/inlines): fix return type, expand hint, fix stale comment
marcoscaceres Apr 27, 2026
4cf941f
fix(core/inlines): update regex comment to reflect all supported forms
marcoscaceres Apr 27, 2026
3ed328b
fix(core/inlines): handle prefixed in-document refs correctly
marcoscaceres Apr 27, 2026
750568d
fix(core/issues-notes): validate GitHub label color, use html templat…
marcoscaceres Apr 27, 2026
a294508
fix(core/inlines): use data-cite-section to prevent dfn-index misclas…
Copilot Apr 28, 2026
ced1700
chore: format with prettier
marcoscaceres Apr 28, 2026
5f72387
Merge branch 'main' into feat/inlines-cross-spec
marcoscaceres Apr 28, 2026
91f9cda
feat(core/data-cite): resolve heading titles via headings API
marcoscaceres Apr 28, 2026
7264147
refactor: review fixes for headings integration
marcoscaceres Apr 28, 2026
becfb11
fix: address Copilot review feedback
marcoscaceres Apr 29, 2026
9a348c6
feat(core/inlines): support \ escape for [[[...]]] expansions
marcoscaceres Apr 29, 2026
4192973
Merge branch 'main' into feat/inlines-cross-spec
marcoscaceres Apr 29, 2026
8c9bf79
Merge branch 'main' into feat/inlines-cross-spec
marcoscaceres Apr 29, 2026
03e52be
Merge branch 'main' into feat/inlines-cross-spec
marcoscaceres Apr 29, 2026
2cc173f
Merge branch 'main' into feat/inlines-cross-spec
marcoscaceres May 5, 2026
2ff1bd3
fix(core/data-cite): cache headings in IndexedDB, warn on errors, use…
marcoscaceres May 7, 2026
869b7d6
Merge branch 'main' into feat/inlines-cross-spec
marcoscaceres May 8, 2026
01e0ed8
refactor(core): extract headings API into core/headings module
marcoscaceres May 8, 2026
3dedc6c
fix(core/headings-db): deduplicate typedef, fix cold-start cache bust
marcoscaceres May 8, 2026
05fa420
rename: headings → xref-headings (and xref-headings-db)
marcoscaceres May 9, 2026
9d34d20
Merge branch 'main' into feat/inlines-cross-spec
marcoscaceres May 13, 2026
bc55a47
fix(ci): revert accidentally committed builds/ artifacts
Copilot May 13, 2026
7038942
fix(core/xref-headings-db): update typedef import after module rename
Copilot May 13, 2026
5f2fe62
refactor: code quality pass - simplify specPart, remove elemHeadingKe…
Copilot May 13, 2026
6c2004d
feat: make xref-headings a proper plugin; use split('#') in inlines; …
Copilot May 13, 2026
3568df3
refactor: consolidate duplicate typeof check in xref-headings run()
Copilot May 13, 2026
1b27917
refactor(dfn-index): remove redundant citeSection guard - existing ch…
Copilot May 13, 2026
8aa424c
refactor: use citeFrag instead of citeSection, move headingApiUrl int…
Copilot May 13, 2026
b09c8f8
fix(dfn-index): restore citeFrag guard and exclude [[[SPEC#id]]] sect…
Copilot May 13, 2026
3cb371f
fix(xref-headings): remove skipCache coupling to API URL; use beforeE…
Copilot May 14, 2026
bb2a4a5
refactor: address review feedback on inlines and xref-headings
marcoscaceres May 14, 2026
f7a04da
fix(inlines): simplify warning hint to wiki link; convert prod API te…
Copilot May 14, 2026
f474be0
fix(tests): use explicit bdi.secno assertion instead of optional chai…
Copilot May 14, 2026
2c13263
Merge branch 'main' into feat/inlines-cross-spec
marcoscaceres May 14, 2026
ff74aa7
refactor(xref-headings): add typedef, move run() to top, extract getA…
Copilot May 14, 2026
fc3c4ee
style(xref-headings): restore multi-line formatting in map callback
Copilot May 14, 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
1 change: 1 addition & 0 deletions profiles/aom.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ const modules = [
import("../src/core/biblio.js"),
import("../src/core/link-to-dfn.js"),
import("../src/core/data-cite.js"),
import("../src/core/xref-headings.js"),
import("../src/core/render-biblio.js"),
import("../src/core/contrib.js"),
import("../src/core/sections.js"),
Expand Down
1 change: 1 addition & 0 deletions profiles/dini.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ const modules = [
import("../src/core/biblio.js"),
import("../src/core/link-to-dfn.js"),
import("../src/core/data-cite.js"),
import("../src/core/xref-headings.js"),
import("../src/core/render-biblio.js"),
import("../src/core/contrib.js"),
import("../src/core/sections.js"),
Expand Down
1 change: 1 addition & 0 deletions profiles/geonovum.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ const modules = [
import("../src/core/biblio.js"),
import("../src/core/link-to-dfn.js"),
import("../src/core/data-cite.js"),
import("../src/core/xref-headings.js"),
import("../src/core/render-biblio.js"),
import("../src/core/contrib.js"),
import("../src/core/sections.js"),
Expand Down
1 change: 1 addition & 0 deletions profiles/w3c.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ const modules = [
import("../src/core/link-to-dfn.js"),
import("../src/core/xref.js"),
import("../src/core/data-cite.js"),
import("../src/core/xref-headings.js"),
import("../src/core/render-biblio.js"),
import("../src/core/dfn-index.js"),
import("../src/core/contrib.js"),
Expand Down
27 changes: 26 additions & 1 deletion src/core/data-cite.js
Original file line number Diff line number Diff line change
Expand Up @@ -184,11 +184,36 @@ export async function run() {

await updateBiblio([...elems]);

// Precompute toCiteDetails for each element to capture originalKey for error
// messages before toCiteDetails mutates dataset.cite
const originalKeys = new Map();
const citeDetailsMap = new Map();
Comment thread
marcoscaceres marked this conversation as resolved.
for (const elem of elems) {
Comment thread
marcoscaceres marked this conversation as resolved.
const originalKey = elem.dataset.cite;
originalKeys.set(elem, elem.dataset.cite);
const citeDetails = toCiteDetails(elem);
citeDetailsMap.set(elem, citeDetails);
}

for (const elem of elems) {
const originalKey = originalKeys.get(elem);
const citeDetails = citeDetailsMap.get(elem);
const linkProps = await getLinkProps(citeDetails);
if (linkProps) {
// Apply alias text (data-lt) for non-section links only.
// [[[SPEC#id|alias]]] elements have both citeFrag and matchedText set;
// their alias is handled by core/xref-headings after this module runs.
// IDL references use data-lt as a lookup term but already have child
// content, so the textContent check prevents corrupting them.
if (
!(elem.dataset.citeFrag && elem.dataset.matchedText) &&
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
7 changes: 5 additions & 2 deletions src/core/dfn-index.js
Original file line number Diff line number Diff line change
Expand Up @@ -344,9 +344,12 @@ function collectExternalTerms() {
if (!elem.dataset.cite) {
continue;
}
const { cite, citeFrag, xrefType, linkType } = elem.dataset;
const { cite, citeFrag, xrefType, linkType, matchedText } = elem.dataset;
// [[[SPEC#id]]] section links have both citeFrag and matchedText; skip them.
if (citeFrag && matchedText) {
continue;
}
if (!(xrefType || linkType || cite.includes("#") || citeFrag)) {
// Not a reference to a definition
continue;
}
const uniqueID = elem.href;
Expand Down
60 changes: 54 additions & 6 deletions src/core/inlines.js
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ 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 = /(?:\[\[\[[^\]]+\]\]\])/; // [[[SPEC]]], [[[SPEC#id]]], [[[#id]]], [[[...|text]]], !/?-prefixed
const inlineAnchor = /(?:\[=[^=]+=\])/; // Inline [= For/link =]
Comment thread
marcoscaceres marked this conversation as resolved.
const inlineElement = /(?:\[\^[^^]+\^\])/; // Inline [^element^]
const inlineCddlReference = /(?:\{\^[^}^]+\^\})/; // {^cddl-type^}, {^type/key^}
Expand Down Expand Up @@ -163,17 +163,65 @@ function inlineRFC2119Matches(matched) {
return nodeElement;
}

/**
* Validates inline expansion/reference syntax.
* Valid forms: [[[#id]]], [[[SPEC]]], [[[SPEC#id]]], [[[SPEC|text]]],
* [[[SPEC#id|text]]], [[[#id|text]]]
*/
const inlineExpansionPattern =
/^(?:!|\\|\?)?(?:#[\w-.]+|[\w-.]+(?:#[\w-.]+)?)(?:\|[^\]]+)?$/;

Comment thread
marcoscaceres marked this conversation as resolved.
/**
* @param {string} matched
* @return {HTMLElement}
* @return {HTMLElement | string}
*/
function inlineRefMatches(matched) {
// slices "[[[" at the beginning and "]]]" at the end
const ref = matched.slice(3, -3).trim();
if (!ref.startsWith("#")) {
return html`<a data-cite="${ref}" data-matched-text="${matched}"></a>`;
const raw = matched.slice(3, -3).trim();
if (!inlineExpansionPattern.test(raw)) {
const msg = `Bad syntax: \`${matched}\` is not a valid inline expansion.`;
const hint =
"See https://github.com/speced/respec/wiki/inlines for valid syntax.";
showWarning(msg, name, { hint });
return matched;
}
const pipeIdx = raw.indexOf("|");
const linkText = pipeIdx !== -1 ? raw.slice(pipeIdx + 1).trim() : null;
const ref = pipeIdx !== -1 ? raw.slice(0, pipeIdx).trim() : raw;

// Strip !/?/\ prefix (normative/informative/escaped markers)
const refWithoutPrefix = ref.replace(/^[!?\\]/, "");

if (ref.startsWith("\\")) {
return `[[[${refWithoutPrefix}]]]`;
}

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

if (refWithoutPrefix.includes("#")) {
const [specName, sectionFrag] = refWithoutPrefix.split("#");
// Preserve any !/?/\ prefix from ref (e.g. "!SPEC" → "!" + specName)
const specPart =
ref.slice(0, ref.length - refWithoutPrefix.length) + specName;
return html`<a
data-cite="${specPart}"
data-cite-frag="${sectionFrag}"
data-matched-text="${matched}"
data-lt="${linkText || null}"
></a>`;
}

return html`<a
data-cite="${ref}"
data-matched-text="${matched}"
data-lt="${linkText || null}"
></a>`;
}

/**
Expand Down
94 changes: 94 additions & 0 deletions src/core/xref-headings-db.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
// @ts-check
import { idb } from "./import-maps.js";

/**
* @typedef {import("./xref-headings.js").HeadingInfo} HeadingInfo
* @typedef {{ query: { spec: string, id: string }, result: HeadingInfo }} HeadingEntry
* @typedef {import("idb").DBSchema & { headings: { key: string, value: HeadingEntry } }} HeadingsDb
*/

const STORE_NAME = "headings";
const CACHE_MAX_AGE = 24 * 60 * 60 * 1000; // 24 hours

async function getDb() {
/** @type {import("idb").IDBPDatabase<HeadingsDb>} */
const db = await idb.openDB("respec-headings", 1, {
upgrade(db) {
[...db.objectStoreNames].forEach(s => db.deleteObjectStore(s));
db.createObjectStore(STORE_NAME);
},
});
return db;
}

/** @param {{ spec: string, id: string }[]} queries */
export async function resolveHeadingsCache(queries) {
/** @type {Map<string, HeadingInfo>} */
const cachedData = new Map();

if (shouldBustCache()) {
await clearHeadingsData();
return cachedData;
}

try {
const db = await getDb();
const tx = db.transaction(STORE_NAME);
for (const query of queries) {
const key = `${query.spec}#${query.id}`;
const entry = await tx.store.get(key);
if (entry) {
cachedData.set(key, entry.result);
}
}
} catch (err) {
console.error(err);
}
return cachedData;
}

/**
* @param {{ spec: string, id: string }[]} queries
* @param {Map<string, HeadingInfo>} results
*/
export async function cacheHeadingsData(queries, results) {
try {
const db = await getDb();
const tx = db.transaction(STORE_NAME, "readwrite");
for (const query of queries) {
const key = `${query.spec}#${query.id}`;
const result = results.get(key);
if (result) {
tx.objectStore(STORE_NAME).put({ query, result }, key);
}
}
await tx.done;
localStorage.setItem("HEADINGS:LAST_CACHED", Date.now().toString());
} catch (e) {
console.error(e);
}
}

/**
* Returns true when the cached headings data has exceeded CACHE_MAX_AGE and
* should be discarded. Returns false when no timestamp exists (cold start).
*/
function shouldBustCache() {
const lastCached = parseInt(
localStorage.getItem("HEADINGS:LAST_CACHED") ?? "",
10
);
if (isNaN(lastCached)) return false;
return Date.now() - lastCached > CACHE_MAX_AGE;
}

/** Clears all cached headings data and removes the last-cached timestamp. */
export async function clearHeadingsData() {
try {
const db = await getDb();
await db.clear(STORE_NAME);
localStorage.removeItem("HEADINGS:LAST_CACHED");
} catch (e) {
console.error(e);
}
}
Loading