diff --git a/profiles/aom.js b/profiles/aom.js index db075aa1d6..02ca4ba974 100644 --- a/profiles/aom.js +++ b/profiles/aom.js @@ -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"), diff --git a/profiles/dini.js b/profiles/dini.js index 0315e6d46e..9d3b94e979 100644 --- a/profiles/dini.js +++ b/profiles/dini.js @@ -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"), diff --git a/profiles/geonovum.js b/profiles/geonovum.js index 798a323b35..285b740f21 100644 --- a/profiles/geonovum.js +++ b/profiles/geonovum.js @@ -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"), diff --git a/profiles/w3c.js b/profiles/w3c.js index cdf270af71..6baf54eeb5 100644 --- a/profiles/w3c.js +++ b/profiles/w3c.js @@ -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"), diff --git a/src/core/data-cite.js b/src/core/data-cite.js index 1a8767612e..2d86f1dbd0 100644 --- a/src/core/data-cite.js +++ b/src/core/data-cite.js @@ -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(); for (const elem of elems) { - 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}"`; diff --git a/src/core/dfn-index.js b/src/core/dfn-index.js index 3bfa9772e1..6968d89635 100644 --- a/src/core/dfn-index.js +++ b/src/core/dfn-index.js @@ -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; diff --git a/src/core/inlines.js b/src/core/inlines.js index 58fc671498..6b0630e947 100644 --- a/src/core/inlines.js +++ b/src/core/inlines.js @@ -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 =] const inlineElement = /(?:\[\^[^^]+\^\])/; // Inline [^element^] const inlineCddlReference = /(?:\{\^[^}^]+\^\})/; // {^cddl-type^}, {^type/key^} @@ -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-.]+)?)(?:\|[^\]]+)?$/; + /** * @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``; + 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`${linkText}` + : html``; } - return html``; + + 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``; + } + + return html``; } /** diff --git a/src/core/xref-headings-db.js b/src/core/xref-headings-db.js new file mode 100644 index 0000000000..d1feb67cd0 --- /dev/null +++ b/src/core/xref-headings-db.js @@ -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} */ + 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} */ + 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} 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); + } +} diff --git a/src/core/xref-headings.js b/src/core/xref-headings.js new file mode 100644 index 0000000000..114b086ef0 --- /dev/null +++ b/src/core/xref-headings.js @@ -0,0 +1,165 @@ +// @ts-check +/** + * Module core/xref-headings + * + * Resolves cross-spec section heading titles via the respec.org headings API. + * Handles [[[SPEC#id]]] section links: applies alias text (data-lt) or fetches + * heading titles from the API, and updates element content with proper secno markup. + * Caches results in IndexedDB via core/xref-headings-db. + * + * Configuration: + * conf.xref.headingApiUrl {string} — override the headings API URL (useful for testing) + * + * @module core/xref-headings + */ +import { cacheHeadingsData, resolveHeadingsCache } from "./xref-headings-db.js"; +import { html } from "./import-maps.js"; +import { showWarning } from "./utils.js"; + +export const name = "core/xref-headings"; + +export const HEADINGS_API_URL = "https://respec.org/xref/search/headings"; + +/** + * @typedef {{ title: string, number: string | null }} HeadingInfo + * @typedef {{ spec: string, id: string, title: string, number: string | null, error?: boolean }} HeadingApiResultEntry + */ + +/** + * Processes all [[[SPEC#id]]] section links in the document: + * - Applies alias text (data-lt) when provided by the author + * - Fetches section heading titles from the headings API for the rest + * - Falls back to the spec title already set by core/data-cite + * + * Must run after core/data-cite (so elements have href and spec-title fallback). + * @param {Conf} conf + */ +export async function run(conf) { + // Section links from [[[SPEC#id]]] have both data-cite-frag and data-matched-text set. + // (data-matched-text is set by inlines.js on all [[[...]]] expansions; + // data-cite-frag carries the section fragment for the SPEC#id form only.) + /** @type {NodeListOf} */ + const elems = document.querySelectorAll( + "a[data-cite-frag][data-matched-text]" + ); + if (!elems.length) return; + + const apiUrl = getApiUrl(conf); + + /** @type {Map} */ + const headingQueries = new Map(); + /** @type {{ elem: HTMLAnchorElement, key: string }[]} */ + const headingElems = []; + + elems.forEach(elem => { + if (elem.dataset.lt) { + elem.textContent = elem.dataset.lt; + delete elem.dataset.lt; + } else { + const spec = (elem.dataset.cite ?? "").replace(/^[!?]/, ""); + const id = elem.dataset.citeFrag ?? ""; + const key = `${spec}#${id}`; + headingElems.push({ elem, key }); + if (!headingQueries.has(key)) { + headingQueries.set(key, { spec, id }); + } + } + }); + + if (!headingElems.length) return; + + const headingTexts = await fetchHeadingTexts( + [...headingQueries.values()], + apiUrl + ); + + headingElems.forEach(({ elem, key }) => { + const heading = headingTexts.get(key); + if (heading?.title) { + elem.textContent = ""; + setHeadingContent(elem, heading); + } + }); +} + +/** + * Returns the headings API URL from conf, falling back to the default. + * @param {Conf} conf + * @returns {string} + */ +function getApiUrl(conf) { + const xrefConf = + typeof conf.xref === "object" && + conf.xref !== null && + !Array.isArray(conf.xref) + ? conf.xref + : {}; + return typeof xrefConf.headingApiUrl === "string" + ? xrefConf.headingApiUrl + : HEADINGS_API_URL; +} + +/** + * Fetches heading titles from the respec.org headings API for cross-spec + * section links ([[[SPEC#id]]] syntax). Returns a Map keyed by "spec#id". + * Uses IndexedDB cache; falls back to network on cache miss. + * @param {{ spec: string, id: string }[]} queries + * @param {string} [apiUrl] - override the API URL (defaults to HEADINGS_API_URL) + * @returns {Promise>} + */ +export async function fetchHeadingTexts(queries, apiUrl = HEADINGS_API_URL) { + if (!queries.length) return new Map(); + + const cached = await resolveHeadingsCache(queries); + const uncachedQueries = queries.filter(q => !cached.has(`${q.spec}#${q.id}`)); + + if (!uncachedQueries.length) return cached; + + try { + const res = await fetch(apiUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ queries: uncachedQueries }), + }); + if (!res.ok) { + const msg = `Failed to fetch heading texts (HTTP ${res.status}).`; + const hint = "Cross-spec section links will fall back to spec titles."; + showWarning(msg, name, { hint }); + return cached; + } + const { result = [] } = await res.json(); + /** @type {Map} */ + const fetched = new Map( + /** @type {HeadingApiResultEntry[]} */ (result) + .filter(entry => !entry.error) + .map( + entry => + /** @type {[string, HeadingInfo]} */ ([ + `${entry.spec}#${entry.id}`, + { title: entry.title, number: entry.number || null }, + ]) + ) + ); + await cacheHeadingsData(uncachedQueries, fetched); + return new Map([...cached, ...fetched]); + } catch { + const msg = "Failed to fetch heading texts from respec.org."; + const hint = "Cross-spec section links will fall back to spec titles."; + showWarning(msg, name, { hint }); + return cached; + } +} + +/** + * Sets heading content on an element using proper secno markup. + * When a section number is available, produces `N Title`. + * @param {HTMLElement} elem + * @param {HeadingInfo} heading + */ +export function setHeadingContent(elem, { title, number }) { + if (number) { + elem.append(html`${number} `, title); + } else { + elem.textContent = title; + } +} diff --git a/src/type-helper.d.ts b/src/type-helper.d.ts index d7f3cd1c66..80e08906ea 100644 --- a/src/type-helper.d.ts +++ b/src/type-helper.d.ts @@ -357,7 +357,7 @@ interface Conf { [key: string]: unknown; }; /** External cross-reference configuration */ - xref?: boolean | string | string[] | { url?: string; specs?: string[]; profile?: string }; + xref?: boolean | string | string[] | { url?: string; specs?: string[]; profile?: string; headingApiUrl?: string }; /** Whether to include JSON-LD metadata */ doJsonLd?: boolean; /** Whether to highlight variables */ diff --git a/tests/data/headings.json b/tests/data/headings.json new file mode 100644 index 0000000000..c9502527fd --- /dev/null +++ b/tests/data/headings.json @@ -0,0 +1,16 @@ +{ + "result": [ + { + "spec": "fetch", + "id": "fetching", + "title": "Fetching", + "number": "4" + }, + { + "spec": "fetch", + "id": "data-fetch", + "title": "Fetching data", + "number": "4.1" + } + ] +} diff --git a/tests/spec/core/inlines-spec.js b/tests/spec/core/inlines-spec.js index 5a39222130..88eb9e8946 100644 --- a/tests/spec/core/inlines-spec.js +++ b/tests/spec/core/inlines-spec.js @@ -1,9 +1,11 @@ "use strict"; import { flushIframes, makeRSDoc, makeStandardOps } from "../SpecHelper.js"; +import { clearHeadingsData } from "../../../src/core/xref-headings-db.js"; describe("Core - Inlines", () => { afterAll(flushIframes); + beforeEach(clearHeadingsData); it("processes inline cite content", async () => { const body = `
@@ -284,6 +286,299 @@ describe("Core - Inlines", () => { 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 = ` +
+
+

[[[!the-spec#some-id]]]

+

[[[?other-spec#some-id]]]

+
+
+ `; + const doc = await makeRSDoc(makeStandardOps(config, body)); + + // [[[!the-spec#some-id]]] → normative (! prefix in normative section = normative) + 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]"); + + // [[[?other-spec#some-id]]] → informative (? prefix overrides 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 = ` +
+
+

[[[?the-spec#some-id|custom link text]]]

+

[[[?other-spec|just the spec]]]

+
+
+ `; + 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 = ` +
+

[[[not-found|Custom Text]]]

+
+ `; + 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 = { + xref: { headingApiUrl: `${location.origin}/tests/data/headings.json` }, + localBiblio: { + fetch: { + title: "Fetch Standard", + href: "https://fetch.spec.whatwg.org/", + }, + }, + }; + const body = ` +
+

[[[fetch#data-fetch]]]

+
+ `; + 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"); + // Local fixture returns { number: "4.1", title: "Fetching data" } + expect(anchor.textContent).toBe("4.1 Fetching data"); + }); + + it("uses heading text from API for [[[SPEC#id]]] when available", async () => { + const config = { + xref: { headingApiUrl: `${location.origin}/tests/data/headings.json` }, + localBiblio: { + fetch: { + title: "Fetch Standard", + href: "https://fetch.spec.whatwg.org/", + }, + }, + }; + const body = ` +
+

[[[fetch#fetching]]]

+
+ `; + 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/#fetching"); + // Local fixture returns { number: "4", title: "Fetching" } + // showing "4 Fetching" + const secno1 = anchor.querySelector("bdi.secno"); + expect(secno1).toBeTruthy(); + expect(secno1.textContent).toBe("4 "); + expect(anchor.textContent).toBe("4 Fetching"); + }); + + it("uses xref.headingApiUrl to fetch heading texts for [[[SPEC#id]]]", async () => { + const config = { + xref: { headingApiUrl: `${location.origin}/tests/data/headings.json` }, + localBiblio: { + fetch: { + title: "Fetch Standard", + href: "https://fetch.spec.whatwg.org/", + }, + }, + }; + const body = ` +
+

[[[fetch#fetching]]]

+
+ `; + 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/#fetching"); + // Local fixture returns { number: "4", title: "Fetching" } + const secno2 = anchor.querySelector("bdi.secno"); + expect(secno2).toBeTruthy(); + expect(secno2.textContent).toBe("4 "); + expect(anchor.textContent).toBe("4 Fetching"); + }); + + it("prefers alias text over heading text for [[[SPEC#id|text]]]", async () => { + const config = { + xref: { headingApiUrl: `${location.origin}/tests/data/headings.json` }, + localBiblio: { + fetch: { + title: "Fetch Standard", + href: "https://fetch.spec.whatwg.org/", + }, + }, + }; + const body = ` +
+

[[[fetch#fetching|custom text]]]

+
+ `; + const doc = await makeRSDoc(makeStandardOps(config, body)); + const anchor = doc.querySelector("#with-alias a[href]"); + expect(anchor).toBeTruthy(); + expect(anchor.href).toBe("https://fetch.spec.whatwg.org/#fetching"); + expect(anchor.textContent).toBe("custom text"); + }); + + it("does not add [[[SPEC#id]]] section links to dfn-index as external definitions", async () => { + const config = { + xref: { headingApiUrl: `${location.origin}/tests/data/headings.json` }, + localBiblio: { + fetch: { + title: "Fetch Standard", + href: "https://fetch.spec.whatwg.org/", + }, + }, + }; + const body = ` +
+
+

[[[fetch#data-fetch]]]

+
+ `; + const doc = await makeRSDoc(makeStandardOps(config, body)); + // Section link should be resolved correctly (heading from local fixture) + 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("4.1 Fetching data"); + // But it must NOT appear in the dfn-index "Terms defined by reference" table + const externalTerms = doc.querySelectorAll( + "#index-defined-elsewhere li[data-spec]" + ); + expect(externalTerms.length).toBe(0); + }); + + it("supports alias text with [[[SPEC#id|text]]] syntax", async () => { + const config = { + xref: { headingApiUrl: `${location.origin}/tests/data/headings.json` }, + localBiblio: { + fetch: { + title: "Fetch Standard", + href: "https://fetch.spec.whatwg.org/", + }, + }, + }; + const body = ` +
+

[[[fetch#data-fetch|fetching data]]]

+

[[[fetch#data-fetch]]]

+
+ `; + 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(); + // heading from local fixture: { number: "4.1", title: "Fetching data" } + expect(noAliasAnchor.textContent).toBe("4.1 Fetching data"); + }); + + 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 = ` +
+

[[[fetch|Custom Fetch Link]]]

+

[[[fetch]]]

+
+ `; + 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 = ` +
+

My Section Heading

+

Some content.

+
+
+

References

+

[[[#my-section|see this 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) { @@ -329,6 +624,17 @@ describe("Core - Inlines", () => { expect(badOne.textContent).toBe("#does-not-exist"); }); + it("does not process [[[#id#invalid]]] with multiple hash fragments", async () => { + const body = ` +

Section

+

[[[#section#invalid]]]

+ `; + 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 = `