diff --git a/.agents/skills/jobindex-search/cli/src/commands/detail.ts b/.agents/skills/jobindex-search/cli/src/commands/detail.ts index 8d82d15..afb3cbd 100644 --- a/.agents/skills/jobindex-search/cli/src/commands/detail.ts +++ b/.agents/skills/jobindex-search/cli/src/commands/detail.ts @@ -19,6 +19,15 @@ interface DetailResult { description: string | null } +/** + * Convert a Unicode code point to a string. Uses `fromCodePoint` (not + * `fromCharCode`) so supplementary-plane code points (e.g. emoji, U+1F600) + * decode correctly, and drops out-of-range values instead of throwing. + */ +function numericEntity(cp: number): string { + return cp >= 0 && cp <= 0x10ffff ? String.fromCodePoint(cp) : "" +} + /** * Decode HTML entities in text */ @@ -30,7 +39,9 @@ function decodeHtmlEntities(text: string): string { .replace(/"/g, '"') .replace(/'/g, "'") .replace(/'/g, "'") - .replace(/&#(\d+);/g, (_, code) => String.fromCharCode(parseInt(code, 10))) + // Numeric character references: decimal (é) and hexadecimal (é). + .replace(/&#(\d+);/g, (_, dec) => numericEntity(parseInt(dec, 10))) + .replace(/&#[xX]([0-9a-fA-F]+);/g, (_, hex) => numericEntity(parseInt(hex, 16))) .replace(/ /g, " ") } diff --git a/.agents/skills/jobindex-search/cli/src/helpers.ts b/.agents/skills/jobindex-search/cli/src/helpers.ts index de6d365..97c6bce 100644 --- a/.agents/skills/jobindex-search/cli/src/helpers.ts +++ b/.agents/skills/jobindex-search/cli/src/helpers.ts @@ -180,6 +180,15 @@ export function parseSearchPage(html: string): SearchPageResult { return { total, results } } +/** + * Convert a Unicode code point to a string. Uses `fromCodePoint` (not + * `fromCharCode`) so supplementary-plane code points (e.g. emoji, U+1F600) + * decode correctly, and drops out-of-range values instead of throwing. + */ +function numericEntity(cp: number): string { + return cp >= 0 && cp <= 0x10ffff ? String.fromCodePoint(cp) : "" +} + /** * Decode HTML entities in text */ @@ -191,7 +200,9 @@ function decodeHtmlEntities(text: string): string { .replace(/"/g, '"') .replace(/'/g, "'") .replace(/'/g, "'") - .replace(/&#(\d+);/g, (_, code) => String.fromCharCode(parseInt(code, 10))) + // Numeric character references: decimal (é) and hexadecimal (é). + .replace(/&#(\d+);/g, (_, dec) => numericEntity(parseInt(dec, 10))) + .replace(/&#[xX]([0-9a-fA-F]+);/g, (_, hex) => numericEntity(parseInt(hex, 16))) .replace(/ /g, " ") } diff --git a/.agents/skills/jobindex-search/cli/tests/parsing.test.ts b/.agents/skills/jobindex-search/cli/tests/parsing.test.ts new file mode 100644 index 0000000..dde9a1a --- /dev/null +++ b/.agents/skills/jobindex-search/cli/tests/parsing.test.ts @@ -0,0 +1,46 @@ +import { describe, test, expect } from "bun:test"; +import { parseJobCards } from "../src/helpers"; + +// Minimal jobad-wrapper markup: parseJobCards splits on `jobad-wrapper-` +// and reads the title from the

and the company from the +// jix-toolbar-top__company link. We inject HTML entities to exercise decoding. +function card(id: string, title: string, company = "Acme A/S"): string { + return `
+

${title}

+ +
`; +} + +describe("decodeHtmlEntities (via parseJobCards)", () => { + test("decodes hexadecimal numeric entities (ø -> ø)", () => { + const [c] = parseJobCards(card("h1", "Smørrebro Chef")); + expect(c.title).toBe("Smørrebro Chef"); + }); + + test("decodes uppercase-X hexadecimal entities (æ -> æ)", () => { + const [c] = parseJobCards(card("h2", "Kære Kollega")); + expect(c.title).toBe("Kære Kollega"); + }); + + test("still decodes decimal numeric entities (å -> å) — regression", () => { + const [c] = parseJobCards(card("h3", "århus Lead")); + expect(c.title).toBe("århus Lead"); + }); + + test("decodes supplementary-plane code points with fromCodePoint (😀)", () => { + const [c] = parseJobCards(card("h4", "Growth 😀")); + expect(c.title).toBe("Growth 😀"); + }); + + test("decodes hex supplementary-plane code points (😀)", () => { + const [c] = parseJobCards(card("h5", "Growth 😀")); + expect(c.title).toBe("Growth 😀"); + }); + + test("decodes hex entities in the company name too", () => { + const [c] = parseJobCards(card("h6", "Engineer", "Nørrebro ApS")); + expect(c.company).toBe("Nørrebro ApS"); + }); +});