|
| 1 | +import { describe, expect, it } from "vitest"; |
| 2 | +import { existsSync, readFileSync } from "node:fs"; |
| 3 | +import { resolve } from "node:path"; |
| 4 | +import { meta } from "../src/copy"; |
| 5 | +import { DEFAULT_SITE_URL } from "../shared/site"; |
| 6 | + |
| 7 | +/** |
| 8 | + * The head's contract. index.html carries %TOKEN%s that vite.config.ts fills |
| 9 | + * from copy.ts and shared/site.ts at build time, so the failure this file |
| 10 | + * guards against is not a wrong string — it is the two halves of the |
| 11 | + * substitution falling out of each other (a token nobody fills, a source |
| 12 | + * nobody references), plus the few rules the strings themselves must keep. |
| 13 | + */ |
| 14 | + |
| 15 | +const html = readFileSync(resolve(__dirname, "../index.html"), "utf8"); |
| 16 | +const pub = (name: string) => resolve(__dirname, "../public", name); |
| 17 | + |
| 18 | +describe("the meta strings themselves", () => { |
| 19 | + it("keeps the description inside the SERP window", () => { |
| 20 | + // Past ~160 chars the tail truncates in results, and the tail is where |
| 21 | + // the qualifiers (MIT, Swift 6, pre-1.0) sit. |
| 22 | + expect(meta.description.length).toBeLessThanOrEqual(160); |
| 23 | + expect(meta.description).not.toMatch(/\n/); |
| 24 | + }); |
| 25 | + |
| 26 | + it("keeps the social image alt to one line", () => { |
| 27 | + expect(meta.imageAlt).not.toMatch(/\n/); |
| 28 | + }); |
| 29 | +}); |
| 30 | + |
| 31 | +describe("the substitution contract", () => { |
| 32 | + it("references exactly the tokens vite.config.ts fills", () => { |
| 33 | + const tokens = new Set([...html.matchAll(/%([A-Z_]+)%/g)].map((m) => m[1])); |
| 34 | + expect([...tokens].sort()).toEqual([ |
| 35 | + "META_DESCRIPTION", |
| 36 | + "META_IMAGE_ALT", |
| 37 | + "META_TITLE", |
| 38 | + "OG_IMAGE", |
| 39 | + "SITE_URL", |
| 40 | + ]); |
| 41 | + }); |
| 42 | + |
| 43 | + it("puts the shared title and description in the head", () => { |
| 44 | + expect(html).toContain("<title>%META_TITLE%</title>"); |
| 45 | + expect(html).toContain('name="description" content="%META_DESCRIPTION%"'); |
| 46 | + expect(html).toContain('property="og:title" content="%META_TITLE%"'); |
| 47 | + expect(html).toContain('property="og:description" content="%META_DESCRIPTION%"'); |
| 48 | + expect(html).toContain('name="twitter:description" content="%META_DESCRIPTION%"'); |
| 49 | + }); |
| 50 | +}); |
| 51 | + |
| 52 | +describe("the social and browser chrome", () => { |
| 53 | + it("carries the full card block: og, image dimensions, alt, twitter", () => { |
| 54 | + for (const needle of [ |
| 55 | + 'property="og:image" content="%OG_IMAGE%"', |
| 56 | + 'property="og:image:width"', |
| 57 | + 'property="og:image:height"', |
| 58 | + 'property="og:image:alt"', |
| 59 | + 'property="og:locale"', |
| 60 | + 'name="twitter:card" content="summary_large_image"', |
| 61 | + 'name="twitter:image"', |
| 62 | + 'name="twitter:image:alt"', |
| 63 | + ]) { |
| 64 | + expect(html, needle).toContain(needle); |
| 65 | + } |
| 66 | + }); |
| 67 | + |
| 68 | + it("tints browser chrome per theme with the resolved paper tokens", () => { |
| 69 | + // oklch is not legal in theme-color; the hexes are the resolved papers |
| 70 | + // (light matches favicon.svg's documented resolution). |
| 71 | + expect(html).toContain('name="theme-color" content="#faf6f1" media="(prefers-color-scheme: light)"'); |
| 72 | + expect(html).toContain('name="theme-color" content="#19120e" media="(prefers-color-scheme: dark)"'); |
| 73 | + }); |
| 74 | + |
| 75 | + it("ships the icons the SVG favicon cannot cover", () => { |
| 76 | + expect(html).toContain('rel="apple-touch-icon" href="/apple-touch-icon.png"'); |
| 77 | + expect(existsSync(pub("apple-touch-icon.png"))).toBe(true); |
| 78 | + expect(existsSync(pub("og.png"))).toBe(true); |
| 79 | + }); |
| 80 | +}); |
| 81 | + |
| 82 | +describe("crawler-facing files", () => { |
| 83 | + it("ships robots.txt pointing at the sitemap", () => { |
| 84 | + const robots = readFileSync(pub("robots.txt"), "utf8"); |
| 85 | + expect(robots).toContain("Allow: /"); |
| 86 | + expect(robots).toContain(`Sitemap: ${DEFAULT_SITE_URL}/sitemap.xml`); |
| 87 | + }); |
| 88 | + |
| 89 | + it("ships a sitemap whose one URL is the site", () => { |
| 90 | + const sitemap = readFileSync(pub("sitemap.xml"), "utf8"); |
| 91 | + expect(sitemap).toContain(`<loc>${DEFAULT_SITE_URL}/</loc>`); |
| 92 | + }); |
| 93 | +}); |
| 94 | + |
| 95 | +describe("the JSON-LD block", () => { |
| 96 | + const substituted = html.replace(/%([A-Z_]+)%/g, (token, key) => { |
| 97 | + const subs: Record<string, string> = { |
| 98 | + SITE_URL: DEFAULT_SITE_URL, |
| 99 | + META_TITLE: meta.title, |
| 100 | + META_DESCRIPTION: meta.description, |
| 101 | + META_IMAGE_ALT: meta.imageAlt, |
| 102 | + }; |
| 103 | + return subs[key] ?? token; |
| 104 | + }); |
| 105 | + const jsonLd = substituted.match( |
| 106 | + /<script type="application\/ld\+json">([\s\S]*?)<\/script>/, |
| 107 | + )?.[1]; |
| 108 | + |
| 109 | + it("parses as JSON after substitution", () => { |
| 110 | + expect(jsonLd).toBeDefined(); |
| 111 | + expect(() => JSON.parse(jsonLd!)).not.toThrow(); |
| 112 | + }); |
| 113 | + |
| 114 | + it("describes the package, nothing invented", () => { |
| 115 | + const data = JSON.parse(jsonLd!); |
| 116 | + expect(data["@type"]).toBe("SoftwareApplication"); |
| 117 | + expect(data.name).toBe("AnnotKit"); |
| 118 | + expect(data.codeRepository).toBe("https://github.com/gpu-cli/annotkit"); |
| 119 | + expect(data.license).toContain("LICENSE"); |
| 120 | + expect(data.offers.price).toBe("0"); |
| 121 | + expect(data.description).toBe(meta.description); |
| 122 | + }); |
| 123 | +}); |
0 commit comments