diff --git a/framework/bundle.ts b/framework/bundle.ts index f2abf269a8b08f04a3e3aa510fb5ada3e72c30dc..e16ba566f5583296394863466eabe285251547bf 100644 --- a/framework/bundle.ts +++ b/framework/bundle.ts @@ -381,8 +381,8 @@ import * as sg from "#sitegen"; import * as fs from "#sitegen/fs"; import type * as view from "#sitegen/view"; import { ASSERT, UNWRAP } from "@clo/lib/assert"; +import * as meta from "@clo/lib/meta"; import * as progress from "@clo/lib/progress"; -import * as meta from "@clo/lib/render/meta"; import * as esbuild from "esbuild"; import * as path from "node:path"; import process from "node:process"; diff --git a/framework/generate.ts b/framework/generate.ts index 0eb6d5ccdac23e9751123eba0c10a4e24d4a0cd0..844a0544bd8522a38735ff8745af14c18c08570b 100644 --- a/framework/generate.ts +++ b/framework/generate.ts @@ -327,7 +327,7 @@ export async function preparePage( ); // -- metadata -- - const renderedMeta = meta.render({ + const renderedMeta = meta.toHtml({ canonical: item.id, ...typeof metadata === "function" ? await metadata({ ssr: true }) @@ -341,21 +341,12 @@ export async function preparePage( // -- html -- let text: string; - const sitegen = sg.context.init(); - if ("render" in Page) { - text = await SitegenWrap.render({ - cloverScripts: sitegen.scripts, - page: Page, - attrs: {}, - }); - } else { - let page = render.element(Page); - if (layout?.default) { - page = render.element(layout.default, { content: page }); - } - - text = (await render.async(page, [sitegen])).text; - } + const cloverScripts = new Set(); + text = await SitegenWrap.render({ + cloverScripts, + page: Page, + attrs: {}, + }); const styleKey = css.styleKey(cssImports, theme); return { @@ -368,7 +359,7 @@ export async function preparePage( cssImports, theme: theme ?? null, styleKey, - clientRefs: Array.from(sitegen.scripts), + clientRefs: Array.from(cloverScripts), } as const; } catch (e) { error.rethrowWithMetadata(e, { page: item.id }); @@ -466,9 +457,8 @@ import * as sg from "#sitegen"; import type { FileItem } from "#sitegen"; import * as fs from "#sitegen/fs"; import * as error from "@clo/lib/error"; +import * as meta from "@clo/lib/meta"; import * as progress from "@clo/lib/progress"; -import * as render from "@clo/lib/render"; -import * as meta from "@clo/lib/render/meta"; import SitegenWrap from "./sitegen-wrap.marko"; diff --git a/framework/global.d.ts b/framework/global.d.ts deleted file mode 100644 index e5064f8d276d83bcb242a7045991ebaf95609475..0000000000000000000000000000000000000000 --- a/framework/global.d.ts +++ /dev/null @@ -1,13 +0,0 @@ -type Timer = ReturnType; -type Awaitable = T | Promise; - -declare global { - namespace JSX { - export type IntrinsicElements = jsx.ts.IntrinsicElements; - export type ElementChildrenAttribute = jsx.ts.ElementChildrenAttribute; - export type Element = jsx.ts.Element; - export type ElementType = jsx.ts.ElementType; - export type ElementClass = jsx.ts.ElementClass; - } -} -import * as jsx from "@clo/lib/render/jsx-runtime"; diff --git a/framework/lib/sitegen.ts b/framework/lib/sitegen.ts index 88a0d1c839f6b1b848c559725a65cfc59e6449ce..ea479f1bf25136ca986ccf1c9b837e184a2528e2 100644 --- a/framework/lib/sitegen.ts +++ b/framework/lib/sitegen.ts @@ -5,15 +5,9 @@ export interface PageExports extends ViewExports { markoOnly?: boolean; } export interface ViewExports { - default: render.Component; - meta: meta.Meta | ((props: { ssr: true }) => Promise | meta.Meta); + default: Marko.Component; + meta: Meta | ((props: { ssr: true }) => Promise | Meta); theme?: css.Theme; - layout?: Layout; -} -export interface Layout { - default?: render.Component; - theme?: css.Theme; - markoOnly?: boolean; } export interface PageRegenerateOptions { tags?: string[]; @@ -52,15 +46,6 @@ export type FontVars = Record; export type FontVariableAxis = number | [min: number, max: number]; export type FontRange = string | number | { start: number; end: number }; -export const context = new render.Context(() => ({ - scripts: new Set(), -})); - -/** Add a client-side script to the page. */ -export function addScript(id: ScriptId | { value: ScriptId }) { - context.get().scripts.add(typeof id === "string" ? id : id.value); -} - // TODO: self-host plausible for extra privacy. i am in a rush as i write this // TODO: properly locate this constant in `site.ts` // @@ -100,6 +85,5 @@ export function joinScripts(scriptSources: string[]) { export { default as CloverScriptInclude } from "./add-script.marko"; import { UNWRAP } from "@clo/lib/assert"; -import * as render from "@clo/lib/render"; -import type * as meta from "@clo/lib/render/meta"; +import type { Meta } from "@clo/lib/meta"; import type * as css from "../css.ts"; diff --git a/framework/lib/view.ts b/framework/lib/view.ts index dfa28defa8f3b3762558ff443c3a1448eb3f7f6a..a324214c53bbcaa71f47911a6c0a079cc0fa41c2 100644 --- a/framework/lib/view.ts +++ b/framework/lib/view.ts @@ -22,9 +22,8 @@ export interface Codegen { // separate for run-time dynamic scripts. For example, the file viewer // includes the canvas for the current page, but only the current page. export interface View> { - component: render.Component; + component: Marko.Component; meta: meta.Meta | ((props: Props) => Promise | meta.Meta); - layout?: render.Component; inlineCss: string; scripts: Record; } @@ -37,7 +36,7 @@ export type Key = keyof ViewMap; export async function serve( context: hono.Context, id: K, - props: PropsFromModule, + props: Record, ) { return context.html(await renderToString(id, { context, ...props }), { headers: { @@ -46,21 +45,15 @@ export async function serve( }); } -type PropsFromModule = M extends { - default: (props: infer T) => render.Node; -} ? T - : never; - export async function renderToString( id: K, - attrs: PropsFromModule, + attrs: Record, ) { const { component: Page, inlineCss, - layout, meta: metadata, - }: View> = UNWRAP( + }: View> = UNWRAP( codegen.views[id], `Missing view ${id}`, ); @@ -68,24 +61,14 @@ export async function renderToString( // -- metadata -- const renderedMetaPromise = Promise.resolve( typeof metadata === "function" ? metadata(attrs) : metadata, - ).then((m) => meta.render(m, codegen.metaTemplate)); + ).then((m) => meta.toHtml(m, codegen.metaTemplate)); - let text; - const sitegen = sg.context.init(); - if ("render" in Page) { - text = await SitegenWrap.render({ - cloverScripts: sitegen.scripts, - page: Page, - attrs, - }); - } else { - let page = render.element(Page, attrs); - if (layout?.default) { - page = render.element(layout.default, { content: page }); - } - - text = (await render.async(page, [sitegen])).text; - } + const cloverScripts = new Set(); + const text = await SitegenWrap.render({ + cloverScripts, + page: Page, + attrs, + }); // -- join document and send -- return sg.wrapDocument({ @@ -94,7 +77,7 @@ export async function renderToString( inlineCss, scripts: sg.joinScripts( Array.from( - sitegen.scripts, + cloverScripts, (id) => UNWRAP(codegen.scripts[id], `Missing script ${id}`), ), ), @@ -118,8 +101,7 @@ export function regenerate(tag: RegenKey) { } import { UNWRAP } from "@clo/lib/assert"; -import * as render from "@clo/lib/render"; -import * as meta from "@clo/lib/render/meta"; +import * as meta from "@clo/lib/meta"; import type * as hono from "hono"; import type { RegenKey, RegisteredViews as ViewMap } from "../../.clover/ts/view.d.ts"; import SitegenWrap from "../sitegen-wrap.marko"; diff --git a/lib/deno.json b/lib/deno.json index d55bce2931fcb9412c4586b61714bf62d521a7cf..9095319340e35ab28b1a627f7dd5328e713ecdfa 100644 --- a/lib/deno.json +++ b/lib/deno.json @@ -12,12 +12,11 @@ "./log": "./log.ts", "./log/stack": "./log/stack.ts", "./Lru": "./Lru.ts", + "./meta": "./meta.ts", "./mime": "./mime.ts", "./node": "./node.ts", "./progress": "./progress.ts", "./queue": "./queue.ts", - "./render": "./render.ts", - "./render/jsx-runtime": "./render/jsx-runtime.ts", "./render/meta": "./render/meta.ts", "./stream": "./stream.ts", "./string": "./string.ts", @@ -27,13 +26,6 @@ "./testing": "./testing.ts", "./ts": "./ts.ts" }, - "imports": { - "@clo/lib/*": "./*.ts" - }, - "compilerOptions": { - "jsx": "react-jsx", - "jsxImportSource": "@clo/lib/render" - }, "exclude": [ "readme.npm.md", "package.json", diff --git a/lib/meta.test.ts b/lib/meta.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..d6737d169a2dc00fbea6b3d933dffb1cf9efc9fc --- /dev/null +++ b/lib/meta.test.ts @@ -0,0 +1,382 @@ +const base = new URL("https://example.com/"); +const T: Template = { base }; + +describe("title", () => { + test("is always first entry in meta", () => { + const { meta } = toTags({ title: "Hello" }, T); + assert.deepEqual(meta[0], { title: "Hello" }); + }); + + test("titleTemplate applied", () => { + const { meta } = toTags({ title: "Page" }, { base, titleTemplate: (t) => `${t} | Site` }); + assert.deepEqual(meta[0], { title: "Page | Site" }); + }); + + test("toHtml title is HTML-escaped", () => { + const out = toHtml({ title: "Hi" }, T); + assert.ok(out.startsWith("<b>Hi</b>")); + }); +}); + +describe("basic meta fields", () => { + test("multiple authors emit separate tags", () => { + const { meta } = toTags({ title: "T", authors: ["Alice", "Bob"] }, T); + assert.deepEqual( + meta.filter((m) => m.name === "author"), + [{ name: "author", content: "Alice" }, { name: "author", content: "Bob" }], + ); + }); + + test("keywords joined with comma-space", () => { + const { meta } = toTags({ title: "T", keywords: ["cats", "dogs", "fish"] }, T); + assert.ok(meta.some((m) => m.name === "keywords" && m.content === "cats, dogs, fish")); + }); + + test("generator: string used as-is", () => { + const { meta } = toTags({ title: "T", generator: "my-tool" }, T); + assert.ok(meta.some((m) => m.name === "generator" && m.content === "my-tool")); + }); + + test("themeColor string emits single tag without media", () => { + const { meta } = toTags({ title: "T", themeColor: "#fff" }, T); + const tags = meta.filter((m) => m.name === "theme-color"); + assert.equal(tags.length, 1); + assert.equal(tags[0]!.content, "#fff"); + assert.equal(tags[0]!.media, undefined); + }); + + test("themeColor object emits two tags with media queries", () => { + const { meta } = toTags({ title: "T", themeColor: { dark: "#111", light: "#eee" } }, T); + assert.ok( + meta.some((m) => m.name === "theme-color" && m.media === "(prefers-color-scheme:light)" && m.content === "#eee"), + ); + assert.ok( + meta.some((m) => m.name === "theme-color" && m.media === "(prefers-color-scheme:dark)" && m.content === "#111"), + ); + }); + + test("denyRobots from meta", () => { + const { meta } = toTags({ title: "T", denyRobots: true }, T); + assert.ok(meta.some((m) => m.name === "robots" && m.content === "noindex,nofollow")); + }); + + test("denyRobots from template", () => { + const { meta } = toTags({ title: "T" }, { ...T, denyRobots: true }); + assert.ok(meta.some((m) => m.name === "robots" && m.content === "noindex,nofollow")); + }); + + test("denyRobots: false on meta does not override template true", () => { + // null/undefined opts out; false is falsy so it also doesn't block + const { meta } = toTags({ title: "T", denyRobots: false }, { ...T, denyRobots: true }); + assert.ok(meta.some((m) => m.name === "robots")); + }); + + test("viewport defaults to mobile-friendly value", () => { + const { meta } = toTags({ title: "T" }, T); + assert.ok( + meta.some((m) => m.name === "viewport" && m.content === "width=device-width, initial-scale=1, maximum-scale=1"), + ); + }); + + test("viewport can be overridden", () => { + const { meta } = toTags({ title: "T", viewport: "width=device-width" }, T); + assert.ok(meta.some((m) => m.name === "viewport" && m.content === "width=device-width")); + }); +}); + +describe("links", () => { + test("canonical resolved against base", () => { + const { links } = toTags({ title: "T", canonical: "/page" }, T); + assert.ok(links.some((l) => l.rel === "canonical" && l.href === "https://example.com/page")); + }); + + test("canonical absolute URL kept as-is", () => { + const { links } = toTags({ title: "T", canonical: "https://other.com/x" }, T); + assert.ok(links.some((l) => l.rel === "canonical" && l.href === "https://other.com/x")); + }); + + test("manifest href not resolved", () => { + const { links } = toTags({ title: "T", manifest: "/manifest.json" }, T); + assert.ok(links.some((l) => l.rel === "manifest" && l.href === "/manifest.json")); + }); +}); + +describe("oEmbed", () => { + test("emits alternate link with json+oembed type, URL resolved", () => { + const { links } = toTags({ title: "T", oEmbed: "/oembed.json" }, T); + assert.ok(links.some((l) => + l.rel === "alternate" + && l.type === "application/json+oembed" + && l.href === "https://example.com/oembed.json" + )); + }); + + test("falls back to template.oEmbed", () => { + const { links } = toTags({ title: "T" }, { ...T, oEmbed: "/oembed.json" }); + assert.ok(links.some((l) => l.type === "application/json+oembed")); + }); +}); + +describe("alternates", () => { + test("array form with lang → hreflang link", () => { + const { links } = toTags({ title: "T", alternates: [{ lang: "fr", url: "/fr" }] }, T); + assert.ok(links.some((l) => l.rel === "alternate" && l.hreflang === "fr" && l.href === "https://example.com/fr")); + }); + + test("array form with type → type link", () => { + const { links } = toTags({ title: "T", alternates: [{ type: "application/rss+xml", url: "/feed.rss" }] }, T); + assert.ok(links.some((l) => l.rel === "alternate" && l.type === "application/rss+xml")); + }); + + test("record form: key with slash → type", () => { + const { links } = toTags({ title: "T", alternates: { "application/atom+xml": "/atom.xml" } }, T); + assert.ok(links.some((l) => l.type === "application/atom+xml" && !l.hreflang)); + }); + + test("record form: key without slash → hreflang", () => { + const { links } = toTags({ title: "T", alternates: { "ja": "/ja/" } }, T); + assert.ok(links.some((l) => l.hreflang === "ja" && !l.type)); + }); + + test("alternate URLs resolved against base", () => { + const { links } = toTags({ title: "T", alternates: [{ lang: "de", url: "/de" }] }, T); + assert.ok(links.some((l) => l.href === "https://example.com/de")); + }); + + test("falls back to template.alternates", () => { + const { links } = toTags({ title: "T" }, { ...T, alternates: [{ lang: "en", url: "/" }] }); + assert.ok(links.some((l) => l.hreflang === "en")); + }); +}); + +describe("embed", () => { + test("no thumbnail → twitter:card = summary", () => { + const { meta } = toTags({ title: "T", embed: {} }, T); + assert.ok(meta.some((m) => m.name === "twitter:card" && m.content === "summary")); + }); + + test("thumbnail → twitter:card = summary_large_image", () => { + const { meta } = toTags({ title: "T", embed: { thumbnail: "/img.png" } }, T); + assert.ok(meta.some((m) => m.name === "twitter:card" && m.content === "summary_large_image")); + }); + + test("thumbnailSize: icon overrides thumbnail default", () => { + const { meta } = toTags({ title: "T", embed: { thumbnail: "/img.png", thumbnailSize: "icon" } }, T); + assert.ok(meta.some((m) => m.name === "twitter:card" && m.content === "summary")); + }); + + test("thumbnail resolved and applied to both og:image and twitter:image", () => { + const { meta } = toTags({ title: "T", embed: { thumbnail: "/img.png" } }, T); + assert.ok(meta.some((m) => m.name === "og:image" && m.content === "https://example.com/img.png")); + assert.ok(meta.some((m) => m.name === "twitter:image" && m.content === "https://example.com/img.png")); + }); + + test("embed title falls back to page title for og", () => { + const { meta } = toTags({ title: "My Page", embed: {} }, T); + assert.ok(meta.some((m) => m.name === "og:title" && m.content === "My Page")); + }); + + test("embed title overrides page title for og", () => { + const { meta } = toTags({ title: "My Page", embed: { title: "Share Title" } }, T); + assert.ok(meta.some((m) => m.name === "og:title" && m.content === "Share Title")); + }); + + test("embed description falls back to page description", () => { + const { meta } = toTags({ title: "T", description: "page desc", embed: {} }, T); + assert.ok(meta.some((m) => m.name === "og:description" && m.content === "page desc")); + }); + + test("siteTitle sets og:site_name", () => { + const { meta } = toTags({ title: "T", embed: { siteTitle: "My Site" } }, T); + assert.ok(meta.some((m) => m.name === "og:site_name" && m.content === "My Site")); + }); + + test("canonical sets og:url when embed active", () => { + const { meta } = toTags({ title: "T", canonical: "/page", embed: {} }, T); + assert.ok(meta.some((m) => m.name === "og:url" && m.content === "https://example.com/page")); + }); + + test("no canonical → no og:url injected by embed", () => { + const { meta } = toTags({ title: "T", embed: {} }, T); + assert.ok(!meta.some((m) => m.name === "og:url")); + }); + + test("explicit openGraph fields override embed defaults", () => { + const { meta } = toTags({ title: "T", embed: { title: "Embed" }, openGraph: { title: "Explicit" } }, T); + assert.ok(meta.some((m) => m.name === "og:title" && m.content === "Explicit")); + assert.equal(meta.filter((m) => m.name === "og:title").length, 1); + }); + + test("embed sets og:type = website", () => { + const { meta } = toTags({ title: "T", embed: {} }, T); + assert.ok(meta.some((m) => m.name === "og:type" && m.content === "website")); + }); +}); + +describe("openGraph nested/array fields", () => { + test("nested object emits namespaced tags", () => { + const { meta } = toTags( + { title: "T", openGraph: { image: { url: "https://example.com/img.png", width: "800" } } }, + T, + ); + assert.ok(meta.some((m) => m.name === "og:image:url" && m.content === "https://example.com/img.png")); + assert.ok(meta.some((m) => m.name === "og:image:width" && m.content === "800")); + }); + + test("array value emits duplicate tags", () => { + const { meta } = toTags({ title: "T", openGraph: { image: ["https://a.com/1.png", "https://a.com/2.png"] } }, T); + assert.equal(meta.filter((m) => m.name === "og:image").length, 2); + }); + + test("null field is skipped", () => { + const { meta } = toTags({ title: "T", openGraph: { image: null } }, T); + assert.ok(!meta.some((m) => m.name === "og:image")); + }); +}); + +describe("template fallbacks", () => { + test("template fields used when meta omits them", () => { + const template: Template = { base, publisher: "Acme", keywords: ["a", "b"] }; + const { meta } = toTags({ title: "T" }, template); + assert.ok(meta.some((m) => m.name === "publisher" && m.content === "Acme")); + assert.ok(meta.some((m) => m.name === "keywords" && m.content === "a, b")); + }); + + test("meta field overrides template, no duplicate", () => { + const template: Template = { base, publisher: "Acme" }; + const { meta } = toTags({ title: "T", publisher: "Override" }, template); + assert.ok(meta.some((m) => m.name === "publisher" && m.content === "Override")); + assert.equal(meta.filter((m) => m.name === "publisher").length, 1); + }); +}); + +describe("toHtml serialization", () => { + test("attribute values with spaces are quoted", () => { + const out = toHtml({ title: "T", description: "hello world" }, T); + assert.ok(out.includes(`content="hello world"`)); + }); + + test("attribute values without special chars are unquoted", () => { + const out = toHtml({ title: "T", publisher: "Acme" }, T); + assert.ok(out.includes(`content=Acme`)); + }); + + test("attribute value with slash is quoted", () => { + const out = toHtml({ title: "T" }, T); + // viewport contains / — ensure it's quoted + assert.ok(out.includes(`content="width=device-width`)); + }); + + test("description with double quotes is escaped", () => { + const out = toHtml({ title: "T", description: `say "hi"` }, T); + assert.ok(!out.includes(`"say "hi"`)); + }); +}); + +describe("scripts", () => { + test("inline script emits children in scripts array", () => { + const { scripts } = toTags({ title: "T", scripts: [{ content: "alert(1)" }] }, T); + assert.deepEqual(scripts, [{ children: "alert(1)" }]); + }); + + test("external script with src and boolean attrs", () => { + const { scripts } = toTags({ title: "T", scripts: [{ src: "/app.js", async: true, defer: false }] }, T); + assert.equal(scripts.length, 1); + assert.equal(scripts[0].src, "/app.js"); + assert.equal(scripts[0].async, true); + assert.equal(scripts[0].defer, undefined); + }); + + test("toHtml renders inline script", () => { + const out = toHtml({ title: "T", scripts: [{ content: "console.log(1)" }] }, T); + assert.ok(out.includes("")); + }); + + test("toHtml renders external script with boolean attr", () => { + const out = toHtml({ title: "T", scripts: [{ src: "/app.js", async: true }] }, T); + assert.ok(out.includes("")); + }); + + test("falls back to template scripts", () => { + const { scripts } = toTags({ title: "T" }, { ...T, scripts: [{ content: "init()" }] }); + assert.deepEqual(scripts, [{ children: "init()" }]); + }); +}); + +describe("styles", () => { + test("inline style emits children in styles array", () => { + const { styles } = toTags({ title: "T", styles: [{ content: "body{margin:0}" }] }, T); + assert.deepEqual(styles, [{ children: "body{margin:0}" }]); + }); + + test("inline style with media", () => { + const { styles } = toTags({ title: "T", styles: [{ content: "p{color:blue}", media: "print" }] }, T); + assert.deepEqual(styles, [{ children: "p{color:blue}", media: "print" }]); + }); + + test("external stylesheet goes into links", () => { + const { links, styles } = toTags({ title: "T", styles: [{ href: "/style.css" }] }, T); + assert.ok(links.some((l) => l.rel === "stylesheet" && l.href === "/style.css")); + assert.equal(styles.length, 0); + }); + + test("external stylesheet with media", () => { + const { links } = toTags({ title: "T", styles: [{ href: "/print.css", media: "print" }] }, T); + assert.ok(links.some((l) => l.rel === "stylesheet" && l.href === "/print.css" && l.media === "print")); + }); + + test("toHtml renders inline style", () => { + const out = toHtml({ title: "T", styles: [{ content: "body{margin:0}" }] }, T); + assert.ok(out.includes("")); + }); + + test("toHtml renders inline style with media", () => { + const out = toHtml({ title: "T", styles: [{ content: "p{color:blue}", media: "print" }] }, T); + assert.ok(out.includes("")); + }); + + test("falls back to template styles", () => { + const { styles } = toTags({ title: "T" }, { ...T, styles: [{ content: "h1{color:red}" }] }); + assert.deepEqual(styles, [{ children: "h1{color:red}" }]); + }); +}); + +describe("extra", () => { + test("tag=meta goes into meta array", () => { + const { meta } = toTags({ title: "T", extra: [{ tag: "meta", name: "verify", content: "abc" }] }, T); + assert.ok(meta.some((m) => m.name === "verify" && m.content === "abc")); + }); + + test("tag=link goes into links array", () => { + const { links } = toTags({ title: "T", extra: [{ tag: "link", rel: "icon", href: "/favicon.ico" }] }, T); + assert.ok(links.some((l) => l.rel === "icon" && l.href === "/favicon.ico")); + }); + + test("tag=script goes into scripts array", () => { + const { scripts } = toTags({ title: "T", extra: [{ tag: "script", src: "/extra.js", async: true }] }, T); + assert.equal(scripts.length, 1); + assert.equal(scripts[0].src, "/extra.js"); + assert.equal(scripts[0].async, true); + }); + + test("tag=style goes into styles array", () => { + const { styles } = toTags({ title: "T", extra: [{ tag: "style", children: "a{color:red}" }] }, T); + assert.deepEqual(styles, [{ children: "a{color:red}" }]); + }); + + test("null/false attrs are filtered out", () => { + const { meta } = toTags({ title: "T", extra: [{ tag: "meta", name: "x", content: "y", bogus: null }] }, T); + const entry = meta.find((m) => m.name === "x")!; + assert.equal(entry.bogus, undefined); + }); + + test("falls back to template extra", () => { + const { meta } = toTags({ title: "T" }, { ...T, extra: [{ tag: "meta", name: "tmpl", content: "yes" }] }); + assert.ok(meta.some((m) => m.name === "tmpl" && m.content === "yes")); + }); +}); + +import assert from "node:assert"; +import { describe, test } from "vitest"; +import { type Template, toHtml, toTags } from "./meta.ts"; diff --git a/lib/meta.ts b/lib/meta.ts new file mode 100644 index 0000000000000000000000000000000000000000..019a79c1c869d4b8d9155dd6d01607c19e487f56 --- /dev/null +++ b/lib/meta.ts @@ -0,0 +1,345 @@ +export interface Meta { + /** required for all pages. `{content}` */ + title: string; + /** recommended for all pages. `` */ + description?: string | null; + /** automatically added for static renders from the 'pages' folders. */ + canonical?: string | null; + /** add ``. Object keys are interpretted as + * mime types if they contain a slash, otherwise seen as an alternative language. */ + alternates?: Alternate[] | Record; + + /** automatically generate both OpenGraph and Twitter meta tags */ + embed?: AutoEmbed | null; + /** add a robots tag for `noindex` and `nofollow` */ + denyRobots?: boolean | null; + /** add 'og:*' meta tags */ + openGraph?: OpenGraph | null; + /** add 'twitter:*' meta tags */ + twitter?: Twitter | null; + /** refer to an oEmbed file. See https://oembed.com */ + oEmbed?: string; + /** add arbitrary scripts to the document head */ + scripts?: Array<{ content: string } | ExternalScript>; + /** add arbitrary styles to the document head */ + styles?: Array<{ content: string } | ExternalStylesheet>; + /** + * '@clo/lib/meta' intentionally excludes a lot of exotic tags. + * Use `{ tag, ...attrs }` to add arbitrary head elements: + * + * ```ts + * extra: [ + * { tag: "meta", name: "site-verification", content: "waffles" }, + * { tag: "link", rel: "icon", href: "/favicon.ico" }, + * ], + * ``` + */ + extra?: Array; + + /** adds `` */ + authors?: string[]; + /** credit your framework or toolchain */ + generator?: string | null; + /** adds `` */ + keywords?: string[]; + /** URL to a manifest; https://developer.mozilla.org/en-US/docs/Web/Manifest */ + manifest?: string | null; + /** adds `` */ + publisher?: string | null; + /** https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/meta/name/referrer */ + referrer?: Referrer | null; + /** adds `` */ + themeColor?: string | { dark: string; light: string } | null; + /** defaults to `width=device-width, initial-scale=1` for mobile compatibility. */ + viewport?: string; +} +export interface ExternalStylesheet { + rel?: "stylesheet"; + href: string; + media?: string; + [attr: string]: string | boolean | null | undefined; + // TODO: add the rest of the important attributes for autocomplete +} +export interface ExternalScript { + src: string; + async?: boolean; + defer?: boolean; + crossorigin?: "anonymous" | "use-credentials" | false; + [attr: string]: string | boolean | null | undefined; + // TODO: add the rest of the attributes for autocomplete +} +export type Alternate = { type: string; url: string } | { + lang: string; + url: string; +}; +export interface AutoEmbed { + /* defaults to the top level page title. */ + title?: string | null; + /* defaults to the top level page description. */ + description?: string | null; + /* add an image to the embed card. */ + thumbnail?: string | null; + /** @default "banner", which applies twitter:card = "summary_large_image" */ + thumbnailSize?: "banner" | "icon"; + /** @default null - no site title */ + siteTitle?: string | null; +} +/** See https://ogp.me for extra rules. */ +export interface OpenGraph { + /** the title of your object as it should appear within the graph */ + title?: string; + /** a one to two sentence description of your object. */ + description?: string | null; + /** the type of your object, e.g., "video.movie". Depending on the type you specify, other properties may also be required */ + type?: string; + /** an image URL which should represent your object within the graph */ + image?: OpenGraphField; + /** 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/" */ + url?: string; + /** a URL to an audio file to accompany this object */ + audio?: OpenGraphField; + /** 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) */ + determiner?: string; + /** the locale these tags are marked up in. Of the format language_TERRITORY. Default is en_US */ + locale?: string; + /** an array of other locales this page is available in */ + "locale:alternate"?: string[]; + /** if your object is part of a larger web site, the name which should be displayed for the overall site. e.g., "IMDb" */ + site_name?: string; + /** a URL to a video file that complements this object */ + video?: OpenGraphField; + [field: string]: OpenGraphField; +} +/** + * when passing an array, the property is duplicated. + * when passing an object, the fields are emitted as namespaced with ':'. + */ +type OpenGraphField = + | string + | { [field: string]: OpenGraphField } + | Array + | (null | undefined); +/** Twitter uses various OpenGraph fields if these are not specified. */ +export interface Twitter { + card: string; + title?: string; + description?: string | null; + url?: string; + image?: string; + player?: string; + /** Same logic as Open Graph */ + [field: string]: OpenGraphField; +} +export interface Template extends Omit { + base: URL; + titleTemplate?: (title: string) => string; +} +export type Referrer = + | "no-referrer" + | "origin" + | "no-referrer-when-downgrade" + | "origin-when-cross-origin" + | "same-origin" + | "strict-origin" + | "strict-origin-when-cross-origin"; +export type ExtraItem = { + tag: "meta" | "link" | "script" | "style"; + [attr: string]: string | boolean | null | undefined; +}; +export interface Tags { + meta: Array>; + links: Array>; + scripts: Array>; + styles: Array>; +} + +// TODO: before publishing clover lib v5, ensure this is actually compatibile with TanStack Router +// this type error will prevent publishing the library +void (NaN as string); + +/** convert a metadata definition into a structured list of tags, compatible with TanStack start */ +export function toTags(meta: Meta, template: Template): Tags { + const { titleTemplate, base } = template; + const resolve = (str: string) => new URL(str, base).href; + + const title = titleTemplate ? titleTemplate(meta.title) : meta.title; + const description = meta.description ?? null; + const canonical = meta.canonical ? resolve(meta.canonical) : null; + const denyRobots = Boolean(meta.denyRobots || template.denyRobots); + const authors = meta.authors ?? template.authors ?? null; + let generator = meta.generator ?? template.generator ?? null; + const keywords = meta.keywords ?? template.keywords ?? null; + const manifest = meta.manifest ?? template.manifest ?? null; + const publisher = meta.publisher ?? template.publisher ?? null; + const referrer = meta.referrer ?? template.referrer ?? null; + const themeColor = meta.themeColor ?? template.themeColor ?? null; + const viewport = meta.viewport ?? template.viewport + ?? "width=device-width, initial-scale=1, maximum-scale=1"; + + const alternates = meta.alternates ?? template.alternates ?? null; + const oEmbed = meta.oEmbed ?? template.oEmbed ?? null; + const embed = meta.embed ?? template.embed ?? null; + let openGraph = meta.openGraph ?? template.openGraph ?? null; + let twitter = meta.twitter ?? template.twitter ?? null; + if (embed) { + const { thumbnail, thumbnailSize, siteTitle } = embed; + openGraph = { + type: "website", + title: embed.title ?? title, + description: embed.description ?? description, + ...openGraph, + }; + twitter = { + card: (thumbnailSize ?? (thumbnail ? "banner" : "icon")) === "banner" + ? "summary_large_image" + : "summary", + ...twitter, + }; + if (thumbnail) { + const resolved = new URL(thumbnail, template.base).href; + openGraph.image = resolved; + twitter.image = resolved; + } + if (siteTitle) { + openGraph.site_name = siteTitle; + } + if (canonical) { + openGraph.url = canonical; + } + } + + const metaTags: Array> = [{ title }]; + const links: Array> = []; + + if (description) metaTags.push({ name: "description", content: description }); + for (const author of authors ?? []) metaTags.push({ name: "author", content: author }); + if (keywords) metaTags.push({ name: "keywords", content: keywords.join(", ") }); + if (generator) { + metaTags.push({ name: "generator", content: generator }); + } + if (publisher) metaTags.push({ name: "publisher", content: publisher }); + if (referrer) metaTags.push({ name: "referrer", content: referrer }); + if (themeColor) { + if (typeof themeColor === "string") { + metaTags.push({ name: "theme-color", content: themeColor }); + } else { + metaTags.push({ name: "theme-color", media: "(prefers-color-scheme:light)", content: themeColor.light }); + metaTags.push({ name: "theme-color", media: "(prefers-color-scheme:dark)", content: themeColor.dark }); + } + } + if (denyRobots) metaTags.push({ name: "robots", content: "noindex,nofollow" }); + if (viewport) metaTags.push({ name: "viewport", content: viewport }); + if (canonical) links.push({ rel: "canonical", href: canonical }); + if (manifest) links.push({ rel: "manifest", href: manifest }); + if (oEmbed) links.push({ rel: "alternate", type: "application/json+oembed", href: resolve(oEmbed) }); + if (alternates) { + const items = Array.isArray(alternates) + ? alternates + : Object.entries(alternates).map(([key, url]) => key.includes("/") ? { type: key, url } : { lang: key, url }); + for (const alt of items) { + const href = resolve(alt.url); + links.push( + "lang" in alt ? { rel: "alternate", hreflang: alt.lang, href } : { rel: "alternate", type: alt.type, href }, + ); + } + } + + if (openGraph) metaTags.push(...collectOpenGraph("og", openGraph)); + if (twitter) metaTags.push(...collectOpenGraph("twitter", twitter)); + + const scriptTags: Array> = []; + const styleTags: Array> = []; + + for (const entry of meta.scripts ?? template.scripts ?? []) { + if ("content" in entry) { + scriptTags.push({ children: entry.content }); + } else { + const { src, ...rest } = entry; + const tag: Record = { src }; + for (const [k, v] of Object.entries(rest)) { + if (v != null && v !== false) tag[k] = v; + } + scriptTags.push(tag); + } + } + + for (const entry of meta.styles ?? template.styles ?? []) { + if ("content" in entry) { + const tag: Record = { children: entry.content }; + if ("media" in entry && entry.media) tag.media = entry.media; + styleTags.push(tag); + } else { + const { href, ...rest } = entry; + const tag: Record = { rel: "stylesheet", href }; + for (const [k, v] of Object.entries(rest)) { + if (v != null && v !== false && k !== "rel") tag[k] = v as string; + } + links.push(tag); + } + } + + for (const { tag, ...attrs } of meta.extra ?? template.extra ?? []) { + const clean: Record = {}; + for (const [k, v] of Object.entries(attrs)) { + if (v != null && v !== false) clean[k] = v; + } + if (tag === "meta") metaTags.push(clean as Record); + else if (tag === "link") links.push(clean as Record); + else if (tag === "script") scriptTags.push(clean); + else if (tag === "style") styleTags.push(clean as Record); + } + + return { meta: metaTags, links, scripts: scriptTags, styles: styleTags }; +} + +/* convert a metadata definition into HTML text. */ +export function toHtml(meta: Meta, template: Template): string { + const tags = toTags(meta, template); + const [{ title }, ...metaTags] = tags.meta as [{ title: string }, ...Record[]]; + let out = `${esc(title)}`; + for (const attrs of metaTags) { + out += ` ` ${k}=${attr(v)}`).join("") + `>`; + } + for (const attrs of tags.links) { + out += ` ` ${k}=${attr(v)}`).join("") + `>`; + } + for (const entry of tags.scripts) { + if ("children" in entry) { + out += ``; + } else { + out += ``; + } + } + for (const entry of tags.styles) { + if ("children" in entry) { + const { children, ...rest } = entry; + out += `${children}`; + } + } + return out; +} + +function serializeAttrs(attrs: Record): string { + let out = ""; + for (const [k, v] of Object.entries(attrs)) { + if (v === true) out += ` ${k}`; + else if (v != null && v !== false) out += ` ${k}=${attr(v as string)}`; + } + return out; +} + +function collectOpenGraph(prefix: string, value: OpenGraphField): Array> { + if (!value) return []; + if (typeof value === "string") return [{ name: prefix, content: value }]; + if (Array.isArray(value)) return value.flatMap((item) => collectOpenGraph(prefix, item)); + return Object.entries(value).flatMap(([key, item]) => collectOpenGraph(`${prefix}:${key}`, item)); +} + +function attr(value: string) { + value = string.escapeHtmlAttr(value); + if (value.match(/["/> ]/)) return "\"" + value + "\""; + return value; +} + +import * as string from "./string.ts"; +import { escapeHtmlContent as esc } from "./string.ts"; diff --git a/lib/readme.changes.md b/lib/readme.changes.md index f0ffa07e537909f385cab8a82b5ea565c8fb659c..9fe2faa48603983de2d15cd200287f959700070c 100644 --- a/lib/readme.changes.md +++ b/lib/readme.changes.md @@ -2,6 +2,17 @@ ## v5 +### breaking + +- `render` has been deleted because it was not being used anymore. sad day + because i will never get to prove how easy it is to write a stable Suspense + and PPR implementations in less than 300 lines each. +- `render/meta` is moved to `meta`, with a new API for rendering tags + - `meta.render` is renamed to `meta.toHtml` + - `meta.toTags` creates an object compatible with TanStack Router + - the `extra` object now takes an array of React-like elements among some + other supported forms. + ### features - `mime`'s database contains `.eot` for embedded opentype fonts. diff --git a/lib/render.ts b/lib/render.ts deleted file mode 100644 index 7372b1596c010a110066c2dce9de87fb95e21489..0000000000000000000000000000000000000000 --- a/lib/render.ts +++ /dev/null @@ -1,479 +0,0 @@ -/** - * @deprecated replacing with pure Marko - * https://git.paperclover.net/clo/sitegen/issues/90 - * - * Clover's rendering engine is the backbone of her website generator. It - * converts objects and components (functions returning {@linkcode Node}) into - * HTML. the engine is simple, self-contained, and easily extendable. - * - * extensions to the rendering engine can made via {@linkcode Context}. for - * example, 'sitegen' uses a context to track needed client scripts without - * introducing patches to {@linkcode State}. - * - * the easiest way to start is with the JSX adapter `./jsx-runtime.ts` and the - * synchronous rendering function {@linkcode sync|render.sync}: - * - * ```tsx - * import * as render from "@clo/lib/render"; - * console.info(render.sync(

good morning

)); - * // jsx is equivilent to: - * console.info(render.sync(render.element("h1", { content: "good morning" }))); - * ``` - * - * NOTE: This module is not production ready, but it is in use at https://paperclover.net - * - * @module - */ - -/** convert a UI description into a string synchronously. - * optional `context` argument pre-populates render context. */ -export function sync(node: Node, contexts?: ContextValue[]): Result { - const state = init(false, contexts ?? []); - const resolved = resolveNode(state, node); - return { text: stringifyNode(resolved), context: state.context }; -} - -/** convert a UI description into a string asynchronously. - * optional `context` argument pre-populates render context. */ -export function async(node: Node, contexts?: ContextValue[]): Promise { - const state = init(true, contexts ?? []); - const resolved = resolveNode(state, node); - const { context } = state; - if (!state.async) { - return Promise.resolve({ text: stringifyNode(resolved), context }); - } - const { resolve, reject, promise } = Promise.withResolvers(); - state.asyncDone = () => { - const { rejections } = state; - if (!rejections) return resolve({ text: stringifyNode(resolved), context }); - if (rejections.length === 1) return reject(rejections[0]); - return reject(new AggregateError(rejections)); - }; - return promise; -} - -interface Result { - /** HTML/XML/RSS Text*/ - text: string; - /** For use with {@linkcode Context}. */ - context: Map; -} - -/** - * mechanism for extending the rendering engine by providing the component tree - * with a shared context. - * - * ```tsx - * const exampleContext = new render.Context(() => ({ count: 0 })); - * - * function Write({ add }) { - * const example: { count: number } = exampleContext.get() - * example.count += add; - * return null; - * } - * function Read() { - * const { count } = exampleContext.get() - * return `Value is: ${count}`; - * } - * - * render.sync([ - * - * - * - * ]); // -> "Value is: 42" - * ``` - */ -export class Context { - #default: (() => T) | null; - - constructor(init?: () => T) { - this.#default = init ?? null; - } - - /** - * Allow pre-populating a render's context - * - * ```tsx - * const state = exampleContext.init({ count: 5 }); - * void render.sync(, [state]); - * state.count; // 10 - * ``` - */ - init(value: T | void): ContextValue { - value ??= UNWRAP(this.#default)(); - (value as ContextValue)[kContext] = this; - return value as ContextValue; - } - - /** - * retrieve the context value from - * - passing `undefined`, retrieves from current render - * - passing a `Result`, typesafe access - * - * due to the implementation using simple global state, this function will - * not work within `async` components. - */ - get(result: Result): T | null; - get(): T; - get(result?: Result): T | null { - if (result) return (result.context.get(this) ?? null) as T | null; - const { context } = UNWRAP(current, "Not in an active render."); - let value = (context.get(this) ?? null) as T | null; - if (!value) { - const init = this.#default; - context.set(this, value = UNWRAP(init, "No init function")()); - } - return value; - } -} - -export type ContextValue = T & { - [kContext]: Context; -}; - -/** the global state */ -export let current: State | null = null; - -/** set the global state */ -export function setCurrent(r: State | null) { - current = r ?? null; -} - -/** inline HTML into a render without escaping it */ -export function raw(node: ResolvedNode): Raw { - return [kRaw, node]; -} - -/** constructs {@linkcode Element} */ -export function element( - type: Element[1], - attrs: Element[2] = {}, - source?: SrcLoc, -): Element { - return source ? [kElement, type, attrs, "", source] : [kElement, type, attrs]; -} - -/** constructs {@linkcode ResolvedElement} */ -export function resolvedElement( - type: ResolvedElement[1], - attrs: ResolvedElement[2], - content: ResolvedElement[3], -): ResolvedElement { - return [kElement, type, attrs, content]; -} - -/** internal global state while rendering */ -export interface State { - /** - * set to '-1' if rendering synchronously - * number of async promises the render is waiting on. - */ - async: number | -1; - asyncDone: null | (() => void); - /** when components reject, those are collected here */ - rejections: unknown[] | null; - context: Map; -} - -/** `Node` represents a webpage that can be "rendered' into HTML. */ -export type Node = - | number - | string // Escape HTML - | Node[] // Concat - | Element // Stringify - | Raw // Insert - | Promise // Await - // Ignore - | (undefined | null | boolean); -/** `Element` represents a component call or HTML element. */ -export type Element = [ - tag: typeof kElement, - type: string | Component, - attrs: Record, - _?: "", // contents - source?: SrcLoc, -]; -export type Raw = [tag: typeof kRaw, html: ResolvedNode]; -/** - * components must return a value; 'undefined' is prohibited here - * to avoid functions that are missing a return statement. - */ -export type Component = (attrs: Record) => Exclude; -/** emitted by JSX runtime */ -export interface SrcLoc { - fileName: string; - lineNumber: number; - columnNumber: number; -} - -/** - * resolution narrows the type {@linkcode Node} into {@linkcode ResolvedNode}. - * Async trees are marked in {@linkcode State}. This operation performs - * everything besides the final string concatenation. This function is agnostic - * across {@linkcode async}/{@linkcode sync} modes. - */ -export function resolveNode(r: State, node: unknown): ResolvedNode { - if (!node && node !== 0) return ""; // falsy, non numeric - if (typeof node !== "object") { - if (node === true) return ""; // booleans are ignored - if (typeof node === "string") return escapeHtmlContent(node); - if (typeof node === "number") return String(node); // no escaping ever - if (typeof node === "symbol" && node.toString() === kElement.toString()) { - throw new Error(`There are two instances of Clover SSR loaded!`); - } - throw new Error(`Cannot render ${inspect(node)} to HTML`); - } - if (node instanceof Promise) { - if (r.async === -1) { - throw new Error(`Asynchronous rendering is not supported here.`); - } - const placeholder: InsertionPoint = [null]; - r.async += 1; - node - .then((result) => void (placeholder[0] = resolveNode(r, result))) - // Intentionally catching errors in `resolveNode` - .catch((e) => (r.rejections ??= []).push(e)) - .finally(() => { - if (--r.async == 0) { - if (r.asyncDone == null) throw new Error("r.asyncDone == null"); - r.asyncDone(); - r.asyncDone = null; - } - }); - // This lie is checked with an assertion in `renderNode` - return placeholder as [ResolvedNode]; - } - if (!Array.isArray(node)) { - throw new Error(`Invalid node type: ${inspect(node)}`); - } - const type = node[0]; - if (type === kElement) { - const { 1: tag, 2: attrs } = node; - if (typeof tag === "function") { - current = r; - try { - return resolveNode(r, tag(attrs)); - } catch (e) { - const { 4: src } = node; - if (e && typeof e === "object") (e as { src?: string }).src ??= src; - throw e; - } finally { - current = null; - } - } - if (typeof tag !== "string") throw new Error("Unexpected " + inspect(type)); - const content = attrs?.content; - if (content) return [kElement, tag, attrs, resolveNode(r, content)]; - return node; - } - if (type === kRaw) return node[1]; - return node.map((elem) => resolveNode(r, elem)); -} - -export type ResolvedNode = - | ResolvedNode[] // Concat - | ResolvedElement // Render - | string; // Raw HTML -export type ResolvedElement = [ - tag: typeof kElement, - type: string, - attrs: Record, - content: ResolvedNode, -]; -/** - * async rendering is done by creating an array of one item, which is already a - * valid {@linkcode Node}, but the element is written once the data is - * available. {@linkcode State} contains a count of how many async jobs are - * left. - */ -export type InsertionPoint = [null | ResolvedNode]; - -/** - * convert {@linkcode ResolvedNode} into HTML text. This operation happens - * after all async work is settled. markup is emitted as concisely as possible. - */ -export function stringifyNode(node: ResolvedNode): string { - ASSERT(node != null, "Unresolved Render Node"); - if (typeof node === "string") return node; - const type = node[0]; - if (type === kElement) { - return stringifyElement(node as ResolvedElement); - } - node = node as ResolvedNode[]; // TS cannot infer. - let out = type ? stringifyNode(type) : ""; - const len = node.length; - for (let i = 1; i < len; i++) { - const elem = node[i]; - if (elem) out += stringifyNode(elem); - } - return out; -} - -function stringifyElement(element: ResolvedElement) { - const { 1: tag, 2: attrs, 3: content } = element; - let out = "<" + tag; - for (const prop in attrs) { - const value = attrs[prop]; - if (!value || typeof value === "function") continue; - let attr; - switch (prop) { - default: - attr = `${prop}=${quoteIfNeeded(escapeAttribute(String(value)))}`; - break; - case "className": // Legacy React Compat - case "class": { - const str = clsx(value as ClassName); - if (!str) continue; - attr = `class=${quoteIfNeeded(escapeAttribute(str))}`; - break; - } - case "style": { - attr = typeof value === "string" - ? `style=${quoteIfNeeded(escapeAttribute(value))}` - : stringifyStyleAttribute(value as Record); - break; - } - case "htmlFor": - throw new Error("Do not use the `htmlFor` attribute. Use `for`"); - case "dangerouslySetInnerHTML": - throw new Error( - "Do not use the `dangerouslySetInnerHTML` attribute. Use `render.raw()`", - ); - case "children": - throw new Error( - "Do not set the `children` attribute to set element contents. Use `content`", - ); - // Do not process these - case "content": - if (tag === "meta") { - attr = `${prop}=${quoteIfNeeded(escapeAttribute(String(value)))}`; - break; - } - continue; - case "ref": - case "key": - continue; - } - if (!out.endsWith("\"")) out += " "; - out += attr; - } - out = out.trimEnd() + ">"; - if (content) out += stringifyNode(content); - if ( - tag !== "br" - && tag !== "img" - && tag !== "input" - && tag !== "meta" - && tag !== "link" - && tag !== "hr" - ) { - out += ``; - } - return out; -} - -function stringifyStyleAttribute(style: Record): string { - let out = ``; - for (const styleName in style) { - if (out) out += ";"; - out += `${styleName.replace(/[A-Z]/g, "-$&").toLowerCase()}:${ - escapeAttribute( - String(style[styleName]), - ) - }`; - } - if (!out) return ""; - return "style=" + quoteIfNeeded(out); -} - -export function quoteIfNeeded(text: string): string { - if (text.match(/["/> ]/)) return "\"" + text + "\""; - return text; -} - -// -- utility functions -- - -/** Construct {@link State} */ -export function init( - allowAsync: boolean, - context: ContextValue[] | Map, -): State { - return { - async: allowAsync ? 0 : -1, - rejections: null, - asyncDone: null, - context: Array.isArray(context) - ? new Map(context.map((value) => [value[kContext], value])) - : context, - }; -} - -export type ClassName = string | Record | ClassName[]; -export function clsx(mix: ClassName): string { - let k, y, str = ""; - if (typeof mix === "string") { - return mix; - } else if (typeof mix === "object") { - if (Array.isArray(mix)) { - for (k = 0; k < mix.length; k++) { - if (mix[k] && (y = clsx(mix[k]!))) { - str && (str += " "); - str += y; - } - } - } else { - for (k in mix) { - if (mix[k]) { - str && (str += " "); - str += k; - } - } - } - } - return str; -} - -// TODO: combine into one function which decides if an attribute needs quotes -// and escapes it correctly depending on the context. - -/** @deprecated */ -export const escapeHtmlContent = (unsafeText: string): string => - String(unsafeText) - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """); -/** @deprecated */ -const escapeAttribute = (unsafeText: string): string => - String(unsafeText) - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """); -/** @deprecated */ -export const escapeHtml = (unsafeText: string): string => - String(unsafeText) - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """) - .replace(/'/g, "'") - .replace(/`/g, "`"); - -/** @deprecated */ -export function inspect(object: unknown): string { - try { - return require("node:util").inspect(object); - } catch { - return typeof object; - } -} - -/** descriminator for {@linkcode Element} */ -const kElement = Symbol("Element"); -/** descriminator for {@linkcode Raw} */ -const kRaw = Symbol("Raw"); -/** secret key to facilitate {@linkcode init} with a `ContextValue[]` */ -const kContext: unique symbol = Symbol(); - -import { ASSERT, UNWRAP } from "./assert.ts"; diff --git a/lib/render/jsx-runtime.ts b/lib/render/jsx-runtime.ts deleted file mode 100644 index f881935cca5a72fe6c9d7288aea3eea0244cccdd..0000000000000000000000000000000000000000 --- a/lib/render/jsx-runtime.ts +++ /dev/null @@ -1,81 +0,0 @@ -/** @module - * provides JSX integration with `lib/render.ts`. - * - * because the jsr does not allow default global augmentations, type - * installation must be done manually by pasting this in your project: - * - * declare global { - * namespace JSX { - * export type IntrinsicElements = jsx.ts.IntrinsicElements; - * export type ElementChildrenAttribute = jsx.ts.ElementChildrenAttribute; - * export type Element = jsx.ts.Element; - * export type ElementType = jsx.ts.ElementType; - * export type ElementClass = jsx.ts.ElementClass; - * } - * } - * import * as jsx from "@clo/lib/render/jsx-runtime"; - */ - -/** this element returns just its contents, and is used by `<>` fragment syntax. */ -export function Fragment( - { content }: { content: render.Node[] }, -): render.Node[] { - return content; -} - -/** construct a JSX element in production mode. */ -export function jsx( - type: string | render.Component, - { children, ...attrs }: Record, -): render.Element { - if (typeof type !== "function" && typeof type !== "string") { - throw new Error("Invalid component type: " + render.inspect(type)); - } - return render.element(type, { content: children, ...attrs }); -} - -/** construct a JSX element in development mode. */ -export function jsxDEV( - type: string | render.Component, - { children, ...attrs }: Record, - // Unused with the clover engine - _key: string, - // Unused with the clover engine - _staticContent: boolean, - source: render.SrcLoc, -): render.Element { - ASSERT( - source != null, - `Build tool does not emit correct JSX debugging information.`, - ); - const { fileName, lineNumber, columnNumber } = source; - - // Assert the component type is valid to render. - if (typeof type !== "function" && typeof type !== "string") { - throw new Error( - `Invalid component type at ${fileName}:${lineNumber}:${columnNumber}: ` - + render.inspect(type) - + ". Clover SSR element must be a function or string.", - ); - } - - return render.element(type, { content: children, ...attrs }, source); -} - -// Clover SSR engine does not yet distinguish between static and non-static JSX. -export { jsx as jsxs }; - -export declare namespace ts { - export interface IntrinsicElements { - [name: string]: Record; - } - export interface ElementChildrenAttribute { - content: render.Node; - } - export type Element = render.Element; - export type ElementType = keyof IntrinsicElements | render.Component; - export type ElementClass = ReturnType; -} - -import { ASSERT } from "../assert.ts"; -import * as render from "../render.ts"; diff --git a/lib/render/meta.test.tsx b/lib/render/meta.test.tsx deleted file mode 100644 index 17f7dd5bc29b9fe1dd16405818f6524486e20d46..0000000000000000000000000000000000000000 --- a/lib/render/meta.test.tsx +++ /dev/null @@ -1,49 +0,0 @@ -// every metadata render requires a base URL -const base: meta.Template = { - base: new URL("https://paperclover.net"), -}; - -test("default", () => - assert.equal( - meta.render({ title: "my website <3" }, base), - "my website <3" - + "", - )); -test("some other meta tags", () => - assert.equal( - meta.render( - { - title: "hello", - description: "my website is a place to be", - canonical: "/", - }, - base, - ), - "hello" - + "" - + "" - + "", - )); -test("automatic embed with '{}'", () => - assert.equal( - meta.render( - { - title: "main title", - description: "main description", - embed: {}, - }, - base, - ), - "main title" - + "" - + "" - + "" - + "" - + "" - + "" - + "", - )); - -import assert from "node:assert"; -import { test } from "vitest"; -import * as meta from "./meta.ts"; diff --git a/lib/render/meta.ts b/lib/render/meta.ts deleted file mode 100644 index 23ff826b4620888c9135dc7f0eb6e4fea08db69f..0000000000000000000000000000000000000000 --- a/lib/render/meta.ts +++ /dev/null @@ -1,256 +0,0 @@ -export { renderMetadata as render }; -export interface Meta { - /** Required for all pages. `{content}` */ - title: string; - /** Recommended for all pages. `` */ - description?: string | null; - /** Automatically added for static renders from the 'pages' folders. */ - canonical?: string | null; - /** Add ``. Object keys are interpretted as - * mime types if they contain a slash, otherwise seen as an alternative language. */ - alternates?: Alternate[] | Record; - - /** Automatically generate both OpenGraph and Twitter meta tags */ - embed?: AutoEmbed | null; - /** Add a robots tag for `noindex` and `nofollow` */ - denyRobots?: boolean | null; - /** Add 'og:*' meta tags */ - openGraph?: OpenGraph | null; - /** Add 'twitter:*' meta tags */ - twitter?: Twitter | null; - /** Refer to an oEmbed file. See https://oembed.com */ - oEmbed?: string; - /** - * '#sitegen/meta' intentionally excludes a lot of exotic tags. - * Add these manually using JSX syntax: - * - * extra: [ - * , - * ], - */ - extra?: render.Node; - - /** Adds `` */ - authors?: string[]; - /** Credit sitegen by setting this to true aka "clover sitegen 3; paperclover.net/sitegen" */ - generator?: string | true | null; - /** Adds `` */ - keywords?: string[]; - /** URL to a manifest; https://developer.mozilla.org/en-US/docs/Web/Manifest */ - manifest?: string | null; - /** Adds `` */ - publisher?: string | null; - /** https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/meta/name/referrer */ - referrer?: Referrer | null; - /** Adds `` */ - themeColor?: string | { dark: string; light: string } | null; - /** Defaults to `width=device-width, initial-scale=1.0` for mobile compatibility. */ - viewport?: string; -} -export type Alternate = { type: string; url: string } | { - lang: string; - url: string; -}; -export interface AutoEmbed { - /* Defaults to the page title. */ - title?: string | null; - /* Defaults to the page description. */ - description?: string | null; - /* Provide to add an embed image. */ - thumbnail?: string | null; - /** @default "banner", which applies twitter:card = "summary_large_image" */ - thumbnailSize?: "banner" | "icon"; - /* Ignored if not passed */ - siteTitle?: string | null; -} -/** See https://ogp.me for extra rules. */ -export interface OpenGraph { - /** The title of your object as it should appear within the graph */ - title?: string; - /** A one to two sentence description of your object. */ - description?: string | null; - /** The type of your object, e.g., "video.movie". Depending on the type you specify, other properties may also be required */ - type?: string; - /** An image URL which should represent your object within the graph */ - image?: OpenGraphField; - /** 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/" */ - url?: string; - /** A URL to an audio file to accompany this object */ - audio?: OpenGraphField; - /** 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) */ - determiner?: string; - /** The locale these tags are marked up in. Of the format language_TERRITORY. Default is en_US */ - locale?: string; - /** An array of other locales this page is available in */ - "locale:alternate"?: string[]; - /** If your object is part of a larger web site, the name which should be displayed for the overall site. e.g., "IMDb" */ - site_name?: string; - /** A URL to a video file that complements this object */ - video?: OpenGraphField; - [field: string]: OpenGraphField; -} -/** - * When passing an array, the property is duplicated. - * When passing an object, the fields are emitted as namespaced with ':'. - */ -type OpenGraphField = - | string - | { [field: string]: OpenGraphField } - | Array - | (null | undefined); -/** Twitter uses various OpenGraph fields if these are not specified. */ -export interface Twitter { - card: string; - title?: string; - description?: string | null; - url?: string; - image?: string; - player?: string; - /** Same logic as Open Graph */ - [field: string]: OpenGraphField; -} -export interface Template extends Omit { - base: URL; - titleTemplate?: (title: string) => string; -} -export interface Alternates { - canonical: string; - types: { [mime: string]: AlternateType }; -} -export interface AlternateType { - url: string; - title: string; -} -export type Referrer = - | "no-referrer" - | "origin" - | "no-referrer-when-downgrade" - | "origin-when-cross-origin" - | "same-origin" - | "strict-origin" - | "strict-origin-when-cross-origin"; - -/* Convert a metadata definition into text. */ -function renderMetadata(meta: Meta, template: Template): string { - const { titleTemplate, base } = template; - const resolve = (str: string) => new URL(str, base).href; - - const title = titleTemplate ? titleTemplate(meta.title) : meta.title; - const description = meta.description ?? null; - const canonical = meta.canonical ? resolve(meta.canonical) : null; - const denyRobots = Boolean(meta.denyRobots || template.denyRobots); - const authors = meta.authors ?? template.authors ?? null; - let generator = meta.generator ?? template.generator ?? null; - const keywords = meta.keywords ?? template.keywords ?? null; - const manifest = meta.manifest ?? template.manifest ?? null; - const publisher = meta.publisher ?? template.publisher ?? null; - const referrer = meta.referrer ?? template.referrer ?? null; - const themeColor = meta.themeColor ?? template.themeColor ?? null; - const viewport = meta.viewport ?? template.viewport - ?? "width=device-width, initial-scale=1, maximum-scale=1"; - - const embed = meta.embed ?? template.embed ?? null; - let openGraph = meta.openGraph ?? template.openGraph ?? null; - let twitter = meta.twitter ?? template.twitter ?? null; - if (embed) { - const { thumbnail, thumbnailSize, siteTitle } = embed; - openGraph = { - type: "website", - title: embed.title ?? title, - description: embed.description ?? description, - ...openGraph, - }; - twitter = { - card: (thumbnailSize ?? (thumbnail ? "banner" : "icon")) === "banner" - ? "summary_large_image" - : "summary", - ...twitter, - }; - if (thumbnail) { - const resolved = new URL(thumbnail, template.base).href; - openGraph.image = resolved; - twitter.image = resolved; - } - if (siteTitle) { - openGraph.site_name = siteTitle; - } - if (canonical) { - openGraph.url = canonical; - } - } - - let out = `${esc(title)}`; - if (description) { - out += ``; - } - for (const author of authors ?? []) { - out += ``; - } - if (keywords) { - out += ``; - } - if (generator) { - if (generator === true) { - generator = "clover sitegen 3; paperclover.net/sitegen"; - } - out += ``; - } - if (publisher) { - out += ``; - } - if (referrer) { - out += ``; - } - if (themeColor) { - if (typeof themeColor === "string") { - out += ``; - } else { - out += "`; - out += "`; - } - } - if (denyRobots) { - out += ``; - } - if (viewport) { - out += ``; - } - if (canonical) { - out += ``; - } - if (manifest) { - out += ``; - } - - if (openGraph) out += renderOpenGraph("og", openGraph); - if (twitter) out += renderOpenGraph("twitter", twitter); - - if (meta.extra) out += render.sync(meta.extra); - if (template.extra) out += render.sync(template.extra); - - return out; -} - -function renderOpenGraph(prefix: string, value: OpenGraphField): string { - if (!value) return ""; - if (typeof value === "string") { - return ``; - } - if (Array.isArray(value)) { - return value - .map((item) => renderOpenGraph(prefix, item)) - .join(""); - } - return Object.entries(value) - .map(([key, item]) => renderOpenGraph(`${prefix}:${key}`, item)) - .join(""); -} - -function attr(value: string) { - return render.quoteIfNeeded(esc(value)); -} - -import { escapeHtmlContent as esc } from "../render.ts"; -import * as render from "../render.ts"; diff --git a/package.json b/package.json index 09442d12a006ff25744562e5ed991da327a728e7..3e4dddc38106201f8016f350cf9c4413946505d7 100644 --- a/package.json +++ b/package.json @@ -28,8 +28,6 @@ "imports": { "#src/*": "./src/*", "#sitegen": "./framework/lib/sitegen.ts", - "#sitegen/*": "./framework/lib/*.ts", - "#jsx/jsx-runtime": "./lib/render/jsx-runtime.ts", - "#jsx/jsx-dev-runtime": "./lib/render/jsx-runtime.ts" + "#sitegen/*": "./framework/lib/*.ts" } } diff --git a/src/blog/localization.ts b/src/blog/localization.ts index 16859e225863daad941858fa1af7899c0925037b..17604daabdf34134d22bdaeab20cf2a0db3363d0 100644 --- a/src/blog/localization.ts +++ b/src/blog/localization.ts @@ -1,8 +1,8 @@ interface LanguageConfig { - title: render.Node; - return: render.Node; + title: string; + return: string; writtenByClover: string; - translatedBy: (_: { author: string }) => render.Node; + translatedBy: (_: { author: string }) => string; tableOfContents: string; } type LanguageMap = Record & { en: LanguageConfig }; @@ -30,5 +30,3 @@ export const languages: LanguageMap = { tableOfContents: "Indice", }, }; - -import * as render from "@clo/lib/render"; diff --git a/src/blog/tags/blog-layout.marko b/src/blog/tags/blog-layout.marko index 2a6c626f4b87c02a07997068ebd9648d6ec8b9bd..f9998c88e76118d9950aebd745e90038a0dcdcb7 100644 --- a/src/blog/tags/blog-layout.marko +++ b/src/blog/tags/blog-layout.marko @@ -17,7 +17,7 @@ export interface Module { canonical: string; }; date: string; - content: render.Node; + content: Marko.Body; translation?: { lang: string; author: string; diff --git a/src/file-viewer/views/canvas.marko b/src/file-viewer/views/canvas.marko index 5830acd60974cdfe01d07656efc852ef570eb126..5aa1335ccff4fb3dcc287eee5ca33ccfb44ee396 100644 --- a/src/file-viewer/views/canvas.marko +++ b/src/file-viewer/views/canvas.marko @@ -15,4 +15,4 @@ export const meta = { title: "canvas" }; client import "./canvas.client.ts"; -import { addScript as AddScript } from "#sitegen"; +import { CloverScriptInclude as AddScript } from "#sitegen"; diff --git a/src/q+a/markdown.ts b/src/q+a/markdown.ts index 10a7208f87d11f4813aad6988cdab1a410e117f7..0d73cdac7288b892109e5d49e1b04aa9341ac6c1 100644 --- a/src/q+a/markdown.ts +++ b/src/q+a/markdown.ts @@ -17,7 +17,7 @@ function questionRef(id: string) { const timestamp = questionIdToTimestamp(id); if (!timestamp) { console.error(`Invalid question ref: ${id}`); - return `[Invalid Question Ref: #${render.escapeHtmlContent(id)}]`; + return `[Invalid Question Ref: #${string.escapeHtmlContent(id)}]`; } return `${formatQuestionTimestamp(timestamp)}`; } @@ -25,14 +25,14 @@ function questionRef(id: string) { function artifactRef(id: string) { const artifact = artifactMap[id]; if (!artifact) { - return `[Invalid Artifact Ref: @${render.escapeHtmlContent(id)}]`; + return `[Invalid Artifact Ref: @${string.escapeHtmlContent(id)}]`; } const [title, url, type] = artifact; if (!url) { - return `${render.escapeHtmlContent(title)}`; + return `${string.escapeHtmlContent(title)}`; } return `` - + render.escapeHtmlContent(title) + + string.escapeHtmlContent(title) + ``; } @@ -69,7 +69,7 @@ export function renderMarkdown(source: string) { ); } -import * as render from "@clo/lib/render"; +import * as string from "@clo/lib/string"; import * as markodown from "@clo/markodown"; import { artifactMap } from "./artifacts.ts"; import { formatQuestionTimestamp, questionIdToTimestamp } from "./format.ts"; diff --git a/src/q+a/tags/question-layout.marko b/src/q+a/tags/question-layout.marko index 93945485dfe8c3e2b266d30ede8e927fea44fa54..19ce7a9896751f80983d63e7620e51deeae070ef 100644 --- a/src/q+a/tags/question-layout.marko +++ b/src/q+a/tags/question-layout.marko @@ -19,4 +19,3 @@ export const theme = { }; import { addScript } from "#sitegen"; -import * as render from "@clo/lib/render"; diff --git a/src/q+a/tags/question.marko b/src/q+a/tags/question.marko index 8e84f4e903466c3b2eff5203d2c5c6cd51ac6d21..837e00e56c35112bc662a07ec2b7450a6723e907 100644 --- a/src/q+a/tags/question.marko +++ b/src/q+a/tags/question.marko @@ -38,4 +38,3 @@ import { formatQuestionISOTimestamp, } from "#src/q+a/format.ts"; import * as markdown from "#src/q+a/markdown.ts"; -import { addScript as Script } from "#sitegen"; diff --git a/src/site.ts b/src/site.ts index 7801c0bebf1b4a446bc49a4add61d43123ae64ab..a0ce9dbab760ac8982e2f7be01e395f179408765 100644 --- a/src/site.ts +++ b/src/site.ts @@ -22,7 +22,7 @@ export const backends: string[] = [ export const meta: MetaTemplate = { base: new URL("https://paperclover.net"), - generator: true, + generator: "clover sitegen 3; https://git.paperclover.net/clo/sitegen", }; // Font subsets reduce bandwidth and protect against proprietary font theft. @@ -123,6 +123,6 @@ export const fonts: sg.Font[] = [ ]; import type * as sg from "#sitegen"; -import type { Template as MetaTemplate } from "@clo/lib/render/meta"; +import type { Template as MetaTemplate } from "@clo/lib/meta"; import * as path from "node:path"; import { nasRoot } from "./file-viewer/paths.ts";