Skip to content
17 changes: 11 additions & 6 deletions src/core/render-biblio.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,17 @@
// Module core/render-biblio
// renders the biblio data pre-processed in core/biblio

import { addId, getIntlData, showError } from "./utils.js";
import { addId, getIntlData, showError, toId } from "./utils.js";
import { biblio } from "./biblio.js";
import { html } from "./import-maps.js";

export const name = "core/render-biblio";

/** @param {string} ref */
function bibRefId(ref) {
return `bib-${toId(ref)}`;
}

const localizationStrings = {
en: {
info_references: "Informative references",
Expand Down Expand Up @@ -191,7 +196,7 @@ function getUniqueRefs(refs) {
*/
export function renderInlineCitation(ref, linkText) {
const key = ref.replace(/^(!|\?)/, "");
const href = `#bib-${key.toLowerCase()}`;
const href = `#${bibRefId(key)}`;
const text = linkText || key;
const elem = html`<cite
><a class="bibref" href="${href}" data-link-type="biblio">${text}</a></cite
Expand All @@ -205,7 +210,7 @@ export function renderInlineCitation(ref, linkText) {
*/
function showRef(reference) {
const { ref, refcontent } = reference;
const refId = `bib-${ref.toLowerCase()}`;
const refId = bibRefId(ref);
const result = html`
<dt id="${refId}">[${ref}]</dt>
<dd>
Expand Down Expand Up @@ -270,10 +275,10 @@ function getAliases(refs) {
function decorateInlineReference(refs, aliases) {
refs
.map(({ ref, refcontent }) => {
const refUrl = `#bib-${ref.toLowerCase()}`;
const refUrl = `#${bibRefId(ref)}`;
const selectors = aliases
.get(refcontent.id)
.map(alias => `a.bibref[href="#bib-${alias.toLowerCase()}"]`)
.map(alias => `a.bibref[href="#${bibRefId(alias)}"]`)
.join(",");
const elems = document.querySelectorAll(selectors);
return { refUrl, elems, refcontent };
Expand All @@ -294,7 +299,7 @@ function warnBadRefs(refs) {
for (const { ref } of refs) {
/** @type {NodeListOf<HTMLElement>} */
const links = document.querySelectorAll(
`a.bibref[href="#bib-${ref.toLowerCase()}"]`
`a.bibref[href="#${bibRefId(ref)}"]`
);
const elements = [...links].filter(
({ textContent: t }) => t.toLowerCase() === ref.toLowerCase()
Expand Down
28 changes: 20 additions & 8 deletions src/core/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -393,6 +393,25 @@ export function addHashId(elem, prefix = "") {
return addId(elem, prefix, hash);
}

/**
* Converts a string to a slug suitable for use in an HTML id attribute:
* lowercases (unless noLC is true), decomposes Unicode, strips diacritics,
* replaces runs of non-word characters with "-", and trims leading/trailing
* hyphens.
* @param {string} txt
* @param {boolean} [noLC] - when true, skip lowercasing
* @returns {string}
*/
export function toId(txt, noLC = false) {
Comment thread
marcoscaceres marked this conversation as resolved.
return (noLC ? txt : txt.toLowerCase())
.trim()
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/\W+/gim, "-")
.replace(/^-+/, "")
.replace(/-+$/, "");
}

/**
* Creates and sets an ID to an element (elem) using a specific prefix if
* provided, and a specific text if given.
Expand All @@ -409,14 +428,7 @@ export function addId(elem, pfx = "", txt = "", noLC = false) {
if (!txt) {
txt = (elem.title ? elem.title : elem.textContent).trim();
}
let id = noLC ? txt : txt.toLowerCase();
id = id
.trim()
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/\W+/gim, "-")
.replace(/^-+/, "")
.replace(/-+$/, "");
let id = toId(txt, noLC);

if (!id) {
id = "generatedID";
Expand Down
40 changes: 40 additions & 0 deletions tests/spec/core/biblio-spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,46 @@ describe("W3C — Bibliographic References", () => {
expect(badRef.textContent.trim()).toBe("Reference not found.");
});

it("generates valid HTML IDs for reference keys that contain spaces", async () => {
const body = `
<section id="conformance">
<p id="ref-ruby">[[Ruby TTS Req]]</p>
<p id="ref-tokyo">[[Tokyo Ghoul: re]]</p>
</section>
`;
const localBiblio = {
"Ruby TTS Req": {
title: "Ruby Text and Speech Requirements",
href: "https://example.com/ruby-tts",
},
"Tokyo Ghoul: re": {
title: "Tokyo Ghoul: re, Vol. 8",
href: "https://example.com/tokyo-ghoul",
},
};
const ops = makeStandardOps({ localBiblio }, body);
const doc = await makeRSDoc(ops);

// dt IDs must not contain spaces or characters that break CSS selectors
const rubyDt = doc.getElementById("bib-ruby-tts-req");
expect(rubyDt).withContext("dt#bib-ruby-tts-req should exist").toBeTruthy();
expect(rubyDt.textContent.trim()).toBe("[Ruby TTS Req]");

// colon+space in "Tokyo Ghoul: re" should both be collapsed to a single hyphen
const tokyoDt = doc.getElementById("bib-tokyo-ghoul-re");
expect(tokyoDt)
.withContext("dt#bib-tokyo-ghoul-re should exist")
.toBeTruthy();
expect(tokyoDt.textContent.trim()).toBe("[Tokyo Ghoul: re]");

// inline citation hrefs must point to the sanitized fragment
const rubyLink = doc.querySelector("#ref-ruby a.bibref");
expect(rubyLink.getAttribute("href")).toBe("#bib-ruby-tts-req");

const tokyoLink = doc.querySelector("#ref-tokyo a.bibref");
expect(tokyoLink.getAttribute("href")).toBe("#bib-tokyo-ghoul-re");
});

it("shows a localized error if reference doesn't exist", async () => {
const body = `<p id="bad-ref">[[bad-ref]]</p>`;
const ops = makeStandardOps({ localBiblio }, body);
Expand Down
67 changes: 67 additions & 0 deletions tests/spec/core/utils-spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -503,6 +503,73 @@ describe("Core - Utils", () => {
});
});

describe("toId", () => {
const { toId } = utils;

it("lowercases the input", () => {
expect(toId("HELLO")).toBe("hello");
expect(toId("MixedCase")).toBe("mixedcase");
});

it("trims leading and trailing whitespace", () => {
expect(toId(" hello ")).toBe("hello");
expect(toId("\nhello\t")).toBe("hello");
});

it("replaces spaces with hyphens", () => {
expect(toId("hello world")).toBe("hello-world");
expect(toId("Ruby TTS Req")).toBe("ruby-tts-req");
});

it("collapses multiple non-word characters into a single hyphen", () => {
expect(toId("Tokyo Ghoul: re")).toBe("tokyo-ghoul-re");
expect(toId("foo bar")).toBe("foo-bar");
expect(toId("a--b")).toBe("a-b");
});

it("removes leading and trailing hyphens after substitution", () => {
expect(toId(": leading colon")).toBe("leading-colon");
expect(toId("trailing colon:")).toBe("trailing-colon");
expect(toId(":surrounded:")).toBe("surrounded");
});

it("strips diacritics", () => {
expect(toId("Ré")).toBe("re");
expect(toId("café")).toBe("cafe");
expect(toId("naïve")).toBe("naive");
});

it("handles punctuation like colons, parens, brackets", () => {
expect(toId("foo(bar)")).toBe("foo-bar");
expect(toId("foo[bar]")).toBe("foo-bar");
expect(toId("foo: bar (baz)")).toBe("foo-bar-baz");
});

it("preserves hyphens and underscores within the string", () => {
expect(toId("CSSOM-VIEW")).toBe("cssom-view");
expect(toId("it_contains")).toBe("it_contains");
});

it("preserves digits", () => {
expect(toId("dom4")).toBe("dom4");
expect(toId("cssom-view-1")).toBe("cssom-view-1");
});

it("skips lowercasing when noLC is true", () => {
expect(toId("Hello World", true)).toBe("Hello-World");
expect(toId("MixedCase", true)).toBe("MixedCase");
});

it("handles an empty string gracefully", () => {
expect(toId("")).toBe("");
});

it("handles strings that are only non-word characters", () => {
expect(toId(":::")).toBe("");
expect(toId(" ")).toBe("");
});
});

describe("addId", () => {
it("addId - creates an id from the content of an elements", () => {
const { addId } = utils;
Expand Down
Loading