@twango/twiki 0.0.0-stage → 0.1.1

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,281 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { createHash } from 'node:crypto';
3
+ import {
4
+ existsSync,
5
+ lstatSync,
6
+ mkdirSync,
7
+ readFileSync,
8
+ realpathSync,
9
+ copyFileSync
10
+ } from 'node:fs';
11
+ import path from 'node:path';
12
+ import { writeChanged, pruneOutputs } from './output';
13
+ import type { CourseFile } from '../../src/lib/files';
14
+ import {
15
+ resolveProject,
16
+ declaredContentCourses,
17
+ discoverSourceFiles,
18
+ projectIdentity
19
+ } from './project-config';
20
+ import { slugifyFilePath, type FilePath } from './slug';
21
+ import { buildFileIcons } from './file-icons';
22
+ import { buildHistories, type HistoryJob } from './history-pool';
23
+ import { courseFilesDirectory } from './edition-paths.js';
24
+ import { stageFingerprint } from '../fingerprint';
25
+ import { courseFingerprint, readCourseCache, saveCourseCache } from './course-cache';
26
+ import { publicationFilter, courseLicenseResolver } from './publishing';
27
+ import { buildRmdPreviews } from './rmd-previews';
28
+ import { browsablePath } from './file-policy';
29
+ export { browsablePath, fileHistoryPolicyKey } from './file-policy';
30
+ const inlineImages = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'avif']);
31
+ export const assetLimit = 25 * 1024 * 1024;
32
+
33
+ export function previewText(bytes: Uint8Array): { text: string } | undefined {
34
+ if (Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength).includes(0)) return;
35
+ try {
36
+ const text = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
37
+ if (/[\x00-\x08\x0e-\x1f]/.test(text)) return;
38
+ return { text };
39
+ } catch {
40
+ return;
41
+ }
42
+ }
43
+
44
+ type CourseOutput = { files: CourseFile[]; outputs: Set<string>; notes: string };
45
+ const previousBuilds = new Map<string, Map<string, CourseOutput>>();
46
+
47
+ export async function buildCourseFiles(
48
+ siteDir: string,
49
+ noteSlugs: Set<string>,
50
+ changedInputs?: Set<string>,
51
+ development = false
52
+ ) {
53
+ const project = resolveProject(siteDir);
54
+ const repo = project.root;
55
+ const publicEdition = process.env.VITE_PUBLIC_EDITION === 'true';
56
+ const isPublished = publicationFilter(repo, project);
57
+ const licenseFor = courseLicenseResolver(repo, project);
58
+ const contentRoot = project.contentDir;
59
+ const output = courseFilesDirectory(siteDir);
60
+ const wanted = new Set<string>();
61
+ for (const dir of ['index', 'blobs']) mkdirSync(path.join(output, dir), { recursive: true });
62
+ const courses = new Map(
63
+ declaredContentCourses(project).map((course) => [course.name, [] as CourseFile[]])
64
+ );
65
+ const key = JSON.stringify([siteDir, projectIdentity(project), publicEdition, development]);
66
+ const previous = previousBuilds.get(key);
67
+ const active = new Set(courses.keys());
68
+ const outputByCourse = new Map<string, Set<string>>();
69
+ const noteKeys = new Map(
70
+ [...courses.keys()].map((course) => [
71
+ course,
72
+ JSON.stringify([...noteSlugs].filter((slug) => slug.startsWith(course + '/')).sort())
73
+ ])
74
+ );
75
+ const paths = discoverSourceFiles(project);
76
+ const fingerprints = new Map<string, string>();
77
+ const cacheFile = (course: string) =>
78
+ path.join(
79
+ siteDir,
80
+ 'build/generated/cache/course-files',
81
+ publicEdition ? 'public' : 'full',
82
+ `${course}.json`
83
+ );
84
+ const cacheRoot = path.join(siteDir, 'build/generated/cache');
85
+ const cacheContext = (course: string) => ({
86
+ repo,
87
+ contentRoot,
88
+ course,
89
+ output,
90
+ cache: cacheRoot
91
+ });
92
+ if (!development) {
93
+ const version = `${await stageFingerprint('files')}\0${publicEdition ? 'public' : 'full'}\0${project.publication}\0${project.discovery}`;
94
+ for (const course of courses.keys()) {
95
+ const tracked = paths.filter((file) => file.startsWith(`${course}/`));
96
+ // Let the R renderer validate its installed runtime and worksheet dependencies.
97
+ if (tracked.some((file) => /\.rmd$/i.test(file))) continue;
98
+ const fingerprint = courseFingerprint(
99
+ repo,
100
+ course,
101
+ tracked,
102
+ version,
103
+ noteKeys.get(course)!,
104
+ contentRoot
105
+ );
106
+ fingerprints.set(course, fingerprint);
107
+ const cached = readCourseCache(cacheFile(course), fingerprint, cacheContext(course));
108
+ if (!cached) continue;
109
+ courses.set(course, cached.files);
110
+ outputByCourse.set(course, cached.outputs);
111
+ for (const file of cached.outputs) wanted.add(file);
112
+ active.delete(course);
113
+ }
114
+ }
115
+ if (
116
+ development &&
117
+ previous &&
118
+ changedInputs?.size &&
119
+ [...changedInputs].every((file) => file.startsWith(contentRoot + path.sep))
120
+ ) {
121
+ for (const course of courses.keys()) {
122
+ const cached = previous.get(course);
123
+ if (
124
+ !cached ||
125
+ cached.notes !== noteKeys.get(course) ||
126
+ [...changedInputs].some(
127
+ (file) =>
128
+ file === path.join(contentRoot, course) ||
129
+ file.startsWith(path.join(contentRoot, course) + path.sep)
130
+ )
131
+ )
132
+ continue;
133
+ if (![...cached.outputs].every(existsSync)) continue;
134
+ courses.set(course, cached.files);
135
+ outputByCourse.set(course, cached.outputs);
136
+ for (const file of cached.outputs) wanted.add(file);
137
+ active.delete(course);
138
+ }
139
+ }
140
+ for (const course of active) outputByCourse.set(course, new Set());
141
+ const retain = (course: string, file: string) => {
142
+ wanted.add(file);
143
+ outputByCourse.get(course)!.add(file);
144
+ };
145
+ let count = 0;
146
+ const historyJobs = new Map<string, HistoryJob>(
147
+ (publicEdition || development ? [] : [...active]).map((course) => [
148
+ course,
149
+ {
150
+ repo: path.join(contentRoot, course),
151
+ output,
152
+ cache: path.join(siteDir, 'build/generated/cache/file-history', course),
153
+ files: []
154
+ }
155
+ ])
156
+ );
157
+ const revisions = new Map(
158
+ (development && !publicEdition ? [...active] : []).map((course) => [
159
+ course,
160
+ execFileSync('git', ['-C', path.join(contentRoot, course), 'rev-parse', 'HEAD'], {
161
+ encoding: 'utf8'
162
+ }).trim()
163
+ ])
164
+ );
165
+ for (const tracked of paths) {
166
+ const relative = tracked;
167
+ const [course, ...segments] = relative.split('/');
168
+ const files = courses.get(course);
169
+ if (!files || !active.has(course) || !browsablePath(segments.join('/'))) continue;
170
+ const source = path.join(contentRoot, tracked);
171
+ if (!existsSync(source) || !lstatSync(source).isFile()) continue;
172
+ if (realpathSync(source) !== path.join(contentRoot, relative)) continue;
173
+ const size = lstatSync(source).size;
174
+ const file: CourseFile = {
175
+ path: segments.join('/'),
176
+ size,
177
+ kind: 'binary',
178
+ license: licenseFor(relative)
179
+ };
180
+ const extension = path.extname(source).slice(1).toLowerCase();
181
+ const slug = slugifyFilePath(relative as FilePath);
182
+ if (extension === 'md' && noteSlugs.has(slug))
183
+ file.note = '/' + (slug.endsWith('/index') ? slug.slice(0, -6) : slug);
184
+ file.locked =
185
+ publicEdition && (!isPublished(relative) || (extension === 'md' && !noteSlugs.has(slug)));
186
+ files.push(file);
187
+ count++;
188
+ if (file.locked) {
189
+ delete file.license;
190
+ continue;
191
+ }
192
+ if (size > assetLimit) continue;
193
+ const bytes = readFileSync(source);
194
+ const hash = createHash('sha256').update(bytes).digest('hex');
195
+ const suffix = extension === 'pdf' ? 'pdf' : inlineImages.has(extension) ? extension : 'bin';
196
+ const blob = `${hash}.${suffix}`;
197
+ file.download = `/_files/blobs/${blob}`;
198
+ retain(course, path.join(output, 'blobs', blob));
199
+ if (!existsSync(path.join(output, 'blobs', blob)))
200
+ copyFileSync(source, path.join(output, 'blobs', blob));
201
+ if (suffix === 'pdf') file.kind = 'pdf';
202
+ else if (inlineImages.has(suffix)) file.kind = 'image';
203
+ else if (previewText(bytes)) file.kind = 'text';
204
+ file.history =
205
+ development && !publicEdition
206
+ ? `/__content/history?course=${encodeURIComponent(course)}&file=${encodeURIComponent(file.path)}&v=${revisions.get(course)}-${hash}`
207
+ : undefined;
208
+ historyJobs.get(course)?.files.push({ file, source: path.join(output, 'blobs', blob) });
209
+ }
210
+ await buildHistories(
211
+ [...historyJobs.values()].filter((job) => job.files.length),
212
+ (job, file) => retain(path.basename(job.repo), file)
213
+ );
214
+ const entries: { course: string; file: string }[] = [];
215
+ for (const [course, files] of courses) {
216
+ if (publicEdition && !files.length) continue;
217
+ if (active.has(course))
218
+ await buildRmdPreviews(siteDir, course, files, output, (file) => retain(course, file));
219
+ retain(course, path.join(output, 'index', `${course}.json`));
220
+ writeChanged(path.join(output, 'index', `${course}.json`), JSON.stringify(files));
221
+ if (publicEdition) {
222
+ const paths = new Set(['']);
223
+ for (const file of files) {
224
+ const segments = file.path.split('/');
225
+ for (let i = 1; i <= segments.length; i++) paths.add(segments.slice(0, i).join('/'));
226
+ }
227
+ entries.push(...[...paths].map((file) => ({ course, file })));
228
+ }
229
+ }
230
+ pruneOutputs(output, wanted, (file) => file === path.join(output, 'icons'));
231
+ if (!publicEdition && !development) {
232
+ const history = [...outputByCourse].flatMap(([course, outputs]) =>
233
+ [...outputs]
234
+ .filter((file) => path.dirname(file) === path.join(output, 'history'))
235
+ .map((file) => `file-history/${course}/${path.basename(file)}`)
236
+ );
237
+ // Keep transport selection portable when a fresh runner restores only the cache.
238
+ writeChanged(
239
+ path.join(siteDir, 'build/generated/cache/history-current.json'),
240
+ JSON.stringify([...new Set(history)].sort())
241
+ );
242
+ }
243
+ mkdirSync(path.join(siteDir, 'src/lib/generated'), { recursive: true });
244
+ writeChanged(path.join(siteDir, 'src/lib/generated/file-entries.json'), JSON.stringify(entries));
245
+ buildFileIcons(
246
+ siteDir,
247
+ [...courses.values()].flatMap((files) => files.map((file) => file.path))
248
+ );
249
+ for (const course of active) {
250
+ const fingerprint = fingerprints.get(course);
251
+ if (fingerprint)
252
+ saveCourseCache(
253
+ cacheFile(course),
254
+ fingerprint,
255
+ courses.get(course)!,
256
+ outputByCourse.get(course)!,
257
+ cacheContext(course)
258
+ );
259
+ }
260
+ if (!development)
261
+ writeChanged(
262
+ path.join(cacheRoot, `course-files-current-${publicEdition ? 'public' : 'full'}.json`),
263
+ JSON.stringify(
264
+ [...fingerprints.keys()]
265
+ .map((course) => `course-files/${publicEdition ? 'public' : 'full'}/${course}.json`)
266
+ .sort()
267
+ )
268
+ );
269
+ previousBuilds.set(
270
+ key,
271
+ new Map(
272
+ [...courses].map(([course, files]) => [
273
+ course,
274
+ { files, outputs: outputByCourse.get(course)!, notes: noteKeys.get(course)! }
275
+ ])
276
+ )
277
+ );
278
+ console.log(
279
+ `files: ${count} tracked files across ${active.size} changed courses (${courses.size - active.size} reused)`
280
+ );
281
+ }
@@ -0,0 +1,33 @@
1
+ import type { Root } from 'mdast';
2
+ import type { InlineMath } from 'mdast-util-math';
3
+ import { visit } from 'unist-util-visit';
4
+
5
+ /** Recognize paired prices that remark-math would silently consume as inline math. */
6
+ export function isCurrencyMath(node: InlineMath, source: string): boolean {
7
+ const start = node.position?.start.offset;
8
+ const end = node.position?.end.offset;
9
+ if (start == null || end == null) return false;
10
+ const raw = source.slice(start, end);
11
+ // Only single-dollar spans starting with an amount and ending at the next amount.
12
+ // Explicit LaTeX and ordinary $2 + 3$ / $x$ expressions remain unchanged.
13
+ if (!/^\$\d/.test(raw) || !/^\d/.test(source.slice(end))) return false;
14
+ if (/[\\\r\n]/.test(node.value)) return false;
15
+ return (
16
+ (/\s\$$/.test(raw) && /\b[A-Za-z]{2,}\b/.test(node.value)) || /^[\d.,\s–—-]+$/.test(node.value)
17
+ );
18
+ }
19
+
20
+ /** Restore the exact literal text, including both dollar signs and all spacing. */
21
+ export function preserveCurrency(source: string) {
22
+ return (tree: Root) => {
23
+ visit(tree, 'inlineMath', (node, index, parent) => {
24
+ if (parent && index != null && isCurrencyMath(node, source)) {
25
+ parent.children[index] = {
26
+ type: 'text',
27
+ value: source.slice(node.position!.start.offset!, node.position!.end.offset!),
28
+ position: node.position
29
+ };
30
+ }
31
+ });
32
+ };
33
+ }
@@ -0,0 +1,222 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { readFileSync } from 'node:fs';
3
+ import path from 'node:path';
4
+ import ts from 'typescript';
5
+
6
+ export interface DeploymentConfig {
7
+ name: string;
8
+ main: string;
9
+ compatibility_date: string;
10
+ compatibility_flags: string[];
11
+ vars: Record<string, string>;
12
+ d1_databases: {
13
+ binding: string;
14
+ database_name: string;
15
+ database_id: string;
16
+ migrations_dir?: string;
17
+ }[];
18
+ r2_buckets?: { binding: string; bucket_name: string; jurisdiction?: string }[];
19
+ assets: {
20
+ binding: string;
21
+ directory: string;
22
+ html_handling: 'drop-trailing-slash';
23
+ not_found_handling: '404-page';
24
+ run_worker_first: true;
25
+ };
26
+ workers_dev: boolean;
27
+ preview_urls: false;
28
+ routes: { pattern: string; custom_domain: true }[];
29
+ account_id?: string;
30
+ minify?: boolean;
31
+ upload_source_maps?: boolean;
32
+ }
33
+
34
+ export function deploymentError(message: string): never {
35
+ // Messages deliberately exclude API bodies, credentials, private paths, and module contents.
36
+ throw new Error(`deployment: ${message}`);
37
+ }
38
+
39
+ export function record(value: unknown): value is Record<string, unknown> {
40
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
41
+ }
42
+
43
+ export function allowedKeys(value: Record<string, unknown>, allowed: string[]): void {
44
+ if (Object.keys(value).some((key) => !allowed.includes(key)))
45
+ deploymentError('unsupported configuration');
46
+ }
47
+
48
+ export function sha256(bytes: Uint8Array | string): string {
49
+ return createHash('sha256').update(bytes).digest('hex');
50
+ }
51
+
52
+ function canonical(value: unknown): unknown {
53
+ if (Array.isArray(value)) return value.map(canonical);
54
+ if (record(value))
55
+ return Object.fromEntries(
56
+ Object.keys(value)
57
+ .sort()
58
+ .map((key) => [key, canonical(value[key])])
59
+ );
60
+ return value;
61
+ }
62
+
63
+ export function configSha256(config: DeploymentConfig): string {
64
+ return sha256(JSON.stringify(canonical(config)));
65
+ }
66
+
67
+ export function validateDeploymentConfig(value: unknown): DeploymentConfig {
68
+ if (!record(value)) deploymentError('invalid configuration');
69
+ allowedKeys(value, [
70
+ '$schema',
71
+ 'name',
72
+ 'main',
73
+ 'compatibility_date',
74
+ 'compatibility_flags',
75
+ 'vars',
76
+ 'd1_databases',
77
+ 'r2_buckets',
78
+ 'assets',
79
+ 'workers_dev',
80
+ 'preview_urls',
81
+ 'routes',
82
+ 'account_id',
83
+ 'minify',
84
+ 'upload_source_maps'
85
+ ]);
86
+ if (
87
+ typeof value.name !== 'string' ||
88
+ !/^[a-z0-9][a-z0-9_-]*$/.test(value.name) ||
89
+ typeof value.main !== 'string' ||
90
+ !value.main
91
+ )
92
+ deploymentError('invalid Worker identity');
93
+ if (
94
+ typeof value.compatibility_date !== 'string' ||
95
+ !/^\d{4}-\d{2}-\d{2}$/.test(value.compatibility_date) ||
96
+ !Array.isArray(value.compatibility_flags) ||
97
+ value.compatibility_flags.some((flag) => typeof flag !== 'string')
98
+ )
99
+ deploymentError('invalid compatibility settings');
100
+ if (
101
+ !record(value.vars) ||
102
+ Object.entries(value.vars).some(([key, v]) => !bindingName(key) || typeof v !== 'string')
103
+ )
104
+ deploymentError('unsupported variables');
105
+ if (!Array.isArray(value.d1_databases)) deploymentError('missing D1 configuration');
106
+ for (const database of value.d1_databases) {
107
+ if (!record(database)) deploymentError('invalid D1 configuration');
108
+ allowedKeys(database, ['binding', 'database_name', 'database_id', 'migrations_dir']);
109
+ if (
110
+ !bindingName(database.binding) ||
111
+ typeof database.database_name !== 'string' ||
112
+ typeof database.database_id !== 'string' ||
113
+ !/^[0-9a-f-]{36}$/.test(database.database_id) ||
114
+ (database.migrations_dir !== undefined && typeof database.migrations_dir !== 'string')
115
+ )
116
+ deploymentError('invalid D1 configuration');
117
+ }
118
+ if (value.r2_buckets !== undefined) {
119
+ if (!Array.isArray(value.r2_buckets)) deploymentError('invalid R2 configuration');
120
+ for (const bucket of value.r2_buckets) {
121
+ if (!record(bucket)) deploymentError('invalid R2 configuration');
122
+ allowedKeys(bucket, ['binding', 'bucket_name', 'jurisdiction']);
123
+ if (
124
+ !bindingName(bucket.binding) ||
125
+ typeof bucket.bucket_name !== 'string' ||
126
+ !bucket.bucket_name ||
127
+ (bucket.jurisdiction !== undefined && typeof bucket.jurisdiction !== 'string')
128
+ )
129
+ deploymentError('invalid R2 configuration');
130
+ }
131
+ }
132
+ if (!record(value.assets)) deploymentError('missing asset configuration');
133
+ allowedKeys(value.assets, [
134
+ 'binding',
135
+ 'directory',
136
+ 'html_handling',
137
+ 'not_found_handling',
138
+ 'run_worker_first'
139
+ ]);
140
+ if (
141
+ !bindingName(value.assets.binding) ||
142
+ typeof value.assets.directory !== 'string' ||
143
+ !value.assets.directory ||
144
+ value.assets.run_worker_first !== true ||
145
+ value.assets.html_handling !== 'drop-trailing-slash' ||
146
+ value.assets.not_found_handling !== '404-page'
147
+ )
148
+ deploymentError('unsupported asset routing');
149
+ if (
150
+ typeof value.workers_dev !== 'boolean' ||
151
+ value.preview_urls !== false ||
152
+ (value.workers_dev && value.vars.TWIKI_AUTH_MODE !== 'none')
153
+ )
154
+ deploymentError('public development endpoints must be disabled');
155
+ if (!Array.isArray(value.routes) || (!value.routes.length && !value.workers_dev))
156
+ deploymentError('missing custom domains');
157
+ for (const route of value.routes) {
158
+ if (!record(route)) deploymentError('invalid custom domain');
159
+ allowedKeys(route, ['pattern', 'custom_domain']);
160
+ if (
161
+ route.custom_domain !== true ||
162
+ typeof route.pattern !== 'string' ||
163
+ !/^[a-z0-9.-]+$/.test(route.pattern) ||
164
+ !route.pattern.includes('.')
165
+ )
166
+ deploymentError('unsupported custom domain');
167
+ }
168
+ if (
169
+ new Set(value.routes.map((route) => (route as { pattern: string }).pattern)).size !==
170
+ value.routes.length
171
+ )
172
+ deploymentError('duplicate custom domains');
173
+ if (
174
+ value.account_id !== undefined &&
175
+ (typeof value.account_id !== 'string' || !/^[0-9a-f]{32}$/.test(value.account_id))
176
+ )
177
+ deploymentError('invalid account identity');
178
+ for (const key of ['minify', 'upload_source_maps'])
179
+ if (value[key] !== undefined && typeof value[key] !== 'boolean')
180
+ deploymentError('invalid build settings');
181
+ const config = { ...value };
182
+ delete config.$schema;
183
+ const names = [
184
+ ...Object.keys(value.vars),
185
+ ...value.d1_databases.map((db) => (db as { binding: string }).binding),
186
+ ...((value.r2_buckets ?? []) as { binding: string }[]).map((bucket) => bucket.binding),
187
+ value.assets.binding
188
+ ];
189
+ if (new Set(names).size !== names.length) deploymentError('duplicate bindings');
190
+ return config as unknown as DeploymentConfig;
191
+ }
192
+
193
+ export function bindingName(value: unknown): value is string {
194
+ return typeof value === 'string' && /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(value);
195
+ }
196
+
197
+ export function readDeploymentConfig(siteRoot: string): DeploymentConfig {
198
+ const parsed = ts.parseConfigFileTextToJson(
199
+ 'wrangler.jsonc',
200
+ readFileSync(path.join(siteRoot, 'wrangler.jsonc'), 'utf8')
201
+ );
202
+ if (parsed.error) deploymentError('invalid JSONC configuration');
203
+ return validateDeploymentConfig(parsed.config);
204
+ }
205
+
206
+ export function expectedBindings(config: DeploymentConfig): Record<string, unknown>[] {
207
+ return [
208
+ ...Object.entries(config.vars).map(([name, text]) => ({ name, type: 'plain_text', text })),
209
+ ...config.d1_databases.map(({ binding: name, database_id: id }) => ({ name, type: 'd1', id })),
210
+ ...(config.r2_buckets ?? []).map(({ binding: name, bucket_name, jurisdiction }) => ({
211
+ name,
212
+ type: 'r2_bucket',
213
+ bucket_name,
214
+ ...(jurisdiction === undefined ? {} : { jurisdiction })
215
+ })),
216
+ { name: config.assets.binding, type: 'assets' }
217
+ ];
218
+ }
219
+
220
+ export function equalJSON(a: unknown, b: unknown): boolean {
221
+ return JSON.stringify(canonical(a)) === JSON.stringify(canonical(b));
222
+ }