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