Skip to content
Open
Show file tree
Hide file tree
Changes from 26 commits
Commits
Show all changes
32 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
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
113 changes: 109 additions & 4 deletions src/core/data-cite.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
showWarning,
wrapInner,
} from "./utils.js";
import { API_URL } from "./xref.js";
import { sub } from "./pubsubhub.js";
export const name = "core/data-cite";

Comment thread
marcoscaceres marked this conversation as resolved.
Outdated
Expand All @@ -29,6 +30,54 @@ export const name = "core/data-cite";
*/
export const THIS_SPEC = "__SPEC__";

/**
* @typedef {{ title: string, number: string | null }} HeadingInfo
*/

/**
* Fetches heading titles from the respec.org headings API for cross-spec
* section links ([[[SPEC#id]]] syntax). Returns a Map keyed by "spec#id".
* Gracefully returns an empty Map on any failure.
* @param {{ spec: string, id: string }[]} queries
* @returns {Promise<Map<string, HeadingInfo>>}
*/
async function fetchHeadingTexts(queries) {
if (!queries.length) return new Map();
const url = new URL("search/headings", API_URL).href;
try {
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ queries }),
});
if (!res.ok) return new Map();
const { result = [] } = await res.json();
/** @type {Map<string, HeadingInfo>} */
const map = new Map();
for (const entry of result) {
if (!entry.error) {
map.set(`${entry.spec}#${entry.id}`, {
title: entry.title,
number: entry.number || null,
});
}
}
return map;
} catch {
return new Map();
}
}

/**
* Formats a heading title for use as link text.
* When a section number is available, produces "§N Title".
* @param {HeadingInfo} heading
* @returns {string}
*/
function formatHeadingText({ title, number }) {
return number ? `§${number} ${title}` : title;
}

/**
* Gets the link properties for the given citation details.
* @param {CiteDetails} citeDetails - The citation details.
Expand Down Expand Up @@ -144,7 +193,7 @@ const findPath = makeComponentFinder("/");
*/
export function toCiteDetails(elem) {
const { dataset } = elem;
const { cite: rawKey, citeFrag, citePath, citeHref } = dataset;
const { cite: rawKey, citeFrag, citePath, citeHref, citeSection } = dataset;

// The key is a fragment, resolve using the shortName as key
if ((rawKey ?? "").startsWith("#") && !citeFrag) {
Expand All @@ -160,7 +209,14 @@ export function toCiteDetails(elem) {
return toCiteDetails(elem);
}

const frag = citeFrag ? `#${citeFrag}` : findFrag(rawKey ?? "");
// data-cite-section stores the section fragment for [[[SPEC#id]]] links.
// Unlike data-cite-frag, it does not cause dfn-index to treat the link as a
// definition reference.
const frag = citeFrag
? `#${citeFrag}`
: citeSection
? `#${citeSection}`
: findFrag(rawKey ?? "");
const path = citePath || findPath(rawKey ?? "").split("#")[0]; // path is always before "#"
const { type } = refTypeFromContext(rawKey ?? "", elem);
const isNormative = type === "normative";
Expand All @@ -184,11 +240,55 @@ export async function run() {

await updateBiblio([...elems]);

// Capture keys after updateBiblio (which may have mutated dataset.cite via
// toCiteDetails), so we can match elements to their biblio entries later.
const originalKeys = new Map();
const citeDetailsMap = new Map();
Comment thread
marcoscaceres marked this conversation as resolved.
const headingQueries = new Map();
const elemHeadingKeys = new Map();
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);

const { citeSection, lt } = elem.dataset;
if (citeSection && !lt && elem.textContent === "") {
const mapKey = `${citeDetails.key}#${citeSection}`;
elemHeadingKeys.set(elem, mapKey);
if (!headingQueries.has(mapKey)) {
headingQueries.set(mapKey, { spec: citeDetails.key, id: citeSection });
}
}
}
const headingTexts = await fetchHeadingTexts([...headingQueries.values()]);

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;
}

// Use heading title from headings API when available and no alias was set.
const headingKey = elemHeadingKeys.get(elem);
if (headingKey && elem.textContent === "") {
const heading = headingTexts.get(headingKey);
if (heading?.title) {
linkProps.title = formatHeadingText(heading);
}
}

linkElem(elem, linkProps, citeDetails);
} else {
const msg = `Couldn't find a match for "${originalKey}"`;
Expand Down Expand Up @@ -228,7 +328,12 @@ async function updateBiblio(elems) {
* @param {Document} doc - The document to cleanup.
*/
function cleanup(doc) {
const attrToRemove = ["data-cite", "data-cite-frag", "data-cite-path"];
const attrToRemove = [
"data-cite",
"data-cite-frag",
"data-cite-path",
"data-cite-section",
];
const elems = doc.querySelectorAll("a[data-cite], dfn[data-cite]");
elems.forEach(elem =>
attrToRemove.forEach(attr => elem.removeAttribute(attr))
Expand Down
59 changes: 53 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,64 @@ 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>`;
let ref = matched.slice(3, -3).trim();
if (!inlineExpansionPattern.test(ref)) {
const msg = `Bad syntax: \`${matched}\` is not a valid inline expansion.`;
const hint =
"Expected `[[[#id]]]`, `[[[SPEC]]]`, `[[[SPEC#id]]]`, `[[[SPEC|text]]]`, `[[[SPEC#id|text]]]`, or `[[[#id|text]]]`; `!`/`?` prefixes are also supported.";
showWarning(msg, name, { hint });
return matched;
}
return html`<a href="${ref}" data-matched-text="${matched}"></a>`;
const pipeIdx = ref.indexOf("|");
const linkText = pipeIdx !== -1 ? ref.slice(pipeIdx + 1).trim() : null;
if (pipeIdx !== -1) ref = ref.slice(0, pipeIdx).trim();

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

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

const hashIdx = refWithoutPrefix.indexOf("#");
if (hashIdx !== -1) {
// SPEC#fragment form: use data-cite-section for the fragment so dfn-index
// doesn't misclassify this section link as an external definition reference.
const prefixLength = ref.length - refWithoutPrefix.length;
const specPart =
ref.slice(0, prefixLength) + refWithoutPrefix.slice(0, hashIdx);
const sectionFrag = refWithoutPrefix.slice(hashIdx + 1);
return html`<a
data-cite="${specPart}"
data-cite-section="${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
Loading
Loading