From ac3b5bd9b0adad806543db8bd217570bb7630313 Mon Sep 17 00:00:00 2001 From: clover caruso Date: Mon, 29 Jun 2026 12:40:57 -0700 Subject: [PATCH] feat: few fixes to meta this lets me use it at work with no patches. --- lib/deno.json | 4 +- lib/meta.test.ts | 93 +++++++++++++++++++++++++++++++------- lib/meta.ts | 101 ++++++++++++++++++++++++++---------------- lib/readme.changes.md | 22 +++++++++ lib/readme.md | 7 +-- 5 files changed, 167 insertions(+), 60 deletions(-) diff --git a/lib/deno.json b/lib/deno.json index 4279c56053e6fc0b149e21827b0d27d0282382ba..35caa360c6cc3f625900892cf06782641dcf1fdb 100644 --- a/lib/deno.json +++ b/lib/deno.json @@ -1,15 +1,15 @@ { "name": "@clo/lib", "description": "high quality typescript libraries", - "version": "5.0.0", + "version": "7.0.0", "license": "MIT", "exports": { "./arrays": "./arrays.ts", "./assert": "./assert.ts", "./async": "./async.ts", "./bytes": "./bytes.ts", - "./error": "./error.ts", "./Events": "./Events.ts", + "./exception": "./exception.ts", "./http": "./http.ts", "./log": "./log.ts", "./log/stack": "./log/stack.ts", diff --git a/lib/meta.test.ts b/lib/meta.test.ts index 75f753c7fe1c88c067cd9a6fe9212cdb45c4c90f..063d12c4fcc37998a23320b2f759d68b3dd552aa 100644 --- a/lib/meta.test.ts +++ b/lib/meta.test.ts @@ -16,6 +16,16 @@ describe("title", () => { const out = meta.toHtml(T, { title: "Hi" }); assert.ok(out.startsWith("<b>Hi</b>")); }); + + test("omitted title emits no title entry", () => { + const tags = meta.toTags(T, { canonical: "/page" }); + assert.ok(!tags.meta.some((m) => "title" in m)); + }); + + test("toHtml without title emits no ", () => { + const out = meta.toHtml(T, { description: "hi" }); + assert.ok(!out.includes("<title>")); + }); }); describe("basic meta fields", () => { @@ -69,10 +79,15 @@ describe("basic meta fields", () => { assert.ok(tags.meta.some((m) => m.name === "robots" && m.content === "noindex,nofollow")); }); - test("denyRobots: false on meta does not override template true", () => { - // null/undefined opts out; false is falsy so it also doesn't block + test("denyRobots: false on meta overrides template true", () => { + // explicit false unsets the template; only undefined inherits. const tags = meta.toTags({ ...T, denyRobots: true }, { title: "T", denyRobots: false }); - assert.ok(tags.meta.some((m) => m.name === "robots")); + assert.ok(!tags.meta.some((m) => m.name === "robots")); + }); + + test("denyRobots: null on meta unsets template true", () => { + const tags = meta.toTags({ ...T, denyRobots: true }, { title: "T", denyRobots: null }); + assert.ok(!tags.meta.some((m) => m.name === "robots")); }); test("viewport defaults to mobile-friendly value", () => { @@ -88,6 +103,19 @@ describe("basic meta fields", () => { const tags = meta.toTags(T, { title: "T", viewport: "width=device-width" }); assert.ok(tags.meta.some((m) => m.name === "viewport" && m.content === "width=device-width")); }); + + test("viewport: null omits the default tag", () => { + const tags = meta.toTags(T, { title: "T", viewport: null }); + assert.ok(!tags.meta.some((m) => m.name === "viewport")); + }); + + test("null unsets a template field; undefined inherits it", () => { + const template: meta.Template = { base, publisher: "Acme" }; + const unset = meta.toTags(template, { title: "T", publisher: null }); + assert.ok(!unset.meta.some((m) => m.name === "publisher")); + const inherited = meta.toTags(template, { title: "T" }); + assert.ok(inherited.meta.some((m) => m.name === "publisher" && m.content === "Acme")); + }); }); describe("links", () => { @@ -173,60 +201,83 @@ describe("embed", () => { test("thumbnail resolved and applied to both og:image and twitter:image", () => { const tags = meta.toTags(T, { title: "T", embed: { thumbnail: "/img.png" } }); - assert.ok(tags.meta.some((m) => m.name === "og:image" && m.content === "https://example.com/img.png")); + assert.ok(tags.meta.some((m) => m.property === "og:image" && m.content === "https://example.com/img.png")); assert.ok(tags.meta.some((m) => m.name === "twitter:image" && m.content === "https://example.com/img.png")); }); + test("thumbnailAlt emits og:image:alt and twitter:image:alt", () => { + const tags = meta.toTags(T, { title: "T", embed: { thumbnail: "/img.png", thumbnailAlt: "a cat" } }); + assert.ok(tags.meta.some((m) => m.property === "og:image:alt" && m.content === "a cat")); + assert.ok(tags.meta.some((m) => m.name === "twitter:image:alt" && m.content === "a cat")); + }); + + test("thumbnailAlt without thumbnail emits nothing", () => { + const tags = meta.toTags(T, { title: "T", embed: { thumbnailAlt: "a cat" } }); + assert.ok(!tags.meta.some((m) => m.content === "a cat")); + }); + test("embed title falls back to page title for og", () => { const tags = meta.toTags(T, { title: "My Page", embed: {} }); - assert.ok(tags.meta.some((m) => m.name === "og:title" && m.content === "My Page")); + assert.ok(tags.meta.some((m) => m.property === "og:title" && m.content === "My Page")); }); test("embed title overrides page title for og", () => { const tags = meta.toTags(T, { title: "My Page", embed: { title: "Share Title" } }); - assert.ok(tags.meta.some((m) => m.name === "og:title" && m.content === "Share Title")); + assert.ok(tags.meta.some((m) => m.property === "og:title" && m.content === "Share Title")); }); test("embed description falls back to page description", () => { const tags = meta.toTags(T, { title: "T", description: "page desc", embed: {} }); - assert.ok(tags.meta.some((m) => m.name === "og:description" && m.content === "page desc")); + assert.ok(tags.meta.some((m) => m.property === "og:description" && m.content === "page desc")); }); test("siteTitle sets og:site_name", () => { const tags = meta.toTags(T, { title: "T", embed: { siteTitle: "My Site" } }); - assert.ok(tags.meta.some((m) => m.name === "og:site_name" && m.content === "My Site")); + assert.ok(tags.meta.some((m) => m.property === "og:site_name" && m.content === "My Site")); }); test("canonical sets og:url when embed active", () => { const tags = meta.toTags(T, { title: "T", canonical: "/page", embed: {} }); - assert.ok(tags.meta.some((m) => m.name === "og:url" && m.content === "https://example.com/page")); + assert.ok(tags.meta.some((m) => m.property === "og:url" && m.content === "https://example.com/page")); }); test("no canonical → no og:url injected by embed", () => { const tags = meta.toTags(T, { title: "T", embed: {} }); - assert.ok(!tags.meta.some((m) => m.name === "og:url")); + assert.ok(!tags.meta.some((m) => m.property === "og:url")); }); test("explicit openGraph fields override embed defaults", () => { const tags = meta.toTags(T, { title: "T", embed: { title: "Embed" }, openGraph: { title: "Explicit" } }); - assert.ok(tags.meta.some((m) => m.name === "og:title" && m.content === "Explicit")); - assert.equal(tags.meta.filter((m) => m.name === "og:title").length, 1); + assert.ok(tags.meta.some((m) => m.property === "og:title" && m.content === "Explicit")); + assert.equal(tags.meta.filter((m) => m.property === "og:title").length, 1); }); test("embed sets og:type = website", () => { const tags = meta.toTags(T, { title: "T", embed: {} }); - assert.ok(tags.meta.some((m) => m.name === "og:type" && m.content === "website")); + assert.ok(tags.meta.some((m) => m.property === "og:type" && m.content === "website")); }); }); describe("openGraph nested/array fields", () => { + test("og tags use property= and twitter tags use name=", () => { + const tags = meta.toTags(T, { + title: "T", + openGraph: { title: "OG" }, + twitter: { card: "summary", title: "TW" }, + }); + const og = tags.meta.find((m) => m.content === "OG")!; + const tw = tags.meta.find((m) => m.content === "TW")!; + assert.deepEqual(og, { property: "og:title", content: "OG" }); + assert.deepEqual(tw, { name: "twitter:title", content: "TW" }); + }); + test("nested object emits namespaced tags", () => { const tags = meta.toTags( T, { title: "T", openGraph: { image: { url: "https://example.com/img.png", width: "800" } } }, ); - assert.ok(tags.meta.some((m) => m.name === "og:image:url" && m.content === "https://example.com/img.png")); - assert.ok(tags.meta.some((m) => m.name === "og:image:width" && m.content === "800")); + assert.ok(tags.meta.some((m) => m.property === "og:image:url" && m.content === "https://example.com/img.png")); + assert.ok(tags.meta.some((m) => m.property === "og:image:width" && m.content === "800")); }); test("array value emits duplicate tags", () => { @@ -234,12 +285,12 @@ describe("openGraph nested/array fields", () => { T, { title: "T", openGraph: { image: ["https://a.com/1.png", "https://a.com/2.png"] } }, ); - assert.equal(tags.meta.filter((m) => m.name === "og:image").length, 2); + assert.equal(tags.meta.filter((m) => m.property === "og:image").length, 2); }); test("null field is skipped", () => { const tags = meta.toTags(T, { title: "T", openGraph: { image: null } }); - assert.ok(!tags.meta.some((m) => m.name === "og:image")); + assert.ok(!tags.meta.some((m) => m.property === "og:image")); }); }); @@ -382,6 +433,13 @@ describe("extra", () => { assert.equal(entry.bogus, undefined); }); + test("toHtml renders an extra meta carrying a title attribute as <meta>", () => { + const out = meta.toHtml(T, { title: "Page", extra: [{ tag: "meta", name: "x", title: "tip", content: "y" }] }); + assert.ok(out.includes("<title>Page")); + assert.ok(out.includes(`title=tip`) && out.includes(`name=x`)); + assert.equal(out.match(//g)?.length, 1); + }); + test("falls back to template extra", () => { const tags = meta.toTags({ ...T, extra: [{ tag: "meta", name: "tmpl", content: "yes" }] }, { title: "T" }); assert.ok(tags.meta.some((m) => m.name === "tmpl" && m.content === "yes")); @@ -401,6 +459,7 @@ interface HtmlAttributes { } interface MetaAttributes extends HtmlAttributes { name?: string; + property?: string; content?: string; charSet?: string; httpEquiv?: string; diff --git a/lib/meta.ts b/lib/meta.ts index 4b76007da717699e9e4beb06c927cb520d054e63..b3eafe92e48cc28768c8388c6ed8450c3f6c90e1 100644 --- a/lib/meta.ts +++ b/lib/meta.ts @@ -1,13 +1,14 @@ +/** `null` unsets something, `undefined` falls back to what the template says. */ export interface Meta { - /** required for all pages. `<title>{content}` */ - title: string; + /** recommended for all pages. `{content}` */ + title?: string | null; /** recommended for all pages. `` */ description?: string | null; /** automatically added for static renders from the 'pages' folders. */ canonical?: string | null; /** add ``. Object keys are interpretted as * mime types if they contain a slash, otherwise seen as an alternative language. */ - alternates?: Alternate[] | Record; + alternates?: Alternate[] | Record | null; /** automatically generate both OpenGraph and Twitter meta tags */ embed?: AutoEmbed | null; @@ -18,11 +19,11 @@ export interface Meta { /** add 'twitter:*' meta tags */ twitter?: Twitter | null; /** refer to an oEmbed file. See https://oembed.com */ - oEmbed?: string; + oEmbed?: string | null; /** add arbitrary scripts to the document head */ - scripts?: Array<{ content: string } | ExternalScript>; + scripts?: Array<{ content: string } | ExternalScript> | null; /** add arbitrary styles to the document head */ - styles?: Array<{ content: string } | ExternalStylesheet>; + styles?: Array<{ content: string } | ExternalStylesheet> | null; /** * '@clo/lib/meta' intentionally excludes a lot of exotic tags. * Use `{ tag, ...attrs }` to add arbitrary head elements: @@ -34,14 +35,14 @@ export interface Meta { * ], * ``` */ - extra?: Array; + extra?: Array | null; /** adds `` */ - authors?: string[]; + authors?: string[] | null; /** credit your framework or toolchain */ generator?: string | null; /** adds `` */ - keywords?: string[]; + keywords?: string[] | null; /** URL to a manifest; https://developer.mozilla.org/en-US/docs/Web/Manifest */ manifest?: string | null; /** adds `` */ @@ -51,7 +52,7 @@ export interface Meta { /** adds `` */ themeColor?: string | { dark: string; light: string } | null; /** defaults to `width=device-width, initial-scale=1` for mobile compatibility. */ - viewport?: string; + viewport?: string | null; } export interface ExternalStylesheet { rel?: "stylesheet"; @@ -77,8 +78,10 @@ export interface AutoEmbed { title?: string | null; /* defaults to the top level page description. */ description?: string | null; - /* add an image to the embed card. */ + /* add an image url to the embed card. */ thumbnail?: string | null; + /** describe the embed image. */ + thumbnailAlt?: string | null; /** @default "banner", which applies twitter:card = "summary_large_image" */ thumbnailSize?: "banner" | "icon" | null; /** @default null - no site title */ @@ -158,34 +161,39 @@ export function toTags(template: Template, meta: Meta): Tags { const { titleTemplate, base } = template; const resolve = (str: string) => new URL(str, base).href; - const title = titleTemplate ? titleTemplate(meta.title) : meta.title; + const title = meta.title == null ? null : titleTemplate ? titleTemplate(meta.title) : meta.title; const description = meta.description ?? null; const canonical = meta.canonical ? resolve(meta.canonical) : null; - const denyRobots = Boolean(meta.denyRobots || template.denyRobots); - const authors = meta.authors ?? template.authors ?? null; - let generator = meta.generator ?? template.generator ?? null; - const keywords = meta.keywords ?? template.keywords ?? null; - const manifest = meta.manifest ?? template.manifest ?? null; - const publisher = meta.publisher ?? template.publisher ?? null; - const referrer = meta.referrer ?? template.referrer ?? null; - const themeColor = meta.themeColor ?? template.themeColor ?? null; - const viewport = meta.viewport ?? template.viewport - ?? "width=device-width, initial-scale=1, maximum-scale=1"; + const denyRobots = Boolean(pick(meta.denyRobots, template.denyRobots)); + const authors = pick(meta.authors, template.authors); + const generator = pick(meta.generator, template.generator); + const keywords = pick(meta.keywords, template.keywords); + const manifest = pick(meta.manifest, template.manifest); + const publisher = pick(meta.publisher, template.publisher); + const referrer = pick(meta.referrer, template.referrer); + const themeColor = pick(meta.themeColor, template.themeColor); + const viewport = pick( + meta.viewport, + template.viewport, + "width=device-width, initial-scale=1, maximum-scale=1", + ); - const alternates = meta.alternates ?? template.alternates ?? null; - const oEmbed = meta.oEmbed ?? template.oEmbed ?? null; - const embed = meta.embed ?? template.embed ?? null; - let openGraph = meta.openGraph ?? template.openGraph ?? null; - let twitter = meta.twitter ?? template.twitter ?? null; + const alternates = pick(meta.alternates, template.alternates); + const oEmbed = pick(meta.oEmbed, template.oEmbed); + const embed = pick(meta.embed, template.embed); + let openGraph = pick(meta.openGraph, template.openGraph); + let twitter = pick(meta.twitter, template.twitter); if (embed) { - const { thumbnail, thumbnailSize, siteTitle } = embed; + const { thumbnail, thumbnailSize, thumbnailAlt, siteTitle } = embed; openGraph = { type: "website", - title: embed.title ?? title, + title: embed.title ?? title ?? undefined, description: embed.description ?? description, ...openGraph, }; twitter = { + title: embed.title ?? title ?? undefined, + description: embed.description ?? description ?? undefined, card: (thumbnailSize ?? (thumbnail ? "banner" : "icon")) === "banner" ? "summary_large_image" : "summary", @@ -195,6 +203,10 @@ export function toTags(template: Template, meta: Meta): Tags { const resolved = new URL(thumbnail, template.base).href; openGraph.image = resolved; twitter.image = resolved; + if (thumbnailAlt) { + openGraph["image:alt"] = thumbnailAlt; + twitter["image:alt"] = thumbnailAlt; + } } if (siteTitle) { openGraph.site_name = siteTitle; @@ -204,7 +216,7 @@ export function toTags(template: Template, meta: Meta): Tags { } } - const metaTags: Array> = [{ title }]; + const metaTags: Array> = title == null ? [] : [{ title }]; const links: Array> = []; if (description) metaTags.push({ name: "description", content: description }); @@ -246,7 +258,7 @@ export function toTags(template: Template, meta: Meta): Tags { const scriptTags: Array> = []; const styleTags: Array> = []; - for (const entry of meta.scripts ?? template.scripts ?? []) { + for (const entry of pick(meta.scripts, template.scripts) ?? []) { if ("content" in entry) { scriptTags.push({ children: String(entry.content) }); } else { @@ -259,7 +271,7 @@ export function toTags(template: Template, meta: Meta): Tags { } } - for (const entry of meta.styles ?? template.styles ?? []) { + for (const entry of pick(meta.styles, template.styles) ?? []) { if ("content" in entry) { const tag: Record = { children: String(entry.content) }; if ("media" in entry && entry.media) tag.media = entry.media; @@ -274,7 +286,7 @@ export function toTags(template: Template, meta: Meta): Tags { } } - for (const { tag, ...attrs } of meta.extra ?? template.extra ?? []) { + for (const { tag, ...attrs } of pick(meta.extra, template.extra) ?? []) { const clean: Record = {}; for (const [k, v] of Object.entries(attrs)) { if (v != null && v !== false) clean[k] = v; @@ -291,10 +303,13 @@ export function toTags(template: Template, meta: Meta): Tags { /* convert a metadata definition into HTML text. */ export function toHtml(template: Template, meta: Meta): string { const tags = toTags(template, meta); - const [{ title }, ...metaTags] = tags.meta as [{ title: string }, ...Record[]]; - let out = `${esc(title)}`; - for (const attrs of metaTags) { - out += ` ` ${k}=${attr(v)}`).join("") + `>`; + let out = ""; + for (const attrs of tags.meta) { + if (Object.keys(attrs).length === 1 && "title" in attrs) { + out += `${esc(attrs.title!)}`; + } else { + out += ` ` ${k}=${attr(v)}`).join("") + `>`; + } } for (const attrs of tags.links) { out += ` ` ${k}=${attr(v)}`).join("") + `>`; @@ -324,9 +339,19 @@ function serializeAttrs(attrs: Record): string { return out; } +function pick(value: T | null | undefined, template: T | null | undefined, fallback: T | null = null): T | null { + if (value !== undefined) return value; + if (template !== undefined) return template; + return fallback; +} + function collectOpenGraph(prefix: string, value: OpenGraphField): Array> { if (!value) return []; - if (typeof value === "string") return [{ name: prefix, content: value }]; + if (typeof value === "string") { + // OG keys on `property`, twitter keys on `name`. + const key = prefix.startsWith("og:") ? "property" : "name"; + return [{ [key]: prefix, content: value }]; + } if (Array.isArray(value)) return value.flatMap((item) => collectOpenGraph(prefix, item)); return Object.entries(value).flatMap(([key, item]) => collectOpenGraph(`${prefix}:${key}`, item)); } diff --git a/lib/readme.changes.md b/lib/readme.changes.md index 48067405e35137742cfad5cff9c206e3df479d1b..0f40490c4dca90b0ac11b53ddedc42906dbc7d67 100644 --- a/lib/readme.changes.md +++ b/lib/readme.changes.md @@ -1,5 +1,27 @@ # notable changes in clover's typescript library +## v7 + +### features + +- `meta`'s auto-embed feature now sets all the twitter tags, not just thumbnail. + sorry i might have been dumb when doing the first version of this. + +## v6 + +### breaking + +- `meta` fields now distinguishes `undefined` and `null`. `null` acts as "unset" + and `undefined` acts as "fallthru to template". +- rename `error` to `exception` + +### features + +- `meta` + - `title` can be optional for composition reasons. + - `embed.thumbnailAlt` sets `og:image:alt` + `twitter:image:alt`. + - emits `og:*` tags with `property=` instead of `name=`. + ## v5 ### breaking diff --git a/lib/readme.md b/lib/readme.md index 4622965780f42d10cc919bd5a77b6bba05aa04ba..a02c0c103afdb56dbcaa808198acb8e065b2d0ce 100644 --- a/lib/readme.md +++ b/lib/readme.md @@ -12,16 +12,17 @@ projects and at work. all top-level namespaces stand alone as their own projects, and generally do not depend on each other. there are no external dependencies; code is easy to audit. - - +- `array` - trivial helpers - `assert` - assertion and type narrowing functions - `async` - promise and asynchronous helpers - `bytes` - `Uint8Array` helpers -- `error` - interop with the `unknown` type in error handlers - `Events` - typed event emitter +- `exception` - interop with the `unknown` type in error handlers +- `http` - basic http server and utilities - `log` - logging, terminal i/o, interactive widgets - `log/stack` - stack trace parse and formatter - `Lru` - least recently used cache +- `math` - trivial helpers - `mime` - trimmed database of ext -> mime types - `node` - the Node.js javascript runtime - `progress` - cli progress bars, nesting status indication -- 2.54.0