| author | |
| committer | |
| log | 0c49257d99c7511a2855a13f1ebaa92aabaa900b |
| tree | bb5f6f3db47a2b29269c460d4a543ff6115ba97f |
| parent | b3b4d0c104c93d533b94d4e4f804ea7e48032893 |
| signature | Signed by SSH key SHA256:xbd+BjjhyBfwk7GVoURf9Yx0gzDerHbvYv7SddNWmAs |
the new meta library is now here, split into `toTags` and `toHtml`, the
former is compatible with TanStack Router's `head` API.
resolves #5221 files changed, 778 insertions(+), 976 deletions(-)
framework/bundle.ts+1-1| ... | @@ -381,8 +381,8 @@ import * as sg from "#sitegen"; | ... | @@ -381,8 +381,8 @@ import * as sg from "#sitegen"; |
| 381 | import * as fs from "#sitegen/fs"; | 381 | import * as fs from "#sitegen/fs"; |
| 382 | import type * as view from "#sitegen/view"; | 382 | import type * as view from "#sitegen/view"; |
| 383 | import { ASSERT, UNWRAP } from "@clo/lib/assert"; | 383 | import { ASSERT, UNWRAP } from "@clo/lib/assert"; |
| 384 | import * as meta from "@clo/lib/meta"; | ||
| 384 | import * as progress from "@clo/lib/progress"; | 385 | import * as progress from "@clo/lib/progress"; |
| 385 | import * as meta from "@clo/lib/render/meta"; | ||
| 386 | import * as esbuild from "esbuild"; | 386 | import * as esbuild from "esbuild"; |
| 387 | import * as path from "node:path"; | 387 | import * as path from "node:path"; |
| 388 | import process from "node:process"; | 388 | import process from "node:process"; |
framework/generate.ts+9-19| ... | @@ -327,7 +327,7 @@ export async function preparePage( | ... | @@ -327,7 +327,7 @@ export async function preparePage( |
| 327 | ); | 327 | ); |
| 328 | 328 | ||
| 329 | // -- metadata -- | 329 | // -- metadata -- |
| 330 | const renderedMeta = meta.render({ | 330 | const renderedMeta = meta.toHtml({ |
| 331 | canonical: item.id, | 331 | canonical: item.id, |
| 332 | ...typeof metadata === "function" | 332 | ...typeof metadata === "function" |
| 333 | ? await metadata({ ssr: true }) | 333 | ? await metadata({ ssr: true }) |
| ... | @@ -341,21 +341,12 @@ export async function preparePage( | ... | @@ -341,21 +341,12 @@ export async function preparePage( |
| 341 | 341 | ||
| 342 | // -- html -- | 342 | // -- html -- |
| 343 | let text: string; | 343 | let text: string; |
| 344 | const sitegen = sg.context.init(); | 344 | const cloverScripts = new Set<string>(); |
| 345 | if ("render" in Page) { | 345 | text = await SitegenWrap.render({ |
| 346 | text = await SitegenWrap.render({ | 346 | cloverScripts, |
| 347 | cloverScripts: sitegen.scripts, | 347 | page: Page, |
| 348 | page: Page, | 348 | attrs: {}, |
| 349 | attrs: {}, | 349 | }); |
| 350 | }); | ||
| 351 | } else { | ||
| 352 | let page = render.element(Page); | ||
| 353 | if (layout?.default) { | ||
| 354 | page = render.element(layout.default, { content: page }); | ||
| 355 | } | ||
| 356 | |||
| 357 | text = (await render.async(page, [sitegen])).text; | ||
| 358 | } | ||
| 359 | 350 | ||
| 360 | const styleKey = css.styleKey(cssImports, theme); | 351 | const styleKey = css.styleKey(cssImports, theme); |
| 361 | return { | 352 | return { |
| ... | @@ -368,7 +359,7 @@ export async function preparePage( | ... | @@ -368,7 +359,7 @@ export async function preparePage( |
| 368 | cssImports, | 359 | cssImports, |
| 369 | theme: theme ?? null, | 360 | theme: theme ?? null, |
| 370 | styleKey, | 361 | styleKey, |
| 371 | clientRefs: Array.from(sitegen.scripts), | 362 | clientRefs: Array.from(cloverScripts), |
| 372 | } as const; | 363 | } as const; |
| 373 | } catch (e) { | 364 | } catch (e) { |
| 374 | error.rethrowWithMetadata(e, { page: item.id }); | 365 | error.rethrowWithMetadata(e, { page: item.id }); |
| ... | @@ -466,9 +457,8 @@ import * as sg from "#sitegen"; | ... | @@ -466,9 +457,8 @@ import * as sg from "#sitegen"; |
| 466 | import type { FileItem } from "#sitegen"; | 457 | import type { FileItem } from "#sitegen"; |
| 467 | import * as fs from "#sitegen/fs"; | 458 | import * as fs from "#sitegen/fs"; |
| 468 | import * as error from "@clo/lib/error"; | 459 | import * as error from "@clo/lib/error"; |
| 460 | import * as meta from "@clo/lib/meta"; | ||
| 469 | import * as progress from "@clo/lib/progress"; | 461 | import * as progress from "@clo/lib/progress"; |
| 470 | import * as render from "@clo/lib/render"; | ||
| 471 | import * as meta from "@clo/lib/render/meta"; | ||
| 472 | 462 | ||
| 473 | import SitegenWrap from "./sitegen-wrap.marko"; | 463 | import SitegenWrap from "./sitegen-wrap.marko"; |
| 474 | 464 |
framework/global.d.ts deleted-13| ... | @@ -1,13 +0,0 @@ | ||
| 1 | type Timer = ReturnType<typeof setTimeout>; | ||
| 2 | type Awaitable<T> = T | Promise<T>; | ||
| 3 | |||
| 4 | declare global { | ||
| 5 | namespace JSX { | ||
| 6 | export type IntrinsicElements = jsx.ts.IntrinsicElements; | ||
| 7 | export type ElementChildrenAttribute = jsx.ts.ElementChildrenAttribute; | ||
| 8 | export type Element = jsx.ts.Element; | ||
| 9 | export type ElementType = jsx.ts.ElementType; | ||
| 10 | export type ElementClass = jsx.ts.ElementClass; | ||
| 11 | } | ||
| 12 | } | ||
| 13 | import * as jsx from "@clo/lib/render/jsx-runtime"; | ||
framework/lib/sitegen.ts+3-19| ... | @@ -5,15 +5,9 @@ export interface PageExports extends ViewExports { | ... | @@ -5,15 +5,9 @@ export interface PageExports extends ViewExports { |
| 5 | markoOnly?: boolean; | 5 | markoOnly?: boolean; |
| 6 | } | 6 | } |
| 7 | export interface ViewExports { | 7 | export interface ViewExports { |
| 8 | default: render.Component; | 8 | default: Marko.Component; |
| 9 | meta: meta.Meta | ((props: { ssr: true }) => Promise<meta.Meta> | meta.Meta); | 9 | meta: Meta | ((props: { ssr: true }) => Promise<Meta> | Meta); |
| 10 | theme?: css.Theme; | 10 | theme?: css.Theme; |
| 11 | layout?: Layout; | ||
| 12 | } | ||
| 13 | export interface Layout { | ||
| 14 | default?: render.Component; | ||
| 15 | theme?: css.Theme; | ||
| 16 | markoOnly?: boolean; | ||
| 17 | } | 11 | } |
| 18 | export interface PageRegenerateOptions { | 12 | export interface PageRegenerateOptions { |
| 19 | tags?: string[]; | 13 | tags?: string[]; |
| ... | @@ -52,15 +46,6 @@ export type FontVars = Record<string, FontVariableAxis>; | ... | @@ -52,15 +46,6 @@ export type FontVars = Record<string, FontVariableAxis>; |
| 52 | export type FontVariableAxis = number | [min: number, max: number]; | 46 | export type FontVariableAxis = number | [min: number, max: number]; |
| 53 | export type FontRange = string | number | { start: number; end: number }; | 47 | export type FontRange = string | number | { start: number; end: number }; |
| 54 | 48 | ||
| 55 | export const context = new render.Context(() => ({ | ||
| 56 | scripts: new Set<string>(), | ||
| 57 | })); | ||
| 58 | |||
| 59 | /** Add a client-side script to the page. */ | ||
| 60 | export function addScript(id: ScriptId | { value: ScriptId }) { | ||
| 61 | context.get().scripts.add(typeof id === "string" ? id : id.value); | ||
| 62 | } | ||
| 63 | |||
| 64 | // TODO: self-host plausible for extra privacy. i am in a rush as i write this | 49 | // TODO: self-host plausible for extra privacy. i am in a rush as i write this |
| 65 | // TODO: properly locate this constant in `site.ts` | 50 | // TODO: properly locate this constant in `site.ts` |
| 66 | // | 51 | // |
| ... | @@ -100,6 +85,5 @@ export function joinScripts(scriptSources: string[]) { | ... | @@ -100,6 +85,5 @@ export function joinScripts(scriptSources: string[]) { |
| 100 | export { default as CloverScriptInclude } from "./add-script.marko"; | 85 | export { default as CloverScriptInclude } from "./add-script.marko"; |
| 101 | 86 | ||
| 102 | import { UNWRAP } from "@clo/lib/assert"; | 87 | import { UNWRAP } from "@clo/lib/assert"; |
| 103 | import * as render from "@clo/lib/render"; | 88 | import type { Meta } from "@clo/lib/meta"; |
| 104 | import type * as meta from "@clo/lib/render/meta"; | ||
| 105 | import type * as css from "../css.ts"; | 89 | import type * as css from "../css.ts"; |
framework/lib/view.ts+13-31| ... | @@ -22,9 +22,8 @@ export interface Codegen { | ... | @@ -22,9 +22,8 @@ export interface Codegen { |
| 22 | // separate for run-time dynamic scripts. For example, the file viewer | 22 | // separate for run-time dynamic scripts. For example, the file viewer |
| 23 | // includes the canvas for the current page, but only the current page. | 23 | // includes the canvas for the current page, but only the current page. |
| 24 | export interface View<Props extends Record<string, unknown>> { | 24 | export interface View<Props extends Record<string, unknown>> { |
| 25 | component: render.Component; | 25 | component: Marko.Component<Props>; |
| 26 | meta: meta.Meta | ((props: Props) => Promise<meta.Meta> | meta.Meta); | 26 | meta: meta.Meta | ((props: Props) => Promise<meta.Meta> | meta.Meta); |
| 27 | layout?: render.Component; | ||
| 28 | inlineCss: string; | 27 | inlineCss: string; |
| 29 | scripts: Record<string, string>; | 28 | scripts: Record<string, string>; |
| 30 | } | 29 | } |
| ... | @@ -37,7 +36,7 @@ export type Key = keyof ViewMap; | ... | @@ -37,7 +36,7 @@ export type Key = keyof ViewMap; |
| 37 | export async function serve<K extends Key>( | 36 | export async function serve<K extends Key>( |
| 38 | context: hono.Context, | 37 | context: hono.Context, |
| 39 | id: K, | 38 | id: K, |
| 40 | props: PropsFromModule<ViewMap[K]>, | 39 | props: Record<string, unknown>, |
| 41 | ) { | 40 | ) { |
| 42 | return context.html(await renderToString(id, { context, ...props }), { | 41 | return context.html(await renderToString(id, { context, ...props }), { |
| 43 | headers: { | 42 | headers: { |
| ... | @@ -46,21 +45,15 @@ export async function serve<K extends Key>( | ... | @@ -46,21 +45,15 @@ export async function serve<K extends Key>( |
| 46 | }); | 45 | }); |
| 47 | } | 46 | } |
| 48 | 47 | ||
| 49 | type PropsFromModule<M extends any> = M extends { | ||
| 50 | default: (props: infer T) => render.Node; | ||
| 51 | } ? T | ||
| 52 | : never; | ||
| 53 | |||
| 54 | export async function renderToString<K extends Key>( | 48 | export async function renderToString<K extends Key>( |
| 55 | id: K, | 49 | id: K, |
| 56 | attrs: PropsFromModule<ViewMap[K]>, | 50 | attrs: Record<string, unknown>, |
| 57 | ) { | 51 | ) { |
| 58 | const { | 52 | const { |
| 59 | component: Page, | 53 | component: Page, |
| 60 | inlineCss, | 54 | inlineCss, |
| 61 | layout, | ||
| 62 | meta: metadata, | 55 | meta: metadata, |
| 63 | }: View<PropsFromModule<ViewMap[K]>> = UNWRAP( | 56 | }: View<Record<string, unknown>> = UNWRAP( |
| 64 | codegen.views[id], | 57 | codegen.views[id], |
| 65 | `Missing view ${id}`, | 58 | `Missing view ${id}`, |
| 66 | ); | 59 | ); |
| ... | @@ -68,24 +61,14 @@ export async function renderToString<K extends Key>( | ... | @@ -68,24 +61,14 @@ export async function renderToString<K extends Key>( |
| 68 | // -- metadata -- | 61 | // -- metadata -- |
| 69 | const renderedMetaPromise = Promise.resolve( | 62 | const renderedMetaPromise = Promise.resolve( |
| 70 | typeof metadata === "function" ? metadata(attrs) : metadata, | 63 | typeof metadata === "function" ? metadata(attrs) : metadata, |
| 71 | ).then((m) => meta.render(m, codegen.metaTemplate)); | 64 | ).then((m) => meta.toHtml(m, codegen.metaTemplate)); |
| 72 | |||
| 73 | let text; | ||
| 74 | const sitegen = sg.context.init(); | ||
| 75 | if ("render" in Page) { | ||
| 76 | text = await SitegenWrap.render({ | ||
| 77 | cloverScripts: sitegen.scripts, | ||
| 78 | page: Page, | ||
| 79 | attrs, | ||
| 80 | }); | ||
| 81 | } else { | ||
| 82 | let page = render.element(Page, attrs); | ||
| 83 | if (layout?.default) { | ||
| 84 | page = render.element(layout.default, { content: page }); | ||
| 85 | } | ||
| 86 | 65 | ||
| 87 | text = (await render.async(page, [sitegen])).text; | 66 | const cloverScripts = new Set<string>(); |
| 88 | } | 67 | const text = await SitegenWrap.render({ |
| 68 | cloverScripts, | ||
| 69 | page: Page, | ||
| 70 | attrs, | ||
| 71 | }); | ||
| 89 | 72 | ||
| 90 | // -- join document and send -- | 73 | // -- join document and send -- |
| 91 | return sg.wrapDocument({ | 74 | return sg.wrapDocument({ |
| ... | @@ -94,7 +77,7 @@ export async function renderToString<K extends Key>( | ... | @@ -94,7 +77,7 @@ export async function renderToString<K extends Key>( |
| 94 | inlineCss, | 77 | inlineCss, |
| 95 | scripts: sg.joinScripts( | 78 | scripts: sg.joinScripts( |
| 96 | Array.from( | 79 | Array.from( |
| 97 | sitegen.scripts, | 80 | cloverScripts, |
| 98 | (id) => UNWRAP(codegen.scripts[id], `Missing script ${id}`), | 81 | (id) => UNWRAP(codegen.scripts[id], `Missing script ${id}`), |
| 99 | ), | 82 | ), |
| 100 | ), | 83 | ), |
| ... | @@ -118,8 +101,7 @@ export function regenerate(tag: RegenKey) { | ... | @@ -118,8 +101,7 @@ export function regenerate(tag: RegenKey) { |
| 118 | } | 101 | } |
| 119 | 102 | ||
| 120 | import { UNWRAP } from "@clo/lib/assert"; | 103 | import { UNWRAP } from "@clo/lib/assert"; |
| 121 | import * as render from "@clo/lib/render"; | 104 | import * as meta from "@clo/lib/meta"; |
| 122 | import * as meta from "@clo/lib/render/meta"; | ||
| 123 | import type * as hono from "hono"; | 105 | import type * as hono from "hono"; |
| 124 | import type { RegenKey, RegisteredViews as ViewMap } from "../../.clover/ts/view.d.ts"; | 106 | import type { RegenKey, RegisteredViews as ViewMap } from "../../.clover/ts/view.d.ts"; |
| 125 | import SitegenWrap from "../sitegen-wrap.marko"; | 107 | import SitegenWrap from "../sitegen-wrap.marko"; |
lib/deno.json+1-9| ... | @@ -12,12 +12,11 @@ | ... | @@ -12,12 +12,11 @@ |
| 12 | "./log": "./log.ts", | 12 | "./log": "./log.ts", |
| 13 | "./log/stack": "./log/stack.ts", | 13 | "./log/stack": "./log/stack.ts", |
| 14 | "./Lru": "./Lru.ts", | 14 | "./Lru": "./Lru.ts", |
| 15 | "./meta": "./meta.ts", | ||
| 15 | "./mime": "./mime.ts", | 16 | "./mime": "./mime.ts", |
| 16 | "./node": "./node.ts", | 17 | "./node": "./node.ts", |
| 17 | "./progress": "./progress.ts", | 18 | "./progress": "./progress.ts", |
| 18 | "./queue": "./queue.ts", | 19 | "./queue": "./queue.ts", |
| 19 | "./render": "./render.ts", | ||
| 20 | "./render/jsx-runtime": "./render/jsx-runtime.ts", | ||
| 21 | "./render/meta": "./render/meta.ts", | 20 | "./render/meta": "./render/meta.ts", |
| 22 | "./stream": "./stream.ts", | 21 | "./stream": "./stream.ts", |
| 23 | "./string": "./string.ts", | 22 | "./string": "./string.ts", |
| ... | @@ -27,13 +26,6 @@ | ... | @@ -27,13 +26,6 @@ |
| 27 | "./testing": "./testing.ts", | 26 | "./testing": "./testing.ts", |
| 28 | "./ts": "./ts.ts" | 27 | "./ts": "./ts.ts" |
| 29 | }, | 28 | }, |
| 30 | "imports": { | ||
| 31 | "@clo/lib/*": "./*.ts" | ||
| 32 | }, | ||
| 33 | "compilerOptions": { | ||
| 34 | "jsx": "react-jsx", | ||
| 35 | "jsxImportSource": "@clo/lib/render" | ||
| 36 | }, | ||
| 37 | "exclude": [ | 29 | "exclude": [ |
| 38 | "readme.npm.md", | 30 | "readme.npm.md", |
| 39 | "package.json", | 31 | "package.json", |
lib/meta.test.ts created+382| ... | @@ -0,0 +1,382 @@ | ||
| 1 | const base = new URL("https://example.com/"); | ||
| 2 | const T: Template = { base }; | ||
| 3 | |||
| 4 | describe("title", () => { | ||
| 5 | test("is always first entry in meta", () => { | ||
| 6 | const { meta } = toTags({ title: "Hello" }, T); | ||
| 7 | assert.deepEqual(meta[0], { title: "Hello" }); | ||
| 8 | }); | ||
| 9 | |||
| 10 | test("titleTemplate applied", () => { | ||
| 11 | const { meta } = toTags({ title: "Page" }, { base, titleTemplate: (t) => `${t} | Site` }); | ||
| 12 | assert.deepEqual(meta[0], { title: "Page | Site" }); | ||
| 13 | }); | ||
| 14 | |||
| 15 | test("toHtml title is HTML-escaped", () => { | ||
| 16 | const out = toHtml({ title: "<b>Hi</b>" }, T); | ||
| 17 | assert.ok(out.startsWith("<title>&lt;b&gt;Hi&lt;/b&gt;</title>")); | ||
| 18 | }); | ||
| 19 | }); | ||
| 20 | |||
| 21 | describe("basic meta fields", () => { | ||
| 22 | test("multiple authors emit separate tags", () => { | ||
| 23 | const { meta } = toTags({ title: "T", authors: ["Alice", "Bob"] }, T); | ||
| 24 | assert.deepEqual( | ||
| 25 | meta.filter((m) => m.name === "author"), | ||
| 26 | [{ name: "author", content: "Alice" }, { name: "author", content: "Bob" }], | ||
| 27 | ); | ||
| 28 | }); | ||
| 29 | |||
| 30 | test("keywords joined with comma-space", () => { | ||
| 31 | const { meta } = toTags({ title: "T", keywords: ["cats", "dogs", "fish"] }, T); | ||
| 32 | assert.ok(meta.some((m) => m.name === "keywords" && m.content === "cats, dogs, fish")); | ||
| 33 | }); | ||
| 34 | |||
| 35 | test("generator: string used as-is", () => { | ||
| 36 | const { meta } = toTags({ title: "T", generator: "my-tool" }, T); | ||
| 37 | assert.ok(meta.some((m) => m.name === "generator" && m.content === "my-tool")); | ||
| 38 | }); | ||
| 39 | |||
| 40 | test("themeColor string emits single tag without media", () => { | ||
| 41 | const { meta } = toTags({ title: "T", themeColor: "#fff" }, T); | ||
| 42 | const tags = meta.filter((m) => m.name === "theme-color"); | ||
| 43 | assert.equal(tags.length, 1); | ||
| 44 | assert.equal(tags[0]!.content, "#fff"); | ||
| 45 | assert.equal(tags[0]!.media, undefined); | ||
| 46 | }); | ||
| 47 | |||
| 48 | test("themeColor object emits two tags with media queries", () => { | ||
| 49 | const { meta } = toTags({ title: "T", themeColor: { dark: "#111", light: "#eee" } }, T); | ||
| 50 | assert.ok( | ||
| 51 | meta.some((m) => m.name === "theme-color" && m.media === "(prefers-color-scheme:light)" && m.content === "#eee"), | ||
| 52 | ); | ||
| 53 | assert.ok( | ||
| 54 | meta.some((m) => m.name === "theme-color" && m.media === "(prefers-color-scheme:dark)" && m.content === "#111"), | ||
| 55 | ); | ||
| 56 | }); | ||
| 57 | |||
| 58 | test("denyRobots from meta", () => { | ||
| 59 | const { meta } = toTags({ title: "T", denyRobots: true }, T); | ||
| 60 | assert.ok(meta.some((m) => m.name === "robots" && m.content === "noindex,nofollow")); | ||
| 61 | }); | ||
| 62 | |||
| 63 | test("denyRobots from template", () => { | ||
| 64 | const { meta } = toTags({ title: "T" }, { ...T, denyRobots: true }); | ||
| 65 | assert.ok(meta.some((m) => m.name === "robots" && m.content === "noindex,nofollow")); | ||
| 66 | }); | ||
| 67 | |||
| 68 | test("denyRobots: false on meta does not override template true", () => { | ||
| 69 | // null/undefined opts out; false is falsy so it also doesn't block | ||
| 70 | const { meta } = toTags({ title: "T", denyRobots: false }, { ...T, denyRobots: true }); | ||
| 71 | assert.ok(meta.some((m) => m.name === "robots")); | ||
| 72 | }); | ||
| 73 | |||
| 74 | test("viewport defaults to mobile-friendly value", () => { | ||
| 75 | const { meta } = toTags({ title: "T" }, T); | ||
| 76 | assert.ok( | ||
| 77 | meta.some((m) => m.name === "viewport" && m.content === "width=device-width, initial-scale=1, maximum-scale=1"), | ||
| 78 | ); | ||
| 79 | }); | ||
| 80 | |||
| 81 | test("viewport can be overridden", () => { | ||
| 82 | const { meta } = toTags({ title: "T", viewport: "width=device-width" }, T); | ||
| 83 | assert.ok(meta.some((m) => m.name === "viewport" && m.content === "width=device-width")); | ||
| 84 | }); | ||
| 85 | }); | ||
| 86 | |||
| 87 | describe("links", () => { | ||
| 88 | test("canonical resolved against base", () => { | ||
| 89 | const { links } = toTags({ title: "T", canonical: "/page" }, T); | ||
| 90 | assert.ok(links.some((l) => l.rel === "canonical" && l.href === "https://example.com/page")); | ||
| 91 | }); | ||
| 92 | |||
| 93 | test("canonical absolute URL kept as-is", () => { | ||
| 94 | const { links } = toTags({ title: "T", canonical: "https://other.com/x" }, T); | ||
| 95 | assert.ok(links.some((l) => l.rel === "canonical" && l.href === "https://other.com/x")); | ||
| 96 | }); | ||
| 97 | |||
| 98 | test("manifest href not resolved", () => { | ||
| 99 | const { links } = toTags({ title: "T", manifest: "/manifest.json" }, T); | ||
| 100 | assert.ok(links.some((l) => l.rel === "manifest" && l.href === "/manifest.json")); | ||
| 101 | }); | ||
| 102 | }); | ||
| 103 | |||
| 104 | describe("oEmbed", () => { | ||
| 105 | test("emits alternate link with json+oembed type, URL resolved", () => { | ||
| 106 | const { links } = toTags({ title: "T", oEmbed: "/oembed.json" }, T); | ||
| 107 | assert.ok(links.some((l) => | ||
| 108 | l.rel === "alternate" | ||
| 109 | && l.type === "application/json+oembed" | ||
| 110 | && l.href === "https://example.com/oembed.json" | ||
| 111 | )); | ||
| 112 | }); | ||
| 113 | |||
| 114 | test("falls back to template.oEmbed", () => { | ||
| 115 | const { links } = toTags({ title: "T" }, { ...T, oEmbed: "/oembed.json" }); | ||
| 116 | assert.ok(links.some((l) => l.type === "application/json+oembed")); | ||
| 117 | }); | ||
| 118 | }); | ||
| 119 | |||
| 120 | describe("alternates", () => { | ||
| 121 | test("array form with lang → hreflang link", () => { | ||
| 122 | const { links } = toTags({ title: "T", alternates: [{ lang: "fr", url: "/fr" }] }, T); | ||
| 123 | assert.ok(links.some((l) => l.rel === "alternate" && l.hreflang === "fr" && l.href === "https://example.com/fr")); | ||
| 124 | }); | ||
| 125 | |||
| 126 | test("array form with type → type link", () => { | ||
| 127 | const { links } = toTags({ title: "T", alternates: [{ type: "application/rss+xml", url: "/feed.rss" }] }, T); | ||
| 128 | assert.ok(links.some((l) => l.rel === "alternate" && l.type === "application/rss+xml")); | ||
| 129 | }); | ||
| 130 | |||
| 131 | test("record form: key with slash → type", () => { | ||
| 132 | const { links } = toTags({ title: "T", alternates: { "application/atom+xml": "/atom.xml" } }, T); | ||
| 133 | assert.ok(links.some((l) => l.type === "application/atom+xml" && !l.hreflang)); | ||
| 134 | }); | ||
| 135 | |||
| 136 | test("record form: key without slash → hreflang", () => { | ||
| 137 | const { links } = toTags({ title: "T", alternates: { "ja": "/ja/" } }, T); | ||
| 138 | assert.ok(links.some((l) => l.hreflang === "ja" && !l.type)); | ||
| 139 | }); | ||
| 140 | |||
| 141 | test("alternate URLs resolved against base", () => { | ||
| 142 | const { links } = toTags({ title: "T", alternates: [{ lang: "de", url: "/de" }] }, T); | ||
| 143 | assert.ok(links.some((l) => l.href === "https://example.com/de")); | ||
| 144 | }); | ||
| 145 | |||
| 146 | test("falls back to template.alternates", () => { | ||
| 147 | const { links } = toTags({ title: "T" }, { ...T, alternates: [{ lang: "en", url: "/" }] }); | ||
| 148 | assert.ok(links.some((l) => l.hreflang === "en")); | ||
| 149 | }); | ||
| 150 | }); | ||
| 151 | |||
| 152 | describe("embed", () => { | ||
| 153 | test("no thumbnail → twitter:card = summary", () => { | ||
| 154 | const { meta } = toTags({ title: "T", embed: {} }, T); | ||
| 155 | assert.ok(meta.some((m) => m.name === "twitter:card" && m.content === "summary")); | ||
| 156 | }); | ||
| 157 | |||
| 158 | test("thumbnail → twitter:card = summary_large_image", () => { | ||
| 159 | const { meta } = toTags({ title: "T", embed: { thumbnail: "/img.png" } }, T); | ||
| 160 | assert.ok(meta.some((m) => m.name === "twitter:card" && m.content === "summary_large_image")); | ||
| 161 | }); | ||
| 162 | |||
| 163 | test("thumbnailSize: icon overrides thumbnail default", () => { | ||
| 164 | const { meta } = toTags({ title: "T", embed: { thumbnail: "/img.png", thumbnailSize: "icon" } }, T); | ||
| 165 | assert.ok(meta.some((m) => m.name === "twitter:card" && m.content === "summary")); | ||
| 166 | }); | ||
| 167 | |||
| 168 | test("thumbnail resolved and applied to both og:image and twitter:image", () => { | ||
| 169 | const { meta } = toTags({ title: "T", embed: { thumbnail: "/img.png" } }, T); | ||
| 170 | assert.ok(meta.some((m) => m.name === "og:image" && m.content === "https://example.com/img.png")); | ||
| 171 | assert.ok(meta.some((m) => m.name === "twitter:image" && m.content === "https://example.com/img.png")); | ||
| 172 | }); | ||
| 173 | |||
| 174 | test("embed title falls back to page title for og", () => { | ||
| 175 | const { meta } = toTags({ title: "My Page", embed: {} }, T); | ||
| 176 | assert.ok(meta.some((m) => m.name === "og:title" && m.content === "My Page")); | ||
| 177 | }); | ||
| 178 | |||
| 179 | test("embed title overrides page title for og", () => { | ||
| 180 | const { meta } = toTags({ title: "My Page", embed: { title: "Share Title" } }, T); | ||
| 181 | assert.ok(meta.some((m) => m.name === "og:title" && m.content === "Share Title")); | ||
| 182 | }); | ||
| 183 | |||
| 184 | test("embed description falls back to page description", () => { | ||
| 185 | const { meta } = toTags({ title: "T", description: "page desc", embed: {} }, T); | ||
| 186 | assert.ok(meta.some((m) => m.name === "og:description" && m.content === "page desc")); | ||
| 187 | }); | ||
| 188 | |||
| 189 | test("siteTitle sets og:site_name", () => { | ||
| 190 | const { meta } = toTags({ title: "T", embed: { siteTitle: "My Site" } }, T); | ||
| 191 | assert.ok(meta.some((m) => m.name === "og:site_name" && m.content === "My Site")); | ||
| 192 | }); | ||
| 193 | |||
| 194 | test("canonical sets og:url when embed active", () => { | ||
| 195 | const { meta } = toTags({ title: "T", canonical: "/page", embed: {} }, T); | ||
| 196 | assert.ok(meta.some((m) => m.name === "og:url" && m.content === "https://example.com/page")); | ||
| 197 | }); | ||
| 198 | |||
| 199 | test("no canonical → no og:url injected by embed", () => { | ||
| 200 | const { meta } = toTags({ title: "T", embed: {} }, T); | ||
| 201 | assert.ok(!meta.some((m) => m.name === "og:url")); | ||
| 202 | }); | ||
| 203 | |||
| 204 | test("explicit openGraph fields override embed defaults", () => { | ||
| 205 | const { meta } = toTags({ title: "T", embed: { title: "Embed" }, openGraph: { title: "Explicit" } }, T); | ||
| 206 | assert.ok(meta.some((m) => m.name === "og:title" && m.content === "Explicit")); | ||
| 207 | assert.equal(meta.filter((m) => m.name === "og:title").length, 1); | ||
| 208 | }); | ||
| 209 | |||
| 210 | test("embed sets og:type = website", () => { | ||
| 211 | const { meta } = toTags({ title: "T", embed: {} }, T); | ||
| 212 | assert.ok(meta.some((m) => m.name === "og:type" && m.content === "website")); | ||
| 213 | }); | ||
| 214 | }); | ||
| 215 | |||
| 216 | describe("openGraph nested/array fields", () => { | ||
| 217 | test("nested object emits namespaced tags", () => { | ||
| 218 | const { meta } = toTags( | ||
| 219 | { title: "T", openGraph: { image: { url: "https://example.com/img.png", width: "800" } } }, | ||
| 220 | T, | ||
| 221 | ); | ||
| 222 | assert.ok(meta.some((m) => m.name === "og:image:url" && m.content === "https://example.com/img.png")); | ||
| 223 | assert.ok(meta.some((m) => m.name === "og:image:width" && m.content === "800")); | ||
| 224 | }); | ||
| 225 | |||
| 226 | test("array value emits duplicate tags", () => { | ||
| 227 | const { meta } = toTags({ title: "T", openGraph: { image: ["https://a.com/1.png", "https://a.com/2.png"] } }, T); | ||
| 228 | assert.equal(meta.filter((m) => m.name === "og:image").length, 2); | ||
| 229 | }); | ||
| 230 | |||
| 231 | test("null field is skipped", () => { | ||
| 232 | const { meta } = toTags({ title: "T", openGraph: { image: null } }, T); | ||
| 233 | assert.ok(!meta.some((m) => m.name === "og:image")); | ||
| 234 | }); | ||
| 235 | }); | ||
| 236 | |||
| 237 | describe("template fallbacks", () => { | ||
| 238 | test("template fields used when meta omits them", () => { | ||
| 239 | const template: Template = { base, publisher: "Acme", keywords: ["a", "b"] }; | ||
| 240 | const { meta } = toTags({ title: "T" }, template); | ||
| 241 | assert.ok(meta.some((m) => m.name === "publisher" && m.content === "Acme")); | ||
| 242 | assert.ok(meta.some((m) => m.name === "keywords" && m.content === "a, b")); | ||
| 243 | }); | ||
| 244 | |||
| 245 | test("meta field overrides template, no duplicate", () => { | ||
| 246 | const template: Template = { base, publisher: "Acme" }; | ||
| 247 | const { meta } = toTags({ title: "T", publisher: "Override" }, template); | ||
| 248 | assert.ok(meta.some((m) => m.name === "publisher" && m.content === "Override")); | ||
| 249 | assert.equal(meta.filter((m) => m.name === "publisher").length, 1); | ||
| 250 | }); | ||
| 251 | }); | ||
| 252 | |||
| 253 | describe("toHtml serialization", () => { | ||
| 254 | test("attribute values with spaces are quoted", () => { | ||
| 255 | const out = toHtml({ title: "T", description: "hello world" }, T); | ||
| 256 | assert.ok(out.includes(`content="hello world"`)); | ||
| 257 | }); | ||
| 258 | |||
| 259 | test("attribute values without special chars are unquoted", () => { | ||
| 260 | const out = toHtml({ title: "T", publisher: "Acme" }, T); | ||
| 261 | assert.ok(out.includes(`content=Acme`)); | ||
| 262 | }); | ||
| 263 | |||
| 264 | test("attribute value with slash is quoted", () => { | ||
| 265 | const out = toHtml({ title: "T" }, T); | ||
| 266 | // viewport contains / — ensure it's quoted | ||
| 267 | assert.ok(out.includes(`content="width=device-width`)); | ||
| 268 | }); | ||
| 269 | |||
| 270 | test("description with double quotes is escaped", () => { | ||
| 271 | const out = toHtml({ title: "T", description: `say "hi"` }, T); | ||
| 272 | assert.ok(!out.includes(`"say "hi"`)); | ||
| 273 | }); | ||
| 274 | }); | ||
| 275 | |||
| 276 | describe("scripts", () => { | ||
| 277 | test("inline script emits children in scripts array", () => { | ||
| 278 | const { scripts } = toTags({ title: "T", scripts: [{ content: "alert(1)" }] }, T); | ||
| 279 | assert.deepEqual(scripts, [{ children: "alert(1)" }]); | ||
| 280 | }); | ||
| 281 | |||
| 282 | test("external script with src and boolean attrs", () => { | ||
| 283 | const { scripts } = toTags({ title: "T", scripts: [{ src: "/app.js", async: true, defer: false }] }, T); | ||
| 284 | assert.equal(scripts.length, 1); | ||
| 285 | assert.equal(scripts[0].src, "/app.js"); | ||
| 286 | assert.equal(scripts[0].async, true); | ||
| 287 | assert.equal(scripts[0].defer, undefined); | ||
| 288 | }); | ||
| 289 | |||
| 290 | test("toHtml renders inline script", () => { | ||
| 291 | const out = toHtml({ title: "T", scripts: [{ content: "console.log(1)" }] }, T); | ||
| 292 | assert.ok(out.includes("<script>console.log(1)</script>")); | ||
| 293 | }); | ||
| 294 | |||
| 295 | test("toHtml renders external script with boolean attr", () => { | ||
| 296 | const out = toHtml({ title: "T", scripts: [{ src: "/app.js", async: true }] }, T); | ||
| 297 | assert.ok(out.includes("<script src=")); | ||
| 298 | assert.ok(out.includes(" async></script>")); | ||
| 299 | }); | ||
| 300 | |||
| 301 | test("falls back to template scripts", () => { | ||
| 302 | const { scripts } = toTags({ title: "T" }, { ...T, scripts: [{ content: "init()" }] }); | ||
| 303 | assert.deepEqual(scripts, [{ children: "init()" }]); | ||
| 304 | }); | ||
| 305 | }); | ||
| 306 | |||
| 307 | describe("styles", () => { | ||
| 308 | test("inline style emits children in styles array", () => { | ||
| 309 | const { styles } = toTags({ title: "T", styles: [{ content: "body{margin:0}" }] }, T); | ||
| 310 | assert.deepEqual(styles, [{ children: "body{margin:0}" }]); | ||
| 311 | }); | ||
| 312 | |||
| 313 | test("inline style with media", () => { | ||
| 314 | const { styles } = toTags({ title: "T", styles: [{ content: "p{color:blue}", media: "print" }] }, T); | ||
| 315 | assert.deepEqual(styles, [{ children: "p{color:blue}", media: "print" }]); | ||
| 316 | }); | ||
| 317 | |||
| 318 | test("external stylesheet goes into links", () => { | ||
| 319 | const { links, styles } = toTags({ title: "T", styles: [{ href: "/style.css" }] }, T); | ||
| 320 | assert.ok(links.some((l) => l.rel === "stylesheet" && l.href === "/style.css")); | ||
| 321 | assert.equal(styles.length, 0); | ||
| 322 | }); | ||
| 323 | |||
| 324 | test("external stylesheet with media", () => { | ||
| 325 | const { links } = toTags({ title: "T", styles: [{ href: "/print.css", media: "print" }] }, T); | ||
| 326 | assert.ok(links.some((l) => l.rel === "stylesheet" && l.href === "/print.css" && l.media === "print")); | ||
| 327 | }); | ||
| 328 | |||
| 329 | test("toHtml renders inline style", () => { | ||
| 330 | const out = toHtml({ title: "T", styles: [{ content: "body{margin:0}" }] }, T); | ||
| 331 | assert.ok(out.includes("<style>body{margin:0}</style>")); | ||
| 332 | }); | ||
| 333 | |||
| 334 | test("toHtml renders inline style with media", () => { | ||
| 335 | const out = toHtml({ title: "T", styles: [{ content: "p{color:blue}", media: "print" }] }, T); | ||
| 336 | assert.ok(out.includes("<style media=print>p{color:blue}</style>")); | ||
| 337 | }); | ||
| 338 | |||
| 339 | test("falls back to template styles", () => { | ||
| 340 | const { styles } = toTags({ title: "T" }, { ...T, styles: [{ content: "h1{color:red}" }] }); | ||
| 341 | assert.deepEqual(styles, [{ children: "h1{color:red}" }]); | ||
| 342 | }); | ||
| 343 | }); | ||
| 344 | |||
| 345 | describe("extra", () => { | ||
| 346 | test("tag=meta goes into meta array", () => { | ||
| 347 | const { meta } = toTags({ title: "T", extra: [{ tag: "meta", name: "verify", content: "abc" }] }, T); | ||
| 348 | assert.ok(meta.some((m) => m.name === "verify" && m.content === "abc")); | ||
| 349 | }); | ||
| 350 | |||
| 351 | test("tag=link goes into links array", () => { | ||
| 352 | const { links } = toTags({ title: "T", extra: [{ tag: "link", rel: "icon", href: "/favicon.ico" }] }, T); | ||
| 353 | assert.ok(links.some((l) => l.rel === "icon" && l.href === "/favicon.ico")); | ||
| 354 | }); | ||
| 355 | |||
| 356 | test("tag=script goes into scripts array", () => { | ||
| 357 | const { scripts } = toTags({ title: "T", extra: [{ tag: "script", src: "/extra.js", async: true }] }, T); | ||
| 358 | assert.equal(scripts.length, 1); | ||
| 359 | assert.equal(scripts[0].src, "/extra.js"); | ||
| 360 | assert.equal(scripts[0].async, true); | ||
| 361 | }); | ||
| 362 | |||
| 363 | test("tag=style goes into styles array", () => { | ||
| 364 | const { styles } = toTags({ title: "T", extra: [{ tag: "style", children: "a{color:red}" }] }, T); | ||
| 365 | assert.deepEqual(styles, [{ children: "a{color:red}" }]); | ||
| 366 | }); | ||
| 367 | |||
| 368 | test("null/false attrs are filtered out", () => { | ||
| 369 | const { meta } = toTags({ title: "T", extra: [{ tag: "meta", name: "x", content: "y", bogus: null }] }, T); | ||
| 370 | const entry = meta.find((m) => m.name === "x")!; | ||
| 371 | assert.equal(entry.bogus, undefined); | ||
| 372 | }); | ||
| 373 | |||
| 374 | test("falls back to template extra", () => { | ||
| 375 | const { meta } = toTags({ title: "T" }, { ...T, extra: [{ tag: "meta", name: "tmpl", content: "yes" }] }); | ||
| 376 | assert.ok(meta.some((m) => m.name === "tmpl" && m.content === "yes")); | ||
| 377 | }); | ||
| 378 | }); | ||
| 379 | |||
| 380 | import assert from "node:assert"; | ||
| 381 | import { describe, test } from "vitest"; | ||
| 382 | import { type Template, toHtml, toTags } from "./meta.ts"; | ||
lib/meta.ts created+345| ... | @@ -0,0 +1,345 @@ | ||
| 1 | export interface Meta { | ||
| 2 | /** required for all pages. `<title>{content}</title>` */ | ||
| 3 | title: string; | ||
| 4 | /** recommended for all pages. `<meta name="description" content="{...}" />` */ | ||
| 5 | description?: string | null; | ||
| 6 | /** automatically added for static renders from the 'pages' folders. */ | ||
| 7 | canonical?: string | null; | ||
| 8 | /** add `<link rel="alternate" ... />`. Object keys are interpretted as | ||
| 9 | * mime types if they contain a slash, otherwise seen as an alternative language. */ | ||
| 10 | alternates?: Alternate[] | Record<string, string>; | ||
| 11 | |||
| 12 | /** automatically generate both OpenGraph and Twitter meta tags */ | ||
| 13 | embed?: AutoEmbed | null; | ||
| 14 | /** add a robots tag for `noindex` and `nofollow` */ | ||
| 15 | denyRobots?: boolean | null; | ||
| 16 | /** add 'og:*' meta tags */ | ||
| 17 | openGraph?: OpenGraph | null; | ||
| 18 | /** add 'twitter:*' meta tags */ | ||
| 19 | twitter?: Twitter | null; | ||
| 20 | /** refer to an oEmbed file. See https://oembed.com */ | ||
| 21 | oEmbed?: string; | ||
| 22 | /** add arbitrary scripts to the document head */ | ||
| 23 | scripts?: Array<{ content: string } | ExternalScript>; | ||
| 24 | /** add arbitrary styles to the document head */ | ||
| 25 | styles?: Array<{ content: string } | ExternalStylesheet>; | ||
| 26 | /** | ||
| 27 | * '@clo/lib/meta' intentionally excludes a lot of exotic tags. | ||
| 28 | * Use `{ tag, ...attrs }` to add arbitrary head elements: | ||
| 29 | * | ||
| 30 | * ```ts | ||
| 31 | * extra: [ | ||
| 32 | * { tag: "meta", name: "site-verification", content: "waffles" }, | ||
| 33 | * { tag: "link", rel: "icon", href: "/favicon.ico" }, | ||
| 34 | * ], | ||
| 35 | * ``` | ||
| 36 | */ | ||
| 37 | extra?: Array<ExtraItem>; | ||
| 38 | |||
| 39 | /** adds `<meta name="author" content="{...}" />` */ | ||
| 40 | authors?: string[]; | ||
| 41 | /** credit your framework or toolchain */ | ||
| 42 | generator?: string | null; | ||
| 43 | /** adds `<meta name="keywords" content="{keywords.join(', ')}" />` */ | ||
| 44 | keywords?: string[]; | ||
| 45 | /** URL to a manifest; https://developer.mozilla.org/en-US/docs/Web/Manifest */ | ||
| 46 | manifest?: string | null; | ||
| 47 | /** adds `<meta name="publisher" content="{...}" />` */ | ||
| 48 | publisher?: string | null; | ||
| 49 | /** https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/meta/name/referrer */ | ||
| 50 | referrer?: Referrer | null; | ||
| 51 | /** adds `<meta name="theme-color" content="{...}" />` */ | ||
| 52 | themeColor?: string | { dark: string; light: string } | null; | ||
| 53 | /** defaults to `width=device-width, initial-scale=1` for mobile compatibility. */ | ||
| 54 | viewport?: string; | ||
| 55 | } | ||
| 56 | export interface ExternalStylesheet { | ||
| 57 | rel?: "stylesheet"; | ||
| 58 | href: string; | ||
| 59 | media?: string; | ||
| 60 | [attr: string]: string | boolean | null | undefined; | ||
| 61 | // TODO: add the rest of the important attributes for autocomplete | ||
| 62 | } | ||
| 63 | export interface ExternalScript { | ||
| 64 | src: string; | ||
| 65 | async?: boolean; | ||
| 66 | defer?: boolean; | ||
| 67 | crossorigin?: "anonymous" | "use-credentials" | false; | ||
| 68 | [attr: string]: string | boolean | null | undefined; | ||
| 69 | // TODO: add the rest of the attributes for autocomplete | ||
| 70 | } | ||
| 71 | export type Alternate = { type: string; url: string } | { | ||
| 72 | lang: string; | ||
| 73 | url: string; | ||
| 74 | }; | ||
| 75 | export interface AutoEmbed { | ||
| 76 | /* defaults to the top level page title. */ | ||
| 77 | title?: string | null; | ||
| 78 | /* defaults to the top level page description. */ | ||
| 79 | description?: string | null; | ||
| 80 | /* add an image to the embed card. */ | ||
| 81 | thumbnail?: string | null; | ||
| 82 | /** @default "banner", which applies twitter:card = "summary_large_image" */ | ||
| 83 | thumbnailSize?: "banner" | "icon"; | ||
| 84 | /** @default null - no site title */ | ||
| 85 | siteTitle?: string | null; | ||
| 86 | } | ||
| 87 | /** See https://ogp.me for extra rules. */ | ||
| 88 | export interface OpenGraph { | ||
| 89 | /** the title of your object as it should appear within the graph */ | ||
| 90 | title?: string; | ||
| 91 | /** a one to two sentence description of your object. */ | ||
| 92 | description?: string | null; | ||
| 93 | /** the type of your object, e.g., "video.movie". Depending on the type you specify, other properties may also be required */ | ||
| 94 | type?: string; | ||
| 95 | /** an image URL which should represent your object within the graph */ | ||
| 96 | image?: OpenGraphField; | ||
| 97 | /** the canonical URL of your object that will be used as its permanent ID in the graph, e.g., "https://www.imdb.com/title/tt0117500/" */ | ||
| 98 | url?: string; | ||
| 99 | /** a URL to an audio file to accompany this object */ | ||
| 100 | audio?: OpenGraphField; | ||
| 101 | /** the word that appears before this object's title in a sentence. An enum of (a, an, the, "", auto). if auto is chosen, the consumer of your data should choose between "a" or "an". Default is "" (blank) */ | ||
| 102 | determiner?: string; | ||
| 103 | /** the locale these tags are marked up in. Of the format language_TERRITORY. Default is en_US */ | ||
| 104 | locale?: string; | ||
| 105 | /** an array of other locales this page is available in */ | ||
| 106 | "locale:alternate"?: string[]; | ||
| 107 | /** if your object is part of a larger web site, the name which should be displayed for the overall site. e.g., "IMDb" */ | ||
| 108 | site_name?: string; | ||
| 109 | /** a URL to a video file that complements this object */ | ||
| 110 | video?: OpenGraphField; | ||
| 111 | [field: string]: OpenGraphField; | ||
| 112 | } | ||
| 113 | /** | ||
| 114 | * when passing an array, the property is duplicated. | ||
| 115 | * when passing an object, the fields are emitted as namespaced with ':'. | ||
| 116 | */ | ||
| 117 | type OpenGraphField = | ||
| 118 | | string | ||
| 119 | | { [field: string]: OpenGraphField } | ||
| 120 | | Array<OpenGraphField> | ||
| 121 | | (null | undefined); | ||
| 122 | /** Twitter uses various OpenGraph fields if these are not specified. */ | ||
| 123 | export interface Twitter { | ||
| 124 | card: string; | ||
| 125 | title?: string; | ||
| 126 | description?: string | null; | ||
| 127 | url?: string; | ||
| 128 | image?: string; | ||
| 129 | player?: string; | ||
| 130 | /** Same logic as Open Graph */ | ||
| 131 | [field: string]: OpenGraphField; | ||
| 132 | } | ||
| 133 | export interface Template extends Omit<Meta, "title" | "description" | "canonical"> { | ||
| 134 | base: URL; | ||
| 135 | titleTemplate?: (title: string) => string; | ||
| 136 | } | ||
| 137 | export type Referrer = | ||
| 138 | | "no-referrer" | ||
| 139 | | "origin" | ||
| 140 | | "no-referrer-when-downgrade" | ||
| 141 | | "origin-when-cross-origin" | ||
| 142 | | "same-origin" | ||
| 143 | | "strict-origin" | ||
| 144 | | "strict-origin-when-cross-origin"; | ||
| 145 | export type ExtraItem = { | ||
| 146 | tag: "meta" | "link" | "script" | "style"; | ||
| 147 | [attr: string]: string | boolean | null | undefined; | ||
| 148 | }; | ||
| 149 | export interface Tags { | ||
| 150 | meta: Array<Record<string, string>>; | ||
| 151 | links: Array<Record<string, string>>; | ||
| 152 | scripts: Array<Record<string, string | boolean>>; | ||
| 153 | styles: Array<Record<string, string>>; | ||
| 154 | } | ||
| 155 | |||
| 156 | // TODO: before publishing clover lib v5, ensure this is actually compatibile with TanStack Router | ||
| 157 | // this type error will prevent publishing the library | ||
| 158 | void (NaN as string); | ||
| 159 | |||
| 160 | /** convert a metadata definition into a structured list of tags, compatible with TanStack start */ | ||
| 161 | export function toTags(meta: Meta, template: Template): Tags { | ||
| 162 | const { titleTemplate, base } = template; | ||
| 163 | const resolve = (str: string) => new URL(str, base).href; | ||
| 164 | |||
| 165 | const title = titleTemplate ? titleTemplate(meta.title) : meta.title; | ||
| 166 | const description = meta.description ?? null; | ||
| 167 | const canonical = meta.canonical ? resolve(meta.canonical) : null; | ||
| 168 | const denyRobots = Boolean(meta.denyRobots || template.denyRobots); | ||
| 169 | const authors = meta.authors ?? template.authors ?? null; | ||
| 170 | let generator = meta.generator ?? template.generator ?? null; | ||
| 171 | const keywords = meta.keywords ?? template.keywords ?? null; | ||
| 172 | const manifest = meta.manifest ?? template.manifest ?? null; | ||
| 173 | const publisher = meta.publisher ?? template.publisher ?? null; | ||
| 174 | const referrer = meta.referrer ?? template.referrer ?? null; | ||
| 175 | const themeColor = meta.themeColor ?? template.themeColor ?? null; | ||
| 176 | const viewport = meta.viewport ?? template.viewport | ||
| 177 | ?? "width=device-width, initial-scale=1, maximum-scale=1"; | ||
| 178 | |||
| 179 | const alternates = meta.alternates ?? template.alternates ?? null; | ||
| 180 | const oEmbed = meta.oEmbed ?? template.oEmbed ?? null; | ||
| 181 | const embed = meta.embed ?? template.embed ?? null; | ||
| 182 | let openGraph = meta.openGraph ?? template.openGraph ?? null; | ||
| 183 | let twitter = meta.twitter ?? template.twitter ?? null; | ||
| 184 | if (embed) { | ||
| 185 | const { thumbnail, thumbnailSize, siteTitle } = embed; | ||
| 186 | openGraph = { | ||
| 187 | type: "website", | ||
| 188 | title: embed.title ?? title, | ||
| 189 | description: embed.description ?? description, | ||
| 190 | ...openGraph, | ||
| 191 | }; | ||
| 192 | twitter = { | ||
| 193 | card: (thumbnailSize ?? (thumbnail ? "banner" : "icon")) === "banner" | ||
| 194 | ? "summary_large_image" | ||
| 195 | : "summary", | ||
| 196 | ...twitter, | ||
| 197 | }; | ||
| 198 | if (thumbnail) { | ||
| 199 | const resolved = new URL(thumbnail, template.base).href; | ||
| 200 | openGraph.image = resolved; | ||
| 201 | twitter.image = resolved; | ||
| 202 | } | ||
| 203 | if (siteTitle) { | ||
| 204 | openGraph.site_name = siteTitle; | ||
| 205 | } | ||
| 206 | if (canonical) { | ||
| 207 | openGraph.url = canonical; | ||
| 208 | } | ||
| 209 | } | ||
| 210 | |||
| 211 | const metaTags: Array<Record<string, string>> = [{ title }]; | ||
| 212 | const links: Array<Record<string, string>> = []; | ||
| 213 | |||
| 214 | if (description) metaTags.push({ name: "description", content: description }); | ||
| 215 | for (const author of authors ?? []) metaTags.push({ name: "author", content: author }); | ||
| 216 | if (keywords) metaTags.push({ name: "keywords", content: keywords.join(", ") }); | ||
| 217 | if (generator) { | ||
| 218 | metaTags.push({ name: "generator", content: generator }); | ||
| 219 | } | ||
| 220 | if (publisher) metaTags.push({ name: "publisher", content: publisher }); | ||
| 221 | if (referrer) metaTags.push({ name: "referrer", content: referrer }); | ||
| 222 | if (themeColor) { | ||
| 223 | if (typeof themeColor === "string") { | ||
| 224 | metaTags.push({ name: "theme-color", content: themeColor }); | ||
| 225 | } else { | ||
| 226 | metaTags.push({ name: "theme-color", media: "(prefers-color-scheme:light)", content: themeColor.light }); | ||
| 227 | metaTags.push({ name: "theme-color", media: "(prefers-color-scheme:dark)", content: themeColor.dark }); | ||
| 228 | } | ||
| 229 | } | ||
| 230 | if (denyRobots) metaTags.push({ name: "robots", content: "noindex,nofollow" }); | ||
| 231 | if (viewport) metaTags.push({ name: "viewport", content: viewport }); | ||
| 232 | if (canonical) links.push({ rel: "canonical", href: canonical }); | ||
| 233 | if (manifest) links.push({ rel: "manifest", href: manifest }); | ||
| 234 | if (oEmbed) links.push({ rel: "alternate", type: "application/json+oembed", href: resolve(oEmbed) }); | ||
| 235 | if (alternates) { | ||
| 236 | const items = Array.isArray(alternates) | ||
| 237 | ? alternates | ||
| 238 | : Object.entries(alternates).map(([key, url]) => key.includes("/") ? { type: key, url } : { lang: key, url }); | ||
| 239 | for (const alt of items) { | ||
| 240 | const href = resolve(alt.url); | ||
| 241 | links.push( | ||
| 242 | "lang" in alt ? { rel: "alternate", hreflang: alt.lang, href } : { rel: "alternate", type: alt.type, href }, | ||
| 243 | ); | ||
| 244 | } | ||
| 245 | } | ||
| 246 | |||
| 247 | if (openGraph) metaTags.push(...collectOpenGraph("og", openGraph)); | ||
| 248 | if (twitter) metaTags.push(...collectOpenGraph("twitter", twitter)); | ||
| 249 | |||
| 250 | const scriptTags: Array<Record<string, string | boolean>> = []; | ||
| 251 | const styleTags: Array<Record<string, string>> = []; | ||
| 252 | |||
| 253 | for (const entry of meta.scripts ?? template.scripts ?? []) { | ||
| 254 | if ("content" in entry) { | ||
| 255 | scriptTags.push({ children: entry.content }); | ||
| 256 | } else { | ||
| 257 | const { src, ...rest } = entry; | ||
| 258 | const tag: Record<string, string | boolean> = { src }; | ||
| 259 | for (const [k, v] of Object.entries(rest)) { | ||
| 260 | if (v != null && v !== false) tag[k] = v; | ||
| 261 | } | ||
| 262 | scriptTags.push(tag); | ||
| 263 | } | ||
| 264 | } | ||
| 265 | |||
| 266 | for (const entry of meta.styles ?? template.styles ?? []) { | ||
| 267 | if ("content" in entry) { | ||
| 268 | const tag: Record<string, string> = { children: entry.content }; | ||
| 269 | if ("media" in entry && entry.media) tag.media = entry.media; | ||
| 270 | styleTags.push(tag); | ||
| 271 | } else { | ||
| 272 | const { href, ...rest } = entry; | ||
| 273 | const tag: Record<string, string> = { rel: "stylesheet", href }; | ||
| 274 | for (const [k, v] of Object.entries(rest)) { | ||
| 275 | if (v != null && v !== false && k !== "rel") tag[k] = v as string; | ||
| 276 | } | ||
| 277 | links.push(tag); | ||
| 278 | } | ||
| 279 | } | ||
| 280 | |||
| 281 | for (const { tag, ...attrs } of meta.extra ?? template.extra ?? []) { | ||
| 282 | const clean: Record<string, string | boolean> = {}; | ||
| 283 | for (const [k, v] of Object.entries(attrs)) { | ||
| 284 | if (v != null && v !== false) clean[k] = v; | ||
| 285 | } | ||
| 286 | if (tag === "meta") metaTags.push(clean as Record<string, string>); | ||
| 287 | else if (tag === "link") links.push(clean as Record<string, string>); | ||
| 288 | else if (tag === "script") scriptTags.push(clean); | ||
| 289 | else if (tag === "style") styleTags.push(clean as Record<string, string>); | ||
| 290 | } | ||
| 291 | |||
| 292 | return { meta: metaTags, links, scripts: scriptTags, styles: styleTags }; | ||
| 293 | } | ||
| 294 | |||
| 295 | /* convert a metadata definition into HTML text. */ | ||
| 296 | export function toHtml(meta: Meta, template: Template): string { | ||
| 297 | const tags = toTags(meta, template); | ||
| 298 | const [{ title }, ...metaTags] = tags.meta as [{ title: string }, ...Record<string, string>[]]; | ||
| 299 | let out = `<title>${esc(title)}</title>`; | ||
| 300 | for (const attrs of metaTags) { | ||
| 301 | out += `<meta` + Object.entries(attrs).map(([k, v]) => ` ${k}=${attr(v)}`).join("") + `>`; | ||
| 302 | } | ||
| 303 | for (const attrs of tags.links) { | ||
| 304 | out += `<link` + Object.entries(attrs).map(([k, v]) => ` ${k}=${attr(v)}`).join("") + `>`; | ||
| 305 | } | ||
| 306 | for (const entry of tags.scripts) { | ||
| 307 | if ("children" in entry) { | ||
| 308 | out += `<script>${entry.children}</script>`; | ||
| 309 | } else { | ||
| 310 | out += `<script` + serializeAttrs(entry) + `></script>`; | ||
| 311 | } | ||
| 312 | } | ||
| 313 | for (const entry of tags.styles) { | ||
| 314 | if ("children" in entry) { | ||
| 315 | const { children, ...rest } = entry; | ||
| 316 | out += `<style` + serializeAttrs(rest) + `>${children}</style>`; | ||
| 317 | } | ||
| 318 | } | ||
| 319 | return out; | ||
| 320 | } | ||
| 321 | |||
| 322 | function serializeAttrs(attrs: Record<string, string | boolean>): string { | ||
| 323 | let out = ""; | ||
| 324 | for (const [k, v] of Object.entries(attrs)) { | ||
| 325 | if (v === true) out += ` ${k}`; | ||
| 326 | else if (v != null && v !== false) out += ` ${k}=${attr(v as string)}`; | ||
| 327 | } | ||
| 328 | return out; | ||
| 329 | } | ||
| 330 | |||
| 331 | function collectOpenGraph(prefix: string, value: OpenGraphField): Array<Record<string, string>> { | ||
| 332 | if (!value) return []; | ||
| 333 | if (typeof value === "string") return [{ name: prefix, content: value }]; | ||
| 334 | if (Array.isArray(value)) return value.flatMap((item) => collectOpenGraph(prefix, item)); | ||
| 335 | return Object.entries(value).flatMap(([key, item]) => collectOpenGraph(`${prefix}:${key}`, item)); | ||
| 336 | } | ||
| 337 | |||
| 338 | function attr(value: string) { | ||
| 339 | value = string.escapeHtmlAttr(value); | ||
| 340 | if (value.match(/["/> ]/)) return "\"" + value + "\""; | ||
| 341 | return value; | ||
| 342 | } | ||
| 343 | |||
| 344 | import * as string from "./string.ts"; | ||
| 345 | import { escapeHtmlContent as esc } from "./string.ts"; | ||
lib/readme.changes.md+11| ... | @@ -2,6 +2,17 @@ | ... | @@ -2,6 +2,17 @@ |
| 2 | 2 | ||
| 3 | ## v5 | 3 | ## v5 |
| 4 | 4 | ||
| 5 | ### breaking | ||
| 6 | |||
| 7 | - `render` has been deleted because it was not being used anymore. sad day | ||
| 8 | because i will never get to prove how easy it is to write a stable Suspense | ||
| 9 | and PPR implementations in less than 300 lines each. | ||
| 10 | - `render/meta` is moved to `meta`, with a new API for rendering tags | ||
| 11 | - `meta.render` is renamed to `meta.toHtml` | ||
| 12 | - `meta.toTags` creates an object compatible with TanStack Router | ||
| 13 | - the `extra` object now takes an array of React-like elements among some | ||
| 14 | other supported forms. | ||
| 15 | |||
| 5 | ### features | 16 | ### features |
| 6 | 17 | ||
| 7 | - `mime`'s database contains `.eot` for embedded opentype fonts. | 18 | - `mime`'s database contains `.eot` for embedded opentype fonts. |
lib/render.ts deleted-479| ... | @@ -1,479 +0,0 @@ | ||
| 1 | /** | ||
| 2 | * @deprecated replacing with pure Marko | ||
| 3 | * https://git.paperclover.net/clo/sitegen/issues/90 | ||
| 4 | * | ||
| 5 | * Clover's rendering engine is the backbone of her website generator. It | ||
| 6 | * converts objects and components (functions returning {@linkcode Node}) into | ||
| 7 | * HTML. the engine is simple, self-contained, and easily extendable. | ||
| 8 | * | ||
| 9 | * extensions to the rendering engine can made via {@linkcode Context}. for | ||
| 10 | * example, 'sitegen' uses a context to track needed client scripts without | ||
| 11 | * introducing patches to {@linkcode State}. | ||
| 12 | * | ||
| 13 | * the easiest way to start is with the JSX adapter `./jsx-runtime.ts` and the | ||
| 14 | * synchronous rendering function {@linkcode sync|render.sync}: | ||
| 15 | * | ||
| 16 | * ```tsx | ||
| 17 | * import * as render from "@clo/lib/render"; | ||
| 18 | * console.info(render.sync(<h1>good morning</h1>)); | ||
| 19 | * // jsx is equivilent to: | ||
| 20 | * console.info(render.sync(render.element("h1", { content: "good morning" }))); | ||
| 21 | * ``` | ||
| 22 | * | ||
| 23 | * NOTE: This module is not production ready, but it is in use at https://paperclover.net | ||
| 24 | * | ||
| 25 | * @module | ||
| 26 | */ | ||
| 27 | |||
| 28 | /** convert a UI description into a string synchronously. | ||
| 29 | * optional `context` argument pre-populates render context. */ | ||
| 30 | export function sync(node: Node, contexts?: ContextValue[]): Result { | ||
| 31 | const state = init(false, contexts ?? []); | ||
| 32 | const resolved = resolveNode(state, node); | ||
| 33 | return { text: stringifyNode(resolved), context: state.context }; | ||
| 34 | } | ||
| 35 | |||
| 36 | /** convert a UI description into a string asynchronously. | ||
| 37 | * optional `context` argument pre-populates render context. */ | ||
| 38 | export function async(node: Node, contexts?: ContextValue[]): Promise<Result> { | ||
| 39 | const state = init(true, contexts ?? []); | ||
| 40 | const resolved = resolveNode(state, node); | ||
| 41 | const { context } = state; | ||
| 42 | if (!state.async) { | ||
| 43 | return Promise.resolve({ text: stringifyNode(resolved), context }); | ||
| 44 | } | ||
| 45 | const { resolve, reject, promise } = Promise.withResolvers<Result>(); | ||
| 46 | state.asyncDone = () => { | ||
| 47 | const { rejections } = state; | ||
| 48 | if (!rejections) return resolve({ text: stringifyNode(resolved), context }); | ||
| 49 | if (rejections.length === 1) return reject(rejections[0]); | ||
| 50 | return reject(new AggregateError(rejections)); | ||
| 51 | }; | ||
| 52 | return promise; | ||
| 53 | } | ||
| 54 | |||
| 55 | interface Result { | ||
| 56 | /** HTML/XML/RSS Text*/ | ||
| 57 | text: string; | ||
| 58 | /** For use with {@linkcode Context}. */ | ||
| 59 | context: Map<Context, object>; | ||
| 60 | } | ||
| 61 | |||
| 62 | /** | ||
| 63 | * mechanism for extending the rendering engine by providing the component tree | ||
| 64 | * with a shared context. | ||
| 65 | * | ||
| 66 | * ```tsx | ||
| 67 | * const exampleContext = new render.Context(() => ({ count: 0 })); | ||
| 68 | * | ||
| 69 | * function Write({ add }) { | ||
| 70 | * const example: { count: number } = exampleContext.get() | ||
| 71 | * example.count += add; | ||
| 72 | * return null; | ||
| 73 | * } | ||
| 74 | * function Read() { | ||
| 75 | * const { count } = exampleContext.get() | ||
| 76 | * return `Value is: ${count}`; | ||
| 77 | * } | ||
| 78 | * | ||
| 79 | * render.sync([ | ||
| 80 | * <Write add={12} /> | ||
| 81 | * <Write add={30} /> | ||
| 82 | * <Read /> | ||
| 83 | * ]); // -> "Value is: 42" | ||
| 84 | * ``` | ||
| 85 | */ | ||
| 86 | export class Context<T extends object = object> { | ||
| 87 | #default: (() => T) | null; | ||
| 88 | |||
| 89 | constructor(init?: () => T) { | ||
| 90 | this.#default = init ?? null; | ||
| 91 | } | ||
| 92 | |||
| 93 | /** | ||
| 94 | * Allow pre-populating a render's context | ||
| 95 | * | ||
| 96 | * ```tsx | ||
| 97 | * const state = exampleContext.init({ count: 5 }); | ||
| 98 | * void render.sync(<Write add={5} />, [state]); | ||
| 99 | * state.count; // 10 | ||
| 100 | * ``` | ||
| 101 | */ | ||
| 102 | init(value: T | void): ContextValue<T> { | ||
| 103 | value ??= UNWRAP(this.#default)(); | ||
| 104 | (value as ContextValue<T>)[kContext] = this; | ||
| 105 | return value as ContextValue<T>; | ||
| 106 | } | ||
| 107 | |||
| 108 | /** | ||
| 109 | * retrieve the context value from | ||
| 110 | * - passing `undefined`, retrieves from current render | ||
| 111 | * - passing a `Result`, typesafe access | ||
| 112 | * | ||
| 113 | * due to the implementation using simple global state, this function will | ||
| 114 | * not work within `async` components. | ||
| 115 | */ | ||
| 116 | get(result: Result): T | null; | ||
| 117 | get(): T; | ||
| 118 | get(result?: Result): T | null { | ||
| 119 | if (result) return (result.context.get(this) ?? null) as T | null; | ||
| 120 | const { context } = UNWRAP(current, "Not in an active render."); | ||
| 121 | let value = (context.get(this) ?? null) as T | null; | ||
| 122 | if (!value) { | ||
| 123 | const init = this.#default; | ||
| 124 | context.set(this, value = UNWRAP(init, "No init function")()); | ||
| 125 | } | ||
| 126 | return value; | ||
| 127 | } | ||
| 128 | } | ||
| 129 | |||
| 130 | export type ContextValue<T extends object = object> = T & { | ||
| 131 | [kContext]: Context<T>; | ||
| 132 | }; | ||
| 133 | |||
| 134 | /** the global state */ | ||
| 135 | export let current: State | null = null; | ||
| 136 | |||
| 137 | /** set the global state */ | ||
| 138 | export function setCurrent(r: State | null) { | ||
| 139 | current = r ?? null; | ||
| 140 | } | ||
| 141 | |||
| 142 | /** inline HTML into a render without escaping it */ | ||
| 143 | export function raw(node: ResolvedNode): Raw { | ||
| 144 | return [kRaw, node]; | ||
| 145 | } | ||
| 146 | |||
| 147 | /** constructs {@linkcode Element} */ | ||
| 148 | export function element( | ||
| 149 | type: Element[1], | ||
| 150 | attrs: Element[2] = {}, | ||
| 151 | source?: SrcLoc, | ||
| 152 | ): Element { | ||
| 153 | return source ? [kElement, type, attrs, "", source] : [kElement, type, attrs]; | ||
| 154 | } | ||
| 155 | |||
| 156 | /** constructs {@linkcode ResolvedElement} */ | ||
| 157 | export function resolvedElement( | ||
| 158 | type: ResolvedElement[1], | ||
| 159 | attrs: ResolvedElement[2], | ||
| 160 | content: ResolvedElement[3], | ||
| 161 | ): ResolvedElement { | ||
| 162 | return [kElement, type, attrs, content]; | ||
| 163 | } | ||
| 164 | |||
| 165 | /** internal global state while rendering */ | ||
| 166 | export interface State { | ||
| 167 | /** | ||
| 168 | * set to '-1' if rendering synchronously | ||
| 169 | * number of async promises the render is waiting on. | ||
| 170 | */ | ||
| 171 | async: number | -1; | ||
| 172 | asyncDone: null | (() => void); | ||
| 173 | /** when components reject, those are collected here */ | ||
| 174 | rejections: unknown[] | null; | ||
| 175 | context: Map<Context, object>; | ||
| 176 | } | ||
| 177 | |||
| 178 | /** `Node` represents a webpage that can be "rendered' into HTML. */ | ||
| 179 | export type Node = | ||
| 180 | | number | ||
| 181 | | string // Escape HTML | ||
| 182 | | Node[] // Concat | ||
| 183 | | Element // Stringify | ||
| 184 | | Raw // Insert | ||
| 185 | | Promise<Node> // Await | ||
| 186 | // Ignore | ||
| 187 | | (undefined | null | boolean); | ||
| 188 | /** `Element` represents a component call or HTML element. */ | ||
| 189 | export type Element = [ | ||
| 190 | tag: typeof kElement, | ||
| 191 | type: string | Component, | ||
| 192 | attrs: Record<string, unknown>, | ||
| 193 | _?: "", // contents | ||
| 194 | source?: SrcLoc, | ||
| 195 | ]; | ||
| 196 | export type Raw = [tag: typeof kRaw, html: ResolvedNode]; | ||
| 197 | /** | ||
| 198 | * components must return a value; 'undefined' is prohibited here | ||
| 199 | * to avoid functions that are missing a return statement. | ||
| 200 | */ | ||
| 201 | export type Component = (attrs: Record<any, any>) => Exclude<Node, undefined>; | ||
| 202 | /** emitted by JSX runtime */ | ||
| 203 | export interface SrcLoc { | ||
| 204 | fileName: string; | ||
| 205 | lineNumber: number; | ||
| 206 | columnNumber: number; | ||
| 207 | } | ||
| 208 | |||
| 209 | /** | ||
| 210 | * resolution narrows the type {@linkcode Node} into {@linkcode ResolvedNode}. | ||
| 211 | * Async trees are marked in {@linkcode State}. This operation performs | ||
| 212 | * everything besides the final string concatenation. This function is agnostic | ||
| 213 | * across {@linkcode async}/{@linkcode sync} modes. | ||
| 214 | */ | ||
| 215 | export function resolveNode(r: State, node: unknown): ResolvedNode { | ||
| 216 | if (!node && node !== 0) return ""; // falsy, non numeric | ||
| 217 | if (typeof node !== "object") { | ||
| 218 | if (node === true) return ""; // booleans are ignored | ||
| 219 | if (typeof node === "string") return escapeHtmlContent(node); | ||
| 220 | if (typeof node === "number") return String(node); // no escaping ever | ||
| 221 | if (typeof node === "symbol" && node.toString() === kElement.toString()) { | ||
| 222 | throw new Error(`There are two instances of Clover SSR loaded!`); | ||
| 223 | } | ||
| 224 | throw new Error(`Cannot render ${inspect(node)} to HTML`); | ||
| 225 | } | ||
| 226 | if (node instanceof Promise) { | ||
| 227 | if (r.async === -1) { | ||
| 228 | throw new Error(`Asynchronous rendering is not supported here.`); | ||
| 229 | } | ||
| 230 | const placeholder: InsertionPoint = [null]; | ||
| 231 | r.async += 1; | ||
| 232 | node | ||
| 233 | .then((result) => void (placeholder[0] = resolveNode(r, result))) | ||
| 234 | // Intentionally catching errors in `resolveNode` | ||
| 235 | .catch((e) => (r.rejections ??= []).push(e)) | ||
| 236 | .finally(() => { | ||
| 237 | if (--r.async == 0) { | ||
| 238 | if (r.asyncDone == null) throw new Error("r.asyncDone == null"); | ||
| 239 | r.asyncDone(); | ||
| 240 | r.asyncDone = null; | ||
| 241 | } | ||
| 242 | }); | ||
| 243 | // This lie is checked with an assertion in `renderNode` | ||
| 244 | return placeholder as [ResolvedNode]; | ||
| 245 | } | ||
| 246 | if (!Array.isArray(node)) { | ||
| 247 | throw new Error(`Invalid node type: ${inspect(node)}`); | ||
| 248 | } | ||
| 249 | const type = node[0]; | ||
| 250 | if (type === kElement) { | ||
| 251 | const { 1: tag, 2: attrs } = node; | ||
| 252 | if (typeof tag === "function") { | ||
| 253 | current = r; | ||
| 254 | try { | ||
| 255 | return resolveNode(r, tag(attrs)); | ||
| 256 | } catch (e) { | ||
| 257 | const { 4: src } = node; | ||
| 258 | if (e && typeof e === "object") (e as { src?: string }).src ??= src; | ||
| 259 | throw e; | ||
| 260 | } finally { | ||
| 261 | current = null; | ||
| 262 | } | ||
| 263 | } | ||
| 264 | if (typeof tag !== "string") throw new Error("Unexpected " + inspect(type)); | ||
| 265 | const content = attrs?.content; | ||
| 266 | if (content) return [kElement, tag, attrs, resolveNode(r, content)]; | ||
| 267 | return node; | ||
| 268 | } | ||
| 269 | if (type === kRaw) return node[1]; | ||
| 270 | return node.map((elem) => resolveNode(r, elem)); | ||
| 271 | } | ||
| 272 | |||
| 273 | export type ResolvedNode = | ||
| 274 | | ResolvedNode[] // Concat | ||
| 275 | | ResolvedElement // Render | ||
| 276 | | string; // Raw HTML | ||
| 277 | export type ResolvedElement = [ | ||
| 278 | tag: typeof kElement, | ||
| 279 | type: string, | ||
| 280 | attrs: Record<string, unknown>, | ||
| 281 | content: ResolvedNode, | ||
| 282 | ]; | ||
| 283 | /** | ||
| 284 | * async rendering is done by creating an array of one item, which is already a | ||
| 285 | * valid {@linkcode Node}, but the element is written once the data is | ||
| 286 | * available. {@linkcode State} contains a count of how many async jobs are | ||
| 287 | * left. | ||
| 288 | */ | ||
| 289 | export type InsertionPoint = [null | ResolvedNode]; | ||
| 290 | |||
| 291 | /** | ||
| 292 | * convert {@linkcode ResolvedNode} into HTML text. This operation happens | ||
| 293 | * after all async work is settled. markup is emitted as concisely as possible. | ||
| 294 | */ | ||
| 295 | export function stringifyNode(node: ResolvedNode): string { | ||
| 296 | ASSERT(node != null, "Unresolved Render Node"); | ||
| 297 | if (typeof node === "string") return node; | ||
| 298 | const type = node[0]; | ||
| 299 | if (type === kElement) { | ||
| 300 | return stringifyElement(node as ResolvedElement); | ||
| 301 | } | ||
| 302 | node = node as ResolvedNode[]; // TS cannot infer. | ||
| 303 | let out = type ? stringifyNode(type) : ""; | ||
| 304 | const len = node.length; | ||
| 305 | for (let i = 1; i < len; i++) { | ||
| 306 | const elem = node[i]; | ||
| 307 | if (elem) out += stringifyNode(elem); | ||
| 308 | } | ||
| 309 | return out; | ||
| 310 | } | ||
| 311 | |||
| 312 | function stringifyElement(element: ResolvedElement) { | ||
| 313 | const { 1: tag, 2: attrs, 3: content } = element; | ||
| 314 | let out = "<" + tag; | ||
| 315 | for (const prop in attrs) { | ||
| 316 | const value = attrs[prop]; | ||
| 317 | if (!value || typeof value === "function") continue; | ||
| 318 | let attr; | ||
| 319 | switch (prop) { | ||
| 320 | default: | ||
| 321 | attr = `${prop}=${quoteIfNeeded(escapeAttribute(String(value)))}`; | ||
| 322 | break; | ||
| 323 | case "className": // Legacy React Compat | ||
| 324 | case "class": { | ||
| 325 | const str = clsx(value as ClassName); | ||
| 326 | if (!str) continue; | ||
| 327 | attr = `class=${quoteIfNeeded(escapeAttribute(str))}`; | ||
| 328 | break; | ||
| 329 | } | ||
| 330 | case "style": { | ||
| 331 | attr = typeof value === "string" | ||
| 332 | ? `style=${quoteIfNeeded(escapeAttribute(value))}` | ||
| 333 | : stringifyStyleAttribute(value as Record<string, string>); | ||
| 334 | break; | ||
| 335 | } | ||
| 336 | case "htmlFor": | ||
| 337 | throw new Error("Do not use the `htmlFor` attribute. Use `for`"); | ||
| 338 | case "dangerouslySetInnerHTML": | ||
| 339 | throw new Error( | ||
| 340 | "Do not use the `dangerouslySetInnerHTML` attribute. Use `render.raw()`", | ||
| 341 | ); | ||
| 342 | case "children": | ||
| 343 | throw new Error( | ||
| 344 | "Do not set the `children` attribute to set element contents. Use `content`", | ||
| 345 | ); | ||
| 346 | // Do not process these | ||
| 347 | case "content": | ||
| 348 | if (tag === "meta") { | ||
| 349 | attr = `${prop}=${quoteIfNeeded(escapeAttribute(String(value)))}`; | ||
| 350 | break; | ||
| 351 | } | ||
| 352 | continue; | ||
| 353 | case "ref": | ||
| 354 | case "key": | ||
| 355 | continue; | ||
| 356 | } | ||
| 357 | if (!out.endsWith("\"")) out += " "; | ||
| 358 | out += attr; | ||
| 359 | } | ||
| 360 | out = out.trimEnd() + ">"; | ||
| 361 | if (content) out += stringifyNode(content); | ||
| 362 | if ( | ||
| 363 | tag !== "br" | ||
| 364 | && tag !== "img" | ||
| 365 | && tag !== "input" | ||
| 366 | && tag !== "meta" | ||
| 367 | && tag !== "link" | ||
| 368 | && tag !== "hr" | ||
| 369 | ) { | ||
| 370 | out += `</${tag}>`; | ||
| 371 | } | ||
| 372 | return out; | ||
| 373 | } | ||
| 374 | |||
| 375 | function stringifyStyleAttribute(style: Record<string, string>): string { | ||
| 376 | let out = ``; | ||
| 377 | for (const styleName in style) { | ||
| 378 | if (out) out += ";"; | ||
| 379 | out += `${styleName.replace(/[A-Z]/g, "-$&").toLowerCase()}:${ | ||
| 380 | escapeAttribute( | ||
| 381 | String(style[styleName]), | ||
| 382 | ) | ||
| 383 | }`; | ||
| 384 | } | ||
| 385 | if (!out) return ""; | ||
| 386 | return "style=" + quoteIfNeeded(out); | ||
| 387 | } | ||
| 388 | |||
| 389 | export function quoteIfNeeded(text: string): string { | ||
| 390 | if (text.match(/["/> ]/)) return "\"" + text + "\""; | ||
| 391 | return text; | ||
| 392 | } | ||
| 393 | |||
| 394 | // -- utility functions -- | ||
| 395 | |||
| 396 | /** Construct {@link State} */ | ||
| 397 | export function init( | ||
| 398 | allowAsync: boolean, | ||
| 399 | context: ContextValue[] | Map<Context, object>, | ||
| 400 | ): State { | ||
| 401 | return { | ||
| 402 | async: allowAsync ? 0 : -1, | ||
| 403 | rejections: null, | ||
| 404 | asyncDone: null, | ||
| 405 | context: Array.isArray(context) | ||
| 406 | ? new Map(context.map((value) => [value[kContext], value])) | ||
| 407 | : context, | ||
| 408 | }; | ||
| 409 | } | ||
| 410 | |||
| 411 | export type ClassName = string | Record<string, boolean | null> | ClassName[]; | ||
| 412 | export function clsx(mix: ClassName): string { | ||
| 413 | let k, y, str = ""; | ||
| 414 | if (typeof mix === "string") { | ||
| 415 | return mix; | ||
| 416 | } else if (typeof mix === "object") { | ||
| 417 | if (Array.isArray(mix)) { | ||
| 418 | for (k = 0; k < mix.length; k++) { | ||
| 419 | if (mix[k] && (y = clsx(mix[k]!))) { | ||
| 420 | str && (str += " "); | ||
| 421 | str += y; | ||
| 422 | } | ||
| 423 | } | ||
| 424 | } else { | ||
| 425 | for (k in mix) { | ||
| 426 | if (mix[k]) { | ||
| 427 | str && (str += " "); | ||
| 428 | str += k; | ||
| 429 | } | ||
| 430 | } | ||
| 431 | } | ||
| 432 | } | ||
| 433 | return str; | ||
| 434 | } | ||
| 435 | |||
| 436 | // TODO: combine into one function which decides if an attribute needs quotes | ||
| 437 | // and escapes it correctly depending on the context. | ||
| 438 | |||
| 439 | /** @deprecated */ | ||
| 440 | export const escapeHtmlContent = (unsafeText: string): string => | ||
| 441 | String(unsafeText) | ||
| 442 | .replace(/&/g, "&amp;") | ||
| 443 | .replace(/</g, "&lt;") | ||
| 444 | .replace(/>/g, "&gt;") | ||
| 445 | .replace(/"/g, "&quot;"); | ||
| 446 | /** @deprecated */ | ||
| 447 | const escapeAttribute = (unsafeText: string): string => | ||
| 448 | String(unsafeText) | ||
| 449 | .replace(/&/g, "&amp;") | ||
| 450 | .replace(/</g, "&lt;") | ||
| 451 | .replace(/>/g, "&gt;") | ||
| 452 | .replace(/"/g, "&quot;"); | ||
| 453 | /** @deprecated */ | ||
| 454 | export const escapeHtml = (unsafeText: string): string => | ||
| 455 | String(unsafeText) | ||
| 456 | .replace(/&/g, "&amp;") | ||
| 457 | .replace(/</g, "&lt;") | ||
| 458 | .replace(/>/g, "&gt;") | ||
| 459 | .replace(/"/g, "&quot;") | ||
| 460 | .replace(/'/g, "&#x27;") | ||
| 461 | .replace(/`/g, "&#x60;"); | ||
| 462 | |||
| 463 | /** @deprecated */ | ||
| 464 | export function inspect(object: unknown): string { | ||
| 465 | try { | ||
| 466 | return require("node:util").inspect(object); | ||
| 467 | } catch { | ||
| 468 | return typeof object; | ||
| 469 | } | ||
| 470 | } | ||
| 471 | |||
| 472 | /** descriminator for {@linkcode Element} */ | ||
| 473 | const kElement = Symbol("Element"); | ||
| 474 | /** descriminator for {@linkcode Raw} */ | ||
| 475 | const kRaw = Symbol("Raw"); | ||
| 476 | /** secret key to facilitate {@linkcode init} with a `ContextValue[]` */ | ||
| 477 | const kContext: unique symbol = Symbol(); | ||
| 478 | |||
| 479 | import { ASSERT, UNWRAP } from "./assert.ts"; | ||
lib/render/jsx-runtime.ts deleted-81| ... | @@ -1,81 +0,0 @@ | ||
| 1 | /** @module | ||
| 2 | * provides JSX integration with `lib/render.ts`. | ||
| 3 | * | ||
| 4 | * because the jsr does not allow default global augmentations, type | ||
| 5 | * installation must be done manually by pasting this in your project: | ||
| 6 | * | ||
| 7 | * declare global { | ||
| 8 | * namespace JSX { | ||
| 9 | * export type IntrinsicElements = jsx.ts.IntrinsicElements; | ||
| 10 | * export type ElementChildrenAttribute = jsx.ts.ElementChildrenAttribute; | ||
| 11 | * export type Element = jsx.ts.Element; | ||
| 12 | * export type ElementType = jsx.ts.ElementType; | ||
| 13 | * export type ElementClass = jsx.ts.ElementClass; | ||
| 14 | * } | ||
| 15 | * } | ||
| 16 | * import * as jsx from "@clo/lib/render/jsx-runtime"; | ||
| 17 | */ | ||
| 18 | |||
| 19 | /** this element returns just its contents, and is used by `<>` fragment syntax. */ | ||
| 20 | export function Fragment( | ||
| 21 | { content }: { content: render.Node[] }, | ||
| 22 | ): render.Node[] { | ||
| 23 | return content; | ||
| 24 | } | ||
| 25 | |||
| 26 | /** construct a JSX element in production mode. */ | ||
| 27 | export function jsx( | ||
| 28 | type: string | render.Component, | ||
| 29 | { children, ...attrs }: Record<string, unknown>, | ||
| 30 | ): render.Element { | ||
| 31 | if (typeof type !== "function" && typeof type !== "string") { | ||
| 32 | throw new Error("Invalid component type: " + render.inspect(type)); | ||
| 33 | } | ||
| 34 | return render.element(type, { content: children, ...attrs }); | ||
| 35 | } | ||
| 36 | |||
| 37 | /** construct a JSX element in development mode. */ | ||
| 38 | export function jsxDEV( | ||
| 39 | type: string | render.Component, | ||
| 40 | { children, ...attrs }: Record<string, unknown>, | ||
| 41 | // Unused with the clover engine | ||
| 42 | _key: string, | ||
| 43 | // Unused with the clover engine | ||
| 44 | _staticContent: boolean, | ||
| 45 | source: render.SrcLoc, | ||
| 46 | ): render.Element { | ||
| 47 | ASSERT( | ||
| 48 | source != null, | ||
| 49 | `Build tool does not emit correct JSX debugging information.`, | ||
| 50 | ); | ||
| 51 | const { fileName, lineNumber, columnNumber } = source; | ||
| 52 | |||
| 53 | // Assert the component type is valid to render. | ||
| 54 | if (typeof type !== "function" && typeof type !== "string") { | ||
| 55 | throw new Error( | ||
| 56 | `Invalid component type at ${fileName}:${lineNumber}:${columnNumber}: ` | ||
| 57 | + render.inspect(type) | ||
| 58 | + ". Clover SSR element must be a function or string.", | ||
| 59 | ); | ||
| 60 | } | ||
| 61 | |||
| 62 | return render.element(type, { content: children, ...attrs }, source); | ||
| 63 | } | ||
| 64 | |||
| 65 | // Clover SSR engine does not yet distinguish between static and non-static JSX. | ||
| 66 | export { jsx as jsxs }; | ||
| 67 | |||
| 68 | export declare namespace ts { | ||
| 69 | export interface IntrinsicElements { | ||
| 70 | [name: string]: Record<string, unknown>; | ||
| 71 | } | ||
| 72 | export interface ElementChildrenAttribute { | ||
| 73 | content: render.Node; | ||
| 74 | } | ||
| 75 | export type Element = render.Element; | ||
| 76 | export type ElementType = keyof IntrinsicElements | render.Component; | ||
| 77 | export type ElementClass = ReturnType<render.Component>; | ||
| 78 | } | ||
| 79 | |||
| 80 | import { ASSERT } from "../assert.ts"; | ||
| 81 | import * as render from "../render.ts"; | ||
lib/render/meta.test.tsx deleted-49| ... | @@ -1,49 +0,0 @@ | ||
| 1 | // every metadata render requires a base URL | ||
| 2 | const base: meta.Template = { | ||
| 3 | base: new URL("https://paperclover.net"), | ||
| 4 | }; | ||
| 5 | |||
| 6 | test("default", () => | ||
| 7 | assert.equal( | ||
| 8 | meta.render({ title: "my website <3" }, base), | ||
| 9 | "<title>my website &lt;3</title>" | ||
| 10 | + "<meta name=viewport content=\"width=device-width, initial-scale=1.0\">", | ||
| 11 | )); | ||
| 12 | test("some other meta tags", () => | ||
| 13 | assert.equal( | ||
| 14 | meta.render( | ||
| 15 | { | ||
| 16 | title: "hello", | ||
| 17 | description: "my website is a place to be", | ||
| 18 | canonical: "/", | ||
| 19 | }, | ||
| 20 | base, | ||
| 21 | ), | ||
| 22 | "<title>hello</title>" | ||
| 23 | + "<meta name=description content=\"my website is a place to be\">" | ||
| 24 | + "<meta name=viewport content=\"width=device-width, initial-scale=1.0\">" | ||
| 25 | + "<link rel=canonical href=\"https://paperclover.net/\">", | ||
| 26 | )); | ||
| 27 | test("automatic embed with '{}'", () => | ||
| 28 | assert.equal( | ||
| 29 | meta.render( | ||
| 30 | { | ||
| 31 | title: "main title", | ||
| 32 | description: "main description", | ||
| 33 | embed: {}, | ||
| 34 | }, | ||
| 35 | base, | ||
| 36 | ), | ||
| 37 | "<title>main title</title>" | ||
| 38 | + "<meta name=description content=\"main description\">" | ||
| 39 | + "<meta name=viewport content=\"width=device-width, initial-scale=1.0\">" | ||
| 40 | + "<meta name=og:type content=website>" | ||
| 41 | + "<meta name=og:title content=\"main title\">" | ||
| 42 | + "<meta name=og:description content=\"main description\">" | ||
| 43 | + "<meta name=twitter:card content=summary>" | ||
| 44 | + "", | ||
| 45 | )); | ||
| 46 | |||
| 47 | import assert from "node:assert"; | ||
| 48 | import { test } from "vitest"; | ||
| 49 | import * as meta from "./meta.ts"; | ||
lib/render/meta.ts deleted-256| ... | @@ -1,256 +0,0 @@ | ||
| 1 | export { renderMetadata as render }; | ||
| 2 | export interface Meta { | ||
| 3 | /** Required for all pages. `<title>{content}</title>` */ | ||
| 4 | title: string; | ||
| 5 | /** Recommended for all pages. `<meta name="description" content="{...}" />` */ | ||
| 6 | description?: string | null; | ||
| 7 | /** Automatically added for static renders from the 'pages' folders. */ | ||
| 8 | canonical?: string | null; | ||
| 9 | /** Add `<link rel="alternate" ... />`. Object keys are interpretted as | ||
| 10 | * mime types if they contain a slash, otherwise seen as an alternative language. */ | ||
| 11 | alternates?: Alternate[] | Record<string, string>; | ||
| 12 | |||
| 13 | /** Automatically generate both OpenGraph and Twitter meta tags */ | ||
| 14 | embed?: AutoEmbed | null; | ||
| 15 | /** Add a robots tag for `noindex` and `nofollow` */ | ||
| 16 | denyRobots?: boolean | null; | ||
| 17 | /** Add 'og:*' meta tags */ | ||
| 18 | openGraph?: OpenGraph | null; | ||
| 19 | /** Add 'twitter:*' meta tags */ | ||
| 20 | twitter?: Twitter | null; | ||
| 21 | /** Refer to an oEmbed file. See https://oembed.com */ | ||
| 22 | oEmbed?: string; | ||
| 23 | /** | ||
| 24 | * '#sitegen/meta' intentionally excludes a lot of exotic tags. | ||
| 25 | * Add these manually using JSX syntax: | ||
| 26 | * | ||
| 27 | * extra: [ | ||
| 28 | * <meta name="site-verification" content="waffles" />, | ||
| 29 | * ], | ||
| 30 | */ | ||
| 31 | extra?: render.Node; | ||
| 32 | |||
| 33 | /** Adds `<meta name="author" content="{...}" />` */ | ||
| 34 | authors?: string[]; | ||
| 35 | /** Credit sitegen by setting this to true aka "clover sitegen 3; paperclover.net/sitegen" */ | ||
| 36 | generator?: string | true | null; | ||
| 37 | /** Adds `<meta name="keywords" content="{keywords.join(', ')}" />` */ | ||
| 38 | keywords?: string[]; | ||
| 39 | /** URL to a manifest; https://developer.mozilla.org/en-US/docs/Web/Manifest */ | ||
| 40 | manifest?: string | null; | ||
| 41 | /** Adds `<meta name="publisher" content="{...}" />` */ | ||
| 42 | publisher?: string | null; | ||
| 43 | /** https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/meta/name/referrer */ | ||
| 44 | referrer?: Referrer | null; | ||
| 45 | /** Adds `<meta name="theme-color" content="{...}" />` */ | ||
| 46 | themeColor?: string | { dark: string; light: string } | null; | ||
| 47 | /** Defaults to `width=device-width, initial-scale=1.0` for mobile compatibility. */ | ||
| 48 | viewport?: string; | ||
| 49 | } | ||
| 50 | export type Alternate = { type: string; url: string } | { | ||
| 51 | lang: string; | ||
| 52 | url: string; | ||
| 53 | }; | ||
| 54 | export interface AutoEmbed { | ||
| 55 | /* Defaults to the page title. */ | ||
| 56 | title?: string | null; | ||
| 57 | /* Defaults to the page description. */ | ||
| 58 | description?: string | null; | ||
| 59 | /* Provide to add an embed image. */ | ||
| 60 | thumbnail?: string | null; | ||
| 61 | /** @default "banner", which applies twitter:card = "summary_large_image" */ | ||
| 62 | thumbnailSize?: "banner" | "icon"; | ||
| 63 | /* Ignored if not passed */ | ||
| 64 | siteTitle?: string | null; | ||
| 65 | } | ||
| 66 | /** See https://ogp.me for extra rules. */ | ||
| 67 | export interface OpenGraph { | ||
| 68 | /** The title of your object as it should appear within the graph */ | ||
| 69 | title?: string; | ||
| 70 | /** A one to two sentence description of your object. */ | ||
| 71 | description?: string | null; | ||
| 72 | /** The type of your object, e.g., "video.movie". Depending on the type you specify, other properties may also be required */ | ||
| 73 | type?: string; | ||
| 74 | /** An image URL which should represent your object within the graph */ | ||
| 75 | image?: OpenGraphField; | ||
| 76 | /** The canonical URL of your object that will be used as its permanent ID in the graph, e.g., "https://www.imdb.com/title/tt0117500/" */ | ||
| 77 | url?: string; | ||
| 78 | /** A URL to an audio file to accompany this object */ | ||
| 79 | audio?: OpenGraphField; | ||
| 80 | /** The word that appears before this object's title in a sentence. An enum of (a, an, the, "", auto). If auto is chosen, the consumer of your data should choose between "a" or "an". Default is "" (blank) */ | ||
| 81 | determiner?: string; | ||
| 82 | /** The locale these tags are marked up in. Of the format language_TERRITORY. Default is en_US */ | ||
| 83 | locale?: string; | ||
| 84 | /** An array of other locales this page is available in */ | ||
| 85 | "locale:alternate"?: string[]; | ||
| 86 | /** If your object is part of a larger web site, the name which should be displayed for the overall site. e.g., "IMDb" */ | ||
| 87 | site_name?: string; | ||
| 88 | /** A URL to a video file that complements this object */ | ||
| 89 | video?: OpenGraphField; | ||
| 90 | [field: string]: OpenGraphField; | ||
| 91 | } | ||
| 92 | /** | ||
| 93 | * When passing an array, the property is duplicated. | ||
| 94 | * When passing an object, the fields are emitted as namespaced with ':'. | ||
| 95 | */ | ||
| 96 | type OpenGraphField = | ||
| 97 | | string | ||
| 98 | | { [field: string]: OpenGraphField } | ||
| 99 | | Array<OpenGraphField> | ||
| 100 | | (null | undefined); | ||
| 101 | /** Twitter uses various OpenGraph fields if these are not specified. */ | ||
| 102 | export interface Twitter { | ||
| 103 | card: string; | ||
| 104 | title?: string; | ||
| 105 | description?: string | null; | ||
| 106 | url?: string; | ||
| 107 | image?: string; | ||
| 108 | player?: string; | ||
| 109 | /** Same logic as Open Graph */ | ||
| 110 | [field: string]: OpenGraphField; | ||
| 111 | } | ||
| 112 | export interface Template extends Omit<Meta, "title" | "description" | "canonical"> { | ||
| 113 | base: URL; | ||
| 114 | titleTemplate?: (title: string) => string; | ||
| 115 | } | ||
| 116 | export interface Alternates { | ||
| 117 | canonical: string; | ||
| 118 | types: { [mime: string]: AlternateType }; | ||
| 119 | } | ||
| 120 | export interface AlternateType { | ||
| 121 | url: string; | ||
| 122 | title: string; | ||
| 123 | } | ||
| 124 | export type Referrer = | ||
| 125 | | "no-referrer" | ||
| 126 | | "origin" | ||
| 127 | | "no-referrer-when-downgrade" | ||
| 128 | | "origin-when-cross-origin" | ||
| 129 | | "same-origin" | ||
| 130 | | "strict-origin" | ||
| 131 | | "strict-origin-when-cross-origin"; | ||
| 132 | |||
| 133 | /* Convert a metadata definition into text. */ | ||
| 134 | function renderMetadata(meta: Meta, template: Template): string { | ||
| 135 | const { titleTemplate, base } = template; | ||
| 136 | const resolve = (str: string) => new URL(str, base).href; | ||
| 137 | |||
| 138 | const title = titleTemplate ? titleTemplate(meta.title) : meta.title; | ||
| 139 | const description = meta.description ?? null; | ||
| 140 | const canonical = meta.canonical ? resolve(meta.canonical) : null; | ||
| 141 | const denyRobots = Boolean(meta.denyRobots || template.denyRobots); | ||
| 142 | const authors = meta.authors ?? template.authors ?? null; | ||
| 143 | let generator = meta.generator ?? template.generator ?? null; | ||
| 144 | const keywords = meta.keywords ?? template.keywords ?? null; | ||
| 145 | const manifest = meta.manifest ?? template.manifest ?? null; | ||
| 146 | const publisher = meta.publisher ?? template.publisher ?? null; | ||
| 147 | const referrer = meta.referrer ?? template.referrer ?? null; | ||
| 148 | const themeColor = meta.themeColor ?? template.themeColor ?? null; | ||
| 149 | const viewport = meta.viewport ?? template.viewport | ||
| 150 | ?? "width=device-width, initial-scale=1, maximum-scale=1"; | ||
| 151 | |||
| 152 | const embed = meta.embed ?? template.embed ?? null; | ||
| 153 | let openGraph = meta.openGraph ?? template.openGraph ?? null; | ||
| 154 | let twitter = meta.twitter ?? template.twitter ?? null; | ||
| 155 | if (embed) { | ||
| 156 | const { thumbnail, thumbnailSize, siteTitle } = embed; | ||
| 157 | openGraph = { | ||
| 158 | type: "website", | ||
| 159 | title: embed.title ?? title, | ||
| 160 | description: embed.description ?? description, | ||
| 161 | ...openGraph, | ||
| 162 | }; | ||
| 163 | twitter = { | ||
| 164 | card: (thumbnailSize ?? (thumbnail ? "banner" : "icon")) === "banner" | ||
| 165 | ? "summary_large_image" | ||
| 166 | : "summary", | ||
| 167 | ...twitter, | ||
| 168 | }; | ||
| 169 | if (thumbnail) { | ||
| 170 | const resolved = new URL(thumbnail, template.base).href; | ||
| 171 | openGraph.image = resolved; | ||
| 172 | twitter.image = resolved; | ||
| 173 | } | ||
| 174 | if (siteTitle) { | ||
| 175 | openGraph.site_name = siteTitle; | ||
| 176 | } | ||
| 177 | if (canonical) { | ||
| 178 | openGraph.url = canonical; | ||
| 179 | } | ||
| 180 | } | ||
| 181 | |||
| 182 | let out = `<title>${esc(title)}</title>`; | ||
| 183 | if (description) { | ||
| 184 | out += `<meta name=description content=${attr(description)}>`; | ||
| 185 | } | ||
| 186 | for (const author of authors ?? []) { | ||
| 187 | out += `<meta name=author content=${attr(author)}>`; | ||
| 188 | } | ||
| 189 | if (keywords) { | ||
| 190 | out += `<meta name=keywords content=${attr(keywords.join(", "))}>`; | ||
| 191 | } | ||
| 192 | if (generator) { | ||
| 193 | if (generator === true) { | ||
| 194 | generator = "clover sitegen 3; paperclover.net/sitegen"; | ||
| 195 | } | ||
| 196 | out += `<meta name=generator content=${attr(generator)}>`; | ||
| 197 | } | ||
| 198 | if (publisher) { | ||
| 199 | out += `<meta name=publisher content=${attr(publisher)}>`; | ||
| 200 | } | ||
| 201 | if (referrer) { | ||
| 202 | out += `<meta name=referrer content=${attr(referrer)}>`; | ||
| 203 | } | ||
| 204 | if (themeColor) { | ||
| 205 | if (typeof themeColor === "string") { | ||
| 206 | out += `<meta name=theme-color content=${attr(themeColor)}>`; | ||
| 207 | } else { | ||
| 208 | out += "<meta name=theme-color media=\"(prefers-color-scheme:light)\" " | ||
| 209 | + `content=${attr(themeColor.light)}>`; | ||
| 210 | out += "<meta name=theme-color media=\"(prefers-color-scheme:dark)\" " | ||
| 211 | + `content=${attr(themeColor.dark)}>`; | ||
| 212 | } | ||
| 213 | } | ||
| 214 | if (denyRobots) { | ||
| 215 | out += `<meta name=robots content=noindex,nofollow>`; | ||
| 216 | } | ||
| 217 | if (viewport) { | ||
| 218 | out += `<meta name=viewport content=${attr(viewport)}>`; | ||
| 219 | } | ||
| 220 | if (canonical) { | ||
| 221 | out += `<link rel=canonical href=${attr(canonical)}>`; | ||
| 222 | } | ||
| 223 | if (manifest) { | ||
| 224 | out += `<link rel=manifest href=${attr(manifest)}>`; | ||
| 225 | } | ||
| 226 | |||
| 227 | if (openGraph) out += renderOpenGraph("og", openGraph); | ||
| 228 | if (twitter) out += renderOpenGraph("twitter", twitter); | ||
| 229 | |||
| 230 | if (meta.extra) out += render.sync(meta.extra); | ||
| 231 | if (template.extra) out += render.sync(template.extra); | ||
| 232 | |||
| 233 | return out; | ||
| 234 | } | ||
| 235 | |||
| 236 | function renderOpenGraph(prefix: string, value: OpenGraphField): string { | ||
| 237 | if (!value) return ""; | ||
| 238 | if (typeof value === "string") { | ||
| 239 | return `<meta name=${attr(prefix)} content=${attr(value)}>`; | ||
| 240 | } | ||
| 241 | if (Array.isArray(value)) { | ||
| 242 | return value | ||
| 243 | .map((item) => renderOpenGraph(prefix, item)) | ||
| 244 | .join(""); | ||
| 245 | } | ||
| 246 | return Object.entries(value) | ||
| 247 | .map(([key, item]) => renderOpenGraph(`${prefix}:${key}`, item)) | ||
| 248 | .join(""); | ||
| 249 | } | ||
| 250 | |||
| 251 | function attr(value: string) { | ||
| 252 | return render.quoteIfNeeded(esc(value)); | ||
| 253 | } | ||
| 254 | |||
| 255 | import { escapeHtmlContent as esc } from "../render.ts"; | ||
| 256 | import * as render from "../render.ts"; | ||
package.json+1-3| ... | @@ -28,8 +28,6 @@ | ... | @@ -28,8 +28,6 @@ |
| 28 | "imports": { | 28 | "imports": { |
| 29 | "#src/*": "./src/*", | 29 | "#src/*": "./src/*", |
| 30 | "#sitegen": "./framework/lib/sitegen.ts", | 30 | "#sitegen": "./framework/lib/sitegen.ts", |
| 31 | "#sitegen/*": "./framework/lib/*.ts", | 31 | "#sitegen/*": "./framework/lib/*.ts" |
| 32 | "#jsx/jsx-runtime": "./lib/render/jsx-runtime.ts", | ||
| 33 | "#jsx/jsx-dev-runtime": "./lib/render/jsx-runtime.ts" | ||
| 34 | } | 32 | } |
| 35 | } | 33 | } |
src/blog/localization.ts+3-5| ... | @@ -1,8 +1,8 @@ | ... | @@ -1,8 +1,8 @@ |
| 1 | interface LanguageConfig { | 1 | interface LanguageConfig { |
| 2 | title: render.Node; | 2 | title: string; |
| 3 | return: render.Node; | 3 | return: string; |
| 4 | writtenByClover: string; | 4 | writtenByClover: string; |
| 5 | translatedBy: (_: { author: string }) => render.Node; | 5 | translatedBy: (_: { author: string }) => string; |
| 6 | tableOfContents: string; | 6 | tableOfContents: string; |
| 7 | } | 7 | } |
| 8 | type LanguageMap = Record<string, LanguageConfig> & { en: LanguageConfig }; | 8 | type LanguageMap = Record<string, LanguageConfig> & { en: LanguageConfig }; |
| ... | @@ -30,5 +30,3 @@ export const languages: LanguageMap = { | ... | @@ -30,5 +30,3 @@ export const languages: LanguageMap = { |
| 30 | tableOfContents: "Indice", | 30 | tableOfContents: "Indice", |
| 31 | }, | 31 | }, |
| 32 | }; | 32 | }; |
| 33 | |||
| 34 | import * as render from "@clo/lib/render"; |
src/blog/tags/blog-layout.marko+1-1| ... | @@ -17,7 +17,7 @@ export interface Module { | ... | @@ -17,7 +17,7 @@ export interface Module { |
| 17 | canonical: string; | 17 | canonical: string; |
| 18 | }; | 18 | }; |
| 19 | date: string; | 19 | date: string; |
| 20 | content: render.Node; | 20 | content: Marko.Body; |
| 21 | translation?: { | 21 | translation?: { |
| 22 | lang: string; | 22 | lang: string; |
| 23 | author: string; | 23 | author: string; |
src/file-viewer/views/canvas.marko+1-1| ... | @@ -15,4 +15,4 @@ export const meta = { title: "canvas" }; | ... | @@ -15,4 +15,4 @@ export const meta = { title: "canvas" }; |
| 15 | 15 | ||
| 16 | client import "./canvas.client.ts"; | 16 | client import "./canvas.client.ts"; |
| 17 | 17 | ||
| 18 | import { addScript as AddScript } from "#sitegen"; | 18 | import { CloverScriptInclude as AddScript } from "#sitegen"; |
src/q+a/markdown.ts+5-5| ... | @@ -17,7 +17,7 @@ function questionRef(id: string) { | ... | @@ -17,7 +17,7 @@ function questionRef(id: string) { |
| 17 | const timestamp = questionIdToTimestamp(id); | 17 | const timestamp = questionIdToTimestamp(id); |
| 18 | if (!timestamp) { | 18 | if (!timestamp) { |
| 19 | console.error(`Invalid question ref: ${id}`); | 19 | console.error(`Invalid question ref: ${id}`); |
| 20 | return `<span class="question-ref">[Invalid Question Ref: #${render.escapeHtmlContent(id)}]</span>`; | 20 | return `<span class="question-ref">[Invalid Question Ref: #${string.escapeHtmlContent(id)}]</span>`; |
| 21 | } | 21 | } |
| 22 | return `<a class="custom question-ref" href="/q+a/${id}">${formatQuestionTimestamp(timestamp)}</a>`; | 22 | return `<a class="custom question-ref" href="/q+a/${id}">${formatQuestionTimestamp(timestamp)}</a>`; |
| 23 | } | 23 | } |
| ... | @@ -25,14 +25,14 @@ function questionRef(id: string) { | ... | @@ -25,14 +25,14 @@ function questionRef(id: string) { |
| 25 | function artifactRef(id: string) { | 25 | function artifactRef(id: string) { |
| 26 | const artifact = artifactMap[id]; | 26 | const artifact = artifactMap[id]; |
| 27 | if (!artifact) { | 27 | if (!artifact) { |
| 28 | return `<span class="artifact">[Invalid Artifact Ref: @${render.escapeHtmlContent(id)}]</span>`; | 28 | return `<span class="artifact">[Invalid Artifact Ref: @${string.escapeHtmlContent(id)}]</span>`; |
| 29 | } | 29 | } |
| 30 | const [title, url, type] = artifact; | 30 | const [title, url, type] = artifact; |
| 31 | if (!url) { | 31 | if (!url) { |
| 32 | return `<span>${render.escapeHtmlContent(title)}</span>`; | 32 | return `<span>${string.escapeHtmlContent(title)}</span>`; |
| 33 | } | 33 | } |
| 34 | return `<a class="custom artifact artifact-${type}" href="${url}">` | 34 | return `<a class="custom artifact artifact-${type}" href="${url}">` |
| 35 | + render.escapeHtmlContent(title) | 35 | + string.escapeHtmlContent(title) |
| 36 | + `</a>`; | 36 | + `</a>`; |
| 37 | } | 37 | } |
| 38 | 38 | ||
| ... | @@ -69,7 +69,7 @@ export function renderMarkdown(source: string) { | ... | @@ -69,7 +69,7 @@ export function renderMarkdown(source: string) { |
| 69 | ); | 69 | ); |
| 70 | } | 70 | } |
| 71 | 71 | ||
| 72 | import * as render from "@clo/lib/render"; | 72 | import * as string from "@clo/lib/string"; |
| 73 | import * as markodown from "@clo/markodown"; | 73 | import * as markodown from "@clo/markodown"; |
| 74 | import { artifactMap } from "./artifacts.ts"; | 74 | import { artifactMap } from "./artifacts.ts"; |
| 75 | import { formatQuestionTimestamp, questionIdToTimestamp } from "./format.ts"; | 75 | import { formatQuestionTimestamp, questionIdToTimestamp } from "./format.ts"; |
src/q+a/tags/question-layout.marko-1| ... | @@ -19,4 +19,3 @@ export const theme = { | ... | @@ -19,4 +19,3 @@ export const theme = { |
| 19 | }; | 19 | }; |
| 20 | 20 | ||
| 21 | import { addScript } from "#sitegen"; | 21 | import { addScript } from "#sitegen"; |
| 22 | import * as render from "@clo/lib/render"; |
src/q+a/tags/question.marko-1| ... | @@ -38,4 +38,3 @@ import { | ... | @@ -38,4 +38,3 @@ import { |
| 38 | formatQuestionISOTimestamp, | 38 | formatQuestionISOTimestamp, |
| 39 | } from "#src/q+a/format.ts"; | 39 | } from "#src/q+a/format.ts"; |
| 40 | import * as markdown from "#src/q+a/markdown.ts"; | 40 | import * as markdown from "#src/q+a/markdown.ts"; |
| 41 | import { addScript as Script } from "#sitegen"; |
src/site.ts+2-2| ... | @@ -22,7 +22,7 @@ export const backends: string[] = [ | ... | @@ -22,7 +22,7 @@ export const backends: string[] = [ |
| 22 | 22 | ||
| 23 | export const meta: MetaTemplate = { | 23 | export const meta: MetaTemplate = { |
| 24 | base: new URL("https://paperclover.net"), | 24 | base: new URL("https://paperclover.net"), |
| 25 | generator: true, | 25 | generator: "clover sitegen 3; https://git.paperclover.net/clo/sitegen", |
| 26 | }; | 26 | }; |
| 27 | 27 | ||
| 28 | // Font subsets reduce bandwidth and protect against proprietary font theft. | 28 | // Font subsets reduce bandwidth and protect against proprietary font theft. |
| ... | @@ -123,6 +123,6 @@ export const fonts: sg.Font[] = [ | ... | @@ -123,6 +123,6 @@ export const fonts: sg.Font[] = [ |
| 123 | ]; | 123 | ]; |
| 124 | 124 | ||
| 125 | import type * as sg from "#sitegen"; | 125 | import type * as sg from "#sitegen"; |
| 126 | import type { Template as MetaTemplate } from "@clo/lib/render/meta"; | 126 | import type { Template as MetaTemplate } from "@clo/lib/meta"; |
| 127 | import * as path from "node:path"; | 127 | import * as path from "node:path"; |
| 128 | import { nasRoot } from "./file-viewer/paths.ts"; | 128 | import { nasRoot } from "./file-viewer/paths.ts"; |