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("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/""}}',
- );
+ ]);
});
});