Skip to content

Commit 46c7609

Browse files
committed
feat(web): site metadata — single-sourced head strings, social gaps, icons, robots/sitemap, JSON-LD
- index.html head is all tokens now; vite.config.ts injects title, description and image alt from copy.ts and URLs from shared/site.ts - meta description verb-first at 152 chars; title reads 'AnnotKit: Native Annotation for AI coding agents' - og:locale, og:image:alt, full twitter block; theme-color per theme from the resolved paper tokens; og:image carries ?v=2 so scrapers keyed on the URL re-scrape instead of serving a stale card - apple-touch-icon.png generated from favicon.svg (scripts/touch-icon.mjs) - robots.txt + sitemap.xml; JSON-LD SoftwareApplication, nothing invented - tests/meta.test.ts locks the substitution contract (135/135 pass)
1 parent 40b9e1d commit 46c7609

10 files changed

Lines changed: 275 additions & 20 deletions

File tree

.beads/interactions.jsonl

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,3 +43,4 @@
4343
{"id":"int-3f6de21d1b8ada2ed206443e45e56ed7","kind":"field_change","created_at":"2026-08-24T20:13:58.693065Z","actor":"Angus Bezzina","issue_id":"annotkit-dvl","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"All five children closed; gates green."}}
4444
{"id":"int-35c0bb4f4e051d956020fc636c4dbfe2","kind":"field_change","created_at":"2026-08-24T20:23:06.275957Z","actor":"Angus Bezzina","issue_id":"annotkit-6r4","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"All six feedback items fixed and verified visually at 1280: (1) .stack > .link { justify-self: start } stops the grid stretch that made the §01 link's accent rule span the column; label now 'Setup guide'. (2) Annotation.install() indented inside #if DEBUG. (3) Xcode body rewritten as steps grounded in README (Add Package Dependencies…, paste URL, Up to Next Major from 0.8.0, add library, leave AnnotKitMCP unchecked). (4) SwiftUI body split into mount fact + notes-path fact with the scheme path spelled out (Edit Scheme ▸ Run ▸ Options ▸ Working Directory). (5) §02 Click/Frame/Anchor rewritten outcome-first in plain language. (6) Privacy line now ends 'Unsubscribe at any time.' Appendix A synced; 124/124 tests, build, responsive-check at five widths all pass."}}
4545
{"id":"int-2ed0d7c69581f002560be1723554b3ff","kind":"field_change","created_at":"2026-08-24T20:30:37.553143Z","actor":"Angus Bezzina","issue_id":"annotkit-l9n","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Landed and verified: copy.ts title/lead replaced verbatim per maintainer; privacy string + .signup__privacy rule + its <p> removed; Appendix A synced; 124/124 tests, build, responsive-check PASS; §04 verified visually at 1280."}}
46+
{"id":"int-0e400da11b9597f6d4b8a880c436ed9d","kind":"field_change","created_at":"2026-08-24T20:53:04.932295Z","actor":"Angus Bezzina","issue_id":"annotkit-3r1","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"All six findings fixed: (1) head strings single-sourced — vite.config.ts injects %META_TITLE%/%META_DESCRIPTION%/%META_IMAGE_ALT% from copy.ts and %SITE_URL% from shared/site.ts, index.html carries only tokens; (2) description rewritten verb-first at 152 chars with all qualifiers kept; (3) added og:locale, og:image:alt, twitter:title/description/image/image:alt, theme-color per theme (#faf6f1 / #19120e, resolved from the paper tokens); (4) apple-touch-icon.png 180×180 generated from favicon.svg via new scripts/touch-icon.mjs (npm run icon); (5) public/robots.txt + sitemap.xml ship; (6) JSON-LD SoftwareApplication block, all fields traceable. tests/meta.test.ts (11 tests) locks the contract — it immediately caught a stray %TOKEN% in a comment. 135/135 tests, build, responsive-check at five widths pass; dist head inspected: all tokens substituted, no leftovers."}}

web/index.html

Lines changed: 50 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,25 @@
44
<meta charset="UTF-8" />
55
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
66

7-
<title>AnnotKit: native annotation for AI coding agents</title>
8-
<meta
9-
name="description"
10-
content="A Swift package for macOS and iOS apps. Click a view in your own dev build, type a note, and hand your coding agent a selector it can trace back to code. MIT, Swift 6, pre-1.0."
11-
/>
7+
<!--
8+
The head's strings are placeholders on purpose: vite.config.ts fills
9+
each percent-wrapped token from src/copy.ts (words) and shared/site.ts
10+
(URLs) at build, so this file can never drift from the page's copy.
11+
Edit the sources, not the substitutions.
12+
-->
13+
<title>%META_TITLE%</title>
14+
<meta name="description" content="%META_DESCRIPTION%" />
1215
<link rel="canonical" href="%SITE_URL%/" />
1316
<meta name="robots" content="index, follow" />
1417
<meta name="color-scheme" content="light dark" />
18+
<!--
19+
Browser chrome tint, per theme. The values are the resolved sRGB of the
20+
paper tokens in src/styles/tokens.css (oklch is not legal here): light
21+
matches favicon.svg's documented resolution, dark is the OKLCH→sRGB
22+
conversion of oklch(19% 0.014 60).
23+
-->
24+
<meta name="theme-color" content="#faf6f1" media="(prefers-color-scheme: light)" />
25+
<meta name="theme-color" content="#19120e" media="(prefers-color-scheme: dark)" />
1526

1627
<!--
1728
Theme, resolved before first paint. This has to be inline and blocking:
@@ -46,19 +57,47 @@
4657
/>
4758

4859
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
60+
<!-- iOS home screen and older favicon paths still want a PNG. -->
61+
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
4962

5063
<meta property="og:type" content="website" />
5164
<meta property="og:site_name" content="AnnotKit" />
52-
<meta property="og:title" content="AnnotKit: native annotation for AI coding agents" />
53-
<meta
54-
property="og:description"
55-
content="A Swift package for macOS and iOS apps. Click a view in your own dev build, type a note, and hand your coding agent a selector it can trace back to code."
56-
/>
65+
<meta property="og:locale" content="en_US" />
66+
<meta property="og:title" content="%META_TITLE%" />
67+
<meta property="og:description" content="%META_DESCRIPTION%" />
5768
<meta property="og:url" content="%SITE_URL%/" />
58-
<meta property="og:image" content="%SITE_URL%/og.png" />
69+
<meta property="og:image" content="%OG_IMAGE%" />
5970
<meta property="og:image:width" content="1200" />
6071
<meta property="og:image:height" content="630" />
72+
<meta property="og:image:alt" content="%META_IMAGE_ALT%" />
73+
<!-- X reads the og: block; the explicit tags keep the scrapers that don't. -->
6174
<meta name="twitter:card" content="summary_large_image" />
75+
<meta name="twitter:title" content="%META_TITLE%" />
76+
<meta name="twitter:description" content="%META_DESCRIPTION%" />
77+
<meta name="twitter:image" content="%OG_IMAGE%" />
78+
<meta name="twitter:image:alt" content="%META_IMAGE_ALT%" />
79+
80+
<!--
81+
Structured data for the one thing this page is about. Every field is
82+
traceable to README.md / Package.swift / LICENSE; nothing invented
83+
(epic §6.6 applies to machines too).
84+
-->
85+
<script type="application/ld+json">
86+
{
87+
"@context": "https://schema.org",
88+
"@type": "SoftwareApplication",
89+
"name": "AnnotKit",
90+
"applicationCategory": "DeveloperApplication",
91+
"operatingSystem": "macOS 15, iOS 17",
92+
"description": "%META_DESCRIPTION%",
93+
"url": "%SITE_URL%/",
94+
"codeRepository": "https://github.com/gpu-cli/annotkit",
95+
"license": "https://github.com/gpu-cli/annotkit/blob/main/LICENSE",
96+
"isAccessibleForFree": true,
97+
"offers": { "@type": "Offer", "price": "0", "priceCurrency": "USD" },
98+
"author": { "@type": "Organization", "name": "GPU CLI", "url": "https://gpu-cli.sh" }
99+
}
100+
</script>
62101
</head>
63102
<body>
64103
<div id="root"></div>

web/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
"deploy": "npm run build && wrangler pages deploy dist --project-name annotkit --branch main",
1717
"lighthouse": "lhci autorun",
1818
"og": "npm run fonts && node scripts/og.mjs",
19+
"icon": "node scripts/touch-icon.mjs",
1920
"responsive": "node scripts/responsive-check.mjs",
2021
"deploy:preview": "npm run build && wrangler pages deploy dist --project-name annotkit --branch preview"
2122
},

web/public/apple-touch-icon.png

2.08 KB
Loading

web/public/robots.txt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# One page; everything on it is crawlable.
2+
User-agent: *
3+
Allow: /
4+
5+
Sitemap: https://annotkit.gpu-cli.sh/sitemap.xml

web/public/sitemap.xml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<!-- One URL, one page. The origin is DEFAULT_SITE_URL in shared/site.ts;
3+
change it there and here together. -->
4+
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
5+
<url>
6+
<loc>https://annotkit.gpu-cli.sh/</loc>
7+
<lastmod>2026-08-24</lastmod>
8+
</url>
9+
</urlset>

web/scripts/touch-icon.mjs

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
/**
2+
* Renders public/apple-touch-icon.png (180×180) from public/favicon.svg.
3+
*
4+
* iOS home-screen icons and a few older favicon paths still want a PNG, and
5+
* an SVG in public/ cannot read the CSS tokens — so the PNG is derived from
6+
* the SVG (which carries the resolved colours) rather than drawn a second
7+
* time. The mark's own paper rect fills the frame, so the shot is opaque.
8+
*
9+
* npm run icon
10+
*/
11+
12+
import { readFileSync, writeFileSync } from "node:fs";
13+
import { resolve } from "node:path";
14+
import { connect, launch, openPage, sleep } from "./lib/chrome.mjs";
15+
16+
const SIZE = 180;
17+
const OUT = resolve("public/apple-touch-icon.png");
18+
19+
const svg = readFileSync(resolve("public/favicon.svg"), "utf8")
20+
.replace(/<\?xml[\s\S]*?\?>/, "")
21+
.replace(/<!--[\s\S]*?-->/g, "")
22+
.replace('width="32" height="32"', `width="${SIZE}" height="${SIZE}"`);
23+
24+
const { child, endpoint } = await launch();
25+
const cdp = connect(endpoint);
26+
await cdp.ready;
27+
const session = await openPage(cdp);
28+
29+
await cdp.send(
30+
"Emulation.setDeviceMetricsOverride",
31+
{ width: SIZE, height: SIZE, deviceScaleFactor: 1, mobile: false },
32+
session,
33+
);
34+
const html = `<!doctype html><html><head><meta charset="utf-8"></head><body style="margin:0">${svg}</body></html>`;
35+
await cdp.send(
36+
"Page.navigate",
37+
{ url: "data:text/html;charset=utf-8," + encodeURIComponent(html) },
38+
session,
39+
);
40+
await sleep(800);
41+
42+
const shot = await cdp.send(
43+
"Page.captureScreenshot",
44+
{ format: "png", clip: { x: 0, y: 0, width: SIZE, height: SIZE, scale: 1 }, captureBeyondViewport: true },
45+
session,
46+
);
47+
writeFileSync(OUT, Buffer.from(shot.data, "base64"));
48+
49+
cdp.close();
50+
child.kill();
51+
52+
console.log(`icon: public/apple-touch-icon.png · ${SIZE}×${SIZE}`);

web/src/copy.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,17 @@
1818
*/
1919

2020
export const meta = {
21-
title: "AnnotKit: native annotation for AI coding agents",
21+
title: "AnnotKit: Native Annotation for AI coding agents",
22+
/**
23+
* The SERP/social description. 152 characters on purpose: past ~160 the
24+
* tail truncates in results, and the tail is where the qualifiers sit.
25+
* Feeds meta description, og:description, twitter:description and the
26+
* JSON-LD block — vite.config.ts injects it into index.html.
27+
*/
2228
description:
23-
"A Swift package for macOS and iOS apps. Click a view in your own dev build, type a note, and hand your coding agent a selector it can trace back to code. MIT, Swift 6, pre-1.0.",
29+
"Annotate your macOS or iOS dev build: click a view, type a note, and hand your coding agent a selector it can trace back to code. MIT, Swift 6, pre-1.0.",
30+
/** Alt text for the social card (og:image:alt / twitter:image:alt). */
31+
imageAlt: "The AnnotKit card: Point at the view, pass the agent a map.",
2432
} as const;
2533

2634
export const masthead = {

web/tests/meta.test.ts

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
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+
});

web/vite.config.ts

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,34 @@ import { defineConfig, type Plugin } from "vite";
22
import react from "@vitejs/plugin-react";
33

44
import { DEFAULT_SITE_URL } from "./shared/site";
5+
import { meta } from "./src/copy";
56
import { highlight } from "./plugins/highlight";
67

78
/**
8-
* Substitutes `%SITE_URL%` in index.html so the canonical and OpenGraph URLs
9-
* cannot drift from `src/config.ts`. A preview deploy can point them at its
10-
* own origin by setting VITE_SITE_URL in the Pages build environment.
9+
* Substitutes the `%TOKEN%`s in index.html so the head strings cannot drift
10+
* from their sources: URLs come from shared/site.ts (a preview deploy points
11+
* them at its own origin by setting VITE_SITE_URL in the Pages build
12+
* environment); title, description and image alt come from src/copy.ts, the
13+
* page's single source of truth for strings (epic §8.6).
1114
*/
12-
function siteUrl(url: string): Plugin {
15+
function siteHead(url: string): Plugin {
16+
const origin = url.replace(/\/$/, "");
17+
const subs: Record<string, string> = {
18+
SITE_URL: origin,
19+
META_TITLE: meta.title,
20+
META_DESCRIPTION: meta.description,
21+
META_IMAGE_ALT: meta.imageAlt,
22+
/**
23+
* Scrapers (X especially) cache a card's image for days and key that
24+
* cache on the image URL, so new art under the same URL never shows.
25+
* Bump the version whenever scripts/og.mjs changes what og.png draws.
26+
*/
27+
OG_IMAGE: `${origin}/og.png?v=2`,
28+
};
1329
return {
14-
name: "annotkit-site-url",
15-
transformIndexHtml: (html) => html.replaceAll("%SITE_URL%", url.replace(/\/$/, "")),
30+
name: "annotkit-site-head",
31+
transformIndexHtml: (html) =>
32+
html.replace(/%([A-Z_]+)%/g, (token, key: string) => subs[key] ?? token),
1633
};
1734
}
1835

@@ -21,7 +38,7 @@ function siteUrl(url: string): Plugin {
2138
export default defineConfig(({ mode }) => {
2239
const url = process.env.VITE_SITE_URL ?? DEFAULT_SITE_URL;
2340
return {
24-
plugins: [react(), siteUrl(url), highlight()],
41+
plugins: [react(), siteHead(url), highlight()],
2542
build: {
2643
target: "es2022",
2744
cssCodeSplit: false,

0 commit comments

Comments
 (0)