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(""));
+ });
});
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 ", () => {
+ const out = meta.toHtml(T, { title: "Page", extra: [{ tag: "meta", name: "x", title: "tip", content: "y" }] });
+ assert.ok(out.includes("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. `{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