@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,407 @@
1
+ import { resolveProject } from './lib/project-config';
2
+ import { mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { spawn, spawnSync } from 'node:child_process';
4
+ import path from 'node:path';
5
+ import { publicAssetManifest } from './lib/public-assets';
6
+ import { buildMode, runEditionBuilds } from './lib/edition-builds';
7
+
8
+ const site = path.resolve(import.meta.dir, '..');
9
+ const options = process.argv.slice(2);
10
+ if (
11
+ options.length > 1 ||
12
+ options.some((value) => !['--app', '--content', '--static'].includes(value))
13
+ )
14
+ throw new Error('Use --app, --content, --static, or no option for the complete SSR build');
15
+ if (options[0] === '--static') {
16
+ process.env.VITE_STATIC_EXPORT = 'true';
17
+ delete process.env.WISCONSIN_APP_STATIC_DIR;
18
+ delete process.env.WISCONSIN_SKIP_CONTENT;
19
+ const mode = buildMode();
20
+ if (mode !== 'serial') await isolatedBuild(mode);
21
+ else serialBuild();
22
+ } else await runtimeBuild(options[0] === '--app');
23
+
24
+ async function command(
25
+ binary: string,
26
+ args: string[],
27
+ environment: NodeJS.ProcessEnv,
28
+ signal: AbortSignal
29
+ ) {
30
+ signal.throwIfAborted();
31
+ const child = spawn(binary, args, {
32
+ cwd: site,
33
+ env: environment,
34
+ stdio: 'inherit',
35
+ detached: process.platform !== 'win32'
36
+ });
37
+ let stopping: Promise<void> | undefined;
38
+ const stop = () => {
39
+ if (stopping || !child.pid) return;
40
+ const send = (kind: NodeJS.Signals) => {
41
+ try {
42
+ if (process.platform === 'win32') child.kill(kind);
43
+ else process.kill(-child.pid!, kind);
44
+ } catch (error) {
45
+ if ((error as NodeJS.ErrnoException).code !== 'ESRCH') throw error;
46
+ }
47
+ };
48
+ send('SIGTERM');
49
+ stopping = new Promise((resolve) =>
50
+ setTimeout(() => {
51
+ send('SIGKILL');
52
+ resolve();
53
+ }, 2000)
54
+ );
55
+ };
56
+ signal.addEventListener('abort', stop, { once: true });
57
+ try {
58
+ await new Promise<void>((resolve, reject) => {
59
+ child.once('error', reject);
60
+ child.once('close', (code, killed) =>
61
+ code === 0 ? resolve() : reject(new Error(`Build command failed (${code ?? killed})`))
62
+ );
63
+ });
64
+ } finally {
65
+ await stopping;
66
+ signal.removeEventListener('abort', stop);
67
+ }
68
+ signal.throwIfAborted();
69
+ }
70
+
71
+ async function runtimeBuild(appOnly: boolean) {
72
+ const started = performance.now();
73
+ const abort = new AbortController();
74
+ const interrupt = () => abort.abort(new Error('Build interrupted'));
75
+ process.on('SIGINT', interrupt);
76
+ process.on('SIGTERM', interrupt);
77
+ let staging: string | undefined;
78
+ try {
79
+ const app = await import('./lib/application-cache');
80
+ const { captureAssetInventory, assetContentType, validateAssetInventory } =
81
+ await import('./lib/deployment-manifest');
82
+ const { captureAssetHashes, flushAssetHashCache } = await import('./lib/asset-hash-cache');
83
+ const { prepareWorker, loadPreparedWorker, buildDeploymentSnapshot } =
84
+ await import('./lib/deployment-prepare');
85
+ const { readDeploymentConfig } = await import('./lib/deployment-config');
86
+ const packaging = await import('./lib/runtime-packaging');
87
+ const { contentInputFingerprint } = await import('./lib/dev-state');
88
+ const environment = app.applicationEnvironment();
89
+ const identity = app.applicationCacheIdentity(site, environment);
90
+ const contentRepo = resolveProject(site).root;
91
+ const contentInputs = appOnly ? '' : contentInputFingerprint(contentRepo, identity.identity);
92
+ let cached = app.loadApplicationCache(site, identity);
93
+ if (cached)
94
+ try {
95
+ validateAssetInventory(
96
+ JSON.parse(
97
+ readFileSync(path.join(cached.directory, 'kit/application-assets.json'), 'utf8')
98
+ ),
99
+ identity.identity
100
+ );
101
+ } catch {
102
+ cached = undefined;
103
+ }
104
+ const appStarted = performance.now();
105
+ if (!cached) {
106
+ mkdirSync(path.join(site, 'build'), { recursive: true });
107
+ const temporary = mkdtempSync(path.join(site, 'build/.application-build-'));
108
+ const previous = path.join(temporary, 'previous');
109
+ const output = path.join(site, 'build/.svelte-kit');
110
+ let moved = false,
111
+ preserve = false;
112
+ try {
113
+ app.prepareApplicationStatic(site, path.join(temporary, 'static'));
114
+ try {
115
+ renameSync(output, previous);
116
+ moved = true;
117
+ } catch (error) {
118
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
119
+ }
120
+ await command(
121
+ 'bun',
122
+ ['x', 'vite', 'build'],
123
+ {
124
+ ...environment,
125
+ WISCONSIN_APP_STATIC_DIR: path.join(temporary, 'static'),
126
+ WISCONSIN_APPLICATION_VERSION: identity.applicationVersion
127
+ },
128
+ abort.signal
129
+ );
130
+ await prepareWorker(site, path.join(output, 'prepared-worker'));
131
+ const inventory = await captureAssetInventory(
132
+ path.join(output, 'cloudflare'),
133
+ identity.identity,
134
+ {
135
+ resolveAsset: async (absolute, filename) => ({
136
+ ...captureAssetHashes(site, absolute),
137
+ contentType: assetContentType(filename)
138
+ })
139
+ }
140
+ );
141
+ writeFileSync(path.join(output, 'application-assets.json'), JSON.stringify(inventory));
142
+ if (app.applicationCacheIdentity(site, environment).identity !== identity.identity)
143
+ throw new Error('Application inputs changed during compilation');
144
+ cached = app.saveApplicationCache(site, identity);
145
+ } finally {
146
+ rmSync(output, { recursive: true, force: true });
147
+ if (moved)
148
+ try {
149
+ renameSync(previous, output);
150
+ } catch (error) {
151
+ preserve = true;
152
+ throw new Error('Previous application output preserved for recovery', { cause: error });
153
+ }
154
+ if (!preserve) rmSync(temporary, { recursive: true, force: true });
155
+ }
156
+ console.log(
157
+ `build: application compiled in ${((performance.now() - appStarted) / 1000).toFixed(2)}s`
158
+ );
159
+ } else
160
+ console.log(
161
+ `build: application reused in ${((performance.now() - appStarted) / 1000).toFixed(2)}s`
162
+ );
163
+ abort.signal.throwIfAborted();
164
+ if (appOnly) {
165
+ flushAssetHashCache(site);
166
+ console.log(`build: app completed in ${((performance.now() - started) / 1000).toFixed(2)}s`);
167
+ return;
168
+ }
169
+ mkdirSync(path.join(site, 'build'), { recursive: true });
170
+ staging = mkdtempSync(path.join(site, 'build/.runtime-build-'));
171
+ const product = path.join(staging, 'kit');
172
+ app.stageApplication(cached!, product);
173
+ const appInventory = validateAssetInventory(
174
+ JSON.parse(readFileSync(path.join(product, 'application-assets.json'), 'utf8')),
175
+ identity.identity
176
+ );
177
+ const editions: import('./lib/runtime-packaging').CapturedEdition[] = [];
178
+ for (const edition of ['public', 'full'] as const) {
179
+ const editionStarted = performance.now();
180
+ const selected = {
181
+ ...environment,
182
+ WISCONSIN_CONTENT_REPO: contentRepo,
183
+ VITE_PUBLIC_EDITION: String(edition === 'public')
184
+ };
185
+ await command('bun', ['tooling/prepare.ts'], selected, abort.signal);
186
+ const search = path.join(staging, `search-${edition}`);
187
+ await command('node', ['tooling/search.js', search], selected, abort.signal);
188
+ editions.push(
189
+ packaging.captureRuntimeEdition(site, edition, path.join(staging, edition), search)
190
+ );
191
+ console.log(
192
+ `build: ${edition} content completed in ${((performance.now() - editionStarted) / 1000).toFixed(2)}s`
193
+ );
194
+ }
195
+ if (
196
+ app.applicationCacheIdentity(site, environment).identity !== identity.identity ||
197
+ contentInputFingerprint(contentRepo, identity.identity) !== contentInputs
198
+ )
199
+ throw new Error(
200
+ 'Source or course content changed during the build; refusing mixed snapshots'
201
+ );
202
+ abort.signal.throwIfAborted();
203
+ const packed = packaging.packageRuntime(
204
+ site,
205
+ path.join(product, 'cloudflare'),
206
+ identity.applicationVersion,
207
+ editions,
208
+ appInventory
209
+ );
210
+ const config = readDeploymentConfig(site);
211
+ const worker = await loadPreparedWorker(path.join(product, 'prepared-worker'), config);
212
+ writeFileSync(
213
+ path.join(product, 'deployment.json'),
214
+ JSON.stringify(buildDeploymentSnapshot(worker, packed.inventory, config, packed.provenance))
215
+ );
216
+ if (
217
+ app.applicationCacheIdentity(site, environment).identity !== identity.identity ||
218
+ contentInputFingerprint(contentRepo, identity.identity) !== contentInputs
219
+ )
220
+ throw new Error(
221
+ 'Source or course content changed during packaging; refusing mixed snapshots'
222
+ );
223
+ abort.signal.throwIfAborted();
224
+ packaging.promoteRuntimeProduct(site, product);
225
+ flushAssetHashCache(site);
226
+ console.log(
227
+ `publishing: ${packed.publicRoutes} public SSR routes; edition data remains private`
228
+ );
229
+ console.log(`build: all completed in ${((performance.now() - started) / 1000).toFixed(2)}s`);
230
+ } finally {
231
+ if (staging) rmSync(staging, { recursive: true, force: true });
232
+ process.removeListener('SIGINT', interrupt);
233
+ process.removeListener('SIGTERM', interrupt);
234
+ }
235
+ }
236
+
237
+ function serialBuild() {
238
+ const output = path.join(site, 'build/.svelte-kit/cloudflare');
239
+ const staged = path.join(site, 'build/generated/public-site');
240
+ const manifestPath = path.join(site, 'build/generated/public-assets.json');
241
+ let publicAssets: Record<string, string> = {};
242
+ mkdirSync(path.dirname(manifestPath), { recursive: true });
243
+ writeFileSync(manifestPath, '{}');
244
+ rmSync(staged, { recursive: true, force: true });
245
+
246
+ const started = performance.now();
247
+
248
+ function build(publicEdition: boolean) {
249
+ const started = performance.now();
250
+ const result = spawnSync('bun', ['x', 'vite', 'build'], {
251
+ cwd: site,
252
+ stdio: 'inherit',
253
+ env: {
254
+ ...process.env,
255
+ NODE_ENV: process.env.NODE_ENV ?? 'production',
256
+ VITE_PUBLIC_EDITION: String(publicEdition)
257
+ }
258
+ });
259
+ if (result.status !== 0)
260
+ throw new Error(`${publicEdition ? 'Public' : 'Full'} build failed`, {
261
+ cause: result.error
262
+ });
263
+ console.log(
264
+ `build: ${publicEdition ? 'public' : 'full'} completed in ${((performance.now() - started) / 1000).toFixed(2)}s`
265
+ );
266
+ }
267
+
268
+ {
269
+ build(true);
270
+ const pages = JSON.parse(
271
+ readFileSync(path.join(site, 'build/generated/public-routes.json'), 'utf8')
272
+ );
273
+ const content = JSON.parse(
274
+ readFileSync(path.join(site, 'build/generated/content-manifest.json'), 'utf8')
275
+ );
276
+ for (const asset of content.htmlAssets) {
277
+ pages[`/${asset}`] = `${asset}.html`;
278
+ pages[`/${asset}.html`] = `${asset}.html`;
279
+ }
280
+ const fileEntries = JSON.parse(
281
+ readFileSync(path.join(site, 'src/lib/generated/file-entries.json'), 'utf8')
282
+ );
283
+ publicAssets = publicAssetManifest(output, pages, fileEntries);
284
+ // Both editions live on the same filesystem. Move the public tree aside
285
+ // before the full adapter recreates its output, without copying every asset.
286
+ renameSync(output, staged);
287
+ console.log(
288
+ `publishing: ${Object.keys(pages).length} public pages; ${fileEntries.length} file routes share one shell; history remains private`
289
+ );
290
+ }
291
+ build(false);
292
+ renameSync(staged, path.join(output, '_published'));
293
+ writeFileSync(manifestPath, JSON.stringify(publicAssets));
294
+
295
+ console.log(`build: all completed in ${((performance.now() - started) / 1000).toFixed(2)}s`);
296
+ }
297
+
298
+ async function isolatedBuild(mode: 'parallel' | 'isolated-serial') {
299
+ const started = performance.now();
300
+ const abort = new AbortController();
301
+ const interrupt = () => abort.abort(new Error('Build interrupted by SIGINT'));
302
+ const terminate = () => abort.abort(new Error('Build interrupted by SIGTERM'));
303
+ process.on('SIGINT', interrupt);
304
+ process.on('SIGTERM', terminate);
305
+ let workspace:
306
+ | { publicSite: string; fullSite: string; cleanup: () => void | Promise<void> }
307
+ | undefined;
308
+ let cleanupSeconds = 0;
309
+ let succeeded = false;
310
+ try {
311
+ const [
312
+ { createBuildWorkspaces, mergeBuildCaches },
313
+ { assembleBuilds },
314
+ { applicationVersion },
315
+ { contentInputFingerprint }
316
+ ] = await Promise.all([
317
+ import('./lib/build-workspace'),
318
+ import('./lib/build-assembly'),
319
+ import('./lib/app-version.js'),
320
+ import('./lib/dev-state')
321
+ ]);
322
+ const contentRepo = resolveProject(site).root;
323
+ const versionEnvironment: NodeJS.ProcessEnv = {
324
+ ...process.env,
325
+ NODE_ENV: process.env.NODE_ENV ?? 'production'
326
+ };
327
+ delete versionEnvironment.WISCONSIN_APPLICATION_VERSION;
328
+ const version = applicationVersion(site, versionEnvironment);
329
+ const contentInputs = contentInputFingerprint(contentRepo, version);
330
+ abort.signal.throwIfAborted();
331
+ workspace = await createBuildWorkspaces(site, contentRepo);
332
+ abort.signal.throwIfAborted();
333
+ const benchmarks = path.join(site, 'build/benchmarks');
334
+ mkdirSync(benchmarks, { recursive: true, mode: 0o700 });
335
+ const logDirectory = mkdtempSync(path.join(benchmarks, 'edition-'));
336
+ const setupSeconds = (performance.now() - started) / 1000;
337
+ console.log(`build: ${mode} setup in ${setupSeconds.toFixed(2)}s; logs: ${logDirectory}`);
338
+ const buildStarted = performance.now();
339
+ const editions = await runEditionBuilds(workspace, {
340
+ mode,
341
+ contentRepo,
342
+ applicationVersion: version,
343
+ logDirectory,
344
+ signal: abort.signal
345
+ });
346
+ const buildSpanSeconds = (performance.now() - buildStarted) / 1000;
347
+ abort.signal.throwIfAborted();
348
+ const validationStarted = performance.now();
349
+ if (
350
+ applicationVersion(site, versionEnvironment) !== version ||
351
+ contentInputFingerprint(contentRepo, version) !== contentInputs
352
+ )
353
+ throw new Error(
354
+ 'Source or course content changed during the build; refusing to assemble mixed editions'
355
+ );
356
+ const validationSeconds = (performance.now() - validationStarted) / 1000;
357
+ const mergeStarted = performance.now();
358
+ mergeBuildCaches(
359
+ workspace.publicSite,
360
+ workspace.fullSite,
361
+ path.join(workspace.fullSite, 'build/generated/cache')
362
+ );
363
+ const cacheMergeSeconds = (performance.now() - mergeStarted) / 1000;
364
+ const assemblyStarted = performance.now();
365
+ const publication = assembleBuilds(site, workspace.publicSite, workspace.fullSite);
366
+ const assemblySeconds = (performance.now() - assemblyStarted) / 1000;
367
+ console.log(
368
+ `publishing: ${publication.publicPages} public pages; ${publication.fileRoutes} file routes share one shell; history remains private`
369
+ );
370
+ const cleanupStarted = performance.now();
371
+ await workspace.cleanup();
372
+ workspace = undefined;
373
+ cleanupSeconds = (performance.now() - cleanupStarted) / 1000;
374
+ const totalSeconds = (performance.now() - started) / 1000;
375
+ console.log(`build: all completed in ${totalSeconds.toFixed(2)}s`);
376
+ console.log(
377
+ `build: metrics ${JSON.stringify({
378
+ mode,
379
+ setupSeconds,
380
+ ...editions,
381
+ buildSpanSeconds,
382
+ validationSeconds,
383
+ cacheMergeSeconds,
384
+ assemblySeconds,
385
+ cleanupSeconds,
386
+ totalSeconds,
387
+ logs: {
388
+ public: path.join(logDirectory, 'public.log'),
389
+ full: path.join(logDirectory, 'full.log')
390
+ }
391
+ })}`
392
+ );
393
+ succeeded = true;
394
+ } finally {
395
+ if (workspace) {
396
+ const cleanupStarted = performance.now();
397
+ await workspace.cleanup();
398
+ cleanupSeconds = (performance.now() - cleanupStarted) / 1000;
399
+ }
400
+ process.removeListener('SIGINT', interrupt);
401
+ process.removeListener('SIGTERM', terminate);
402
+ if (!succeeded)
403
+ console.log(
404
+ `build: ${mode} failed after ${((performance.now() - started) / 1000).toFixed(2)}s; cleanup ${cleanupSeconds.toFixed(2)}s`
405
+ );
406
+ }
407
+ }
@@ -0,0 +1,133 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import {
3
+ appendFileSync,
4
+ cpSync,
5
+ existsSync,
6
+ mkdirSync,
7
+ mkdtempSync,
8
+ readFileSync,
9
+ rmSync,
10
+ writeFileSync
11
+ } from 'node:fs';
12
+ import { tmpdir } from 'node:os';
13
+ import path from 'node:path';
14
+ import { compilerCacheFiles } from './lib/cache-selection';
15
+
16
+ const mode = process.argv[2];
17
+ if (mode === 'plan' || mode === 'save-products' || mode === 'restore-products') {
18
+ const { planProductCaches, transferProductCaches } = await import('./lib/product-cache');
19
+ const { resolve } = await import('node:path');
20
+ const options: import('./lib/product-cache').ProductOptions = {};
21
+ let planFile: string | undefined;
22
+ for (let i = 3; i < process.argv.length; i++) {
23
+ const argument = process.argv[i];
24
+ if (argument === '--restore') options.restore = true;
25
+ else if (argument === '--group') options.group = process.argv[++i];
26
+ else if (argument === '--namespace') {
27
+ const namespace = process.argv[++i];
28
+ if (!['production', 'benchmark'].includes(namespace))
29
+ throw new Error('Invalid product namespace');
30
+ options.namespace = namespace as import('./lib/product-cache').ProductNamespace;
31
+ } else if (argument === '--kind') {
32
+ const kind = process.argv[++i];
33
+ if (!['global', 'course', 'search', 'application'].includes(kind))
34
+ throw new Error('Invalid product kind');
35
+ options.kind = kind as import('./lib/product-cache').ProductKind;
36
+ } else if (argument === '--plan-file') planFile = process.argv[++i];
37
+ else throw new Error('Invalid product-cache argument');
38
+ }
39
+ const result =
40
+ mode === 'plan'
41
+ ? await planProductCaches(process.cwd(), options)
42
+ : await transferProductCaches(
43
+ process.cwd(),
44
+ mode === 'save-products' ? 'save' : 'restore',
45
+ process.env.BUILD_CACHE_KEY,
46
+ options
47
+ );
48
+ const json = JSON.stringify(result);
49
+ if (planFile) {
50
+ mkdirSync(path.dirname(resolve(planFile)), { recursive: true });
51
+ writeFileSync(resolve(planFile), json + '\n', { mode: 0o600 });
52
+ } else console.log(json);
53
+ process.exit(0);
54
+ }
55
+ if (mode !== 'restore' && mode !== 'save') throw new Error('Expected restore or save');
56
+ const secret = process.env.BUILD_CACHE_KEY;
57
+ const cache = path.resolve('build/generated/cache');
58
+ const encrypted = path.resolve('build/generated/compiler-cache.gpg');
59
+ if (!secret) {
60
+ console.log(
61
+ mode === 'restore'
62
+ ? 'BUILD_CACHE_KEY is missing; building cold'
63
+ : 'BUILD_CACHE_KEY is missing; skipping cache save'
64
+ );
65
+ process.exit(0);
66
+ }
67
+ if (mode === 'restore' && !existsSync(encrypted)) {
68
+ console.log('No compatible cache archive was restored; building cold');
69
+ process.exit(0);
70
+ }
71
+
72
+ const temporary = mkdtempSync(path.join(tmpdir(), 'wisconsin-cache-'));
73
+ const archive = path.join(temporary, 'cache.tar.gz');
74
+ const status = path.join(temporary, 'status');
75
+ const env = { ...process.env, GNUPGHOME: temporary };
76
+ const run = (command: string, args: string[]) =>
77
+ execFileSync(command, args, {
78
+ env,
79
+ input: command === 'gpg' ? secret + '\n' : undefined,
80
+ stdio: 'pipe'
81
+ });
82
+ const gpg = [
83
+ '--batch',
84
+ '--yes',
85
+ '--pinentry-mode',
86
+ 'loopback',
87
+ '--no-symkey-cache',
88
+ '--passphrase-fd',
89
+ '0'
90
+ ];
91
+
92
+ try {
93
+ if (mode === 'restore') {
94
+ run('gpg', [...gpg, '--status-file', status, '--output', archive, '--decrypt', encrypted]);
95
+ if (!readFileSync(status, 'utf8').split('\n').includes('[GNUPG:] GOODMDC'))
96
+ throw new Error('Cache integrity was not verified');
97
+ const staged = path.join(temporary, 'unpacked');
98
+ mkdirSync(staged);
99
+ run('tar', ['-xzf', archive, '-C', staged, '--no-same-owner']);
100
+ mkdirSync(cache, { recursive: true });
101
+ cpSync(staged, cache, { recursive: true });
102
+ console.log('Compiler cache archive restored; each stage validates its own inputs');
103
+ } else {
104
+ const files = compilerCacheFiles(cache);
105
+ const list = path.join(temporary, 'files');
106
+ writeFileSync(list, files.map((file) => file + '\0').join(''));
107
+ run('tar', ['-czf', archive, '-C', cache, '--null', '--files-from', list]);
108
+ run('gpg', [
109
+ ...gpg,
110
+ '--symmetric',
111
+ '--cipher-algo',
112
+ 'AES256',
113
+ '--compress-algo',
114
+ 'none',
115
+ '--output',
116
+ encrypted,
117
+ archive
118
+ ]);
119
+ if (process.env.GITHUB_OUTPUT) appendFileSync(process.env.GITHUB_OUTPUT, 'ready=true\n');
120
+ console.log(`Compiler cache encrypted (${files.length} current files)`);
121
+ }
122
+ } catch {
123
+ console.log(
124
+ mode === 'restore'
125
+ ? 'Cache could not be verified; building cold'
126
+ : 'Cache encryption failed; skipping cache save'
127
+ );
128
+ } finally {
129
+ try {
130
+ run('gpgconf', ['--kill', 'gpg-agent']);
131
+ } catch {}
132
+ rmSync(temporary, { recursive: true, force: true });
133
+ }
@@ -0,0 +1,164 @@
1
+ import { resolveProject } from './lib/project-config';
2
+ import { markdownFiles } from './lib/markdown-files';
3
+ export { markdownFiles } from './lib/markdown-files';
4
+ import { preserveCurrency } from './lib/currency';
5
+ import { stripObsidianComments } from './lib/comments';
6
+ /** Validate Markdown math and render Mermaid with the site's installed engines. */
7
+ import { readFileSync } from 'node:fs';
8
+ import path from 'node:path';
9
+ import { fileURLToPath } from 'node:url';
10
+ import { unified } from 'unified';
11
+ import remarkParse from 'remark-parse';
12
+ import remarkGfm from 'remark-gfm';
13
+ import remarkMath from 'remark-math';
14
+ import { visit } from 'unist-util-visit';
15
+ import katex, { type KatexOptions } from 'katex';
16
+ import { chromium, type Browser, type Page } from '@playwright/test';
17
+
18
+ export interface Expression {
19
+ kind: 'math' | 'mermaid';
20
+ value: string;
21
+ line: number;
22
+ column: number;
23
+ display: boolean;
24
+ }
25
+
26
+ export function expressions(markdown: string): Expression[] {
27
+ // Keep original line/column positions while excluding metadata and OFM comments.
28
+ const blank = (s: string) => s.replace(/[^\r\n]/g, ' ');
29
+ const source = stripObsidianComments(
30
+ markdown.replace(/^---\r?\n[\s\S]*?\r?\n(?:---|\.\.\.)(?:\r?\n|$)/, blank),
31
+ true
32
+ );
33
+ const tree = unified().use(remarkParse).use(remarkGfm).use(remarkMath).parse(source);
34
+ preserveCurrency(source)(tree);
35
+ const result: Expression[] = [];
36
+ visit(tree, (node) => {
37
+ if (
38
+ node.type === 'math' ||
39
+ node.type === 'inlineMath' ||
40
+ (node.type === 'code' && node.lang === 'mermaid')
41
+ ) {
42
+ result.push({
43
+ kind: node.type === 'code' ? 'mermaid' : 'math',
44
+ value: node.value,
45
+ line: node.position?.start.line ?? 1,
46
+ column: node.position?.start.column ?? 1,
47
+ display: node.type === 'math'
48
+ });
49
+ }
50
+ });
51
+ return result;
52
+ }
53
+
54
+ export function checkMath(
55
+ expression: Expression,
56
+ macros: NonNullable<KatexOptions['macros']> = {}
57
+ ): void {
58
+ katex.renderToString(expression.value, {
59
+ throwOnError: true,
60
+ displayMode: expression.display,
61
+ output: 'html',
62
+ macros,
63
+ strict: 'ignore' // match the site's permissive rendering of supported extensions
64
+ });
65
+ }
66
+
67
+ export async function mermaidPage(browser: Browser): Promise<Page> {
68
+ const page = await browser.newPage();
69
+ // Notes need no network access to render. Keep any diagram links local/inert.
70
+ await page.route('**/*', (route) => route.abort());
71
+ await page.setContent('<!doctype html><html><body></body></html>');
72
+ const entry = import.meta.resolve('mermaid');
73
+ await page.addScriptTag({
74
+ path: path.join(path.dirname(fileURLToPath(entry)), 'mermaid.min.js')
75
+ });
76
+ return page;
77
+ }
78
+
79
+ export async function checkMermaid(page: Page, source: string): Promise<void> {
80
+ await page.evaluate(async (text) => {
81
+ const mermaid = (window as unknown as { mermaid: typeof import('mermaid').default }).mermaid;
82
+ mermaid.initialize({ startOnLoad: false, securityLevel: 'loose', theme: 'base' });
83
+ await mermaid.parse(text);
84
+ const { svg } = await mermaid.render('validation-diagram', text);
85
+ if (!svg.includes('<svg')) throw new Error('Mermaid produced no SVG');
86
+ document.body.replaceChildren();
87
+ }, source);
88
+ }
89
+
90
+ async function main() {
91
+ const args = process.argv.slice(2);
92
+ if (args.includes('--help')) {
93
+ console.log(
94
+ 'Usage: bun run check:diagrams [Markdown files or Git directories...]\nDefaults to all content, including untracked non-ignored notes.'
95
+ );
96
+ return;
97
+ }
98
+ const content = resolveProject().contentDir;
99
+ const files = markdownFiles(args.length ? args : [content]);
100
+ if (!files.length) throw new Error('No Markdown files found');
101
+ let math = 0,
102
+ mermaid = 0,
103
+ failures = 0;
104
+ let browser: Browser | undefined;
105
+ let page: Page | undefined;
106
+ try {
107
+ for (const file of files) {
108
+ const macros: NonNullable<KatexOptions['macros']> = {};
109
+ for (const expression of expressions(readFileSync(file, 'utf8'))) {
110
+ if (expression.kind === 'mermaid' && !browser) {
111
+ // Browser setup failures are infrastructure errors, not errors in every note.
112
+ browser = await chromium.launch();
113
+ page = await mermaidPage(browser);
114
+ }
115
+ try {
116
+ if (expression.kind === 'math') {
117
+ math++;
118
+ checkMath(expression, macros);
119
+ } else {
120
+ mermaid++;
121
+ // Bound pathological diagrams; replace the page after any failure.
122
+ let timer: ReturnType<typeof setTimeout> | undefined;
123
+ try {
124
+ await Promise.race([
125
+ checkMermaid(page!, expression.value),
126
+ new Promise<never>((_, reject) => {
127
+ timer = setTimeout(
128
+ () => reject(new Error('Mermaid render exceeded 15 seconds')),
129
+ 15_000
130
+ );
131
+ })
132
+ ]);
133
+ } finally {
134
+ clearTimeout(timer);
135
+ }
136
+ }
137
+ } catch (error) {
138
+ failures++;
139
+ const message = error instanceof Error ? error.message : String(error);
140
+ console.error(
141
+ `${path.relative(process.cwd(), file)}:${expression.line}:${expression.column}: ${expression.kind}: ${message.split(/\n\s+at /)[0].replace(/\s+/g, ' ')}`
142
+ );
143
+ if (expression.kind === 'mermaid') {
144
+ await page!.close();
145
+ page = await mermaidPage(browser!);
146
+ }
147
+ }
148
+ }
149
+ }
150
+ } finally {
151
+ await browser?.close();
152
+ }
153
+ console.log(
154
+ `Checked ${files.length} Markdown files: ${math} math expressions, ${mermaid} Mermaid diagrams; ${failures} errors.`
155
+ );
156
+ if (failures) process.exitCode = 1;
157
+ }
158
+
159
+ if (import.meta.main) {
160
+ main().catch((error) => {
161
+ console.error(error instanceof Error ? error.message : String(error));
162
+ process.exitCode = 1;
163
+ });
164
+ }