| ... | @@ -1,109 +1,115 @@ | ... | @@ -1,109 +1,115 @@ |
| 1 | const base = new URL("https://example.com/"); | 1 | const base = new URL("https://example.com/"); |
| 2 | const T: Template = { base }; | 2 | const T: meta.Template = { base }; |
| 3 | | 3 | |
| 4 | describe("title", () => { | 4 | describe("title", () => { |
| 5 | test("is always first entry in meta", () => { | 5 | test("is always first entry in meta", () => { |
| 6 | const { meta } = toTags({ title: "Hello" }, T); | 6 | const tags = meta.toTags(T, { title: "Hello" }); |
| 7 | assert.deepEqual(meta[0], { title: "Hello" }); | 7 | assert.deepEqual(tags.meta[0], { title: "Hello" }); |
| 8 | }); | 8 | }); |
| 9 | | 9 | |
| 10 | test("titleTemplate applied", () => { | 10 | test("titleTemplate applied", () => { |
| 11 | const { meta } = toTags({ title: "Page" }, { base, titleTemplate: (t) => `${t} | Site` }); | 11 | const tags = meta.toTags({ base, titleTemplate: (t) => `${t} | Site` }, { title: "Page" }); |
| 12 | assert.deepEqual(meta[0], { title: "Page | Site" }); | 12 | assert.deepEqual(tags.meta[0], { title: "Page | Site" }); |
| 13 | }); | 13 | }); |
| 14 | | 14 | |
| 15 | test("toHtml title is HTML-escaped", () => { | 15 | test("toHtml title is HTML-escaped", () => { |
| 16 | const out = toHtml({ title: "<b>Hi</b>" }, T); | 16 | const out = meta.toHtml(T, { title: "<b>Hi</b>" }); |
| 17 | assert.ok(out.startsWith("<title>&lt;b&gt;Hi&lt;/b&gt;</title>")); | 17 | assert.ok(out.startsWith("<title>&lt;b&gt;Hi&lt;/b&gt;</title>")); |
| 18 | }); | 18 | }); |
| 19 | }); | 19 | }); |
| 20 | | 20 | |
| 21 | describe("basic meta fields", () => { | 21 | describe("basic meta fields", () => { |
| 22 | test("multiple authors emit separate tags", () => { | 22 | test("multiple authors emit separate tags", () => { |
| 23 | const { meta } = toTags({ title: "T", authors: ["Alice", "Bob"] }, T); | 23 | const tags = meta.toTags(T, { title: "T", authors: ["Alice", "Bob"] }); |
| 24 | assert.deepEqual( | 24 | assert.deepEqual( |
| 25 | meta.filter((m) => m.name === "author"), | 25 | tags.meta.filter((m) => m.name === "author"), |
| 26 | [{ name: "author", content: "Alice" }, { name: "author", content: "Bob" }], | 26 | [{ name: "author", content: "Alice" }, { name: "author", content: "Bob" }], |
| 27 | ); | 27 | ); |
| 28 | }); | 28 | }); |
| 29 | | 29 | |
| 30 | test("keywords joined with comma-space", () => { | 30 | test("keywords joined with comma-space", () => { |
| 31 | const { meta } = toTags({ title: "T", keywords: ["cats", "dogs", "fish"] }, T); | 31 | const tags = meta.toTags(T, { title: "T", keywords: ["cats", "dogs", "fish"] }); |
| 32 | assert.ok(meta.some((m) => m.name === "keywords" && m.content === "cats, dogs, fish")); | 32 | assert.ok(tags.meta.some((m) => m.name === "keywords" && m.content === "cats, dogs, fish")); |
| 33 | }); | 33 | }); |
| 34 | | 34 | |
| 35 | test("generator: string used as-is", () => { | 35 | test("generator: string used as-is", () => { |
| 36 | const { meta } = toTags({ title: "T", generator: "my-tool" }, T); | 36 | const tags = meta.toTags(T, { title: "T", generator: "my-tool" }); |
| 37 | assert.ok(meta.some((m) => m.name === "generator" && m.content === "my-tool")); | 37 | assert.ok(tags.meta.some((m) => m.name === "generator" && m.content === "my-tool")); |
| 38 | }); | 38 | }); |
| 39 | | 39 | |
| 40 | test("themeColor string emits single tag without media", () => { | 40 | test("themeColor string emits single tag without media", () => { |
| 41 | const { meta } = toTags({ title: "T", themeColor: "#fff" }, T); | 41 | const tags = meta.toTags(T, { title: "T", themeColor: "#fff" }); |
| 42 | const tags = meta.filter((m) => m.name === "theme-color"); | 42 | const themeColor = tags.meta.filter((m) => m.name === "theme-color"); |
| 43 | assert.equal(tags.length, 1); | 43 | assert.equal(themeColor.length, 1); |
| 44 | assert.equal(tags[0]!.content, "#fff"); | 44 | assert.equal(themeColor[0]!.content, "#fff"); |
| 45 | assert.equal(tags[0]!.media, undefined); | 45 | assert.equal(themeColor[0]!.media, undefined); |
| 46 | }); | 46 | }); |
| 47 | | 47 | |
| 48 | test("themeColor object emits two tags with media queries", () => { | 48 | test("themeColor object emits two tags with media queries", () => { |
| 49 | const { meta } = toTags({ title: "T", themeColor: { dark: "#111", light: "#eee" } }, T); | 49 | const tags = meta.toTags(T, { title: "T", themeColor: { dark: "#111", light: "#eee" } }); |
| 50 | assert.ok( | 50 | assert.ok( |
| 51 | meta.some((m) => m.name === "theme-color" && m.media === "(prefers-color-scheme:light)" && m.content === "#eee"), | 51 | tags.meta.some((m) => |
| | 52 | m.name === "theme-color" && m.media === "(prefers-color-scheme:light)" && m.content === "#eee" |
| | 53 | ), |
| 52 | ); | 54 | ); |
| 53 | assert.ok( | 55 | assert.ok( |
| 54 | meta.some((m) => m.name === "theme-color" && m.media === "(prefers-color-scheme:dark)" && m.content === "#111"), | 56 | tags.meta.some((m) => |
| | 57 | m.name === "theme-color" && m.media === "(prefers-color-scheme:dark)" && m.content === "#111" |
| | 58 | ), |
| 55 | ); | 59 | ); |
| 56 | }); | 60 | }); |
| 57 | | 61 | |
| 58 | test("denyRobots from meta", () => { | 62 | test("denyRobots from meta", () => { |
| 59 | const { meta } = toTags({ title: "T", denyRobots: true }, T); | 63 | const tags = meta.toTags(T, { title: "T", denyRobots: true }); |
| 60 | assert.ok(meta.some((m) => m.name === "robots" && m.content === "noindex,nofollow")); | 64 | assert.ok(tags.meta.some((m) => m.name === "robots" && m.content === "noindex,nofollow")); |
| 61 | }); | 65 | }); |
| 62 | | 66 | |
| 63 | test("denyRobots from template", () => { | 67 | test("denyRobots from template", () => { |
| 64 | const { meta } = toTags({ title: "T" }, { ...T, denyRobots: true }); | 68 | const tags = meta.toTags({ ...T, denyRobots: true }, { title: "T" }); |
| 65 | assert.ok(meta.some((m) => m.name === "robots" && m.content === "noindex,nofollow")); | 69 | assert.ok(tags.meta.some((m) => m.name === "robots" && m.content === "noindex,nofollow")); |
| 66 | }); | 70 | }); |
| 67 | | 71 | |
| 68 | test("denyRobots: false on meta does not override template true", () => { | 72 | test("denyRobots: false on meta does not override template true", () => { |
| 69 | // null/undefined opts out; false is falsy so it also doesn't block | 73 | // null/undefined opts out; false is falsy so it also doesn't block |
| 70 | const { meta } = toTags({ title: "T", denyRobots: false }, { ...T, denyRobots: true }); | 74 | const tags = meta.toTags({ ...T, denyRobots: true }, { title: "T", denyRobots: false }); |
| 71 | assert.ok(meta.some((m) => m.name === "robots")); | 75 | assert.ok(tags.meta.some((m) => m.name === "robots")); |
| 72 | }); | 76 | }); |
| 73 | | 77 | |
| 74 | test("viewport defaults to mobile-friendly value", () => { | 78 | test("viewport defaults to mobile-friendly value", () => { |
| 75 | const { meta } = toTags({ title: "T" }, T); | 79 | const tags = meta.toTags(T, { title: "T" }); |
| 76 | assert.ok( | 80 | assert.ok( |
| 77 | meta.some((m) => m.name === "viewport" && m.content === "width=device-width, initial-scale=1, maximum-scale=1"), | 81 | tags.meta.some((m) => |
| | 82 | m.name === "viewport" && m.content === "width=device-width, initial-scale=1, maximum-scale=1" |
| | 83 | ), |
| 78 | ); | 84 | ); |
| 79 | }); | 85 | }); |
| 80 | | 86 | |
| 81 | test("viewport can be overridden", () => { | 87 | test("viewport can be overridden", () => { |
| 82 | const { meta } = toTags({ title: "T", viewport: "width=device-width" }, T); | 88 | const tags = meta.toTags(T, { title: "T", viewport: "width=device-width" }); |
| 83 | assert.ok(meta.some((m) => m.name === "viewport" && m.content === "width=device-width")); | 89 | assert.ok(tags.meta.some((m) => m.name === "viewport" && m.content === "width=device-width")); |
| 84 | }); | 90 | }); |
| 85 | }); | 91 | }); |
| 86 | | 92 | |
| 87 | describe("links", () => { | 93 | describe("links", () => { |
| 88 | test("canonical resolved against base", () => { | 94 | test("canonical resolved against base", () => { |
| 89 | const { links } = toTags({ title: "T", canonical: "/page" }, T); | 95 | const { links } = meta.toTags(T, { title: "T", canonical: "/page" }); |
| 90 | assert.ok(links.some((l) => l.rel === "canonical" && l.href === "https://example.com/page")); | 96 | assert.ok(links.some((l) => l.rel === "canonical" && l.href === "https://example.com/page")); |
| 91 | }); | 97 | }); |
| 92 | | 98 | |
| 93 | test("canonical absolute URL kept as-is", () => { | 99 | test("canonical absolute URL kept as-is", () => { |
| 94 | const { links } = toTags({ title: "T", canonical: "https://other.com/x" }, T); | 100 | const { links } = meta.toTags(T, { title: "T", canonical: "https://other.com/x" }); |
| 95 | assert.ok(links.some((l) => l.rel === "canonical" && l.href === "https://other.com/x")); | 101 | assert.ok(links.some((l) => l.rel === "canonical" && l.href === "https://other.com/x")); |
| 96 | }); | 102 | }); |
| 97 | | 103 | |
| 98 | test("manifest href not resolved", () => { | 104 | test("manifest href not resolved", () => { |
| 99 | const { links } = toTags({ title: "T", manifest: "/manifest.json" }, T); | 105 | const { links } = meta.toTags(T, { title: "T", manifest: "/manifest.json" }); |
| 100 | assert.ok(links.some((l) => l.rel === "manifest" && l.href === "/manifest.json")); | 106 | assert.ok(links.some((l) => l.rel === "manifest" && l.href === "/manifest.json")); |
| 101 | }); | 107 | }); |
| 102 | }); | 108 | }); |
| 103 | | 109 | |
| 104 | describe("oEmbed", () => { | 110 | describe("oEmbed", () => { |
| 105 | test("emits alternate link with json+oembed type, URL resolved", () => { | 111 | test("emits alternate link with json+oembed type, URL resolved", () => { |
| 106 | const { links } = toTags({ title: "T", oEmbed: "/oembed.json" }, T); | 112 | const { links } = meta.toTags(T, { title: "T", oEmbed: "/oembed.json" }); |
| 107 | assert.ok(links.some((l) => | 113 | assert.ok(links.some((l) => |
| 108 | l.rel === "alternate" | 114 | l.rel === "alternate" |
| 109 | && l.type === "application/json+oembed" | 115 | && l.type === "application/json+oembed" |
| ... | @@ -112,271 +118,346 @@ describe("oEmbed", () => { | ... | @@ -112,271 +118,346 @@ describe("oEmbed", () => { |
| 112 | }); | 118 | }); |
| 113 | | 119 | |
| 114 | test("falls back to template.oEmbed", () => { | 120 | test("falls back to template.oEmbed", () => { |
| 115 | const { links } = toTags({ title: "T" }, { ...T, oEmbed: "/oembed.json" }); | 121 | const { links } = meta.toTags({ ...T, oEmbed: "/oembed.json" }, { title: "T" }); |
| 116 | assert.ok(links.some((l) => l.type === "application/json+oembed")); | 122 | assert.ok(links.some((l) => l.type === "application/json+oembed")); |
| 117 | }); | 123 | }); |
| 118 | }); | 124 | }); |
| 119 | | 125 | |
| 120 | describe("alternates", () => { | 126 | describe("alternates", () => { |
| 121 | test("array form with lang → hreflang link", () => { | 127 | test("array form with lang → hreflang link", () => { |
| 122 | const { links } = toTags({ title: "T", alternates: [{ lang: "fr", url: "/fr" }] }, T); | 128 | const { links } = meta.toTags(T, { title: "T", alternates: [{ lang: "fr", url: "/fr" }] }); |
| 123 | assert.ok(links.some((l) => l.rel === "alternate" && l.hreflang === "fr" && l.href === "https://example.com/fr")); | 129 | assert.ok(links.some((l) => l.rel === "alternate" && l.hreflang === "fr" && l.href === "https://example.com/fr")); |
| 124 | }); | 130 | }); |
| 125 | | 131 | |
| 126 | test("array form with type → type link", () => { | 132 | test("array form with type → type link", () => { |
| 127 | const { links } = toTags({ title: "T", alternates: [{ type: "application/rss+xml", url: "/feed.rss" }] }, T); | 133 | const { links } = meta.toTags(T, { title: "T", alternates: [{ type: "application/rss+xml", url: "/feed.rss" }] }); |
| 128 | assert.ok(links.some((l) => l.rel === "alternate" && l.type === "application/rss+xml")); | 134 | assert.ok(links.some((l) => l.rel === "alternate" && l.type === "application/rss+xml")); |
| 129 | }); | 135 | }); |
| 130 | | 136 | |
| 131 | test("record form: key with slash → type", () => { | 137 | test("record form: key with slash → type", () => { |
| 132 | const { links } = toTags({ title: "T", alternates: { "application/atom+xml": "/atom.xml" } }, T); | 138 | const { links } = meta.toTags(T, { title: "T", alternates: { "application/atom+xml": "/atom.xml" } }); |
| 133 | assert.ok(links.some((l) => l.type === "application/atom+xml" && !l.hreflang)); | 139 | assert.ok(links.some((l) => l.type === "application/atom+xml" && !l.hreflang)); |
| 134 | }); | 140 | }); |
| 135 | | 141 | |
| 136 | test("record form: key without slash → hreflang", () => { | 142 | test("record form: key without slash → hreflang", () => { |
| 137 | const { links } = toTags({ title: "T", alternates: { "ja": "/ja/" } }, T); | 143 | const { links } = meta.toTags(T, { title: "T", alternates: { "ja": "/ja/" } }); |
| 138 | assert.ok(links.some((l) => l.hreflang === "ja" && !l.type)); | 144 | assert.ok(links.some((l) => l.hreflang === "ja" && !l.type)); |
| 139 | }); | 145 | }); |
| 140 | | 146 | |
| 141 | test("alternate URLs resolved against base", () => { | 147 | test("alternate URLs resolved against base", () => { |
| 142 | const { links } = toTags({ title: "T", alternates: [{ lang: "de", url: "/de" }] }, T); | 148 | const { links } = meta.toTags(T, { title: "T", alternates: [{ lang: "de", url: "/de" }] }); |
| 143 | assert.ok(links.some((l) => l.href === "https://example.com/de")); | 149 | assert.ok(links.some((l) => l.href === "https://example.com/de")); |
| 144 | }); | 150 | }); |
| 145 | | 151 | |
| 146 | test("falls back to template.alternates", () => { | 152 | test("falls back to template.alternates", () => { |
| 147 | const { links } = toTags({ title: "T" }, { ...T, alternates: [{ lang: "en", url: "/" }] }); | 153 | const { links } = meta.toTags({ ...T, alternates: [{ lang: "en", url: "/" }] }, { title: "T" }); |
| 148 | assert.ok(links.some((l) => l.hreflang === "en")); | 154 | assert.ok(links.some((l) => l.hreflang === "en")); |
| 149 | }); | 155 | }); |
| 150 | }); | 156 | }); |
| 151 | | 157 | |
| 152 | describe("embed", () => { | 158 | describe("embed", () => { |
| 153 | test("no thumbnail → twitter:card = summary", () => { | 159 | test("no thumbnail → twitter:card = summary", () => { |
| 154 | const { meta } = toTags({ title: "T", embed: {} }, T); | 160 | const tags = meta.toTags(T, { title: "T", embed: {} }); |
| 155 | assert.ok(meta.some((m) => m.name === "twitter:card" && m.content === "summary")); | 161 | assert.ok(tags.meta.some((m) => m.name === "twitter:card" && m.content === "summary")); |
| 156 | }); | 162 | }); |
| 157 | | 163 | |
| 158 | test("thumbnail → twitter:card = summary_large_image", () => { | 164 | test("thumbnail → twitter:card = summary_large_image", () => { |
| 159 | const { meta } = toTags({ title: "T", embed: { thumbnail: "/img.png" } }, T); | 165 | const tags = meta.toTags(T, { title: "T", embed: { thumbnail: "/img.png" } }); |
| 160 | assert.ok(meta.some((m) => m.name === "twitter:card" && m.content === "summary_large_image")); | 166 | assert.ok(tags.meta.some((m) => m.name === "twitter:card" && m.content === "summary_large_image")); |
| 161 | }); | 167 | }); |
| 162 | | 168 | |
| 163 | test("thumbnailSize: icon overrides thumbnail default", () => { | 169 | test("thumbnailSize: icon overrides thumbnail default", () => { |
| 164 | const { meta } = toTags({ title: "T", embed: { thumbnail: "/img.png", thumbnailSize: "icon" } }, T); | 170 | const tags = meta.toTags(T, { title: "T", embed: { thumbnail: "/img.png", thumbnailSize: "icon" } }); |
| 165 | assert.ok(meta.some((m) => m.name === "twitter:card" && m.content === "summary")); | 171 | assert.ok(tags.meta.some((m) => m.name === "twitter:card" && m.content === "summary")); |
| 166 | }); | 172 | }); |
| 167 | | 173 | |
| 168 | test("thumbnail resolved and applied to both og:image and twitter:image", () => { | 174 | test("thumbnail resolved and applied to both og:image and twitter:image", () => { |
| 169 | const { meta } = toTags({ title: "T", embed: { thumbnail: "/img.png" } }, T); | 175 | const tags = meta.toTags(T, { title: "T", embed: { thumbnail: "/img.png" } }); |
| 170 | assert.ok(meta.some((m) => m.name === "og:image" && m.content === "https://example.com/img.png")); | 176 | assert.ok(tags.meta.some((m) => m.name === "og:image" && m.content === "https://example.com/img.png")); |
| 171 | assert.ok(meta.some((m) => m.name === "twitter:image" && m.content === "https://example.com/img.png")); | 177 | assert.ok(tags.meta.some((m) => m.name === "twitter:image" && m.content === "https://example.com/img.png")); |
| 172 | }); | 178 | }); |
| 173 | | 179 | |
| 174 | test("embed title falls back to page title for og", () => { | 180 | test("embed title falls back to page title for og", () => { |
| 175 | const { meta } = toTags({ title: "My Page", embed: {} }, T); | 181 | const tags = meta.toTags(T, { title: "My Page", embed: {} }); |
| 176 | assert.ok(meta.some((m) => m.name === "og:title" && m.content === "My Page")); | 182 | assert.ok(tags.meta.some((m) => m.name === "og:title" && m.content === "My Page")); |
| 177 | }); | 183 | }); |
| 178 | | 184 | |
| 179 | test("embed title overrides page title for og", () => { | 185 | test("embed title overrides page title for og", () => { |
| 180 | const { meta } = toTags({ title: "My Page", embed: { title: "Share Title" } }, T); | 186 | const tags = meta.toTags(T, { title: "My Page", embed: { title: "Share Title" } }); |
| 181 | assert.ok(meta.some((m) => m.name === "og:title" && m.content === "Share Title")); | 187 | assert.ok(tags.meta.some((m) => m.name === "og:title" && m.content === "Share Title")); |
| 182 | }); | 188 | }); |
| 183 | | 189 | |
| 184 | test("embed description falls back to page description", () => { | 190 | test("embed description falls back to page description", () => { |
| 185 | const { meta } = toTags({ title: "T", description: "page desc", embed: {} }, T); | 191 | const tags = meta.toTags(T, { title: "T", description: "page desc", embed: {} }); |
| 186 | assert.ok(meta.some((m) => m.name === "og:description" && m.content === "page desc")); | 192 | assert.ok(tags.meta.some((m) => m.name === "og:description" && m.content === "page desc")); |
| 187 | }); | 193 | }); |
| 188 | | 194 | |
| 189 | test("siteTitle sets og:site_name", () => { | 195 | test("siteTitle sets og:site_name", () => { |
| 190 | const { meta } = toTags({ title: "T", embed: { siteTitle: "My Site" } }, T); | 196 | const tags = meta.toTags(T, { title: "T", embed: { siteTitle: "My Site" } }); |
| 191 | assert.ok(meta.some((m) => m.name === "og:site_name" && m.content === "My Site")); | 197 | assert.ok(tags.meta.some((m) => m.name === "og:site_name" && m.content === "My Site")); |
| 192 | }); | 198 | }); |
| 193 | | 199 | |
| 194 | test("canonical sets og:url when embed active", () => { | 200 | test("canonical sets og:url when embed active", () => { |
| 195 | const { meta } = toTags({ title: "T", canonical: "/page", embed: {} }, T); | 201 | const tags = meta.toTags(T, { title: "T", canonical: "/page", embed: {} }); |
| 196 | assert.ok(meta.some((m) => m.name === "og:url" && m.content === "https://example.com/page")); | 202 | assert.ok(tags.meta.some((m) => m.name === "og:url" && m.content === "https://example.com/page")); |
| 197 | }); | 203 | }); |
| 198 | | 204 | |
| 199 | test("no canonical → no og:url injected by embed", () => { | 205 | test("no canonical → no og:url injected by embed", () => { |
| 200 | const { meta } = toTags({ title: "T", embed: {} }, T); | 206 | const tags = meta.toTags(T, { title: "T", embed: {} }); |
| 201 | assert.ok(!meta.some((m) => m.name === "og:url")); | 207 | assert.ok(!tags.meta.some((m) => m.name === "og:url")); |
| 202 | }); | 208 | }); |
| 203 | | 209 | |
| 204 | test("explicit openGraph fields override embed defaults", () => { | 210 | test("explicit openGraph fields override embed defaults", () => { |
| 205 | const { meta } = toTags({ title: "T", embed: { title: "Embed" }, openGraph: { title: "Explicit" } }, T); | 211 | const tags = meta.toTags(T, { title: "T", embed: { title: "Embed" }, openGraph: { title: "Explicit" } }); |
| 206 | assert.ok(meta.some((m) => m.name === "og:title" && m.content === "Explicit")); | 212 | assert.ok(tags.meta.some((m) => m.name === "og:title" && m.content === "Explicit")); |
| 207 | assert.equal(meta.filter((m) => m.name === "og:title").length, 1); | 213 | assert.equal(tags.meta.filter((m) => m.name === "og:title").length, 1); |
| 208 | }); | 214 | }); |
| 209 | | 215 | |
| 210 | test("embed sets og:type = website", () => { | 216 | test("embed sets og:type = website", () => { |
| 211 | const { meta } = toTags({ title: "T", embed: {} }, T); | 217 | const tags = meta.toTags(T, { title: "T", embed: {} }); |
| 212 | assert.ok(meta.some((m) => m.name === "og:type" && m.content === "website")); | 218 | assert.ok(tags.meta.some((m) => m.name === "og:type" && m.content === "website")); |
| 213 | }); | 219 | }); |
| 214 | }); | 220 | }); |
| 215 | | 221 | |
| 216 | describe("openGraph nested/array fields", () => { | 222 | describe("openGraph nested/array fields", () => { |
| 217 | test("nested object emits namespaced tags", () => { | 223 | test("nested object emits namespaced tags", () => { |
| 218 | const { meta } = toTags( | 224 | const tags = meta.toTags( |
| 219 | { title: "T", openGraph: { image: { url: "https://example.com/img.png", width: "800" } } }, | | |
| 220 | T, | 225 | T, |
| | 226 | { title: "T", openGraph: { image: { url: "https://example.com/img.png", width: "800" } } }, |
| 221 | ); | 227 | ); |
| 222 | assert.ok(meta.some((m) => m.name === "og:image:url" && m.content === "https://example.com/img.png")); | 228 | assert.ok(tags.meta.some((m) => m.name === "og:image:url" && m.content === "https://example.com/img.png")); |
| 223 | assert.ok(meta.some((m) => m.name === "og:image:width" && m.content === "800")); | 229 | assert.ok(tags.meta.some((m) => m.name === "og:image:width" && m.content === "800")); |
| 224 | }); | 230 | }); |
| 225 | | 231 | |
| 226 | test("array value emits duplicate tags", () => { | 232 | test("array value emits duplicate tags", () => { |
| 227 | const { meta } = toTags({ title: "T", openGraph: { image: ["https://a.com/1.png", "https://a.com/2.png"] } }, T); | 233 | const tags = meta.toTags( |
| 228 | assert.equal(meta.filter((m) => m.name === "og:image").length, 2); | 234 | T, |
| | 235 | { title: "T", openGraph: { image: ["https://a.com/1.png", "https://a.com/2.png"] } }, |
| | 236 | ); |
| | 237 | assert.equal(tags.meta.filter((m) => m.name === "og:image").length, 2); |
| 229 | }); | 238 | }); |
| 230 | | 239 | |
| 231 | test("null field is skipped", () => { | 240 | test("null field is skipped", () => { |
| 232 | const { meta } = toTags({ title: "T", openGraph: { image: null } }, T); | 241 | const tags = meta.toTags(T, { title: "T", openGraph: { image: null } }); |
| 233 | assert.ok(!meta.some((m) => m.name === "og:image")); | 242 | assert.ok(!tags.meta.some((m) => m.name === "og:image")); |
| 234 | }); | 243 | }); |
| 235 | }); | 244 | }); |
| 236 | | 245 | |
| 237 | describe("template fallbacks", () => { | 246 | describe("template fallbacks", () => { |
| 238 | test("template fields used when meta omits them", () => { | 247 | test("template fields used when meta omits them", () => { |
| 239 | const template: Template = { base, publisher: "Acme", keywords: ["a", "b"] }; | 248 | const template: meta.Template = { base, publisher: "Acme", keywords: ["a", "b"] }; |
| 240 | const { meta } = toTags({ title: "T" }, template); | 249 | const tags = meta.toTags(template, { title: "T" }); |
| 241 | assert.ok(meta.some((m) => m.name === "publisher" && m.content === "Acme")); | 250 | assert.ok(tags.meta.some((m) => m.name === "publisher" && m.content === "Acme")); |
| 242 | assert.ok(meta.some((m) => m.name === "keywords" && m.content === "a, b")); | 251 | assert.ok(tags.meta.some((m) => m.name === "keywords" && m.content === "a, b")); |
| 243 | }); | 252 | }); |
| 244 | | 253 | |
| 245 | test("meta field overrides template, no duplicate", () => { | 254 | test("meta field overrides template, no duplicate", () => { |
| 246 | const template: Template = { base, publisher: "Acme" }; | 255 | const template: meta.Template = { base, publisher: "Acme" }; |
| 247 | const { meta } = toTags({ title: "T", publisher: "Override" }, template); | 256 | const tags = meta.toTags(template, { title: "T", publisher: "Override" }); |
| 248 | assert.ok(meta.some((m) => m.name === "publisher" && m.content === "Override")); | 257 | assert.ok(tags.meta.some((m) => m.name === "publisher" && m.content === "Override")); |
| 249 | assert.equal(meta.filter((m) => m.name === "publisher").length, 1); | 258 | assert.equal(tags.meta.filter((m) => m.name === "publisher").length, 1); |
| 250 | }); | 259 | }); |
| 251 | }); | 260 | }); |
| 252 | | 261 | |
| 253 | describe("toHtml serialization", () => { | 262 | describe("toHtml serialization", () => { |
| 254 | test("attribute values with spaces are quoted", () => { | 263 | test("attribute values with spaces are quoted", () => { |
| 255 | const out = toHtml({ title: "T", description: "hello world" }, T); | 264 | const out = meta.toHtml(T, { title: "T", description: "hello world" }); |
| 256 | assert.ok(out.includes(`content="hello world"`)); | 265 | assert.ok(out.includes(`content="hello world"`)); |
| 257 | }); | 266 | }); |
| 258 | | 267 | |
| 259 | test("attribute values without special chars are unquoted", () => { | 268 | test("attribute values without special chars are unquoted", () => { |
| 260 | const out = toHtml({ title: "T", publisher: "Acme" }, T); | 269 | const out = meta.toHtml(T, { title: "T", publisher: "Acme" }); |
| 261 | assert.ok(out.includes(`content=Acme`)); | 270 | assert.ok(out.includes(`content=Acme`)); |
| 262 | }); | 271 | }); |
| 263 | | 272 | |
| 264 | test("attribute value with slash is quoted", () => { | 273 | test("attribute value with slash is quoted", () => { |
| 265 | const out = toHtml({ title: "T" }, T); | 274 | const out = meta.toHtml(T, { title: "T" }); |
| 266 | // viewport contains / — ensure it's quoted | 275 | // viewport contains / — ensure it's quoted |
| 267 | assert.ok(out.includes(`content="width=device-width`)); | 276 | assert.ok(out.includes(`content="width=device-width`)); |
| 268 | }); | 277 | }); |
| 269 | | 278 | |
| 270 | test("description with double quotes is escaped", () => { | 279 | test("description with double quotes is escaped", () => { |
| 271 | const out = toHtml({ title: "T", description: `say "hi"` }, T); | 280 | const out = meta.toHtml(T, { title: "T", description: `say "hi"` }); |
| 272 | assert.ok(!out.includes(`"say "hi"`)); | 281 | assert.ok(!out.includes(`"say "hi"`)); |
| 273 | }); | 282 | }); |
| 274 | }); | 283 | }); |
| 275 | | 284 | |
| 276 | describe("scripts", () => { | 285 | describe("scripts", () => { |
| 277 | test("inline script emits children in scripts array", () => { | 286 | test("inline script emits children in scripts array", () => { |
| 278 | const { scripts } = toTags({ title: "T", scripts: [{ content: "alert(1)" }] }, T); | 287 | const { scripts } = meta.toTags(T, { title: "T", scripts: [{ content: "alert(1)" }] }); |
| 279 | assert.deepEqual(scripts, [{ children: "alert(1)" }]); | 288 | assert.deepEqual(scripts, [{ children: "alert(1)" }]); |
| 280 | }); | 289 | }); |
| 281 | | 290 | |
| 282 | test("external script with src and boolean attrs", () => { | 291 | test("external script with src and boolean attrs", () => { |
| 283 | const { scripts } = toTags({ title: "T", scripts: [{ src: "/app.js", async: true, defer: false }] }, T); | 292 | const { scripts } = meta.toTags(T, { title: "T", scripts: [{ src: "/app.js", async: true, defer: false }] }); |
| 284 | assert.equal(scripts.length, 1); | 293 | assert.equal(scripts.length, 1); |
| | 294 | assert.ok(scripts[0]); |
| 285 | assert.equal(scripts[0].src, "/app.js"); | 295 | assert.equal(scripts[0].src, "/app.js"); |
| 286 | assert.equal(scripts[0].async, true); | 296 | assert.equal(scripts[0].async, true); |
| 287 | assert.equal(scripts[0].defer, undefined); | 297 | assert.equal(scripts[0].defer, undefined); |
| 288 | }); | 298 | }); |
| 289 | | 299 | |
| 290 | test("toHtml renders inline script", () => { | 300 | test("toHtml renders inline script", () => { |
| 291 | const out = toHtml({ title: "T", scripts: [{ content: "console.log(1)" }] }, T); | 301 | const out = meta.toHtml(T, { title: "T", scripts: [{ content: "console.log(1)" }] }); |
| 292 | assert.ok(out.includes("<script>console.log(1)</script>")); | 302 | assert.ok(out.includes("<script>console.log(1)</script>")); |
| 293 | }); | 303 | }); |
| 294 | | 304 | |
| 295 | test("toHtml renders external script with boolean attr", () => { | 305 | test("toHtml renders external script with boolean attr", () => { |
| 296 | const out = toHtml({ title: "T", scripts: [{ src: "/app.js", async: true }] }, T); | 306 | const out = meta.toHtml(T, { title: "T", scripts: [{ src: "/app.js", async: true }] }); |
| 297 | assert.ok(out.includes("<script src=")); | 307 | assert.ok(out.includes("<script src=")); |
| 298 | assert.ok(out.includes(" async></script>")); | 308 | assert.ok(out.includes(" async></script>")); |
| 299 | }); | 309 | }); |
| 300 | | 310 | |
| 301 | test("falls back to template scripts", () => { | 311 | test("falls back to template scripts", () => { |
| 302 | const { scripts } = toTags({ title: "T" }, { ...T, scripts: [{ content: "init()" }] }); | 312 | const { scripts } = meta.toTags({ ...T, scripts: [{ content: "init()" }] }, { title: "T" }); |
| 303 | assert.deepEqual(scripts, [{ children: "init()" }]); | 313 | assert.deepEqual(scripts, [{ children: "init()" }]); |
| 304 | }); | 314 | }); |
| 305 | }); | 315 | }); |
| 306 | | 316 | |
| 307 | describe("styles", () => { | 317 | describe("styles", () => { |
| 308 | test("inline style emits children in styles array", () => { | 318 | test("inline style emits children in styles array", () => { |
| 309 | const { styles } = toTags({ title: "T", styles: [{ content: "body{margin:0}" }] }, T); | 319 | const { styles } = meta.toTags(T, { title: "T", styles: [{ content: "body{margin:0}" }] }); |
| 310 | assert.deepEqual(styles, [{ children: "body{margin:0}" }]); | 320 | assert.deepEqual(styles, [{ children: "body{margin:0}" }]); |
| 311 | }); | 321 | }); |
| 312 | | 322 | |
| 313 | test("inline style with media", () => { | 323 | test("inline style with media", () => { |
| 314 | const { styles } = toTags({ title: "T", styles: [{ content: "p{color:blue}", media: "print" }] }, T); | 324 | const { styles } = meta.toTags(T, { title: "T", styles: [{ content: "p{color:blue}", media: "print" }] }); |
| 315 | assert.deepEqual(styles, [{ children: "p{color:blue}", media: "print" }]); | 325 | assert.deepEqual(styles, [{ children: "p{color:blue}", media: "print" }]); |
| 316 | }); | 326 | }); |
| 317 | | 327 | |
| 318 | test("external stylesheet goes into links", () => { | 328 | test("external stylesheet goes into links", () => { |
| 319 | const { links, styles } = toTags({ title: "T", styles: [{ href: "/style.css" }] }, T); | 329 | const { links, styles } = meta.toTags(T, { title: "T", styles: [{ href: "/style.css" }] }); |
| 320 | assert.ok(links.some((l) => l.rel === "stylesheet" && l.href === "/style.css")); | 330 | assert.ok(links.some((l) => l.rel === "stylesheet" && l.href === "/style.css")); |
| 321 | assert.equal(styles.length, 0); | 331 | assert.equal(styles.length, 0); |
| 322 | }); | 332 | }); |
| 323 | | 333 | |
| 324 | test("external stylesheet with media", () => { | 334 | test("external stylesheet with media", () => { |
| 325 | const { links } = toTags({ title: "T", styles: [{ href: "/print.css", media: "print" }] }, T); | 335 | const { links } = meta.toTags(T, { title: "T", styles: [{ href: "/print.css", media: "print" }] }); |
| 326 | assert.ok(links.some((l) => l.rel === "stylesheet" && l.href === "/print.css" && l.media === "print")); | 336 | assert.ok(links.some((l) => l.rel === "stylesheet" && l.href === "/print.css" && l.media === "print")); |
| 327 | }); | 337 | }); |
| 328 | | 338 | |
| 329 | test("toHtml renders inline style", () => { | 339 | test("toHtml renders inline style", () => { |
| 330 | const out = toHtml({ title: "T", styles: [{ content: "body{margin:0}" }] }, T); | 340 | const out = meta.toHtml(T, { title: "T", styles: [{ content: "body{margin:0}" }] }); |
| 331 | assert.ok(out.includes("<style>body{margin:0}</style>")); | 341 | assert.ok(out.includes("<style>body{margin:0}</style>")); |
| 332 | }); | 342 | }); |
| 333 | | 343 | |
| 334 | test("toHtml renders inline style with media", () => { | 344 | test("toHtml renders inline style with media", () => { |
| 335 | const out = toHtml({ title: "T", styles: [{ content: "p{color:blue}", media: "print" }] }, T); | 345 | const out = meta.toHtml(T, { title: "T", styles: [{ content: "p{color:blue}", media: "print" }] }); |
| 336 | assert.ok(out.includes("<style media=print>p{color:blue}</style>")); | 346 | assert.ok(out.includes("<style media=print>p{color:blue}</style>")); |
| 337 | }); | 347 | }); |
| 338 | | 348 | |
| 339 | test("falls back to template styles", () => { | 349 | test("falls back to template styles", () => { |
| 340 | const { styles } = toTags({ title: "T" }, { ...T, styles: [{ content: "h1{color:red}" }] }); | 350 | const { styles } = meta.toTags({ ...T, styles: [{ content: "h1{color:red}" }] }, { title: "T" }); |
| 341 | assert.deepEqual(styles, [{ children: "h1{color:red}" }]); | 351 | assert.deepEqual(styles, [{ children: "h1{color:red}" }]); |
| 342 | }); | 352 | }); |
| 343 | }); | 353 | }); |
| 344 | | 354 | |
| 345 | describe("extra", () => { | 355 | describe("extra", () => { |
| 346 | test("tag=meta goes into meta array", () => { | 356 | test("tag=meta goes into meta array", () => { |
| 347 | const { meta } = toTags({ title: "T", extra: [{ tag: "meta", name: "verify", content: "abc" }] }, T); | 357 | const tags = meta.toTags(T, { title: "T", extra: [{ tag: "meta", name: "verify", content: "abc" }] }); |
| 348 | assert.ok(meta.some((m) => m.name === "verify" && m.content === "abc")); | 358 | assert.ok(tags.meta.some((m) => m.name === "verify" && m.content === "abc")); |
| 349 | }); | 359 | }); |
| 350 | | 360 | |
| 351 | test("tag=link goes into links array", () => { | 361 | test("tag=link goes into links array", () => { |
| 352 | const { links } = toTags({ title: "T", extra: [{ tag: "link", rel: "icon", href: "/favicon.ico" }] }, T); | 362 | const { links } = meta.toTags(T, { title: "T", extra: [{ tag: "link", rel: "icon", href: "/favicon.ico" }] }); |
| 353 | assert.ok(links.some((l) => l.rel === "icon" && l.href === "/favicon.ico")); | 363 | assert.ok(links.some((l) => l.rel === "icon" && l.href === "/favicon.ico")); |
| 354 | }); | 364 | }); |
| 355 | | 365 | |
| 356 | test("tag=script goes into scripts array", () => { | 366 | test("tag=script goes into scripts array", () => { |
| 357 | const { scripts } = toTags({ title: "T", extra: [{ tag: "script", src: "/extra.js", async: true }] }, T); | 367 | const { scripts } = meta.toTags(T, { title: "T", extra: [{ tag: "script", src: "/extra.js", async: true }] }); |
| 358 | assert.equal(scripts.length, 1); | 368 | assert.equal(scripts.length, 1); |
| | 369 | assert.ok(scripts[0]); |
| 359 | assert.equal(scripts[0].src, "/extra.js"); | 370 | assert.equal(scripts[0].src, "/extra.js"); |
| 360 | assert.equal(scripts[0].async, true); | 371 | assert.equal(scripts[0].async, true); |
| 361 | }); | 372 | }); |
| 362 | | 373 | |
| 363 | test("tag=style goes into styles array", () => { | 374 | test("tag=style goes into styles array", () => { |
| 364 | const { styles } = toTags({ title: "T", extra: [{ tag: "style", children: "a{color:red}" }] }, T); | 375 | const { styles } = meta.toTags(T, { title: "T", extra: [{ tag: "style", children: "a{color:red}" }] }); |
| 365 | assert.deepEqual(styles, [{ children: "a{color:red}" }]); | 376 | assert.deepEqual(styles, [{ children: "a{color:red}" }]); |
| 366 | }); | 377 | }); |
| 367 | | 378 | |
| 368 | test("null/false attrs are filtered out", () => { | 379 | test("null/false attrs are filtered out", () => { |
| 369 | const { meta } = toTags({ title: "T", extra: [{ tag: "meta", name: "x", content: "y", bogus: null }] }, T); | 380 | const tags = meta.toTags(T, { title: "T", extra: [{ tag: "meta", name: "x", content: "y", bogus: null }] }); |
| 370 | const entry = meta.find((m) => m.name === "x")!; | 381 | const entry = tags.meta.find((m) => m.name === "x")!; |
| 371 | assert.equal(entry.bogus, undefined); | 382 | assert.equal(entry.bogus, undefined); |
| 372 | }); | 383 | }); |
| 373 | | 384 | |
| 374 | test("falls back to template extra", () => { | 385 | test("falls back to template extra", () => { |
| 375 | const { meta } = toTags({ title: "T" }, { ...T, extra: [{ tag: "meta", name: "tmpl", content: "yes" }] }); | 386 | const tags = meta.toTags({ ...T, extra: [{ tag: "meta", name: "tmpl", content: "yes" }] }, { title: "T" }); |
| 376 | assert.ok(meta.some((m) => m.name === "tmpl" && m.content === "yes")); | 387 | assert.ok(tags.meta.some((m) => m.name === "tmpl" && m.content === "yes")); |
| | 388 | }); |
| | 389 | }); |
| | 390 | |
| | 391 | // Typescript test for TanStack router compatibility. |
| | 392 | interface HtmlAttributes { |
| | 393 | id?: string; |
| | 394 | className?: string; |
| | 395 | title?: string; |
| | 396 | lang?: string; |
| | 397 | dir?: string; |
| | 398 | hidden?: boolean; |
| | 399 | tabIndex?: number; |
| | 400 | children?: unknown; |
| | 401 | } |
| | 402 | interface MetaAttributes extends HtmlAttributes { |
| | 403 | name?: string; |
| | 404 | content?: string; |
| | 405 | charSet?: string; |
| | 406 | httpEquiv?: string; |
| | 407 | media?: string; |
| | 408 | } |
| | 409 | interface LinkAttributes extends HtmlAttributes { |
| | 410 | rel?: string; |
| | 411 | href?: string; |
| | 412 | hrefLang?: string; |
| | 413 | media?: string; |
| | 414 | type?: string; |
| | 415 | sizes?: string; |
| | 416 | as?: string; |
| | 417 | } |
| | 418 | interface ScriptAttributes extends HtmlAttributes { |
| | 419 | src?: string; |
| | 420 | async?: boolean; |
| | 421 | defer?: boolean; |
| | 422 | type?: string; |
| | 423 | noModule?: boolean; |
| | 424 | integrity?: string; |
| | 425 | } |
| | 426 | interface StyleAttributes extends HtmlAttributes { |
| | 427 | media?: string; |
| | 428 | type?: string; |
| | 429 | } |
| | 430 | |
| | 431 | /** The `head()` return contract from TanStack Start, deps-free. */ |
| | 432 | interface TanStackHead { |
| | 433 | meta?: Array<MetaAttributes | undefined>; |
| | 434 | links?: Array<LinkAttributes | undefined>; |
| | 435 | scripts?: Array<ScriptAttributes | undefined>; |
| | 436 | styles?: Array<StyleAttributes | undefined>; |
| | 437 | } |
| | 438 | |
| | 439 | // Type-level assertion. A compile error on this line means `toTags`' output is |
| | 440 | // no longer a valid TanStack `head()` payload. |
| | 441 | const _asHead: (tags: meta.Tags) => TanStackHead = (tags) => tags; |
| | 442 | |
| | 443 | describe("TanStack Router compatibility", () => { |
| | 444 | test("toTags output is a valid head() payload", () => { |
| | 445 | const tags = meta.toTags( |
| | 446 | { base: new URL("https://example.com/") }, |
| | 447 | { |
| | 448 | title: "Home", |
| | 449 | description: "hi", |
| | 450 | embed: { thumbnail: "/og.png" }, |
| | 451 | scripts: [{ src: "/a.js", async: true }, { content: "console.log(1)" }], |
| | 452 | styles: [{ href: "/a.css" }, { content: "a{color:red}" }], |
| | 453 | extra: [{ tag: "link", rel: "icon", href: "/favicon.ico" }], |
| | 454 | }, |
| | 455 | ); |
| | 456 | const head: TanStackHead = _asHead(tags); |
| | 457 | assert.deepEqual(Object.keys(head), ["meta", "links", "scripts", "styles"]); |
| 377 | }); | 458 | }); |
| 378 | }); | 459 | }); |
| 379 | | 460 | |
| 380 | import assert from "node:assert"; | 461 | import assert from "node:assert"; |
| 381 | import { describe, test } from "vitest"; | 462 | import { describe, test } from "vitest"; |
| 382 | import { type Template, toHtml, toTags } from "./meta.ts"; | 463 | import * as meta from "./meta.ts"; |