| author | |
| committer | |
| log | ac3b5bd9b0adad806543db8bd217570bb7630313 |
| tree | 26c1331772d314bd3076d9fc09c84636cb92e373 |
| parent | cedba8ef7133a2d55f9c5318b0e6098ab0fe4abd |
| signature | Signed by SSH key SHA256:cOKiuRFOeSRxne6EWgHtdQQSlBxjOXm2hOCFnCdLQbQ |
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 | 1 | { |
| 2 | 2 | "name": "@clo/lib", |
| 3 | 3 | "description": "high quality typescript libraries", |
| 4 | "version": "5.0.0", | |
| 4 | "version": "7.0.0", | |
| 5 | 5 | "license": "MIT", |
| 6 | 6 | "exports": { |
| 7 | 7 | "./arrays": "./arrays.ts", |
| 8 | 8 | "./assert": "./assert.ts", |
| 9 | 9 | "./async": "./async.ts", |
| 10 | 10 | "./bytes": "./bytes.ts", |
| 11 | "./error": "./error.ts", | |
| 12 | 11 | "./Events": "./Events.ts", |
| 12 | "./exception": "./exception.ts", | |
| 13 | 13 | "./http": "./http.ts", |
| 14 | 14 | "./log": "./log.ts", |
| 15 | 15 | "./log/stack": "./log/stack.ts", |
lib/meta.test.ts+76-17| ... | ... | @@ -16,6 +16,16 @@ describe("title", () => { |
| 16 | 16 | const out = meta.toHtml(T, { title: "<b>Hi</b>" }); |
| 17 | 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 | }); |
| 20 | 30 | |
| 21 | 31 | describe("basic meta fields", () => { |
| ... | ... | @@ -69,10 +79,15 @@ describe("basic meta fields", () => { |
| 69 | 79 | assert.ok(tags.meta.some((m) => m.name === "robots" && m.content === "noindex,nofollow")); |
| 70 | 80 | }); |
| 71 | 81 | |
| 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. | |
| 74 | 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 | }); |
| 77 | 92 | |
| 78 | 93 | test("viewport defaults to mobile-friendly value", () => { |
| ... | ... | @@ -88,6 +103,19 @@ describe("basic meta fields", () => { |
| 88 | 103 | const tags = meta.toTags(T, { title: "T", viewport: "width=device-width" }); |
| 89 | 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 | }); |
| 92 | 120 | |
| 93 | 121 | describe("links", () => { |
| ... | ... | @@ -173,60 +201,83 @@ describe("embed", () => { |
| 173 | 201 | |
| 174 | 202 | test("thumbnail resolved and applied to both og:image and twitter:image", () => { |
| 175 | 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 | 205 | assert.ok(tags.meta.some((m) => m.name === "twitter:image" && m.content === "https://example.com/img.png")); |
| 178 | 206 | }); |
| 179 | 207 | |
| 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 | 219 | test("embed title falls back to page title for og", () => { |
| 181 | 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 | }); |
| 184 | 223 | |
| 185 | 224 | test("embed title overrides page title for og", () => { |
| 186 | 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 | }); |
| 189 | 228 | |
| 190 | 229 | test("embed description falls back to page description", () => { |
| 191 | 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 | }); |
| 194 | 233 | |
| 195 | 234 | test("siteTitle sets og:site_name", () => { |
| 196 | 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 | }); |
| 199 | 238 | |
| 200 | 239 | test("canonical sets og:url when embed active", () => { |
| 201 | 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 | }); |
| 204 | 243 | |
| 205 | 244 | test("no canonical → no og:url injected by embed", () => { |
| 206 | 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 | }); |
| 209 | 248 | |
| 210 | 249 | test("explicit openGraph fields override embed defaults", () => { |
| 211 | 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")); | |
| 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); | |
| 214 | 253 | }); |
| 215 | 254 | |
| 216 | 255 | test("embed sets og:type = website", () => { |
| 217 | 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 | }); |
| 221 | 260 | |
| 222 | 261 | describe("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 | 274 | test("nested object emits namespaced tags", () => { |
| 224 | 275 | const tags = meta.toTags( |
| 225 | 276 | T, |
| 226 | 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")); | |
| 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")); | |
| 230 | 281 | }); |
| 231 | 282 | |
| 232 | 283 | test("array value emits duplicate tags", () => { |
| ... | ... | @@ -234,12 +285,12 @@ describe("openGraph nested/array fields", () => { |
| 234 | 285 | T, |
| 235 | 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 | }); |
| 239 | 290 | |
| 240 | 291 | test("null field is skipped", () => { |
| 241 | 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 | }); |
| 245 | 296 | |
| ... | ... | @@ -382,6 +433,13 @@ describe("extra", () => { |
| 382 | 433 | assert.equal(entry.bogus, undefined); |
| 383 | 434 | }); |
| 384 | 435 | |
| 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 | 443 | test("falls back to template extra", () => { |
| 386 | 444 | const tags = meta.toTags({ ...T, extra: [{ tag: "meta", name: "tmpl", content: "yes" }] }, { title: "T" }); |
| 387 | 445 | assert.ok(tags.meta.some((m) => m.name === "tmpl" && m.content === "yes")); |
| ... | ... | @@ -401,6 +459,7 @@ interface HtmlAttributes { |
| 401 | 459 | } |
| 402 | 460 | interface MetaAttributes extends HtmlAttributes { |
| 403 | 461 | name?: string; |
| 462 | property?: string; | |
| 404 | 463 | content?: string; |
| 405 | 464 | charSet?: string; |
| 406 | 465 | httpEquiv?: string; |
lib/meta.ts+63-38| ... | ... | @@ -1,13 +1,14 @@ |
| 1 | /** `null` unsets something, `undefined` falls back to what the template says. */ | |
| 1 | 2 | export 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; | |
| 4 | 5 | /** recommended for all pages. `<meta name="description" content="{...}" />` */ |
| 5 | 6 | description?: string | null; |
| 6 | 7 | /** automatically added for static renders from the 'pages' folders. */ |
| 7 | 8 | canonical?: string | null; |
| 8 | 9 | /** add `<link rel="alternate" ... />`. Object keys are interpretted as |
| 9 | 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; | |
| 11 | 12 | |
| 12 | 13 | /** automatically generate both OpenGraph and Twitter meta tags */ |
| 13 | 14 | embed?: AutoEmbed | null; |
| ... | ... | @@ -18,11 +19,11 @@ export interface Meta { |
| 18 | 19 | /** add 'twitter:*' meta tags */ |
| 19 | 20 | twitter?: Twitter | null; |
| 20 | 21 | /** refer to an oEmbed file. See https://oembed.com */ |
| 21 | oEmbed?: string; | |
| 22 | oEmbed?: string | null; | |
| 22 | 23 | /** add arbitrary scripts to the document head */ |
| 23 | scripts?: Array<{ content: string } | ExternalScript>; | |
| 24 | scripts?: Array<{ content: string } | ExternalScript> | null; | |
| 24 | 25 | /** add arbitrary styles to the document head */ |
| 25 | styles?: Array<{ content: string } | ExternalStylesheet>; | |
| 26 | styles?: Array<{ content: string } | ExternalStylesheet> | null; | |
| 26 | 27 | /** |
| 27 | 28 | * '@clo/lib/meta' intentionally excludes a lot of exotic tags. |
| 28 | 29 | * Use `{ tag, ...attrs }` to add arbitrary head elements: |
| ... | ... | @@ -34,14 +35,14 @@ export interface Meta { |
| 34 | 35 | * ], |
| 35 | 36 | * ``` |
| 36 | 37 | */ |
| 37 | extra?: Array<ExtraItem>; | |
| 38 | extra?: Array<ExtraItem> | null; | |
| 38 | 39 | |
| 39 | 40 | /** adds `<meta name="author" content="{...}" />` */ |
| 40 | authors?: string[]; | |
| 41 | authors?: string[] | null; | |
| 41 | 42 | /** credit your framework or toolchain */ |
| 42 | 43 | generator?: string | null; |
| 43 | 44 | /** adds `<meta name="keywords" content="{keywords.join(', ')}" />` */ |
| 44 | keywords?: string[]; | |
| 45 | keywords?: string[] | null; | |
| 45 | 46 | /** URL to a manifest; https://developer.mozilla.org/en-US/docs/Web/Manifest */ |
| 46 | 47 | manifest?: string | null; |
| 47 | 48 | /** adds `<meta name="publisher" content="{...}" />` */ |
| ... | ... | @@ -51,7 +52,7 @@ export interface Meta { |
| 51 | 52 | /** adds `<meta name="theme-color" content="{...}" />` */ |
| 52 | 53 | themeColor?: string | { dark: string; light: string } | null; |
| 53 | 54 | /** defaults to `width=device-width, initial-scale=1` for mobile compatibility. */ |
| 54 | viewport?: string; | |
| 55 | viewport?: string | null; | |
| 55 | 56 | } |
| 56 | 57 | export interface ExternalStylesheet { |
| 57 | 58 | rel?: "stylesheet"; |
| ... | ... | @@ -77,8 +78,10 @@ export interface AutoEmbed { |
| 77 | 78 | title?: string | null; |
| 78 | 79 | /* defaults to the top level page description. */ |
| 79 | 80 | description?: string | null; |
| 80 | /* add an image to the embed card. */ | |
| 81 | /* add an image url to the embed card. */ | |
| 81 | 82 | thumbnail?: string | null; |
| 83 | /** describe the embed image. */ | |
| 84 | thumbnailAlt?: string | null; | |
| 82 | 85 | /** @default "banner", which applies twitter:card = "summary_large_image" */ |
| 83 | 86 | thumbnailSize?: "banner" | "icon" | null; |
| 84 | 87 | /** @default null - no site title */ |
| ... | ... | @@ -158,34 +161,39 @@ export function toTags(template: Template, meta: Meta): Tags { |
| 158 | 161 | const { titleTemplate, base } = template; |
| 159 | 162 | const resolve = (str: string) => new URL(str, base).href; |
| 160 | 163 | |
| 161 | const title = titleTemplate ? titleTemplate(meta.title) : meta.title; | |
| 164 | const title = meta.title == null ? null : titleTemplate ? titleTemplate(meta.title) : meta.title; | |
| 162 | 165 | const description = meta.description ?? null; |
| 163 | 166 | 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 | ); | |
| 174 | 180 | |
| 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); | |
| 180 | 186 | if (embed) { |
| 181 | const { thumbnail, thumbnailSize, siteTitle } = embed; | |
| 187 | const { thumbnail, thumbnailSize, thumbnailAlt, siteTitle } = embed; | |
| 182 | 188 | openGraph = { |
| 183 | 189 | type: "website", |
| 184 | title: embed.title ?? title, | |
| 190 | title: embed.title ?? title ?? undefined, | |
| 185 | 191 | description: embed.description ?? description, |
| 186 | 192 | ...openGraph, |
| 187 | 193 | }; |
| 188 | 194 | twitter = { |
| 195 | title: embed.title ?? title ?? undefined, | |
| 196 | description: embed.description ?? description ?? undefined, | |
| 189 | 197 | card: (thumbnailSize ?? (thumbnail ? "banner" : "icon")) === "banner" |
| 190 | 198 | ? "summary_large_image" |
| 191 | 199 | : "summary", |
| ... | ... | @@ -195,6 +203,10 @@ export function toTags(template: Template, meta: Meta): Tags { |
| 195 | 203 | const resolved = new URL(thumbnail, template.base).href; |
| 196 | 204 | openGraph.image = resolved; |
| 197 | 205 | twitter.image = resolved; |
| 206 | if (thumbnailAlt) { | |
| 207 | openGraph["image:alt"] = thumbnailAlt; | |
| 208 | twitter["image:alt"] = thumbnailAlt; | |
| 209 | } | |
| 198 | 210 | } |
| 199 | 211 | if (siteTitle) { |
| 200 | 212 | openGraph.site_name = siteTitle; |
| ... | ... | @@ -204,7 +216,7 @@ export function toTags(template: Template, meta: Meta): Tags { |
| 204 | 216 | } |
| 205 | 217 | } |
| 206 | 218 | |
| 207 | const metaTags: Array<Record<string, string>> = [{ title }]; | |
| 219 | const metaTags: Array<Record<string, string>> = title == null ? [] : [{ title }]; | |
| 208 | 220 | const links: Array<Record<string, string>> = []; |
| 209 | 221 | |
| 210 | 222 | if (description) metaTags.push({ name: "description", content: description }); |
| ... | ... | @@ -246,7 +258,7 @@ export function toTags(template: Template, meta: Meta): Tags { |
| 246 | 258 | const scriptTags: Array<Record<string, string | boolean>> = []; |
| 247 | 259 | const styleTags: Array<Record<string, string>> = []; |
| 248 | 260 | |
| 249 | for (const entry of meta.scripts ?? template.scripts ?? []) { | |
| 261 | for (const entry of pick(meta.scripts, template.scripts) ?? []) { | |
| 250 | 262 | if ("content" in entry) { |
| 251 | 263 | scriptTags.push({ children: String(entry.content) }); |
| 252 | 264 | } else { |
| ... | ... | @@ -259,7 +271,7 @@ export function toTags(template: Template, meta: Meta): Tags { |
| 259 | 271 | } |
| 260 | 272 | } |
| 261 | 273 | |
| 262 | for (const entry of meta.styles ?? template.styles ?? []) { | |
| 274 | for (const entry of pick(meta.styles, template.styles) ?? []) { | |
| 263 | 275 | if ("content" in entry) { |
| 264 | 276 | const tag: Record<string, string> = { children: String(entry.content) }; |
| 265 | 277 | if ("media" in entry && entry.media) tag.media = entry.media; |
| ... | ... | @@ -274,7 +286,7 @@ export function toTags(template: Template, meta: Meta): Tags { |
| 274 | 286 | } |
| 275 | 287 | } |
| 276 | 288 | |
| 277 | for (const { tag, ...attrs } of meta.extra ?? template.extra ?? []) { | |
| 289 | for (const { tag, ...attrs } of pick(meta.extra, template.extra) ?? []) { | |
| 278 | 290 | const clean: Record<string, string | boolean> = {}; |
| 279 | 291 | for (const [k, v] of Object.entries(attrs)) { |
| 280 | 292 | if (v != null && v !== false) clean[k] = v; |
| ... | ... | @@ -291,10 +303,13 @@ export function toTags(template: Template, meta: Meta): Tags { |
| 291 | 303 | /* convert a metadata definition into HTML text. */ |
| 292 | 304 | export function toHtml(template: Template, meta: Meta): string { |
| 293 | 305 | 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 | } | |
| 298 | 313 | } |
| 299 | 314 | for (const attrs of tags.links) { |
| 300 | 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 | 339 | return out; |
| 325 | 340 | } |
| 326 | 341 | |
| 342 | function 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 | ||
| 327 | 348 | function collectOpenGraph(prefix: string, value: OpenGraphField): Array<Record<string, string>> { |
| 328 | 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 | 355 | if (Array.isArray(value)) return value.flatMap((item) => collectOpenGraph(prefix, item)); |
| 331 | 356 | return Object.entries(value).flatMap(([key, item]) => collectOpenGraph(`${prefix}:${key}`, item)); |
| 332 | 357 | } |
lib/readme.changes.md+22| ... | ... | @@ -1,5 +1,27 @@ |
| 1 | 1 | # notable changes in clover's typescript library |
| 2 | 2 | |
| 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 | 25 | ## v5 |
| 4 | 26 | |
| 5 | 27 | ### breaking |
lib/readme.md+4-3| ... | ... | @@ -12,16 +12,17 @@ projects and at work. |
| 12 | 12 | all top-level namespaces stand alone as their own projects, and generally do not |
| 13 | 13 | depend on each other. there are no external dependencies; code is easy to audit. |
| 14 | 14 | |
| 15 | <!-- - `fs` - rebuilt api for `node:fs` --> | |
| 16 | ||
| 15 | - `array` - trivial helpers | |
| 17 | 16 | - `assert` - assertion and type narrowing functions |
| 18 | 17 | - `async` - promise and asynchronous helpers |
| 19 | 18 | - `bytes` - `Uint8Array` helpers |
| 20 | - `error` - interop with the `unknown` type in error handlers | |
| 21 | 19 | - `Events` - typed event emitter |
| 20 | - `exception` - interop with the `unknown` type in error handlers | |
| 21 | - `http` - basic http server and utilities | |
| 22 | 22 | - `log` - logging, terminal i/o, interactive widgets |
| 23 | 23 | - `log/stack` - stack trace parse and formatter |
| 24 | 24 | - `Lru` - least recently used cache |
| 25 | - `math` - trivial helpers | |
| 25 | 26 | - `mime` - trimmed database of ext -> mime types |
| 26 | 27 | - `node` - the Node.js javascript runtime |
| 27 | 28 | - `progress` - cli progress bars, nesting status indication |