authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-06-15 10:27:30-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-06-15 12:49:48-07:00
loga25086ee5de025766806d480542ed6c10aef35f9
treecbc4e374de585ceb6d91d6d452d02596f16cb556
parent72e9a94ec20202598734c0edc99e6f5cba3fd296
signature Signed by SSH key SHA256:cOKiuRFOeSRxne6EWgHtdQQSlBxjOXm2hOCFnCdLQbQ

chore: publish lib v5


12 files changed, 246 insertions(+), 568 deletions(-)

framework/generate.ts+2-2
...@@ -327,12 +327,12 @@ export async function preparePage(...@@ -327,12 +327,12 @@ export async function preparePage(
327 );327 );
328328
329 // -- metadata --329 // -- metadata --
330 const renderedMeta = meta.toHtml({330 const renderedMeta = meta.toHtml(metaTemplate, {
331 canonical: item.id,331 canonical: item.id,
332 ...typeof metadata === "function"332 ...typeof metadata === "function"
333 ? await metadata({ ssr: true })333 ? await metadata({ ssr: true })
334 : metadata,334 : metadata,
335 }, metaTemplate);335 });
336 if (!renderedMeta.includes("<title>")) {336 if (!renderedMeta.includes("<title>")) {
337 throw new Error(337 throw new Error(
338 "Page is missing 'meta.title'. " + "All pages need a title tag.",338 "Page is missing 'meta.title'. " + "All pages need a title tag.",
framework/lib/view.ts+1-1
...@@ -62,7 +62,7 @@ export async function renderToString<K extends Key>(...@@ -62,7 +62,7 @@ export async function renderToString<K extends Key>(
62 // -- metadata --62 // -- metadata --
63 const renderedMetaPromise = Promise.resolve(63 const renderedMetaPromise = Promise.resolve(
64 typeof metadata === "function" ? metadata(attrs) : metadata,64 typeof metadata === "function" ? metadata(attrs) : metadata,
65 ).then((m) => meta.toHtml(m, codegen.metaTemplate));65 ).then((m) => meta.toHtml(codegen.metaTemplate, m));
6666
67 const cloverScripts = new Set<string>();67 const cloverScripts = new Set<string>();
68 const text = await SitegenWrap.render({68 const text = await SitegenWrap.render({
lib/arrays.ts+4-2
...@@ -6,7 +6,7 @@ export function* enumerate<T extends [unknown, ...unknown[]]>(...@@ -6,7 +6,7 @@ export function* enumerate<T extends [unknown, ...unknown[]]>(
6 ...arrays: { [K in keyof T]: ArrayLike<T[K]> }6 ...arrays: { [K in keyof T]: ArrayLike<T[K]> }
7): Generator<[...items: T, index: number]> {7): Generator<[...items: T, index: number]> {
8 const { length } = arrays[0];8 const { length } = arrays[0];
9 arrays.forEach((x) => assert(x.length === length), "All arrays must be equal in length");9 arrays.forEach((x) => ASSERT(x.length === length), "All arrays must be equal in length");
10 for (let i = 0; i < length; i += 1) yield arrays.map((x) => x[i]!).concat(i) as [...T, number];10 for (let i = 0; i < length; i += 1) yield arrays.map((x) => x[i]!).concat(i) as [...T, number];
11}11}
1212
...@@ -15,6 +15,8 @@ export function mapMany<T extends [unknown, ...unknown[]], O>(...@@ -15,6 +15,8 @@ export function mapMany<T extends [unknown, ...unknown[]], O>(
15 map: (...args: [...items: T, index: number]) => O,15 map: (...args: [...items: T, index: number]) => O,
16): O[] {16): O[] {
17 const { length } = arrays[0];17 const { length } = arrays[0];
18 arrays.forEach((x) => assert(x.length === length), "All arrays must be equal in length");18 arrays.forEach((x) => ASSERT(x.length === length), "All arrays must be equal in length");
19 return Array.from({ length }, (_, i) => map(...(arrays.map((a) => a[i]!).concat(i) as [...T, number])));19 return Array.from({ length }, (_, i) => map(...(arrays.map((a) => a[i]!).concat(i) as [...T, number])));
20}20}
21
22import { ASSERT } from "./assert.ts";
lib/deno.json+4-2
...@@ -1,23 +1,25 @@...@@ -1,23 +1,25 @@
1{1{
2 "name": "@clo/lib",2 "name": "@clo/lib",
3 "description": "high quality typescript libraries",3 "description": "high quality typescript libraries",
4 "version": "4.0.0",4 "version": "5.0.0",
5 "license": "MIT",5 "license": "MIT",
6 "exports": {6 "exports": {
7 "./arrays": "./arrays.ts",
7 "./assert": "./assert.ts",8 "./assert": "./assert.ts",
8 "./async": "./async.ts",9 "./async": "./async.ts",
9 "./bytes": "./bytes.ts",10 "./bytes": "./bytes.ts",
10 "./error": "./error.ts",11 "./error": "./error.ts",
11 "./Events": "./Events.ts",12 "./Events": "./Events.ts",
13 "./http": "./http.ts",
12 "./log": "./log.ts",14 "./log": "./log.ts",
13 "./log/stack": "./log/stack.ts",15 "./log/stack": "./log/stack.ts",
14 "./Lru": "./Lru.ts",16 "./Lru": "./Lru.ts",
17 "./math": "./math.ts",
15 "./meta": "./meta.ts",18 "./meta": "./meta.ts",
16 "./mime": "./mime.ts",19 "./mime": "./mime.ts",
17 "./node": "./node.ts",20 "./node": "./node.ts",
18 "./progress": "./progress.ts",21 "./progress": "./progress.ts",
19 "./queue": "./queue.ts",22 "./queue": "./queue.ts",
20 "./render/meta": "./render/meta.ts",
21 "./stream": "./stream.ts",23 "./stream": "./stream.ts",
22 "./string": "./string.ts",24 "./string": "./string.ts",
23 "./string/ansi": "./string/ansi.ts",25 "./string/ansi": "./string/ansi.ts",
lib/math.ts+2-2
...@@ -1,8 +1,8 @@...@@ -1,8 +1,8 @@
1export function gcd(a: number, b: number) {1export function gcd(a: number, b: number): number {
2 while (b !== 0) [a, b] = [b, a % b];2 while (b !== 0) [a, b] = [b, a % b];
3 return a;3 return a;
4}4}
55
6export function clamp(value: number, min: number, max: number) {6export function clamp(value: number, min: number, max: number): number {
7 return Math.min(max, Math.max(min, value));7 return Math.min(max, Math.max(min, value));
8}8}
lib/meta.test.ts+190-109
...@@ -1,109 +1,115 @@...@@ -1,109 +1,115 @@
1const base = new URL("https://example.com/");1const base = new URL("https://example.com/");
2const T: Template = { base };2const T: meta.Template = { base };
33
4describe("title", () => {4describe("title", () => {
5 test("is always first entry in meta", () => {5 test("is always first entry in meta", () => {
6 const { meta } = toTags({ title: "Hello" }, T);6 const tags = meta.toTags(T, { title: "Hello" });
7 assert.deepEqual(meta[0], { title: "Hello" });7 assert.deepEqual(tags.meta[0], { title: "Hello" });
8 });8 });
99
10 test("titleTemplate applied", () => {10 test("titleTemplate applied", () => {
11 const { meta } = toTags({ title: "Page" }, { base, titleTemplate: (t) => `${t} | Site` });11 const tags = meta.toTags({ base, titleTemplate: (t) => `${t} | Site` }, { title: "Page" });
12 assert.deepEqual(meta[0], { title: "Page | Site" });12 assert.deepEqual(tags.meta[0], { title: "Page | Site" });
13 });13 });
1414
15 test("toHtml title is HTML-escaped", () => {15 test("toHtml title is HTML-escaped", () => {
16 const out = toHtml({ title: "<b>Hi</b>" }, T);16 const out = meta.toHtml(T, { title: "<b>Hi</b>" });
17 assert.ok(out.startsWith("<title>&lt;b&gt;Hi&lt;/b&gt;</title>"));17 assert.ok(out.startsWith("<title>&lt;b&gt;Hi&lt;/b&gt;</title>"));
18 });18 });
19});19});
2020
21describe("basic meta fields", () => {21describe("basic meta fields", () => {
22 test("multiple authors emit separate tags", () => {22 test("multiple authors emit separate tags", () => {
23 const { meta } = toTags({ title: "T", authors: ["Alice", "Bob"] }, T);23 const tags = meta.toTags(T, { title: "T", authors: ["Alice", "Bob"] });
24 assert.deepEqual(24 assert.deepEqual(
25 meta.filter((m) => m.name === "author"),25 tags.meta.filter((m) => m.name === "author"),
26 [{ name: "author", content: "Alice" }, { name: "author", content: "Bob" }],26 [{ name: "author", content: "Alice" }, { name: "author", content: "Bob" }],
27 );27 );
28 });28 });
2929
30 test("keywords joined with comma-space", () => {30 test("keywords joined with comma-space", () => {
31 const { meta } = toTags({ title: "T", keywords: ["cats", "dogs", "fish"] }, T);31 const tags = meta.toTags(T, { title: "T", keywords: ["cats", "dogs", "fish"] });
32 assert.ok(meta.some((m) => m.name === "keywords" && m.content === "cats, dogs, fish"));32 assert.ok(tags.meta.some((m) => m.name === "keywords" && m.content === "cats, dogs, fish"));
33 });33 });
3434
35 test("generator: string used as-is", () => {35 test("generator: string used as-is", () => {
36 const { meta } = toTags({ title: "T", generator: "my-tool" }, T);36 const tags = meta.toTags(T, { title: "T", generator: "my-tool" });
37 assert.ok(meta.some((m) => m.name === "generator" && m.content === "my-tool"));37 assert.ok(tags.meta.some((m) => m.name === "generator" && m.content === "my-tool"));
38 });38 });
3939
40 test("themeColor string emits single tag without media", () => {40 test("themeColor string emits single tag without media", () => {
41 const { meta } = toTags({ title: "T", themeColor: "#fff" }, T);41 const tags = meta.toTags(T, { title: "T", themeColor: "#fff" });
42 const tags = meta.filter((m) => m.name === "theme-color");42 const themeColor = tags.meta.filter((m) => m.name === "theme-color");
43 assert.equal(tags.length, 1);43 assert.equal(themeColor.length, 1);
44 assert.equal(tags[0]!.content, "#fff");44 assert.equal(themeColor[0]!.content, "#fff");
45 assert.equal(tags[0]!.media, undefined);45 assert.equal(themeColor[0]!.media, undefined);
46 });46 });
4747
48 test("themeColor object emits two tags with media queries", () => {48 test("themeColor object emits two tags with media queries", () => {
49 const { meta } = toTags({ title: "T", themeColor: { dark: "#111", light: "#eee" } }, T);49 const tags = meta.toTags(T, { title: "T", themeColor: { dark: "#111", light: "#eee" } });
50 assert.ok(50 assert.ok(
51 meta.some((m) => m.name === "theme-color" && m.media === "(prefers-color-scheme:light)" && m.content === "#eee"),51 tags.meta.some((m) =>
52 m.name === "theme-color" && m.media === "(prefers-color-scheme:light)" && m.content === "#eee"
53 ),
52 );54 );
53 assert.ok(55 assert.ok(
54 meta.some((m) => m.name === "theme-color" && m.media === "(prefers-color-scheme:dark)" && m.content === "#111"),56 tags.meta.some((m) =>
57 m.name === "theme-color" && m.media === "(prefers-color-scheme:dark)" && m.content === "#111"
58 ),
55 );59 );
56 });60 });
5761
58 test("denyRobots from meta", () => {62 test("denyRobots from meta", () => {
59 const { meta } = toTags({ title: "T", denyRobots: true }, T);63 const tags = meta.toTags(T, { title: "T", denyRobots: true });
60 assert.ok(meta.some((m) => m.name === "robots" && m.content === "noindex,nofollow"));64 assert.ok(tags.meta.some((m) => m.name === "robots" && m.content === "noindex,nofollow"));
61 });65 });
6266
63 test("denyRobots from template", () => {67 test("denyRobots from template", () => {
64 const { meta } = toTags({ title: "T" }, { ...T, denyRobots: true });68 const tags = meta.toTags({ ...T, denyRobots: true }, { title: "T" });
65 assert.ok(meta.some((m) => m.name === "robots" && m.content === "noindex,nofollow"));69 assert.ok(tags.meta.some((m) => m.name === "robots" && m.content === "noindex,nofollow"));
66 });70 });
6771
68 test("denyRobots: false on meta does not override template true", () => {72 test("denyRobots: false on meta does not override template true", () => {
69 // null/undefined opts out; false is falsy so it also doesn't block73 // null/undefined opts out; false is falsy so it also doesn't block
70 const { meta } = toTags({ title: "T", denyRobots: false }, { ...T, denyRobots: true });74 const tags = meta.toTags({ ...T, denyRobots: true }, { title: "T", denyRobots: false });
71 assert.ok(meta.some((m) => m.name === "robots"));75 assert.ok(tags.meta.some((m) => m.name === "robots"));
72 });76 });
7377
74 test("viewport defaults to mobile-friendly value", () => {78 test("viewport defaults to mobile-friendly value", () => {
75 const { meta } = toTags({ title: "T" }, T);79 const tags = meta.toTags(T, { title: "T" });
76 assert.ok(80 assert.ok(
77 meta.some((m) => m.name === "viewport" && m.content === "width=device-width, initial-scale=1, maximum-scale=1"),81 tags.meta.some((m) =>
82 m.name === "viewport" && m.content === "width=device-width, initial-scale=1, maximum-scale=1"
83 ),
78 );84 );
79 });85 });
8086
81 test("viewport can be overridden", () => {87 test("viewport can be overridden", () => {
82 const { meta } = toTags({ title: "T", viewport: "width=device-width" }, T);88 const tags = meta.toTags(T, { title: "T", viewport: "width=device-width" });
83 assert.ok(meta.some((m) => m.name === "viewport" && m.content === "width=device-width"));89 assert.ok(tags.meta.some((m) => m.name === "viewport" && m.content === "width=device-width"));
84 });90 });
85});91});
8692
87describe("links", () => {93describe("links", () => {
88 test("canonical resolved against base", () => {94 test("canonical resolved against base", () => {
89 const { links } = toTags({ title: "T", canonical: "/page" }, T);95 const { links } = meta.toTags(T, { title: "T", canonical: "/page" });
90 assert.ok(links.some((l) => l.rel === "canonical" && l.href === "https://example.com/page"));96 assert.ok(links.some((l) => l.rel === "canonical" && l.href === "https://example.com/page"));
91 });97 });
9298
93 test("canonical absolute URL kept as-is", () => {99 test("canonical absolute URL kept as-is", () => {
94 const { links } = toTags({ title: "T", canonical: "https://other.com/x" }, T);100 const { links } = meta.toTags(T, { title: "T", canonical: "https://other.com/x" });
95 assert.ok(links.some((l) => l.rel === "canonical" && l.href === "https://other.com/x"));101 assert.ok(links.some((l) => l.rel === "canonical" && l.href === "https://other.com/x"));
96 });102 });
97103
98 test("manifest href not resolved", () => {104 test("manifest href not resolved", () => {
99 const { links } = toTags({ title: "T", manifest: "/manifest.json" }, T);105 const { links } = meta.toTags(T, { title: "T", manifest: "/manifest.json" });
100 assert.ok(links.some((l) => l.rel === "manifest" && l.href === "/manifest.json"));106 assert.ok(links.some((l) => l.rel === "manifest" && l.href === "/manifest.json"));
101 });107 });
102});108});
103109
104describe("oEmbed", () => {110describe("oEmbed", () => {
105 test("emits alternate link with json+oembed type, URL resolved", () => {111 test("emits alternate link with json+oembed type, URL resolved", () => {
106 const { links } = toTags({ title: "T", oEmbed: "/oembed.json" }, T);112 const { links } = meta.toTags(T, { title: "T", oEmbed: "/oembed.json" });
107 assert.ok(links.some((l) =>113 assert.ok(links.some((l) =>
108 l.rel === "alternate"114 l.rel === "alternate"
109 && l.type === "application/json+oembed"115 && l.type === "application/json+oembed"
...@@ -112,271 +118,346 @@ describe("oEmbed", () => {...@@ -112,271 +118,346 @@ describe("oEmbed", () => {
112 });118 });
113119
114 test("falls back to template.oEmbed", () => {120 test("falls back to template.oEmbed", () => {
115 const { links } = toTags({ title: "T" }, { ...T, oEmbed: "/oembed.json" });121 const { links } = meta.toTags({ ...T, oEmbed: "/oembed.json" }, { title: "T" });
116 assert.ok(links.some((l) => l.type === "application/json+oembed"));122 assert.ok(links.some((l) => l.type === "application/json+oembed"));
117 });123 });
118});124});
119125
120describe("alternates", () => {126describe("alternates", () => {
121 test("array form with lang → hreflang link", () => {127 test("array form with lang → hreflang link", () => {
122 const { links } = toTags({ title: "T", alternates: [{ lang: "fr", url: "/fr" }] }, T);128 const { links } = meta.toTags(T, { title: "T", alternates: [{ lang: "fr", url: "/fr" }] });
123 assert.ok(links.some((l) => l.rel === "alternate" && l.hreflang === "fr" && l.href === "https://example.com/fr"));129 assert.ok(links.some((l) => l.rel === "alternate" && l.hreflang === "fr" && l.href === "https://example.com/fr"));
124 });130 });
125131
126 test("array form with type → type link", () => {132 test("array form with type → type link", () => {
127 const { links } = toTags({ title: "T", alternates: [{ type: "application/rss+xml", url: "/feed.rss" }] }, T);133 const { links } = meta.toTags(T, { title: "T", alternates: [{ type: "application/rss+xml", url: "/feed.rss" }] });
128 assert.ok(links.some((l) => l.rel === "alternate" && l.type === "application/rss+xml"));134 assert.ok(links.some((l) => l.rel === "alternate" && l.type === "application/rss+xml"));
129 });135 });
130136
131 test("record form: key with slash → type", () => {137 test("record form: key with slash → type", () => {
132 const { links } = toTags({ title: "T", alternates: { "application/atom+xml": "/atom.xml" } }, T);138 const { links } = meta.toTags(T, { title: "T", alternates: { "application/atom+xml": "/atom.xml" } });
133 assert.ok(links.some((l) => l.type === "application/atom+xml" && !l.hreflang));139 assert.ok(links.some((l) => l.type === "application/atom+xml" && !l.hreflang));
134 });140 });
135141
136 test("record form: key without slash → hreflang", () => {142 test("record form: key without slash → hreflang", () => {
137 const { links } = toTags({ title: "T", alternates: { "ja": "/ja/" } }, T);143 const { links } = meta.toTags(T, { title: "T", alternates: { "ja": "/ja/" } });
138 assert.ok(links.some((l) => l.hreflang === "ja" && !l.type));144 assert.ok(links.some((l) => l.hreflang === "ja" && !l.type));
139 });145 });
140146
141 test("alternate URLs resolved against base", () => {147 test("alternate URLs resolved against base", () => {
142 const { links } = toTags({ title: "T", alternates: [{ lang: "de", url: "/de" }] }, T);148 const { links } = meta.toTags(T, { title: "T", alternates: [{ lang: "de", url: "/de" }] });
143 assert.ok(links.some((l) => l.href === "https://example.com/de"));149 assert.ok(links.some((l) => l.href === "https://example.com/de"));
144 });150 });
145151
146 test("falls back to template.alternates", () => {152 test("falls back to template.alternates", () => {
147 const { links } = toTags({ title: "T" }, { ...T, alternates: [{ lang: "en", url: "/" }] });153 const { links } = meta.toTags({ ...T, alternates: [{ lang: "en", url: "/" }] }, { title: "T" });
148 assert.ok(links.some((l) => l.hreflang === "en"));154 assert.ok(links.some((l) => l.hreflang === "en"));
149 });155 });
150});156});
151157
152describe("embed", () => {158describe("embed", () => {
153 test("no thumbnail → twitter:card = summary", () => {159 test("no thumbnail → twitter:card = summary", () => {
154 const { meta } = toTags({ title: "T", embed: {} }, T);160 const tags = meta.toTags(T, { title: "T", embed: {} });
155 assert.ok(meta.some((m) => m.name === "twitter:card" && m.content === "summary"));161 assert.ok(tags.meta.some((m) => m.name === "twitter:card" && m.content === "summary"));
156 });162 });
157163
158 test("thumbnail → twitter:card = summary_large_image", () => {164 test("thumbnail → twitter:card = summary_large_image", () => {
159 const { meta } = toTags({ title: "T", embed: { thumbnail: "/img.png" } }, T);165 const tags = meta.toTags(T, { title: "T", embed: { thumbnail: "/img.png" } });
160 assert.ok(meta.some((m) => m.name === "twitter:card" && m.content === "summary_large_image"));166 assert.ok(tags.meta.some((m) => m.name === "twitter:card" && m.content === "summary_large_image"));
161 });167 });
162168
163 test("thumbnailSize: icon overrides thumbnail default", () => {169 test("thumbnailSize: icon overrides thumbnail default", () => {
164 const { meta } = toTags({ title: "T", embed: { thumbnail: "/img.png", thumbnailSize: "icon" } }, T);170 const tags = meta.toTags(T, { title: "T", embed: { thumbnail: "/img.png", thumbnailSize: "icon" } });
165 assert.ok(meta.some((m) => m.name === "twitter:card" && m.content === "summary"));171 assert.ok(tags.meta.some((m) => m.name === "twitter:card" && m.content === "summary"));
166 });172 });
167173
168 test("thumbnail resolved and applied to both og:image and twitter:image", () => {174 test("thumbnail resolved and applied to both og:image and twitter:image", () => {
169 const { meta } = toTags({ title: "T", embed: { thumbnail: "/img.png" } }, T);175 const tags = meta.toTags(T, { title: "T", embed: { thumbnail: "/img.png" } });
170 assert.ok(meta.some((m) => m.name === "og:image" && m.content === "https://example.com/img.png"));176 assert.ok(tags.meta.some((m) => m.name === "og:image" && m.content === "https://example.com/img.png"));
171 assert.ok(meta.some((m) => m.name === "twitter:image" && m.content === "https://example.com/img.png"));177 assert.ok(tags.meta.some((m) => m.name === "twitter:image" && m.content === "https://example.com/img.png"));
172 });178 });
173179
174 test("embed title falls back to page title for og", () => {180 test("embed title falls back to page title for og", () => {
175 const { meta } = toTags({ title: "My Page", embed: {} }, T);181 const tags = meta.toTags(T, { title: "My Page", embed: {} });
176 assert.ok(meta.some((m) => m.name === "og:title" && m.content === "My Page"));182 assert.ok(tags.meta.some((m) => m.name === "og:title" && m.content === "My Page"));
177 });183 });
178184
179 test("embed title overrides page title for og", () => {185 test("embed title overrides page title for og", () => {
180 const { meta } = toTags({ title: "My Page", embed: { title: "Share Title" } }, T);186 const tags = meta.toTags(T, { title: "My Page", embed: { title: "Share Title" } });
181 assert.ok(meta.some((m) => m.name === "og:title" && m.content === "Share Title"));187 assert.ok(tags.meta.some((m) => m.name === "og:title" && m.content === "Share Title"));
182 });188 });
183189
184 test("embed description falls back to page description", () => {190 test("embed description falls back to page description", () => {
185 const { meta } = toTags({ title: "T", description: "page desc", embed: {} }, T);191 const tags = meta.toTags(T, { title: "T", description: "page desc", embed: {} });
186 assert.ok(meta.some((m) => m.name === "og:description" && m.content === "page desc"));192 assert.ok(tags.meta.some((m) => m.name === "og:description" && m.content === "page desc"));
187 });193 });
188194
189 test("siteTitle sets og:site_name", () => {195 test("siteTitle sets og:site_name", () => {
190 const { meta } = toTags({ title: "T", embed: { siteTitle: "My Site" } }, T);196 const tags = meta.toTags(T, { title: "T", embed: { siteTitle: "My Site" } });
191 assert.ok(meta.some((m) => m.name === "og:site_name" && m.content === "My Site"));197 assert.ok(tags.meta.some((m) => m.name === "og:site_name" && m.content === "My Site"));
192 });198 });
193199
194 test("canonical sets og:url when embed active", () => {200 test("canonical sets og:url when embed active", () => {
195 const { meta } = toTags({ title: "T", canonical: "/page", embed: {} }, T);201 const tags = meta.toTags(T, { title: "T", canonical: "/page", embed: {} });
196 assert.ok(meta.some((m) => m.name === "og:url" && m.content === "https://example.com/page"));202 assert.ok(tags.meta.some((m) => m.name === "og:url" && m.content === "https://example.com/page"));
197 });203 });
198204
199 test("no canonical → no og:url injected by embed", () => {205 test("no canonical → no og:url injected by embed", () => {
200 const { meta } = toTags({ title: "T", embed: {} }, T);206 const tags = meta.toTags(T, { title: "T", embed: {} });
201 assert.ok(!meta.some((m) => m.name === "og:url"));207 assert.ok(!tags.meta.some((m) => m.name === "og:url"));
202 });208 });
203209
204 test("explicit openGraph fields override embed defaults", () => {210 test("explicit openGraph fields override embed defaults", () => {
205 const { meta } = toTags({ title: "T", embed: { title: "Embed" }, openGraph: { title: "Explicit" } }, T);211 const tags = meta.toTags(T, { title: "T", embed: { title: "Embed" }, openGraph: { title: "Explicit" } });
206 assert.ok(meta.some((m) => m.name === "og:title" && m.content === "Explicit"));212 assert.ok(tags.meta.some((m) => m.name === "og:title" && m.content === "Explicit"));
207 assert.equal(meta.filter((m) => m.name === "og:title").length, 1);213 assert.equal(tags.meta.filter((m) => m.name === "og:title").length, 1);
208 });214 });
209215
210 test("embed sets og:type = website", () => {216 test("embed sets og:type = website", () => {
211 const { meta } = toTags({ title: "T", embed: {} }, T);217 const tags = meta.toTags(T, { title: "T", embed: {} });
212 assert.ok(meta.some((m) => m.name === "og:type" && m.content === "website"));218 assert.ok(tags.meta.some((m) => m.name === "og:type" && m.content === "website"));
213 });219 });
214});220});
215221
216describe("openGraph nested/array fields", () => {222describe("openGraph nested/array fields", () => {
217 test("nested object emits namespaced tags", () => {223 test("nested object emits namespaced tags", () => {
218 const { meta } = toTags(224 const tags = meta.toTags(
219 { title: "T", openGraph: { image: { url: "https://example.com/img.png", width: "800" } } },
220 T,225 T,
226 { title: "T", openGraph: { image: { url: "https://example.com/img.png", width: "800" } } },
221 );227 );
222 assert.ok(meta.some((m) => m.name === "og:image:url" && m.content === "https://example.com/img.png"));228 assert.ok(tags.meta.some((m) => m.name === "og:image:url" && m.content === "https://example.com/img.png"));
223 assert.ok(meta.some((m) => m.name === "og:image:width" && m.content === "800"));229 assert.ok(tags.meta.some((m) => m.name === "og:image:width" && m.content === "800"));
224 });230 });
225231
226 test("array value emits duplicate tags", () => {232 test("array value emits duplicate tags", () => {
227 const { meta } = toTags({ title: "T", openGraph: { image: ["https://a.com/1.png", "https://a.com/2.png"] } }, T);233 const tags = meta.toTags(
228 assert.equal(meta.filter((m) => m.name === "og:image").length, 2);234 T,
235 { title: "T", openGraph: { image: ["https://a.com/1.png", "https://a.com/2.png"] } },
236 );
237 assert.equal(tags.meta.filter((m) => m.name === "og:image").length, 2);
229 });238 });
230239
231 test("null field is skipped", () => {240 test("null field is skipped", () => {
232 const { meta } = toTags({ title: "T", openGraph: { image: null } }, T);241 const tags = meta.toTags(T, { title: "T", openGraph: { image: null } });
233 assert.ok(!meta.some((m) => m.name === "og:image"));242 assert.ok(!tags.meta.some((m) => m.name === "og:image"));
234 });243 });
235});244});
236245
237describe("template fallbacks", () => {246describe("template fallbacks", () => {
238 test("template fields used when meta omits them", () => {247 test("template fields used when meta omits them", () => {
239 const template: Template = { base, publisher: "Acme", keywords: ["a", "b"] };248 const template: meta.Template = { base, publisher: "Acme", keywords: ["a", "b"] };
240 const { meta } = toTags({ title: "T" }, template);249 const tags = meta.toTags(template, { title: "T" });
241 assert.ok(meta.some((m) => m.name === "publisher" && m.content === "Acme"));250 assert.ok(tags.meta.some((m) => m.name === "publisher" && m.content === "Acme"));
242 assert.ok(meta.some((m) => m.name === "keywords" && m.content === "a, b"));251 assert.ok(tags.meta.some((m) => m.name === "keywords" && m.content === "a, b"));
243 });252 });
244253
245 test("meta field overrides template, no duplicate", () => {254 test("meta field overrides template, no duplicate", () => {
246 const template: Template = { base, publisher: "Acme" };255 const template: meta.Template = { base, publisher: "Acme" };
247 const { meta } = toTags({ title: "T", publisher: "Override" }, template);256 const tags = meta.toTags(template, { title: "T", publisher: "Override" });
248 assert.ok(meta.some((m) => m.name === "publisher" && m.content === "Override"));257 assert.ok(tags.meta.some((m) => m.name === "publisher" && m.content === "Override"));
249 assert.equal(meta.filter((m) => m.name === "publisher").length, 1);258 assert.equal(tags.meta.filter((m) => m.name === "publisher").length, 1);
250 });259 });
251});260});
252261
253describe("toHtml serialization", () => {262describe("toHtml serialization", () => {
254 test("attribute values with spaces are quoted", () => {263 test("attribute values with spaces are quoted", () => {
255 const out = toHtml({ title: "T", description: "hello world" }, T);264 const out = meta.toHtml(T, { title: "T", description: "hello world" });
256 assert.ok(out.includes(`content="hello world"`));265 assert.ok(out.includes(`content="hello world"`));
257 });266 });
258267
259 test("attribute values without special chars are unquoted", () => {268 test("attribute values without special chars are unquoted", () => {
260 const out = toHtml({ title: "T", publisher: "Acme" }, T);269 const out = meta.toHtml(T, { title: "T", publisher: "Acme" });
261 assert.ok(out.includes(`content=Acme`));270 assert.ok(out.includes(`content=Acme`));
262 });271 });
263272
264 test("attribute value with slash is quoted", () => {273 test("attribute value with slash is quoted", () => {
265 const out = toHtml({ title: "T" }, T);274 const out = meta.toHtml(T, { title: "T" });
266 // viewport contains / — ensure it's quoted275 // viewport contains / — ensure it's quoted
267 assert.ok(out.includes(`content="width=device-width`));276 assert.ok(out.includes(`content="width=device-width`));
268 });277 });
269278
270 test("description with double quotes is escaped", () => {279 test("description with double quotes is escaped", () => {
271 const out = toHtml({ title: "T", description: `say "hi"` }, T);280 const out = meta.toHtml(T, { title: "T", description: `say "hi"` });
272 assert.ok(!out.includes(`"say "hi"`));281 assert.ok(!out.includes(`"say "hi"`));
273 });282 });
274});283});
275284
276describe("scripts", () => {285describe("scripts", () => {
277 test("inline script emits children in scripts array", () => {286 test("inline script emits children in scripts array", () => {
278 const { scripts } = toTags({ title: "T", scripts: [{ content: "alert(1)" }] }, T);287 const { scripts } = meta.toTags(T, { title: "T", scripts: [{ content: "alert(1)" }] });
279 assert.deepEqual(scripts, [{ children: "alert(1)" }]);288 assert.deepEqual(scripts, [{ children: "alert(1)" }]);
280 });289 });
281290
282 test("external script with src and boolean attrs", () => {291 test("external script with src and boolean attrs", () => {
283 const { scripts } = toTags({ title: "T", scripts: [{ src: "/app.js", async: true, defer: false }] }, T);292 const { scripts } = meta.toTags(T, { title: "T", scripts: [{ src: "/app.js", async: true, defer: false }] });
284 assert.equal(scripts.length, 1);293 assert.equal(scripts.length, 1);
294 assert.ok(scripts[0]);
285 assert.equal(scripts[0].src, "/app.js");295 assert.equal(scripts[0].src, "/app.js");
286 assert.equal(scripts[0].async, true);296 assert.equal(scripts[0].async, true);
287 assert.equal(scripts[0].defer, undefined);297 assert.equal(scripts[0].defer, undefined);
288 });298 });
289299
290 test("toHtml renders inline script", () => {300 test("toHtml renders inline script", () => {
291 const out = toHtml({ title: "T", scripts: [{ content: "console.log(1)" }] }, T);301 const out = meta.toHtml(T, { title: "T", scripts: [{ content: "console.log(1)" }] });
292 assert.ok(out.includes("<script>console.log(1)</script>"));302 assert.ok(out.includes("<script>console.log(1)</script>"));
293 });303 });
294304
295 test("toHtml renders external script with boolean attr", () => {305 test("toHtml renders external script with boolean attr", () => {
296 const out = toHtml({ title: "T", scripts: [{ src: "/app.js", async: true }] }, T);306 const out = meta.toHtml(T, { title: "T", scripts: [{ src: "/app.js", async: true }] });
297 assert.ok(out.includes("<script src="));307 assert.ok(out.includes("<script src="));
298 assert.ok(out.includes(" async></script>"));308 assert.ok(out.includes(" async></script>"));
299 });309 });
300310
301 test("falls back to template scripts", () => {311 test("falls back to template scripts", () => {
302 const { scripts } = toTags({ title: "T" }, { ...T, scripts: [{ content: "init()" }] });312 const { scripts } = meta.toTags({ ...T, scripts: [{ content: "init()" }] }, { title: "T" });
303 assert.deepEqual(scripts, [{ children: "init()" }]);313 assert.deepEqual(scripts, [{ children: "init()" }]);
304 });314 });
305});315});
306316
307describe("styles", () => {317describe("styles", () => {
308 test("inline style emits children in styles array", () => {318 test("inline style emits children in styles array", () => {
309 const { styles } = toTags({ title: "T", styles: [{ content: "body{margin:0}" }] }, T);319 const { styles } = meta.toTags(T, { title: "T", styles: [{ content: "body{margin:0}" }] });
310 assert.deepEqual(styles, [{ children: "body{margin:0}" }]);320 assert.deepEqual(styles, [{ children: "body{margin:0}" }]);
311 });321 });
312322
313 test("inline style with media", () => {323 test("inline style with media", () => {
314 const { styles } = toTags({ title: "T", styles: [{ content: "p{color:blue}", media: "print" }] }, T);324 const { styles } = meta.toTags(T, { title: "T", styles: [{ content: "p{color:blue}", media: "print" }] });
315 assert.deepEqual(styles, [{ children: "p{color:blue}", media: "print" }]);325 assert.deepEqual(styles, [{ children: "p{color:blue}", media: "print" }]);
316 });326 });
317327
318 test("external stylesheet goes into links", () => {328 test("external stylesheet goes into links", () => {
319 const { links, styles } = toTags({ title: "T", styles: [{ href: "/style.css" }] }, T);329 const { links, styles } = meta.toTags(T, { title: "T", styles: [{ href: "/style.css" }] });
320 assert.ok(links.some((l) => l.rel === "stylesheet" && l.href === "/style.css"));330 assert.ok(links.some((l) => l.rel === "stylesheet" && l.href === "/style.css"));
321 assert.equal(styles.length, 0);331 assert.equal(styles.length, 0);
322 });332 });
323333
324 test("external stylesheet with media", () => {334 test("external stylesheet with media", () => {
325 const { links } = toTags({ title: "T", styles: [{ href: "/print.css", media: "print" }] }, T);335 const { links } = meta.toTags(T, { title: "T", styles: [{ href: "/print.css", media: "print" }] });
326 assert.ok(links.some((l) => l.rel === "stylesheet" && l.href === "/print.css" && l.media === "print"));336 assert.ok(links.some((l) => l.rel === "stylesheet" && l.href === "/print.css" && l.media === "print"));
327 });337 });
328338
329 test("toHtml renders inline style", () => {339 test("toHtml renders inline style", () => {
330 const out = toHtml({ title: "T", styles: [{ content: "body{margin:0}" }] }, T);340 const out = meta.toHtml(T, { title: "T", styles: [{ content: "body{margin:0}" }] });
331 assert.ok(out.includes("<style>body{margin:0}</style>"));341 assert.ok(out.includes("<style>body{margin:0}</style>"));
332 });342 });
333343
334 test("toHtml renders inline style with media", () => {344 test("toHtml renders inline style with media", () => {
335 const out = toHtml({ title: "T", styles: [{ content: "p{color:blue}", media: "print" }] }, T);345 const out = meta.toHtml(T, { title: "T", styles: [{ content: "p{color:blue}", media: "print" }] });
336 assert.ok(out.includes("<style media=print>p{color:blue}</style>"));346 assert.ok(out.includes("<style media=print>p{color:blue}</style>"));
337 });347 });
338348
339 test("falls back to template styles", () => {349 test("falls back to template styles", () => {
340 const { styles } = toTags({ title: "T" }, { ...T, styles: [{ content: "h1{color:red}" }] });350 const { styles } = meta.toTags({ ...T, styles: [{ content: "h1{color:red}" }] }, { title: "T" });
341 assert.deepEqual(styles, [{ children: "h1{color:red}" }]);351 assert.deepEqual(styles, [{ children: "h1{color:red}" }]);
342 });352 });
343});353});
344354
345describe("extra", () => {355describe("extra", () => {
346 test("tag=meta goes into meta array", () => {356 test("tag=meta goes into meta array", () => {
347 const { meta } = toTags({ title: "T", extra: [{ tag: "meta", name: "verify", content: "abc" }] }, T);357 const tags = meta.toTags(T, { title: "T", extra: [{ tag: "meta", name: "verify", content: "abc" }] });
348 assert.ok(meta.some((m) => m.name === "verify" && m.content === "abc"));358 assert.ok(tags.meta.some((m) => m.name === "verify" && m.content === "abc"));
349 });359 });
350360
351 test("tag=link goes into links array", () => {361 test("tag=link goes into links array", () => {
352 const { links } = toTags({ title: "T", extra: [{ tag: "link", rel: "icon", href: "/favicon.ico" }] }, T);362 const { links } = meta.toTags(T, { title: "T", extra: [{ tag: "link", rel: "icon", href: "/favicon.ico" }] });
353 assert.ok(links.some((l) => l.rel === "icon" && l.href === "/favicon.ico"));363 assert.ok(links.some((l) => l.rel === "icon" && l.href === "/favicon.ico"));
354 });364 });
355365
356 test("tag=script goes into scripts array", () => {366 test("tag=script goes into scripts array", () => {
357 const { scripts } = toTags({ title: "T", extra: [{ tag: "script", src: "/extra.js", async: true }] }, T);367 const { scripts } = meta.toTags(T, { title: "T", extra: [{ tag: "script", src: "/extra.js", async: true }] });
358 assert.equal(scripts.length, 1);368 assert.equal(scripts.length, 1);
369 assert.ok(scripts[0]);
359 assert.equal(scripts[0].src, "/extra.js");370 assert.equal(scripts[0].src, "/extra.js");
360 assert.equal(scripts[0].async, true);371 assert.equal(scripts[0].async, true);
361 });372 });
362373
363 test("tag=style goes into styles array", () => {374 test("tag=style goes into styles array", () => {
364 const { styles } = toTags({ title: "T", extra: [{ tag: "style", children: "a{color:red}" }] }, T);375 const { styles } = meta.toTags(T, { title: "T", extra: [{ tag: "style", children: "a{color:red}" }] });
365 assert.deepEqual(styles, [{ children: "a{color:red}" }]);376 assert.deepEqual(styles, [{ children: "a{color:red}" }]);
366 });377 });
367378
368 test("null/false attrs are filtered out", () => {379 test("null/false attrs are filtered out", () => {
369 const { meta } = toTags({ title: "T", extra: [{ tag: "meta", name: "x", content: "y", bogus: null }] }, T);380 const tags = meta.toTags(T, { title: "T", extra: [{ tag: "meta", name: "x", content: "y", bogus: null }] });
370 const entry = meta.find((m) => m.name === "x")!;381 const entry = tags.meta.find((m) => m.name === "x")!;
371 assert.equal(entry.bogus, undefined);382 assert.equal(entry.bogus, undefined);
372 });383 });
373384
374 test("falls back to template extra", () => {385 test("falls back to template extra", () => {
375 const { meta } = toTags({ title: "T" }, { ...T, extra: [{ tag: "meta", name: "tmpl", content: "yes" }] });386 const tags = meta.toTags({ ...T, extra: [{ tag: "meta", name: "tmpl", content: "yes" }] }, { title: "T" });
376 assert.ok(meta.some((m) => m.name === "tmpl" && m.content === "yes"));387 assert.ok(tags.meta.some((m) => m.name === "tmpl" && m.content === "yes"));
388 });
389});
390
391// Typescript test for TanStack router compatibility.
392interface HtmlAttributes {
393 id?: string;
394 className?: string;
395 title?: string;
396 lang?: string;
397 dir?: string;
398 hidden?: boolean;
399 tabIndex?: number;
400 children?: unknown;
401}
402interface MetaAttributes extends HtmlAttributes {
403 name?: string;
404 content?: string;
405 charSet?: string;
406 httpEquiv?: string;
407 media?: string;
408}
409interface LinkAttributes extends HtmlAttributes {
410 rel?: string;
411 href?: string;
412 hrefLang?: string;
413 media?: string;
414 type?: string;
415 sizes?: string;
416 as?: string;
417}
418interface ScriptAttributes extends HtmlAttributes {
419 src?: string;
420 async?: boolean;
421 defer?: boolean;
422 type?: string;
423 noModule?: boolean;
424 integrity?: string;
425}
426interface StyleAttributes extends HtmlAttributes {
427 media?: string;
428 type?: string;
429}
430
431/** The `head()` return contract from TanStack Start, deps-free. */
432interface TanStackHead {
433 meta?: Array<MetaAttributes | undefined>;
434 links?: Array<LinkAttributes | undefined>;
435 scripts?: Array<ScriptAttributes | undefined>;
436 styles?: Array<StyleAttributes | undefined>;
437}
438
439// Type-level assertion. A compile error on this line means `toTags`' output is
440// no longer a valid TanStack `head()` payload.
441const _asHead: (tags: meta.Tags) => TanStackHead = (tags) => tags;
442
443describe("TanStack Router compatibility", () => {
444 test("toTags output is a valid head() payload", () => {
445 const tags = meta.toTags(
446 { base: new URL("https://example.com/") },
447 {
448 title: "Home",
449 description: "hi",
450 embed: { thumbnail: "/og.png" },
451 scripts: [{ src: "/a.js", async: true }, { content: "console.log(1)" }],
452 styles: [{ href: "/a.css" }, { content: "a{color:red}" }],
453 extra: [{ tag: "link", rel: "icon", href: "/favicon.ico" }],
454 },
455 );
456 const head: TanStackHead = _asHead(tags);
457 assert.deepEqual(Object.keys(head), ["meta", "links", "scripts", "styles"]);
377 });458 });
378});459});
379460
380import assert from "node:assert";461import assert from "node:assert";
381import { describe, test } from "vitest";462import { describe, test } from "vitest";
382import { type Template, toHtml, toTags } from "./meta.ts";463import * as meta from "./meta.ts";
lib/meta.ts+6-10
...@@ -80,7 +80,7 @@ export interface AutoEmbed {...@@ -80,7 +80,7 @@ export interface AutoEmbed {
80 /* add an image to the embed card. */80 /* add an image to the embed card. */
81 thumbnail?: string | null;81 thumbnail?: string | null;
82 /** @default "banner", which applies twitter:card = "summary_large_image" */82 /** @default "banner", which applies twitter:card = "summary_large_image" */
83 thumbnailSize?: "banner" | "icon";83 thumbnailSize?: "banner" | "icon" | null;
84 /** @default null - no site title */84 /** @default null - no site title */
85 siteTitle?: string | null;85 siteTitle?: string | null;
86}86}
...@@ -153,12 +153,8 @@ export interface Tags {...@@ -153,12 +153,8 @@ export interface Tags {
153 styles: Array<Record<string, string>>;153 styles: Array<Record<string, string>>;
154}154}
155155
156// TODO: before publishing clover lib v5, ensure this is actually compatibile with TanStack Router
157// this type error will prevent publishing the library
158void (NaN as string);
159
160/** convert a metadata definition into a structured list of tags, compatible with TanStack start */156/** convert a metadata definition into a structured list of tags, compatible with TanStack start */
161export function toTags(meta: Meta, template: Template): Tags {157export function toTags(template: Template, meta: Meta): Tags {
162 const { titleTemplate, base } = template;158 const { titleTemplate, base } = template;
163 const resolve = (str: string) => new URL(str, base).href;159 const resolve = (str: string) => new URL(str, base).href;
164160
...@@ -252,7 +248,7 @@ export function toTags(meta: Meta, template: Template): Tags {...@@ -252,7 +248,7 @@ export function toTags(meta: Meta, template: Template): Tags {
252248
253 for (const entry of meta.scripts ?? template.scripts ?? []) {249 for (const entry of meta.scripts ?? template.scripts ?? []) {
254 if ("content" in entry) {250 if ("content" in entry) {
255 scriptTags.push({ children: entry.content });251 scriptTags.push({ children: String(entry.content) });
256 } else {252 } else {
257 const { src, ...rest } = entry;253 const { src, ...rest } = entry;
258 const tag: Record<string, string | boolean> = { src };254 const tag: Record<string, string | boolean> = { src };
...@@ -265,7 +261,7 @@ export function toTags(meta: Meta, template: Template): Tags {...@@ -265,7 +261,7 @@ export function toTags(meta: Meta, template: Template): Tags {
265261
266 for (const entry of meta.styles ?? template.styles ?? []) {262 for (const entry of meta.styles ?? template.styles ?? []) {
267 if ("content" in entry) {263 if ("content" in entry) {
268 const tag: Record<string, string> = { children: entry.content };264 const tag: Record<string, string> = { children: String(entry.content) };
269 if ("media" in entry && entry.media) tag.media = entry.media;265 if ("media" in entry && entry.media) tag.media = entry.media;
270 styleTags.push(tag);266 styleTags.push(tag);
271 } else {267 } else {
...@@ -293,8 +289,8 @@ export function toTags(meta: Meta, template: Template): Tags {...@@ -293,8 +289,8 @@ export function toTags(meta: Meta, template: Template): Tags {
293}289}
294290
295/* convert a metadata definition into HTML text. */291/* convert a metadata definition into HTML text. */
296export function toHtml(meta: Meta, template: Template): string {292export function toHtml(template: Template, meta: Meta): string {
297 const tags = toTags(meta, template);293 const tags = toTags(template, meta);
298 const [{ title }, ...metaTags] = tags.meta as [{ title: string }, ...Record<string, string>[]];294 const [{ title }, ...metaTags] = tags.meta as [{ title: string }, ...Record<string, string>[]];
299 let out = `<title>${esc(title)}</title>`;295 let out = `<title>${esc(title)}</title>`;
300 for (const attrs of metaTags) {296 for (const attrs of metaTags) {
lib/readme.changes.md+18-5
...@@ -7,23 +7,36 @@...@@ -7,23 +7,36 @@
7- `render` has been deleted because it was not being used anymore. sad day7- `render` has been deleted because it was not being used anymore. sad day
8 because i will never get to prove how easy it is to write a stable Suspense8 because i will never get to prove how easy it is to write a stable Suspense
9 and PPR implementations in less than 300 lines each.9 and PPR implementations in less than 300 lines each.
10 - deprecated html escaping methods moved to `string.escapeHtmlContent` and
11 `string.escapeHtmlAttr`
10- `render/meta` is moved to `meta`, with a new API for rendering tags12- `render/meta` is moved to `meta`, with a new API for rendering tags
11 - `meta.render` is renamed to `meta.toHtml`13 - `meta.render` is renamed to `meta.toHtml`, arguments re-ordered
12 - `meta.toTags` creates an object compatible with TanStack Router14 - `meta.toTags` creates an object compatible with TanStack Router
13 - the `extra` object now takes an array of React-like elements among some15 - the `extra` object now takes an array of React-like elements among some
14 other supported forms.16 other supported forms.
15- `log` has recieved so many bug-fix changes that it is worth ensuring your17- `log` and `progress` have recieved so many bug-fix changes that it is worth
16 application edge cases still work as expected.18 ensuring your application edge cases still work as expected.
19- `string.formatByteSize` formats differently.
1720
18### features21### features
1922
20- `mime`'s database contains `.eot` for embedded opentype fonts.23- new library `http`.
21- `async.deferred`24- new library `math`.
25- new library `arrays`.
22- `log`26- `log`
23 - many many many many rendering bug-fixes27 - many many many many rendering bug-fixes
24 - `writeError` writes to stderr with proper logic.28 - `writeError` writes to stderr with proper logic.
25 - buffering auto-flushes synchronously when it grows past 64k.29 - buffering auto-flushes synchronously when it grows past 64k.
26 - custom scopes30 - custom scopes
31- `string`
32 - `formatDurationLetters` spans years to seconds, plus infinity and negatives.
33- `mime`'s database contains `.eot` for embedded opentype fonts.
34- `async.deferred`.
35- `queue.setConcurrency`.
36- `subprocess.lookupProcessOnPort`.
37- `subprocess/ffmpeg` progress handles fractional framerates (progress bug).
38- `string/ansi` gains `clearToEndOfScreen`.
39- `progress` estimation, maxHeight, and timer bug fixes.
2740
28## v441## v4
2942
lib/subprocess.ts+10-5
...@@ -90,7 +90,7 @@ export async function spawnAndWait(...@@ -90,7 +90,7 @@ export async function spawnAndWait(
90// }90// }
91// });91// });
9292
93async function findInodeForPort(port) {93async function findInodeForPort(port: number) {
94 const hexPort = port.toString(16).toUpperCase().padStart(4, "0");94 const hexPort = port.toString(16).toUpperCase().padStart(4, "0");
95 for (const f of ["/proc/net/tcp", "/proc/net/tcp6"]) {95 for (const f of ["/proc/net/tcp", "/proc/net/tcp6"]) {
96 try {96 try {
...@@ -104,7 +104,7 @@ async function findInodeForPort(port) {...@@ -104,7 +104,7 @@ async function findInodeForPort(port) {
104 return null;104 return null;
105}105}
106106
107async function findPidForInode(inode) {107async function findPidForInode(inode: string) {
108 const target = `socket:[${inode}]`;108 const target = `socket:[${inode}]`;
109 for (const pid of await fs.readdir("/proc")) {109 for (const pid of await fs.readdir("/proc")) {
110 if (!/^\d+$/.test(pid)) continue;110 if (!/^\d+$/.test(pid)) continue;
...@@ -154,7 +154,8 @@ async function commandFromPortWindows(port: number) {...@@ -154,7 +154,8 @@ async function commandFromPortWindows(port: number) {
154 return { pid: Number(pid), command: cmdline };154 return { pid: Number(pid), command: cmdline };
155}155}
156156
157export async function lookupProcessOnPort(port: number) {157/** Racily searches for what process is taking up a given port. */
158export async function lookupProcessOnPort(port: number): Promise<ProcessLookup> {
158 switch (os.platform()) {159 switch (os.platform()) {
159 case "linux":160 case "linux":
160 return commandFromPortLinux(port);161 return commandFromPortLinux(port);
...@@ -167,10 +168,14 @@ export async function lookupProcessOnPort(port: number) {...@@ -167,10 +168,14 @@ export async function lookupProcessOnPort(port: number) {
167 }168 }
168}169}
169170
170// import * as process from "node:process";171interface ProcessLookup {
172 pid: number;
173 command: string;
174}
175
171import { Buffer } from "node:buffer";176import { Buffer } from "node:buffer";
172import * as child_process from "node:child_process";177import * as child_process from "node:child_process";
173import * as events from "node:events";178import * as events from "node:events";
174import * as fs from "node:fs/promises";179import * as fs from "node:fs/promises";
180import * as os from "node:os";
175import * as util from "node:util";181import * as util from "node:util";
176import os from "os";
lib/testing.ts+8-6
...@@ -111,13 +111,15 @@ export class SyncPromise<T> implements Promise<T> {...@@ -111,13 +111,15 @@ export class SyncPromise<T> implements Promise<T> {
111 }111 }
112}112}
113113
114export interface FakeTimerEntry {
115 deadline: number;
116 resolve: () => void;
117 src: stack.Frame[];
118}
119
114export class FakeTimers {120export class FakeTimers {
115 time = 0;121 time = 0;
116 entries: Array<{122 entries: Array<FakeTimerEntry> = [];
117 deadline: number;
118 resolve: () => void;
119 src: stack.Frame[];
120 }> = [];
121123
122 now: () => number = () => {124 now: () => number = () => {
123 return this.time;125 return this.time;
...@@ -260,7 +262,7 @@ export class MockScreen implements Disposable, log.WidgetHost {...@@ -260,7 +262,7 @@ export class MockScreen implements Disposable, log.WidgetHost {
260 ASSERT(this.timers.entries.length === 0);262 ASSERT(this.timers.entries.length === 0);
261 }263 }
262264
263 expectWithoutConsume(ms: number) {265 expectWithoutConsume(ms: number): FakeTimerEntry {
264 const wait = UNWRAP(266 const wait = UNWRAP(
265 this.timers.next(),267 this.timers.next(),
266 () => this.out.length > 0 ? "terminal i/o did not wait" : "no terminal i/o",268 () => this.out.length > 0 ? "terminal i/o did not wait" : "no terminal i/o",
package-lock.json-420
...@@ -573,22 +573,6 @@...@@ -573,22 +573,6 @@
573 "node": ">=18"573 "node": ">=18"
574 }574 }
575 },575 },
576 "node_modules/@esbuild/openharmony-arm64": {
577 "version": "0.27.7",
578 "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz",
579 "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==",
580 "cpu": [
581 "arm64"
582 ],
583 "license": "MIT",
584 "optional": true,
585 "os": [
586 "openharmony"
587 ],
588 "engines": {
589 "node": ">=18"
590 }
591 },
592 "node_modules/@esbuild/sunos-x64": {576 "node_modules/@esbuild/sunos-x64": {
593 "version": "0.25.5",577 "version": "0.25.5",
594 "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.5.tgz",578 "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.5.tgz",
...@@ -673,7 +657,6 @@...@@ -673,7 +657,6 @@
673 "resolved": "https://registry.npmjs.org/@marko/compiler/-/compiler-5.39.62.tgz",657 "resolved": "https://registry.npmjs.org/@marko/compiler/-/compiler-5.39.62.tgz",
674 "integrity": "sha512-aBmWraqvVMLzi3oZkm5cEAVTv4qtLIVl9tkci6M+duSaEbKq4+n9FuNjYlOA0/4slRmOeAOuYyi2BenzgKmQNA==",658 "integrity": "sha512-aBmWraqvVMLzi3oZkm5cEAVTv4qtLIVl9tkci6M+duSaEbKq4+n9FuNjYlOA0/4slRmOeAOuYyi2BenzgKmQNA==",
675 "license": "MIT",659 "license": "MIT",
676 "peer": true,
677 "dependencies": {660 "dependencies": {
678 "@luxass/strip-json-comments": "^1.4.0",661 "@luxass/strip-json-comments": "^1.4.0",
679 "complain": "^1.6.1",662 "complain": "^1.6.1",
...@@ -1443,7 +1426,6 @@...@@ -1443,7 +1426,6 @@
1443 "integrity": "sha512-P8OtKZRv/5J5hhz0cUAdu/cLuPIKXpQl1R9pZtvmHWQvrAUVd0UNIPT4IB4W3rNOqVO0rlqHmCIbSwxh/c9yUQ==",1426 "integrity": "sha512-P8OtKZRv/5J5hhz0cUAdu/cLuPIKXpQl1R9pZtvmHWQvrAUVd0UNIPT4IB4W3rNOqVO0rlqHmCIbSwxh/c9yUQ==",
1444 "hasInstallScript": true,1427 "hasInstallScript": true,
1445 "license": "MIT",1428 "license": "MIT",
1446 "peer": true,
1447 "bin": {1429 "bin": {
1448 "esbuild": "bin/esbuild"1430 "esbuild": "bin/esbuild"
1449 },1431 },
...@@ -2114,7 +2096,6 @@...@@ -2114,7 +2096,6 @@
2114 "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",2096 "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
2115 "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",2097 "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
2116 "license": "MIT",2098 "license": "MIT",
2117 "peer": true,
2118 "engines": {2099 "engines": {
2119 "node": ">=12"2100 "node": ">=12"
2120 },2101 },
...@@ -2528,406 +2509,6 @@...@@ -2528,406 +2509,6 @@
2528 }2509 }
2529 }2510 }
2530 },2511 },
2531 "node_modules/vitest/node_modules/@esbuild/aix-ppc64": {
2532 "version": "0.27.7",
2533 "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz",
2534 "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==",
2535 "cpu": [
2536 "ppc64"
2537 ],
2538 "license": "MIT",
2539 "optional": true,
2540 "os": [
2541 "aix"
2542 ],
2543 "engines": {
2544 "node": ">=18"
2545 }
2546 },
2547 "node_modules/vitest/node_modules/@esbuild/android-arm": {
2548 "version": "0.27.7",
2549 "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz",
2550 "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==",
2551 "cpu": [
2552 "arm"
2553 ],
2554 "license": "MIT",
2555 "optional": true,
2556 "os": [
2557 "android"
2558 ],
2559 "engines": {
2560 "node": ">=18"
2561 }
2562 },
2563 "node_modules/vitest/node_modules/@esbuild/android-arm64": {
2564 "version": "0.27.7",
2565 "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz",
2566 "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==",
2567 "cpu": [
2568 "arm64"
2569 ],
2570 "license": "MIT",
2571 "optional": true,
2572 "os": [
2573 "android"
2574 ],
2575 "engines": {
2576 "node": ">=18"
2577 }
2578 },
2579 "node_modules/vitest/node_modules/@esbuild/android-x64": {
2580 "version": "0.27.7",
2581 "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz",
2582 "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==",
2583 "cpu": [
2584 "x64"
2585 ],
2586 "license": "MIT",
2587 "optional": true,
2588 "os": [
2589 "android"
2590 ],
2591 "engines": {
2592 "node": ">=18"
2593 }
2594 },
2595 "node_modules/vitest/node_modules/@esbuild/darwin-arm64": {
2596 "version": "0.27.7",
2597 "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz",
2598 "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==",
2599 "cpu": [
2600 "arm64"
2601 ],
2602 "license": "MIT",
2603 "optional": true,
2604 "os": [
2605 "darwin"
2606 ],
2607 "engines": {
2608 "node": ">=18"
2609 }
2610 },
2611 "node_modules/vitest/node_modules/@esbuild/darwin-x64": {
2612 "version": "0.27.7",
2613 "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz",
2614 "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==",
2615 "cpu": [
2616 "x64"
2617 ],
2618 "license": "MIT",
2619 "optional": true,
2620 "os": [
2621 "darwin"
2622 ],
2623 "engines": {
2624 "node": ">=18"
2625 }
2626 },
2627 "node_modules/vitest/node_modules/@esbuild/freebsd-arm64": {
2628 "version": "0.27.7",
2629 "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz",
2630 "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==",
2631 "cpu": [
2632 "arm64"
2633 ],
2634 "license": "MIT",
2635 "optional": true,
2636 "os": [
2637 "freebsd"
2638 ],
2639 "engines": {
2640 "node": ">=18"
2641 }
2642 },
2643 "node_modules/vitest/node_modules/@esbuild/freebsd-x64": {
2644 "version": "0.27.7",
2645 "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz",
2646 "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==",
2647 "cpu": [
2648 "x64"
2649 ],
2650 "license": "MIT",
2651 "optional": true,
2652 "os": [
2653 "freebsd"
2654 ],
2655 "engines": {
2656 "node": ">=18"
2657 }
2658 },
2659 "node_modules/vitest/node_modules/@esbuild/linux-arm": {
2660 "version": "0.27.7",
2661 "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz",
2662 "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==",
2663 "cpu": [
2664 "arm"
2665 ],
2666 "license": "MIT",
2667 "optional": true,
2668 "os": [
2669 "linux"
2670 ],
2671 "engines": {
2672 "node": ">=18"
2673 }
2674 },
2675 "node_modules/vitest/node_modules/@esbuild/linux-arm64": {
2676 "version": "0.27.7",
2677 "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz",
2678 "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==",
2679 "cpu": [
2680 "arm64"
2681 ],
2682 "license": "MIT",
2683 "optional": true,
2684 "os": [
2685 "linux"
2686 ],
2687 "engines": {
2688 "node": ">=18"
2689 }
2690 },
2691 "node_modules/vitest/node_modules/@esbuild/linux-ia32": {
2692 "version": "0.27.7",
2693 "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz",
2694 "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==",
2695 "cpu": [
2696 "ia32"
2697 ],
2698 "license": "MIT",
2699 "optional": true,
2700 "os": [
2701 "linux"
2702 ],
2703 "engines": {
2704 "node": ">=18"
2705 }
2706 },
2707 "node_modules/vitest/node_modules/@esbuild/linux-loong64": {
2708 "version": "0.27.7",
2709 "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz",
2710 "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==",
2711 "cpu": [
2712 "loong64"
2713 ],
2714 "license": "MIT",
2715 "optional": true,
2716 "os": [
2717 "linux"
2718 ],
2719 "engines": {
2720 "node": ">=18"
2721 }
2722 },
2723 "node_modules/vitest/node_modules/@esbuild/linux-mips64el": {
2724 "version": "0.27.7",
2725 "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz",
2726 "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==",
2727 "cpu": [
2728 "mips64el"
2729 ],
2730 "license": "MIT",
2731 "optional": true,
2732 "os": [
2733 "linux"
2734 ],
2735 "engines": {
2736 "node": ">=18"
2737 }
2738 },
2739 "node_modules/vitest/node_modules/@esbuild/linux-ppc64": {
2740 "version": "0.27.7",
2741 "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz",
2742 "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==",
2743 "cpu": [
2744 "ppc64"
2745 ],
2746 "license": "MIT",
2747 "optional": true,
2748 "os": [
2749 "linux"
2750 ],
2751 "engines": {
2752 "node": ">=18"
2753 }
2754 },
2755 "node_modules/vitest/node_modules/@esbuild/linux-riscv64": {
2756 "version": "0.27.7",
2757 "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz",
2758 "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==",
2759 "cpu": [
2760 "riscv64"
2761 ],
2762 "license": "MIT",
2763 "optional": true,
2764 "os": [
2765 "linux"
2766 ],
2767 "engines": {
2768 "node": ">=18"
2769 }
2770 },
2771 "node_modules/vitest/node_modules/@esbuild/linux-s390x": {
2772 "version": "0.27.7",
2773 "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz",
2774 "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==",
2775 "cpu": [
2776 "s390x"
2777 ],
2778 "license": "MIT",
2779 "optional": true,
2780 "os": [
2781 "linux"
2782 ],
2783 "engines": {
2784 "node": ">=18"
2785 }
2786 },
2787 "node_modules/vitest/node_modules/@esbuild/linux-x64": {
2788 "version": "0.27.7",
2789 "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz",
2790 "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==",
2791 "cpu": [
2792 "x64"
2793 ],
2794 "license": "MIT",
2795 "optional": true,
2796 "os": [
2797 "linux"
2798 ],
2799 "engines": {
2800 "node": ">=18"
2801 }
2802 },
2803 "node_modules/vitest/node_modules/@esbuild/netbsd-arm64": {
2804 "version": "0.27.7",
2805 "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz",
2806 "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==",
2807 "cpu": [
2808 "arm64"
2809 ],
2810 "license": "MIT",
2811 "optional": true,
2812 "os": [
2813 "netbsd"
2814 ],
2815 "engines": {
2816 "node": ">=18"
2817 }
2818 },
2819 "node_modules/vitest/node_modules/@esbuild/netbsd-x64": {
2820 "version": "0.27.7",
2821 "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz",
2822 "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==",
2823 "cpu": [
2824 "x64"
2825 ],
2826 "license": "MIT",
2827 "optional": true,
2828 "os": [
2829 "netbsd"
2830 ],
2831 "engines": {
2832 "node": ">=18"
2833 }
2834 },
2835 "node_modules/vitest/node_modules/@esbuild/openbsd-arm64": {
2836 "version": "0.27.7",
2837 "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz",
2838 "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==",
2839 "cpu": [
2840 "arm64"
2841 ],
2842 "license": "MIT",
2843 "optional": true,
2844 "os": [
2845 "openbsd"
2846 ],
2847 "engines": {
2848 "node": ">=18"
2849 }
2850 },
2851 "node_modules/vitest/node_modules/@esbuild/openbsd-x64": {
2852 "version": "0.27.7",
2853 "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz",
2854 "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==",
2855 "cpu": [
2856 "x64"
2857 ],
2858 "license": "MIT",
2859 "optional": true,
2860 "os": [
2861 "openbsd"
2862 ],
2863 "engines": {
2864 "node": ">=18"
2865 }
2866 },
2867 "node_modules/vitest/node_modules/@esbuild/sunos-x64": {
2868 "version": "0.27.7",
2869 "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz",
2870 "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==",
2871 "cpu": [
2872 "x64"
2873 ],
2874 "license": "MIT",
2875 "optional": true,
2876 "os": [
2877 "sunos"
2878 ],
2879 "engines": {
2880 "node": ">=18"
2881 }
2882 },
2883 "node_modules/vitest/node_modules/@esbuild/win32-arm64": {
2884 "version": "0.27.7",
2885 "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz",
2886 "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==",
2887 "cpu": [
2888 "arm64"
2889 ],
2890 "license": "MIT",
2891 "optional": true,
2892 "os": [
2893 "win32"
2894 ],
2895 "engines": {
2896 "node": ">=18"
2897 }
2898 },
2899 "node_modules/vitest/node_modules/@esbuild/win32-ia32": {
2900 "version": "0.27.7",
2901 "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz",
2902 "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==",
2903 "cpu": [
2904 "ia32"
2905 ],
2906 "license": "MIT",
2907 "optional": true,
2908 "os": [
2909 "win32"
2910 ],
2911 "engines": {
2912 "node": ">=18"
2913 }
2914 },
2915 "node_modules/vitest/node_modules/@esbuild/win32-x64": {
2916 "version": "0.27.7",
2917 "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz",
2918 "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==",
2919 "cpu": [
2920 "x64"
2921 ],
2922 "license": "MIT",
2923 "optional": true,
2924 "os": [
2925 "win32"
2926 ],
2927 "engines": {
2928 "node": ">=18"
2929 }
2930 },
2931 "node_modules/vitest/node_modules/@vitest/mocker": {2512 "node_modules/vitest/node_modules/@vitest/mocker": {
2932 "version": "4.1.2",2513 "version": "4.1.2",
2933 "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.2.tgz",2514 "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.2.tgz",
...@@ -2973,7 +2554,6 @@...@@ -2973,7 +2554,6 @@
2973 "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.3.tgz",2554 "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.3.tgz",
2974 "integrity": "sha512-B9ifbFudT1TFhfltfaIPgjo9Z3mDynBTJSUYxTjOQruf/zHH+ezCQKcoqO+h7a9Pw9Nm/OtlXAiGT1axBgwqrQ==",2555 "integrity": "sha512-B9ifbFudT1TFhfltfaIPgjo9Z3mDynBTJSUYxTjOQruf/zHH+ezCQKcoqO+h7a9Pw9Nm/OtlXAiGT1axBgwqrQ==",
2975 "license": "MIT",2556 "license": "MIT",
2976 "peer": true,
2977 "dependencies": {2557 "dependencies": {
2978 "lightningcss": "^1.32.0",2558 "lightningcss": "^1.32.0",
2979 "picomatch": "^4.0.4",2559 "picomatch": "^4.0.4",
src/pages/index.marko+1-4
...@@ -22,10 +22,7 @@ export const meta = {...@@ -22,10 +22,7 @@ export const meta = {
22 </p>22 </p>
23 <h2>art</h2>23 <h2>art</h2>
24 <ul class="section-list">24 <ul class="section-list">
25 <li>25 <li>ongoing: <a href="/hotewig-reanimated">hotewig reanimated</a></li>
26 ongoing:
27 <a href="/hotewig-reanimated">hotewig reanimated</a>
28 </li>
29 <li>26 <li>
30 video collab:27 video collab:
31 <a href="/history-of-japan-reanimated">history of japan reanimated</a>28 <a href="/history-of-japan-reanimated">history of japan reanimated</a>