@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,293 @@
1
+ import {
2
+ configSha256,
3
+ deploymentError,
4
+ equalJSON,
5
+ record,
6
+ type DeploymentConfig
7
+ } from './deployment-config';
8
+ import {
9
+ ASSET_HASH,
10
+ validateAssetInventory,
11
+ verifyAssetBytes,
12
+ type DeploymentAsset
13
+ } from './deployment-manifest';
14
+ import {
15
+ validatePreparedWorker,
16
+ verifyModuleBytes,
17
+ type DeploymentSnapshot,
18
+ type PreparedModule
19
+ } from './deployment-prepare';
20
+
21
+ export interface DeploymentAPI {
22
+ request(endpoint: string, init?: RequestInit, bearer?: string): Promise<unknown>;
23
+ }
24
+
25
+ /** Official API, bounded retry, sanitized errors. Caller must explicitly invoke --upload. */
26
+ export class CloudflareDeploymentAPI implements DeploymentAPI {
27
+ constructor(
28
+ private readonly token: string,
29
+ private readonly fetcher: (input: string, init: RequestInit) => Promise<Response> = fetch,
30
+ private readonly sleep: (milliseconds: number) => Promise<void> = (milliseconds) =>
31
+ new Promise((resolve) => setTimeout(resolve, milliseconds))
32
+ ) {
33
+ if (!token || /[\r\n]/.test(token)) deploymentError('missing API credential');
34
+ }
35
+
36
+ async request(endpoint: string, init: RequestInit = {}, bearer = this.token): Promise<unknown> {
37
+ if (!endpoint.startsWith('/accounts/') || endpoint.includes('://') || /[\r\n]/.test(endpoint))
38
+ deploymentError('invalid API endpoint');
39
+ for (let attempt = 0; attempt < 4; attempt++) {
40
+ let response: Response;
41
+ try {
42
+ response = await this.fetcher(`https://api.cloudflare.com/client/v4${endpoint}`, {
43
+ ...init,
44
+ redirect: 'error',
45
+ signal: AbortSignal.timeout(30_000),
46
+ headers: {
47
+ ...Object.fromEntries(new Headers(init.headers)),
48
+ Authorization: `Bearer ${bearer}`
49
+ }
50
+ });
51
+ } catch {
52
+ if (attempt === 3) deploymentError('API transport failed');
53
+ await this.sleep(Math.min(1000 * 2 ** attempt, 10_000));
54
+ continue;
55
+ }
56
+ if (response.status === 429 || response.status >= 500) {
57
+ if (attempt === 3) deploymentError(`API transient failure (${response.status})`);
58
+ const retryAfter = response.headers.get('Retry-After');
59
+ const seconds = retryAfter === null ? NaN : Number(retryAfter);
60
+ const delay = Number.isFinite(seconds)
61
+ ? seconds * 1000
62
+ : retryAfter
63
+ ? Date.parse(retryAfter) - Date.now()
64
+ : NaN;
65
+ await response.body?.cancel();
66
+ await this.sleep(
67
+ Math.max(0, Math.min(Number.isFinite(delay) ? delay : 1000 * 2 ** attempt, 30_000))
68
+ );
69
+ continue;
70
+ }
71
+ if (!response.ok) deploymentError(`API request failed (${response.status})`);
72
+ let envelope: unknown;
73
+ try {
74
+ envelope = await response.json();
75
+ } catch {
76
+ deploymentError('invalid API response');
77
+ }
78
+ if (!record(envelope) || envelope.success !== true || !('result' in envelope))
79
+ deploymentError('API operation failed');
80
+ // Domain listings must be complete; fail instead of silently accepting a truncated collection.
81
+ if (
82
+ /\/workers\/domains(?:\?|$)/.test(endpoint) &&
83
+ record(envelope.result_info) &&
84
+ typeof envelope.result_info.total_pages === 'number' &&
85
+ envelope.result_info.total_pages > 1
86
+ )
87
+ deploymentError('paginated domain listing unsupported');
88
+ return envelope.result;
89
+ }
90
+ deploymentError('API retries exhausted');
91
+ }
92
+ }
93
+
94
+ export interface DeploymentBodyProvider {
95
+ readModule(module: PreparedModule): Promise<Uint8Array>;
96
+ readAsset(filename: string, asset: DeploymentAsset): Promise<Uint8Array>;
97
+ }
98
+
99
+ function jsonRequest(method: string, body: unknown): RequestInit {
100
+ return { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) };
101
+ }
102
+
103
+ async function verifyRouting(
104
+ api: DeploymentAPI,
105
+ accountPrefix: string,
106
+ workerPrefix: string,
107
+ config: DeploymentConfig
108
+ ): Promise<void> {
109
+ const [domains, subdomain] = await Promise.all([
110
+ api.request(
111
+ `${accountPrefix}/workers/domains?service=${encodeURIComponent(config.name)}&environment=production`
112
+ ),
113
+ api.request(`${workerPrefix}/subdomain`)
114
+ ]);
115
+ if (
116
+ !Array.isArray(domains) ||
117
+ domains.some(
118
+ (domain) =>
119
+ !record(domain) ||
120
+ typeof domain.hostname !== 'string' ||
121
+ domain.service !== config.name ||
122
+ domain.environment !== 'production'
123
+ )
124
+ )
125
+ deploymentError('invalid custom domain response');
126
+ if (
127
+ !equalJSON(
128
+ domains.map((domain) => domain.hostname).sort(),
129
+ config.routes.map((route) => route.pattern).sort()
130
+ )
131
+ )
132
+ deploymentError('custom domains differ; explicit route provisioning is required');
133
+ if (!record(subdomain) || subdomain.enabled !== false || subdomain.previews_enabled !== false)
134
+ deploymentError('remote development endpoints must already be disabled');
135
+ }
136
+
137
+ export interface UploadDeploymentOptions extends DeploymentBodyProvider {
138
+ api: DeploymentAPI;
139
+ accountId: string;
140
+ expectedProvenance: string;
141
+ config: DeploymentConfig;
142
+ log?: (message: string) => void;
143
+ }
144
+
145
+ /** Upload requested bodies only. An authenticated cached inventory can supply the complete manifest. */
146
+ export async function uploadDeployment(
147
+ snapshot: DeploymentSnapshot,
148
+ options: UploadDeploymentOptions
149
+ ): Promise<{ uploadedAssets: number; versionId?: string }> {
150
+ const { api, config } = options;
151
+ if (
152
+ !/^[0-9a-f]{32}$/.test(options.accountId) ||
153
+ (config.account_id && config.account_id !== options.accountId)
154
+ )
155
+ deploymentError('account identity mismatch');
156
+ if (
157
+ snapshot.schema !== 1 ||
158
+ snapshot.provenance !== options.expectedProvenance ||
159
+ snapshot.configSha256 !== configSha256(config)
160
+ )
161
+ deploymentError('stale deployment snapshot');
162
+ const inventory = validateAssetInventory(snapshot.inventory, options.expectedProvenance);
163
+ const worker = validatePreparedWorker(snapshot.worker, config);
164
+ const modules: [PreparedModule, Uint8Array][] = [];
165
+ // Validate every Worker dependency before performing any remote mutation.
166
+ for (const module of worker.modules) {
167
+ let bytes: Uint8Array;
168
+ try {
169
+ bytes = await options.readModule(module);
170
+ } catch {
171
+ deploymentError('prepared Worker module is unavailable');
172
+ }
173
+ verifyModuleBytes(module, bytes);
174
+ modules.push([module, bytes]);
175
+ }
176
+ const accountPrefix = `/accounts/${options.accountId}`;
177
+ const workerPrefix = `${accountPrefix}/workers/scripts/${encodeURIComponent(config.name)}`;
178
+ await verifyRouting(api, accountPrefix, workerPrefix, config);
179
+ const manifest = Object.fromEntries(
180
+ Object.entries(inventory.assets).map(([filename, { hash, size }]) => [filename, { hash, size }])
181
+ );
182
+ const byHash = new Map<string, [string, DeploymentAsset]>();
183
+ for (const [filename, asset] of Object.entries(inventory.assets)) {
184
+ const existing = byHash.get(asset.hash);
185
+ if (
186
+ existing &&
187
+ (existing[1].sha256 !== asset.sha256 ||
188
+ existing[1].size !== asset.size ||
189
+ existing[1].contentType !== asset.contentType)
190
+ )
191
+ deploymentError('ambiguous asset hash');
192
+ byHash.set(asset.hash, [filename, asset]);
193
+ }
194
+ const session = await api.request(
195
+ `${workerPrefix}/assets-upload-session`,
196
+ jsonRequest('POST', { manifest })
197
+ );
198
+ if (
199
+ !record(session) ||
200
+ typeof session.jwt !== 'string' ||
201
+ !session.jwt ||
202
+ !Array.isArray(session.buckets)
203
+ )
204
+ deploymentError('invalid asset upload session');
205
+ const buckets: string[][] = [];
206
+ const seen = new Set<string>();
207
+ for (const bucket of session.buckets) {
208
+ if (!Array.isArray(bucket) || !bucket.length) deploymentError('invalid requested asset bucket');
209
+ for (const hash of bucket) {
210
+ if (typeof hash !== 'string' || !ASSET_HASH.test(hash) || !byHash.has(hash) || seen.has(hash))
211
+ deploymentError('invalid requested asset hash');
212
+ seen.add(hash);
213
+ }
214
+ buckets.push(bucket);
215
+ }
216
+ const uploadToken = session.jwt;
217
+ let completionToken = buckets.length === 0 ? uploadToken : '';
218
+ let cursor = 0;
219
+ let failed = false;
220
+ async function uploadBuckets(): Promise<void> {
221
+ while (!failed && cursor < buckets.length) {
222
+ const bucket = buckets[cursor++];
223
+ try {
224
+ const payload = new FormData();
225
+ for (const hash of bucket) {
226
+ const [filename, asset] = byHash.get(hash)!;
227
+ let bytes: Uint8Array;
228
+ try {
229
+ bytes = await options.readAsset(filename, asset);
230
+ } catch {
231
+ deploymentError('requested asset body is unavailable');
232
+ }
233
+ verifyAssetBytes(filename, asset, bytes);
234
+ payload.append(
235
+ hash,
236
+ new File([Buffer.from(bytes).toString('base64')], hash, { type: asset.contentType }),
237
+ hash
238
+ );
239
+ }
240
+ const result = await api.request(
241
+ `${accountPrefix}/workers/assets/upload?base64=true`,
242
+ { method: 'POST', body: payload },
243
+ uploadToken
244
+ );
245
+ if (
246
+ !record(result) ||
247
+ (result.jwt !== undefined && (typeof result.jwt !== 'string' || !result.jwt))
248
+ )
249
+ deploymentError('invalid asset completion response');
250
+ if (typeof result.jwt === 'string') {
251
+ if (completionToken && completionToken !== result.jwt)
252
+ deploymentError('conflicting asset completion tokens');
253
+ completionToken = result.jwt;
254
+ }
255
+ } catch (error) {
256
+ failed = true;
257
+ throw error;
258
+ }
259
+ }
260
+ }
261
+ const results = await Promise.allSettled(
262
+ Array.from({ length: Math.min(3, buckets.length) }, uploadBuckets)
263
+ );
264
+ for (const result of results) if (result.status === 'rejected') throw result.reason;
265
+ if (!completionToken) deploymentError('asset upload did not complete');
266
+ const metadata = {
267
+ ...worker.metadata,
268
+ keep_bindings: ['secret_text', 'secret_key'],
269
+ assets: {
270
+ jwt: completionToken,
271
+ config: {
272
+ html_handling: config.assets.html_handling,
273
+ not_found_handling: config.assets.not_found_handling,
274
+ run_worker_first: true,
275
+ ...inventory.controlFiles
276
+ }
277
+ }
278
+ };
279
+ const payload = new FormData();
280
+ payload.set('metadata', JSON.stringify(metadata));
281
+ for (const [module, bytes] of modules)
282
+ payload.set(module.name, new File([bytes as BlobPart], module.name, { type: module.type }));
283
+ const deployment = await api.request(`${workerPrefix}?bindings_inherit=strict`, {
284
+ method: 'PUT',
285
+ body: payload
286
+ });
287
+ if (!record(deployment)) deploymentError('invalid Worker deployment result');
288
+ // Verify endpoint/domain state again; never create, remove, or retarget domains silently.
289
+ await verifyRouting(api, accountPrefix, workerPrefix, config);
290
+ const versionId = typeof deployment.version_id === 'string' ? deployment.version_id : undefined;
291
+ options.log?.(`deployment: uploaded ${seen.size} asset bodies; Worker API accepted deployment`);
292
+ return { uploadedAssets: seen.size, ...(versionId ? { versionId } : {}) };
293
+ }
@@ -0,0 +1,130 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { existsSync, watch, type FSWatcher } from 'node:fs';
3
+ import path from 'node:path';
4
+ import {
5
+ resolveProject,
6
+ declaredContentCourses,
7
+ gitAvailable,
8
+ contentWithinProject
9
+ } from './project-config';
10
+
11
+ export interface ContentRepository {
12
+ directory: string;
13
+ head: string;
14
+ gitDirectory: string;
15
+ commonDirectory: string;
16
+ index: string;
17
+ config: string;
18
+ worktreeConfig: string;
19
+ tracked: Buffer;
20
+ }
21
+
22
+ /** Read local Git metadata; never initialize submodules or fetch remote content. */
23
+ export function contentGit(...args: string[]) {
24
+ return execFileSync('git', args, { maxBuffer: 128 * 1024 * 1024 });
25
+ }
26
+
27
+ /** Course directories declared by the root, matching compiler discovery without loading renderers. */
28
+ export function declaredCourseDirectories(repo: string): string[] {
29
+ return declaredContentCourses(resolveProject(process.cwd(), repo)).map(
30
+ (course) => course.directory
31
+ );
32
+ }
33
+
34
+ /** Follow initialized declared courses and indexed gitlinks, including nested projects. */
35
+ export function contentRepositories(repo: string): ContentRepository[] {
36
+ const project = resolveProject(process.cwd(), repo);
37
+ if (project.discovery !== 'git') return [];
38
+ const root = path.resolve(repo);
39
+ const pending = [
40
+ ...(contentWithinProject(project) && gitAvailable(root)
41
+ ? [root]
42
+ : gitAvailable(project.contentDir)
43
+ ? [project.contentDir]
44
+ : []),
45
+ ...declaredCourseDirectories(root).filter((directory) =>
46
+ existsSync(path.join(directory, '.git'))
47
+ )
48
+ ];
49
+ const repositories: ContentRepository[] = [];
50
+ const visited = new Set<string>();
51
+ for (let position = 0; position < pending.length; position++) {
52
+ const directory = pending[position];
53
+ if (visited.has(directory)) continue;
54
+ visited.add(directory);
55
+ const [gitDirectory, commonDirectory, index, config, worktreeConfig] = contentGit(
56
+ '-C',
57
+ directory,
58
+ 'rev-parse',
59
+ '--path-format=absolute',
60
+ '--git-dir',
61
+ '--git-common-dir',
62
+ '--git-path',
63
+ 'index',
64
+ '--git-path',
65
+ 'config',
66
+ '--git-path',
67
+ 'config.worktree'
68
+ )
69
+ .toString()
70
+ .trimEnd()
71
+ .split('\n');
72
+ let head = 'unborn';
73
+ try {
74
+ head = contentGit('-C', directory, 'rev-parse', '--verify', '--quiet', 'HEAD')
75
+ .toString()
76
+ .trim();
77
+ } catch {
78
+ /* Empty repositories still have tracked content. */
79
+ }
80
+ const tracked = contentGit(
81
+ '-C',
82
+ directory,
83
+ 'ls-files',
84
+ '-z',
85
+ '--stage',
86
+ ...(directory === root ? ['--', path.relative(root, project.contentDir) || '.'] : [])
87
+ );
88
+ repositories.push({
89
+ directory,
90
+ head,
91
+ gitDirectory,
92
+ commonDirectory,
93
+ index,
94
+ config,
95
+ worktreeConfig,
96
+ tracked
97
+ });
98
+ for (const entry of tracked.toString().split('\0')) {
99
+ if (!entry.startsWith('160000 ')) continue;
100
+ const file = entry.slice(entry.indexOf('\t') + 1);
101
+ const nested = path.join(directory, file);
102
+ if (existsSync(path.join(nested, '.git'))) pending.push(nested);
103
+ }
104
+ }
105
+ return repositories;
106
+ }
107
+
108
+ type GitDirectoryWatch = (
109
+ directory: string,
110
+ options: { recursive: boolean },
111
+ listener: (event: string, file: string | Buffer | null) => void
112
+ ) => FSWatcher;
113
+
114
+ /** Missing metadata directories can race removal; resource or permission failures must remain visible. */
115
+ export function watchContentGitDirectory(
116
+ directory: string,
117
+ recursive: boolean,
118
+ files: () => Set<string>,
119
+ invalidate: () => void,
120
+ watchDirectory: GitDirectoryWatch = watch
121
+ ) {
122
+ try {
123
+ return watchDirectory(directory, { recursive }, (_event, file) => {
124
+ // Node may omit the filename, so conservatively reconcile the repository.
125
+ if (file === null || recursive || files().has(String(file))) invalidate();
126
+ });
127
+ } catch (error) {
128
+ if (!['ENOENT', 'ENOTDIR'].includes((error as NodeJS.ErrnoException).code ?? '')) throw error;
129
+ }
130
+ }
@@ -0,0 +1,86 @@
1
+ import { existsSync, readFileSync, realpathSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import type { IncomingMessage, ServerResponse } from 'node:http';
4
+ import type { CourseFile } from '../../src/lib/files';
5
+ import { assetLimit, browsablePath, fileHistoryPolicyKey } from './course-files';
6
+ import { createFileHistoryBuilder } from './file-history';
7
+ import { parseGitmodules } from './lastmod';
8
+
9
+ const assetPrefix = '/__content/history-assets';
10
+
11
+ /** Dev-only: create history when its panel is opened, outside the generated static tree. */
12
+ export function serveDevHistory(
13
+ request: IncomingMessage,
14
+ response: ServerResponse,
15
+ site: string,
16
+ repo: string
17
+ ) {
18
+ const url = new URL(request.url ?? '/', 'http://localhost');
19
+ if (url.pathname !== '/__content/history' && !url.pathname.startsWith(assetPrefix + '/'))
20
+ return false;
21
+ response.setHeader('Cache-Control', 'no-store');
22
+ if (process.env.VITE_PUBLIC_EDITION === 'true') {
23
+ response.statusCode = 404;
24
+ response.end();
25
+ return true;
26
+ }
27
+ if (request.method !== 'GET' && request.method !== 'HEAD') {
28
+ response.statusCode = 405;
29
+ response.end();
30
+ return true;
31
+ }
32
+ const output = path.join(site, 'build/generated/dev-history');
33
+ try {
34
+ let target: string;
35
+ if (url.pathname.startsWith(assetPrefix + '/')) {
36
+ const name = url.pathname.slice(assetPrefix.length + 1);
37
+ if (!/^[a-f0-9]{64}(?:-(?:blame|[a-f0-9]{40,64}))?\.(?:json|diff)$/.test(name))
38
+ throw new Error('Not found');
39
+ target = path.join(output, 'history', name);
40
+ } else {
41
+ const course = url.searchParams.get('course') ?? '';
42
+ const file = url.searchParams.get('file') ?? '';
43
+ const module = parseGitmodules(path.join(repo, '.gitmodules')).find(
44
+ (entry) => entry.path === `content/${course}`
45
+ );
46
+ if (!module || !/^[\w-]+$/.test(course) || !browsablePath(file)) throw new Error('Not found');
47
+ const index: CourseFile[] = JSON.parse(
48
+ readFileSync(
49
+ path.join(site, 'build/generated/assets/_files/index', `${course}.json`),
50
+ 'utf8'
51
+ )
52
+ );
53
+ const entry = index.find(
54
+ (entry) =>
55
+ entry.path === file && !entry.locked && entry.history?.startsWith('/__content/history?')
56
+ );
57
+ if (!entry) throw new Error('Not found');
58
+ const source = path.join(module.fullPath, file);
59
+ if (realpathSync(source) !== source) throw new Error('Not found');
60
+ const bytes = readFileSync(source);
61
+ if (bytes.length > assetLimit) throw new Error('Not found');
62
+ const build = createFileHistoryBuilder(
63
+ module.fullPath,
64
+ output,
65
+ path.join(site, 'build/generated/cache/file-history', course),
66
+ browsablePath,
67
+ fileHistoryPolicyKey(),
68
+ undefined,
69
+ assetPrefix
70
+ );
71
+ const asset = build?.(entry, bytes);
72
+ if (!asset) throw new Error('Not found');
73
+ target = path.join(output, 'history', path.basename(asset));
74
+ }
75
+ if (!existsSync(target)) throw new Error('Not found');
76
+ response.setHeader(
77
+ 'Content-Type',
78
+ target.endsWith('.json') ? 'application/json' : 'text/plain; charset=utf-8'
79
+ );
80
+ response.end(request.method === 'HEAD' ? undefined : readFileSync(target));
81
+ } catch {
82
+ response.statusCode = 404;
83
+ response.end('History unavailable');
84
+ }
85
+ return true;
86
+ }
@@ -0,0 +1,38 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import type { IncomingMessage, ServerResponse } from 'node:http';
4
+ import type { ContentManifest } from '../../src/lib/types';
5
+ import { socialImagePath } from '../../src/lib/social-image';
6
+ import { cachedSocialImage, defaultSocialCard, socialCard } from './social-images';
7
+
8
+ export async function serveDevSocialImage(
9
+ request: IncomingMessage,
10
+ response: ServerResponse,
11
+ site: string
12
+ ) {
13
+ const url = new URL(request.url ?? '/', 'http://localhost');
14
+ if (!url.pathname.startsWith('/_og/')) return false;
15
+ response.setHeader('Cache-Control', 'no-store');
16
+ if (request.method !== 'GET' && request.method !== 'HEAD') {
17
+ response.statusCode = 405;
18
+ response.end();
19
+ return true;
20
+ }
21
+ const manifest: ContentManifest = JSON.parse(
22
+ readFileSync(path.join(site, 'build/generated/content-manifest.json'), 'utf8')
23
+ );
24
+ const page = Object.values(manifest.pages).find(
25
+ (page) => socialImagePath(page.slug) === url.pathname
26
+ );
27
+ const card =
28
+ url.pathname === socialImagePath() ? defaultSocialCard : page ? socialCard(page) : undefined;
29
+ if (!card) {
30
+ response.statusCode = 404;
31
+ response.end();
32
+ return true;
33
+ }
34
+ const { cached } = await cachedSocialImage(site, card);
35
+ response.setHeader('Content-Type', 'image/png');
36
+ response.end(request.method === 'HEAD' ? undefined : readFileSync(cached));
37
+ return true;
38
+ }
@@ -0,0 +1,107 @@
1
+ import { createHash } from 'node:crypto';
2
+ import {
3
+ existsSync,
4
+ lstatSync,
5
+ readFileSync,
6
+ readdirSync,
7
+ readlinkSync,
8
+ type BigIntStats
9
+ } from 'node:fs';
10
+ import path from 'node:path';
11
+ import { contentGit, contentRepositories } from './dev-git-state';
12
+ import {
13
+ resolveProject,
14
+ discoverSourceFiles,
15
+ projectIdentity,
16
+ gitAvailable,
17
+ contentWithinProject
18
+ } from './project-config';
19
+
20
+ function fileStat(file: string): BigIntStats | null {
21
+ try {
22
+ return lstatSync(file, { bigint: true });
23
+ } catch (error) {
24
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null;
25
+ throw error;
26
+ }
27
+ }
28
+
29
+ function stamp(file: string, stat = fileStat(file)) {
30
+ if (!stat) return null;
31
+ return [
32
+ String(stat.mode),
33
+ String(stat.ino),
34
+ String(stat.size),
35
+ String(stat.mtimeNs),
36
+ String(stat.ctimeNs),
37
+ stat.isSymbolicLink() ? readlinkSync(file) : ''
38
+ ];
39
+ }
40
+
41
+ /** Validate tracked paths, working-copy edits, index changes and Git dates without reading large assets. */
42
+ export function contentInputFingerprint(repo: string, pipeline: string) {
43
+ const project = resolveProject(process.cwd(), repo);
44
+ const hash = createHash('sha256').update(pipeline).update(projectIdentity(project));
45
+ for (const file of discoverSourceFiles(project)) {
46
+ if (project.discovery === 'filesystem') {
47
+ const bytes = readFileSync(path.join(project.contentDir, file));
48
+ hash.update(JSON.stringify([file, bytes.length])).update(bytes);
49
+ } else hash.update(JSON.stringify([file, stamp(path.join(project.contentDir, file))]));
50
+ }
51
+ if (project.configFile) hash.update(readFileSync(project.configFile));
52
+ const modules = path.join(repo, '.gitmodules');
53
+ hash.update(existsSync(modules) ? readFileSync(modules) : '');
54
+ for (const repository of contentRepositories(repo)) {
55
+ const { directory, head, tracked, config, worktreeConfig } = repository;
56
+ hash.update(directory).update(head).update(tracked);
57
+ // Registration and worktree-specific config can change recursive visibility
58
+ // without changing a commit or any tracked working-copy file.
59
+ hash.update(JSON.stringify([stamp(config), stamp(worktreeConfig)]));
60
+ const modules = path.join(directory, '.gitmodules');
61
+ hash.update(existsSync(modules) ? readFileSync(modules) : '');
62
+ for (const entry of tracked.toString().split('\0').filter(Boolean)) {
63
+ const file = entry.slice(entry.indexOf('\t') + 1);
64
+ hash.update(JSON.stringify([file, stamp(path.join(directory, file))]));
65
+ }
66
+ }
67
+ // Git's recursive index is authoritative about which initialized submodules
68
+ // are enabled, including nested registration changes.
69
+ if (project.discovery === 'git' && contentWithinProject(project) && gitAvailable(repo))
70
+ hash.update(
71
+ contentGit(
72
+ '-C',
73
+ repo,
74
+ 'ls-files',
75
+ '-z',
76
+ '--stage',
77
+ '--recurse-submodules',
78
+ '--',
79
+ path.relative(repo, project.contentDir) || '.'
80
+ )
81
+ );
82
+ return hash.digest('hex');
83
+ }
84
+
85
+ /** A production build or missing/modified output invalidates the saved dev result. */
86
+ export function contentOutputFingerprint(site: string) {
87
+ const hash = createHash('sha256');
88
+ function walk(file: string) {
89
+ const stat = fileStat(file);
90
+ if (stat?.isDirectory()) {
91
+ for (const name of readdirSync(file).sort()) walk(path.join(file, name));
92
+ } else hash.update(JSON.stringify([path.relative(site, file), stamp(file, stat)]));
93
+ }
94
+ for (const file of [
95
+ 'build/generated/content-manifest.json',
96
+ 'build/generated/expected-404.json',
97
+ 'build/generated/expected-missing-id.json',
98
+ 'build/generated/public-routes.json',
99
+ 'build/generated/public-assets.json',
100
+ 'build/generated/pages',
101
+ 'build/generated/assets',
102
+ 'src/lib/generated',
103
+ 'static'
104
+ ])
105
+ walk(path.join(site, file));
106
+ return hash.digest('hex');
107
+ }