authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-03-23 22:29:41-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-04-06 13:11:39-07:00
log0c49257d99c7511a2855a13f1ebaa92aabaa900b
treebb5f6f3db47a2b29269c460d4a543ff6115ba97f
parentb3b4d0c104c93d533b94d4e4f804ea7e48032893
signature Signed by SSH key SHA256:xbd+BjjhyBfwk7GVoURf9Yx0gzDerHbvYv7SddNWmAs

feat: new `@clo/lib/meta`, delete `@clo/lib/render`

the new meta library is now here, split into `toTags` and `toHtml`, the former is compatible with TanStack Router's `head` API. resolves #52

21 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";
381import * as fs from "#sitegen/fs";381import * as fs from "#sitegen/fs";
382import type * as view from "#sitegen/view";382import type * as view from "#sitegen/view";
383import { ASSERT, UNWRAP } from "@clo/lib/assert";383import { ASSERT, UNWRAP } from "@clo/lib/assert";
384import * as meta from "@clo/lib/meta";
384import * as progress from "@clo/lib/progress";385import * as progress from "@clo/lib/progress";
385import * as meta from "@clo/lib/render/meta";
386import * as esbuild from "esbuild";386import * as esbuild from "esbuild";
387import * as path from "node:path";387import * as path from "node:path";
388import process from "node:process";388import 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 );
328328
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(
341341
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 }
359350
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";
466import type { FileItem } from "#sitegen";457import type { FileItem } from "#sitegen";
467import * as fs from "#sitegen/fs";458import * as fs from "#sitegen/fs";
468import * as error from "@clo/lib/error";459import * as error from "@clo/lib/error";
460import * as meta from "@clo/lib/meta";
469import * as progress from "@clo/lib/progress";461import * as progress from "@clo/lib/progress";
470import * as render from "@clo/lib/render";
471import * as meta from "@clo/lib/render/meta";
472462
473import SitegenWrap from "./sitegen-wrap.marko";463import SitegenWrap from "./sitegen-wrap.marko";
474464
framework/global.d.ts deleted-13
...@@ -1,13 +0,0 @@
1type Timer = ReturnType<typeof setTimeout>;
2type Awaitable<T> = T | Promise<T>;
3
4declare 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}
13import * 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}
7export interface ViewExports {7export 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}
13export interface Layout {
14 default?: render.Component;
15 theme?: css.Theme;
16 markoOnly?: boolean;
17}11}
18export interface PageRegenerateOptions {12export 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>;
52export type FontVariableAxis = number | [min: number, max: number];46export type FontVariableAxis = number | [min: number, max: number];
53export type FontRange = string | number | { start: number; end: number };47export type FontRange = string | number | { start: number; end: number };
5448
55export const context = new render.Context(() => ({
56 scripts: new Set<string>(),
57}));
58
59/** Add a client-side script to the page. */
60export 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 this49// 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[]) {
100export { default as CloverScriptInclude } from "./add-script.marko";85export { default as CloverScriptInclude } from "./add-script.marko";
10186
102import { UNWRAP } from "@clo/lib/assert";87import { UNWRAP } from "@clo/lib/assert";
103import * as render from "@clo/lib/render";88import type { Meta } from "@clo/lib/meta";
104import type * as meta from "@clo/lib/render/meta";
105import type * as css from "../css.ts";89import 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 viewer22// 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.
24export interface View<Props extends Record<string, unknown>> {24export 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;
37export async function serve<K extends Key>(36export 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}
4847
49type PropsFromModule<M extends any> = M extends {
50 default: (props: infer T) => render.Node;
51} ? T
52 : never;
53
54export async function renderToString<K extends Key>(48export 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 }
8665
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 });
8972
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}
119102
120import { UNWRAP } from "@clo/lib/assert";103import { UNWRAP } from "@clo/lib/assert";
121import * as render from "@clo/lib/render";104import * as meta from "@clo/lib/meta";
122import * as meta from "@clo/lib/render/meta";
123import type * as hono from "hono";105import type * as hono from "hono";
124import type { RegenKey, RegisteredViews as ViewMap } from "../../.clover/ts/view.d.ts";106import type { RegenKey, RegisteredViews as ViewMap } from "../../.clover/ts/view.d.ts";
125import SitegenWrap from "../sitegen-wrap.marko";107import 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 @@
1const base = new URL("https://example.com/");
2const T: Template = { base };
3
4describe("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
21describe("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
87describe("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
104describe("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
120describe("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
152describe("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
216describe("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
237describe("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
253describe("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
276describe("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
307describe("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
345describe("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
380import assert from "node:assert";
381import { describe, test } from "vitest";
382import { type Template, toHtml, toTags } from "./meta.ts";
lib/meta.ts created+345
...@@ -0,0 +1,345 @@
1export 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}
56export 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}
63export 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}
71export type Alternate = { type: string; url: string } | {
72 lang: string;
73 url: string;
74};
75export 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. */
88export 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 */
117type OpenGraphField =
118 | string
119 | { [field: string]: OpenGraphField }
120 | Array<OpenGraphField>
121 | (null | undefined);
122/** Twitter uses various OpenGraph fields if these are not specified. */
123export 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}
133export interface Template extends Omit<Meta, "title" | "description" | "canonical"> {
134 base: URL;
135 titleTemplate?: (title: string) => string;
136}
137export 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";
145export type ExtraItem = {
146 tag: "meta" | "link" | "script" | "style";
147 [attr: string]: string | boolean | null | undefined;
148};
149export 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
158void (NaN as string);
159
160/** convert a metadata definition into a structured list of tags, compatible with TanStack start */
161export 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. */
296export 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
322function 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
331function 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
338function attr(value: string) {
339 value = string.escapeHtmlAttr(value);
340 if (value.match(/["/> ]/)) return "\"" + value + "\"";
341 return value;
342}
343
344import * as string from "./string.ts";
345import { escapeHtmlContent as esc } from "./string.ts";
lib/readme.changes.md+11
...@@ -2,6 +2,17 @@...@@ -2,6 +2,17 @@
22
3## v53## v5
44
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### features16### features
617
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. */
30export 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. */
38export 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
55interface 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 */
86export 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
130export type ContextValue<T extends object = object> = T & {
131 [kContext]: Context<T>;
132};
133
134/** the global state */
135export let current: State | null = null;
136
137/** set the global state */
138export function setCurrent(r: State | null) {
139 current = r ?? null;
140}
141
142/** inline HTML into a render without escaping it */
143export function raw(node: ResolvedNode): Raw {
144 return [kRaw, node];
145}
146
147/** constructs {@linkcode Element} */
148export 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} */
157export 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 */
166export 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. */
179export 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. */
189export type Element = [
190 tag: typeof kElement,
191 type: string | Component,
192 attrs: Record<string, unknown>,
193 _?: "", // contents
194 source?: SrcLoc,
195];
196export 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 */
201export type Component = (attrs: Record<any, any>) => Exclude<Node, undefined>;
202/** emitted by JSX runtime */
203export 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 */
215export 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
273export type ResolvedNode =
274 | ResolvedNode[] // Concat
275 | ResolvedElement // Render
276 | string; // Raw HTML
277export 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 */
289export 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 */
295export 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
312function 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
375function 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
389export function quoteIfNeeded(text: string): string {
390 if (text.match(/["/> ]/)) return "\"" + text + "\"";
391 return text;
392}
393
394// -- utility functions --
395
396/** Construct {@link State} */
397export 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
411export type ClassName = string | Record<string, boolean | null> | ClassName[];
412export 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 */
440export 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 */
447const 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 */
454export 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 */
464export 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} */
473const kElement = Symbol("Element");
474/** descriminator for {@linkcode Raw} */
475const kRaw = Symbol("Raw");
476/** secret key to facilitate {@linkcode init} with a `ContextValue[]` */
477const kContext: unique symbol = Symbol();
478
479import { 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. */
20export function Fragment(
21 { content }: { content: render.Node[] },
22): render.Node[] {
23 return content;
24}
25
26/** construct a JSX element in production mode. */
27export 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. */
38export 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.
66export { jsx as jsxs };
67
68export 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
80import { ASSERT } from "../assert.ts";
81import * as render from "../render.ts";
lib/render/meta.test.tsx deleted-49
...@@ -1,49 +0,0 @@
1// every metadata render requires a base URL
2const base: meta.Template = {
3 base: new URL("https://paperclover.net"),
4};
5
6test("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 ));
12test("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 ));
27test("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
47import assert from "node:assert";
48import { test } from "vitest";
49import * as meta from "./meta.ts";
lib/render/meta.ts deleted-256
...@@ -1,256 +0,0 @@
1export { renderMetadata as render };
2export 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}
50export type Alternate = { type: string; url: string } | {
51 lang: string;
52 url: string;
53};
54export 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. */
67export 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 */
96type OpenGraphField =
97 | string
98 | { [field: string]: OpenGraphField }
99 | Array<OpenGraphField>
100 | (null | undefined);
101/** Twitter uses various OpenGraph fields if these are not specified. */
102export 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}
112export interface Template extends Omit<Meta, "title" | "description" | "canonical"> {
113 base: URL;
114 titleTemplate?: (title: string) => string;
115}
116export interface Alternates {
117 canonical: string;
118 types: { [mime: string]: AlternateType };
119}
120export interface AlternateType {
121 url: string;
122 title: string;
123}
124export 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. */
134function 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
236function 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
251function attr(value: string) {
252 return render.quoteIfNeeded(esc(value));
253}
254
255import { escapeHtmlContent as esc } from "../render.ts";
256import * 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 @@
1interface LanguageConfig {1interface 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}
8type LanguageMap = Record<string, LanguageConfig> & { en: LanguageConfig };8type 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
34import * 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" };
1515
16client import "./canvas.client.ts";16client import "./canvas.client.ts";
1717
18import { addScript as AddScript } from "#sitegen";18import { 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) {
25function artifactRef(id: string) {25function 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}
3838
...@@ -69,7 +69,7 @@ export function renderMarkdown(source: string) {...@@ -69,7 +69,7 @@ export function renderMarkdown(source: string) {
69 );69 );
70}70}
7171
72import * as render from "@clo/lib/render";72import * as string from "@clo/lib/string";
73import * as markodown from "@clo/markodown";73import * as markodown from "@clo/markodown";
74import { artifactMap } from "./artifacts.ts";74import { artifactMap } from "./artifacts.ts";
75import { formatQuestionTimestamp, questionIdToTimestamp } from "./format.ts";75import { 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};
2020
21import { addScript } from "#sitegen";21import { addScript } from "#sitegen";
22import * 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";
40import * as markdown from "#src/q+a/markdown.ts";40import * as markdown from "#src/q+a/markdown.ts";
41import { 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[] = [
2222
23export const meta: MetaTemplate = {23export 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};
2727
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];
124124
125import type * as sg from "#sitegen";125import type * as sg from "#sitegen";
126import type { Template as MetaTemplate } from "@clo/lib/render/meta";126import type { Template as MetaTemplate } from "@clo/lib/meta";
127import * as path from "node:path";127import * as path from "node:path";
128import { nasRoot } from "./file-viewer/paths.ts";128import { nasRoot } from "./file-viewer/paths.ts";