@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,116 @@
1
+ import { lstatSync, readFileSync, realpathSync, mkdirSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { wranglerAssetHash } from './deployment-manifest';
4
+ import { sha256 } from './deployment-config';
5
+ import { writeChanged } from './output';
6
+
7
+ export const assetHashAlgorithmVersion = 'wrangler-4.100-blake3-base64-extension-v1';
8
+ export interface AssetHashes {
9
+ hash: string;
10
+ sha256: string;
11
+ size: number;
12
+ }
13
+ type Record = AssetHashes & { stamp: string; extension: string };
14
+ const inventories = new Map<string, Map<string, Record>>();
15
+ const digest = /^[a-f0-9]{64}$/;
16
+ const assetDigest = /^[a-f0-9]{32}$/;
17
+ function inventory(site: string) {
18
+ const root = path.resolve(site);
19
+ let value = inventories.get(root);
20
+ if (!value) {
21
+ value = new Map();
22
+ try {
23
+ const saved = JSON.parse(
24
+ readFileSync(path.join(root, 'build/generated/cache/deployment-hashes.json'), 'utf8')
25
+ );
26
+ if (
27
+ saved.schema === 1 &&
28
+ saved.algorithm === assetHashAlgorithmVersion &&
29
+ Array.isArray(saved.entries)
30
+ ) {
31
+ for (const [file, record] of saved.entries) {
32
+ if (
33
+ typeof file === 'string' &&
34
+ file.startsWith(root + path.sep) &&
35
+ valid(record) &&
36
+ typeof record.stamp === 'string' &&
37
+ typeof record.extension === 'string'
38
+ )
39
+ value.set(file, record);
40
+ }
41
+ }
42
+ } catch {}
43
+ inventories.set(root, value);
44
+ }
45
+ return value;
46
+ }
47
+ function valid(value: AssetHashes) {
48
+ return (
49
+ value &&
50
+ digest.test(value.sha256) &&
51
+ assetDigest.test(value.hash) &&
52
+ Number.isSafeInteger(value.size) &&
53
+ value.size >= 0
54
+ );
55
+ }
56
+ function fileStamp(site: string, file: string) {
57
+ const absolute = path.resolve(file);
58
+ if (!absolute.startsWith(path.resolve(site) + path.sep) || realpathSync(absolute) !== absolute)
59
+ throw new Error('Asset hashing requires an owned regular file');
60
+ const stat = lstatSync(absolute, { bigint: true });
61
+ if (!stat.isFile()) throw new Error('Asset hashing requires a regular file');
62
+ return JSON.stringify(
63
+ [stat.dev, stat.ino, stat.mode, stat.size, stat.mtimeNs, stat.ctimeNs].map(String)
64
+ );
65
+ }
66
+ /** Local acceleration only. This file/stamp inventory must never be transported. */
67
+ export function cachedAssetHashes(site: string, file: string): AssetHashes | undefined {
68
+ try {
69
+ const absolute = path.resolve(file);
70
+ const record = inventory(site).get(absolute);
71
+ // Some Linux filesystems coalesce timestamp updates within a kernel tick,
72
+ // even when stat exposes nanoseconds. Never reuse recently written files.
73
+ const stat = lstatSync(absolute, { bigint: true });
74
+ const newest = stat.mtimeNs > stat.ctimeNs ? stat.mtimeNs : stat.ctimeNs;
75
+ if (BigInt(Date.now()) * 1_000_000n - newest < 1_000_000_000n) return;
76
+ if (
77
+ record &&
78
+ record.extension === path.extname(absolute) &&
79
+ record.stamp === fileStamp(site, absolute)
80
+ )
81
+ return { hash: record.hash, sha256: record.sha256, size: record.size };
82
+ } catch {}
83
+ }
84
+ /** Caller has verified these hashes against an authenticated product's actual bytes. */
85
+ export function rememberVerifiedAsset(site: string, file: string, hashes: AssetHashes) {
86
+ if (!valid(hashes)) throw new Error('Invalid verified asset hashes');
87
+ const absolute = path.resolve(file);
88
+ if (lstatSync(absolute).size !== hashes.size) throw new Error('Verified asset size changed');
89
+ inventory(site).set(absolute, {
90
+ ...hashes,
91
+ stamp: fileStamp(site, absolute),
92
+ extension: path.extname(absolute)
93
+ });
94
+ }
95
+ export function captureAssetHashes(site: string, file: string): AssetHashes {
96
+ const cached = cachedAssetHashes(site, file);
97
+ if (cached) return cached;
98
+ const before = fileStamp(site, file);
99
+ const bytes = readFileSync(file);
100
+ const value = { hash: wranglerAssetHash(bytes, file), sha256: sha256(bytes), size: bytes.length };
101
+ if (before !== fileStamp(site, file)) throw new Error('Asset changed while hashing');
102
+ rememberVerifiedAsset(site, file, value);
103
+ return value;
104
+ }
105
+ export function flushAssetHashCache(site: string) {
106
+ const file = path.join(path.resolve(site), 'build/generated/cache/deployment-hashes.json');
107
+ mkdirSync(path.dirname(file), { recursive: true });
108
+ writeChanged(
109
+ file,
110
+ JSON.stringify({
111
+ schema: 1,
112
+ algorithm: assetHashAlgorithmVersion,
113
+ entries: [...inventory(site)]
114
+ })
115
+ );
116
+ }
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Directory-derived AutoTag, ported verbatim from the wisconsin Quartz fork's
3
+ * `quartz/plugins/transformers/autotag.ts`, minus the Quartz plugin wrapper.
4
+ *
5
+ * Config matches quartz.config.ts: enabled, excludePaths ["assets", "des-inv"],
6
+ * customMapping {}.
7
+ */
8
+ import { slugTag } from './slug';
9
+
10
+ export interface AutoTagOptions {
11
+ excludePaths?: string[];
12
+ customMapping?: Record<string, string>;
13
+ }
14
+
15
+ export const QUARTZ_AUTOTAG_OPTIONS: AutoTagOptions = {
16
+ excludePaths: ['assets', 'des-inv'],
17
+ customMapping: {}
18
+ };
19
+
20
+ interface TagStrategy {
21
+ tag: string;
22
+ scope: 'global' | 'immediate'; // global applies to all descendants, immediate only to direct children
23
+ }
24
+
25
+ function extractCourseCode(directory: string): string {
26
+ return directory.includes('-') ? directory.substring(directory.lastIndexOf('-') + 1) : directory;
27
+ }
28
+
29
+ function extractTerm(directory: string): string | null {
30
+ return directory.includes('-') ? directory.substring(0, directory.indexOf('-')) : null;
31
+ }
32
+
33
+ function extractSubject(courseCode: string): string | null {
34
+ const match = courseCode.match(/^[a-z]+/i);
35
+ return match ? match[0] : null;
36
+ }
37
+
38
+ function generateTagStrategies(
39
+ directory: string,
40
+ customMapping?: Record<string, string>
41
+ ): TagStrategy[] {
42
+ const strategies: TagStrategy[] = [];
43
+ const courseCode = customMapping?.[directory] || extractCourseCode(directory);
44
+
45
+ // Course code tag is global - applies to all nested content
46
+ strategies.push({ tag: slugTag(courseCode), scope: 'global' });
47
+
48
+ // Term tag only applies to immediate children
49
+ const term = extractTerm(directory);
50
+ if (term) {
51
+ strategies.push({ tag: slugTag(term), scope: 'immediate' });
52
+ }
53
+
54
+ // Subject tag only applies to immediate children
55
+ const subject = extractSubject(courseCode);
56
+ if (subject && subject !== courseCode) {
57
+ strategies.push({ tag: slugTag(subject), scope: 'immediate' });
58
+ }
59
+
60
+ return strategies;
61
+ }
62
+
63
+ /**
64
+ * Returns the auto tags for a page given its path relative to content/
65
+ * (e.g. "sp26-cs537/p1/README.md"). The caller appends them to the page's
66
+ * existing tags, deduped, in order — same as the fork.
67
+ */
68
+ export function autoTagsFor(
69
+ relativePath: string,
70
+ opts: AutoTagOptions = QUARTZ_AUTOTAG_OPTIONS
71
+ ): string[] {
72
+ const pathSegments = relativePath.split('/');
73
+ if (pathSegments.length < 2) return [];
74
+
75
+ const firstDir = pathSegments[0];
76
+ if (opts.excludePaths?.includes(firstDir)) return [];
77
+
78
+ // Check if this is an immediate child (only 2 segments) or nested deeper
79
+ const isImmediateChild = pathSegments.length === 2;
80
+
81
+ // Generate tag strategies for this directory
82
+ const strategies = generateTagStrategies(firstDir, opts.customMapping);
83
+
84
+ // Filter strategies based on scope
85
+ return strategies
86
+ .filter((s) => s.scope === 'global' || (s.scope === 'immediate' && isImmediateChild))
87
+ .map((s) => s.tag);
88
+ }
89
+
90
+ /** fork behavior: append new tags after existing ones, skipping duplicates */
91
+ export function applyAutoTags(
92
+ existingTags: string[],
93
+ relativePath: string,
94
+ opts?: AutoTagOptions
95
+ ): string[] {
96
+ const applicableTags = autoTagsFor(relativePath, opts);
97
+ const newTags = applicableTags.filter((tag) => !existingTags.includes(tag));
98
+ return [...existingTags, ...newTags];
99
+ }
@@ -0,0 +1,148 @@
1
+ import {
2
+ existsSync,
3
+ lstatSync,
4
+ mkdirSync,
5
+ mkdtempSync,
6
+ readFileSync,
7
+ readdirSync,
8
+ realpathSync,
9
+ renameSync,
10
+ rmSync,
11
+ writeFileSync
12
+ } from 'node:fs';
13
+ import path from 'node:path';
14
+ import { publicAssetManifest } from './public-assets';
15
+
16
+ function directory(file: string) {
17
+ if (!lstatSync(file).isDirectory()) throw new Error(`Expected build directory: ${file}`);
18
+ }
19
+
20
+ function contained(root: string, file: string) {
21
+ const relative = path.relative(root, path.resolve(root, file));
22
+ if (relative.startsWith('..' + path.sep) || relative === '..' || path.isAbsolute(relative))
23
+ throw new Error(`Build artifact escapes its snapshot: ${file}`);
24
+ return path.resolve(root, file);
25
+ }
26
+
27
+ function rejectSymlinks(root: string) {
28
+ for (const entry of readdirSync(root, { recursive: true, withFileTypes: true }))
29
+ if (entry.isSymbolicLink())
30
+ throw new Error(
31
+ `Public artifact cannot be a symlink: ${path.join(entry.parentPath, entry.name)}`
32
+ );
33
+ }
34
+
35
+ /** Assemble completed isolated editions; only successful promotion changes canonical output. */
36
+ export function assembleBuilds(canonicalSite: string, publicSite: string, fullSite: string) {
37
+ const sites = [canonicalSite, publicSite, fullSite].map((site) => realpathSync(site));
38
+ if (new Set(sites).size !== sites.length)
39
+ throw new Error('Build editions must have separate roots');
40
+ const publicOutput = path.join(publicSite, 'build/.svelte-kit/cloudflare');
41
+ const fullOutput = path.join(fullSite, 'build/.svelte-kit/cloudflare');
42
+ const publicGenerated = path.join(publicSite, 'build/generated');
43
+ const fullGenerated = path.join(fullSite, 'build/generated');
44
+ const trees = ['build/.svelte-kit', 'build/generated', 'src/lib/generated', 'static'];
45
+ for (const tree of trees) directory(path.join(fullSite, tree));
46
+ directory(publicOutput);
47
+ directory(fullOutput);
48
+ rejectSymlinks(publicOutput);
49
+ const pages: Record<string, string> = JSON.parse(
50
+ readFileSync(path.join(publicGenerated, 'public-routes.json'), 'utf8')
51
+ );
52
+ const content: { htmlAssets: string[] } = JSON.parse(
53
+ readFileSync(path.join(publicGenerated, 'content-manifest.json'), 'utf8')
54
+ );
55
+ const entries: { course: string; file: string }[] = JSON.parse(
56
+ readFileSync(path.join(publicSite, 'src/lib/generated/file-entries.json'), 'utf8')
57
+ );
58
+ for (const asset of content.htmlAssets) {
59
+ pages[`/${asset}`] = `${asset}.html`;
60
+ pages[`/${asset}.html`] = `${asset}.html`;
61
+ }
62
+ for (const file of Object.values(pages)) {
63
+ const target = contained(publicOutput, file);
64
+ if (!lstatSync(target).isFile()) throw new Error(`Public page is missing: ${file}`);
65
+ }
66
+ const publicAssets = publicAssetManifest(publicOutput, pages, entries);
67
+ for (const target of new Set(Object.values(publicAssets))) {
68
+ const relative = decodeURIComponent(target.slice('/_published'.length));
69
+ const file = contained(publicOutput, relative.replace(/^\//, '') || 'index.html');
70
+ if (
71
+ ![file, `${file}.html`].some(
72
+ (candidate) => existsSync(candidate) && lstatSync(candidate).isFile()
73
+ )
74
+ )
75
+ throw new Error(`Public asset target is missing: ${target}`);
76
+ }
77
+
78
+ const published = path.join(fullOutput, '_published');
79
+ const transaction = mkdtempSync(path.join(canonicalSite, 'build/.assembly-'));
80
+ const promoted: {
81
+ source: string;
82
+ target: string;
83
+ backup: string;
84
+ replaced: boolean;
85
+ installed: boolean;
86
+ }[] = [];
87
+ let movedPublic = false;
88
+ try {
89
+ rmSync(published, { recursive: true, force: true });
90
+ renameSync(publicOutput, published);
91
+ movedPublic = true;
92
+ for (const name of ['public-routes.json', 'public-assets', 'public-files', 'public-static']) {
93
+ const source = path.join(publicGenerated, name);
94
+ if (!existsSync(source)) continue;
95
+ const target = path.join(fullGenerated, name);
96
+ rmSync(target, { recursive: true, force: true });
97
+ renameSync(source, target);
98
+ }
99
+ writeFileSync(path.join(fullGenerated, 'public-assets.json'), JSON.stringify(publicAssets));
100
+ // These local reuse records contain scratch paths and output inode stamps.
101
+ rmSync(path.join(fullGenerated, 'cache/course-files'), { recursive: true, force: true });
102
+ rmSync(path.join(fullGenerated, 'dev-state.json'), { force: true });
103
+ for (const [index, tree] of trees.entries()) {
104
+ const operation = {
105
+ source: path.join(fullSite, tree),
106
+ target: path.join(canonicalSite, tree),
107
+ backup: path.join(transaction, String(index)),
108
+ replaced: false,
109
+ installed: false
110
+ };
111
+ promoted.push(operation);
112
+ mkdirSync(path.dirname(operation.target), { recursive: true });
113
+ if (existsSync(operation.target)) {
114
+ renameSync(operation.target, operation.backup);
115
+ operation.replaced = true;
116
+ }
117
+ renameSync(operation.source, operation.target);
118
+ operation.installed = true;
119
+ }
120
+ } catch (error) {
121
+ const failures: unknown[] = [];
122
+ for (const operation of promoted.reverse()) {
123
+ try {
124
+ if (operation.installed) renameSync(operation.target, operation.source);
125
+ if (operation.replaced) renameSync(operation.backup, operation.target);
126
+ } catch (rollbackError) {
127
+ failures.push(rollbackError);
128
+ }
129
+ }
130
+ if (movedPublic && existsSync(published)) {
131
+ try {
132
+ renameSync(published, publicOutput);
133
+ } catch (rollbackError) {
134
+ failures.push(rollbackError);
135
+ }
136
+ }
137
+ if (failures.length)
138
+ throw new AggregateError(
139
+ [error, ...failures],
140
+ `Assembly rollback failed; backups retained at ${transaction}`
141
+ );
142
+ rmSync(transaction, { recursive: true, force: true });
143
+ throw error;
144
+ }
145
+ // Canonical .wrangler and build/.wrangler are outside the promoted trees.
146
+ rmSync(transaction, { recursive: true, force: true });
147
+ return { publicPages: Object.keys(pages).length, fileRoutes: entries.length, publicAssets };
148
+ }