| 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 | 381 | import * as fs from "#sitegen/fs"; |
| 382 | 382 | import type * as view from "#sitegen/view"; |
| 383 | 383 | import { ASSERT, UNWRAP } from "@clo/lib/assert"; |
| 384 | import * as meta from "@clo/lib/meta"; | |
| 384 | 385 | import * as progress from "@clo/lib/progress"; |
| 385 | import * as meta from "@clo/lib/render/meta"; | |
| 386 | 386 | import * as esbuild from "esbuild"; |
| 387 | 387 | import * as path from "node:path"; |
| 388 | 388 | import process from "node:process"; |
framework/generate.ts+9-19| ... | ... | @@ -327,7 +327,7 @@ export async function preparePage( |
| 327 | 327 | ); |
| 328 | 328 | |
| 329 | 329 | // -- metadata -- |
| 330 | const renderedMeta = meta.render({ | |
| 330 | const renderedMeta = meta.toHtml({ | |
| 331 | 331 | canonical: item.id, |
| 332 | 332 | ...typeof metadata === "function" |
| 333 | 333 | ? await metadata({ ssr: true }) |
| ... | ... | @@ -341,21 +341,12 @@ export async function preparePage( |
| 341 | 341 | |
| 342 | 342 | // -- html -- |
| 343 | 343 | let text: string; |
| 344 | const sitegen = sg.context.init(); | |
| 345 | if ("render" in Page) { | |
| 346 | text = await SitegenWrap.render({ | |
| 347 | cloverScripts: sitegen.scripts, | |
| 348 | page: Page, | |
| 349 | attrs: {}, | |
| 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 | } | |
| 344 | const cloverScripts = new Set<string>(); | |
| 345 | text = await SitegenWrap.render({ | |
| 346 | cloverScripts, | |
| 347 | page: Page, | |
| 348 | attrs: {}, | |
| 349 | }); | |
| 359 | 350 | |
| 360 | 351 | const styleKey = css.styleKey(cssImports, theme); |
| 361 | 352 | return { |
| ... | ... | @@ -368,7 +359,7 @@ export async function preparePage( |
| 368 | 359 | cssImports, |
| 369 | 360 | theme: theme ?? null, |
| 370 | 361 | styleKey, |
| 371 | clientRefs: Array.from(sitegen.scripts), | |
| 362 | clientRefs: Array.from(cloverScripts), | |
| 372 | 363 | } as const; |
| 373 | 364 | } catch (e) { |
| 374 | 365 | error.rethrowWithMetadata(e, { page: item.id }); |
| ... | ... | @@ -466,9 +457,8 @@ import * as sg from "#sitegen"; |
| 466 | 457 | import type { FileItem } from "#sitegen"; |
| 467 | 458 | import * as fs from "#sitegen/fs"; |
| 468 | 459 | import * as error from "@clo/lib/error"; |
| 460 | import * as meta from "@clo/lib/meta"; | |
| 469 | 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 | 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 | 5 | markoOnly?: boolean; |
| 6 | 6 | } |
| 7 | 7 | export interface ViewExports { |
| 8 | default: render.Component; | |
| 9 | meta: meta.Meta | ((props: { ssr: true }) => Promise<meta.Meta> | meta.Meta); | |
| 8 | default: Marko.Component; | |
| 9 | meta: Meta | ((props: { ssr: true }) => Promise<Meta> | Meta); | |
| 10 | 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 | 12 | export interface PageRegenerateOptions { |
| 19 | 13 | tags?: string[]; |
| ... | ... | @@ -52,15 +46,6 @@ export type FontVars = Record<string, FontVariableAxis>; |
| 52 | 46 | export type FontVariableAxis = number | [min: number, max: number]; |
| 53 | 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 | 49 | // TODO: self-host plausible for extra privacy. i am in a rush as i write this |
| 65 | 50 | // TODO: properly locate this constant in `site.ts` |
| 66 | 51 | // |
| ... | ... | @@ -100,6 +85,5 @@ export function joinScripts(scriptSources: string[]) { |
| 100 | 85 | export { default as CloverScriptInclude } from "./add-script.marko"; |
| 101 | 86 | |
| 102 | 87 | import { UNWRAP } from "@clo/lib/assert"; |
| 103 | import * as render from "@clo/lib/render"; | |
| 104 | import type * as meta from "@clo/lib/render/meta"; | |
| 88 | import type { Meta } from "@clo/lib/meta"; | |
| 105 | 89 | import type * as css from "../css.ts"; |
framework/lib/view.ts+13-31| ... | ... | @@ -22,9 +22,8 @@ export interface Codegen { |
| 22 | 22 | // separate for run-time dynamic scripts. For example, the file viewer |
| 23 | 23 | // includes the canvas for the current page, but only the current page. |
| 24 | 24 | export interface View<Props extends Record<string, unknown>> { |
| 25 | component: render.Component; | |
| 25 | component: Marko.Component<Props>; | |
| 26 | 26 | meta: meta.Meta | ((props: Props) => Promise<meta.Meta> | meta.Meta); |
| 27 | layout?: render.Component; | |
| 28 | 27 | inlineCss: string; |
| 29 | 28 | scripts: Record<string, string>; |
| 30 | 29 | } |
| ... | ... | @@ -37,7 +36,7 @@ export type Key = keyof ViewMap; |
| 37 | 36 | export async function serve<K extends Key>( |
| 38 | 37 | context: hono.Context, |
| 39 | 38 | id: K, |
| 40 | props: PropsFromModule<ViewMap[K]>, | |
| 39 | props: Record<string, unknown>, | |
| 41 | 40 | ) { |
| 42 | 41 | return context.html(await renderToString(id, { context, ...props }), { |
| 43 | 42 | headers: { |
| ... | ... | @@ -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 | 48 | export async function renderToString<K extends Key>( |
| 55 | 49 | id: K, |
| 56 | attrs: PropsFromModule<ViewMap[K]>, | |
| 50 | attrs: Record<string, unknown>, | |
| 57 | 51 | ) { |
| 58 | 52 | const { |
| 59 | 53 | component: Page, |
| 60 | 54 | inlineCss, |
| 61 | layout, | |
| 62 | 55 | meta: metadata, |
| 63 | }: View<PropsFromModule<ViewMap[K]>> = UNWRAP( | |
| 56 | }: View<Record<string, unknown>> = UNWRAP( | |
| 64 | 57 | codegen.views[id], |
| 65 | 58 | `Missing view ${id}`, |
| 66 | 59 | ); |
| ... | ... | @@ -68,24 +61,14 @@ export async function renderToString<K extends Key>( |
| 68 | 61 | // -- metadata -- |
| 69 | 62 | const renderedMetaPromise = Promise.resolve( |
| 70 | 63 | typeof metadata === "function" ? metadata(attrs) : metadata, |
| 71 | ).then((m) => meta.render(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 | } | |
| 64 | ).then((m) => meta.toHtml(m, codegen.metaTemplate)); | |
| 86 | 65 | |
| 87 | text = (await render.async(page, [sitegen])).text; | |
| 88 | } | |
| 66 | const cloverScripts = new Set<string>(); | |
| 67 | const text = await SitegenWrap.render({ | |
| 68 | cloverScripts, | |
| 69 | page: Page, | |
| 70 | attrs, | |
| 71 | }); | |
| 89 | 72 | |
| 90 | 73 | // -- join document and send -- |
| 91 | 74 | return sg.wrapDocument({ |
| ... | ... | @@ -94,7 +77,7 @@ export async function renderToString<K extends Key>( |
| 94 | 77 | inlineCss, |
| 95 | 78 | scripts: sg.joinScripts( |
| 96 | 79 | Array.from( |
| 97 | sitegen.scripts, | |
| 80 | cloverScripts, | |
| 98 | 81 | (id) => UNWRAP(codegen.scripts[id], `Missing script ${id}`), |
| 99 | 82 | ), |
| 100 | 83 | ), |
| ... | ... | @@ -118,8 +101,7 @@ export function regenerate(tag: RegenKey) { |
| 118 | 101 | } |
| 119 | 102 | |
| 120 | 103 | import { UNWRAP } from "@clo/lib/assert"; |
| 121 | import * as render from "@clo/lib/render"; | |
| 122 | import * as meta from "@clo/lib/render/meta"; | |
| 104 | import * as meta from "@clo/lib/meta"; | |
| 123 | 105 | import type * as hono from "hono"; |
| 124 | 106 | import type { RegenKey, RegisteredViews as ViewMap } from "../../.clover/ts/view.d.ts"; |
| 125 | 107 | import SitegenWrap from "../sitegen-wrap.marko"; |
lib/deno.json+1-9| ... | ... | @@ -12,12 +12,11 @@ |
| 12 | 12 | "./log": "./log.ts", |
| 13 | 13 | "./log/stack": "./log/stack.ts", |
| 14 | 14 | "./Lru": "./Lru.ts", |
| 15 | "./meta": "./meta.ts", | |
| 15 | 16 | "./mime": "./mime.ts", |
| 16 | 17 | "./node": "./node.ts", |
| 17 | 18 | "./progress": "./progress.ts", |
| 18 | 19 | "./queue": "./queue.ts", |
| 19 | "./render": "./render.ts", | |
| 20 | "./render/jsx-runtime": "./render/jsx-runtime.ts", | |
| 21 | 20 | "./render/meta": "./render/meta.ts", |
| 22 | 21 | "./stream": "./stream.ts", |
| 23 | 22 | "./string": "./string.ts", |
| ... | ... | @@ -27,13 +26,6 @@ |
| 27 | 26 | "./testing": "./testing.ts", |
| 28 | 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 | 29 | "exclude": [ |
| 38 | 30 | "readme.npm.md", |
| 39 | 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 | 2 | |
| 3 | 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 | 16 | ### features |
| 6 | 17 | |
| 7 | 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 | 28 | "imports": { |
| 29 | 29 | "#src/*": "./src/*", |
| 30 | 30 | "#sitegen": "./framework/lib/sitegen.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" | |
| 31 | "#sitegen/*": "./framework/lib/*.ts" | |
| 34 | 32 | } |
| 35 | 33 | } |
src/blog/localization.ts+3-5| ... | ... | @@ -1,8 +1,8 @@ |
| 1 | 1 | interface LanguageConfig { |
| 2 | title: render.Node; | |
| 3 | return: render.Node; | |
| 2 | title: string; | |
| 3 | return: string; | |
| 4 | 4 | writtenByClover: string; |
| 5 | translatedBy: (_: { author: string }) => render.Node; | |
| 5 | translatedBy: (_: { author: string }) => string; | |
| 6 | 6 | tableOfContents: string; |
| 7 | 7 | } |
| 8 | 8 | type LanguageMap = Record<string, LanguageConfig> & { en: LanguageConfig }; |
| ... | ... | @@ -30,5 +30,3 @@ export const languages: LanguageMap = { |
| 30 | 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 | 17 | canonical: string; |
| 18 | 18 | }; |
| 19 | 19 | date: string; |
| 20 | content: render.Node; | |
| 20 | content: Marko.Body; | |
| 21 | 21 | translation?: { |
| 22 | 22 | lang: string; |
| 23 | 23 | author: string; |
src/file-viewer/views/canvas.marko+1-1| ... | ... | @@ -15,4 +15,4 @@ export const meta = { title: "canvas" }; |
| 15 | 15 | |
| 16 | 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 | 17 | const timestamp = questionIdToTimestamp(id); |
| 18 | 18 | if (!timestamp) { |
| 19 | 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 | 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 | 25 | function artifactRef(id: string) { |
| 26 | 26 | const artifact = artifactMap[id]; |
| 27 | 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 | 30 | const [title, url, type] = artifact; |
| 31 | 31 | if (!url) { |
| 32 | return `<span>${render.escapeHtmlContent(title)}</span>`; | |
| 32 | return `<span>${string.escapeHtmlContent(title)}</span>`; | |
| 33 | 33 | } |
| 34 | 34 | return `<a class="custom artifact artifact-${type}" href="${url}">` |
| 35 | + render.escapeHtmlContent(title) | |
| 35 | + string.escapeHtmlContent(title) | |
| 36 | 36 | + `</a>`; |
| 37 | 37 | } |
| 38 | 38 | |
| ... | ... | @@ -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 | 73 | import * as markodown from "@clo/markodown"; |
| 74 | 74 | import { artifactMap } from "./artifacts.ts"; |
| 75 | 75 | import { formatQuestionTimestamp, questionIdToTimestamp } from "./format.ts"; |
src/q+a/tags/question-layout.marko-1| ... | ... | @@ -19,4 +19,3 @@ export const theme = { |
| 19 | 19 | }; |
| 20 | 20 | |
| 21 | 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 | 38 | formatQuestionISOTimestamp, |
| 39 | 39 | } from "#src/q+a/format.ts"; |
| 40 | 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 | 22 | |
| 23 | 23 | export const meta: MetaTemplate = { |
| 24 | 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 | 28 | // Font subsets reduce bandwidth and protect against proprietary font theft. |
| ... | ... | @@ -123,6 +123,6 @@ export const fonts: sg.Font[] = [ |
| 123 | 123 | ]; |
| 124 | 124 | |
| 125 | 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 | 127 | import * as path from "node:path"; |
| 128 | 128 | import { nasRoot } from "./file-viewer/paths.ts"; |