diff --git a/static/xref/script.js b/static/xref/script.js index 7696a39a..f90d1a1d 100644 --- a/static/xref/script.js +++ b/static/xref/script.js @@ -156,7 +156,7 @@ function renderResults(entries, query) { const specInfo = metadata.specs[entry.status][entry.spec]; const link = new URL(entry.uri, specInfo.url).href; const title = escapeHTML(specInfo.title); - const cite = metadata.types.idl.has(entry.type) + const cites = metadata.types.idl.has(entry.type) ? howToCiteIDL(citeTerm, entry, overloadedPairs) : metadata.types.markup.has(entry.type) ? howToCiteMarkup(citeTerm, entry) @@ -164,12 +164,14 @@ function renderResults(entries, query) { metadata.types.http.has(entry.type) ? howToCiteAnchor(citeTerm, entry) : howToCiteTerm(citeTerm, entry); + // Each citation is its own button: click/tap the citation to copy just it. + const citeCell = cites.map(citeButton).join('
'); let row = ` ${title} ${entry.shortname} ${entry.type} - ${cite} + ${citeCell} `; html += row; } @@ -221,19 +223,17 @@ function howToCiteIDL(term, entry, overloadedPairs = null) { const { type, for: forList } = entry; const safeTerm = escapeHTML(term); if (forList) { - return forList - .map(f => { - const safeF = escapeHTML(f); - let displayTerm = type === 'enum-value' ? `"${safeTerm}"` : safeTerm; - if (overloadedPairs?.has(`${entry.uri}|${f}`)) { - const hint = extractOverloadHint(entry.uri, f, term); - if (hint) { - displayTerm = displayTerm.replace('()', `(${escapeHTML(hint)})`); - } + return forList.map(f => { + const safeF = escapeHTML(f); + let displayTerm = type === 'enum-value' ? `"${safeTerm}"` : safeTerm; + if (overloadedPairs?.has(`${entry.uri}|${f}`)) { + const hint = extractOverloadHint(entry.uri, f, term); + if (hint) { + displayTerm = displayTerm.replace('()', `(${escapeHTML(hint)})`); } - return `{{${safeF}/${displayTerm ? displayTerm : '""'}}}`; - }) - .join('
'); + } + return `{{${safeF}/${displayTerm ? displayTerm : '""'}}}`; + }); } let cite; switch (type) { @@ -251,7 +251,7 @@ function howToCiteIDL(term, entry, overloadedPairs = null) { cite = cite.replace('()', `(${escapeHTML(hint)})`); } } - return cite; + return [cite]; } /** @@ -297,45 +297,96 @@ function extractOverloadHint(uri, forContext, term) { } function howToCiteMarkup(term, entry) { - const { type, for: forList, shortname } = entry; + const { type, for: forList } = entry; const safeTerm = escapeHTML(term); if (forList) { - return forList.map(f => `[^${escapeHTML(f)}/${safeTerm}^]`).join('
'); + return forList.map(f => `[^${escapeHTML(f)}/${safeTerm}^]`); } if (type === 'element-attr') { - return `[^/${safeTerm}^]`; + return [`[^/${safeTerm}^]`]; } - return `[^${safeTerm}^]`; + return [`[^${safeTerm}^]`]; } function howToCiteAnchor(term, entry) { const { type, for: forList } = entry; term = escapeHTML(term); if (!forList) { - return escapeHTML(`${term}`); + return [escapeHTML(`${term}`)]; } - return forList - .map(f => - escapeHTML( - `${term}`, - ), - ) - .join('
'); + return forList.map(f => + escapeHTML(`${term}`), + ); } function howToCiteTerm(term, entry) { - const { type, for: forList, shortname } = entry; + const { type, for: forList } = entry; term = escapeHTML(term.replace('/', '\\/')); if (forList) { - return forList.map(f => `[=${escapeHTML(f)}/${term}=]`).join('
'); + return forList.map(f => `[=${escapeHTML(f)}/${term}=]`); } - return `[=${term}=]`; + return [`[=${term}=]`]; } function escapeHTML(str) { return str.replace(/&/g, '&').replace(//g, '>'); } +// Render one citation as its own copy button. The accessible name conveys the +// action + value; `cite` is already escaped for &<>, but an attribute value +// also needs " escaped (e.g. exception citations like {{"DOMException"}}). +function citeButton(cite) { + const label = `Copy citation ${cite.replace(/"/g, '"')}`; + return ``; +} + +// A single reused live region. Reusing one element (rather than appending a +// new toast per click) avoids stacking overlapping toasts and competing +// announcements when copying several citations in quick succession. +let copyToast; +let copyToastTimer; +function showCopyToast(message) { + if (!copyToast) { + copyToast = document.createElement('div'); + copyToast.className = 'copy-toast'; + copyToast.setAttribute('role', 'status'); + document.body.appendChild(copyToast); + } + clearTimeout(copyToastTimer); + // Set the text on the next frame so the (already-attached) live region + // announces it as a change rather than as initial content. + requestAnimationFrame(() => { + copyToast.textContent = message; + copyToast.classList.add('is-visible'); + }); + copyToastTimer = setTimeout(() => { + copyToast.classList.remove('is-visible'); + }, 1500); +} + +async function copyCitation(text) { + if (!text) return; + if (!navigator.clipboard) { + showCopyToast('Clipboard unavailable'); + return; + } + try { + await navigator.clipboard.writeText(text); + showCopyToast(`Copied: ${text}`); + } catch (err) { + console.error(err); + showCopyToast('Copy failed'); + } +} + +// Click-to-copy: each citation is itself a ", + ); + }); + + it("escapes double quotes in the aria-label so the attribute stays well-formed", () => { + // Exception citations like {{"DOMException"}} contain literal quotes that + // would otherwise break out of the aria-label attribute. + const html = citeButton('{{"DOMException"}}'); + expect(html).toContain('aria-label="Copy citation {{"DOMException"}}"'); + // The visible button text keeps the real quotes (escapeHTML leaves " alone). + expect(html).toContain('>{{"DOMException"}}'); + }); + + it("leaves already-escaped &<> entities intact in the label", () => { + // `cite` reaches citeButton already escaped for & < > by escapeHTML, so the + // helper must not double-escape them. + const html = citeButton("[=a&b/x=]"); + expect(html).toContain('aria-label="Copy citation [=a&b/x=]"'); + }); +}); diff --git a/tests/static/xref/script.test.js b/tests/static/xref/script.test.js index af13e283..509d072a 100644 --- a/tests/static/xref/script.test.js +++ b/tests/static/xref/script.test.js @@ -11,13 +11,21 @@ const SRC = fileURLToPath( ); const text = readFileSync(SRC, "utf8"); +// Stop before citeButton so the slice excludes the clipboard code (which +// references the `output` DOM global and would throw in this context). const fnBlock = text.slice( text.indexOf("function howToCiteIDL"), - text.indexOf("async function ready"), + text.indexOf("function citeButton"), ); const excStart = text.indexOf("const exceptionExceptions"); const excBlock = text.slice(excStart, text.indexOf("]);", excStart) + 3); +// Fail loudly if the helpers are renamed/reordered so the slices no longer +// capture what we expect, rather than silently testing the wrong thing. +if (!fnBlock.includes("function howToCiteTerm") || !excBlock.includes("URIError")) { + throw new Error("could not slice citation helpers out of script.js"); +} + const sandbox = {}; vm.createContext(sandbox); vm.runInContext( @@ -33,45 +41,51 @@ const XSS = ""; describe("xref/script - citation HTML escaping", () => { it("escapes the term and for-context in IDL citations", () => { - expect(howToCiteIDL(XSS, { type: "attribute", for: ["Window"] })).not.toContain( - ""] }), + howToCiteIDL(XSS, { type: "attribute", for: ["Window"] }).join(""), + ).not.toContain(""] }).join(""), ).toContain("<f>"); - expect(howToCiteIDL(XSS, { type: "interface" })).not.toContain(" { expect( - howToCiteMarkup(XSS, { type: "element", for: [""] }), + howToCiteMarkup(XSS, { type: "element", for: [""] }).join(""), ).not.toContain(" { - expect(howToCiteTerm(XSS, { type: "dfn" })).not.toContain(""] })).toContain( - "<f>", - ); + expect(howToCiteTerm(XSS, { type: "dfn" }).join("")).not.toContain(""] }).join(""), + ).toContain("<f>"); }); it("leaves ordinary citations unchanged", () => { expect( howToCiteIDL("postMessage", { type: "method", for: ["Window"] }), - ).toBe("{{Window/postMessage}}"); + ).toEqual(["{{Window/postMessage}}"]); expect( howToCiteIDL("classic", { type: "enum-value", for: ["WorkerType"] }), - ).toBe('{{WorkerType/"classic"}}'); - expect(howToCiteTerm("a/b", { type: "dfn" })).toBe("[=a\\/b=]"); + ).toEqual(['{{WorkerType/"classic"}}']); + expect(howToCiteTerm("a/b", { type: "dfn" })).toEqual(["[=a\\/b=]"]); }); it("renders the empty-term fallback (the touched truthiness branch)", () => { // `escapeHTML("")` is still falsy, so the `safeTerm ? termPart : '""'` // branch must keep producing the empty-string placeholder. - expect(howToCiteIDL("", { type: "method", for: ["Window"] })).toBe( + expect(howToCiteIDL("", { type: "method", for: ["Window"] })).toEqual([ '{{Window/""}}', - ); + ]); }); });