@t09tanaka/stoneage 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/LICENSE +21 -0
  3. package/README.md +768 -0
  4. package/dist/agent-skill.d.ts +1 -0
  5. package/dist/agent-skill.js +140 -0
  6. package/dist/assets.d.ts +125 -0
  7. package/dist/assets.js +341 -0
  8. package/dist/cli.d.ts +236 -0
  9. package/dist/cli.js +3077 -0
  10. package/dist/core.d.ts +473 -0
  11. package/dist/core.js +2897 -0
  12. package/dist/data.d.ts +121 -0
  13. package/dist/data.js +358 -0
  14. package/dist/deploy.d.ts +34 -0
  15. package/dist/deploy.js +203 -0
  16. package/dist/dev.d.ts +36 -0
  17. package/dist/dev.js +313 -0
  18. package/dist/example.d.ts +134 -0
  19. package/dist/example.js +1272 -0
  20. package/dist/fragment/client.d.ts +13 -0
  21. package/dist/fragment/client.js +150 -0
  22. package/dist/fragment.d.ts +15 -0
  23. package/dist/fragment.js +27 -0
  24. package/dist/html.d.ts +57 -0
  25. package/dist/html.js +208 -0
  26. package/dist/images.d.ts +95 -0
  27. package/dist/images.js +292 -0
  28. package/dist/index.d.ts +1 -0
  29. package/dist/index.js +1 -0
  30. package/dist/migration.d.ts +157 -0
  31. package/dist/migration.js +983 -0
  32. package/dist/optimize.d.ts +15 -0
  33. package/dist/optimize.js +215 -0
  34. package/dist/pagination.d.ts +26 -0
  35. package/dist/pagination.js +62 -0
  36. package/dist/paths.d.ts +1 -0
  37. package/dist/paths.js +10 -0
  38. package/dist/search.d.ts +32 -0
  39. package/dist/search.js +55 -0
  40. package/dist/testing.d.ts +66 -0
  41. package/dist/testing.js +97 -0
  42. package/dist/validate.d.ts +2 -0
  43. package/dist/validate.js +1 -0
  44. package/jsx-loader.mjs +52 -0
  45. package/jsx-register.mjs +7 -0
  46. package/package.json +135 -0
  47. package/tsconfig.base.json +16 -0
@@ -0,0 +1,1272 @@
1
+ import { createHash } from "node:crypto";
2
+ import { mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
3
+ import { dirname, join, relative, sep } from "node:path";
4
+ import { performance } from "node:perf_hooks";
5
+ import { fileURLToPath } from "node:url";
6
+ import { writeTailwindUtilitiesCss } from "./assets.js";
7
+ import { buildSite, definePublicDataJsonArtifacts, defineRouteFamily, hashValue, patternParamMatcher, writeNormalizedJsonData, } from "./core.js";
8
+ import { optimizeImages, srcsetFor, } from "./images.js";
9
+ import { pageForItemIndex, paginate } from "./pagination.js";
10
+ import { defineSearchIndexArtifact } from "./search.js";
11
+ const recordHashValueReferences = new WeakMap();
12
+ const exampleCssCandidates = ["w", "m"];
13
+ const exampleBuildConcurrency = 128;
14
+ const exampleFileWalkConcurrency = 64;
15
+ const conversationIndexPageSize = 20;
16
+ const sessionConversationPreviewLimit = 12;
17
+ const exampleSitemapMaxUrlsPerFile = 10_000;
18
+ const fourDigitYear = patternParamMatcher(/^\d{4}$/);
19
+ let exampleRenderFingerprintCache;
20
+ const exampleTailwindCss = `
21
+ @layer base {
22
+ body {
23
+ margin: 0;
24
+ color: #111827;
25
+ font-family: ui-sans-serif, system-ui, sans-serif;
26
+ }
27
+ a {
28
+ color: #1d4ed8;
29
+ text-decoration-line: none;
30
+ }
31
+ a:hover {
32
+ text-decoration-line: underline;
33
+ }
34
+ h1 {
35
+ font-size: 1.875rem;
36
+ font-weight: 600;
37
+ }
38
+ h2 {
39
+ margin-top: 2rem;
40
+ font-size: 1.25rem;
41
+ font-weight: 600;
42
+ }
43
+ main {
44
+ max-width: 48rem;
45
+ margin-inline: auto;
46
+ padding: 2.5rem 1.5rem;
47
+ }
48
+ }
49
+ @utility w {
50
+ max-width: 56rem;
51
+ }
52
+ @utility m {
53
+ color: #64748b;
54
+ font-size: .875rem;
55
+ }
56
+ `;
57
+ const defaultCounts = {
58
+ members: 250,
59
+ sessions: 80,
60
+ conversations: 3_000,
61
+ bills: 600,
62
+ };
63
+ export async function buildExampleSite(options) {
64
+ const counts = {
65
+ ...defaultCounts,
66
+ ...options.counts,
67
+ };
68
+ return buildExampleSiteFromData(options, createExampleData(counts));
69
+ }
70
+ async function buildExampleSiteFromData(options, sourceData) {
71
+ const data = options.mutateConversationId
72
+ ? dataWithMutatedConversation(sourceData, options.mutateConversationId)
73
+ : sourceData;
74
+ const dataHashing = {
75
+ recordsHashed: 0,
76
+ };
77
+ const normalized = await writeExampleNormalizedData(options.outDir, data, dataHashing, options.recordHashCache);
78
+ const normalizedData = dataFromSnapshots(normalized.snapshots);
79
+ const partialConversationBuild = Boolean(options.partialConversationId);
80
+ if (!partialConversationBuild) {
81
+ await writeSiteCss(options.outDir);
82
+ }
83
+ const imageOptimization = partialConversationBuild
84
+ ? emptyImageOptimization()
85
+ : await prepareExampleImages(options.outDir, normalizedData.members, {
86
+ converter: options.imageConverter,
87
+ loadConverter: options.loadImageConverter,
88
+ });
89
+ const paginationSummary = {
90
+ conversationIndexItemsMaterialized: 0,
91
+ };
92
+ const trailingSlash = options.trailingSlash ?? "always";
93
+ const renderFingerprint = await exampleRenderFingerprint();
94
+ const buildFingerprint = exampleBuildFingerprint(normalized.snapshots, imageOptimization.variantsByMember, {
95
+ includePublicDataExports: Boolean(options.includePublicDataExports),
96
+ sitemapMaxUrlsPerFile: options.sitemapMaxUrlsPerFile ?? exampleSitemapMaxUrlsPerFile,
97
+ trailingSlash,
98
+ renderFingerprint,
99
+ });
100
+ const result = await buildSite({
101
+ outDir: options.outDir,
102
+ buildFingerprint,
103
+ renderFingerprint,
104
+ concurrency: exampleBuildConcurrency,
105
+ partial: Boolean(options.partialConversationId),
106
+ trailingSlash,
107
+ site: {
108
+ baseUrl: "https://example.stoneage.local",
109
+ title: "StoneAge",
110
+ description: "A generated public-data archive used to benchmark StoneAge.",
111
+ openGraph: false,
112
+ },
113
+ assets: {
114
+ stylesheets: ["/s.css"],
115
+ },
116
+ prefetch: {
117
+ serviceWorker: true,
118
+ linkRel: false,
119
+ scriptPath: "/p.js",
120
+ workerPath: "/p-sw.js",
121
+ cacheName: "stoneage-example-prefetch-v1",
122
+ },
123
+ sitemap: {
124
+ maxUrlsPerFile: options.sitemapMaxUrlsPerFile ?? exampleSitemapMaxUrlsPerFile,
125
+ },
126
+ routes: createRoutes(normalizedData, {
127
+ onlyConversationId: options.partialConversationId,
128
+ portraitVariants: imageOptimization.variantsByMember,
129
+ dataHashing,
130
+ pagination: paginationSummary,
131
+ trailingSlash,
132
+ recordHashCache: options.recordHashCache,
133
+ conversationIndexCache: options.conversationIndexCache,
134
+ }),
135
+ artifacts: options.includePublicDataExports
136
+ ? createPublicDataArtifacts(normalized.snapshots, trailingSlash)
137
+ : undefined,
138
+ });
139
+ return {
140
+ ...result,
141
+ imageOptimization: imageOptimization.summary,
142
+ dataNormalization: normalized.summary,
143
+ dataHashing,
144
+ pagination: paginationSummary,
145
+ };
146
+ }
147
+ function dataFromSnapshots(snapshots) {
148
+ return {
149
+ members: snapshots.members.data,
150
+ sessions: snapshots.sessions.data,
151
+ conversations: snapshots.conversations.data,
152
+ bills: snapshots.bills.data,
153
+ categories: snapshots.categories.data,
154
+ votes: snapshots.votes.data,
155
+ yearlyAggregates: snapshots.yearlyAggregates.data,
156
+ };
157
+ }
158
+ function exampleBuildFingerprint(snapshots, variantsByMember, options) {
159
+ return hashValue({
160
+ snapshots: Object.fromEntries(Object.entries(snapshots).map(([key, snapshot]) => [
161
+ key,
162
+ {
163
+ hash: snapshot.hash,
164
+ bytes: snapshot.bytes,
165
+ },
166
+ ])),
167
+ imageVariants: [...variantsByMember.entries()].map(([memberId, variants]) => [
168
+ memberId,
169
+ variants.map((variant) => ({
170
+ publicPath: variant.publicPath,
171
+ width: variant.width,
172
+ format: variant.format,
173
+ bytes: variant.bytes,
174
+ })),
175
+ ]),
176
+ includePublicDataExports: options.includePublicDataExports,
177
+ sitemapMaxUrlsPerFile: options.sitemapMaxUrlsPerFile,
178
+ trailingSlash: options.trailingSlash,
179
+ renderFingerprint: options.renderFingerprint,
180
+ });
181
+ }
182
+ async function exampleRenderFingerprint() {
183
+ if (exampleRenderFingerprintCache !== undefined) {
184
+ return exampleRenderFingerprintCache;
185
+ }
186
+ const moduleSource = await readFile(fileURLToPath(import.meta.url), "utf8");
187
+ exampleRenderFingerprintCache = hashFingerprint(moduleSource);
188
+ return exampleRenderFingerprintCache;
189
+ }
190
+ export async function benchmarkExampleSite(options) {
191
+ await rm(options.outDir, { recursive: true, force: true });
192
+ const counts = {
193
+ ...defaultCounts,
194
+ ...options.counts,
195
+ };
196
+ const data = createExampleData(counts);
197
+ const recordHashCache = new Map();
198
+ const conversationIndexCache = new Map();
199
+ const fullStart = performance.now();
200
+ const full = await buildExampleSiteFromData({ ...options, recordHashCache, conversationIndexCache }, data);
201
+ const fullBuildMs = performance.now() - fullStart;
202
+ const incrementalStart = performance.now();
203
+ const incremental = await buildExampleSiteFromData({
204
+ ...options,
205
+ recordHashCache,
206
+ conversationIndexCache,
207
+ }, data);
208
+ const incrementalBuildMs = performance.now() - incrementalStart;
209
+ const changedStart = performance.now();
210
+ const changed = await buildExampleSiteFromData({
211
+ ...options,
212
+ recordHashCache,
213
+ conversationIndexCache,
214
+ mutateConversationId: "conversation-001",
215
+ partialConversationId: "conversation-001",
216
+ }, data);
217
+ const changedBuildMs = performance.now() - changedStart;
218
+ const outputSizes = await measureExampleOutputSizes(options.outDir, {
219
+ includePublicDataExports: Boolean(options.includePublicDataExports),
220
+ knownHtmlOutputBytes: htmlOutputBytesFromManifest(changed.manifest),
221
+ });
222
+ const sitemapOutput = await measureSitemapOutput(options.outDir, full.report.sitemapFiles);
223
+ const largestDependencyFanout = largestDependencyFanoutFromManifest(full.manifest);
224
+ const metrics = {
225
+ pageCount: full.report.pageCount,
226
+ fullBuildMs,
227
+ incrementalBuildMs,
228
+ fullBuildPagesWritten: full.pagesWritten,
229
+ incrementalBuildPagesWritten: incremental.pagesWritten,
230
+ changedBuildMs,
231
+ changedBuildPagesWritten: changed.pagesWritten,
232
+ normalizedDataSnapshots: full.dataNormalization.snapshots,
233
+ normalizedDataBytes: full.dataNormalization.bytes,
234
+ fullBuildNormalizedDataWrites: full.dataNormalization.written,
235
+ incrementalBuildNormalizedDataWrites: incremental.dataNormalization.written,
236
+ changedBuildNormalizedDataWrites: changed.dataNormalization.written,
237
+ fullBuildRecordHashes: full.dataHashing.recordsHashed,
238
+ incrementalBuildRecordHashes: incremental.dataHashing.recordsHashed,
239
+ changedBuildRecordHashes: changed.dataHashing.recordsHashed,
240
+ fullBuildConversationIndexItemsMaterialized: full.pagination.conversationIndexItemsMaterialized,
241
+ incrementalBuildConversationIndexItemsMaterialized: incremental.pagination.conversationIndexItemsMaterialized,
242
+ changedBuildConversationIndexItemsMaterialized: changed.pagination.conversationIndexItemsMaterialized,
243
+ ...outputSizes,
244
+ imageOptimization: full.imageOptimization,
245
+ largestHtml: full.report.largestHtml,
246
+ routeFamilies: full.report.routeFamilies,
247
+ sitemapUrlCount: full.manifest.pages.filter((page) => page.sitemap).length,
248
+ sitemapFileCount: full.report.sitemapFiles.length,
249
+ sitemapBytes: sitemapOutput.bytes,
250
+ largestSitemapBytes: sitemapOutput.largestBytes,
251
+ missingLinkCount: full.report.missingLinks.length,
252
+ validationIssueCount: full.report.validation.issues.length,
253
+ largestDependencyFanout,
254
+ publicDataRecordCounts: exampleDataRecordCounts(data),
255
+ };
256
+ await writeJson(join(options.outDir, ".stoneage/benchmark.json"), metrics);
257
+ return metrics;
258
+ }
259
+ async function measureSitemapOutput(outDir, files) {
260
+ const sizes = await Promise.all(files.map(async (file) => (await stat(join(outDir, file))).size));
261
+ return {
262
+ bytes: sizes.reduce((sum, bytes) => sum + bytes, 0),
263
+ largestBytes: sizes.length === 0 ? 0 : Math.max(...sizes),
264
+ };
265
+ }
266
+ export async function measureExampleOutputSizes(outDir, options = {}) {
267
+ const sizes = {
268
+ totalOutputBytes: 0,
269
+ publicOutputBytes: 0,
270
+ internalOutputBytes: 0,
271
+ htmlOutputBytes: 0,
272
+ cssOutputBytes: 0,
273
+ jsOutputBytes: 0,
274
+ imageOutputBytes: 0,
275
+ dataOutputBytes: 0,
276
+ averageHtmlBytes: 0,
277
+ p95HtmlBytes: 0,
278
+ };
279
+ const htmlSizes = [];
280
+ const knownHtmlOutputBytes = options.knownHtmlOutputBytes ?? new Map();
281
+ const knownHtmlOutputDirectories = knownHtmlIndexDirectories(knownHtmlOutputBytes);
282
+ for (const bytes of knownHtmlOutputBytes.values()) {
283
+ sizes.totalOutputBytes += bytes;
284
+ sizes.publicOutputBytes += bytes;
285
+ sizes.htmlOutputBytes += bytes;
286
+ htmlSizes.push(bytes);
287
+ }
288
+ await walkFiles(outDir, async (path) => {
289
+ const publicPath = relative(outDir, path);
290
+ const parts = publicPath.split(sep);
291
+ const normalizedPublicPath = parts.join("/");
292
+ const knownHtmlBytes = options.knownHtmlOutputBytes?.get(normalizedPublicPath);
293
+ if (knownHtmlBytes !== undefined) {
294
+ return;
295
+ }
296
+ const bytes = (await stat(path)).size;
297
+ sizes.totalOutputBytes += bytes;
298
+ if (parts.includes(".stoneage")) {
299
+ sizes.internalOutputBytes += bytes;
300
+ return;
301
+ }
302
+ sizes.publicOutputBytes += bytes;
303
+ if (knownHtmlBytes !== undefined || normalizedPublicPath.endsWith(".html")) {
304
+ sizes.htmlOutputBytes += bytes;
305
+ htmlSizes.push(bytes);
306
+ }
307
+ if (normalizedPublicPath.endsWith(".css")) {
308
+ sizes.cssOutputBytes += bytes;
309
+ }
310
+ if (normalizedPublicPath.endsWith(".js")) {
311
+ sizes.jsOutputBytes += bytes;
312
+ }
313
+ if (isImageOutputPath(normalizedPublicPath)) {
314
+ sizes.imageOutputBytes += bytes;
315
+ }
316
+ if (options.includePublicDataExports && parts[0] === "data") {
317
+ sizes.dataOutputBytes += bytes;
318
+ }
319
+ }, {
320
+ shouldSkipDirectory: (path) => {
321
+ if (knownHtmlOutputDirectories.size === 0) {
322
+ return false;
323
+ }
324
+ const publicPath = relative(outDir, path).split(sep).join("/");
325
+ return knownHtmlOutputDirectories.has(publicPath);
326
+ },
327
+ });
328
+ sizes.averageHtmlBytes = averageBytes(htmlSizes);
329
+ sizes.p95HtmlBytes = percentileBytes(htmlSizes, 0.95);
330
+ return sizes;
331
+ }
332
+ function htmlOutputBytesFromManifest(manifest) {
333
+ return new Map(manifest.pages.map((page) => [page.outputPath, page.htmlBytes]));
334
+ }
335
+ function knownHtmlIndexDirectories(knownHtmlOutputBytes) {
336
+ const directories = new Set();
337
+ for (const outputPath of knownHtmlOutputBytes.keys()) {
338
+ if (!outputPath.endsWith("/index.html")) {
339
+ continue;
340
+ }
341
+ directories.add(outputPath.slice(0, -"/index.html".length));
342
+ }
343
+ return directories;
344
+ }
345
+ function largestDependencyFanoutFromManifest(manifest) {
346
+ const fanout = new Map();
347
+ for (const output of [...manifest.pages, ...manifest.artifacts]) {
348
+ for (const key of Object.keys(output.dependencies)) {
349
+ if (key.startsWith("\0stoneage:")) {
350
+ continue;
351
+ }
352
+ fanout.set(key, (fanout.get(key) ?? 0) + 1);
353
+ }
354
+ }
355
+ return Math.max(0, ...fanout.values());
356
+ }
357
+ function averageBytes(sizes) {
358
+ if (sizes.length === 0) {
359
+ return 0;
360
+ }
361
+ return sizes.reduce((sum, bytes) => sum + bytes, 0) / sizes.length;
362
+ }
363
+ function percentileBytes(sizes, percentile) {
364
+ if (sizes.length === 0) {
365
+ return 0;
366
+ }
367
+ const sorted = [...sizes].sort((left, right) => left - right);
368
+ const index = Math.ceil(sorted.length * percentile) - 1;
369
+ return sorted[Math.max(0, Math.min(index, sorted.length - 1))] ?? 0;
370
+ }
371
+ function summarizeCompression(metrics) {
372
+ return {
373
+ filesCompressed: metrics.filesCompressed,
374
+ originalBytes: metrics.originalBytes,
375
+ brotliBytes: metrics.brotliBytes,
376
+ gzipBytes: metrics.gzipBytes,
377
+ };
378
+ }
379
+ function createRoutes(data, options = {}) {
380
+ const conversationsByMember = groupBy(data.conversations, (conversation) => conversation.memberId);
381
+ const conversationsBySession = groupBy(data.conversations, (conversation) => conversation.sessionId);
382
+ const membersById = new Map(data.members.map((member) => [member.id, member]));
383
+ const sessionsById = new Map(data.sessions.map((session) => [session.id, session]));
384
+ const categoryIndexRows = createCategoryIndexSummaries(data);
385
+ const yearIndexRows = data.yearlyAggregates;
386
+ const dataHashing = options.dataHashing ?? { recordsHashed: 0 };
387
+ const paginationSummary = options.pagination ?? { conversationIndexItemsMaterialized: 0 };
388
+ const trailingSlash = options.trailingSlash ?? "always";
389
+ const changedConversation = options.onlyConversationId
390
+ ? data.conversations.find((conversation) => conversation.id === options.onlyConversationId)
391
+ : undefined;
392
+ const lazyHashes = Boolean(changedConversation);
393
+ const memberHashes = createRecordHashLookup(data.members, (member) => member.id, dataHashing, {
394
+ lazy: lazyHashes,
395
+ cacheScope: "member",
396
+ cache: options.recordHashCache,
397
+ });
398
+ const sessionHashes = createRecordHashLookup(data.sessions, (session) => session.id, dataHashing, {
399
+ lazy: lazyHashes,
400
+ cacheScope: "session",
401
+ cache: options.recordHashCache,
402
+ });
403
+ const conversationHashes = createRecordHashLookup(data.conversations, (conversation) => conversation.id, dataHashing, {
404
+ lazy: lazyHashes,
405
+ cacheScope: "conversation",
406
+ cache: options.recordHashCache,
407
+ });
408
+ const conversationSummaryHashes = createRecordHashLookup(data.conversations, (conversation) => conversation.id, dataHashing, {
409
+ lazy: lazyHashes,
410
+ hashFingerprintOf: conversationSummaryFingerprint,
411
+ cacheScope: "conversation-summary",
412
+ cache: options.recordHashCache,
413
+ });
414
+ const billHashes = createRecordHashLookup(data.bills, (bill) => bill.id, dataHashing, {
415
+ lazy: lazyHashes,
416
+ cacheScope: "bill",
417
+ cache: options.recordHashCache,
418
+ });
419
+ const members = changedConversation
420
+ ? data.members.filter((member) => member.id === changedConversation.memberId)
421
+ : data.members;
422
+ const sessions = changedConversation
423
+ ? data.sessions.filter((session) => session.id === changedConversation.sessionId)
424
+ : data.sessions;
425
+ const conversations = changedConversation ? [changedConversation] : data.conversations;
426
+ const bills = changedConversation ? [] : data.bills;
427
+ const categoryIndexEntries = changedConversation ? [] : [categoryIndexRows];
428
+ const yearIndexEntries = changedConversation ? [] : [yearIndexRows];
429
+ const conversationIndexPages = changedConversation
430
+ ? [
431
+ conversationIndexPageForConversation(data.conversations, changedConversation, paginationSummary),
432
+ ]
433
+ : paginateConversationIndexPages(data.conversations, paginationSummary, options.conversationIndexCache);
434
+ return [
435
+ defineRouteFamily({
436
+ name: "categoryIndexes",
437
+ pattern: "/categories/",
438
+ entries: () => categoryIndexEntries.map((rows) => ({
439
+ params: {},
440
+ links: [],
441
+ dependencies: [{ key: "category-index", hash: hashValue(rows) }],
442
+ metadata: {
443
+ title: "Categories",
444
+ description: "Category totals for the generated public-data archive.",
445
+ },
446
+ render: () => renderCategoryIndex(rows),
447
+ })),
448
+ }),
449
+ defineRouteFamily({
450
+ name: "yearIndexes",
451
+ pattern: "/years/",
452
+ entries: () => yearIndexEntries.map((rows) => ({
453
+ params: {},
454
+ links: [],
455
+ dependencies: [{ key: "year-index", hash: hashValue(rows) }],
456
+ metadata: {
457
+ title: "Years",
458
+ description: "Yearly totals for the generated public-data archive.",
459
+ },
460
+ render: () => renderYearIndex(rows),
461
+ })),
462
+ }),
463
+ defineRouteFamily({
464
+ name: "members",
465
+ pattern: "/members/:id/:year/",
466
+ paramMatchers: {
467
+ year: fourDigitYear,
468
+ },
469
+ entries: () => members.map((member) => ({
470
+ params: { id: member.slug, year: member.year },
471
+ links: (conversationsByMember.get(member.id) ?? [])
472
+ .slice(0, 8)
473
+ .map((conversation) => conversationPath(conversation, trailingSlash)),
474
+ dependencies: [
475
+ { key: `member:${member.id}`, hash: requiredHash(memberHashes, member.id) },
476
+ ...(conversationsByMember.get(member.id) ?? [])
477
+ .slice(0, 8)
478
+ .map((conversation) => ({
479
+ key: `conversation-summary:${conversation.id}`,
480
+ hash: requiredHash(conversationSummaryHashes, conversation.id),
481
+ })),
482
+ ],
483
+ metadata: {
484
+ title: member.name,
485
+ description: `${member.name} represents ${member.district} in ${member.year}.`,
486
+ },
487
+ render: () => renderMember(member, conversationsByMember.get(member.id) ?? [], options.portraitVariants?.get(member.id) ?? [], trailingSlash),
488
+ })),
489
+ }),
490
+ defineRouteFamily({
491
+ name: "sessions",
492
+ pattern: "/sessions/:id/",
493
+ entries: () => sessions.map((session) => ({
494
+ params: { id: session.slug },
495
+ links: (conversationsBySession.get(session.id) ?? [])
496
+ .slice(0, sessionConversationPreviewLimit)
497
+ .map((conversation) => conversationPath(conversation, trailingSlash)),
498
+ dependencies: [
499
+ { key: `session:${session.id}`, hash: requiredHash(sessionHashes, session.id) },
500
+ ...(conversationsBySession.get(session.id) ?? [])
501
+ .slice(0, sessionConversationPreviewLimit)
502
+ .map((conversation) => ({
503
+ key: `conversation-summary:${conversation.id}`,
504
+ hash: requiredHash(conversationSummaryHashes, conversation.id),
505
+ })),
506
+ ],
507
+ metadata: {
508
+ title: session.title,
509
+ description: `Session held on ${session.date}.`,
510
+ },
511
+ render: () => renderSession(session, conversationsBySession.get(session.id) ?? [], trailingSlash),
512
+ })),
513
+ }),
514
+ defineRouteFamily({
515
+ name: "conversationIndexes",
516
+ pattern: "/conversations/page/:page/",
517
+ entries: () => conversationIndexPages.map((page) => ({
518
+ params: { page: page.page },
519
+ path: page.page === 1 ? "/conversations/" : undefined,
520
+ links: conversationIndexLinks(page, trailingSlash),
521
+ dependencies: page.items.map((conversation) => ({
522
+ key: `conversation-index:${conversation.id}`,
523
+ hash: requiredHash(conversationSummaryHashes, conversation.id),
524
+ })),
525
+ metadata: {
526
+ title: page.page === 1 ? "Conversations" : `Conversations page ${page.page}`,
527
+ description: `Conversation index page ${page.page} of ${page.totalPages}.`,
528
+ prefetch: page.page < page.totalPages
529
+ ? [conversationIndexPagePath(page.page + 1, trailingSlash)]
530
+ : [],
531
+ },
532
+ render: () => renderConversationIndex(page, trailingSlash),
533
+ })),
534
+ }),
535
+ defineRouteFamily({
536
+ name: "conversations",
537
+ pattern: "/conversations/:id/",
538
+ entries: () => conversations.map((conversation) => ({
539
+ params: { id: conversation.slug },
540
+ links: [
541
+ memberPath(membersById.get(conversation.memberId), trailingSlash),
542
+ sessionPath(sessionsById.get(conversation.sessionId), trailingSlash),
543
+ ],
544
+ dependencies: [
545
+ { key: `conversation:${conversation.id}`, hash: requiredHash(conversationHashes, conversation.id) },
546
+ ],
547
+ metadata: {
548
+ title: conversation.title,
549
+ description: excerpt(conversation.body, 140),
550
+ },
551
+ render: () => renderConversation(conversation, membersById.get(conversation.memberId), sessionsById.get(conversation.sessionId), trailingSlash),
552
+ })),
553
+ }),
554
+ defineRouteFamily({
555
+ name: "bills",
556
+ pattern: "/bills/:id/",
557
+ entries: () => bills.map((bill) => ({
558
+ params: { id: bill.slug },
559
+ links: [],
560
+ dependencies: [{ key: `bill:${bill.id}`, hash: requiredHash(billHashes, bill.id) }],
561
+ metadata: {
562
+ title: bill.title,
563
+ description: `${bill.title} is currently ${bill.status}.`,
564
+ },
565
+ render: () => renderBill(bill),
566
+ })),
567
+ }),
568
+ ];
569
+ }
570
+ function createPublicDataArtifacts(snapshots, trailingSlash) {
571
+ const artifacts = [
572
+ snapshots.members,
573
+ snapshots.sessions,
574
+ snapshots.conversations,
575
+ snapshots.bills,
576
+ snapshots.categories,
577
+ snapshots.votes,
578
+ snapshots.yearlyAggregates,
579
+ ];
580
+ const data = {
581
+ members: snapshots.members.data,
582
+ sessions: snapshots.sessions.data,
583
+ conversations: snapshots.conversations.data,
584
+ bills: snapshots.bills.data,
585
+ categories: snapshots.categories.data,
586
+ votes: snapshots.votes.data,
587
+ yearlyAggregates: snapshots.yearlyAggregates.data,
588
+ };
589
+ const categoriesById = new Map(data.categories.map((category) => [category.id, category]));
590
+ const searchDocuments = [
591
+ ...data.members.map((member) => ({
592
+ id: member.id,
593
+ title: member.name,
594
+ url: memberPath(member, trailingSlash),
595
+ text: member.district,
596
+ tags: ["member", member.district, `year:${member.year}`],
597
+ })),
598
+ ...data.sessions.map((session) => ({
599
+ id: session.id,
600
+ title: session.title,
601
+ url: sessionPath(session, trailingSlash),
602
+ text: session.date,
603
+ tags: ["session"],
604
+ })),
605
+ ...data.conversations.map((conversation) => ({
606
+ id: conversation.id,
607
+ title: conversation.title,
608
+ url: conversationPath(conversation, trailingSlash),
609
+ text: conversation.body,
610
+ tags: ["conversation"],
611
+ })),
612
+ ...data.bills.map((bill) => ({
613
+ id: bill.id,
614
+ title: bill.title,
615
+ url: routeUrl(`/bills/${bill.slug}/`, trailingSlash),
616
+ text: bill.status,
617
+ tags: [
618
+ "bill",
619
+ bill.status,
620
+ `category:${categoriesById.get(bill.categoryId)?.slug ?? "uncategorized"}`,
621
+ ],
622
+ })),
623
+ ];
624
+ return [
625
+ definePublicDataJsonArtifacts(artifacts, {
626
+ path: (artifact) => `/data/${artifact.key.replace(/^public:/, "")}.json`,
627
+ }),
628
+ defineSearchIndexArtifact({
629
+ records: searchDocuments,
630
+ dependencies: artifacts.map((artifact) => artifact.dependency),
631
+ document: (document) => document,
632
+ }),
633
+ ];
634
+ }
635
+ async function writeExampleNormalizedData(outDir, data, dataHashing, recordHashCache) {
636
+ const normalizedDir = join(outDir, ".stoneage/normalized");
637
+ const cacheManifestPath = join(outDir, ".stoneage/normalized-manifest.json");
638
+ const cacheOptions = (scope, records, keyOf, cacheScope) => {
639
+ if (!recordHashCache) {
640
+ return {};
641
+ }
642
+ const fingerprint = normalizedDataFingerprint(scope, records, keyOf, cacheScope, dataHashing, recordHashCache);
643
+ return {
644
+ contentFingerprint: { hash: fingerprint.hash },
645
+ renderedContent: fingerprint.renderedContent,
646
+ cacheManifestPath,
647
+ };
648
+ };
649
+ const snapshots = {
650
+ members: await writeNormalizedJsonData({
651
+ key: "public:members",
652
+ path: join(normalizedDir, "members.json"),
653
+ data: data.members,
654
+ ...cacheOptions("members", data.members, (member) => member.id, "member"),
655
+ sourceDependencies: [{ key: "example:members", value: data.members.length }],
656
+ }),
657
+ sessions: await writeNormalizedJsonData({
658
+ key: "public:sessions",
659
+ path: join(normalizedDir, "sessions.json"),
660
+ data: data.sessions,
661
+ ...cacheOptions("sessions", data.sessions, (session) => session.id, "session"),
662
+ sourceDependencies: [{ key: "example:sessions", value: data.sessions.length }],
663
+ }),
664
+ conversations: await writeNormalizedJsonData({
665
+ key: "public:conversations",
666
+ path: join(normalizedDir, "conversations.json"),
667
+ data: data.conversations,
668
+ ...cacheOptions("conversations", data.conversations, (conversation) => conversation.id, "conversation"),
669
+ sourceDependencies: [{ key: "example:conversations", value: data.conversations.length }],
670
+ }),
671
+ bills: await writeNormalizedJsonData({
672
+ key: "public:bills",
673
+ path: join(normalizedDir, "bills.json"),
674
+ data: data.bills,
675
+ ...cacheOptions("bills", data.bills, (bill) => bill.id, "bill"),
676
+ sourceDependencies: [{ key: "example:bills", value: data.bills.length }],
677
+ }),
678
+ categories: await writeNormalizedJsonData({
679
+ key: "public:categories",
680
+ path: join(normalizedDir, "categories.json"),
681
+ data: data.categories,
682
+ ...cacheOptions("categories", data.categories, (category) => category.id, "category"),
683
+ sourceDependencies: [{ key: "example:categories", value: data.categories.length }],
684
+ }),
685
+ votes: await writeNormalizedJsonData({
686
+ key: "public:votes",
687
+ path: join(normalizedDir, "votes.json"),
688
+ data: data.votes,
689
+ ...cacheOptions("votes", data.votes, (vote) => vote.id, "vote"),
690
+ sourceDependencies: [{ key: "example:votes", value: data.votes.length }],
691
+ }),
692
+ yearlyAggregates: await writeNormalizedJsonData({
693
+ key: "public:yearly-aggregates",
694
+ path: join(normalizedDir, "yearly-aggregates.json"),
695
+ data: data.yearlyAggregates,
696
+ ...cacheOptions("yearly-aggregates", data.yearlyAggregates, (aggregate) => String(aggregate.year), "yearly-aggregate"),
697
+ sourceDependencies: [
698
+ { key: "example:yearly-aggregates", value: data.yearlyAggregates.length },
699
+ ],
700
+ }),
701
+ };
702
+ const snapshotList = Object.values(snapshots);
703
+ return {
704
+ snapshots,
705
+ summary: {
706
+ snapshots: snapshotList.length,
707
+ written: snapshotList.filter((snapshot) => snapshot.written).length,
708
+ bytes: snapshotList.reduce((sum, snapshot) => sum + snapshot.bytes, 0),
709
+ },
710
+ };
711
+ }
712
+ function normalizedDataFingerprint(scope, records, keyOf, cacheScope, dataHashing, recordHashCache) {
713
+ const recordHashes = createRecordHashLookup(records, keyOf, dataHashing, {
714
+ lazy: false,
715
+ cacheScope,
716
+ cache: recordHashCache,
717
+ });
718
+ const hash = createHash("sha256");
719
+ updateLengthPrefixedHash(hash, scope);
720
+ for (const record of records) {
721
+ const key = keyOf(record);
722
+ updateLengthPrefixedHash(hash, key);
723
+ updateLengthPrefixedHash(hash, requiredHash(recordHashes, key));
724
+ }
725
+ return {
726
+ hash: compactHashDigest(hash),
727
+ renderedContent: () => renderNormalizedJsonRecords(records, keyOf, cacheScope, recordHashCache),
728
+ };
729
+ }
730
+ function renderNormalizedJsonRecords(records, keyOf, cacheScope, recordHashCache) {
731
+ const serialized = records.map((record) => {
732
+ const key = keyOf(record);
733
+ return recordHashCache?.get(`${cacheScope}:${key}`)?.fingerprint ?? JSON.stringify(record);
734
+ });
735
+ return `[${serialized.join(",")}]\n`;
736
+ }
737
+ function conversationIndexPageForConversation(conversations, conversation, paginationSummary) {
738
+ const index = conversations.findIndex((item) => item.id === conversation.id);
739
+ if (index < 0) {
740
+ paginationSummary.conversationIndexItemsMaterialized += 0;
741
+ return paginate([], { pageSize: conversationIndexPageSize })[0];
742
+ }
743
+ const page = pageForItemIndex(index, { pageSize: conversationIndexPageSize });
744
+ const totalPages = Math.ceil(conversations.length / conversationIndexPageSize);
745
+ const start = (page - 1) * conversationIndexPageSize;
746
+ const items = conversations.slice(start, start + conversationIndexPageSize);
747
+ paginationSummary.conversationIndexItemsMaterialized += items.length;
748
+ return {
749
+ page,
750
+ pageSize: conversationIndexPageSize,
751
+ totalItems: conversations.length,
752
+ totalPages,
753
+ items,
754
+ };
755
+ }
756
+ function paginateConversationIndexPages(conversations, paginationSummary, cache) {
757
+ const cacheKey = conversationIndexCacheKey(conversations);
758
+ const cached = cache?.get(cacheKey);
759
+ if (cached) {
760
+ return cached;
761
+ }
762
+ const pages = paginate(conversations, { pageSize: conversationIndexPageSize });
763
+ paginationSummary.conversationIndexItemsMaterialized += conversations.length;
764
+ cache?.set(cacheKey, pages);
765
+ return pages;
766
+ }
767
+ function conversationIndexCacheKey(conversations) {
768
+ const first = conversations[0];
769
+ const last = conversations.at(-1);
770
+ return [
771
+ conversationIndexPageSize,
772
+ conversations.length,
773
+ first?.id ?? "",
774
+ first?.slug ?? "",
775
+ first ? conversationLabel(first) : "",
776
+ last?.id ?? "",
777
+ last?.slug ?? "",
778
+ last ? conversationLabel(last) : "",
779
+ ].join("|");
780
+ }
781
+ function createRecordHashLookup(items, keyOf, dataHashing, options) {
782
+ const valueOf = options.hashValueOf ?? ((item) => item);
783
+ if (!options.lazy) {
784
+ const hashes = new Map();
785
+ for (const item of items) {
786
+ const key = keyOf(item);
787
+ hashes.set(key, hashRecordItem(options, key, item, valueOf, dataHashing));
788
+ }
789
+ return {
790
+ get: (key) => hashes.get(key),
791
+ };
792
+ }
793
+ const records = new Map(items.map((item) => [keyOf(item), item]));
794
+ const hashes = new Map();
795
+ return {
796
+ get(key) {
797
+ const current = hashes.get(key);
798
+ if (current !== undefined) {
799
+ return current;
800
+ }
801
+ const item = records.get(key);
802
+ if (item === undefined) {
803
+ return undefined;
804
+ }
805
+ const hash = hashRecordItem(options, key, item, valueOf, dataHashing);
806
+ hashes.set(key, hash);
807
+ return hash;
808
+ },
809
+ };
810
+ }
811
+ function hashRecordItem(options, key, item, valueOf, dataHashing) {
812
+ return options.hashFingerprintOf
813
+ ? hashRecordFingerprint(options, key, options.hashFingerprintOf(item), dataHashing)
814
+ : hashRecordValue(options, key, valueOf(item), dataHashing);
815
+ }
816
+ function hashRecordFingerprint(options, key, fingerprint, dataHashing) {
817
+ if (options.cache && options.cacheScope) {
818
+ const cacheKey = `${options.cacheScope}:${key}`;
819
+ const cached = options.cache.get(cacheKey);
820
+ if (cached?.fingerprint === fingerprint) {
821
+ return cached.hash;
822
+ }
823
+ const hash = hashFingerprint(fingerprint);
824
+ options.cache.set(cacheKey, { fingerprint, hash });
825
+ dataHashing.recordsHashed += 1;
826
+ return hash;
827
+ }
828
+ dataHashing.recordsHashed += 1;
829
+ return hashFingerprint(fingerprint);
830
+ }
831
+ function hashRecordValue(options, key, value, dataHashing) {
832
+ if (options.cache && options.cacheScope) {
833
+ const cacheKey = `${options.cacheScope}:${key}`;
834
+ const cached = options.cache.get(cacheKey);
835
+ if (cached && recordHashValueReferences.get(cached) === value) {
836
+ return cached.hash;
837
+ }
838
+ const fingerprint = JSON.stringify(value);
839
+ if (cached?.fingerprint === fingerprint) {
840
+ recordHashValueReferences.set(cached, value);
841
+ return cached.hash;
842
+ }
843
+ const hash = hashFingerprint(fingerprint);
844
+ const entry = { fingerprint, hash };
845
+ options.cache.set(cacheKey, entry);
846
+ recordHashValueReferences.set(entry, value);
847
+ dataHashing.recordsHashed += 1;
848
+ return hash;
849
+ }
850
+ dataHashing.recordsHashed += 1;
851
+ return hashValue(value);
852
+ }
853
+ function hashFingerprint(fingerprint) {
854
+ return compactHashDigest(createHash("sha256").update(fingerprint));
855
+ }
856
+ function updateLengthPrefixedHash(hash, value) {
857
+ hash.update(String(value.length));
858
+ hash.update(":");
859
+ hash.update(value);
860
+ hash.update("\0");
861
+ }
862
+ function compactHashDigest(hash) {
863
+ return hash.digest().subarray(0, 16).toString("base64url");
864
+ }
865
+ function requiredHash(lookup, key) {
866
+ const hash = lookup.get(key);
867
+ if (hash === undefined) {
868
+ throw new Error(`Missing record hash: ${key}`);
869
+ }
870
+ return hash;
871
+ }
872
+ function createExampleData(counts) {
873
+ const categories = createExampleCategories();
874
+ const members = Array.from({ length: counts.members }, (_, index) => {
875
+ const number = index + 1;
876
+ return {
877
+ id: `member-${pad(number)}`,
878
+ slug: `m${number}`,
879
+ name: `Member ${pad(number)}`,
880
+ district: `District ${(index % 24) + 1}`,
881
+ year: 2026,
882
+ };
883
+ });
884
+ const sessions = Array.from({ length: counts.sessions }, (_, index) => {
885
+ const number = index + 1;
886
+ return {
887
+ id: `session-${pad(number)}`,
888
+ slug: `s${number}`,
889
+ title: `Session ${pad(number)}`,
890
+ date: `2026-${pad((index % 12) + 1, 2)}-${pad((index % 27) + 1, 2)}`,
891
+ };
892
+ });
893
+ const conversations = Array.from({ length: counts.conversations }, (_, index) => {
894
+ const number = index + 1;
895
+ const member = members[index % members.length];
896
+ const session = sessions[index % sessions.length];
897
+ const category = categories[index % categories.length];
898
+ return {
899
+ id: `conversation-${pad(number)}`,
900
+ slug: `c${number}`,
901
+ memberId: member.id,
902
+ sessionId: session.id,
903
+ categoryId: category.id,
904
+ title: `Conversation ${pad(number)} by ${member.name}`,
905
+ body: createParagraph(number, member.name, session.title),
906
+ };
907
+ });
908
+ const bills = Array.from({ length: counts.bills }, (_, index) => {
909
+ const number = index + 1;
910
+ const category = categories[index % categories.length];
911
+ return {
912
+ id: `bill-${pad(number)}`,
913
+ slug: `b${number}`,
914
+ categoryId: category.id,
915
+ title: `Bill ${pad(number)}`,
916
+ status: ["introduced", "in committee", "passed", "rejected"][index % 4],
917
+ };
918
+ });
919
+ const votes = createExampleVotes(bills, members, sessions, categories);
920
+ const yearlyAggregates = createYearlyAggregates({
921
+ members,
922
+ sessions,
923
+ conversations,
924
+ bills,
925
+ categories,
926
+ votes,
927
+ });
928
+ return {
929
+ members,
930
+ sessions,
931
+ conversations,
932
+ bills,
933
+ categories,
934
+ votes,
935
+ yearlyAggregates,
936
+ };
937
+ }
938
+ function createExampleCategories() {
939
+ return [
940
+ { id: "category-budget", slug: "budget", name: "Budget" },
941
+ { id: "category-housing", slug: "housing", name: "Housing" },
942
+ { id: "category-transportation", slug: "transportation", name: "Transportation" },
943
+ { id: "category-health", slug: "health", name: "Health" },
944
+ { id: "category-education", slug: "education", name: "Education" },
945
+ { id: "category-environment", slug: "environment", name: "Environment" },
946
+ { id: "category-public-safety", slug: "public-safety", name: "Public Safety" },
947
+ { id: "category-administration", slug: "administration", name: "Administration" },
948
+ ];
949
+ }
950
+ function createExampleVotes(bills, members, sessions, categories) {
951
+ if (members.length === 0 || sessions.length === 0) {
952
+ return [];
953
+ }
954
+ const positions = ["yes", "no", "abstain", "yes"];
955
+ return bills.map((bill, index) => {
956
+ const number = index + 1;
957
+ const session = sessions[index % sessions.length];
958
+ const member = members[index % members.length];
959
+ const category = categories.find((item) => item.id === bill.categoryId) ?? categories[0];
960
+ return {
961
+ id: `vote-${pad(number)}`,
962
+ billId: bill.id,
963
+ memberId: member.id,
964
+ sessionId: session.id,
965
+ categoryId: category.id,
966
+ date: session.date,
967
+ position: positions[index % positions.length],
968
+ };
969
+ });
970
+ }
971
+ function createYearlyAggregates(data) {
972
+ const years = new Set();
973
+ for (const member of data.members) {
974
+ years.add(member.year);
975
+ }
976
+ for (const session of data.sessions) {
977
+ years.add(Number(session.date.slice(0, 4)));
978
+ }
979
+ for (const vote of data.votes) {
980
+ years.add(Number(vote.date.slice(0, 4)));
981
+ }
982
+ return [...years]
983
+ .sort((left, right) => left - right)
984
+ .map((year) => ({
985
+ year,
986
+ memberCount: data.members.filter((member) => member.year === year).length,
987
+ sessionCount: data.sessions.filter((session) => yearOfDate(session.date) === year).length,
988
+ conversationCount: data.conversations.filter((conversation) => {
989
+ const session = data.sessions.find((item) => item.id === conversation.sessionId);
990
+ return session !== undefined && yearOfDate(session.date) === year;
991
+ }).length,
992
+ billCount: data.bills.length,
993
+ voteCount: data.votes.filter((vote) => yearOfDate(vote.date) === year).length,
994
+ categoryCount: data.categories.length,
995
+ passedBillCount: data.bills.filter((bill) => bill.status === "passed").length,
996
+ }));
997
+ }
998
+ function yearOfDate(date) {
999
+ return Number(date.slice(0, 4));
1000
+ }
1001
+ function exampleDataRecordCounts(data) {
1002
+ return {
1003
+ members: data.members.length,
1004
+ sessions: data.sessions.length,
1005
+ conversations: data.conversations.length,
1006
+ bills: data.bills.length,
1007
+ categories: data.categories.length,
1008
+ votes: data.votes.length,
1009
+ yearlyAggregates: data.yearlyAggregates.length,
1010
+ };
1011
+ }
1012
+ function createCategoryIndexSummaries(data) {
1013
+ return data.categories.map((category) => ({
1014
+ slug: category.slug,
1015
+ name: category.name,
1016
+ conversationCount: data.conversations.filter((conversation) => conversation.categoryId === category.id).length,
1017
+ billCount: data.bills.filter((bill) => bill.categoryId === category.id).length,
1018
+ voteCount: data.votes.filter((vote) => vote.categoryId === category.id).length,
1019
+ }));
1020
+ }
1021
+ function dataWithMutatedConversation(data, id) {
1022
+ const index = data.conversations.findIndex((conversation) => conversation.id === id);
1023
+ if (index < 0) {
1024
+ return data;
1025
+ }
1026
+ const conversation = data.conversations[index];
1027
+ const conversations = data.conversations.slice();
1028
+ conversations[index] = {
1029
+ ...conversation,
1030
+ title: `${conversation.title} Updated`,
1031
+ body: `${conversation.body} Updated for incremental benchmark.`,
1032
+ };
1033
+ return { ...data, conversations };
1034
+ }
1035
+ function renderMember(member, conversations, portraitVariants, trailingSlash) {
1036
+ const latest = conversations.slice(0, 8);
1037
+ const portrait = renderMemberPortrait(member, portraitVariants);
1038
+ return `<main class="w">${portrait}<p class="m">${escapeHtml(member.district)}<h1>${escapeHtml(member.name)}</h1><section><h2>Latest conversations</h2><ul>${latest
1039
+ .map((conversation) => `<li><a href="${conversationPath(conversation, trailingSlash)}">${escapeHtml(conversationLabel(conversation))}</a>`)
1040
+ .join("")}</ul></section></main>`;
1041
+ }
1042
+ function renderMemberPortrait(member, portraitVariants) {
1043
+ const src = `/assets/portraits/${member.id}.png`;
1044
+ const srcset = srcsetFor(portraitVariants.filter((image) => image.format === "webp"));
1045
+ const srcsetAttribute = srcset ? ` srcset="${escapeAttribute(srcset)}" sizes="64px"` : "";
1046
+ return `<img src="${src}"${srcsetAttribute} width="64" height="64" alt="" loading="lazy">`;
1047
+ }
1048
+ function renderSession(session, conversations, trailingSlash) {
1049
+ return `<main class="w"><p class="m">${escapeHtml(session.date)}<h1>${escapeHtml(session.title)}</h1><p>${conversations.length} conversations are linked to this session.<ul>${conversations
1050
+ .slice(0, sessionConversationPreviewLimit)
1051
+ .map((conversation) => `<li><a href="${conversationPath(conversation, trailingSlash)}">${escapeHtml(conversationLabel(conversation))}</a>`)
1052
+ .join("")}</ul></main>`;
1053
+ }
1054
+ function renderConversation(conversation, member, session, trailingSlash) {
1055
+ return `<main><h1>${escapeHtml(conversation.title)}</h1><p><a href="${memberPath(member, trailingSlash)}">Member</a><p><a href="${sessionPath(session, trailingSlash)}">Session</a><p>${escapeHtml(conversation.body)}</main>`;
1056
+ }
1057
+ function conversationIndexLinks(page, trailingSlash) {
1058
+ return [
1059
+ ...page.items.map((conversation) => conversationPath(conversation, trailingSlash)),
1060
+ ...(page.page > 1 ? [conversationIndexPagePath(page.page - 1, trailingSlash)] : []),
1061
+ ...(page.page < page.totalPages ? [conversationIndexPagePath(page.page + 1, trailingSlash)] : []),
1062
+ ];
1063
+ }
1064
+ function renderConversationIndex(page, trailingSlash) {
1065
+ const previous = page.page > 1
1066
+ ? `<a href="${conversationIndexPagePath(page.page - 1, trailingSlash)}">Previous</a>`
1067
+ : "";
1068
+ const next = page.page < page.totalPages
1069
+ ? `<a href="${conversationIndexPagePath(page.page + 1, trailingSlash)}">Next</a>`
1070
+ : "";
1071
+ return `<main class="w"><p class="m">Page ${page.page} of ${page.totalPages}<h1>Conversations</h1><ul>${page.items
1072
+ .map((conversation) => `<li><a href="${conversationPath(conversation, trailingSlash)}">${escapeHtml(conversationLabel(conversation))}</a>`)
1073
+ .join("")}</ul><nav>${previous}${next}</nav></main>`;
1074
+ }
1075
+ function renderBill(bill) {
1076
+ return `<main><p class="m">${escapeHtml(bill.status)}<h1>${escapeHtml(bill.title)}</h1><p>This bill page is generated from normalized public data.</main>`;
1077
+ }
1078
+ function renderCategoryIndex(rows) {
1079
+ return `<main class="w"><h1>Categories</h1><ul>${rows
1080
+ .map((row) => `<li><strong>${escapeHtml(row.name)}</strong> ${row.conversationCount} conversations, ${row.billCount} bills, ${row.voteCount} votes`)
1081
+ .join("")}</ul></main>`;
1082
+ }
1083
+ function renderYearIndex(rows) {
1084
+ return `<main class="w"><h1>Years</h1><ul>${rows
1085
+ .map((row) => `<li><strong>${row.year}</strong> ${row.conversationCount} conversations, ${row.billCount} bills, ${row.voteCount} votes`)
1086
+ .join("")}</ul></main>`;
1087
+ }
1088
+ function memberPath(member, trailingSlash) {
1089
+ return routeUrl(`/members/${member.slug}/${member.year}/`, trailingSlash);
1090
+ }
1091
+ function sessionPath(session, trailingSlash) {
1092
+ return routeUrl(`/sessions/${session.slug}/`, trailingSlash);
1093
+ }
1094
+ function conversationPath(conversation, trailingSlash) {
1095
+ return routeUrl(`/conversations/${conversation.slug}/`, trailingSlash);
1096
+ }
1097
+ function conversationIndexPagePath(page, trailingSlash) {
1098
+ return routeUrl(page === 1 ? "/conversations/" : `/conversations/page/${page}/`, trailingSlash);
1099
+ }
1100
+ function routeUrl(path, trailingSlash) {
1101
+ if (path === "/") {
1102
+ return path;
1103
+ }
1104
+ const withoutTrailingSlash = path.replace(/\/+$/, "");
1105
+ return trailingSlash === "never" ? withoutTrailingSlash : `${withoutTrailingSlash}/`;
1106
+ }
1107
+ function conversationLabel(conversation) {
1108
+ const [label] = conversation.title.split(" by ", 1);
1109
+ return label ?? conversation.title;
1110
+ }
1111
+ function conversationSummaryFingerprint(conversation) {
1112
+ return `{"slug":${JSON.stringify(conversation.slug)},"label":${JSON.stringify(conversationLabel(conversation))}}`;
1113
+ }
1114
+ async function prepareExampleImages(outDir, members, options) {
1115
+ const images = members.map((member) => ({
1116
+ source: join(outDir, `assets/portraits/${member.id}.png`),
1117
+ publicPath: `/assets/portraits/${member.id}.png`,
1118
+ memberId: member.id,
1119
+ }));
1120
+ await Promise.all(images.map((image) => writePlaceholderPng(image.source)));
1121
+ const result = await optimizeImages({
1122
+ outDir,
1123
+ images,
1124
+ widths: [160, 320],
1125
+ formats: ["webp"],
1126
+ skipExisting: true,
1127
+ converter: options.converter,
1128
+ loadConverter: options.loadConverter,
1129
+ });
1130
+ const availableVariants = [
1131
+ ...result.generated,
1132
+ ...result.skipped
1133
+ .filter(existingImageVariantSkipped)
1134
+ .map((image) => ({
1135
+ source: image.source,
1136
+ publicPath: image.publicPath,
1137
+ width: image.width,
1138
+ format: image.format,
1139
+ bytes: image.bytes,
1140
+ })),
1141
+ ];
1142
+ const variantsByMember = new Map();
1143
+ for (const image of images) {
1144
+ variantsByMember.set(image.memberId, availableVariants.filter((variant) => variant.source === image.source));
1145
+ }
1146
+ return {
1147
+ variantsByMember,
1148
+ summary: summarizeImageOptimization(result),
1149
+ };
1150
+ }
1151
+ function existingImageVariantSkipped(image) {
1152
+ return (image.reason === "existing-current" &&
1153
+ image.publicPath !== undefined &&
1154
+ image.width !== undefined &&
1155
+ image.format !== undefined &&
1156
+ image.bytes !== undefined);
1157
+ }
1158
+ function summarizeImageOptimization(result) {
1159
+ return {
1160
+ generated: result.generated.length,
1161
+ skipped: result.skipped.length,
1162
+ outputBytes: result.generated.reduce((total, image) => total + image.bytes, 0),
1163
+ };
1164
+ }
1165
+ function emptyImageOptimization() {
1166
+ return {
1167
+ variantsByMember: new Map(),
1168
+ summary: {
1169
+ generated: 0,
1170
+ skipped: 0,
1171
+ outputBytes: 0,
1172
+ },
1173
+ };
1174
+ }
1175
+ async function writePlaceholderPng(path) {
1176
+ const content = Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=", "base64");
1177
+ await writeBinaryIfChanged(path, content);
1178
+ }
1179
+ async function writeSiteCss(outDir) {
1180
+ await writeTailwindUtilitiesCss(join(outDir, "s.css"), {
1181
+ candidates: exampleCssCandidates,
1182
+ css: exampleTailwindCss,
1183
+ });
1184
+ }
1185
+ async function writeJson(path, value) {
1186
+ await writeText(path, `${JSON.stringify(value)}\n`);
1187
+ }
1188
+ async function writeText(path, value) {
1189
+ await mkdir(dirname(path), { recursive: true });
1190
+ await writeFile(path, value, { flag: "w" });
1191
+ }
1192
+ async function writeBinaryIfChanged(path, value) {
1193
+ try {
1194
+ const current = await readFile(path);
1195
+ if (current.equals(value)) {
1196
+ return;
1197
+ }
1198
+ }
1199
+ catch (error) {
1200
+ if (!isMissingPath(error)) {
1201
+ throw error;
1202
+ }
1203
+ }
1204
+ await mkdir(dirname(path), { recursive: true });
1205
+ await writeFile(path, value, { flag: "w" });
1206
+ }
1207
+ async function walkFiles(path, onFile, options = {}) {
1208
+ const entries = await readdir(path, { withFileTypes: true });
1209
+ await mapConcurrent(entries, exampleFileWalkConcurrency, async (entry) => {
1210
+ const entryPath = join(path, entry.name);
1211
+ if (entry.isDirectory()) {
1212
+ if (options.shouldSkipDirectory?.(entryPath)) {
1213
+ return;
1214
+ }
1215
+ await walkFiles(entryPath, onFile, options);
1216
+ return;
1217
+ }
1218
+ if (entry.isFile()) {
1219
+ await onFile(entryPath);
1220
+ }
1221
+ });
1222
+ }
1223
+ async function mapConcurrent(items, concurrency, worker) {
1224
+ const limit = Math.max(1, Math.floor(concurrency));
1225
+ let nextIndex = 0;
1226
+ async function runWorker() {
1227
+ while (nextIndex < items.length) {
1228
+ const index = nextIndex;
1229
+ nextIndex += 1;
1230
+ await worker(items[index]);
1231
+ }
1232
+ }
1233
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, () => runWorker()));
1234
+ }
1235
+ function isImageOutputPath(path) {
1236
+ return /\.(avif|gif|jpe?g|png|svg|webp)$/u.test(path);
1237
+ }
1238
+ function isMissingPath(error) {
1239
+ return error instanceof Error && "code" in error && error.code === "ENOENT";
1240
+ }
1241
+ function groupBy(items, keyOf) {
1242
+ const map = new Map();
1243
+ for (const item of items) {
1244
+ const key = keyOf(item);
1245
+ const group = map.get(key);
1246
+ if (group) {
1247
+ group.push(item);
1248
+ }
1249
+ else {
1250
+ map.set(key, [item]);
1251
+ }
1252
+ }
1253
+ return map;
1254
+ }
1255
+ function createParagraph(seed, memberName, sessionTitle) {
1256
+ return `${memberName} discussed budget transparency during ${sessionTitle}. Record ${seed}.`;
1257
+ }
1258
+ function excerpt(value, length) {
1259
+ return value.length > length ? `${value.slice(0, length - 1)}...` : value;
1260
+ }
1261
+ function pad(value, length = 3) {
1262
+ return String(value).padStart(length, "0");
1263
+ }
1264
+ function escapeHtml(value) {
1265
+ return value
1266
+ .replaceAll("&", "&amp;")
1267
+ .replaceAll("<", "&lt;")
1268
+ .replaceAll(">", "&gt;");
1269
+ }
1270
+ function escapeAttribute(value) {
1271
+ return escapeHtml(value).replaceAll('"', "&quot;");
1272
+ }