diff --git a/packages/parsers/src/hfIds.parseCount.test.ts b/packages/parsers/src/hfIds.parseCount.test.ts new file mode 100644 index 0000000000..d5bf48b802 --- /dev/null +++ b/packages/parsers/src/hfIds.parseCount.test.ts @@ -0,0 +1,66 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const parses = vi.hoisted(() => ({ count: 0 })); +vi.mock("linkedom", async (actual) => { + const real = await actual(); + return { + ...real, + parseHTML: (...args: Parameters) => { + parses.count += 1; + return real.parseHTML(...args); + }, + }; +}); + +const { ensureHfIds } = await import("./hfIds.js"); + +const page = (body: string) => + `t${body}`; + +describe("ensureHfIds on a document whose elements all carry their ids", () => { + beforeEach(() => { + parses.count = 0; + }); + + it("returns it as it is, without a parse: a stamped bundle has nothing to mint", () => { + const html = page( + `
` + + `
`, + ); + expect(ensureHfIds(html)).toBe(html); + expect(parses.count).toBe(0); + }); + + it("parses once when one element lacks its id, or has an empty one", () => { + const missing = page(`

hi

`); + expect(ensureHfIds(missing)).toMatch(/

hi<\/p>/); + expect(parses.count).toBe(1); + expect(ensureHfIds(page(`

x
`))).toMatch(/data-hf-id="hf-[0-9a-z]+"/); + expect(parses.count).toBe(2); + }); + + it("parses a stamped page not opening then : callers re-parsing the output expect both", () => { + const headless = `
x
`; + expect(ensureHfIds(headless)).toContain(""); + const shapes = [ + `t

x

`, + `
x

x

`, + ``, + `t

x

`, + ]; + for (const html of shapes) ensureHfIds(html); + expect(parses.count).toBe(1 + shapes.length); + }); + + it("checks a fragment from its first tag, so elements before a stray still get their ids", () => { + const minted = ensureHfIds(`

y

x
`); + expect(minted).toMatch(/

y<\/p>/); + expect(parses.count).toBe(1); + }); + + it("reads an id only from the tag's own attributes, never from a quoted value", () => { + const html = page(`

x
`); + expect(ensureHfIds(html)).toMatch(/
+ !/^(?:/])/i.test(html.replace(/^(?:\s|))*/, "")); + +function alreadyStamped(html: string): boolean { + const tags = startTags(html); + if (!tags) return false; + const body = isFragment(html) ? -1 : tags.findIndex((tag) => tag.name === "body"); + if (!isFragment(html) && !opensHeadThenBody(html, tags, body)) return false; + return tags + .slice(body + 1) + .every( + (tag) => + !IMPLIED.has(tag.name) && + tag.name !== "table" && + (EXCLUDED_TAGS.has(tag.name) || hasHfId(html, tag)), + ); +} + +const HEAD_CONTENT = new Set(["meta", "link", "base", "title", "style", "script"]); + +// The parse path's document.body read adds any missing /, and callers re-parsing its output expect both. +function opensHeadThenBody(html: string, tags: StartTag[], body: number): boolean { + const [first, head] = tags; + if (body < 0 || first?.name !== "html" || head?.name !== "head") return false; + const inHead = tags.slice(2, body); + return ( + inHead.every((tag) => HEAD_CONTENT.has(tag.name)) && + closesHead(html, inHead.at(-1) ?? head, tags[body]!) + ); +} + +function closesHead(html: string, last: StartTag, body: StartTag): boolean { + const from = RAW_TEXT.has(last.name) ? rawTextEnd(html, last.name, last.bracket) : last.bracket; + const between = html.slice(from, html.lastIndexOf("<", body.nameEnd)); + const marks = between.matchAll(/|<\/head\s*>/gi); + return from >= 0 && [...marks].some((mark) => !mark[0].startsWith("))*/, ""), - ); + const wrapped = isFragment(html); const { document } = wrapped ? parseHTML(`${html}`) : parseHTML(html);