Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
111 changes: 81 additions & 30 deletions static/xref/script.js
Original file line number Diff line number Diff line change
Expand Up @@ -156,20 +156,22 @@ 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)
: metadata.types.css.has(entry.type) ||
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('<br>');
let row = `
<tr>
<td><a href="${link}">${title}</a></td>
<td>${entry.shortname}</td>
<td>${entry.type}</td>
<td>${cite}</td>
<td>${citeCell}</td>
</tr>`;
html += row;
}
Expand Down Expand Up @@ -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('<br>');
}
return `{{${safeF}/${displayTerm ? displayTerm : '""'}}}`;
});
}
let cite;
switch (type) {
Expand All @@ -251,7 +251,7 @@ function howToCiteIDL(term, entry, overloadedPairs = null) {
cite = cite.replace('()', `(${escapeHTML(hint)})`);
}
}
return cite;
return [cite];
}

/**
Expand Down Expand Up @@ -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('<br>');
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(`<a data-xref-type="${type}">${term}</a>`);
return [escapeHTML(`<a data-xref-type="${type}">${term}</a>`)];
}
return forList
.map(f =>
escapeHTML(
`<a data-xref-type="${type}" data-xref-for="${f}">${term}</a>`,
),
)
.join('<br>');
return forList.map(f =>
escapeHTML(`<a data-xref-type="${type}" data-xref-for="${f}">${term}</a>`),
);
}

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('<br>');
return forList.map(f => `[=${escapeHTML(f)}/${term}=]`);
}
return `[=${term}=]`;
return [`[=${term}=]`];
}

function escapeHTML(str) {
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}

// 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, '&quot;')}`;
return `<button type="button" class="cite" aria-label="${label}">${cite}</button>`;
}

// 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 <button>, so clicking/tapping the
// citation copies just that one. Being a real button, keyboard activation
// (Enter/Space) dispatches a click for free.
output.addEventListener('click', e => {
const cite = e.target.closest('.cite');
if (cite) copyCitation(cite.textContent);
});

async function ready() {
const updateInput = (el, values) => {
el.setAttribute('placeholder', values.slice(0, 5).join(','));
Expand Down
49 changes: 49 additions & 0 deletions static/xref/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,55 @@ table th {
text-align: left;
}

.cite {
font: inherit;
color: inherit;
background: none;
border: 0;
padding: 0.1rem 0.25rem;
margin: -0.1rem -0.25rem;
border-radius: 4px;
text-align: left;
cursor: pointer;
}

.cite:hover {
background: #dbeafe;
}

.cite:focus-visible {
outline: 2px solid #005a9c;
outline-offset: 1px;
}

.copy-toast {
position: fixed;
bottom: 1.5rem;
left: 50%;
transform: translateX(-50%);
max-width: 90vw;
white-space: pre-line;
background: #1e293b;
color: white;
padding: 0.5rem 1.2rem;
border-radius: 8px;
font-size: 0.85rem;
z-index: 9999;
opacity: 0;
transition: opacity 0.2s;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
}

.copy-toast.is-visible {
opacity: 1;
}

@media (prefers-reduced-motion: reduce) {
.copy-toast {
transition: none;
}
}

.autocomplete {
background: white;
z-index: 1000;
Expand Down
52 changes: 52 additions & 0 deletions tests/static/xref/clipboard.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import vm from "node:vm";

// static/xref/script.js is an ES module that does top-level DOM work and a
// metadata fetch on load, so it can't be imported here. Slice out the pure
// citeButton helper and evaluate it in isolation to test the citation-button
// markup (the part that does not need a real browser).
const SRC = fileURLToPath(
new URL("../../../static/xref/script.js", import.meta.url),
);
const text = readFileSync(SRC, "utf8");
const fnBlock = text.slice(
text.indexOf("function citeButton"),
text.indexOf("// A single reused live region"),
);
// Fail loudly if the helper is reordered/renamed so the slice no longer
// captures citeButton, rather than silently testing the wrong thing.
if (!fnBlock.includes("aria-label")) {
throw new Error("could not slice citeButton out of script.js");
}

const sandbox = {};
vm.createContext(sandbox);
vm.runInContext(`${fnBlock}\nthis.citeButton = citeButton;`, sandbox);
const { citeButton } = sandbox;

describe("xref/script - citeButton", () => {
it("wraps a citation in a real button so the table cell keeps its role", () => {
expect(citeButton("{{Window/postMessage}}")).toBe(
'<button type="button" class="cite" ' +
'aria-label="Copy citation {{Window/postMessage}}">' +
"{{Window/postMessage}}</button>",
);
});

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 {{&quot;DOMException&quot;}}"');
// The visible button text keeps the real quotes (escapeHTML leaves " alone).
expect(html).toContain('>{{"DOMException"}}</button>');
});

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&amp;b/x=]");
expect(html).toContain('aria-label="Copy citation [=a&amp;b/x=]"');
});
});
50 changes: 32 additions & 18 deletions tests/static/xref/script.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -33,45 +41,51 @@ const XSS = "<img src=x onerror=alert(1)>";

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(
"<img",
);
expect(
howToCiteIDL("postMessage", { type: "method", for: ["<f>"] }),
howToCiteIDL(XSS, { type: "attribute", for: ["Window"] }).join(""),
).not.toContain("<img");
expect(
howToCiteIDL("postMessage", { type: "method", for: ["<f>"] }).join(""),
).toContain("&lt;f&gt;");
expect(howToCiteIDL(XSS, { type: "interface" })).not.toContain("<img");
expect(howToCiteIDL(XSS, { type: "interface" }).join("")).not.toContain(
"<img",
);
});

it("escapes the term and for-context in markup citations", () => {
expect(
howToCiteMarkup(XSS, { type: "element", for: ["<f>"] }),
howToCiteMarkup(XSS, { type: "element", for: ["<f>"] }).join(""),
).not.toContain("<img");
expect(howToCiteMarkup(XSS, { type: "element-attr" })).not.toContain("<img");
expect(howToCiteMarkup(XSS, { type: "element" })).not.toContain("<img");
expect(
howToCiteMarkup(XSS, { type: "element-attr" }).join(""),
).not.toContain("<img");
expect(howToCiteMarkup(XSS, { type: "element" }).join("")).not.toContain(
"<img",
);
});

it("escapes the term and for-context in dfn-term citations", () => {
expect(howToCiteTerm(XSS, { type: "dfn" })).not.toContain("<img");
expect(howToCiteTerm("x", { type: "dfn", for: ["<f>"] })).toContain(
"&lt;f&gt;",
);
expect(howToCiteTerm(XSS, { type: "dfn" }).join("")).not.toContain("<img");
expect(
howToCiteTerm("x", { type: "dfn", for: ["<f>"] }).join(""),
).toContain("&lt;f&gt;");
});

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