| ... | ... | @@ -1,109 +1,115 @@ |
| 1 | 1 | const base = new URL("https://example.com/"); |
| 2 | | const T: Template = { base }; |
| 2 | const T: meta.Template = { base }; |
| 3 | 3 | |
| 4 | 4 | describe("title", () => { |
| 5 | 5 | test("is always first entry in meta", () => { |
| 6 | | const { meta } = toTags({ title: "Hello" }, T); |
| 7 | | assert.deepEqual(meta[0], { title: "Hello" }); |
| 6 | const tags = meta.toTags(T, { title: "Hello" }); |
| 7 | assert.deepEqual(tags.meta[0], { title: "Hello" }); |
| 8 | 8 | }); |
| 9 | 9 | |
| 10 | 10 | test("titleTemplate applied", () => { |
| 11 | | const { meta } = toTags({ title: "Page" }, { base, titleTemplate: (t) => `${t} | Site` }); |
| 12 | | assert.deepEqual(meta[0], { title: "Page | Site" }); |
| 11 | const tags = meta.toTags({ base, titleTemplate: (t) => `${t} | Site` }, { title: "Page" }); |
| 12 | assert.deepEqual(tags.meta[0], { title: "Page | Site" }); |
| 13 | 13 | }); |
| 14 | 14 | |
| 15 | 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 | 17 | assert.ok(out.startsWith("<title>&lt;b&gt;Hi&lt;/b&gt;</title>")); |
| 18 | 18 | }); |
| 19 | 19 | }); |
| 20 | 20 | |
| 21 | 21 | describe("basic meta fields", () => { |
| 22 | 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 | 24 | assert.deepEqual( |
| 25 | | meta.filter((m) => m.name === "author"), |
| 25 | tags.meta.filter((m) => m.name === "author"), |
| 26 | 26 | [{ name: "author", content: "Alice" }, { name: "author", content: "Bob" }], |
| 27 | 27 | ); |
| 28 | 28 | }); |
| 29 | 29 | |
| 30 | 30 | test("keywords joined with comma-space", () => { |
| 31 | | const { meta } = toTags({ title: "T", keywords: ["cats", "dogs", "fish"] }, T); |
| 32 | | assert.ok(meta.some((m) => m.name === "keywords" && m.content === "cats, dogs, fish")); |
| 31 | const tags = meta.toTags(T, { title: "T", keywords: ["cats", "dogs", "fish"] }); |
| 32 | assert.ok(tags.meta.some((m) => m.name === "keywords" && m.content === "cats, dogs, fish")); |
| 33 | 33 | }); |
| 34 | 34 | |
| 35 | 35 | test("generator: string used as-is", () => { |
| 36 | | const { meta } = toTags({ title: "T", generator: "my-tool" }, T); |
| 37 | | assert.ok(meta.some((m) => m.name === "generator" && m.content === "my-tool")); |
| 36 | const tags = meta.toTags(T, { title: "T", generator: "my-tool" }); |
| 37 | assert.ok(tags.meta.some((m) => m.name === "generator" && m.content === "my-tool")); |
| 38 | 38 | }); |
| 39 | 39 | |
| 40 | 40 | test("themeColor string emits single tag without media", () => { |
| 41 | | const { meta } = toTags({ title: "T", themeColor: "#fff" }, T); |
| 42 | | const tags = meta.filter((m) => m.name === "theme-color"); |
| 43 | | assert.equal(tags.length, 1); |
| 44 | | assert.equal(tags[0]!.content, "#fff"); |
| 45 | | assert.equal(tags[0]!.media, undefined); |
| 41 | const tags = meta.toTags(T, { title: "T", themeColor: "#fff" }); |
| 42 | const themeColor = tags.meta.filter((m) => m.name === "theme-color"); |
| 43 | assert.equal(themeColor.length, 1); |
| 44 | assert.equal(themeColor[0]!.content, "#fff"); |
| 45 | assert.equal(themeColor[0]!.media, undefined); |
| 46 | 46 | }); |
| 47 | 47 | |
| 48 | 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 | 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 | 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 | 62 | test("denyRobots from meta", () => { |
| 59 | | const { meta } = toTags({ title: "T", denyRobots: true }, T); |
| 60 | | assert.ok(meta.some((m) => m.name === "robots" && m.content === "noindex,nofollow")); |
| 63 | const tags = meta.toTags(T, { title: "T", denyRobots: true }); |
| 64 | assert.ok(tags.meta.some((m) => m.name === "robots" && m.content === "noindex,nofollow")); |
| 61 | 65 | }); |
| 62 | 66 | |
| 63 | 67 | test("denyRobots from template", () => { |
| 64 | | const { meta } = toTags({ title: "T" }, { ...T, denyRobots: true }); |
| 65 | | assert.ok(meta.some((m) => m.name === "robots" && m.content === "noindex,nofollow")); |
| 68 | const tags = meta.toTags({ ...T, denyRobots: true }, { title: "T" }); |
| 69 | assert.ok(tags.meta.some((m) => m.name === "robots" && m.content === "noindex,nofollow")); |
| 66 | 70 | }); |
| 67 | 71 | |
| 68 | 72 | test("denyRobots: false on meta does not override template true", () => { |
| 69 | 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 }); |
| 71 | | assert.ok(meta.some((m) => m.name === "robots")); |
| 74 | const tags = meta.toTags({ ...T, denyRobots: true }, { title: "T", denyRobots: false }); |
| 75 | assert.ok(tags.meta.some((m) => m.name === "robots")); |
| 72 | 76 | }); |
| 73 | 77 | |
| 74 | 78 | test("viewport defaults to mobile-friendly value", () => { |
| 75 | | const { meta } = toTags({ title: "T" }, T); |
| 79 | const tags = meta.toTags(T, { title: "T" }); |
| 76 | 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 | 87 | test("viewport can be overridden", () => { |
| 82 | | const { meta } = toTags({ title: "T", viewport: "width=device-width" }, T); |
| 83 | | assert.ok(meta.some((m) => m.name === "viewport" && m.content === "width=device-width")); |
| 88 | const tags = meta.toTags(T, { title: "T", viewport: "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 | 93 | describe("links", () => { |
| 88 | 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 | 96 | assert.ok(links.some((l) => l.rel === "canonical" && l.href === "https://example.com/page")); |
| 91 | 97 | }); |
| 92 | 98 | |
| 93 | 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 | 101 | assert.ok(links.some((l) => l.rel === "canonical" && l.href === "https://other.com/x")); |
| 96 | 102 | }); |
| 97 | 103 | |
| 98 | 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 | 106 | assert.ok(links.some((l) => l.rel === "manifest" && l.href === "/manifest.json")); |
| 101 | 107 | }); |
| 102 | 108 | }); |
| 103 | 109 | |
| 104 | 110 | describe("oEmbed", () => { |
| 105 | 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 | 113 | assert.ok(links.some((l) => |
| 108 | 114 | l.rel === "alternate" |
| 109 | 115 | && l.type === "application/json+oembed" |
| ... | ... | @@ -112,271 +118,346 @@ describe("oEmbed", () => { |
| 112 | 118 | }); |
| 113 | 119 | |
| 114 | 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 | 122 | assert.ok(links.some((l) => l.type === "application/json+oembed")); |
| 117 | 123 | }); |
| 118 | 124 | }); |
| 119 | 125 | |
| 120 | 126 | describe("alternates", () => { |
| 121 | 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 | 129 | assert.ok(links.some((l) => l.rel === "alternate" && l.hreflang === "fr" && l.href === "https://example.com/fr")); |
| 124 | 130 | }); |
| 125 | 131 | |
| 126 | 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 | 134 | assert.ok(links.some((l) => l.rel === "alternate" && l.type === "application/rss+xml")); |
| 129 | 135 | }); |
| 130 | 136 | |
| 131 | 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 | 139 | assert.ok(links.some((l) => l.type === "application/atom+xml" && !l.hreflang)); |
| 134 | 140 | }); |
| 135 | 141 | |
| 136 | 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 | 144 | assert.ok(links.some((l) => l.hreflang === "ja" && !l.type)); |
| 139 | 145 | }); |
| 140 | 146 | |
| 141 | 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 | 149 | assert.ok(links.some((l) => l.href === "https://example.com/de")); |
| 144 | 150 | }); |
| 145 | 151 | |
| 146 | 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 | 154 | assert.ok(links.some((l) => l.hreflang === "en")); |
| 149 | 155 | }); |
| 150 | 156 | }); |
| 151 | 157 | |
| 152 | 158 | describe("embed", () => { |
| 153 | 159 | test("no thumbnail → twitter:card = summary", () => { |
| 154 | | const { meta } = toTags({ title: "T", embed: {} }, T); |
| 155 | | assert.ok(meta.some((m) => m.name === "twitter:card" && m.content === "summary")); |
| 160 | const tags = meta.toTags(T, { title: "T", embed: {} }); |
| 161 | assert.ok(tags.meta.some((m) => m.name === "twitter:card" && m.content === "summary")); |
| 156 | 162 | }); |
| 157 | 163 | |
| 158 | 164 | test("thumbnail → twitter:card = summary_large_image", () => { |
| 159 | | const { meta } = toTags({ title: "T", embed: { thumbnail: "/img.png" } }, T); |
| 160 | | assert.ok(meta.some((m) => m.name === "twitter:card" && m.content === "summary_large_image")); |
| 165 | const tags = meta.toTags(T, { title: "T", embed: { thumbnail: "/img.png" } }); |
| 166 | assert.ok(tags.meta.some((m) => m.name === "twitter:card" && m.content === "summary_large_image")); |
| 161 | 167 | }); |
| 162 | 168 | |
| 163 | 169 | test("thumbnailSize: icon overrides thumbnail default", () => { |
| 164 | | const { meta } = toTags({ title: "T", embed: { thumbnail: "/img.png", thumbnailSize: "icon" } }, T); |
| 165 | | assert.ok(meta.some((m) => m.name === "twitter:card" && m.content === "summary")); |
| 170 | const tags = meta.toTags(T, { title: "T", embed: { thumbnail: "/img.png", thumbnailSize: "icon" } }); |
| 171 | assert.ok(tags.meta.some((m) => m.name === "twitter:card" && m.content === "summary")); |
| 166 | 172 | }); |
| 167 | 173 | |
| 168 | 174 | test("thumbnail resolved and applied to both og:image and twitter:image", () => { |
| 169 | | const { meta } = toTags({ title: "T", embed: { thumbnail: "/img.png" } }, T); |
| 170 | | assert.ok(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")); |
| 175 | const tags = meta.toTags(T, { title: "T", embed: { thumbnail: "/img.png" } }); |
| 176 | assert.ok(tags.meta.some((m) => m.name === "og: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 | 180 | test("embed title falls back to page title for og", () => { |
| 175 | | const { meta } = toTags({ title: "My Page", embed: {} }, T); |
| 176 | | assert.ok(meta.some((m) => m.name === "og:title" && m.content === "My Page")); |
| 181 | const tags = meta.toTags(T, { title: "My Page", embed: {} }); |
| 182 | assert.ok(tags.meta.some((m) => m.name === "og:title" && m.content === "My Page")); |
| 177 | 183 | }); |
| 178 | 184 | |
| 179 | 185 | test("embed title overrides page title for og", () => { |
| 180 | | const { meta } = toTags({ title: "My Page", embed: { title: "Share Title" } }, T); |
| 181 | | assert.ok(meta.some((m) => m.name === "og:title" && m.content === "Share Title")); |
| 186 | const tags = meta.toTags(T, { title: "My Page", embed: { title: "Share Title" } }); |
| 187 | assert.ok(tags.meta.some((m) => m.name === "og:title" && m.content === "Share Title")); |
| 182 | 188 | }); |
| 183 | 189 | |
| 184 | 190 | test("embed description falls back to page description", () => { |
| 185 | | const { meta } = toTags({ title: "T", description: "page desc", embed: {} }, T); |
| 186 | | assert.ok(meta.some((m) => m.name === "og:description" && m.content === "page desc")); |
| 191 | const tags = meta.toTags(T, { title: "T", description: "page desc", embed: {} }); |
| 192 | assert.ok(tags.meta.some((m) => m.name === "og:description" && m.content === "page desc")); |
| 187 | 193 | }); |
| 188 | 194 | |
| 189 | 195 | test("siteTitle sets og:site_name", () => { |
| 190 | | const { meta } = toTags({ title: "T", embed: { siteTitle: "My Site" } }, T); |
| 191 | | assert.ok(meta.some((m) => m.name === "og:site_name" && m.content === "My Site")); |
| 196 | const tags = meta.toTags(T, { title: "T", embed: { siteTitle: "My Site" } }); |
| 197 | assert.ok(tags.meta.some((m) => m.name === "og:site_name" && m.content === "My Site")); |
| 192 | 198 | }); |
| 193 | 199 | |
| 194 | 200 | test("canonical sets og:url when embed active", () => { |
| 195 | | const { meta } = toTags({ title: "T", canonical: "/page", embed: {} }, T); |
| 196 | | assert.ok(meta.some((m) => m.name === "og:url" && m.content === "https://example.com/page")); |
| 201 | const tags = meta.toTags(T, { title: "T", canonical: "/page", embed: {} }); |
| 202 | assert.ok(tags.meta.some((m) => m.name === "og:url" && m.content === "https://example.com/page")); |
| 197 | 203 | }); |
| 198 | 204 | |
| 199 | 205 | test("no canonical → no og:url injected by embed", () => { |
| 200 | | const { meta } = toTags({ title: "T", embed: {} }, T); |
| 201 | | assert.ok(!meta.some((m) => m.name === "og:url")); |
| 206 | const tags = meta.toTags(T, { title: "T", embed: {} }); |
| 207 | assert.ok(!tags.meta.some((m) => m.name === "og:url")); |
| 202 | 208 | }); |
| 203 | 209 | |
| 204 | 210 | test("explicit openGraph fields override embed defaults", () => { |
| 205 | | const { meta } = toTags({ title: "T", embed: { title: "Embed" }, openGraph: { title: "Explicit" } }, T); |
| 206 | | assert.ok(meta.some((m) => m.name === "og:title" && m.content === "Explicit")); |
| 207 | | assert.equal(meta.filter((m) => m.name === "og:title").length, 1); |
| 211 | const tags = meta.toTags(T, { title: "T", embed: { title: "Embed" }, openGraph: { title: "Explicit" } }); |
| 212 | assert.ok(tags.meta.some((m) => m.name === "og:title" && m.content === "Explicit")); |
| 213 | assert.equal(tags.meta.filter((m) => m.name === "og:title").length, 1); |
| 208 | 214 | }); |
| 209 | 215 | |
| 210 | 216 | test("embed sets og:type = website", () => { |
| 211 | | const { meta } = toTags({ title: "T", embed: {} }, T); |
| 212 | | assert.ok(meta.some((m) => m.name === "og:type" && m.content === "website")); |
| 217 | const tags = meta.toTags(T, { title: "T", embed: {} }); |
| 218 | assert.ok(tags.meta.some((m) => m.name === "og:type" && m.content === "website")); |
| 213 | 219 | }); |
| 214 | 220 | }); |
| 215 | 221 | |
| 216 | 222 | describe("openGraph nested/array fields", () => { |
| 217 | 223 | test("nested object emits namespaced tags", () => { |
| 218 | | const { meta } = toTags( |
| 219 | | { title: "T", openGraph: { image: { url: "https://example.com/img.png", width: "800" } } }, |
| 224 | const tags = meta.toTags( |
| 220 | 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")); |
| 223 | | assert.ok(meta.some((m) => m.name === "og:image:width" && m.content === "800")); |
| 228 | assert.ok(tags.meta.some((m) => m.name === "og:image:url" && m.content === "https://example.com/img.png")); |
| 229 | assert.ok(tags.meta.some((m) => m.name === "og:image:width" && m.content === "800")); |
| 224 | 230 | }); |
| 225 | 231 | |
| 226 | 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); |
| 228 | | assert.equal(meta.filter((m) => m.name === "og:image").length, 2); |
| 233 | const tags = meta.toTags( |
| 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 | 240 | test("null field is skipped", () => { |
| 232 | | const { meta } = toTags({ title: "T", openGraph: { image: null } }, T); |
| 233 | | assert.ok(!meta.some((m) => m.name === "og:image")); |
| 241 | const tags = meta.toTags(T, { title: "T", openGraph: { image: null } }); |
| 242 | assert.ok(!tags.meta.some((m) => m.name === "og:image")); |
| 234 | 243 | }); |
| 235 | 244 | }); |
| 236 | 245 | |
| 237 | 246 | describe("template fallbacks", () => { |
| 238 | 247 | test("template fields used when meta omits them", () => { |
| 239 | | const template: Template = { base, publisher: "Acme", keywords: ["a", "b"] }; |
| 240 | | const { meta } = toTags({ title: "T" }, template); |
| 241 | | assert.ok(meta.some((m) => m.name === "publisher" && m.content === "Acme")); |
| 242 | | assert.ok(meta.some((m) => m.name === "keywords" && m.content === "a, b")); |
| 248 | const template: meta.Template = { base, publisher: "Acme", keywords: ["a", "b"] }; |
| 249 | const tags = meta.toTags(template, { title: "T" }); |
| 250 | assert.ok(tags.meta.some((m) => m.name === "publisher" && m.content === "Acme")); |
| 251 | assert.ok(tags.meta.some((m) => m.name === "keywords" && m.content === "a, b")); |
| 243 | 252 | }); |
| 244 | 253 | |
| 245 | 254 | test("meta field overrides template, no duplicate", () => { |
| 246 | | const template: Template = { base, publisher: "Acme" }; |
| 247 | | const { meta } = toTags({ title: "T", publisher: "Override" }, template); |
| 248 | | assert.ok(meta.some((m) => m.name === "publisher" && m.content === "Override")); |
| 249 | | assert.equal(meta.filter((m) => m.name === "publisher").length, 1); |
| 255 | const template: meta.Template = { base, publisher: "Acme" }; |
| 256 | const tags = meta.toTags(template, { title: "T", publisher: "Override" }); |
| 257 | assert.ok(tags.meta.some((m) => m.name === "publisher" && m.content === "Override")); |
| 258 | assert.equal(tags.meta.filter((m) => m.name === "publisher").length, 1); |
| 250 | 259 | }); |
| 251 | 260 | }); |
| 252 | 261 | |
| 253 | 262 | describe("toHtml serialization", () => { |
| 254 | 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 | 265 | assert.ok(out.includes(`content="hello world"`)); |
| 257 | 266 | }); |
| 258 | 267 | |
| 259 | 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 | 270 | assert.ok(out.includes(`content=Acme`)); |
| 262 | 271 | }); |
| 263 | 272 | |
| 264 | 273 | test("attribute value with slash is quoted", () => { |
| 265 | | const out = toHtml({ title: "T" }, T); |
| 274 | const out = meta.toHtml(T, { title: "T" }); |
| 266 | 275 | // viewport contains / — ensure it's quoted |
| 267 | 276 | assert.ok(out.includes(`content="width=device-width`)); |
| 268 | 277 | }); |
| 269 | 278 | |
| 270 | 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 | 281 | assert.ok(!out.includes(`"say "hi"`)); |
| 273 | 282 | }); |
| 274 | 283 | }); |
| 275 | 284 | |
| 276 | 285 | describe("scripts", () => { |
| 277 | 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 | 288 | assert.deepEqual(scripts, [{ children: "alert(1)" }]); |
| 280 | 289 | }); |
| 281 | 290 | |
| 282 | 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 | 293 | assert.equal(scripts.length, 1); |
| 294 | assert.ok(scripts[0]); |
| 285 | 295 | assert.equal(scripts[0].src, "/app.js"); |
| 286 | 296 | assert.equal(scripts[0].async, true); |
| 287 | 297 | assert.equal(scripts[0].defer, undefined); |
| 288 | 298 | }); |
| 289 | 299 | |
| 290 | 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 | 302 | assert.ok(out.includes("<script>console.log(1)</script>")); |
| 293 | 303 | }); |
| 294 | 304 | |
| 295 | 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 | 307 | assert.ok(out.includes("<script src=")); |
| 298 | 308 | assert.ok(out.includes(" async></script>")); |
| 299 | 309 | }); |
| 300 | 310 | |
| 301 | 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 | 313 | assert.deepEqual(scripts, [{ children: "init()" }]); |
| 304 | 314 | }); |
| 305 | 315 | }); |
| 306 | 316 | |
| 307 | 317 | describe("styles", () => { |
| 308 | 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 | 320 | assert.deepEqual(styles, [{ children: "body{margin:0}" }]); |
| 311 | 321 | }); |
| 312 | 322 | |
| 313 | 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 | 325 | assert.deepEqual(styles, [{ children: "p{color:blue}", media: "print" }]); |
| 316 | 326 | }); |
| 317 | 327 | |
| 318 | 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 | 330 | assert.ok(links.some((l) => l.rel === "stylesheet" && l.href === "/style.css")); |
| 321 | 331 | assert.equal(styles.length, 0); |
| 322 | 332 | }); |
| 323 | 333 | |
| 324 | 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 | 336 | assert.ok(links.some((l) => l.rel === "stylesheet" && l.href === "/print.css" && l.media === "print")); |
| 327 | 337 | }); |
| 328 | 338 | |
| 329 | 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 | 341 | assert.ok(out.includes("<style>body{margin:0}</style>")); |
| 332 | 342 | }); |
| 333 | 343 | |
| 334 | 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 | 346 | assert.ok(out.includes("<style media=print>p{color:blue}</style>")); |
| 337 | 347 | }); |
| 338 | 348 | |
| 339 | 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 | 351 | assert.deepEqual(styles, [{ children: "h1{color:red}" }]); |
| 342 | 352 | }); |
| 343 | 353 | }); |
| 344 | 354 | |
| 345 | 355 | describe("extra", () => { |
| 346 | 356 | test("tag=meta goes into meta array", () => { |
| 347 | | const { meta } = toTags({ title: "T", extra: [{ tag: "meta", name: "verify", content: "abc" }] }, T); |
| 348 | | assert.ok(meta.some((m) => m.name === "verify" && m.content === "abc")); |
| 357 | const tags = meta.toTags(T, { title: "T", extra: [{ tag: "meta", name: "verify", content: "abc" }] }); |
| 358 | assert.ok(tags.meta.some((m) => m.name === "verify" && m.content === "abc")); |
| 349 | 359 | }); |
| 350 | 360 | |
| 351 | 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 | 363 | assert.ok(links.some((l) => l.rel === "icon" && l.href === "/favicon.ico")); |
| 354 | 364 | }); |
| 355 | 365 | |
| 356 | 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 | 368 | assert.equal(scripts.length, 1); |
| 369 | assert.ok(scripts[0]); |
| 359 | 370 | assert.equal(scripts[0].src, "/extra.js"); |
| 360 | 371 | assert.equal(scripts[0].async, true); |
| 361 | 372 | }); |
| 362 | 373 | |
| 363 | 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 | 376 | assert.deepEqual(styles, [{ children: "a{color:red}" }]); |
| 366 | 377 | }); |
| 367 | 378 | |
| 368 | 379 | test("null/false attrs are filtered out", () => { |
| 369 | | const { meta } = toTags({ title: "T", extra: [{ tag: "meta", name: "x", content: "y", bogus: null }] }, T); |
| 370 | | const entry = meta.find((m) => m.name === "x")!; |
| 380 | const tags = meta.toTags(T, { title: "T", extra: [{ tag: "meta", name: "x", content: "y", bogus: null }] }); |
| 381 | const entry = tags.meta.find((m) => m.name === "x")!; |
| 371 | 382 | assert.equal(entry.bogus, undefined); |
| 372 | 383 | }); |
| 373 | 384 | |
| 374 | 385 | test("falls back to template extra", () => { |
| 375 | | const { meta } = toTags({ title: "T" }, { ...T, extra: [{ tag: "meta", name: "tmpl", content: "yes" }] }); |
| 376 | | assert.ok(meta.some((m) => m.name === "tmpl" && m.content === "yes")); |
| 386 | const tags = meta.toTags({ ...T, extra: [{ tag: "meta", name: "tmpl", content: "yes" }] }, { title: "T" }); |
| 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 | 461 | import assert from "node:assert"; |
| 381 | 462 | import { describe, test } from "vitest"; |
| 382 | | import { type Template, toHtml, toTags } from "./meta.ts"; |
| 463 | import * as meta from "./meta.ts"; |