diff --git a/src/core/link-to-dfn.js b/src/core/link-to-dfn.js index e412c29aba..d85b425e81 100644 --- a/src/core/link-to-dfn.js +++ b/src/core/link-to-dfn.js @@ -170,7 +170,8 @@ function collectDfns(title) { if ("idl" in dfn.dataset || dfnType !== "dfn") { result.get(dfnFor)?.set("idl", dfn); } - addId(dfn, "dfn", title); + const idText = dfnFor ? `${dfnFor}-${title}` : title; + addId(dfn, "dfn", idText); } } diff --git a/src/core/location-hash.js b/src/core/location-hash.js index 1135d28ebd..2e5f41feff 100644 --- a/src/core/location-hash.js +++ b/src/core/location-hash.js @@ -6,6 +6,7 @@ // the window to the correct point in the document when processing is done. export const name = "core/location-hash"; +const DFN_ID_PREFIX = "dfn-"; export function run() { if (!window.location.hash) { @@ -39,6 +40,37 @@ export function run() { const updatedElement = document.getElementById(id); if (updatedElement) { newHash = id; + } else if (id.startsWith(DFN_ID_PREFIX)) { + const legacyTerm = id.slice(DFN_ID_PREFIX.length); + const numericSuffixMatch = legacyTerm.match(/^(.+)-(\d+)$/); + const termWithLeadingHyphen = `-${legacyTerm}`; + const scopedDfnElements = [ + ...document.querySelectorAll( + `[data-dfn-type][id^='${DFN_ID_PREFIX}']` + ), + ]; + let matchingElements = scopedDfnElements.filter(({ id }) => { + const scopedId = id.slice(DFN_ID_PREFIX.length); + return ( + scopedId === legacyTerm || scopedId.endsWith(termWithLeadingHyphen) + ); + }); + if (!matchingElements.length && numericSuffixMatch) { + const [, baseTerm, index] = numericSuffixMatch; + const baseTermWithLeadingHyphen = `-${baseTerm}`; + matchingElements = scopedDfnElements.filter(({ id }) => { + const scopedId = id.slice(DFN_ID_PREFIX.length); + return ( + scopedId === baseTerm || + scopedId.endsWith(baseTermWithLeadingHyphen) + ); + }); + if (matchingElements[Number(index)]) { + newHash = matchingElements[Number(index)].id; + } + } else if (matchingElements.length === 1) { + newHash = matchingElements[0].id; + } } } window.location.hash = `#${newHash}`; diff --git a/tests/spec/core/link-to-dfn-spec.js b/tests/spec/core/link-to-dfn-spec.js index 4be40fe6a5..4b29d30379 100644 --- a/tests/spec/core/link-to-dfn-spec.js +++ b/tests/spec/core/link-to-dfn-spec.js @@ -17,11 +17,36 @@ describe("Core — Link to definitions", () => { const doc = await makeRSDoc(ops); const a = doc.getElementById("testAnchor"); expect(a).toBeTruthy(); - expect(a.hash).toBe("#dfn-test"); + expect(a.hash).toBe("#dfn-window-test"); const decodedHash = decodeURIComponent(a.hash); expect(doc.getElementById(decodedHash.slice(1))).toBeTruthy(); }); + it("uses data-dfn-for in generated IDs for duplicate terms", async () => { + const body = ` +
+

Test section

+

+ state + state + state + state +

+
`; + const ops = makeStandardOps(null, body); + const doc = await makeRSDoc(ops); + const [requestState, responseState] = doc.querySelectorAll("dfn"); + expect(requestState.id).toBe("dfn-request-state"); + expect(responseState.id).toBe("dfn-response-state"); + expect(requestState.id).not.toMatch(/-\d+$/); + expect(responseState.id).not.toMatch(/-\d+$/); + + const requestStateLink = doc.getElementById("requestState"); + const responseStateLink = doc.getElementById("responseState"); + expect(requestStateLink.hash).toBe("#dfn-request-state"); + expect(responseStateLink.hash).toBe("#dfn-response-state"); + }); + it("links to IDL definitions and wraps in code if needed", async () => { const bodyText = `
diff --git a/tests/spec/core/location-hash-legacy-indexed.html b/tests/spec/core/location-hash-legacy-indexed.html new file mode 100644 index 0000000000..1e7743377f --- /dev/null +++ b/tests/spec/core/location-hash-legacy-indexed.html @@ -0,0 +1,25 @@ + + + + Legacy Indexed Hashes + +
+

Basic doc

+
+
+

CUSTOM PARAGRAPH

+
+
+

Legacy indexed terms

+

unsafe current time

+

unsafe current time

+
+ diff --git a/tests/spec/core/location-hash-spec.js b/tests/spec/core/location-hash-spec.js index a95be7d3b8..cb644b69d9 100644 --- a/tests/spec/core/location-hash-spec.js +++ b/tests/spec/core/location-hash-spec.js @@ -6,6 +6,7 @@ describe("Core — Location Hash", () => { afterAll(flushIframes); const ops = makeStandardOps(); const simpleURL = "/tests/spec/core/simple.html"; + const legacyIndexedURL = "/tests/spec/core/location-hash-legacy-indexed.html"; describe("legacy fragment format", () => { it("leaves editor defined id alone, even if they include illegal chars", async () => { @@ -22,7 +23,12 @@ describe("Core — Location Hash", () => { it("recovers legacy encoded hashes for slots", async () => { const testURL = `${simpleURL}#dfn-%5B%5Bescapedslot%5D%5D`; const doc = await makeRSDoc(ops, testURL); - expect(doc.location.hash).toBe("#dfn-escapedslot"); + expect(doc.location.hash).toBe("#dfn-test-escapedslot"); + }, 20000); + it("recovers legacy numeric-suffixed hashes", async () => { + const testURL = `${legacyIndexedURL}#dfn-unsafe-current-time-0`; + const doc = await makeRSDoc(ops, testURL); + expect(doc.location.hash).toBe("#dfn-window-unsafe-current-time"); }, 20000); }); }); diff --git a/tests/spec/core/xref-spec.js b/tests/spec/core/xref-spec.js index ee6e622a1d..37ca85b75b 100644 --- a/tests/spec/core/xref-spec.js +++ b/tests/spec/core/xref-spec.js @@ -852,7 +852,7 @@ describe("Core — xref", () => { // as base == [[type]], it is treated as a local internal slot const link1 = doc.querySelector("#link1 a"); - expect(link1.getAttribute("href")).toBe("#dfn-type"); + expect(link1.getAttribute("href")).toBe("#dfn-window-type"); expect(link1.firstElementChild.localName).toBe("code"); // the base "Credential" is used as "forContext" for [[type]]