| 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,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 | }); |
| 20 | 30 | ||
| 21 | describe("basic meta fields", () => { | 31 | describe("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 | }); |
| 71 | 81 | ||
| 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 block | 83 | // 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 | }); |
| 77 | 92 | ||
| 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 | }); |
| 92 | 120 | ||
| 93 | describe("links", () => { | 121 | describe("links", () => { |
| ... | @@ -173,60 +201,83 @@ describe("embed", () => { | ... | @@ -173,60 +201,83 @@ describe("embed", () => { |
| 173 | 201 | ||
| 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 | }); |
| 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 | 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 | }); |
| 184 | 223 | ||
| 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 | }); |
| 189 | 228 | ||
| 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 | }); |
| 194 | 233 | ||
| 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 | }); |
| 199 | 238 | ||
| 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 | }); |
| 204 | 243 | ||
| 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 | }); |
| 209 | 248 | ||
| 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 | }); |
| 215 | 254 | ||
| 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 | }); |
| 221 | 260 | ||
| 222 | describe("openGraph nested/array fields", () => { | 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 | 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 | }); |
| 231 | 282 | ||
| 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 | }); |
| 239 | 290 | ||
| 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 | }); |
| 245 | 296 | ||
| ... | @@ -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 | }); |
| 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 | 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 | } |
| 402 | interface MetaAttributes extends HtmlAttributes { | 460 | interface 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. */ | ||
| 1 | export interface Meta { | 2 | export 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 as | 9 | /** 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; |
| 11 | 12 | ||
| 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; |
| 38 | 39 | ||
| 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 | } |
| 56 | export interface ExternalStylesheet { | 57 | export 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; |
| 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 | 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.viewport | 175 | 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 | ); | ||
| 174 | 180 | ||
| 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 | } |
| 206 | 218 | ||
| 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>> = []; |
| 209 | 221 | ||
| 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>> = []; |
| 248 | 260 | ||
| 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 | } |
| 261 | 273 | ||
| 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 | } |
| 276 | 288 | ||
| 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. */ |
| 292 | export function toHtml(template: Template, meta: Meta): string { | 304 | export 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 | } |
| 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 | function collectOpenGraph(prefix: string, value: OpenGraphField): Array<Record<string, string>> { | 348 | function 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 library | 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 | ## v5 | 25 | ## v5 |
| 4 | 26 | ||
| 5 | ### breaking | 27 | ### breaking |
lib/readme.md+4-3| ... | @@ -12,16 +12,17 @@ projects and at work. | ... | @@ -12,16 +12,17 @@ projects and at work. |
| 12 | all top-level namespaces stand alone as their own projects, and generally do not | 12 | all top-level namespaces stand alone as their own projects, and generally do not |
| 13 | depend on each other. there are no external dependencies; code is easy to audit. | 13 | depend on each other. there are no external dependencies; code is easy to audit. |
| 14 | 14 | ||
| 15 | <!-- - `fs` - rebuilt api for `node:fs` --> | 15 | - `array` - trivial helpers |
| 16 | |||
| 17 | - `assert` - assertion and type narrowing functions | 16 | - `assert` - assertion and type narrowing functions |
| 18 | - `async` - promise and asynchronous helpers | 17 | - `async` - promise and asynchronous helpers |
| 19 | - `bytes` - `Uint8Array` helpers | 18 | - `bytes` - `Uint8Array` helpers |
| 20 | - `error` - interop with the `unknown` type in error handlers | ||
| 21 | - `Events` - typed event emitter | 19 | - `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 widgets | 22 | - `log` - logging, terminal i/o, interactive widgets |
| 23 | - `log/stack` - stack trace parse and formatter | 23 | - `log/stack` - stack trace parse and formatter |
| 24 | - `Lru` - least recently used cache | 24 | - `Lru` - least recently used cache |
| 25 | - `math` - trivial helpers | ||
| 25 | - `mime` - trimmed database of ext -> mime types | 26 | - `mime` - trimmed database of ext -> mime types |
| 26 | - `node` - the Node.js javascript runtime | 27 | - `node` - the Node.js javascript runtime |
| 27 | - `progress` - cli progress bars, nesting status indication | 28 | - `progress` - cli progress bars, nesting status indication |