@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,257 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { createHash, randomBytes } from 'node:crypto';
3
+ import { existsSync, lstatSync, readFileSync, realpathSync, statSync } from 'node:fs';
4
+ import path from 'node:path';
5
+ import { resolveProject, declaredContentCourses, discoverSourceFiles } from './project-config';
6
+
7
+ const runtimeEnvironment = [
8
+ 'PATH',
9
+ 'R_HOME',
10
+ 'RHOME',
11
+ 'R_LIBS',
12
+ 'R_LIBS_SITE',
13
+ 'R_LIBS_USER',
14
+ 'R_ENVIRON',
15
+ 'R_ENVIRON_USER',
16
+ 'R_PROFILE',
17
+ 'R_PROFILE_USER',
18
+ 'LD_LIBRARY_PATH',
19
+ 'FONTCONFIG_PATH',
20
+ 'FONTCONFIG_FILE',
21
+ 'FONTCONFIG_SYSROOT',
22
+ 'FC_LANG',
23
+ 'XDG_CONFIG_HOME',
24
+ 'XDG_DATA_HOME'
25
+ ] as const;
26
+ const probes = new Map<string, string>();
27
+ const unavailableFontIdentity = randomBytes(16).toString('hex');
28
+
29
+ /** Font paths locate inputs; only portable face metadata and content hashes enter the identity. */
30
+ export function rFontIdentity(environment: NodeJS.ProcessEnv = process.env) {
31
+ const deadline = performance.now() + 5_000;
32
+ const maximumBytes = 256 * 1024 * 1024;
33
+ let bytes = 0;
34
+ const hashes = new Map<string, string>();
35
+ const hashFile = (file: string, maximumSize: number) => {
36
+ if (hashes.has(file)) return hashes.get(file)!;
37
+ const info = statSync(file);
38
+ if (
39
+ !info.isFile() ||
40
+ info.size > maximumSize ||
41
+ bytes + info.size > maximumBytes ||
42
+ hashes.size >= 512 ||
43
+ performance.now() >= deadline
44
+ )
45
+ throw new Error('Font identity exceeds its probe budget');
46
+ const content = readFileSync(file);
47
+ bytes += content.length;
48
+ const hash = createHash('sha256').update(content).digest('hex');
49
+ hashes.set(file, hash);
50
+ return hash;
51
+ };
52
+ const run = (command: string, args: string[]) => {
53
+ const remaining = Math.floor(deadline - performance.now());
54
+ if (remaining < 1) throw new Error('Font identity probe timed out');
55
+ return execFileSync(command, args, {
56
+ env: environment,
57
+ encoding: 'utf8',
58
+ timeout: Math.min(2_000, remaining),
59
+ maxBuffer: 1024 * 1024,
60
+ stdio: ['ignore', 'pipe', 'pipe']
61
+ });
62
+ };
63
+ const format =
64
+ '%{file}\t%{family}\t%{style}\t%{fontversion}\t%{foundry}\t%{index}\t%{antialias}\t%{hinting}\t%{hintstyle}\t%{rgba}\n';
65
+ const faces = (output: string) =>
66
+ [
67
+ ...new Set(
68
+ output
69
+ .split('\n')
70
+ .filter(Boolean)
71
+ .map((line) => {
72
+ const [file, ...metadata] = line.split('\t');
73
+ if (!file || metadata.length !== 9) throw new Error('Invalid resolved font metadata');
74
+ return JSON.stringify([metadata, hashFile(file, 64 * 1024 * 1024)]);
75
+ })
76
+ )
77
+ ].sort();
78
+ try {
79
+ const inventory = faces(run('fc-list', ['--format', format]));
80
+ if (!inventory.length) throw new Error('Font inventory is empty');
81
+ const matches = [];
82
+ for (const family of ['', 'sans', 'serif', 'monospace', 'symbol']) {
83
+ for (const style of ['', 'Bold', 'Italic', 'Bold Italic']) {
84
+ const pattern = family + (style ? `:style=${style}` : '');
85
+ const resolved = faces(run('fc-match', ['--format', format, pattern]));
86
+ if (!resolved.length) throw new Error('Default font cannot be resolved');
87
+ matches.push([pattern, resolved]);
88
+ }
89
+ }
90
+ const configuration = run('fc-conflist', [])
91
+ .split('\n')
92
+ .filter((line) => line.startsWith('+ '))
93
+ .map((line) => {
94
+ const separator = line.indexOf(': ', 2);
95
+ if (separator < 0) throw new Error('Invalid active font configuration');
96
+ return hashFile(line.slice(2, separator), 1024 * 1024);
97
+ });
98
+ if (performance.now() >= deadline) throw new Error('Font identity probe timed out');
99
+ return (
100
+ 'fontconfig-v1:' +
101
+ createHash('sha256')
102
+ .update(JSON.stringify([inventory, matches, configuration]))
103
+ .digest('hex')
104
+ );
105
+ } catch {
106
+ // R can use native devices without Fontconfig tools. Render normally, but
107
+ // previews bypass cache reads, while the process identity keeps source guards stable.
108
+ return `fontconfig-unverified-v1:${process.platform}:${unavailableFontIdentity}`;
109
+ }
110
+ }
111
+
112
+ function selection(environment: NodeJS.ProcessEnv) {
113
+ const executable = environment.RSCRIPT ?? 'Rscript';
114
+ const locations = executable.includes(path.sep)
115
+ ? [path.resolve(executable)]
116
+ : (environment.PATH ?? '')
117
+ .split(path.delimiter)
118
+ .map((directory) => path.resolve(directory, executable));
119
+ const binary = locations.find(existsSync);
120
+ let metadata: unknown;
121
+ if (binary) {
122
+ try {
123
+ const info = statSync(binary);
124
+ metadata = [binary, info.dev, info.ino, info.size, info.mtimeMs, info.ctimeMs];
125
+ } catch (error) {
126
+ if (!['ENOENT', 'ENOTDIR'].includes((error as NodeJS.ErrnoException).code ?? '')) throw error;
127
+ }
128
+ }
129
+ return JSON.stringify([
130
+ executable,
131
+ runtimeEnvironment.map((name) => [name, environment[name]]),
132
+ metadata
133
+ ]);
134
+ }
135
+
136
+ /** Share the version probe with rendering, but refresh before accepting a saved dev snapshot. */
137
+ export function rRuntimeVersion(environment: NodeJS.ProcessEnv = process.env, refresh = false) {
138
+ const key = selection(environment);
139
+ if (!refresh && probes.has(key)) return probes.get(key)!;
140
+ try {
141
+ const version = execFileSync(
142
+ environment.RSCRIPT ?? 'Rscript',
143
+ [
144
+ '--vanilla',
145
+ '-e',
146
+ 'cat(R.version.string); for (p in c("knitr", "evaluate", "highr", "xfun", "yaml")) cat(p, as.character(packageVersion(p))); cat("\\n"); dput(R.version); dput(capabilities()); dput(extSoftVersion()); dput(grDevices::grSoftVersion()); dput(Sys.getlocale()); dput(Sys.timezone()); packages <- utils::installed.packages(); packages <- packages[order(packages[, "Package"], packages[, "Version"], packages[, "Built"]), c("Package", "Version", "Built"), drop = FALSE]; cat("\\n", apply(packages, 1, paste, collapse = " "), sep = "\\n")'
147
+ ],
148
+ { env: environment, encoding: 'utf8', timeout: 30_000, stdio: 'pipe' }
149
+ );
150
+ const identity = `${version}\n${rFontIdentity(environment)}`;
151
+ probes.set(key, identity);
152
+ return identity;
153
+ } catch (error) {
154
+ probes.delete(key);
155
+ throw new Error(
156
+ 'R Markdown previews require R and knitr. Install r-base-core and r-cran-knitr (see site/README.md).',
157
+ { cause: error }
158
+ );
159
+ }
160
+ }
161
+
162
+ export interface RRenderProfile {
163
+ schemaVersion: 1;
164
+ platform: string;
165
+ architecture: string;
166
+ runtime: string;
167
+ runtimeSha256: string;
168
+ }
169
+
170
+ /** Capture only dependency/device/version provenance from an actual renderer, never note data. */
171
+ export function captureRRenderProfile(
172
+ environment: NodeJS.ProcessEnv = process.env
173
+ ): RRenderProfile {
174
+ return renderProfile(rRuntimeVersion(environment, true));
175
+ }
176
+
177
+ function renderProfile(runtime: string): RRenderProfile {
178
+ const separator = runtime.lastIndexOf('\nfontconfig-');
179
+ if (separator < 0) throw new Error('Missing renderer font provenance');
180
+ const versions = runtime.slice(0, separator);
181
+ const platform = process.platform;
182
+ const architecture = process.arch;
183
+ return {
184
+ schemaVersion: 1,
185
+ platform,
186
+ architecture,
187
+ runtime: versions,
188
+ runtimeSha256: createHash('sha256')
189
+ .update(JSON.stringify(['r-render-profile-v1', platform, architecture, versions]))
190
+ .digest('hex')
191
+ };
192
+ }
193
+
194
+ export function readRRenderProfile(site: string, environment: NodeJS.ProcessEnv = process.env) {
195
+ const file = path.resolve(
196
+ environment.WISCONSIN_R_RENDER_PROFILE_FILE ?? path.join(site, 'tooling/r-render-profile.json')
197
+ );
198
+ if (!existsSync(file)) return;
199
+ if (!lstatSync(file).isFile() || realpathSync(file) !== file)
200
+ throw new Error('R render profile must be an owned regular lock file');
201
+ const value: RRenderProfile = JSON.parse(readFileSync(file, 'utf8'));
202
+ if (
203
+ value.schemaVersion !== 1 ||
204
+ value.platform !== process.platform ||
205
+ value.architecture !== process.arch ||
206
+ typeof value.runtime !== 'string' ||
207
+ !value.runtime ||
208
+ !/^[a-f0-9]{64}$/.test(value.runtimeSha256) ||
209
+ renderProfile(`${value.runtime}\nfontconfig-v1:unused`).runtimeSha256 !== value.runtimeSha256
210
+ )
211
+ throw new Error('Invalid or incompatible R render profile lock');
212
+ return value;
213
+ }
214
+
215
+ /** A provisioned renderer must match the maintained lock, rather than silently updating policy. */
216
+ export function verifyRRenderProfile(
217
+ site: string,
218
+ runtime: string,
219
+ environment: NodeJS.ProcessEnv = process.env
220
+ ) {
221
+ if (environment.WISCONSIN_R_CACHE_ONLY !== '1' && environment.WISCONSIN_R_REQUIRE_PROFILE !== '1')
222
+ return;
223
+ const expected = readRRenderProfile(site, environment);
224
+ if (!expected) throw new Error('A verified R render profile lock is required by this build');
225
+ if (expected && renderProfile(runtime).runtimeSha256 !== expected.runtimeSha256)
226
+ throw new Error(
227
+ 'Installed R renderer does not match tooling/r-render-profile.json; provision the locked dependencies or explicitly update the verified lock'
228
+ );
229
+ }
230
+
231
+ /** Reconstruct an approved identity only from the maintained lock and independently observed fonts. */
232
+ export function lockedRRuntime(site: string, environment: NodeJS.ProcessEnv = process.env) {
233
+ const profile = readRRenderProfile(site, environment);
234
+ if (!profile) throw new Error('R cache-only reuse requires a verified render profile lock');
235
+ const fonts = rFontIdentity(environment);
236
+ if (!/^fontconfig-v1:[a-f0-9]{64}$/.test(fonts))
237
+ throw new Error('R cache-only reuse requires independently verified fonts');
238
+ return `${profile.runtime}\n${fonts}`;
239
+ }
240
+
241
+ /** Probe conservatively for indexed worksheets in declared, initialized courses. */
242
+ export function rRendererIdentity(repo: string, environment: NodeJS.ProcessEnv = process.env) {
243
+ const project = resolveProject(process.cwd(), repo, environment);
244
+ const courses = declaredContentCourses(project);
245
+ if (!courses.length) return 'unused';
246
+ const hasWorksheet = discoverSourceFiles(project).some(
247
+ (file) => /\.rmd$/i.test(file) && courses.some((course) => file.startsWith(course.name + '/'))
248
+ );
249
+ if (!hasWorksheet) return 'unused';
250
+ let version: string | null;
251
+ try {
252
+ version = rRuntimeVersion(environment, true);
253
+ } catch {
254
+ version = null;
255
+ }
256
+ return JSON.stringify([selection(environment), version]);
257
+ }
@@ -0,0 +1,25 @@
1
+ /** Coalesce a burst and retain at most one follow-up while a build is running. */
2
+ export function rebuildQueue(build: () => Promise<void>, delay = 100) {
3
+ let dirty = false;
4
+ let running = false;
5
+ let pending = Promise.resolve();
6
+ return {
7
+ schedule() {
8
+ dirty = true;
9
+ if (running) return;
10
+ running = true;
11
+ pending = (async () => {
12
+ try {
13
+ await new Promise((resolve) => setTimeout(resolve, delay));
14
+ while (dirty) {
15
+ dirty = false;
16
+ await build();
17
+ }
18
+ } finally {
19
+ running = false;
20
+ }
21
+ })();
22
+ },
23
+ wait: () => pending
24
+ };
25
+ }
@@ -0,0 +1,166 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { lstatSync, readFileSync, realpathSync } from 'node:fs';
3
+ import path from 'node:path';
4
+ import matter from 'gray-matter';
5
+ import yaml from 'js-yaml';
6
+ import type { CourseFile } from '../../src/lib/files';
7
+ import { stageFingerprint } from '../fingerprint';
8
+ import { resolveProject, declaredContentCourses, discoverSourceFiles } from './project-config';
9
+ import { browsablePath } from './file-policy';
10
+ import { publicationFilter } from './publishing';
11
+ import { lockedRRuntime, readRRenderProfile } from './r-runtime';
12
+ import { readRmdPreviewResult, rmdCourseInputs } from './rmd-preview-cache';
13
+ import { slugifyFilePath, type FilePath } from './slug';
14
+
15
+ export interface RmdPreflight {
16
+ schemaVersion: 1;
17
+ requiresR: boolean;
18
+ worksheets: number;
19
+ verified: number;
20
+ reason: 'unused' | 'verified' | 'profile-or-fonts' | 'missing-or-stale' | 'invalid-inputs';
21
+ runtimeSha256?: string;
22
+ }
23
+
24
+ /** Reconstruct the renderer's broad, indexed course closure without executing R or a worksheet. */
25
+ export async function preflightRmdPreviews(
26
+ site: string,
27
+ repo: string,
28
+ environment: NodeJS.ProcessEnv = process.env
29
+ ): Promise<RmdPreflight> {
30
+ let worksheets = 0,
31
+ verified = 0;
32
+ try {
33
+ const project = resolveProject(site, repo, environment);
34
+ const courses = new Set(declaredContentCourses(project).map((course) => course.name));
35
+ if (!courses.size)
36
+ return { schemaVersion: 1, requiresR: false, worksheets: 0, verified: 0, reason: 'unused' };
37
+ const sources = new Map<string, { relative: string; source: string }[]>();
38
+ const paths = discoverSourceFiles(project);
39
+ for (const tracked of paths) {
40
+ const [course, ...segments] = tracked.split('/');
41
+ const relative = segments.join('/');
42
+ if (!courses.has(course) || !browsablePath(relative)) continue;
43
+ const source = path.resolve(project.contentDir, tracked);
44
+ const info = lstatSync(source, { throwIfNoEntry: false });
45
+ if (!info?.isFile() || realpathSync(source) !== source) continue;
46
+ const files = sources.get(course) ?? [];
47
+ files.push({ relative, source });
48
+ sources.set(course, files);
49
+ }
50
+ const worksheetCourses = [...sources].filter(([, files]) =>
51
+ files.some((file) => /\.rmd$/i.test(file.relative))
52
+ );
53
+ if (!worksheetCourses.length)
54
+ return { schemaVersion: 1, requiresR: false, worksheets: 0, verified: 0, reason: 'unused' };
55
+ const published = publicationFilter(repo, project);
56
+ const editions: { course: string; files: CourseFile[] }[] = [];
57
+ for (const [course, sources] of worksheetCourses) {
58
+ const noteSlugs = new Set<string>();
59
+ // Match the compiler's whole non-draft catalog, including locked notes.
60
+ for (const tracked of paths) {
61
+ if (
62
+ !tracked.startsWith(`${course}/`) ||
63
+ path.extname(tracked).toLowerCase() !== '.md' ||
64
+ tracked.split('/').some((segment) => segment.startsWith('.'))
65
+ )
66
+ continue;
67
+ const source = path.resolve(project.contentDir, tracked);
68
+ const info = lstatSync(source, { throwIfNoEntry: false });
69
+ if (!info?.isFile() || realpathSync(source) !== source) continue;
70
+ if (info.size > 25 * 1024 * 1024) throw new Error('Uncertain oversized note catalog');
71
+ const { data } = matter(readFileSync(source, 'utf8'), {
72
+ engines: { yaml: (value) => yaml.load(value, { schema: yaml.JSON_SCHEMA }) as object }
73
+ });
74
+ if (data.draft !== true && data.draft !== 'true')
75
+ noteSlugs.add(slugifyFilePath(tracked as FilePath));
76
+ }
77
+ const full: CourseFile[] = [],
78
+ publicFiles: CourseFile[] = [];
79
+ for (const { relative, source } of sources) {
80
+ const size = lstatSync(source).size;
81
+ const bytes = size <= 25 * 1024 * 1024 ? readFileSync(source) : undefined;
82
+ const hash = bytes && createHash('sha256').update(bytes).digest('hex');
83
+ const extension = path.extname(source).slice(1).toLowerCase();
84
+ const suffix =
85
+ extension === 'pdf'
86
+ ? 'pdf'
87
+ : ['png', 'jpg', 'jpeg', 'gif', 'webp', 'avif'].includes(extension)
88
+ ? extension
89
+ : 'bin';
90
+ const file: CourseFile = {
91
+ path: relative,
92
+ size,
93
+ kind:
94
+ bytes && ['png', 'jpg', 'jpeg', 'gif', 'webp', 'avif'].includes(extension)
95
+ ? 'image'
96
+ : 'binary',
97
+ download: hash ? `/_files/blobs/${hash}.${suffix}` : undefined
98
+ };
99
+ const slug = slugifyFilePath(`${course}/${relative}` as FilePath);
100
+ if (extension === 'md' && noteSlugs.has(slug))
101
+ file.note = '/' + (slug.endsWith('/index') ? slug.slice(0, -6) : slug);
102
+ full.push(file);
103
+ const locked =
104
+ !published(`${course}/${relative}`) || (extension === 'md' && !noteSlugs.has(slug));
105
+ publicFiles.push(
106
+ locked ? { ...file, kind: 'binary', locked: true, download: undefined } : { ...file }
107
+ );
108
+ }
109
+ for (const files of [publicFiles, full]) {
110
+ worksheets += files.filter(
111
+ (file) => !file.locked && file.download && /\.rmd$/i.test(file.path)
112
+ ).length;
113
+ editions.push({ course, files });
114
+ }
115
+ }
116
+ if (!worksheets)
117
+ return { schemaVersion: 1, requiresR: false, worksheets: 0, verified: 0, reason: 'unused' };
118
+ let runtime: string;
119
+ try {
120
+ runtime = lockedRRuntime(site, environment);
121
+ } catch {
122
+ return {
123
+ schemaVersion: 1,
124
+ requiresR: true,
125
+ worksheets,
126
+ verified,
127
+ reason: 'profile-or-fonts'
128
+ };
129
+ }
130
+ const policy = await stageFingerprint('rmd');
131
+ for (const { course, files } of editions) {
132
+ const inputs = rmdCourseInputs(files);
133
+ for (const worksheet of files.filter(
134
+ (file) => !file.locked && file.download && /\.rmd$/i.test(file.path)
135
+ )) {
136
+ if (
137
+ !readRmdPreviewResult(site, {
138
+ runtime,
139
+ policy,
140
+ course,
141
+ inputs,
142
+ worksheet: worksheet.path
143
+ })
144
+ )
145
+ return {
146
+ schemaVersion: 1,
147
+ requiresR: true,
148
+ worksheets,
149
+ verified,
150
+ reason: 'missing-or-stale'
151
+ };
152
+ verified++;
153
+ }
154
+ }
155
+ return {
156
+ schemaVersion: 1,
157
+ requiresR: false,
158
+ worksheets,
159
+ verified,
160
+ reason: 'verified',
161
+ runtimeSha256: readRRenderProfile(site, environment)!.runtimeSha256
162
+ };
163
+ } catch {
164
+ return { schemaVersion: 1, requiresR: true, worksheets, verified, reason: 'invalid-inputs' };
165
+ }
166
+ }
@@ -0,0 +1,93 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { lstatSync, readFileSync, realpathSync } from 'node:fs';
3
+ import path from 'node:path';
4
+ import type { CourseFile, RmdPreview } from '../../src/lib/files';
5
+
6
+ const sha256 = (bytes: string | Uint8Array) => createHash('sha256').update(bytes).digest('hex');
7
+ export type RmdPreviewInputs = {
8
+ runtime: string;
9
+ policy: string;
10
+ course: string;
11
+ inputs: string;
12
+ worksheet: string;
13
+ };
14
+ export type RmdPreviewResult = { preview: string; blobs: string[] };
15
+
16
+ export function rmdCourseInputs(files: CourseFile[]) {
17
+ return JSON.stringify({
18
+ available: files
19
+ .filter((file) => !file.locked && file.download)
20
+ .map(({ path, download }) => [path, download])
21
+ .sort(),
22
+ // parseRmd also resolves links and embeds against locked or oversized entries.
23
+ // Preserve catalog order, which determines the first exact matching candidate.
24
+ catalog: files.map((file) => [
25
+ file.path,
26
+ file.kind === 'image',
27
+ Boolean(file.locked),
28
+ file.note ?? null,
29
+ file.download ?? null
30
+ ])
31
+ });
32
+ }
33
+
34
+ export function rmdPreviewKey(inputs: RmdPreviewInputs) {
35
+ return sha256(
36
+ `${inputs.runtime}\n${inputs.policy}\n${inputs.course}\n${inputs.inputs}\n${inputs.worksheet}`
37
+ );
38
+ }
39
+
40
+ export function rmdPreviewRecord(inputs: RmdPreviewInputs, result: RmdPreviewResult) {
41
+ return { schemaVersion: 2, ...inputs, ...result };
42
+ }
43
+
44
+ /** Validate actual immutable bytes, not only existence or restored file timestamps. */
45
+ export function readRmdPreviewResult(
46
+ site: string,
47
+ inputs: RmdPreviewInputs
48
+ ): RmdPreviewResult | undefined {
49
+ try {
50
+ if (!/\nfontconfig-v1:[a-f0-9]{64}$/.test(inputs.runtime)) return;
51
+ const directory = path.resolve(site, 'build/generated/cache/rmd', rmdPreviewKey(inputs));
52
+ if (realpathSync(directory) !== directory) return;
53
+ const read = (name: string) => {
54
+ const file = path.join(directory, name);
55
+ if (!lstatSync(file).isFile() || realpathSync(file) !== file)
56
+ throw new Error('Unsafe R preview cache file');
57
+ const info = lstatSync(file);
58
+ if (info.size > 25 * 1024 * 1024) throw new Error('Oversized R preview cache file');
59
+ return readFileSync(file);
60
+ };
61
+ const saved = JSON.parse(read('result.json').toString());
62
+ if (
63
+ saved.schemaVersion !== 2 ||
64
+ Object.entries(inputs).some(([name, value]) => saved[name] !== value) ||
65
+ !Array.isArray(saved.blobs) ||
66
+ !saved.blobs.length ||
67
+ saved.blobs.length > 1024 ||
68
+ typeof saved.preview !== 'string' ||
69
+ !saved.blobs.includes(saved.preview) ||
70
+ new Set(saved.blobs).size !== saved.blobs.length
71
+ )
72
+ return;
73
+ for (const blob of saved.blobs) {
74
+ if (typeof blob !== 'string' || !/^[a-f0-9]{64}\.(?:json|png|jpg|jpeg|svg|pdf)$/.test(blob))
75
+ return;
76
+ if (sha256(read(blob)) !== blob.slice(0, 64)) return;
77
+ }
78
+ if (!saved.preview.endsWith('.json')) return;
79
+ const preview: RmdPreview = JSON.parse(read(saved.preview).toString());
80
+ if (typeof preview.title !== 'string' || typeof preview.html !== 'string') return;
81
+ const downloads = new Set<string>();
82
+ for (const [, download] of JSON.parse(inputs.inputs).available) {
83
+ if (typeof download === 'string' && /^\/_files\/blobs\/[a-f0-9]{64}\.[a-z]+$/.test(download))
84
+ downloads.add(path.basename(download));
85
+ }
86
+ for (const match of preview.html.matchAll(/\/_files\/blobs\/([a-f0-9]{64}\.[a-z]+)/g)) {
87
+ if (!saved.blobs.includes(match[1]) && !downloads.has(match[1])) return;
88
+ }
89
+ return { preview: saved.preview, blobs: saved.blobs };
90
+ } catch {
91
+ return;
92
+ }
93
+ }
@@ -0,0 +1,129 @@
1
+ import { stageFingerprint } from '../fingerprint';
2
+ import { execFileSync } from 'node:child_process';
3
+ import { createHash } from 'node:crypto';
4
+ import { mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, copyFileSync } from 'node:fs';
5
+ import { tmpdir } from 'node:os';
6
+ import path from 'node:path';
7
+ import { parseRmd } from '../../src/lib/rmd';
8
+ import type { CourseFile, RmdPreview } from '../../src/lib/files';
9
+ import { writeChanged } from './output';
10
+ import { lockedRRuntime, rRuntimeVersion, verifyRRenderProfile } from './r-runtime';
11
+ import {
12
+ readRmdPreviewResult,
13
+ rmdCourseInputs,
14
+ rmdPreviewKey,
15
+ rmdPreviewRecord
16
+ } from './rmd-preview-cache';
17
+
18
+ const runner = path.join(import.meta.dirname, 'knit-rmd.R');
19
+ const digest = (bytes: string | Uint8Array) => createHash('sha256').update(bytes).digest('hex');
20
+ const limit = 25 * 1024 * 1024;
21
+
22
+ /** Knit only available course files, in a temporary copy, never in the source checkout. */
23
+ export async function buildRmdPreviews(
24
+ site: string,
25
+ course: string,
26
+ files: CourseFile[],
27
+ output: string,
28
+ retain: (file: string) => void
29
+ ) {
30
+ const worksheets = files.filter(
31
+ (file) => !file.locked && file.download && /\.rmd$/i.test(file.path)
32
+ );
33
+ if (!worksheets.length) return;
34
+ const cacheOnly = process.env.WISCONSIN_R_CACHE_ONLY === '1';
35
+ let runtime: string;
36
+ try {
37
+ runtime = cacheOnly ? lockedRRuntime(site) : rRuntimeVersion();
38
+ } catch (error) {
39
+ if (cacheOnly || process.env.WISCONSIN_R_REQUIRE_PROFILE === '1')
40
+ throw new Error(
41
+ 'Verified R previews are unavailable; install the locked R renderer before building',
42
+ { cause: error }
43
+ );
44
+ const message = error instanceof Error ? error.message : String(error);
45
+ console.warn(`rmd: ${message} Using Source and Interactive views.`);
46
+ for (const worksheet of worksheets) delete worksheet.rmdPreview;
47
+ return;
48
+ }
49
+ verifyRRenderProfile(site, runtime);
50
+ const available = files.filter((file) => !file.locked && file.download);
51
+ const inputs = rmdCourseInputs(files);
52
+ const policy = await stageFingerprint('rmd');
53
+ for (const worksheet of worksheets) {
54
+ const context = { runtime, policy, course, inputs, worksheet: worksheet.path };
55
+ const key = rmdPreviewKey(context);
56
+ const cache = path.join(site, 'build/generated/cache/rmd', key);
57
+ const record = path.join(cache, 'result.json');
58
+ let result = readRmdPreviewResult(site, context);
59
+ if (!result && cacheOnly)
60
+ throw new Error(
61
+ `Verified R preview is missing or stale for ${course}/${worksheet.path}; install the locked R renderer before building`
62
+ );
63
+ if (!result) {
64
+ const scratch = mkdtempSync(path.join(tmpdir(), 'wisconsin-rmd-'));
65
+ try {
66
+ const working = path.join(scratch, 'course');
67
+ for (const file of available) {
68
+ const target = path.join(working, file.path);
69
+ mkdirSync(path.dirname(target), { recursive: true });
70
+ copyFileSync(path.join(output, 'blobs', path.basename(file.download!)), target);
71
+ }
72
+ const figures = path.join(scratch, 'figures');
73
+ mkdirSync(figures);
74
+ const knitted = path.join(scratch, 'knitted.md');
75
+ execFileSync(
76
+ process.env.RSCRIPT ?? 'Rscript',
77
+ ['--vanilla', runner, path.join(working, worksheet.path), knitted, figures],
78
+ { cwd: working, timeout: 120_000, maxBuffer: 8 * 1024 * 1024, stdio: 'pipe' }
79
+ );
80
+ let markdown = readFileSync(knitted, 'utf8');
81
+ const blobs: string[] = [];
82
+ const save = (bytes: Uint8Array, extension: string) => {
83
+ if (bytes.length > limit)
84
+ throw new Error('Rendered R Markdown asset exceeds the 25 MiB hosting limit');
85
+ const blob = `${digest(bytes)}.${extension}`;
86
+ writeChanged(path.join(cache, blob), bytes);
87
+ blobs.push(blob);
88
+ return blob;
89
+ };
90
+ for (const figure of readdirSync(figures, { recursive: true, withFileTypes: true })) {
91
+ if (!figure.isFile()) continue;
92
+ const source = path.join(figure.parentPath, figure.name);
93
+ const extension = path.extname(source).slice(1);
94
+ if (!['png', 'jpg', 'jpeg', 'svg', 'pdf'].includes(extension))
95
+ throw new Error(`Unsupported R plot format: ${extension}`);
96
+ const blob = save(readFileSync(source), extension);
97
+ markdown = markdown.split(source).join(`/_files/blobs/${blob}`);
98
+ }
99
+ const parsed = await parseRmd(markdown, course, worksheet.path, files);
100
+ if (parsed.blocks.some((block) => block.kind === 'r'))
101
+ throw new Error('Knitting left an unevaluated R chunk');
102
+ const preview: RmdPreview = {
103
+ title: parsed.title,
104
+ html: parsed.blocks
105
+ .map((block) => (block.kind === 'markdown' ? block.html : ''))
106
+ .join('\n')
107
+ };
108
+ result = {
109
+ preview: save(Buffer.from(JSON.stringify(preview)), 'json'),
110
+ blobs: [...new Set(blobs)]
111
+ };
112
+ writeChanged(record, JSON.stringify(rmdPreviewRecord(context, result)));
113
+ console.log(`rmd: rendered ${course}/${worksheet.path}`);
114
+ } catch (error) {
115
+ throw new Error(`Could not render ${course}/${worksheet.path}: ${String(error)}`, {
116
+ cause: error
117
+ });
118
+ } finally {
119
+ rmSync(scratch, { recursive: true, force: true });
120
+ }
121
+ }
122
+ for (const blob of result.blobs) {
123
+ const destination = path.join(output, 'blobs', blob);
124
+ writeChanged(destination, readFileSync(path.join(cache, blob)));
125
+ retain(destination);
126
+ }
127
+ worksheet.rmdPreview = `/_files/blobs/${result.preview}`;
128
+ }
129
+ }