@twango/twiki 0.0.0-stage → 0.1.0-rc.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 (225) hide show
  1. package/.github/actions/product-cache/action.yml +32 -0
  2. package/.github/actions/product-cache/index.mjs +450 -0
  3. package/LICENSE +21 -0
  4. package/README.md +146 -2
  5. package/bun.lock +1701 -0
  6. package/cli/runtime.test.ts +420 -0
  7. package/cli/runtime.ts +385 -0
  8. package/cli/twiki.ts +305 -0
  9. package/package.json +138 -4
  10. package/site/_headers +7 -0
  11. package/site/assets/fonts/OFL.txt +93 -0
  12. package/site/assets/fonts/OverusedGrotesk-SemiBold.ttf +0 -0
  13. package/site/assets/social-background.svg +38 -0
  14. package/site/bun.lock +1701 -0
  15. package/site/database/index.ts +7 -0
  16. package/site/database/schema.ts +95 -0
  17. package/site/drizzle.config.ts +7 -0
  18. package/site/migrations/0001_auth.sql +14 -0
  19. package/site/migrations/0002_site_access.sql +5 -0
  20. package/site/migrations/0003_github_username.sql +1 -0
  21. package/site/migrations/meta/0002_snapshot.json +432 -0
  22. package/site/migrations/meta/0003_snapshot.json +439 -0
  23. package/site/migrations/meta/_journal.json +27 -0
  24. package/site/package.json +120 -0
  25. package/site/src/app.d.ts +14 -0
  26. package/site/src/app.html +11 -0
  27. package/site/src/hooks.server.ts +12 -0
  28. package/site/src/lib/analytics-config.json +1 -0
  29. package/site/src/lib/analytics.ts +40 -0
  30. package/site/src/lib/auth-config.json +3 -0
  31. package/site/src/lib/components/SEO.svelte +113 -0
  32. package/site/src/lib/components/doc/AccessLink.svelte +27 -0
  33. package/site/src/lib/components/doc/Backlinks.svelte +59 -0
  34. package/site/src/lib/components/doc/Breadcrumbs.svelte +29 -0
  35. package/site/src/lib/components/doc/ContentMeta.svelte +61 -0
  36. package/site/src/lib/components/doc/DocPager.svelte +91 -0
  37. package/site/src/lib/components/doc/DocShell.svelte +115 -0
  38. package/site/src/lib/components/doc/LicenseNotice.svelte +26 -0
  39. package/site/src/lib/components/doc/LockedContent.svelte +29 -0
  40. package/site/src/lib/components/doc/NavTree.svelte +158 -0
  41. package/site/src/lib/components/doc/NoteActions.svelte +139 -0
  42. package/site/src/lib/components/doc/PublicationBadge.svelte +49 -0
  43. package/site/src/lib/components/doc/ReaderModeToggle.svelte +19 -0
  44. package/site/src/lib/components/doc/Sidebar.svelte +351 -0
  45. package/site/src/lib/components/doc/Toc.svelte +339 -0
  46. package/site/src/lib/components/doc/enhancements.ts +146 -0
  47. package/site/src/lib/components/doc/heading-tracker.svelte.ts +39 -0
  48. package/site/src/lib/components/doc/image-preview.ts +114 -0
  49. package/site/src/lib/components/doc/reader-mode.svelte.ts +40 -0
  50. package/site/src/lib/components/embeds/mermaid.ts +353 -0
  51. package/site/src/lib/components/files/FileCode.svelte +81 -0
  52. package/site/src/lib/components/files/FileExplorer.svelte +74 -0
  53. package/site/src/lib/components/files/FileHistory.svelte +170 -0
  54. package/site/src/lib/components/files/FileIcon.svelte +28 -0
  55. package/site/src/lib/components/files/FileRmd.svelte +209 -0
  56. package/site/src/lib/components/files/FileTabs.svelte +180 -0
  57. package/site/src/lib/components/files/FileTree.svelte +67 -0
  58. package/site/src/lib/components/files/blame-gutter.ts +45 -0
  59. package/site/src/lib/components/files/code-view.ts +98 -0
  60. package/site/src/lib/components/files/file-workspace.svelte.ts +100 -0
  61. package/site/src/lib/components/graph/GraphPanel.svelte +275 -0
  62. package/site/src/lib/components/graph/GraphView.svelte +100 -0
  63. package/site/src/lib/components/graph/graph-data.ts +137 -0
  64. package/site/src/lib/components/graph/graph-model.ts +103 -0
  65. package/site/src/lib/components/graph/index.ts +3 -0
  66. package/site/src/lib/components/graph/render-graph.ts +626 -0
  67. package/site/src/lib/components/popover/index.ts +1 -0
  68. package/site/src/lib/components/popover/link-popovers.ts +233 -0
  69. package/site/src/lib/components/popover/popover.css +82 -0
  70. package/site/src/lib/components/search/SearchButton.svelte +57 -0
  71. package/site/src/lib/components/search/SearchPalette.svelte +189 -0
  72. package/site/src/lib/components/search/index.ts +3 -0
  73. package/site/src/lib/components/search/pagefind-client.svelte.ts +129 -0
  74. package/site/src/lib/components/search/pagefind-query.ts +22 -0
  75. package/site/src/lib/components/search/pagefind-url.ts +12 -0
  76. package/site/src/lib/components/search/search-state.svelte.ts +22 -0
  77. package/site/src/lib/components/ui/IconButton.svelte +30 -0
  78. package/site/src/lib/components/ui/ThemeToggle.svelte +22 -0
  79. package/site/src/lib/components/ui/index.ts +1 -0
  80. package/site/src/lib/config.ts +9 -0
  81. package/site/src/lib/content-license.ts +7 -0
  82. package/site/src/lib/file-history.ts +37 -0
  83. package/site/src/lib/file-icons.ts +27 -0
  84. package/site/src/lib/file-tabs.ts +72 -0
  85. package/site/src/lib/files.ts +73 -0
  86. package/site/src/lib/github-user.ts +70 -0
  87. package/site/src/lib/metadata.ts +166 -0
  88. package/site/src/lib/notebook-nav.ts +63 -0
  89. package/site/src/lib/publication.ts +1 -0
  90. package/site/src/lib/rmd.ts +156 -0
  91. package/site/src/lib/server/access-admin.ts +33 -0
  92. package/site/src/lib/server/content-model.ts +253 -0
  93. package/site/src/lib/server/content-provider.ts +266 -0
  94. package/site/src/lib/server/content.ts +277 -0
  95. package/site/src/lib/server/runtime-content.ts +34 -0
  96. package/site/src/lib/site-config.json +9 -0
  97. package/site/src/lib/social-image.ts +13 -0
  98. package/site/src/lib/styles/article.css +607 -0
  99. package/site/src/lib/styles/foundation.css +138 -0
  100. package/site/src/lib/styles/print.css +115 -0
  101. package/site/src/lib/styles/shell.css +182 -0
  102. package/site/src/lib/text-exports.ts +5 -0
  103. package/site/src/lib/types.ts +140 -0
  104. package/site/src/lib/utils.ts +6 -0
  105. package/site/src/routes/+error.svelte +16 -0
  106. package/site/src/routes/+layout.server.ts +10 -0
  107. package/site/src/routes/+layout.svelte +72 -0
  108. package/site/src/routes/+layout.ts +3 -0
  109. package/site/src/routes/404/+page.svelte +13 -0
  110. package/site/src/routes/[...slug]/+page.server.ts +24 -0
  111. package/site/src/routes/[...slug]/+page.svelte +180 -0
  112. package/site/src/routes/[course]/files/[...file]/+page.svelte +373 -0
  113. package/site/src/routes/[course]/files/[...file]/+page.ts +38 -0
  114. package/site/src/routes/admin/access/+page.server.ts +53 -0
  115. package/site/src/routes/admin/access/+page.svelte +118 -0
  116. package/site/src/routes/graph.json/+server.ts +7 -0
  117. package/site/src/routes/index.xml/+server.ts +7 -0
  118. package/site/src/routes/layout.css +10 -0
  119. package/site/src/routes/robots.txt/+server.ts +9 -0
  120. package/site/src/routes/sitemap.xml/+server.ts +7 -0
  121. package/site/src/routes/tags/+page.server.ts +5 -0
  122. package/site/src/routes/tags/+page.svelte +38 -0
  123. package/site/src/routes/tags/[...tag]/+page.server.ts +22 -0
  124. package/site/src/routes/tags/[...tag]/+page.svelte +52 -0
  125. package/site/static/favicon.png +0 -0
  126. package/site/static/fonts/JetBrainsMono-Italic-VF.woff2 +0 -0
  127. package/site/static/fonts/JetBrainsMono-VF.woff2 +0 -0
  128. package/site/static/fonts/OverusedGrotesk-VF.woff2 +0 -0
  129. package/site/static/fonts/OverusedGroteskItalic-VF.woff2 +0 -0
  130. package/site/static/fonts/Virgil.woff2 +0 -0
  131. package/site/svelte.config.js +71 -0
  132. package/site/tooling/LICENSE-quartz +25 -0
  133. package/site/tooling/adapter.js +55 -0
  134. package/site/tooling/assets.ts +323 -0
  135. package/site/tooling/benchmark.ts +191 -0
  136. package/site/tooling/build.ts +407 -0
  137. package/site/tooling/cache.ts +133 -0
  138. package/site/tooling/check-diagrams.ts +164 -0
  139. package/site/tooling/check-links.ts +86 -0
  140. package/site/tooling/check-targets.ts +139 -0
  141. package/site/tooling/compiler.ts +1386 -0
  142. package/site/tooling/deploy.ts +112 -0
  143. package/site/tooling/fingerprint.ts +81 -0
  144. package/site/tooling/lib/app-version.js +92 -0
  145. package/site/tooling/lib/application-asset-urls.ts +27 -0
  146. package/site/tooling/lib/application-cache.ts +269 -0
  147. package/site/tooling/lib/asset-hash-cache.ts +116 -0
  148. package/site/tooling/lib/autotag.ts +99 -0
  149. package/site/tooling/lib/build-assembly.ts +148 -0
  150. package/site/tooling/lib/build-workspace.ts +386 -0
  151. package/site/tooling/lib/cache-selection.ts +70 -0
  152. package/site/tooling/lib/cloudflare-prewarm.js +40 -0
  153. package/site/tooling/lib/comments.ts +32 -0
  154. package/site/tooling/lib/content-cache.ts +244 -0
  155. package/site/tooling/lib/content-urls.ts +50 -0
  156. package/site/tooling/lib/course-cache.ts +298 -0
  157. package/site/tooling/lib/course-files.ts +281 -0
  158. package/site/tooling/lib/currency.ts +33 -0
  159. package/site/tooling/lib/deployment-config.ts +222 -0
  160. package/site/tooling/lib/deployment-manifest.ts +274 -0
  161. package/site/tooling/lib/deployment-prepare.ts +325 -0
  162. package/site/tooling/lib/deployment-upload.ts +293 -0
  163. package/site/tooling/lib/dev-git-state.ts +130 -0
  164. package/site/tooling/lib/dev-history.ts +86 -0
  165. package/site/tooling/lib/dev-social-images.ts +38 -0
  166. package/site/tooling/lib/dev-state.ts +107 -0
  167. package/site/tooling/lib/edition-builds.ts +184 -0
  168. package/site/tooling/lib/edition-paths.js +29 -0
  169. package/site/tooling/lib/empty-tag-route.js +23 -0
  170. package/site/tooling/lib/file-history.ts +201 -0
  171. package/site/tooling/lib/file-icons.ts +93 -0
  172. package/site/tooling/lib/file-policy.ts +38 -0
  173. package/site/tooling/lib/file-search.js +13 -0
  174. package/site/tooling/lib/git-revisions.ts +41 -0
  175. package/site/tooling/lib/history-pool.ts +74 -0
  176. package/site/tooling/lib/history-worker.ts +22 -0
  177. package/site/tooling/lib/knit-rmd.R +38 -0
  178. package/site/tooling/lib/lastmod.ts +201 -0
  179. package/site/tooling/lib/markdown-files.ts +51 -0
  180. package/site/tooling/lib/ofm.ts +634 -0
  181. package/site/tooling/lib/output.ts +60 -0
  182. package/site/tooling/lib/product-cache.ts +1006 -0
  183. package/site/tooling/lib/project-config.ts +288 -0
  184. package/site/tooling/lib/public-assets.ts +38 -0
  185. package/site/tooling/lib/public-links.ts +56 -0
  186. package/site/tooling/lib/publishing.ts +156 -0
  187. package/site/tooling/lib/r-runtime.ts +257 -0
  188. package/site/tooling/lib/rebuild-queue.ts +25 -0
  189. package/site/tooling/lib/rmd-preflight.ts +166 -0
  190. package/site/tooling/lib/rmd-preview-cache.ts +93 -0
  191. package/site/tooling/lib/rmd-previews.ts +129 -0
  192. package/site/tooling/lib/runtime-data-modules.ts +30 -0
  193. package/site/tooling/lib/runtime-packaging.ts +321 -0
  194. package/site/tooling/lib/search-cache.js +165 -0
  195. package/site/tooling/lib/search-inputs.js +129 -0
  196. package/site/tooling/lib/slug.ts +377 -0
  197. package/site/tooling/lib/social-images.ts +210 -0
  198. package/site/tooling/lib/text-exports.ts +51 -0
  199. package/site/tooling/lib/visit-elements.ts +17 -0
  200. package/site/tooling/live-test.ts +138 -0
  201. package/site/tooling/pipeline.ts +103 -0
  202. package/site/tooling/prepare.ts +3 -0
  203. package/site/tooling/publish.ts +28 -0
  204. package/site/tooling/r-cache.ts +45 -0
  205. package/site/tooling/r-render-profile.json +7 -0
  206. package/site/tooling/search.js +94 -0
  207. package/site/tooling/verify-deployment.ts +109 -0
  208. package/site/tooling/vite-content.ts +240 -0
  209. package/site/tooling/wrangler-paths.js +25 -0
  210. package/site/tooling/wrangler.ts +19 -0
  211. package/site/tsconfig.json +16 -0
  212. package/site/vite.config.ts +40 -0
  213. package/site/worker/access.ts +57 -0
  214. package/site/worker/analytics.ts +100 -0
  215. package/site/worker/auth-routes.ts +78 -0
  216. package/site/worker/auth.ts +78 -0
  217. package/site/worker/content-runtime.ts +226 -0
  218. package/site/worker/framing.ts +4 -0
  219. package/site/worker/gate.ts +147 -0
  220. package/site/worker/index.ts +9 -0
  221. package/site/worker/login.ts +62 -0
  222. package/site/worker/publication.ts +44 -0
  223. package/site/worker/runtime.ts +104 -0
  224. package/site/worker/tsconfig.json +13 -0
  225. package/site/wrangler.jsonc +37 -0
@@ -0,0 +1,323 @@
1
+ /** Sync assets, emit navigation, and report unresolved content links for prerendering. */
2
+
3
+ import {
4
+ copyFileSync,
5
+ cpSync,
6
+ existsSync,
7
+ linkSync,
8
+ mkdirSync,
9
+ readdirSync,
10
+ readFileSync,
11
+ rmSync,
12
+ statSync,
13
+ unlinkSync
14
+ } from 'node:fs';
15
+ import path from 'node:path';
16
+ import {
17
+ staticDirectory,
18
+ courseFilesDirectory,
19
+ compiledAssetsDirectory
20
+ } from './lib/edition-paths.js';
21
+ import { writeChanged } from './lib/output';
22
+ import { writeTextExports } from './lib/text-exports';
23
+ import { buildCourseFiles } from './lib/course-files';
24
+ import { addNotebookNavigation } from '../src/lib/notebook-nav';
25
+ import type { CourseFile } from '../src/lib/files';
26
+ import { runtimeDataAssets, runtimeDataModuleSource } from './lib/runtime-data-modules';
27
+ import {
28
+ contentEntries,
29
+ displayRoute,
30
+ getManifest,
31
+ resetContentCache,
32
+ navTree,
33
+ loadPage,
34
+ pageSlugForRoute
35
+ } from '../src/lib/server/content';
36
+
37
+ export async function prepareAssets(changedInputs?: Set<string>, development = false) {
38
+ resetContentCache();
39
+ const SITE_DIR = path.resolve(import.meta.dirname, '..');
40
+ const GENERATED = path.join(SITE_DIR, 'build/generated');
41
+ const STATIC_DIR = staticDirectory(SITE_DIR);
42
+ mkdirSync(STATIC_DIR, { recursive: true });
43
+ if (process.env.VITE_PUBLIC_EDITION === 'true') {
44
+ for (const name of ['fonts', 'favicon.png']) {
45
+ const source = path.join(SITE_DIR, 'static', name);
46
+ if (existsSync(source)) cpSync(source, path.join(STATIC_DIR, name), { recursive: true });
47
+ }
48
+ }
49
+ if (!existsSync(path.join(GENERATED, 'public-assets.json'))) {
50
+ mkdirSync(GENERATED, { recursive: true });
51
+ writeChanged(path.join(GENERATED, 'public-assets.json'), '{}');
52
+ }
53
+
54
+ if (!existsSync(path.join(GENERATED, 'content-manifest.json'))) {
55
+ throw new Error('Content must be compiled before preparing assets');
56
+ }
57
+
58
+ const manifest = getManifest();
59
+ await buildCourseFiles(
60
+ SITE_DIR,
61
+ new Set(Object.keys(manifest.pages)),
62
+ changedInputs,
63
+ development
64
+ );
65
+ if (process.env.VITE_PUBLIC_EDITION === 'true') {
66
+ const entries = JSON.parse(
67
+ readFileSync(path.join(SITE_DIR, 'src/lib/generated/file-entries.json'), 'utf8')
68
+ ) as { course: string; file: string }[];
69
+ manifest.fileCourses = entries
70
+ .filter((entry) => entry.file === '')
71
+ .map((entry) => entry.course);
72
+ for (const course of manifest.fileCourses) {
73
+ if (!manifest.folders.includes(course)) manifest.folders.push(course);
74
+ if (!manifest.tree.children.some((node) => node.slug === course))
75
+ manifest.tree.children.push({
76
+ name: course,
77
+ slug: course,
78
+ title: course,
79
+ children: [],
80
+ pages: []
81
+ });
82
+ }
83
+ writeChanged(path.join(GENERATED, 'content-manifest.json'), JSON.stringify(manifest));
84
+ }
85
+ const WARN_BYTES = 20 * 1024 * 1024;
86
+ const FAIL_BYTES = 25 * 1024 * 1024;
87
+
88
+ // ---------------------------------------------------------------------------
89
+ // 1. Asset sync into static/
90
+ // ---------------------------------------------------------------------------
91
+
92
+ const htmlAssetSet = new Set(manifest.htmlAssets);
93
+ const srcRoot = compiledAssetsDirectory(SITE_DIR);
94
+
95
+ /** rel path in build/generated/assets → rel path in static/. */
96
+ function destRel(rel: string): string {
97
+ return htmlAssetSet.has(rel) ? `${rel}.html` : rel;
98
+ }
99
+
100
+ function* walk(dir: string): Generator<string> {
101
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
102
+ const p = path.join(dir, entry.name);
103
+ if (entry.isDirectory()) yield* walk(p);
104
+ else yield p;
105
+ }
106
+ }
107
+
108
+ let copied = 0;
109
+ let kept = 0;
110
+ let oversize = false;
111
+ const wanted = new Set<string>(Object.values(runtimeDataAssets));
112
+
113
+ const sources = [
114
+ ...[...walk(srcRoot)]
115
+ .filter((src) => !path.relative(srcRoot, src).startsWith('_files' + path.sep))
116
+ .map((src) => ({ src, rel: path.relative(srcRoot, src) })),
117
+ ...[...walk(courseFilesDirectory(SITE_DIR))].map((src) => ({
118
+ src,
119
+ rel: path.join('_files', path.relative(courseFilesDirectory(SITE_DIR), src))
120
+ }))
121
+ ];
122
+ for (const { src, rel: relative } of sources) {
123
+ const rel = relative.split(path.sep).join('/');
124
+ const out = destRel(rel);
125
+ wanted.add(out);
126
+ const dest = path.join(STATIC_DIR, out);
127
+ const srcStat = statSync(src);
128
+ if (srcStat.size > FAIL_BYTES) {
129
+ console.error(
130
+ `FAIL: asset ${rel} is ${(srcStat.size / 1048576).toFixed(1)} MiB (>25 MiB Workers cap)`
131
+ );
132
+ oversize = true;
133
+ } else if (srcStat.size > WARN_BYTES) {
134
+ console.warn(`warn: asset ${rel} is ${(srcStat.size / 1048576).toFixed(1)} MiB (>20 MiB)`);
135
+ }
136
+ if (existsSync(dest)) {
137
+ const destStat = statSync(dest);
138
+ if (
139
+ destStat.ino === srcStat.ino ||
140
+ (destStat.size === srcStat.size && readFileSync(dest).equals(readFileSync(src)))
141
+ ) {
142
+ kept++;
143
+ continue;
144
+ }
145
+ unlinkSync(dest);
146
+ }
147
+ mkdirSync(path.dirname(dest), { recursive: true });
148
+ try {
149
+ linkSync(src, dest); // hardlink: free, instant
150
+ } catch {
151
+ copyFileSync(src, dest);
152
+ }
153
+ copied++;
154
+ }
155
+ if (oversize) throw new Error('Assets exceed the deployment size limit');
156
+
157
+ for (const file of writeTextExports(
158
+ STATIC_DIR,
159
+ Object.keys(manifest.pages).map(loadPage),
160
+ wanted
161
+ ))
162
+ wanted.add(file);
163
+
164
+ // Prune stale synced files (never touch the hand-placed statics).
165
+ const KEEP = new Set(['.gitignore', 'favicon.png']);
166
+ let pruned = 0;
167
+ for (const file of walk(STATIC_DIR)) {
168
+ const rel = path.relative(STATIC_DIR, file).split(path.sep).join('/');
169
+ if (KEEP.has(rel) || rel.startsWith('fonts/') || rel.startsWith('_og/')) continue;
170
+ if (!wanted.has(rel)) {
171
+ unlinkSync(file);
172
+ pruned++;
173
+ }
174
+ }
175
+ // Remove now-empty directories.
176
+ function pruneEmptyDirs(dir: string): boolean {
177
+ let empty = true;
178
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
179
+ const p = path.join(dir, entry.name);
180
+ if (entry.isDirectory()) {
181
+ if (pruneEmptyDirs(p)) rmSync(p, { recursive: true });
182
+ else empty = false;
183
+ } else empty = false;
184
+ }
185
+ return empty;
186
+ }
187
+ pruneEmptyDirs(STATIC_DIR);
188
+ console.log(`assets: pruned ${pruned} stale files`);
189
+
190
+ console.log(`assets: ${copied} synced, ${kept} up-to-date, ${wanted.size} total`);
191
+
192
+ // ---------------------------------------------------------------------------
193
+ // 2. Nav tree module
194
+ // ---------------------------------------------------------------------------
195
+
196
+ const navOut = path.join(SITE_DIR, 'src/lib/generated/nav.json');
197
+ mkdirSync(path.dirname(navOut), { recursive: true });
198
+ const navigation = navTree();
199
+ const fileIndexDirectory = path.join(STATIC_DIR, '_files/index');
200
+ for (const name of existsSync(fileIndexDirectory) ? readdirSync(fileIndexDirectory) : []) {
201
+ if (!name.endsWith('.json')) continue;
202
+ const files: CourseFile[] = JSON.parse(
203
+ readFileSync(path.join(STATIC_DIR, '_files/index', name), 'utf8')
204
+ );
205
+ addNotebookNavigation(navigation, name.slice(0, -5), files);
206
+ }
207
+ for (const node of navigation) {
208
+ if (manifest.fileCourses?.includes(node.segment) && !node.children.length)
209
+ node.children.push({
210
+ title: 'Files',
211
+ segment: 'files',
212
+ route: `/${node.segment}/files`,
213
+ children: []
214
+ });
215
+ }
216
+ writeChanged(navOut, JSON.stringify(navigation));
217
+ for (const [input, asset] of Object.entries(runtimeDataAssets)) {
218
+ const data = JSON.parse(readFileSync(path.join(path.dirname(navOut), input), 'utf8'));
219
+ writeChanged(path.join(STATIC_DIR, asset), runtimeDataModuleSource(data));
220
+ }
221
+ console.log(`nav: wrote src/lib/generated/nav.json`);
222
+
223
+ // ---------------------------------------------------------------------------
224
+ // 3. Expected-404 list for the strict prerender crawler
225
+ // ---------------------------------------------------------------------------
226
+
227
+ const valid = new Set<string>(['/']);
228
+ for (const route of contentEntries()) if (route !== '') valid.add(`/${route}`);
229
+ valid.add('/tags');
230
+ for (const tag of Object.keys(manifest.tags)) valid.add(`/tags/${tag}`);
231
+ for (const a of manifest.assets) valid.add(`/${a}`);
232
+ // html assets are linkable both extensionless (live URL) and with .html
233
+ for (const h of manifest.htmlAssets) {
234
+ valid.add(`/${h}`);
235
+ valid.add(`/${h}.html`);
236
+ }
237
+ valid.add('/index.xml');
238
+ valid.add('/sitemap.xml');
239
+ valid.add('/404');
240
+ valid.add('/favicon.png');
241
+
242
+ const expected404 = new Set<string>();
243
+ const attrRe = /(?:href|src)="([^"]*)"/g;
244
+ for (const slug of Object.keys(manifest.pages)) {
245
+ const doc = loadPage(slug);
246
+ for (const match of doc.html.matchAll(attrRe)) {
247
+ const raw = match[1];
248
+ if (!raw.startsWith('/') || raw.startsWith('//')) continue; // internal only
249
+ let p = raw.split('#')[0].split('?')[0];
250
+ if (p.length > 1 && p.endsWith('/')) p = p.slice(0, -1);
251
+ try {
252
+ p = decodeURI(p);
253
+ } catch {
254
+ /* keep raw */
255
+ }
256
+ if (p === '') p = '/';
257
+ if (!valid.has(p)) expected404.add(p);
258
+ }
259
+ }
260
+
261
+ const sorted = [...expected404].sort();
262
+ writeChanged(path.join(GENERATED, 'expected-404.json'), JSON.stringify(sorted, null, '\t'));
263
+ console.log(
264
+ `expected-404: ${sorted.length} unresolved content link targets reported to the crawler`
265
+ );
266
+
267
+ // Missing content anchors are reported without blocking publication.
268
+ const pageIds = new Map<string, Set<string>>();
269
+ const idRe = /id="([^"]*)"/g;
270
+ for (const slug of Object.keys(manifest.pages)) {
271
+ const doc = loadPage(slug);
272
+ const ids = new Set<string>();
273
+ for (const m of doc.html.matchAll(idRe)) ids.add(m[1]);
274
+ pageIds.set(slug, ids);
275
+ }
276
+
277
+ const missingAnchor = new Set<string>();
278
+ const anchorRe = /(?:href|src)="((?:\/[^"#]*)?#[^"]+)"/g;
279
+ for (const slug of Object.keys(manifest.pages)) {
280
+ const doc = loadPage(slug);
281
+ for (const m of doc.html.matchAll(anchorRe)) {
282
+ const raw = m[1];
283
+ if (raw.startsWith('//')) continue;
284
+ const hashIdx = raw.indexOf('#');
285
+ let target = raw.slice(0, hashIdx) || `/${displayRoute(slug)}`;
286
+ let id = raw.slice(hashIdx + 1);
287
+ if (id.startsWith('page=')) continue; // PDF page anchors pass raw
288
+ try {
289
+ target = decodeURI(target);
290
+ id = decodeURI(id);
291
+ } catch {
292
+ /* keep raw */
293
+ }
294
+ if (target.length > 1 && target.endsWith('/')) target = target.slice(0, -1);
295
+ // Map the URL path the prerenderer renders (`/` → home, `/dir` → folder
296
+ // index) to the canonical page slug, exactly like the catch-all route.
297
+ const route = target === '/' ? '' : target.slice(1);
298
+ const targetSlug = pageSlugForRoute(route);
299
+ if (!targetSlug) continue; // not a page route (asset/folder-listing/404 elsewhere)
300
+ const ids = pageIds.get(targetSlug);
301
+ if (!ids) continue;
302
+ // The prerenderer keys missing-id errors by the rendered path, not the slug.
303
+ if (!ids.has(id)) missingAnchor.add(`${target}#${id}`);
304
+ }
305
+ }
306
+
307
+ const sortedIds = [...missingAnchor].sort();
308
+ writeChanged(
309
+ path.join(GENERATED, 'expected-missing-id.json'),
310
+ JSON.stringify(sortedIds, null, '\t')
311
+ );
312
+ console.log(
313
+ `expected-missing-id: ${sortedIds.length} unresolved content anchors reported to the crawler`
314
+ );
315
+ console.log(
316
+ `routes: ${contentEntries().length} content + ${Object.keys(manifest.tags).length + 1} tag + 2 xml + /404`
317
+ );
318
+
319
+ if (!development) {
320
+ const { buildSocialImages } = await import('./lib/social-images');
321
+ await buildSocialImages(SITE_DIR, manifest);
322
+ }
323
+ }
@@ -0,0 +1,191 @@
1
+ import { spawn, spawnSync } from 'node:child_process';
2
+ import { createWriteStream, mkdirSync, writeFileSync } from 'node:fs';
3
+ import { createServer } from 'node:net';
4
+ import { availableParallelism, cpus, totalmem } from 'node:os';
5
+ import path from 'node:path';
6
+
7
+ const [mode, requestedLabel] = process.argv.slice(2);
8
+ if (mode !== 'build' && mode !== 'dev') {
9
+ console.error('Usage: bun tooling/benchmark.ts <build|dev> [label]');
10
+ process.exit(2);
11
+ }
12
+ const label = requestedLabel ?? `${mode}-${new Date().toISOString().replaceAll(':', '-')}`;
13
+ if (!/^[\w.-]+$/.test(label) || label === '.' || label === '..') {
14
+ throw new Error('Label must contain only letters, numbers, underscores, dots, or hyphens');
15
+ }
16
+ const timeoutSeconds = Number(process.env.BENCHMARK_TIMEOUT_SECONDS ?? '300');
17
+ if (!Number.isFinite(timeoutSeconds) || timeoutSeconds <= 0) {
18
+ throw new Error('BENCHMARK_TIMEOUT_SECONDS must be positive');
19
+ }
20
+ const site = path.resolve(import.meta.dirname, '..');
21
+ const directory = path.join(site, 'build/benchmarks');
22
+ mkdirSync(directory, { recursive: true });
23
+ const logPath = path.join(directory, `${label}.log`);
24
+ const resultPath = path.join(directory, `${label}.json`);
25
+ const log = createWriteStream(logPath);
26
+ const nodeRuntime = spawnSync('node', ['--version'], { encoding: 'utf8', timeout: 5000 });
27
+ const actualNodeVersion = nodeRuntime.status === 0 ? nodeRuntime.stdout.trim() : undefined;
28
+ const started = performance.now();
29
+ const elapsed = () => Math.round((performance.now() - started) / 10) / 100;
30
+
31
+ async function freePort() {
32
+ const server = createServer();
33
+ await new Promise<void>((resolve, reject) => {
34
+ server.once('error', reject);
35
+ server.listen(0, '127.0.0.1', resolve);
36
+ });
37
+ const address = server.address();
38
+ if (!address || typeof address === 'string') throw new Error('No benchmark port allocated');
39
+ await new Promise<void>((resolve, reject) =>
40
+ server.close((error) => (error ? reject(error) : resolve()))
41
+ );
42
+ return address.port;
43
+ }
44
+
45
+ const port = mode === 'dev' ? await freePort() : undefined;
46
+ const args =
47
+ mode === 'build'
48
+ ? ['run', process.env.WISCONSIN_BENCHMARK_STATIC === 'true' ? 'build:static' : 'build:all']
49
+ : ['run', 'dev', '--host', '127.0.0.1', '--port', String(port), '--strictPort'];
50
+ const command = process.versions.bun ? process.execPath : 'bun';
51
+ const child = spawn(command, args, {
52
+ cwd: site,
53
+ env: { ...process.env, FORCE_COLOR: '0', NO_COLOR: '1' },
54
+ detached: process.platform !== 'win32',
55
+ stdio: ['ignore', 'pipe', 'pipe']
56
+ });
57
+ let readySeconds: number | undefined;
58
+ let httpSeconds: number | undefined;
59
+ let failure: string | undefined;
60
+ let closed = false;
61
+ let exitCode: number | null = null;
62
+ let exitSignal: string | null = null;
63
+ let tail = '';
64
+ for (const stream of [child.stdout, child.stderr]) {
65
+ stream.on('data', (chunk: Buffer) => {
66
+ log.write(chunk);
67
+ process.stdout.write(chunk);
68
+ tail = (tail + chunk.toString()).slice(-4096);
69
+ if (
70
+ mode === 'dev' &&
71
+ readySeconds === undefined &&
72
+ /\bready in\s+[\d.]+\s*(?:ms|s)/.test(tail)
73
+ ) {
74
+ readySeconds = elapsed();
75
+ }
76
+ });
77
+ }
78
+ const completion = new Promise<void>((resolve) => {
79
+ child.on('error', (error) => {
80
+ failure = error.message;
81
+ });
82
+ child.on('close', (code, signal) => {
83
+ closed = true;
84
+ exitCode = code;
85
+ exitSignal = signal;
86
+ resolve();
87
+ });
88
+ });
89
+ function stop() {
90
+ if (!child.pid) return;
91
+ try {
92
+ if (process.platform === 'win32') child.kill('SIGTERM');
93
+ else process.kill(-child.pid, 'SIGTERM');
94
+ } catch (error) {
95
+ if ((error as NodeJS.ErrnoException).code !== 'ESRCH') throw error;
96
+ }
97
+ }
98
+ function interrupt() {
99
+ failure = 'Benchmark interrupted';
100
+ gracefulStop();
101
+ }
102
+ process.once('SIGINT', interrupt);
103
+ process.once('SIGTERM', interrupt);
104
+ const timeout = setTimeout(() => {
105
+ failure = `Timed out after ${timeoutSeconds}s`;
106
+ gracefulStop();
107
+ }, timeoutSeconds * 1000);
108
+ let killTimeout: ReturnType<typeof setTimeout> | undefined;
109
+ function gracefulStop() {
110
+ if (killTimeout) return;
111
+ stop();
112
+ killTimeout = setTimeout(
113
+ () => {
114
+ if (!child.pid) return;
115
+ try {
116
+ if (process.platform === 'win32') child.kill('SIGKILL');
117
+ else process.kill(-child.pid, 'SIGKILL');
118
+ } catch (error) {
119
+ if ((error as NodeJS.ErrnoException).code !== 'ESRCH') throw error;
120
+ }
121
+ },
122
+ mode === 'build' &&
123
+ ['parallel', 'isolated-serial'].includes(process.env.WISCONSIN_BUILD_MODE ?? '')
124
+ ? 15_000
125
+ : 2_000
126
+ );
127
+ }
128
+ try {
129
+ if (mode === 'dev') {
130
+ while (!closed && !failure && httpSeconds === undefined) {
131
+ try {
132
+ const response = await fetch(`http://127.0.0.1:${port}/`, {
133
+ signal: AbortSignal.timeout(2000),
134
+ redirect: 'manual'
135
+ });
136
+ const html = await response.text();
137
+ if (
138
+ response.status === 200 &&
139
+ response.headers.get('content-type')?.includes('text/html') &&
140
+ /<h1\b[^>]*>[\s\S]*?<\/h1>/i.test(html) &&
141
+ !html.includes('<vite-error-overlay')
142
+ ) {
143
+ httpSeconds = elapsed();
144
+ }
145
+ } catch {
146
+ /* The server may still be compiling or binding its port. */
147
+ }
148
+ if (httpSeconds === undefined) await new Promise((resolve) => setTimeout(resolve, 100));
149
+ }
150
+ if (httpSeconds === undefined && !failure)
151
+ failure = 'Dev server exited before serving the expected home page';
152
+ gracefulStop();
153
+ }
154
+ await completion;
155
+ if (mode === 'build' && exitCode !== 0 && !failure)
156
+ failure = `Build exited with ${exitCode ?? exitSignal}`;
157
+ } finally {
158
+ clearTimeout(timeout);
159
+ if (killTimeout) clearTimeout(killTimeout);
160
+ process.removeListener('SIGINT', interrupt);
161
+ process.removeListener('SIGTERM', interrupt);
162
+ await new Promise<void>((resolve) => log.end(resolve));
163
+ }
164
+ const result = {
165
+ label,
166
+ mode,
167
+ recordedAt: new Date().toISOString(),
168
+ command: [command, ...args],
169
+ seconds: elapsed(),
170
+ readySeconds,
171
+ firstHttp200Seconds: httpSeconds,
172
+ exitCode,
173
+ exitSignal,
174
+ success: !failure,
175
+ failure,
176
+ host: {
177
+ platform: process.platform,
178
+ architecture: process.arch,
179
+ bun: process.versions.bun,
180
+ nodeCompatibilityVersion: process.versions.node,
181
+ actualNodeVersion,
182
+ cpu: cpus()[0]?.model,
183
+ availableCpus: availableParallelism(),
184
+ memoryBytes: totalmem()
185
+ },
186
+ logPath
187
+ };
188
+ writeFileSync(resultPath, JSON.stringify(result, null, 2) + '\n');
189
+ console.log(`Benchmark saved: ${resultPath}`);
190
+ console.log(JSON.stringify(result, null, 2));
191
+ if (failure) process.exitCode = 1;