@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,244 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { lstatSync, mkdirSync, readFileSync, realpathSync } from 'node:fs';
3
+ import path from 'node:path';
4
+ import type { FullSlug } from './slug';
5
+ import { writeChanged } from './output';
6
+
7
+ export type ContentEdition = 'public' | 'full';
8
+ export interface ContentSummary {
9
+ slug: FullSlug;
10
+ rel: string;
11
+ frontmatter: Record<string, unknown> & { title: string };
12
+ heading?: string;
13
+ title: string;
14
+ description: string;
15
+ tags: string[];
16
+ toc: { depth: number; text: string; slug: string }[];
17
+ draft: boolean;
18
+ hasMermaid: boolean;
19
+ wordCount: number;
20
+ }
21
+ export interface ContentCachePage {
22
+ parseKey: string;
23
+ summary: ContentSummary;
24
+ outgoing: string[];
25
+ transcludes: { target: string; selector?: string }[];
26
+ parseWarnings: string[];
27
+ unknownLanguages?: Record<string, number>;
28
+ linkWarnings: string[];
29
+ transclusionWarnings: string[];
30
+ body?: string;
31
+ document?: string;
32
+ documentKey?: string;
33
+ }
34
+ export interface ContentCacheIndex {
35
+ schema: 1;
36
+ renderer: string;
37
+ context: string;
38
+ pages: Record<string, ContentCachePage>;
39
+ }
40
+ export interface ContentBody {
41
+ html: string;
42
+ markdown: string;
43
+ }
44
+
45
+ export function contentHash(bytes: string | Uint8Array) {
46
+ return createHash('sha256').update(bytes).digest('hex');
47
+ }
48
+
49
+ const digest = /^[a-f0-9]{64}$/;
50
+ export const contentObjectPath = /^content\/(?:public|full)\/(?:bodies|docs)\/[a-f0-9]{64}\.json$/;
51
+ const strings = (value: unknown): value is string[] =>
52
+ Array.isArray(value) && value.every((item) => typeof item === 'string');
53
+
54
+ /** Refuse symlinks at every level; restored manifests contain portable paths only. */
55
+ function readRegular(cache: string, relative: string): Buffer | undefined {
56
+ try {
57
+ const root = realpathSync(cache);
58
+ if (root !== path.resolve(cache)) return;
59
+ const file = path.join(root, relative);
60
+ if (realpathSync(file) !== file || !lstatSync(file).isFile()) return;
61
+ return readFileSync(file);
62
+ } catch {
63
+ return;
64
+ }
65
+ }
66
+
67
+ function writeOwned(cache: string, relative: string, bytes: string | Uint8Array) {
68
+ const root = path.resolve(cache);
69
+ mkdirSync(root, { recursive: true });
70
+ if (realpathSync(root) !== root)
71
+ throw new Error('Content cache must have owned directory ancestors');
72
+ let directory = root;
73
+ for (const segment of relative.split('/').slice(0, -1)) {
74
+ directory = path.join(directory, segment);
75
+ const info = lstatSync(directory, { throwIfNoEntry: false });
76
+ if (info && (!info.isDirectory() || realpathSync(directory) !== directory))
77
+ throw new Error('Content cache must have owned directory ancestors');
78
+ if (!info) mkdirSync(directory);
79
+ }
80
+ const target = path.join(root, relative);
81
+ const info = lstatSync(target, { throwIfNoEntry: false });
82
+ if (info && (!info.isFile() || realpathSync(target) !== target))
83
+ throw new Error('Content cache entries must be owned regular files');
84
+ writeChanged(target, bytes);
85
+ }
86
+
87
+ function ownedObject(value: unknown, edition: ContentEdition, kind?: 'bodies' | 'docs') {
88
+ return (
89
+ typeof value === 'string' &&
90
+ contentObjectPath.test(value) &&
91
+ value.startsWith(`content/${edition}/${kind ?? ''}`)
92
+ );
93
+ }
94
+
95
+ export function readContentObject(cache: string, relative: string, edition: ContentEdition) {
96
+ if (!ownedObject(relative, edition)) return;
97
+ const bytes = readRegular(cache, relative);
98
+ if (!bytes || contentHash(bytes) !== path.basename(relative, '.json')) return;
99
+ return bytes;
100
+ }
101
+
102
+ export function readContentBody(cache: string, relative: string, edition: ContentEdition) {
103
+ if (!ownedObject(relative, edition, 'bodies')) return;
104
+ try {
105
+ const bytes = readContentObject(cache, relative, edition);
106
+ if (!bytes) return;
107
+ const body: unknown = JSON.parse(bytes.toString());
108
+ if (
109
+ body &&
110
+ typeof body === 'object' &&
111
+ 'html' in body &&
112
+ typeof body.html === 'string' &&
113
+ 'markdown' in body &&
114
+ typeof body.markdown === 'string'
115
+ )
116
+ return body as ContentBody;
117
+ } catch {
118
+ return;
119
+ }
120
+ }
121
+
122
+ function validPage(value: unknown, edition: ContentEdition): value is ContentCachePage {
123
+ if (!value || typeof value !== 'object') return false;
124
+ const page = value as ContentCachePage;
125
+ const summary = page.summary;
126
+ return (
127
+ digest.test(page.parseKey) &&
128
+ !!summary &&
129
+ typeof summary === 'object' &&
130
+ typeof summary.rel === 'string' &&
131
+ typeof summary.slug === 'string' &&
132
+ typeof summary.title === 'string' &&
133
+ typeof summary.description === 'string' &&
134
+ !!summary.frontmatter &&
135
+ typeof summary.frontmatter === 'object' &&
136
+ !Array.isArray(summary.frontmatter) &&
137
+ typeof summary.draft === 'boolean' &&
138
+ typeof summary.hasMermaid === 'boolean' &&
139
+ Number.isFinite(summary.wordCount) &&
140
+ strings(summary.tags) &&
141
+ Array.isArray(summary.toc) &&
142
+ summary.toc.every(
143
+ (entry) =>
144
+ entry &&
145
+ Number.isFinite(entry.depth) &&
146
+ typeof entry.text === 'string' &&
147
+ typeof entry.slug === 'string'
148
+ ) &&
149
+ strings(page.outgoing) &&
150
+ strings(page.parseWarnings) &&
151
+ strings(page.linkWarnings) &&
152
+ strings(page.transclusionWarnings) &&
153
+ (page.unknownLanguages === undefined ||
154
+ (!!page.unknownLanguages &&
155
+ typeof page.unknownLanguages === 'object' &&
156
+ !Array.isArray(page.unknownLanguages) &&
157
+ Object.values(page.unknownLanguages).every(
158
+ (count) => Number.isInteger(count) && count > 0
159
+ ))) &&
160
+ Array.isArray(page.transcludes) &&
161
+ page.transcludes.every(
162
+ (entry) =>
163
+ entry &&
164
+ typeof entry.target === 'string' &&
165
+ (entry.selector === undefined || typeof entry.selector === 'string')
166
+ ) &&
167
+ (page.body === undefined || ownedObject(page.body, edition, 'bodies')) &&
168
+ (page.document === undefined || ownedObject(page.document, edition, 'docs')) &&
169
+ (page.documentKey === undefined || digest.test(page.documentKey))
170
+ );
171
+ }
172
+
173
+ export function readContentIndex(cache: string, edition: ContentEdition, renderer: string) {
174
+ try {
175
+ const bytes = readRegular(cache, `content-current-${edition}.json`);
176
+ if (!bytes) return;
177
+ const { checksum, ...index } = JSON.parse(bytes.toString());
178
+ if (
179
+ index.schema !== 1 ||
180
+ index.renderer !== renderer ||
181
+ !digest.test(index.context) ||
182
+ checksum !== contentHash(JSON.stringify(index)) ||
183
+ !index.pages ||
184
+ typeof index.pages !== 'object' ||
185
+ Array.isArray(index.pages) ||
186
+ !Object.entries(index.pages).every(
187
+ ([relative, page]) => validPage(page, edition) && page.summary.rel === relative
188
+ )
189
+ )
190
+ return;
191
+ return index as ContentCacheIndex;
192
+ } catch {
193
+ return;
194
+ }
195
+ }
196
+
197
+ export function writeContentObject(
198
+ cache: string,
199
+ edition: ContentEdition,
200
+ kind: 'bodies' | 'docs',
201
+ bytes: string | Uint8Array
202
+ ) {
203
+ const relative = `content/${edition}/${kind}/${contentHash(bytes)}.json`;
204
+ writeOwned(cache, relative, bytes);
205
+ return relative;
206
+ }
207
+
208
+ /** Commit the current selection only after the compiler completed all output checks. */
209
+ export function writeContentIndex(
210
+ cache: string,
211
+ edition: ContentEdition,
212
+ index: ContentCacheIndex
213
+ ) {
214
+ const payload = JSON.stringify(index);
215
+ writeOwned(
216
+ cache,
217
+ `content-current-${edition}.json`,
218
+ JSON.stringify({ ...index, checksum: contentHash(payload) })
219
+ );
220
+ const files = new Set<string>([`content-current-${edition}.json`]);
221
+ for (const page of Object.values(index.pages)) {
222
+ if (page.body) files.add(page.body);
223
+ if (page.document) files.add(page.document);
224
+ }
225
+ writeOwned(cache, `content-files-current-${edition}.json`, JSON.stringify([...files].sort()));
226
+ }
227
+
228
+ /** Earlier targets may already be expanded, so the safe reverse closure is not depth limited. */
229
+ export function transclusionClosure(pages: Iterable<ContentCachePage>, changed: Set<string>) {
230
+ const records = [...pages];
231
+ const affected = new Set(changed);
232
+ let added: boolean;
233
+ do {
234
+ added = false;
235
+ for (const page of records) {
236
+ if (affected.has(page.summary.slug)) continue;
237
+ if (page.transcludes.some(({ target }) => affected.has(target))) {
238
+ affected.add(page.summary.slug);
239
+ added = true;
240
+ }
241
+ }
242
+ } while (added);
243
+ return affected;
244
+ }
@@ -0,0 +1,50 @@
1
+ import { visitElements } from './visit-elements';
2
+ import type { Root } from 'hast';
3
+ import { simplifySlug, stripSlashes, type FullSlug } from './slug';
4
+
5
+ export function htmlAssetPaths(assets: { rel: string; slug: FullSlug }[]): Map<string, string> {
6
+ return new Map(
7
+ assets
8
+ .filter((asset) => asset.rel.toLowerCase().endsWith('.html'))
9
+ .map((asset) => [`/${simplifySlug(asset.slug)}`.replace(/\/$/, ''), `/${asset.slug}.html`])
10
+ );
11
+ }
12
+
13
+ function isLocalContentUrl(value: string): boolean {
14
+ return !(
15
+ /^[a-zA-Z][a-zA-Z\d+.-]*:/.test(value) ||
16
+ value.startsWith('//') ||
17
+ value.startsWith('#')
18
+ );
19
+ }
20
+
21
+ function resolveContentUrl(
22
+ value: string,
23
+ pageUrl: URL,
24
+ assetPaths: ReadonlyMap<string, string>
25
+ ): string {
26
+ if (!isLocalContentUrl(value)) return value;
27
+
28
+ const url = new URL(value, pageUrl);
29
+ const route = url.pathname.replace(/\/$/, '') || '/';
30
+ const assetPath = assetPaths.get(decodeURIComponent(route));
31
+ const pathname = assetPath ? assetPath.split('/').map(encodeURIComponent).join('/') : route;
32
+ return pathname + url.search + url.hash;
33
+ }
34
+
35
+ export function rewriteContentUrls(
36
+ tree: Root,
37
+ slug: FullSlug,
38
+ assetPaths: ReadonlyMap<string, string>
39
+ ) {
40
+ const pagePath = stripSlashes(simplifySlug(slug), true);
41
+ const pageUrl = new URL(`/${pagePath}`, 'https://content.invalid');
42
+
43
+ visitElements(tree, (node) => {
44
+ for (const attribute of ['href', 'src']) {
45
+ const value = node.properties[attribute];
46
+ if (typeof value === 'string')
47
+ node.properties[attribute] = resolveContentUrl(value, pageUrl, assetPaths);
48
+ }
49
+ });
50
+ }
@@ -0,0 +1,298 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { execFileSync } from 'node:child_process';
3
+ import { lstatSync, mkdirSync, readFileSync, realpathSync } from 'node:fs';
4
+ import path from 'node:path';
5
+ import type { CourseFile } from '../../src/lib/files';
6
+ import { writeChanged } from './output';
7
+ import { browsablePath } from './file-policy';
8
+
9
+ const digest = (bytes: string | Uint8Array) => createHash('sha256').update(bytes).digest('hex');
10
+ const sha = /^[a-f0-9]{64}$/;
11
+ const courseName = /^[\w-]+$/;
12
+ const historyName = /^[a-f0-9]{64}(?:\.json|-blame\.json|-[a-f0-9]{40,64}\.diff)$/;
13
+ const blobName = /^blobs\/[a-f0-9]{64}\.(?:bin|pdf|png|jpg|jpeg|gif|webp|avif)$/;
14
+ type Origin =
15
+ | { kind: 'index' }
16
+ | { kind: 'source'; path: string }
17
+ | { kind: 'history'; cachePath: string };
18
+ export type CourseCacheOutput = { path: string; bytes: number; sha256: string; origin: Origin };
19
+ export type CourseCacheRecord = {
20
+ version: 2;
21
+ course: string;
22
+ fingerprint: string;
23
+ files: CourseFile[];
24
+ outputs: CourseCacheOutput[];
25
+ payloadSha256: string;
26
+ };
27
+ export type CourseCacheContext = {
28
+ repo: string;
29
+ contentRoot?: string;
30
+ course: string;
31
+ output: string;
32
+ cache: string;
33
+ };
34
+
35
+ function regularBytes(root: string, relative: string) {
36
+ if (
37
+ !relative ||
38
+ /[\\\x00-\x1f\x7f]/.test(relative) ||
39
+ relative.split('/').some((part) => !part || part === '.' || part === '..')
40
+ )
41
+ return;
42
+ const file = path.join(root, relative);
43
+ try {
44
+ if (!lstatSync(file).isFile() || realpathSync(file) !== path.join(realpathSync(root), relative))
45
+ return;
46
+ return readFileSync(file);
47
+ } catch (error) {
48
+ if (['ENOENT', 'ENOTDIR'].includes((error as NodeJS.ErrnoException).code ?? '')) return;
49
+ throw error;
50
+ }
51
+ }
52
+
53
+ /** Portable identity includes tracked source bytes, offline edits, deletions and publication rules. */
54
+ export function courseFingerprint(
55
+ repo: string,
56
+ course: string,
57
+ tracked: string[],
58
+ version: string,
59
+ notes: string,
60
+ contentRoot?: string
61
+ ) {
62
+ const sourceRoot = contentRoot ?? path.join(repo, 'content');
63
+ if (!courseName.test(course)) throw new Error('Unsafe course name');
64
+ const hash = createHash('sha256').update(
65
+ JSON.stringify(['course-files-v2', course, version, notes])
66
+ );
67
+ try {
68
+ hash.update(
69
+ execFileSync('git', ['-C', path.join(sourceRoot, course), 'rev-parse', 'HEAD'], {
70
+ stdio: ['ignore', 'pipe', 'ignore']
71
+ })
72
+ );
73
+ } catch {
74
+ hash.update('unborn');
75
+ }
76
+ const prefix = contentRoot === undefined ? `content/${course}/` : `${course}/`;
77
+ const inputs = [
78
+ ...new Set([
79
+ ...tracked.filter(
80
+ (file) => file.startsWith(prefix) && browsablePath(file.slice(prefix.length))
81
+ ),
82
+ `${prefix}publish.yaml`
83
+ ])
84
+ ].sort();
85
+ for (const file of inputs) {
86
+ const bytes = regularBytes(contentRoot === undefined ? repo : sourceRoot, file);
87
+ hash.update(JSON.stringify([file, bytes ? [bytes.length, digest(bytes)] : null]));
88
+ }
89
+ return hash.digest('hex');
90
+ }
91
+
92
+ function payload(saved: Omit<CourseCacheRecord, 'payloadSha256'>) {
93
+ return JSON.stringify([
94
+ saved.version,
95
+ saved.course,
96
+ saved.fingerprint,
97
+ saved.files,
98
+ saved.outputs
99
+ ]);
100
+ }
101
+
102
+ /** Validate portable metadata before output restoration or transport selection. */
103
+ export function parseCourseCache(value: unknown, course: string): CourseCacheRecord | undefined {
104
+ const saved = value as CourseCacheRecord;
105
+ if (
106
+ !saved ||
107
+ saved.version !== 2 ||
108
+ saved.course !== course ||
109
+ !courseName.test(course) ||
110
+ typeof saved.fingerprint !== 'string' ||
111
+ !sha.test(saved.fingerprint) ||
112
+ !Array.isArray(saved.files) ||
113
+ !Array.isArray(saved.outputs) ||
114
+ typeof saved.payloadSha256 !== 'string' ||
115
+ !sha.test(saved.payloadSha256) ||
116
+ digest(payload(saved)) !== saved.payloadSha256
117
+ )
118
+ return;
119
+ if (
120
+ !saved.files.every(
121
+ (file) =>
122
+ file &&
123
+ typeof file.path === 'string' &&
124
+ browsablePath(file.path) &&
125
+ Number.isSafeInteger(file.size) &&
126
+ file.size >= 0 &&
127
+ ['text', 'image', 'pdf', 'binary'].includes(file.kind) &&
128
+ [file.download, file.history, file.rmdPreview, file.note].every(
129
+ (value) => value === undefined || typeof value === 'string'
130
+ )
131
+ )
132
+ )
133
+ return;
134
+ const paths = new Set<string>();
135
+ for (const output of saved.outputs) {
136
+ if (
137
+ !output ||
138
+ typeof output.path !== 'string' ||
139
+ !browsablePath(output.path) ||
140
+ paths.has(output.path) ||
141
+ !Number.isSafeInteger(output.bytes) ||
142
+ output.bytes < 0 ||
143
+ typeof output.sha256 !== 'string' ||
144
+ !sha.test(output.sha256) ||
145
+ !output.origin
146
+ )
147
+ return;
148
+ paths.add(output.path);
149
+ if (output.origin.kind === 'index') {
150
+ if (output.path !== `index/${course}.json`) return;
151
+ } else if (output.origin.kind === 'history') {
152
+ const name = output.path.slice('history/'.length);
153
+ if (
154
+ !output.path.startsWith('history/') ||
155
+ !historyName.test(name) ||
156
+ output.origin.cachePath !== `file-history/${course}/${name}`
157
+ )
158
+ return;
159
+ } else if (output.origin.kind === 'source') {
160
+ const source = output.origin.path;
161
+ if (
162
+ !blobName.test(output.path) ||
163
+ output.path.slice(6, 70) !== output.sha256 ||
164
+ typeof source !== 'string' ||
165
+ !browsablePath(source) ||
166
+ !saved.files.some(
167
+ (file) =>
168
+ !file.locked &&
169
+ file.path === source &&
170
+ file.size === output.bytes &&
171
+ file.download === `/_files/${output.path}`
172
+ )
173
+ )
174
+ return;
175
+ } else return;
176
+ }
177
+ if (saved.files.length && !paths.has(`index/${course}.json`)) return;
178
+ for (const file of saved.files) {
179
+ if (
180
+ file.download &&
181
+ (file.locked ||
182
+ !file.download.startsWith('/_files/blobs/') ||
183
+ !paths.has(file.download.slice('/_files/'.length)))
184
+ )
185
+ return;
186
+ if (
187
+ file.history &&
188
+ (file.locked ||
189
+ !file.history.startsWith('/_files/history/') ||
190
+ !paths.has(file.history.slice('/_files/'.length)))
191
+ )
192
+ return;
193
+ if (file.rmdPreview) return; // R courses remain owned by the renderer preflight.
194
+ }
195
+ return saved;
196
+ }
197
+
198
+ function safeParents(root: string, relative: string) {
199
+ for (let parent = path.dirname(relative); parent !== '.'; parent = path.dirname(parent)) {
200
+ const target = path.join(root, parent);
201
+ const stat = lstatSync(target, { throwIfNoEntry: false });
202
+ if (
203
+ stat &&
204
+ (!stat.isDirectory() || realpathSync(target) !== path.join(realpathSync(root), parent))
205
+ )
206
+ return false;
207
+ }
208
+ return true;
209
+ }
210
+
211
+ export function readCourseCache(file: string, fingerprint: string, context: CourseCacheContext) {
212
+ try {
213
+ if (!lstatSync(file).isFile() || realpathSync(file) !== path.resolve(file)) return;
214
+ const saved = parseCourseCache(JSON.parse(readFileSync(file, 'utf8')), context.course);
215
+ if (!saved || saved.fingerprint !== fingerprint) return;
216
+ const pending: CourseCacheOutput[] = [];
217
+ const recoveryBytes = (output: CourseCacheOutput) => {
218
+ if (output.origin.kind === 'index') return Buffer.from(JSON.stringify(saved.files));
219
+ if (output.origin.kind === 'source')
220
+ return regularBytes(
221
+ path.join(context.contentRoot ?? path.join(context.repo, 'content'), context.course),
222
+ output.origin.path
223
+ );
224
+ return regularBytes(context.cache, output.origin.cachePath);
225
+ };
226
+ for (const output of saved.outputs) {
227
+ if (!safeParents(context.output, output.path)) return;
228
+ const existing = regularBytes(context.output, output.path);
229
+ if (existing && existing.length === output.bytes && digest(existing) === output.sha256)
230
+ continue;
231
+ const bytes = recoveryBytes(output);
232
+ if (!bytes || bytes.length !== output.bytes || digest(bytes) !== output.sha256) return;
233
+ pending.push(output);
234
+ }
235
+ // Check the complete closure before writing; unavailable products rebuild normally.
236
+ for (const output of pending) {
237
+ // Recheck bytes at the write boundary without retaining whole courses in memory.
238
+ const bytes = recoveryBytes(output);
239
+ if (
240
+ !bytes ||
241
+ bytes.length !== output.bytes ||
242
+ digest(bytes) !== output.sha256 ||
243
+ !safeParents(context.output, output.path)
244
+ )
245
+ return;
246
+ const target = path.join(context.output, output.path);
247
+ mkdirSync(path.dirname(target), { recursive: true });
248
+ const stat = lstatSync(target, { throwIfNoEntry: false });
249
+ if (stat && !stat.isFile()) return;
250
+ writeChanged(target, bytes);
251
+ }
252
+ return {
253
+ files: saved.files,
254
+ outputs: new Set(saved.outputs.map((output) => path.join(context.output, output.path)))
255
+ };
256
+ } catch {
257
+ return;
258
+ }
259
+ }
260
+
261
+ export function saveCourseCache(
262
+ file: string,
263
+ fingerprint: string,
264
+ files: CourseFile[],
265
+ outputs: Set<string>,
266
+ context: CourseCacheContext
267
+ ) {
268
+ const records: CourseCacheOutput[] = [...outputs].sort().map((target) => {
269
+ const relative = path.relative(context.output, target).split(path.sep).join('/');
270
+ const bytes = regularBytes(context.output, relative);
271
+ if (!bytes) throw new Error('Invalid course cache output');
272
+ let origin: Origin;
273
+ if (relative === `index/${context.course}.json`) origin = { kind: 'index' };
274
+ else if (relative.startsWith('history/'))
275
+ origin = {
276
+ kind: 'history',
277
+ cachePath: `file-history/${context.course}/${path.basename(relative)}`
278
+ };
279
+ else {
280
+ const source = files.find(
281
+ (entry) => !entry.locked && entry.download === `/_files/${relative}`
282
+ );
283
+ if (!source) throw new Error('Course output has no tracked source');
284
+ origin = { kind: 'source', path: source.path };
285
+ }
286
+ return { path: relative, bytes: bytes.length, sha256: digest(bytes), origin };
287
+ });
288
+ const saved = {
289
+ version: 2 as const,
290
+ course: context.course,
291
+ fingerprint,
292
+ files,
293
+ outputs: records
294
+ };
295
+ const record = { ...saved, payloadSha256: digest(payload(saved)) };
296
+ if (!parseCourseCache(record, context.course)) throw new Error('Invalid portable course cache');
297
+ writeChanged(file, JSON.stringify(record));
298
+ }