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 @@...@@ -1,15 +1,15 @@
1{1{
2 "name": "@clo/lib",2 "name": "@clo/lib",
3 "description": "high quality typescript libraries",3 "description": "high quality typescript libraries",
4 "version": "5.0.0",4 "version": "7.0.0",
5 "license": "MIT",5 "license": "MIT",
6 "exports": {6 "exports": {
7 "./arrays": "./arrays.ts",7 "./arrays": "./arrays.ts",
8 "./assert": "./assert.ts",8 "./assert": "./assert.ts",
9 "./async": "./async.ts",9 "./async": "./async.ts",
10 "./bytes": "./bytes.ts",10 "./bytes": "./bytes.ts",
11 "./error": "./error.ts",
12 "./Events": "./Events.ts",11 "./Events": "./Events.ts",
12 "./exception": "./exception.ts",
13 "./http": "./http.ts",13 "./http": "./http.ts",
14 "./log": "./log.ts",14 "./log": "./log.ts",
15 "./log/stack": "./log/stack.ts",15 "./log/stack": "./log/stack.ts",
lib/meta.test.ts+76-17
...@@ -16,6 +16,16 @@ describe("title", () => {...@@ -16,6 +16,16 @@ describe("title", () => {
16 const out = meta.toHtml(T, { title: "<b>Hi</b>" });16 const out = meta.toHtml(T, { title: "<b>Hi</b>" });
17 assert.ok(out.startsWith("<title>&lt;b&gt;Hi&lt;/b&gt;</title>"));17 assert.ok(out.startsWith("<title>&lt;b&gt;Hi&lt;/b&gt;</title>"));
18 });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 });
19});29});
2030
21describe("basic meta fields", () => {31describe("basic meta fields", () => {
...@@ -69,10 +79,15 @@ describe("basic meta fields", () => {...@@ -69,10 +79,15 @@ describe("basic meta fields", () => {
69 assert.ok(tags.meta.some((m) => m.name === "robots" && m.content === "noindex,nofollow"));79 assert.ok(tags.meta.some((m) => m.name === "robots" && m.content === "noindex,nofollow"));
70 });80 });
7181
72 test("denyRobots: false on meta does not override template true", () => {82 test("denyRobots: false on meta overrides template true", () => {
73 // null/undefined opts out; false is falsy so it also doesn't block83 // explicit false unsets the template; only undefined inherits.
74 const tags = meta.toTags({ ...T, denyRobots: true }, { title: "T", denyRobots: false });84 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"));
76 });91 });
7792
78 test("viewport defaults to mobile-friendly value", () => {93 test("viewport defaults to mobile-friendly value", () => {
...@@ -88,6 +103,19 @@ describe("basic meta fields", () => {...@@ -88,6 +103,19 @@ describe("basic meta fields", () => {
88 const tags = meta.toTags(T, { title: "T", viewport: "width=device-width" });103 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"));104 assert.ok(tags.meta.some((m) => m.name === "viewport" && m.content === "width=device-width"));
90 });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 });
91});119});
92120
93describe("links", () => {121describe("links", () => {
...@@ -173,60 +201,83 @@ describe("embed", () => {...@@ -173,60 +201,83 @@ describe("embed", () => {
173201
174 test("thumbnail resolved and applied to both og:image and twitter:image", () => {202 test("thumbnail resolved and applied to both og:image and twitter:image", () => {
175 const tags = meta.toTags(T, { title: "T", embed: { thumbnail: "/img.png" } });203 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"));
177 assert.ok(tags.meta.some((m) => m.name === "twitter: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"));
178 });206 });
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
180 test("embed title falls back to page title for og", () => {219 test("embed title falls back to page title for og", () => {
181 const tags = meta.toTags(T, { title: "My Page", embed: {} });220 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"));
183 });222 });
184223
185 test("embed title overrides page title for og", () => {224 test("embed title overrides page title for og", () => {
186 const tags = meta.toTags(T, { title: "My Page", embed: { title: "Share Title" } });225 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"));
188 });227 });
189228
190 test("embed description falls back to page description", () => {229 test("embed description falls back to page description", () => {
191 const tags = meta.toTags(T, { title: "T", description: "page desc", embed: {} });230 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"));
193 });232 });
194233
195 test("siteTitle sets og:site_name", () => {234 test("siteTitle sets og:site_name", () => {
196 const tags = meta.toTags(T, { title: "T", embed: { siteTitle: "My Site" } });235 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"));
198 });237 });
199238
200 test("canonical sets og:url when embed active", () => {239 test("canonical sets og:url when embed active", () => {
201 const tags = meta.toTags(T, { title: "T", canonical: "/page", embed: {} });240 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"));
203 });242 });
204243
205 test("no canonical → no og:url injected by embed", () => {244 test("no canonical → no og:url injected by embed", () => {
206 const tags = meta.toTags(T, { title: "T", embed: {} });245 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"));
208 });247 });
209248
210 test("explicit openGraph fields override embed defaults", () => {249 test("explicit openGraph fields override embed defaults", () => {
211 const tags = meta.toTags(T, { title: "T", embed: { title: "Embed" }, openGraph: { title: "Explicit" } });250 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"));251 assert.ok(tags.meta.some((m) => m.property === "og:title" && m.content === "Explicit"));
213 assert.equal(tags.meta.filter((m) => m.name === "og:title").length, 1);252 assert.equal(tags.meta.filter((m) => m.property === "og:title").length, 1);
214 });253 });
215254
216 test("embed sets og:type = website", () => {255 test("embed sets og:type = website", () => {
217 const tags = meta.toTags(T, { title: "T", embed: {} });256 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"));
219 });258 });
220});259});
221260
222describe("openGraph nested/array fields", () => {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
223 test("nested object emits namespaced tags", () => {274 test("nested object emits namespaced tags", () => {
224 const tags = meta.toTags(275 const tags = meta.toTags(
225 T,276 T,
226 { title: "T", openGraph: { image: { url: "https://example.com/img.png", width: "800" } } },277 { title: "T", openGraph: { image: { url: "https://example.com/img.png", width: "800" } } },
227 );278 );
228 assert.ok(tags.meta.some((m) => m.name === "og:image:url" && m.content === "https://example.com/img.png"));279 assert.ok(tags.meta.some((m) => m.property === "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"));280 assert.ok(tags.meta.some((m) => m.property === "og:image:width" && m.content === "800"));
230 });281 });
231282
232 test("array value emits duplicate tags", () => {283 test("array value emits duplicate tags", () => {
...@@ -234,12 +285,12 @@ describe("openGraph nested/array fields", () => {...@@ -234,12 +285,12 @@ describe("openGraph nested/array fields", () => {
234 T,285 T,
235 { title: "T", openGraph: { image: ["https://a.com/1.png", "https://a.com/2.png"] } },286 { title: "T", openGraph: { image: ["https://a.com/1.png", "https://a.com/2.png"] } },
236 );287 );
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);
238 });289 });
239290
240 test("null field is skipped", () => {291 test("null field is skipped", () => {
241 const tags = meta.toTags(T, { title: "T", openGraph: { image: null } });292 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"));
243 });294 });
244});295});
245296
...@@ -382,6 +433,13 @@ describe("extra", () => {...@@ -382,6 +433,13 @@ describe("extra", () => {
382 assert.equal(entry.bogus, undefined);433 assert.equal(entry.bogus, undefined);
383 });434 });
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
385 test("falls back to template extra", () => {443 test("falls back to template extra", () => {
386 const tags = meta.toTags({ ...T, extra: [{ tag: "meta", name: "tmpl", content: "yes" }] }, { title: "T" });444 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"));445 assert.ok(tags.meta.some((m) => m.name === "tmpl" && m.content === "yes"));
...@@ -401,6 +459,7 @@ interface HtmlAttributes {...@@ -401,6 +459,7 @@ interface HtmlAttributes {
401}459}
402interface MetaAttributes extends HtmlAttributes {460interface MetaAttributes extends HtmlAttributes {
403 name?: string;461 name?: string;
462 property?: string;
404 content?: string;463 content?: string;
405 charSet?: string;464 charSet?: string;
406 httpEquiv?: string;465 httpEquiv?: string;
lib/meta.ts+63-38
...@@ -1,13 +1,14 @@...@@ -1,13 +1,14 @@
1/** `null` unsets something, `undefined` falls back to what the template says. */
1export interface Meta {2export interface Meta {
2 /** required for all pages. `<title>{content}</title>` */3 /** recommended for all pages. `<title>{content}</title>` */
3 title: string;4 title?: string | null;
4 /** recommended for all pages. `<meta name="description" content="{...}" />` */5 /** recommended for all pages. `<meta name="description" content="{...}" />` */
5 description?: string | null;6 description?: string | null;
6 /** automatically added for static renders from the 'pages' folders. */7 /** automatically added for static renders from the 'pages' folders. */
7 canonical?: string | null;8 canonical?: string | null;
8 /** add `<link rel="alternate" ... />`. Object keys are interpretted as9 /** add `<link rel="alternate" ... />`. Object keys are interpretted as
9 * mime types if they contain a slash, otherwise seen as an alternative language. */10 * 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
12 /** automatically generate both OpenGraph and Twitter meta tags */13 /** automatically generate both OpenGraph and Twitter meta tags */
13 embed?: AutoEmbed | null;14 embed?: AutoEmbed | null;
...@@ -18,11 +19,11 @@ export interface Meta {...@@ -18,11 +19,11 @@ export interface Meta {
18 /** add 'twitter:*' meta tags */19 /** add 'twitter:*' meta tags */
19 twitter?: Twitter | null;20 twitter?: Twitter | null;
20 /** refer to an oEmbed file. See https://oembed.com */21 /** refer to an oEmbed file. See https://oembed.com */
21 oEmbed?: string;22 oEmbed?: string | null;
22 /** add arbitrary scripts to the document head */23 /** add arbitrary scripts to the document head */
23 scripts?: Array<{ content: string } | ExternalScript>;24 scripts?: Array<{ content: string } | ExternalScript> | null;
24 /** add arbitrary styles to the document head */25 /** add arbitrary styles to the document head */
25 styles?: Array<{ content: string } | ExternalStylesheet>;26 styles?: Array<{ content: string } | ExternalStylesheet> | null;
26 /**27 /**
27 * '@clo/lib/meta' intentionally excludes a lot of exotic tags.28 * '@clo/lib/meta' intentionally excludes a lot of exotic tags.
28 * Use `{ tag, ...attrs }` to add arbitrary head elements:29 * Use `{ tag, ...attrs }` to add arbitrary head elements:
...@@ -34,14 +35,14 @@ export interface Meta {...@@ -34,14 +35,14 @@ export interface Meta {
34 * ],35 * ],
35 * ```36 * ```
36 */37 */
37 extra?: Array<ExtraItem>;38 extra?: Array<ExtraItem> | null;
3839
39 /** adds `<meta name="author" content="{...}" />` */40 /** adds `<meta name="author" content="{...}" />` */
40 authors?: string[];41 authors?: string[] | null;
41 /** credit your framework or toolchain */42 /** credit your framework or toolchain */
42 generator?: string | null;43 generator?: string | null;
43 /** adds `<meta name="keywords" content="{keywords.join(', ')}" />` */44 /** adds `<meta name="keywords" content="{keywords.join(', ')}" />` */
44 keywords?: string[];45 keywords?: string[] | null;
45 /** URL to a manifest; https://developer.mozilla.org/en-US/docs/Web/Manifest */46 /** URL to a manifest; https://developer.mozilla.org/en-US/docs/Web/Manifest */
46 manifest?: string | null;47 manifest?: string | null;
47 /** adds `<meta name="publisher" content="{...}" />` */48 /** adds `<meta name="publisher" content="{...}" />` */
...@@ -51,7 +52,7 @@ export interface Meta {...@@ -51,7 +52,7 @@ export interface Meta {
51 /** adds `<meta name="theme-color" content="{...}" />` */52 /** adds `<meta name="theme-color" content="{...}" />` */
52 themeColor?: string | { dark: string; light: string } | null;53 themeColor?: string | { dark: string; light: string } | null;
53 /** defaults to `width=device-width, initial-scale=1` for mobile compatibility. */54 /** defaults to `width=device-width, initial-scale=1` for mobile compatibility. */
54 viewport?: string;55 viewport?: string | null;
55}56}
56export interface ExternalStylesheet {57export interface ExternalStylesheet {
57 rel?: "stylesheet";58 rel?: "stylesheet";
...@@ -77,8 +78,10 @@ export interface AutoEmbed {...@@ -77,8 +78,10 @@ export interface AutoEmbed {
77 title?: string | null;78 title?: string | null;
78 /* defaults to the top level page description. */79 /* defaults to the top level page description. */
79 description?: string | null;80 description?: string | null;
80 /* add an image to the embed card. */81 /* add an image url to the embed card. */
81 thumbnail?: string | null;82 thumbnail?: string | null;
83 /** describe the embed image. */
84 thumbnailAlt?: string | null;
82 /** @default "banner", which applies twitter:card = "summary_large_image" */85 /** @default "banner", which applies twitter:card = "summary_large_image" */
83 thumbnailSize?: "banner" | "icon" | null;86 thumbnailSize?: "banner" | "icon" | null;
84 /** @default null - no site title */87 /** @default null - no site title */
...@@ -158,34 +161,39 @@ export function toTags(template: Template, meta: Meta): Tags {...@@ -158,34 +161,39 @@ export function toTags(template: Template, meta: Meta): Tags {
158 const { titleTemplate, base } = template;161 const { titleTemplate, base } = template;
159 const resolve = (str: string) => new URL(str, base).href;162 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;
162 const description = meta.description ?? null;165 const description = meta.description ?? null;
163 const canonical = meta.canonical ? resolve(meta.canonical) : null;166 const canonical = meta.canonical ? resolve(meta.canonical) : null;
164 const denyRobots = Boolean(meta.denyRobots || template.denyRobots);167 const denyRobots = Boolean(pick(meta.denyRobots, template.denyRobots));
165 const authors = meta.authors ?? template.authors ?? null;168 const authors = pick(meta.authors, template.authors);
166 let generator = meta.generator ?? template.generator ?? null;169 const generator = pick(meta.generator, template.generator);
167 const keywords = meta.keywords ?? template.keywords ?? null;170 const keywords = pick(meta.keywords, template.keywords);
168 const manifest = meta.manifest ?? template.manifest ?? null;171 const manifest = pick(meta.manifest, template.manifest);
169 const publisher = meta.publisher ?? template.publisher ?? null;172 const publisher = pick(meta.publisher, template.publisher);
170 const referrer = meta.referrer ?? template.referrer ?? null;173 const referrer = pick(meta.referrer, template.referrer);
171 const themeColor = meta.themeColor ?? template.themeColor ?? null;174 const themeColor = pick(meta.themeColor, template.themeColor);
172 const viewport = meta.viewport ?? template.viewport175 const viewport = pick(
173 ?? "width=device-width, initial-scale=1, maximum-scale=1";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;181 const alternates = pick(meta.alternates, template.alternates);
176 const oEmbed = meta.oEmbed ?? template.oEmbed ?? null;182 const oEmbed = pick(meta.oEmbed, template.oEmbed);
177 const embed = meta.embed ?? template.embed ?? null;183 const embed = pick(meta.embed, template.embed);
178 let openGraph = meta.openGraph ?? template.openGraph ?? null;184 let openGraph = pick(meta.openGraph, template.openGraph);
179 let twitter = meta.twitter ?? template.twitter ?? null;185 let twitter = pick(meta.twitter, template.twitter);
180 if (embed) {186 if (embed) {
181 const { thumbnail, thumbnailSize, siteTitle } = embed;187 const { thumbnail, thumbnailSize, thumbnailAlt, siteTitle } = embed;
182 openGraph = {188 openGraph = {
183 type: "website",189 type: "website",
184 title: embed.title ?? title,190 title: embed.title ?? title ?? undefined,
185 description: embed.description ?? description,191 description: embed.description ?? description,
186 ...openGraph,192 ...openGraph,
187 };193 };
188 twitter = {194 twitter = {
195 title: embed.title ?? title ?? undefined,
196 description: embed.description ?? description ?? undefined,
189 card: (thumbnailSize ?? (thumbnail ? "banner" : "icon")) === "banner"197 card: (thumbnailSize ?? (thumbnail ? "banner" : "icon")) === "banner"
190 ? "summary_large_image"198 ? "summary_large_image"
191 : "summary",199 : "summary",
...@@ -195,6 +203,10 @@ export function toTags(template: Template, meta: Meta): Tags {...@@ -195,6 +203,10 @@ export function toTags(template: Template, meta: Meta): Tags {
195 const resolved = new URL(thumbnail, template.base).href;203 const resolved = new URL(thumbnail, template.base).href;
196 openGraph.image = resolved;204 openGraph.image = resolved;
197 twitter.image = resolved;205 twitter.image = resolved;
206 if (thumbnailAlt) {
207 openGraph["image:alt"] = thumbnailAlt;
208 twitter["image:alt"] = thumbnailAlt;
209 }
198 }210 }
199 if (siteTitle) {211 if (siteTitle) {
200 openGraph.site_name = siteTitle;212 openGraph.site_name = siteTitle;
...@@ -204,7 +216,7 @@ export function toTags(template: Template, meta: Meta): Tags {...@@ -204,7 +216,7 @@ export function toTags(template: Template, meta: Meta): Tags {
204 }216 }
205 }217 }
206218
207 const metaTags: Array<Record<string, string>> = [{ title }];219 const metaTags: Array<Record<string, string>> = title == null ? [] : [{ title }];
208 const links: Array<Record<string, string>> = [];220 const links: Array<Record<string, string>> = [];
209221
210 if (description) metaTags.push({ name: "description", content: description });222 if (description) metaTags.push({ name: "description", content: description });
...@@ -246,7 +258,7 @@ export function toTags(template: Template, meta: Meta): Tags {...@@ -246,7 +258,7 @@ export function toTags(template: Template, meta: Meta): Tags {
246 const scriptTags: Array<Record<string, string | boolean>> = [];258 const scriptTags: Array<Record<string, string | boolean>> = [];
247 const styleTags: Array<Record<string, string>> = [];259 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) ?? []) {
250 if ("content" in entry) {262 if ("content" in entry) {
251 scriptTags.push({ children: String(entry.content) });263 scriptTags.push({ children: String(entry.content) });
252 } else {264 } else {
...@@ -259,7 +271,7 @@ export function toTags(template: Template, meta: Meta): Tags {...@@ -259,7 +271,7 @@ export function toTags(template: Template, meta: Meta): Tags {
259 }271 }
260 }272 }
261273
262 for (const entry of meta.styles ?? template.styles ?? []) {274 for (const entry of pick(meta.styles, template.styles) ?? []) {
263 if ("content" in entry) {275 if ("content" in entry) {
264 const tag: Record<string, string> = { children: String(entry.content) };276 const tag: Record<string, string> = { children: String(entry.content) };
265 if ("media" in entry && entry.media) tag.media = entry.media;277 if ("media" in entry && entry.media) tag.media = entry.media;
...@@ -274,7 +286,7 @@ export function toTags(template: Template, meta: Meta): Tags {...@@ -274,7 +286,7 @@ export function toTags(template: Template, meta: Meta): Tags {
274 }286 }
275 }287 }
276288
277 for (const { tag, ...attrs } of meta.extra ?? template.extra ?? []) {289 for (const { tag, ...attrs } of pick(meta.extra, template.extra) ?? []) {
278 const clean: Record<string, string | boolean> = {};290 const clean: Record<string, string | boolean> = {};
279 for (const [k, v] of Object.entries(attrs)) {291 for (const [k, v] of Object.entries(attrs)) {
280 if (v != null && v !== false) clean[k] = v;292 if (v != null && v !== false) clean[k] = v;
...@@ -291,10 +303,13 @@ export function toTags(template: Template, meta: Meta): Tags {...@@ -291,10 +303,13 @@ export function toTags(template: Template, meta: Meta): Tags {
291/* convert a metadata definition into HTML text. */303/* convert a metadata definition into HTML text. */
292export function toHtml(template: Template, meta: Meta): string {304export function toHtml(template: Template, meta: Meta): string {
293 const tags = toTags(template, meta);305 const tags = toTags(template, meta);
294 const [{ title }, ...metaTags] = tags.meta as [{ title: string }, ...Record<string, string>[]];306 let out = "";
295 let out = `<title>${esc(title)}</title>`;307 for (const attrs of tags.meta) {
296 for (const attrs of metaTags) {308 if (Object.keys(attrs).length === 1 && "title" in attrs) {
297 out += `<meta` + Object.entries(attrs).map(([k, v]) => ` ${k}=${attr(v)}`).join("") + `>`;309 out += `<title>${esc(attrs.title!)}</title>`;
310 } else {
311 out += `<meta` + Object.entries(attrs).map(([k, v]) => ` ${k}=${attr(v)}`).join("") + `>`;
312 }
298 }313 }
299 for (const attrs of tags.links) {314 for (const attrs of tags.links) {
300 out += `<link` + Object.entries(attrs).map(([k, v]) => ` ${k}=${attr(v)}`).join("") + `>`;315 out += `<link` + Object.entries(attrs).map(([k, v]) => ` ${k}=${attr(v)}`).join("") + `>`;
...@@ -324,9 +339,19 @@ function serializeAttrs(attrs: Record<string, string | boolean>): string {...@@ -324,9 +339,19 @@ function serializeAttrs(attrs: Record<string, string | boolean>): string {
324 return out;339 return out;
325}340}
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
327function collectOpenGraph(prefix: string, value: OpenGraphField): Array<Record<string, string>> {348function collectOpenGraph(prefix: string, value: OpenGraphField): Array<Record<string, string>> {
328 if (!value) return [];349 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 }
330 if (Array.isArray(value)) return value.flatMap((item) => collectOpenGraph(prefix, item));355 if (Array.isArray(value)) return value.flatMap((item) => collectOpenGraph(prefix, item));
331 return Object.entries(value).flatMap(([key, item]) => collectOpenGraph(`${prefix}:${key}`, item));356 return Object.entries(value).flatMap(([key, item]) => collectOpenGraph(`${prefix}:${key}`, item));
332}357}
lib/readme.changes.md+22
...@@ -1,5 +1,27 @@...@@ -1,5 +1,27 @@
1# notable changes in clover's typescript library1# 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
3## v525## v5
426
5### breaking27### breaking
lib/readme.md+4-3
...@@ -12,16 +12,17 @@ projects and at work....@@ -12,16 +12,17 @@ projects and at work.
12all top-level namespaces stand alone as their own projects, and generally do not12all top-level namespaces stand alone as their own projects, and generally do not
13depend on each other. there are no external dependencies; code is easy to audit.13depend on each other. there are no external dependencies; code is easy to audit.
1414
15<!-- - `fs` - rebuilt api for `node:fs` -->15- `array` - trivial helpers
16
17- `assert` - assertion and type narrowing functions16- `assert` - assertion and type narrowing functions
18- `async` - promise and asynchronous helpers17- `async` - promise and asynchronous helpers
19- `bytes` - `Uint8Array` helpers18- `bytes` - `Uint8Array` helpers
20- `error` - interop with the `unknown` type in error handlers
21- `Events` - typed event emitter19- `Events` - typed event emitter
20- `exception` - interop with the `unknown` type in error handlers
21- `http` - basic http server and utilities
22- `log` - logging, terminal i/o, interactive widgets22- `log` - logging, terminal i/o, interactive widgets
23- `log/stack` - stack trace parse and formatter23- `log/stack` - stack trace parse and formatter
24- `Lru` - least recently used cache24- `Lru` - least recently used cache
25- `math` - trivial helpers
25- `mime` - trimmed database of ext -> mime types26- `mime` - trimmed database of ext -> mime types
26- `node` - the Node.js javascript runtime27- `node` - the Node.js javascript runtime
27- `progress` - cli progress bars, nesting status indication28- `progress` - cli progress bars, nesting status indication