/** * Page Metadata Tests * * Tests the metadata collector for: * - Resolving contributions into deduplicated metadata * - HTML rendering with proper escaping * - Safe JSON-LD serialization * - HTML attribute escaping */ import { describe, it, expect } from "vitest"; import { resolvePageMetadata, renderPageMetadata, safeJsonLdSerialize, escapeHtmlAttr, } from "../../../src/page/metadata.js"; import type { PageMetadataContribution } from "../../../src/plugins/types.js"; describe("resolvePageMetadata", () => { it("resolves meta tags correctly", () => { const contributions: PageMetadataContribution[] = [ { kind: "meta", name: "description", content: "A test page" }, { kind: "meta", name: "robots", content: "index, follow" }, ]; const result = resolvePageMetadata(contributions); expect(result.meta).toEqual([ { name: "description", content: "A test page" }, { name: "robots", content: "index, follow" }, ]); }); it("resolves property tags correctly", () => { const contributions: PageMetadataContribution[] = [ { kind: "property", property: "og:title", content: "My Page" }, { kind: "property", property: "og:type", content: "article" }, ]; const result = resolvePageMetadata(contributions); expect(result.properties).toEqual([ { property: "og:title", content: "My Page" }, { property: "og:type", content: "article" }, ]); }); it("resolves canonical link", () => { const contributions: PageMetadataContribution[] = [ { kind: "link", rel: "canonical", href: "https://example.com/page" }, ]; const result = resolvePageMetadata(contributions); expect(result.links).toEqual([{ rel: "canonical", href: "https://example.com/page" }]); }); it("resolves alternate links with hreflang", () => { const contributions: PageMetadataContribution[] = [ { kind: "link", rel: "alternate", href: "https://example.com/en/page", hreflang: "en" }, { kind: "link", rel: "alternate", href: "https://example.com/fr/page", hreflang: "fr" }, ]; const result = resolvePageMetadata(contributions); expect(result.links).toEqual([ { rel: "alternate", href: "https://example.com/en/page", hreflang: "en" }, { rel: "alternate", href: "https://example.com/fr/page", hreflang: "fr" }, ]); }); it("resolves nlweb link for agent discovery", () => { const contributions: PageMetadataContribution[] = [ { kind: "link", rel: "nlweb", href: "https://example.com/nlweb" }, ]; const result = resolvePageMetadata(contributions); expect(result.links).toEqual([{ rel: "nlweb", href: "https://example.com/nlweb" }]); }); it("resolves JSON-LD", () => { const graph = { "@type": "Article", name: "Test" }; const contributions: PageMetadataContribution[] = [{ kind: "jsonld", id: "article", graph }]; const result = resolvePageMetadata(contributions); expect(result.jsonld).toHaveLength(1); expect(result.jsonld[0]!.id).toBe("article"); expect(JSON.parse(result.jsonld[0]!.json)).toEqual(graph); }); it("first-wins dedupe for meta by name", () => { const contributions: PageMetadataContribution[] = [ { kind: "meta", name: "description", content: "First" }, { kind: "meta", name: "description", content: "Second" }, ]; const result = resolvePageMetadata(contributions); expect(result.meta).toHaveLength(1); expect(result.meta[0]!.content).toBe("First"); }); it("first-wins dedupe for meta by explicit key", () => { const contributions: PageMetadataContribution[] = [ { kind: "meta", name: "description", content: "First", key: "seo-desc" }, { kind: "meta", name: "og-description", content: "Second", key: "seo-desc" }, ]; const result = resolvePageMetadata(contributions); expect(result.meta).toHaveLength(1); expect(result.meta[0]!.content).toBe("First"); }); it("first-wins dedupe for property", () => { const contributions: PageMetadataContribution[] = [ { kind: "property", property: "og:title", content: "First" }, { kind: "property", property: "og:title", content: "Second" }, ]; const result = resolvePageMetadata(contributions); expect(result.properties).toHaveLength(1); expect(result.properties[0]!.content).toBe("First"); }); it("canonical is singleton (second canonical ignored)", () => { const contributions: PageMetadataContribution[] = [ { kind: "link", rel: "canonical", href: "https://example.com/first" }, { kind: "link", rel: "canonical", href: "https://example.com/second" }, ]; const result = resolvePageMetadata(contributions); expect(result.links).toHaveLength(1); expect(result.links[0]!.href).toBe("https://example.com/first"); }); it("alternate links deduped by hreflang", () => { const contributions: PageMetadataContribution[] = [ { kind: "link", rel: "alternate", href: "https://example.com/en/v1", hreflang: "en" }, { kind: "link", rel: "alternate", href: "https://example.com/en/v2", hreflang: "en" }, ]; const result = resolvePageMetadata(contributions); expect(result.links).toHaveLength(1); expect(result.links[0]!.href).toBe("https://example.com/en/v1"); }); it("JSON-LD deduped by id", () => { const contributions: PageMetadataContribution[] = [ { kind: "jsonld", id: "article", graph: { "@type": "Article", name: "First" } }, { kind: "jsonld", id: "article", graph: { "@type": "Article", name: "Second" } }, ]; const result = resolvePageMetadata(contributions); expect(result.jsonld).toHaveLength(1); expect(JSON.parse(result.jsonld[0]!.json)).toEqual({ "@type": "Article", name: "First", }); }); it("JSON-LD without id is always appended", () => { const contributions: PageMetadataContribution[] = [ { kind: "jsonld", graph: { "@type": "Article", name: "First" } }, { kind: "jsonld", graph: { "@type": "BreadcrumbList", name: "Second" } }, ]; const result = resolvePageMetadata(contributions); expect(result.jsonld).toHaveLength(2); }); it("rejects non-HTTP link href (javascript:, data:, blob:)", () => { const contributions: PageMetadataContribution[] = [ { kind: "link", rel: "canonical", href: "javascript:alert(1)" }, { kind: "link", rel: "alternate", href: "data:text/html,

hi

", hreflang: "en" }, { kind: "link", rel: "alternate", href: "blob:https://example.com/abc", hreflang: "fr" }, ]; const result = resolvePageMetadata(contributions); expect(result.links).toHaveLength(0); }); it("accepts valid HTTP and HTTPS hrefs", () => { const contributions: PageMetadataContribution[] = [ { kind: "link", rel: "canonical", href: "https://example.com/page" }, { kind: "link", rel: "alternate", href: "http://example.com/en", hreflang: "en" }, ]; const result = resolvePageMetadata(contributions); expect(result.links).toHaveLength(2); }); }); describe("renderPageMetadata", () => { it("renders meta tags with escaped attributes", () => { const html = renderPageMetadata({ meta: [{ name: 'desc"ription', content: "A & page" }], properties: [], links: [], jsonld: [], }); expect(html).toBe(''); }); it("renders property tags", () => { const html = renderPageMetadata({ meta: [], properties: [{ property: "og:title", content: "My Page" }], links: [], jsonld: [], }); expect(html).toBe(''); }); it("renders link tags with hreflang", () => { const html = renderPageMetadata({ meta: [], properties: [], links: [{ rel: "alternate", href: "https://example.com/fr", hreflang: "fr" }], jsonld: [], }); expect(html).toBe(''); }); it("renders JSON-LD script tags", () => { const json = JSON.stringify({ "@type": "Article" }); const html = renderPageMetadata({ meta: [], properties: [], links: [], jsonld: [{ id: "article", json }], }); expect(html).toBe(``); }); }); describe("safeJsonLdSerialize", () => { it("escapes in nested values", () => { const result = safeJsonLdSerialize({ text: "" }); expect(result).not.toContain(""); expect(result).toContain("\\u003c"); expect(result).toContain("\\u003e"); }); it("escapes " }); expect(result).not.toContain("