@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,386 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import {
3
+ constants,
4
+ cpSync,
5
+ existsSync,
6
+ lstatSync,
7
+ mkdirSync,
8
+ mkdtempSync,
9
+ readFileSync,
10
+ readdirSync,
11
+ readlinkSync,
12
+ realpathSync,
13
+ renameSync,
14
+ rmSync,
15
+ statSync,
16
+ symlinkSync,
17
+ writeFileSync
18
+ } from 'node:fs';
19
+ import path from 'node:path';
20
+ import { compilerCacheFiles, historyCachePath } from './cache-selection';
21
+
22
+ const copyOptions = {
23
+ recursive: true,
24
+ mode: constants.COPYFILE_FICLONE,
25
+ preserveTimestamps: true,
26
+ verbatimSymlinks: true
27
+ };
28
+ const parserPath = /^stage1\/[a-f0-9]{2}\/[a-f0-9]{64}\.json$/;
29
+ const socialPath = /^social-titles\/[a-f0-9]{64}\.png$/;
30
+
31
+ function within(root: string, file: string) {
32
+ const relative = path.relative(root, file);
33
+ return relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
34
+ }
35
+
36
+ function maintained(file: string) {
37
+ return !(
38
+ /^(?:build|node_modules|\.git|\.svelte-kit|\.wrangler|dist|test-results|playwright-report|tests|docs|benchmarks)(?:\/|$)/.test(
39
+ file
40
+ ) ||
41
+ file.startsWith('src/lib/generated/') ||
42
+ /^vite\.config\..*timestamp-/.test(file)
43
+ );
44
+ }
45
+
46
+ /** Git supplies maintained static assets; Git-free fixtures retain the standard hand-placed assets. */
47
+ function sourceFiles(site: string) {
48
+ let files: string[];
49
+ try {
50
+ files = execFileSync(
51
+ 'git',
52
+ ['-C', site, 'ls-files', '--cached', '--others', '--exclude-standard', '-z', '--', '.'],
53
+ { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: 32 * 1024 * 1024 }
54
+ ).split('\0');
55
+ } catch {
56
+ files = [];
57
+ const walk = (directory: string) => {
58
+ if (!existsSync(path.join(site, directory))) return;
59
+ for (const entry of readdirSync(path.join(site, directory), { withFileTypes: true })) {
60
+ const file = `${directory}/${entry.name}`;
61
+ if (!maintained(file)) continue;
62
+ if (entry.isDirectory()) walk(file);
63
+ else files.push(file);
64
+ }
65
+ };
66
+ for (const directory of ['src', 'tooling', 'worker', 'assets', 'database', 'migrations'])
67
+ walk(directory);
68
+ for (const entry of readdirSync(site, { withFileTypes: true }))
69
+ if (entry.isFile() && maintained(entry.name) && !entry.name.startsWith('.dev.vars'))
70
+ files.push(entry.name);
71
+ files.push('.dev.vars.example', 'static/.gitignore', 'static/favicon.png');
72
+ walk('static/fonts');
73
+ }
74
+ // Vite loads ignored environment files too. Both editions must see the same bytes.
75
+ for (const entry of readdirSync(site, { withFileTypes: true }))
76
+ if (entry.name.startsWith('.env') && !entry.isDirectory()) files.push(entry.name);
77
+ return [...new Set(files.filter((file) => file && maintained(file)))].sort();
78
+ }
79
+
80
+ function copySource(site: string, destination: string, files: string[]) {
81
+ const root = realpathSync(site);
82
+ for (const file of files) {
83
+ const source = path.resolve(site, file);
84
+ if (!within(path.resolve(site), source)) throw new Error('Invalid site source path');
85
+ const info = lstatSync(source, { throwIfNoEntry: false });
86
+ if (!info) continue; // Tracked files deleted in the working tree are absent from its snapshot.
87
+ if (!within(root, realpathSync(source)) || !statSync(source).isFile())
88
+ throw new Error(`Site source must be an owned regular file: ${file}`);
89
+ const target = path.join(destination, file);
90
+ mkdirSync(path.dirname(target), { recursive: true });
91
+ cpSync(source, target, {
92
+ recursive: true,
93
+ mode: constants.COPYFILE_FICLONE,
94
+ preserveTimestamps: true,
95
+ dereference: true
96
+ });
97
+ }
98
+ }
99
+
100
+ function copyDependencies(site: string, destination: string) {
101
+ const source = path.join(site, 'node_modules');
102
+ if (!existsSync(source)) return;
103
+ const root = realpathSync(source);
104
+ const target = path.join(destination, 'node_modules');
105
+ cpSync(root, target, {
106
+ ...copyOptions,
107
+ filter: (file) => !['.vite', '.vite-temp', '.cache'].includes(path.relative(root, file))
108
+ });
109
+ // Keep relative package/bin links, but turn internal absolute links into portable links.
110
+ // External workspace links would silently share source or writable dependency files.
111
+ for (const entry of readdirSync(root, { recursive: true, withFileTypes: true })) {
112
+ if (!entry.isSymbolicLink()) continue;
113
+ const original = path.join(entry.parentPath, entry.name);
114
+ const relative = path.relative(root, original);
115
+ const copied = path.join(target, relative);
116
+ if (!existsSync(copied) && !lstatSync(copied, { throwIfNoEntry: false })) continue;
117
+ const resolved = realpathSync(original);
118
+ if (!within(root, resolved)) throw new Error('Dependency symlink leaves the installed tree');
119
+ if (path.isAbsolute(readlinkSync(original))) {
120
+ rmSync(copied);
121
+ symlinkSync(path.relative(path.dirname(original), resolved), copied);
122
+ }
123
+ }
124
+ }
125
+
126
+ /** Create independent edition trees without touching previous canonical build output. */
127
+ export async function createBuildWorkspaces(site: string, contentRepo: string) {
128
+ const started = performance.now();
129
+ site = path.resolve(site);
130
+ const runs = path.join(site, 'build/edition-runs');
131
+ mkdirSync(runs, { recursive: true });
132
+ const directory = mkdtempSync(path.join(runs, 'run-'));
133
+ const publicSite = path.join(directory, 'public/site');
134
+ const fullSite = path.join(directory, 'full/site');
135
+ const cleanup = () => rmSync(directory, { recursive: true, force: true });
136
+ try {
137
+ mkdirSync(fullSite, { recursive: true });
138
+ copySource(site, fullSite, sourceFiles(site));
139
+ copyDependencies(site, fullSite);
140
+ const sourceCache = path.join(site, 'build/generated/cache');
141
+ if (existsSync(sourceCache)) {
142
+ for (const file of compilerCacheFiles(sourceCache)) {
143
+ const target = path.join(fullSite, 'build/generated/cache', file);
144
+ mkdirSync(path.dirname(target), { recursive: true });
145
+ cpSync(path.join(sourceCache, file), target, copyOptions);
146
+ }
147
+ }
148
+ // Clone the captured tree, rather than rereading a potentially changing source inventory.
149
+ // Public course catalogs never build history. Git dates and worksheet caches
150
+ // remain shared inputs; private history is seeded only into the full edition.
151
+ const history = path.join(fullSite, 'build/generated/cache/file-history');
152
+ const historyManifest = path.join(fullSite, 'build/generated/cache/history-current.json');
153
+ cpSync(fullSite, publicSite, {
154
+ ...copyOptions,
155
+ filter: (file) => file !== historyManifest && !within(history, file)
156
+ });
157
+ return {
158
+ directory,
159
+ publicSite,
160
+ fullSite,
161
+ contentRepo: path.resolve(contentRepo),
162
+ setupSeconds: (performance.now() - started) / 1000,
163
+ cleanup
164
+ };
165
+ } catch (error) {
166
+ cleanup();
167
+ throw error;
168
+ }
169
+ }
170
+
171
+ function regular(cache: string, file: string) {
172
+ try {
173
+ const root = realpathSync(cache);
174
+ const absolute = path.join(root, file);
175
+ return (
176
+ within(root, absolute) && lstatSync(absolute).isFile() && realpathSync(absolute) === absolute
177
+ );
178
+ } catch {
179
+ return false;
180
+ }
181
+ }
182
+
183
+ function manifest(cache: string, name: string, pattern: RegExp): string[] | undefined {
184
+ try {
185
+ if (!regular(cache, name)) throw new Error('Missing current manifest');
186
+ const files: unknown = JSON.parse(readFileSync(path.join(cache, name), 'utf8'));
187
+ if (
188
+ !Array.isArray(files) ||
189
+ !files.every((file) => typeof file === 'string' && pattern.test(file))
190
+ )
191
+ throw new Error('Invalid current manifest');
192
+ return [...new Set(files.filter((file) => regular(cache, file)))].sort();
193
+ } catch {
194
+ console.log(`No valid ${name}; omitting its entries from merged cache`);
195
+ return undefined;
196
+ }
197
+ }
198
+
199
+ /** Reuse an already disposable full cache; canonical promotion belongs to build assembly. */
200
+ function reconcileFullCache(
201
+ cache: string,
202
+ entries: Map<string, string>,
203
+ manifests: Map<string, string[]>
204
+ ) {
205
+ if (realpathSync(cache) !== cache)
206
+ throw new Error('Disposable cache must not have symlink ancestors');
207
+ const wanted = new Set([...entries.keys(), ...manifests.keys()]);
208
+ const checked = new Set([cache]);
209
+ // A public-only addition must not write through an untrusted seeded directory.
210
+ for (const file of wanted) {
211
+ let directory = path.dirname(path.join(cache, file));
212
+ while (!checked.has(directory)) {
213
+ const info = lstatSync(directory, { throwIfNoEntry: false });
214
+ if (info && (!info.isDirectory() || realpathSync(directory) !== directory))
215
+ throw new Error('Disposable cache entry must have owned directory ancestors');
216
+ checked.add(directory);
217
+ directory = path.dirname(directory);
218
+ }
219
+ }
220
+ const transaction = mkdtempSync(path.join(path.dirname(cache), '.cache-merge-'));
221
+ try {
222
+ const replacements: string[] = [];
223
+ // Prepare every addition before mutating the disposable full cache. Retain
224
+ // existing identical files, including public cards inherited from the seed.
225
+ for (const [file, source] of entries) {
226
+ const target = path.join(cache, file);
227
+ if (
228
+ path.resolve(source) === target ||
229
+ (regular(cache, file) && readFileSync(source).equals(readFileSync(target)))
230
+ )
231
+ continue;
232
+ const staged = path.join(transaction, file);
233
+ mkdirSync(path.dirname(staged), { recursive: true });
234
+ cpSync(source, staged, copyOptions);
235
+ replacements.push(file);
236
+ }
237
+ for (const [name, files] of manifests) {
238
+ writeFileSync(path.join(transaction, name), JSON.stringify(files));
239
+ replacements.push(name);
240
+ }
241
+ for (const file of replacements) {
242
+ const target = path.join(cache, file);
243
+ mkdirSync(path.dirname(target), { recursive: true });
244
+ renameSync(path.join(transaction, file), target);
245
+ }
246
+ const retainedDirectories = new Set<string>();
247
+ for (const file of wanted) {
248
+ let directory = path.posix.dirname(file);
249
+ while (directory !== '.') {
250
+ retainedDirectories.add(directory);
251
+ directory = path.posix.dirname(directory);
252
+ }
253
+ }
254
+ function prune(directory: string) {
255
+ for (const entry of readdirSync(path.join(cache, directory), { withFileTypes: true })) {
256
+ const file = directory ? `${directory}/${entry.name}` : entry.name;
257
+ if (wanted.has(file)) continue;
258
+ if (entry.isDirectory() && retainedDirectories.has(file)) prune(file);
259
+ else rmSync(path.join(cache, file), { recursive: true, force: true });
260
+ }
261
+ }
262
+ prune('');
263
+ } finally {
264
+ rmSync(transaction, { recursive: true, force: true });
265
+ }
266
+ }
267
+
268
+ /** Merge only selected entries into a staged cache; the caller promotes it after both builds pass. */
269
+ export function mergeBuildCaches(publicSite: string, fullSite: string, targetCache: string) {
270
+ const publicCache = path.join(publicSite, 'build/generated/cache');
271
+ const fullCache = path.join(fullSite, 'build/generated/cache');
272
+ const entries = new Map<string, string>();
273
+ const manifests = new Map<string, string[]>();
274
+ const add = (cache: string, file: string) => {
275
+ const source = path.join(cache, file);
276
+ const previous = entries.get(file);
277
+ if (previous && previous !== source && !readFileSync(previous).equals(readFileSync(source)))
278
+ throw new Error(`Conflicting immutable build cache entry: ${file}`);
279
+ entries.set(file, source);
280
+ };
281
+ const parsers = [publicCache, fullCache].map((cache) =>
282
+ manifest(cache, 'stage1-current.json', parserPath)
283
+ );
284
+ for (let index = 0; index < parsers.length; index++)
285
+ for (const file of parsers[index] ?? []) add(index === 0 ? publicCache : fullCache, file);
286
+ if (parsers.some((files) => files !== undefined))
287
+ manifests.set(
288
+ 'stage1-current.json',
289
+ [...new Set(parsers.flatMap((files) => files ?? []))].sort()
290
+ );
291
+ for (const [edition, cache] of [
292
+ ['public', publicCache],
293
+ ['full', fullCache]
294
+ ] as const) {
295
+ const name = `social-titles-current-${edition}.json`;
296
+ const files = manifest(cache, name, socialPath);
297
+ if (files) {
298
+ manifests.set(name, files);
299
+ for (const file of files) add(cache, file);
300
+ }
301
+ }
302
+ const histories = manifest(fullCache, 'history-current.json', historyCachePath);
303
+ if (histories) manifests.set('history-current.json', histories);
304
+ for (const cache of [publicCache, fullCache]) {
305
+ if (!existsSync(cache)) continue;
306
+ const files = compilerCacheFiles(cache);
307
+ // Full history/revision logs and Git dates are authoritative mutable metadata.
308
+ if (cache === fullCache)
309
+ for (const file of files)
310
+ if (file.startsWith('file-history/') || file.startsWith('gitdates-v3-')) add(cache, file);
311
+ for (const record of files.filter((file) => /^rmd\/[^/]+\/result\.json$/.test(file))) {
312
+ const directory = path.posix.dirname(record);
313
+ let blobs: string[];
314
+ try {
315
+ const saved = JSON.parse(readFileSync(path.join(cache, record), 'utf8'));
316
+ if (
317
+ typeof saved.preview !== 'string' ||
318
+ !Array.isArray(saved.blobs) ||
319
+ !saved.blobs.includes(saved.preview) ||
320
+ !saved.blobs.every(
321
+ (blob: unknown) =>
322
+ typeof blob === 'string' &&
323
+ /^[a-f0-9]{64}\.[a-z]+$/.test(blob) &&
324
+ regular(cache, `${directory}/${blob}`)
325
+ )
326
+ )
327
+ throw new Error('Incomplete R preview cache');
328
+ blobs = saved.blobs;
329
+ } catch {
330
+ continue; // An incomplete worksheet cache safely renders again.
331
+ }
332
+ add(cache, record);
333
+ for (const blob of blobs) add(cache, `${directory}/${blob}`);
334
+ }
335
+ }
336
+
337
+ targetCache = path.resolve(targetCache);
338
+ const existing = lstatSync(targetCache, { throwIfNoEntry: false });
339
+ if (existing && !existing.isDirectory())
340
+ throw new Error('Cache merge target must be an owned directory');
341
+ if (existing && targetCache === path.resolve(fullCache)) {
342
+ // This tree is discarded on any error and promoted only after both editions
343
+ // pass. Recopying all of its immutable files adds no canonical protection.
344
+ reconcileFullCache(targetCache, entries, manifests);
345
+ return { files: entries.size + manifests.size };
346
+ }
347
+ mkdirSync(path.dirname(targetCache), { recursive: true });
348
+ const transaction = mkdtempSync(path.join(path.dirname(targetCache), '.cache-merge-'));
349
+ const staged = path.join(transaction, 'cache');
350
+ const previous = path.join(transaction, 'previous');
351
+ let movedPrevious = false;
352
+ let cleanupTransaction = true;
353
+ try {
354
+ mkdirSync(staged);
355
+ for (const [file, source] of [...entries].sort(([a], [b]) => a.localeCompare(b))) {
356
+ const target = path.join(staged, file);
357
+ mkdirSync(path.dirname(target), { recursive: true });
358
+ cpSync(source, target, copyOptions);
359
+ }
360
+ for (const [name, files] of manifests)
361
+ writeFileSync(path.join(staged, name), JSON.stringify(files));
362
+ if (existing) {
363
+ renameSync(targetCache, previous);
364
+ movedPrevious = true;
365
+ }
366
+ try {
367
+ renameSync(staged, targetCache);
368
+ } catch (error) {
369
+ if (movedPrevious) {
370
+ try {
371
+ renameSync(previous, targetCache);
372
+ } catch (rollbackError) {
373
+ cleanupTransaction = false;
374
+ throw new AggregateError(
375
+ [error, rollbackError],
376
+ `Cache merge rollback failed; previous cache preserved at ${previous}`
377
+ );
378
+ }
379
+ }
380
+ throw error;
381
+ }
382
+ } finally {
383
+ if (cleanupTransaction) rmSync(transaction, { recursive: true, force: true });
384
+ }
385
+ return { files: entries.size + manifests.size };
386
+ }
@@ -0,0 +1,70 @@
1
+ import { lstatSync, readFileSync, readdirSync, realpathSync } from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ export const historyCachePath =
5
+ /^file-history\/[\w-]+\/[a-f0-9]{64}(?:\.json|-blame\.json|-[a-f0-9]{40,64}\.diff)$/;
6
+
7
+ /** Archive current entries; historical local generations remain available for undo. */
8
+ export function compilerCacheFiles(cache: string) {
9
+ const files: string[] = [];
10
+ const root = realpathSync(cache);
11
+ const regular = (file: string) => {
12
+ try {
13
+ const target = path.join(root, file);
14
+ return lstatSync(target).isFile() && realpathSync(target) === target;
15
+ } catch {
16
+ return false;
17
+ }
18
+ };
19
+ const current = (name: string, pattern: RegExp) => {
20
+ try {
21
+ if (!regular(name)) throw new Error('Missing cache manifest');
22
+ const manifest: unknown = JSON.parse(readFileSync(path.join(cache, name), 'utf8'));
23
+ if (
24
+ !Array.isArray(manifest) ||
25
+ !manifest.every((file) => typeof file === 'string' && pattern.test(file))
26
+ )
27
+ throw new Error('Invalid current cache manifest');
28
+ files.push(name, ...manifest.filter(regular));
29
+ } catch {
30
+ console.log(`No valid ${name}; omitting its entries from cache save`);
31
+ }
32
+ };
33
+ current('stage1-current.json', /^stage1\/[a-f0-9]{2}\/[a-f0-9]{64}\.json$/);
34
+ for (const edition of ['public', 'full'])
35
+ current(`social-titles-current-${edition}.json`, /^social-titles\/[a-f0-9]{64}\.png$/);
36
+ // A restored runner has no output tree. Prefer the portable manifest emitted
37
+ // after a full build prunes history output; older caches can still use that tree.
38
+ const historyManifest = lstatSync(path.join(cache, 'history-current.json'), {
39
+ throwIfNoEntry: false
40
+ });
41
+ if (historyManifest) current('history-current.json', historyCachePath);
42
+ const histories = new Set<string>();
43
+ if (!historyManifest) {
44
+ try {
45
+ for (const entry of readdirSync(path.resolve(cache, '../assets/_files/history'), {
46
+ withFileTypes: true
47
+ }))
48
+ if (entry.isFile()) histories.add(entry.name);
49
+ } catch (error) {
50
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
51
+ }
52
+ }
53
+ const walk = (directory: string, select: (name: string) => boolean) => {
54
+ try {
55
+ if (!lstatSync(path.join(cache, directory)).isDirectory()) return;
56
+ for (const entry of readdirSync(path.join(cache, directory), { withFileTypes: true })) {
57
+ const file = `${directory}/${entry.name}`;
58
+ if (entry.isDirectory()) walk(file, select);
59
+ else if (entry.isFile() && select(entry.name) && regular(file)) files.push(file);
60
+ }
61
+ } catch (error) {
62
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
63
+ }
64
+ };
65
+ walk('file-history', (name) => histories.has(name) || /^revisions-[\w-]+\.jsonl$/.test(name));
66
+ walk('rmd', () => true);
67
+ for (const file of readdirSync(cache))
68
+ if (/^gitdates-v3-[\w-]+\.json$/.test(file) && regular(file)) files.push(file);
69
+ return [...new Set(files)].sort();
70
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Prewarm the Cloudflare adapter's public emulator during development.
3
+ * Cloudflare initializes one platform independently of route config; this helper
4
+ * is scoped to that adapter. Actual requests still receive their original
5
+ * config and prerender options, including the adapter's protected prerender env.
6
+ *
7
+ * @param {import('@sveltejs/kit').Emulator} emulator
8
+ * @returns {import('@sveltejs/kit').Emulator}
9
+ */
10
+ export function prewarmCloudflareEmulator(emulator) {
11
+ if (process.env.NODE_ENV !== 'development' || !emulator.platform) return emulator;
12
+ const platform = emulator.platform.bind(emulator);
13
+ /** @type {Promise<App.Platform> | undefined} */
14
+ let initialization;
15
+ const initialize = () => {
16
+ if (initialization) return initialization;
17
+ /** @type {Promise<App.Platform>} */
18
+ let attempt;
19
+ try {
20
+ attempt = Promise.resolve(platform({ config: {}, prerender: false }));
21
+ } catch (error) {
22
+ attempt = Promise.reject(error);
23
+ }
24
+ initialization = attempt;
25
+ // Observe background failures immediately. Requests already awaiting this
26
+ // attempt still fail; a later request can retry initialization.
27
+ void attempt.catch(() => {
28
+ if (initialization === attempt) initialization = undefined;
29
+ });
30
+ return attempt;
31
+ };
32
+ initialize();
33
+ return {
34
+ ...emulator,
35
+ async platform(details) {
36
+ await initialize();
37
+ return platform(details);
38
+ }
39
+ };
40
+ }
@@ -0,0 +1,32 @@
1
+ import { unified } from 'unified';
2
+ import remarkParse from 'remark-parse';
3
+ import { visit } from 'unist-util-visit';
4
+
5
+ /** Obsidian comments apply to prose, never fenced or inline code (notably Mermaid %%). */
6
+ export function stripObsidianComments(source: string, preservePositions = false): string {
7
+ if (!source.includes('%%')) return source;
8
+ const protectedRanges: { start: number; end: number }[] = [];
9
+ visit(unified().use(remarkParse).parse(source), (node) => {
10
+ if (node.type === 'code' || node.type === 'inlineCode') {
11
+ const start = node.position?.start.offset;
12
+ const end = node.position?.end.offset;
13
+ if (start !== undefined && end !== undefined) protectedRanges.push({ start, end });
14
+ }
15
+ });
16
+ const pattern = /%%[\s\S]*?%%/g;
17
+ let result = '',
18
+ cursor = 0;
19
+ for (let match = pattern.exec(source); match; match = pattern.exec(source)) {
20
+ const protectedRange = protectedRanges.find(
21
+ (range) => match!.index >= range.start && match!.index < range.end
22
+ );
23
+ if (protectedRange) {
24
+ pattern.lastIndex = protectedRange.end;
25
+ continue;
26
+ }
27
+ result += source.slice(cursor, match.index);
28
+ if (preservePositions) result += match[0].replace(/[^\r\n]/g, ' ');
29
+ cursor = pattern.lastIndex;
30
+ }
31
+ return result + source.slice(cursor);
32
+ }