diff --git a/framework/generate.ts b/framework/generate.ts
index d473e55b2df43164ec5341cef5f27c141c93a1e4..5d209a5bdc833674665b288334078a84caf81650 100644
--- a/framework/generate.ts
+++ b/framework/generate.ts
@@ -327,12 +327,12 @@ export async function preparePage(
);
// -- metadata --
- const renderedMeta = meta.toHtml({
+ const renderedMeta = meta.toHtml(metaTemplate, {
canonical: item.id,
...typeof metadata === "function"
? await metadata({ ssr: true })
: metadata,
- }, metaTemplate);
+ });
if (!renderedMeta.includes("
")) {
throw new Error(
"Page is missing 'meta.title'. " + "All pages need a title tag.",
diff --git a/framework/lib/view.ts b/framework/lib/view.ts
index c8ad37589db256ce302156b8685215fcb33d5bcf..54c8f7c6254c6f96688d9f7897d829fe8f4c6aa9 100644
--- a/framework/lib/view.ts
+++ b/framework/lib/view.ts
@@ -62,7 +62,7 @@ export async function renderToString(
// -- metadata --
const renderedMetaPromise = Promise.resolve(
typeof metadata === "function" ? metadata(attrs) : metadata,
- ).then((m) => meta.toHtml(m, codegen.metaTemplate));
+ ).then((m) => meta.toHtml(codegen.metaTemplate, m));
const cloverScripts = new Set();
const text = await SitegenWrap.render({
diff --git a/lib/arrays.ts b/lib/arrays.ts
index d5d0220baea98e01750ff6c77f5e105d3b7c7a49..f1c9540c28b8d3d1c56baddb957d97de291e63e7 100644
--- a/lib/arrays.ts
+++ b/lib/arrays.ts
@@ -6,7 +6,7 @@ export function* enumerate(
...arrays: { [K in keyof T]: ArrayLike }
): Generator<[...items: T, index: number]> {
const { length } = arrays[0];
- arrays.forEach((x) => assert(x.length === length), "All arrays must be equal in length");
+ arrays.forEach((x) => ASSERT(x.length === length), "All arrays must be equal in length");
for (let i = 0; i < length; i += 1) yield arrays.map((x) => x[i]!).concat(i) as [...T, number];
}
@@ -15,6 +15,8 @@ export function mapMany(
map: (...args: [...items: T, index: number]) => O,
): O[] {
const { length } = arrays[0];
- arrays.forEach((x) => assert(x.length === length), "All arrays must be equal in length");
+ arrays.forEach((x) => ASSERT(x.length === length), "All arrays must be equal in length");
return Array.from({ length }, (_, i) => map(...(arrays.map((a) => a[i]!).concat(i) as [...T, number])));
}
+
+import { ASSERT } from "./assert.ts";
diff --git a/lib/deno.json b/lib/deno.json
index 9095319340e35ab28b1a627f7dd5328e713ecdfa..4279c56053e6fc0b149e21827b0d27d0282382ba 100644
--- a/lib/deno.json
+++ b/lib/deno.json
@@ -1,23 +1,25 @@
{
"name": "@clo/lib",
"description": "high quality typescript libraries",
- "version": "4.0.0",
+ "version": "5.0.0",
"license": "MIT",
"exports": {
+ "./arrays": "./arrays.ts",
"./assert": "./assert.ts",
"./async": "./async.ts",
"./bytes": "./bytes.ts",
"./error": "./error.ts",
"./Events": "./Events.ts",
+ "./http": "./http.ts",
"./log": "./log.ts",
"./log/stack": "./log/stack.ts",
"./Lru": "./Lru.ts",
+ "./math": "./math.ts",
"./meta": "./meta.ts",
"./mime": "./mime.ts",
"./node": "./node.ts",
"./progress": "./progress.ts",
"./queue": "./queue.ts",
- "./render/meta": "./render/meta.ts",
"./stream": "./stream.ts",
"./string": "./string.ts",
"./string/ansi": "./string/ansi.ts",
diff --git a/lib/math.ts b/lib/math.ts
index 11509965e9433eb10c9da0c4546d89567be9f852..20a6d7868cd22473e3962f5fb124e3b272037aeb 100644
--- a/lib/math.ts
+++ b/lib/math.ts
@@ -1,8 +1,8 @@
-export function gcd(a: number, b: number) {
+export function gcd(a: number, b: number): number {
while (b !== 0) [a, b] = [b, a % b];
return a;
}
-export function clamp(value: number, min: number, max: number) {
+export function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value));
}
diff --git a/lib/meta.test.ts b/lib/meta.test.ts
index d6737d169a2dc00fbea6b3d933dffb1cf9efc9fc..75f753c7fe1c88c067cd9a6fe9212cdb45c4c90f 100644
--- a/lib/meta.test.ts
+++ b/lib/meta.test.ts
@@ -1,109 +1,115 @@
const base = new URL("https://example.com/");
-const T: Template = { base };
+const T: meta.Template = { base };
describe("title", () => {
test("is always first entry in meta", () => {
- const { meta } = toTags({ title: "Hello" }, T);
- assert.deepEqual(meta[0], { title: "Hello" });
+ const tags = meta.toTags(T, { title: "Hello" });
+ assert.deepEqual(tags.meta[0], { title: "Hello" });
});
test("titleTemplate applied", () => {
- const { meta } = toTags({ title: "Page" }, { base, titleTemplate: (t) => `${t} | Site` });
- assert.deepEqual(meta[0], { title: "Page | Site" });
+ const tags = meta.toTags({ base, titleTemplate: (t) => `${t} | Site` }, { title: "Page" });
+ assert.deepEqual(tags.meta[0], { title: "Page | Site" });
});
test("toHtml title is HTML-escaped", () => {
- const out = toHtml({ title: "Hi" }, T);
+ const out = meta.toHtml(T, { title: "Hi" });
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);
+ const tags = meta.toTags(T, { title: "T", authors: ["Alice", "Bob"] });
assert.deepEqual(
- meta.filter((m) => m.name === "author"),
+ tags.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"));
+ const tags = meta.toTags(T, { title: "T", keywords: ["cats", "dogs", "fish"] });
+ assert.ok(tags.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"));
+ const tags = meta.toTags(T, { title: "T", generator: "my-tool" });
+ assert.ok(tags.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);
+ const tags = meta.toTags(T, { title: "T", themeColor: "#fff" });
+ const themeColor = tags.meta.filter((m) => m.name === "theme-color");
+ assert.equal(themeColor.length, 1);
+ assert.equal(themeColor[0]!.content, "#fff");
+ assert.equal(themeColor[0]!.media, undefined);
});
test("themeColor object emits two tags with media queries", () => {
- const { meta } = toTags({ title: "T", themeColor: { dark: "#111", light: "#eee" } }, T);
+ const tags = meta.toTags(T, { title: "T", themeColor: { dark: "#111", light: "#eee" } });
assert.ok(
- meta.some((m) => m.name === "theme-color" && m.media === "(prefers-color-scheme:light)" && m.content === "#eee"),
+ tags.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"),
+ tags.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"));
+ const tags = meta.toTags(T, { title: "T", denyRobots: true });
+ assert.ok(tags.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"));
+ const tags = meta.toTags({ ...T, denyRobots: true }, { title: "T" });
+ assert.ok(tags.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"));
+ const tags = meta.toTags({ ...T, denyRobots: true }, { title: "T", denyRobots: false });
+ assert.ok(tags.meta.some((m) => m.name === "robots"));
});
test("viewport defaults to mobile-friendly value", () => {
- const { meta } = toTags({ title: "T" }, T);
+ const tags = meta.toTags(T, { title: "T" });
assert.ok(
- meta.some((m) => m.name === "viewport" && m.content === "width=device-width, initial-scale=1, maximum-scale=1"),
+ tags.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"));
+ const tags = meta.toTags(T, { title: "T", viewport: "width=device-width" });
+ assert.ok(tags.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);
+ const { links } = meta.toTags(T, { title: "T", canonical: "/page" });
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);
+ const { links } = meta.toTags(T, { title: "T", canonical: "https://other.com/x" });
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);
+ const { links } = meta.toTags(T, { title: "T", manifest: "/manifest.json" });
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);
+ const { links } = meta.toTags(T, { title: "T", oEmbed: "/oembed.json" });
assert.ok(links.some((l) =>
l.rel === "alternate"
&& l.type === "application/json+oembed"
@@ -112,271 +118,346 @@ describe("oEmbed", () => {
});
test("falls back to template.oEmbed", () => {
- const { links } = toTags({ title: "T" }, { ...T, oEmbed: "/oembed.json" });
+ const { links } = meta.toTags({ ...T, oEmbed: "/oembed.json" }, { title: "T" });
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);
+ const { links } = meta.toTags(T, { title: "T", alternates: [{ lang: "fr", url: "/fr" }] });
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);
+ const { links } = meta.toTags(T, { title: "T", alternates: [{ type: "application/rss+xml", url: "/feed.rss" }] });
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);
+ const { links } = meta.toTags(T, { title: "T", alternates: { "application/atom+xml": "/atom.xml" } });
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);
+ const { links } = meta.toTags(T, { title: "T", alternates: { "ja": "/ja/" } });
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);
+ const { links } = meta.toTags(T, { title: "T", alternates: [{ lang: "de", url: "/de" }] });
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: "/" }] });
+ const { links } = meta.toTags({ ...T, alternates: [{ lang: "en", url: "/" }] }, { title: "T" });
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"));
+ const tags = meta.toTags(T, { title: "T", embed: {} });
+ assert.ok(tags.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"));
+ const tags = meta.toTags(T, { title: "T", embed: { thumbnail: "/img.png" } });
+ assert.ok(tags.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"));
+ const tags = meta.toTags(T, { title: "T", embed: { thumbnail: "/img.png", thumbnailSize: "icon" } });
+ assert.ok(tags.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"));
+ const tags = meta.toTags(T, { title: "T", embed: { thumbnail: "/img.png" } });
+ assert.ok(tags.meta.some((m) => m.name === "og:image" && m.content === "https://example.com/img.png"));
+ assert.ok(tags.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"));
+ const tags = meta.toTags(T, { title: "My Page", embed: {} });
+ assert.ok(tags.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"));
+ const tags = meta.toTags(T, { title: "My Page", embed: { title: "Share Title" } });
+ assert.ok(tags.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"));
+ const tags = meta.toTags(T, { title: "T", description: "page desc", embed: {} });
+ assert.ok(tags.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"));
+ const tags = meta.toTags(T, { title: "T", embed: { siteTitle: "My Site" } });
+ assert.ok(tags.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"));
+ const tags = meta.toTags(T, { title: "T", canonical: "/page", embed: {} });
+ assert.ok(tags.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"));
+ const tags = meta.toTags(T, { title: "T", embed: {} });
+ assert.ok(!tags.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);
+ const tags = meta.toTags(T, { title: "T", embed: { title: "Embed" }, openGraph: { title: "Explicit" } });
+ assert.ok(tags.meta.some((m) => m.name === "og:title" && m.content === "Explicit"));
+ assert.equal(tags.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"));
+ const tags = meta.toTags(T, { title: "T", embed: {} });
+ assert.ok(tags.meta.some((m) => m.name === "og:type" && m.content === "website"));
});
});
describe("openGraph nested/array fields", () => {
test("nested object emits namespaced tags", () => {
- const { meta } = toTags(
+ const tags = meta.toTags(
+ T,
{ 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"));
+ assert.ok(tags.meta.some((m) => m.name === "og:image:url" && m.content === "https://example.com/img.png"));
+ assert.ok(tags.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);
+ const tags = meta.toTags(
+ T,
+ { title: "T", openGraph: { image: ["https://a.com/1.png", "https://a.com/2.png"] } },
+ );
+ assert.equal(tags.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"));
+ const tags = meta.toTags(T, { title: "T", openGraph: { image: null } });
+ assert.ok(!tags.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"));
+ const template: meta.Template = { base, publisher: "Acme", keywords: ["a", "b"] };
+ const tags = meta.toTags(template, { title: "T" });
+ assert.ok(tags.meta.some((m) => m.name === "publisher" && m.content === "Acme"));
+ assert.ok(tags.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);
+ const template: meta.Template = { base, publisher: "Acme" };
+ const tags = meta.toTags(template, { title: "T", publisher: "Override" });
+ assert.ok(tags.meta.some((m) => m.name === "publisher" && m.content === "Override"));
+ assert.equal(tags.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);
+ const out = meta.toHtml(T, { title: "T", description: "hello world" });
assert.ok(out.includes(`content="hello world"`));
});
test("attribute values without special chars are unquoted", () => {
- const out = toHtml({ title: "T", publisher: "Acme" }, T);
+ const out = meta.toHtml(T, { title: "T", publisher: "Acme" });
assert.ok(out.includes(`content=Acme`));
});
test("attribute value with slash is quoted", () => {
- const out = toHtml({ title: "T" }, T);
+ const out = meta.toHtml(T, { title: "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);
+ const out = meta.toHtml(T, { title: "T", description: `say "hi"` });
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);
+ const { scripts } = meta.toTags(T, { title: "T", scripts: [{ content: "alert(1)" }] });
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);
+ const { scripts } = meta.toTags(T, { title: "T", scripts: [{ src: "/app.js", async: true, defer: false }] });
assert.equal(scripts.length, 1);
+ assert.ok(scripts[0]);
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);
+ const out = meta.toHtml(T, { title: "T", scripts: [{ content: "console.log(1)" }] });
assert.ok(out.includes(""));
});
test("toHtml renders external script with boolean attr", () => {
- const out = toHtml({ title: "T", scripts: [{ src: "/app.js", async: true }] }, T);
+ const out = meta.toHtml(T, { title: "T", scripts: [{ src: "/app.js", async: true }] });
assert.ok(out.includes(""));
});
test("falls back to template scripts", () => {
- const { scripts } = toTags({ title: "T" }, { ...T, scripts: [{ content: "init()" }] });
+ const { scripts } = meta.toTags({ ...T, scripts: [{ content: "init()" }] }, { title: "T" });
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);
+ const { styles } = meta.toTags(T, { title: "T", styles: [{ content: "body{margin:0}" }] });
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);
+ const { styles } = meta.toTags(T, { title: "T", styles: [{ content: "p{color:blue}", media: "print" }] });
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);
+ const { links, styles } = meta.toTags(T, { title: "T", styles: [{ href: "/style.css" }] });
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);
+ const { links } = meta.toTags(T, { title: "T", styles: [{ href: "/print.css", media: "print" }] });
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);
+ const out = meta.toHtml(T, { title: "T", styles: [{ content: "body{margin:0}" }] });
assert.ok(out.includes(""));
});
test("toHtml renders inline style with media", () => {
- const out = toHtml({ title: "T", styles: [{ content: "p{color:blue}", media: "print" }] }, T);
+ const out = meta.toHtml(T, { title: "T", styles: [{ content: "p{color:blue}", media: "print" }] });
assert.ok(out.includes(""));
});
test("falls back to template styles", () => {
- const { styles } = toTags({ title: "T" }, { ...T, styles: [{ content: "h1{color:red}" }] });
+ const { styles } = meta.toTags({ ...T, styles: [{ content: "h1{color:red}" }] }, { title: "T" });
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"));
+ const tags = meta.toTags(T, { title: "T", extra: [{ tag: "meta", name: "verify", content: "abc" }] });
+ assert.ok(tags.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);
+ const { links } = meta.toTags(T, { title: "T", extra: [{ tag: "link", rel: "icon", href: "/favicon.ico" }] });
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);
+ const { scripts } = meta.toTags(T, { title: "T", extra: [{ tag: "script", src: "/extra.js", async: true }] });
assert.equal(scripts.length, 1);
+ assert.ok(scripts[0]);
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);
+ const { styles } = meta.toTags(T, { title: "T", extra: [{ tag: "style", children: "a{color:red}" }] });
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")!;
+ const tags = meta.toTags(T, { title: "T", extra: [{ tag: "meta", name: "x", content: "y", bogus: null }] });
+ const entry = tags.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"));
+ const tags = meta.toTags({ ...T, extra: [{ tag: "meta", name: "tmpl", content: "yes" }] }, { title: "T" });
+ assert.ok(tags.meta.some((m) => m.name === "tmpl" && m.content === "yes"));
+ });
+});
+
+// Typescript test for TanStack router compatibility.
+interface HtmlAttributes {
+ id?: string;
+ className?: string;
+ title?: string;
+ lang?: string;
+ dir?: string;
+ hidden?: boolean;
+ tabIndex?: number;
+ children?: unknown;
+}
+interface MetaAttributes extends HtmlAttributes {
+ name?: string;
+ content?: string;
+ charSet?: string;
+ httpEquiv?: string;
+ media?: string;
+}
+interface LinkAttributes extends HtmlAttributes {
+ rel?: string;
+ href?: string;
+ hrefLang?: string;
+ media?: string;
+ type?: string;
+ sizes?: string;
+ as?: string;
+}
+interface ScriptAttributes extends HtmlAttributes {
+ src?: string;
+ async?: boolean;
+ defer?: boolean;
+ type?: string;
+ noModule?: boolean;
+ integrity?: string;
+}
+interface StyleAttributes extends HtmlAttributes {
+ media?: string;
+ type?: string;
+}
+
+/** The `head()` return contract from TanStack Start, deps-free. */
+interface TanStackHead {
+ meta?: Array;
+ links?: Array;
+ scripts?: Array;
+ styles?: Array;
+}
+
+// Type-level assertion. A compile error on this line means `toTags`' output is
+// no longer a valid TanStack `head()` payload.
+const _asHead: (tags: meta.Tags) => TanStackHead = (tags) => tags;
+
+describe("TanStack Router compatibility", () => {
+ test("toTags output is a valid head() payload", () => {
+ const tags = meta.toTags(
+ { base: new URL("https://example.com/") },
+ {
+ title: "Home",
+ description: "hi",
+ embed: { thumbnail: "/og.png" },
+ scripts: [{ src: "/a.js", async: true }, { content: "console.log(1)" }],
+ styles: [{ href: "/a.css" }, { content: "a{color:red}" }],
+ extra: [{ tag: "link", rel: "icon", href: "/favicon.ico" }],
+ },
+ );
+ const head: TanStackHead = _asHead(tags);
+ assert.deepEqual(Object.keys(head), ["meta", "links", "scripts", "styles"]);
});
});
import assert from "node:assert";
import { describe, test } from "vitest";
-import { type Template, toHtml, toTags } from "./meta.ts";
+import * as meta from "./meta.ts";
diff --git a/lib/meta.ts b/lib/meta.ts
index 019a79c1c869d4b8d9155dd6d01607c19e487f56..4b76007da717699e9e4beb06c927cb520d054e63 100644
--- a/lib/meta.ts
+++ b/lib/meta.ts
@@ -80,7 +80,7 @@ export interface AutoEmbed {
/* add an image to the embed card. */
thumbnail?: string | null;
/** @default "banner", which applies twitter:card = "summary_large_image" */
- thumbnailSize?: "banner" | "icon";
+ thumbnailSize?: "banner" | "icon" | null;
/** @default null - no site title */
siteTitle?: string | null;
}
@@ -153,12 +153,8 @@ export interface Tags {
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 {
+export function toTags(template: Template, meta: Meta): Tags {
const { titleTemplate, base } = template;
const resolve = (str: string) => new URL(str, base).href;
@@ -252,7 +248,7 @@ export function toTags(meta: Meta, template: Template): Tags {
for (const entry of meta.scripts ?? template.scripts ?? []) {
if ("content" in entry) {
- scriptTags.push({ children: entry.content });
+ scriptTags.push({ children: String(entry.content) });
} else {
const { src, ...rest } = entry;
const tag: Record = { src };
@@ -265,7 +261,7 @@ export function toTags(meta: Meta, template: Template): Tags {
for (const entry of meta.styles ?? template.styles ?? []) {
if ("content" in entry) {
- const tag: Record = { children: entry.content };
+ const tag: Record = { children: String(entry.content) };
if ("media" in entry && entry.media) tag.media = entry.media;
styleTags.push(tag);
} else {
@@ -293,8 +289,8 @@ export function toTags(meta: Meta, template: Template): Tags {
}
/* convert a metadata definition into HTML text. */
-export function toHtml(meta: Meta, template: Template): string {
- const tags = toTags(meta, template);
+export function toHtml(template: Template, meta: Meta): string {
+ const tags = toTags(template, meta);
const [{ title }, ...metaTags] = tags.meta as [{ title: string }, ...Record[]];
let out = `${esc(title)}`;
for (const attrs of metaTags) {
diff --git a/lib/readme.changes.md b/lib/readme.changes.md
index 1e5bea473919187b0feed50602f839b289c41ea1..48067405e35137742cfad5cff9c206e3df479d1b 100644
--- a/lib/readme.changes.md
+++ b/lib/readme.changes.md
@@ -7,23 +7,36 @@
- `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.
+ - deprecated html escaping methods moved to `string.escapeHtmlContent` and
+ `string.escapeHtmlAttr`
- `render/meta` is moved to `meta`, with a new API for rendering tags
- - `meta.render` is renamed to `meta.toHtml`
+ - `meta.render` is renamed to `meta.toHtml`, arguments re-ordered
- `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.
-- `log` has recieved so many bug-fix changes that it is worth ensuring your
- application edge cases still work as expected.
+- `log` and `progress` have recieved so many bug-fix changes that it is worth
+ ensuring your application edge cases still work as expected.
+- `string.formatByteSize` formats differently.
### features
-- `mime`'s database contains `.eot` for embedded opentype fonts.
-- `async.deferred`
+- new library `http`.
+- new library `math`.
+- new library `arrays`.
- `log`
- many many many many rendering bug-fixes
- `writeError` writes to stderr with proper logic.
- buffering auto-flushes synchronously when it grows past 64k.
- custom scopes
+- `string`
+ - `formatDurationLetters` spans years to seconds, plus infinity and negatives.
+- `mime`'s database contains `.eot` for embedded opentype fonts.
+- `async.deferred`.
+- `queue.setConcurrency`.
+- `subprocess.lookupProcessOnPort`.
+- `subprocess/ffmpeg` progress handles fractional framerates (progress bug).
+- `string/ansi` gains `clearToEndOfScreen`.
+- `progress` estimation, maxHeight, and timer bug fixes.
## v4
diff --git a/lib/subprocess.ts b/lib/subprocess.ts
index 157485b77b5ac1d2b97aa11d58dfbacb853b4a1c..851a9733a294d82bd56b1b80e02cf30eec3794be 100644
--- a/lib/subprocess.ts
+++ b/lib/subprocess.ts
@@ -90,7 +90,7 @@ export async function spawnAndWait(
// }
// });
-async function findInodeForPort(port) {
+async function findInodeForPort(port: number) {
const hexPort = port.toString(16).toUpperCase().padStart(4, "0");
for (const f of ["/proc/net/tcp", "/proc/net/tcp6"]) {
try {
@@ -104,7 +104,7 @@ async function findInodeForPort(port) {
return null;
}
-async function findPidForInode(inode) {
+async function findPidForInode(inode: string) {
const target = `socket:[${inode}]`;
for (const pid of await fs.readdir("/proc")) {
if (!/^\d+$/.test(pid)) continue;
@@ -154,7 +154,8 @@ async function commandFromPortWindows(port: number) {
return { pid: Number(pid), command: cmdline };
}
-export async function lookupProcessOnPort(port: number) {
+/** Racily searches for what process is taking up a given port. */
+export async function lookupProcessOnPort(port: number): Promise {
switch (os.platform()) {
case "linux":
return commandFromPortLinux(port);
@@ -167,10 +168,14 @@ export async function lookupProcessOnPort(port: number) {
}
}
-// import * as process from "node:process";
+interface ProcessLookup {
+ pid: number;
+ command: string;
+}
+
import { Buffer } from "node:buffer";
import * as child_process from "node:child_process";
import * as events from "node:events";
import * as fs from "node:fs/promises";
+import * as os from "node:os";
import * as util from "node:util";
-import os from "os";
diff --git a/lib/testing.ts b/lib/testing.ts
index 29307f1efbc584dcf7b082e00ed8d249965627a5..82c623f2e6009aa6f5b1d5911af0c155bf2e4906 100644
--- a/lib/testing.ts
+++ b/lib/testing.ts
@@ -111,13 +111,15 @@ export class SyncPromise implements Promise {
}
}
+export interface FakeTimerEntry {
+ deadline: number;
+ resolve: () => void;
+ src: stack.Frame[];
+}
+
export class FakeTimers {
time = 0;
- entries: Array<{
- deadline: number;
- resolve: () => void;
- src: stack.Frame[];
- }> = [];
+ entries: Array = [];
now: () => number = () => {
return this.time;
@@ -260,7 +262,7 @@ export class MockScreen implements Disposable, log.WidgetHost {
ASSERT(this.timers.entries.length === 0);
}
- expectWithoutConsume(ms: number) {
+ expectWithoutConsume(ms: number): FakeTimerEntry {
const wait = UNWRAP(
this.timers.next(),
() => this.out.length > 0 ? "terminal i/o did not wait" : "no terminal i/o",
diff --git a/package-lock.json b/package-lock.json
index 6f0ca7c483219628ac9e1249a12400bf826a0b8f..fcfe0424b6a333f7d1114a25515c1853eb013f60 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -573,22 +573,6 @@
"node": ">=18"
}
},
- "node_modules/@esbuild/openharmony-arm64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz",
- "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==",
- "cpu": [
- "arm64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "openharmony"
- ],
- "engines": {
- "node": ">=18"
- }
- },
"node_modules/@esbuild/sunos-x64": {
"version": "0.25.5",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.5.tgz",
@@ -673,7 +657,6 @@
"resolved": "https://registry.npmjs.org/@marko/compiler/-/compiler-5.39.62.tgz",
"integrity": "sha512-aBmWraqvVMLzi3oZkm5cEAVTv4qtLIVl9tkci6M+duSaEbKq4+n9FuNjYlOA0/4slRmOeAOuYyi2BenzgKmQNA==",
"license": "MIT",
- "peer": true,
"dependencies": {
"@luxass/strip-json-comments": "^1.4.0",
"complain": "^1.6.1",
@@ -1443,7 +1426,6 @@
"integrity": "sha512-P8OtKZRv/5J5hhz0cUAdu/cLuPIKXpQl1R9pZtvmHWQvrAUVd0UNIPT4IB4W3rNOqVO0rlqHmCIbSwxh/c9yUQ==",
"hasInstallScript": true,
"license": "MIT",
- "peer": true,
"bin": {
"esbuild": "bin/esbuild"
},
@@ -2114,7 +2096,6 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=12"
},
@@ -2528,406 +2509,6 @@
}
}
},
- "node_modules/vitest/node_modules/@esbuild/aix-ppc64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz",
- "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==",
- "cpu": [
- "ppc64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "aix"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/vitest/node_modules/@esbuild/android-arm": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz",
- "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==",
- "cpu": [
- "arm"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "android"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/vitest/node_modules/@esbuild/android-arm64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz",
- "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==",
- "cpu": [
- "arm64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "android"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/vitest/node_modules/@esbuild/android-x64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz",
- "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==",
- "cpu": [
- "x64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "android"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/vitest/node_modules/@esbuild/darwin-arm64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz",
- "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==",
- "cpu": [
- "arm64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "darwin"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/vitest/node_modules/@esbuild/darwin-x64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz",
- "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==",
- "cpu": [
- "x64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "darwin"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/vitest/node_modules/@esbuild/freebsd-arm64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz",
- "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==",
- "cpu": [
- "arm64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "freebsd"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/vitest/node_modules/@esbuild/freebsd-x64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz",
- "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==",
- "cpu": [
- "x64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "freebsd"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/vitest/node_modules/@esbuild/linux-arm": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz",
- "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==",
- "cpu": [
- "arm"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/vitest/node_modules/@esbuild/linux-arm64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz",
- "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==",
- "cpu": [
- "arm64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/vitest/node_modules/@esbuild/linux-ia32": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz",
- "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==",
- "cpu": [
- "ia32"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/vitest/node_modules/@esbuild/linux-loong64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz",
- "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==",
- "cpu": [
- "loong64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/vitest/node_modules/@esbuild/linux-mips64el": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz",
- "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==",
- "cpu": [
- "mips64el"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/vitest/node_modules/@esbuild/linux-ppc64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz",
- "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==",
- "cpu": [
- "ppc64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/vitest/node_modules/@esbuild/linux-riscv64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz",
- "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==",
- "cpu": [
- "riscv64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/vitest/node_modules/@esbuild/linux-s390x": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz",
- "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==",
- "cpu": [
- "s390x"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/vitest/node_modules/@esbuild/linux-x64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz",
- "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==",
- "cpu": [
- "x64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/vitest/node_modules/@esbuild/netbsd-arm64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz",
- "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==",
- "cpu": [
- "arm64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "netbsd"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/vitest/node_modules/@esbuild/netbsd-x64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz",
- "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==",
- "cpu": [
- "x64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "netbsd"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/vitest/node_modules/@esbuild/openbsd-arm64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz",
- "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==",
- "cpu": [
- "arm64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "openbsd"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/vitest/node_modules/@esbuild/openbsd-x64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz",
- "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==",
- "cpu": [
- "x64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "openbsd"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/vitest/node_modules/@esbuild/sunos-x64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz",
- "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==",
- "cpu": [
- "x64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "sunos"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/vitest/node_modules/@esbuild/win32-arm64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz",
- "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==",
- "cpu": [
- "arm64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/vitest/node_modules/@esbuild/win32-ia32": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz",
- "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==",
- "cpu": [
- "ia32"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/vitest/node_modules/@esbuild/win32-x64": {
- "version": "0.27.7",
- "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz",
- "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==",
- "cpu": [
- "x64"
- ],
- "license": "MIT",
- "optional": true,
- "os": [
- "win32"
- ],
- "engines": {
- "node": ">=18"
- }
- },
"node_modules/vitest/node_modules/@vitest/mocker": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.2.tgz",
@@ -2973,7 +2554,6 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.3.tgz",
"integrity": "sha512-B9ifbFudT1TFhfltfaIPgjo9Z3mDynBTJSUYxTjOQruf/zHH+ezCQKcoqO+h7a9Pw9Nm/OtlXAiGT1axBgwqrQ==",
"license": "MIT",
- "peer": true,
"dependencies": {
"lightningcss": "^1.32.0",
"picomatch": "^4.0.4",
diff --git a/src/pages/index.marko b/src/pages/index.marko
index 0667a798df73193be579f09ecdcf163fe1962413..c3c4e5b141a94f6c5291633f8a4f3e9b7acc9fdd 100644
--- a/src/pages/index.marko
+++ b/src/pages/index.marko
@@ -22,10 +22,7 @@ export const meta = {
art