authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-06-29 12:40:57-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-07-10 15:55:31-07:00
logac3b5bd9b0adad806543db8bd217570bb7630313
tree26c1331772d314bd3076d9fc09c84636cb92e373
parentcedba8ef7133a2d55f9c5318b0e6098ab0fe4abd
signature Signed by SSH key SHA256:cOKiuRFOeSRxne6EWgHtdQQSlBxjOXm2hOCFnCdLQbQ

feat: few fixes to meta

this lets me use it at work with no patches.

5 files changed, 167 insertions(+), 60 deletions(-)

lib/deno.json+2-2
......@@ -1,15 +1,15 @@
11{
22 "name": "@clo/lib",
33 "description": "high quality typescript libraries",
4 "version": "5.0.0",
4 "version": "7.0.0",
55 "license": "MIT",
66 "exports": {
77 "./arrays": "./arrays.ts",
88 "./assert": "./assert.ts",
99 "./async": "./async.ts",
1010 "./bytes": "./bytes.ts",
11 "./error": "./error.ts",
1211 "./Events": "./Events.ts",
12 "./exception": "./exception.ts",
1313 "./http": "./http.ts",
1414 "./log": "./log.ts",
1515 "./log/stack": "./log/stack.ts",
lib/meta.test.ts+76-17
......@@ -16,6 +16,16 @@ describe("title", () => {
1616 const out = meta.toHtml(T, { title: "<b>Hi</b>" });
1717 assert.ok(out.startsWith("<title>&lt;b&gt;Hi&lt;/b&gt;</title>"));
1818 });
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 });
1929});
2030
2131describe("basic meta fields", () => {
......@@ -69,10 +79,15 @@ describe("basic meta fields", () => {
6979 assert.ok(tags.meta.some((m) => m.name === "robots" && m.content === "noindex,nofollow"));
7080 });
7181
72 test("denyRobots: false on meta does not override template true", () => {
73 // null/undefined opts out; false is falsy so it also doesn't block
82 test("denyRobots: false on meta overrides template true", () => {
83 // explicit false unsets the template; only undefined inherits.
7484 const tags = meta.toTags({ ...T, denyRobots: true }, { title: "T", denyRobots: false });
75 assert.ok(tags.meta.some((m) => m.name === "robots"));
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"));
7691 });
7792
7893 test("viewport defaults to mobile-friendly value", () => {
......@@ -88,6 +103,19 @@ describe("basic meta fields", () => {
88103 const tags = meta.toTags(T, { title: "T", viewport: "width=device-width" });
89104 assert.ok(tags.meta.some((m) => m.name === "viewport" && m.content === "width=device-width"));
90105 });
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 });
91119});
92120
93121describe("links", () => {
......@@ -173,60 +201,83 @@ describe("embed", () => {
173201
174202 test("thumbnail resolved and applied to both og:image and twitter:image", () => {
175203 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"));
204 assert.ok(tags.meta.some((m) => m.property === "og:image" && m.content === "https://example.com/img.png"));
177205 assert.ok(tags.meta.some((m) => m.name === "twitter:image" && m.content === "https://example.com/img.png"));
178206 });
179207
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
180219 test("embed title falls back to page title for og", () => {
181220 const tags = meta.toTags(T, { title: "My Page", embed: {} });
182 assert.ok(tags.meta.some((m) => m.name === "og:title" && m.content === "My Page"));
221 assert.ok(tags.meta.some((m) => m.property === "og:title" && m.content === "My Page"));
183222 });
184223
185224 test("embed title overrides page title for og", () => {
186225 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"));
226 assert.ok(tags.meta.some((m) => m.property === "og:title" && m.content === "Share Title"));
188227 });
189228
190229 test("embed description falls back to page description", () => {
191230 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"));
231 assert.ok(tags.meta.some((m) => m.property === "og:description" && m.content === "page desc"));
193232 });
194233
195234 test("siteTitle sets og:site_name", () => {
196235 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"));
236 assert.ok(tags.meta.some((m) => m.property === "og:site_name" && m.content === "My Site"));
198237 });
199238
200239 test("canonical sets og:url when embed active", () => {
201240 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"));
241 assert.ok(tags.meta.some((m) => m.property === "og:url" && m.content === "https://example.com/page"));
203242 });
204243
205244 test("no canonical → no og:url injected by embed", () => {
206245 const tags = meta.toTags(T, { title: "T", embed: {} });
207 assert.ok(!tags.meta.some((m) => m.name === "og:url"));
246 assert.ok(!tags.meta.some((m) => m.property === "og:url"));
208247 });
209248
210249 test("explicit openGraph fields override embed defaults", () => {
211250 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);
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);
214253 });
215254
216255 test("embed sets og:type = website", () => {
217256 const tags = meta.toTags(T, { title: "T", embed: {} });
218 assert.ok(tags.meta.some((m) => m.name === "og:type" && m.content === "website"));
257 assert.ok(tags.meta.some((m) => m.property === "og:type" && m.content === "website"));
219258 });
220259});
221260
222261describe("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
223274 test("nested object emits namespaced tags", () => {
224275 const tags = meta.toTags(
225276 T,
226277 { title: "T", openGraph: { image: { url: "https://example.com/img.png", width: "800" } } },
227278 );
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"));
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"));
230281 });
231282
232283 test("array value emits duplicate tags", () => {
......@@ -234,12 +285,12 @@ describe("openGraph nested/array fields", () => {
234285 T,
235286 { title: "T", openGraph: { image: ["https://a.com/1.png", "https://a.com/2.png"] } },
236287 );
237 assert.equal(tags.meta.filter((m) => m.name === "og:image").length, 2);
288 assert.equal(tags.meta.filter((m) => m.property === "og:image").length, 2);
238289 });
239290
240291 test("null field is skipped", () => {
241292 const tags = meta.toTags(T, { title: "T", openGraph: { image: null } });
242 assert.ok(!tags.meta.some((m) => m.name === "og:image"));
293 assert.ok(!tags.meta.some((m) => m.property === "og:image"));
243294 });
244295});
245296
......@@ -382,6 +433,13 @@ describe("extra", () => {
382433 assert.equal(entry.bogus, undefined);
383434 });
384435
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
385443 test("falls back to template extra", () => {
386444 const tags = meta.toTags({ ...T, extra: [{ tag: "meta", name: "tmpl", content: "yes" }] }, { title: "T" });
387445 assert.ok(tags.meta.some((m) => m.name === "tmpl" && m.content === "yes"));
......@@ -401,6 +459,7 @@ interface HtmlAttributes {
401459}
402460interface MetaAttributes extends HtmlAttributes {
403461 name?: string;
462 property?: string;
404463 content?: string;
405464 charSet?: string;
406465 httpEquiv?: string;
lib/meta.ts+63-38
......@@ -1,13 +1,14 @@
1/** `null` unsets something, `undefined` falls back to what the template says. */
12export interface Meta {
2 /** required for all pages. `<title>{content}</title>` */
3 title: string;
3 /** recommended for all pages. `<title>{content}</title>` */
4 title?: string | null;
45 /** recommended for all pages. `<meta name="description" content="{...}" />` */
56 description?: string | null;
67 /** automatically added for static renders from the 'pages' folders. */
78 canonical?: string | null;
89 /** add `<link rel="alternate" ... />`. Object keys are interpretted as
910 * mime types if they contain a slash, otherwise seen as an alternative language. */
10 alternates?: Alternate[] | Record<string, string>;
11 alternates?: Alternate[] | Record<string, string> | null;
1112
1213 /** automatically generate both OpenGraph and Twitter meta tags */
1314 embed?: AutoEmbed | null;
......@@ -18,11 +19,11 @@ export interface Meta {
1819 /** add 'twitter:*' meta tags */
1920 twitter?: Twitter | null;
2021 /** refer to an oEmbed file. See https://oembed.com */
21 oEmbed?: string;
22 oEmbed?: string | null;
2223 /** add arbitrary scripts to the document head */
23 scripts?: Array<{ content: string } | ExternalScript>;
24 scripts?: Array<{ content: string } | ExternalScript> | null;
2425 /** add arbitrary styles to the document head */
25 styles?: Array<{ content: string } | ExternalStylesheet>;
26 styles?: Array<{ content: string } | ExternalStylesheet> | null;
2627 /**
2728 * '@clo/lib/meta' intentionally excludes a lot of exotic tags.
2829 * Use `{ tag, ...attrs }` to add arbitrary head elements:
......@@ -34,14 +35,14 @@ export interface Meta {
3435 * ],
3536 * ```
3637 */
37 extra?: Array<ExtraItem>;
38 extra?: Array<ExtraItem> | null;
3839
3940 /** adds `<meta name="author" content="{...}" />` */
40 authors?: string[];
41 authors?: string[] | null;
4142 /** credit your framework or toolchain */
4243 generator?: string | null;
4344 /** adds `<meta name="keywords" content="{keywords.join(', ')}" />` */
44 keywords?: string[];
45 keywords?: string[] | null;
4546 /** URL to a manifest; https://developer.mozilla.org/en-US/docs/Web/Manifest */
4647 manifest?: string | null;
4748 /** adds `<meta name="publisher" content="{...}" />` */
......@@ -51,7 +52,7 @@ export interface Meta {
5152 /** adds `<meta name="theme-color" content="{...}" />` */
5253 themeColor?: string | { dark: string; light: string } | null;
5354 /** defaults to `width=device-width, initial-scale=1` for mobile compatibility. */
54 viewport?: string;
55 viewport?: string | null;
5556}
5657export interface ExternalStylesheet {
5758 rel?: "stylesheet";
......@@ -77,8 +78,10 @@ export interface AutoEmbed {
7778 title?: string | null;
7879 /* defaults to the top level page description. */
7980 description?: string | null;
80 /* add an image to the embed card. */
81 /* add an image url to the embed card. */
8182 thumbnail?: string | null;
83 /** describe the embed image. */
84 thumbnailAlt?: string | null;
8285 /** @default "banner", which applies twitter:card = "summary_large_image" */
8386 thumbnailSize?: "banner" | "icon" | null;
8487 /** @default null - no site title */
......@@ -158,34 +161,39 @@ export function toTags(template: Template, meta: Meta): Tags {
158161 const { titleTemplate, base } = template;
159162 const resolve = (str: string) => new URL(str, base).href;
160163
161 const title = titleTemplate ? titleTemplate(meta.title) : meta.title;
164 const title = meta.title == null ? null : titleTemplate ? titleTemplate(meta.title) : meta.title;
162165 const description = meta.description ?? null;
163166 const canonical = meta.canonical ? resolve(meta.canonical) : null;
164 const denyRobots = Boolean(meta.denyRobots || template.denyRobots);
165 const authors = meta.authors ?? template.authors ?? null;
166 let generator = meta.generator ?? template.generator ?? null;
167 const keywords = meta.keywords ?? template.keywords ?? null;
168 const manifest = meta.manifest ?? template.manifest ?? null;
169 const publisher = meta.publisher ?? template.publisher ?? null;
170 const referrer = meta.referrer ?? template.referrer ?? null;
171 const themeColor = meta.themeColor ?? template.themeColor ?? null;
172 const viewport = meta.viewport ?? template.viewport
173 ?? "width=device-width, initial-scale=1, maximum-scale=1";
167 const denyRobots = Boolean(pick(meta.denyRobots, template.denyRobots));
168 const authors = pick(meta.authors, template.authors);
169 const generator = pick(meta.generator, template.generator);
170 const keywords = pick(meta.keywords, template.keywords);
171 const manifest = pick(meta.manifest, template.manifest);
172 const publisher = pick(meta.publisher, template.publisher);
173 const referrer = pick(meta.referrer, template.referrer);
174 const themeColor = pick(meta.themeColor, template.themeColor);
175 const viewport = pick(
176 meta.viewport,
177 template.viewport,
178 "width=device-width, initial-scale=1, maximum-scale=1",
179 );
174180
175 const alternates = meta.alternates ?? template.alternates ?? null;
176 const oEmbed = meta.oEmbed ?? template.oEmbed ?? null;
177 const embed = meta.embed ?? template.embed ?? null;
178 let openGraph = meta.openGraph ?? template.openGraph ?? null;
179 let twitter = meta.twitter ?? template.twitter ?? null;
181 const alternates = pick(meta.alternates, template.alternates);
182 const oEmbed = pick(meta.oEmbed, template.oEmbed);
183 const embed = pick(meta.embed, template.embed);
184 let openGraph = pick(meta.openGraph, template.openGraph);
185 let twitter = pick(meta.twitter, template.twitter);
180186 if (embed) {
181 const { thumbnail, thumbnailSize, siteTitle } = embed;
187 const { thumbnail, thumbnailSize, thumbnailAlt, siteTitle } = embed;
182188 openGraph = {
183189 type: "website",
184 title: embed.title ?? title,
190 title: embed.title ?? title ?? undefined,
185191 description: embed.description ?? description,
186192 ...openGraph,
187193 };
188194 twitter = {
195 title: embed.title ?? title ?? undefined,
196 description: embed.description ?? description ?? undefined,
189197 card: (thumbnailSize ?? (thumbnail ? "banner" : "icon")) === "banner"
190198 ? "summary_large_image"
191199 : "summary",
......@@ -195,6 +203,10 @@ export function toTags(template: Template, meta: Meta): Tags {
195203 const resolved = new URL(thumbnail, template.base).href;
196204 openGraph.image = resolved;
197205 twitter.image = resolved;
206 if (thumbnailAlt) {
207 openGraph["image:alt"] = thumbnailAlt;
208 twitter["image:alt"] = thumbnailAlt;
209 }
198210 }
199211 if (siteTitle) {
200212 openGraph.site_name = siteTitle;
......@@ -204,7 +216,7 @@ export function toTags(template: Template, meta: Meta): Tags {
204216 }
205217 }
206218
207 const metaTags: Array<Record<string, string>> = [{ title }];
219 const metaTags: Array<Record<string, string>> = title == null ? [] : [{ title }];
208220 const links: Array<Record<string, string>> = [];
209221
210222 if (description) metaTags.push({ name: "description", content: description });
......@@ -246,7 +258,7 @@ export function toTags(template: Template, meta: Meta): Tags {
246258 const scriptTags: Array<Record<string, string | boolean>> = [];
247259 const styleTags: Array<Record<string, string>> = [];
248260
249 for (const entry of meta.scripts ?? template.scripts ?? []) {
261 for (const entry of pick(meta.scripts, template.scripts) ?? []) {
250262 if ("content" in entry) {
251263 scriptTags.push({ children: String(entry.content) });
252264 } else {
......@@ -259,7 +271,7 @@ export function toTags(template: Template, meta: Meta): Tags {
259271 }
260272 }
261273
262 for (const entry of meta.styles ?? template.styles ?? []) {
274 for (const entry of pick(meta.styles, template.styles) ?? []) {
263275 if ("content" in entry) {
264276 const tag: Record<string, string> = { children: String(entry.content) };
265277 if ("media" in entry && entry.media) tag.media = entry.media;
......@@ -274,7 +286,7 @@ export function toTags(template: Template, meta: Meta): Tags {
274286 }
275287 }
276288
277 for (const { tag, ...attrs } of meta.extra ?? template.extra ?? []) {
289 for (const { tag, ...attrs } of pick(meta.extra, template.extra) ?? []) {
278290 const clean: Record<string, string | boolean> = {};
279291 for (const [k, v] of Object.entries(attrs)) {
280292 if (v != null && v !== false) clean[k] = v;
......@@ -291,10 +303,13 @@ export function toTags(template: Template, meta: Meta): Tags {
291303/* convert a metadata definition into HTML text. */
292304export function toHtml(template: Template, meta: Meta): string {
293305 const tags = toTags(template, meta);
294 const [{ title }, ...metaTags] = tags.meta as [{ title: string }, ...Record<string, string>[]];
295 let out = `<title>${esc(title)}</title>`;
296 for (const attrs of metaTags) {
297 out += `<meta` + Object.entries(attrs).map(([k, v]) => ` ${k}=${attr(v)}`).join("") + `>`;
306 let out = "";
307 for (const attrs of tags.meta) {
308 if (Object.keys(attrs).length === 1 && "title" in attrs) {
309 out += `<title>${esc(attrs.title!)}</title>`;
310 } else {
311 out += `<meta` + Object.entries(attrs).map(([k, v]) => ` ${k}=${attr(v)}`).join("") + `>`;
312 }
298313 }
299314 for (const attrs of tags.links) {
300315 out += `<link` + Object.entries(attrs).map(([k, v]) => ` ${k}=${attr(v)}`).join("") + `>`;
......@@ -324,9 +339,19 @@ function serializeAttrs(attrs: Record<string, string | boolean>): string {
324339 return out;
325340}
326341
342function pick<T>(value: T | null | undefined, template: T | null | undefined, fallback: T | null = null): T | null {
343 if (value !== undefined) return value;
344 if (template !== undefined) return template;
345 return fallback;
346}
347
327348function collectOpenGraph(prefix: string, value: OpenGraphField): Array<Record<string, string>> {
328349 if (!value) return [];
329 if (typeof value === "string") return [{ name: prefix, content: value }];
350 if (typeof value === "string") {
351 // OG keys on `property`, twitter keys on `name`.
352 const key = prefix.startsWith("og:") ? "property" : "name";
353 return [{ [key]: prefix, content: value }];
354 }
330355 if (Array.isArray(value)) return value.flatMap((item) => collectOpenGraph(prefix, item));
331356 return Object.entries(value).flatMap(([key, item]) => collectOpenGraph(`${prefix}:${key}`, item));
332357}
lib/readme.changes.md+22
......@@ -1,5 +1,27 @@
11# notable changes in clover's typescript library
22
3## v7
4
5### features
6
7- `meta`'s auto-embed feature now sets all the twitter tags, not just thumbnail.
8 sorry i might have been dumb when doing the first version of this.
9
10## v6
11
12### breaking
13
14- `meta` fields now distinguishes `undefined` and `null`. `null` acts as "unset"
15 and `undefined` acts as "fallthru to template".
16- rename `error` to `exception`
17
18### features
19
20- `meta`
21 - `title` can be optional for composition reasons.
22 - `embed.thumbnailAlt` sets `og:image:alt` + `twitter:image:alt`.
23 - emits `og:*` tags with `property=` instead of `name=`.
24
325## v5
426
527### breaking
lib/readme.md+4-3
......@@ -12,16 +12,17 @@ projects and at work.
1212all top-level namespaces stand alone as their own projects, and generally do not
1313depend on each other. there are no external dependencies; code is easy to audit.
1414
15<!-- - `fs` - rebuilt api for `node:fs` -->
16
15- `array` - trivial helpers
1716- `assert` - assertion and type narrowing functions
1817- `async` - promise and asynchronous helpers
1918- `bytes` - `Uint8Array` helpers
20- `error` - interop with the `unknown` type in error handlers
2119- `Events` - typed event emitter
20- `exception` - interop with the `unknown` type in error handlers
21- `http` - basic http server and utilities
2222- `log` - logging, terminal i/o, interactive widgets
2323- `log/stack` - stack trace parse and formatter
2424- `Lru` - least recently used cache
25- `math` - trivial helpers
2526- `mime` - trimmed database of ext -> mime types
2627- `node` - the Node.js javascript runtime
2728- `progress` - cli progress bars, nesting status indication