@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,30 @@
1
+ import path from 'node:path';
2
+
3
+ // Content changes update these revalidated assets without renaming application
4
+ // chunks referenced by every prerendered page. Keep them outside _app/immutable.
5
+ export const runtimeDataAssets = {
6
+ 'nav.json': 'navigation.js',
7
+ 'file-icons.json': 'file-icons.js'
8
+ } as const;
9
+
10
+ export function runtimeDataModuleSource(data: unknown): string {
11
+ // JSON.parse preserves own "__proto__" keys in filename/folder catalogs;
12
+ // a JavaScript object literal would interpret those keys as prototype setters.
13
+ return `export default JSON.parse(${JSON.stringify(JSON.stringify(data))});\n`;
14
+ }
15
+
16
+ export function resolveRuntimeDataImport(
17
+ source: string,
18
+ config: { root: string; command: 'build' | 'serve'; consumer?: 'client' | 'server' }
19
+ ) {
20
+ if (config.command !== 'build' || config.consumer !== 'client') return;
21
+ // SSR and development retain the JSON import. Production browsers load exactly
22
+ // the same edition-specific data through the authenticated asset routing layer.
23
+ for (const [input, asset] of Object.entries(runtimeDataAssets)) {
24
+ if (
25
+ source === `$lib/generated/${input}` ||
26
+ source === path.join(config.root, 'src/lib/generated', input)
27
+ )
28
+ return { id: `/${asset}`, external: 'absolute' as const };
29
+ }
30
+ }
@@ -0,0 +1,321 @@
1
+ import {
2
+ constants,
3
+ cpSync,
4
+ existsSync,
5
+ lstatSync,
6
+ mkdirSync,
7
+ mkdtempSync,
8
+ readFileSync,
9
+ readdirSync,
10
+ realpathSync,
11
+ renameSync,
12
+ rmSync,
13
+ writeFileSync
14
+ } from 'node:fs';
15
+ import path from 'node:path';
16
+ import type { ContentManifest, PageDoc } from '../../src/lib/types';
17
+ import { fileRoute } from '../../src/lib/files';
18
+ import { createContentModel } from '../../src/lib/server/content-model';
19
+ import { captureAssetHashes } from './asset-hash-cache';
20
+ import { sha256 } from './deployment-config';
21
+ import {
22
+ assetContentType,
23
+ createAssetInventory,
24
+ mergeVerifiedInventories,
25
+ type AssetInventory,
26
+ type DeploymentAsset
27
+ } from './deployment-manifest';
28
+
29
+ export type RuntimeEdition = 'public' | 'full';
30
+ export type CapturedEdition = {
31
+ edition: RuntimeEdition;
32
+ metadata: string;
33
+ staticRoot: string;
34
+ routes: string[];
35
+ staticAssets: Record<string, DeploymentAsset>;
36
+ sources: Record<string, string>;
37
+ };
38
+ const copyOptions = { recursive: true, mode: constants.COPYFILE_FICLONE, preserveTimestamps: true };
39
+ const urlPath = (file: string) => '/' + file.split('/').map(encodeURIComponent).join('/');
40
+ function ownedFile(root: string, file: string) {
41
+ const absolute = path.resolve(root, file);
42
+ if (
43
+ !absolute.startsWith(path.resolve(root) + path.sep) ||
44
+ !lstatSync(absolute).isFile() ||
45
+ realpathSync(absolute) !== absolute
46
+ )
47
+ throw new Error('Runtime artifact must be an owned regular file');
48
+ return absolute;
49
+ }
50
+ function files(directory: string): string[] {
51
+ const result: string[] = [];
52
+ function walk(relative: string) {
53
+ for (const entry of readdirSync(path.join(directory, relative), { withFileTypes: true })) {
54
+ const file = relative ? `${relative}/${entry.name}` : entry.name;
55
+ if (entry.isDirectory()) walk(file);
56
+ else {
57
+ ownedFile(directory, file);
58
+ result.push(file);
59
+ }
60
+ }
61
+ }
62
+ walk('');
63
+ return result.sort();
64
+ }
65
+ function copy(source: string, destination: string) {
66
+ mkdirSync(path.dirname(destination), { recursive: true });
67
+ cpSync(source, destination, copyOptions);
68
+ }
69
+ function asset(site: string, source: string, filename: string): DeploymentAsset {
70
+ return { ...captureAssetHashes(site, source), contentType: assetContentType(filename) };
71
+ }
72
+ function staticFiles(root: string) {
73
+ return files(root).filter(
74
+ (file) => !file.startsWith('.') && !['_headers', '_redirects', '_worker.js'].includes(file)
75
+ );
76
+ }
77
+
78
+ /** Capture an edition immediately after its pipeline, before the next edition replaces shared JSON. */
79
+ export function captureRuntimeEdition(
80
+ site: string,
81
+ edition: RuntimeEdition,
82
+ metadata: string,
83
+ searchDirectory?: string
84
+ ): CapturedEdition {
85
+ if (existsSync(metadata)) throw new Error('Runtime metadata staging already exists');
86
+ mkdirSync(metadata, { recursive: true });
87
+ const generated = path.join(site, 'build/generated');
88
+ const manifest: ContentManifest = JSON.parse(
89
+ readFileSync(ownedFile(generated, 'content-manifest.json'), 'utf8')
90
+ );
91
+ copy(ownedFile(generated, 'content-manifest.json'), path.join(metadata, 'manifest.json'));
92
+ for (const slug of Object.keys(manifest.pages)) {
93
+ const name = `pages/${slug}.json`;
94
+ const source = ownedFile(generated, name);
95
+ const page: PageDoc = JSON.parse(readFileSync(source, 'utf8'));
96
+ if (
97
+ page.slug !== slug ||
98
+ page.publication.public !== manifest.pages[slug].publication.public ||
99
+ (edition === 'public' &&
100
+ !page.publication.public &&
101
+ (!page.locked || page.html || page.markdown || page.description))
102
+ )
103
+ throw new Error('Runtime PageDoc publication does not match its edition');
104
+ copy(source, path.join(metadata, name));
105
+ }
106
+ for (const name of ['nav.json', 'file-icons.json'])
107
+ copy(ownedFile(path.join(site, 'src/lib/generated'), name), path.join(metadata, name));
108
+ const staticRoot = path.join(
109
+ site,
110
+ edition === 'public' ? 'build/generated/public-static' : 'static'
111
+ );
112
+ const staticAssets: Record<string, DeploymentAsset> = Object.create(null);
113
+ const sources: Record<string, string> = Object.create(null);
114
+ for (const name of staticFiles(staticRoot)) {
115
+ if (name.startsWith('pagefind/')) continue;
116
+ if (/^(?:_app|_content|_published)(?:\/|$)/.test(name))
117
+ throw new Error('Runtime assets cannot replace application/internal namespaces');
118
+ sources[name] = ownedFile(staticRoot, name);
119
+ staticAssets[name] = asset(site, sources[name], urlPath(name));
120
+ }
121
+ if (searchDirectory)
122
+ for (const name of files(path.join(searchDirectory, 'pagefind'))) {
123
+ const target = `pagefind/${name}`;
124
+ sources[target] = ownedFile(path.join(searchDirectory, 'pagefind'), name);
125
+ staticAssets[target] = asset(site, sources[target], urlPath(target));
126
+ }
127
+ const model = createContentModel(manifest);
128
+ const routes = new Set([
129
+ '/',
130
+ '/tags',
131
+ '/index.xml',
132
+ '/sitemap.xml',
133
+ '/graph.json',
134
+ '/robots.txt',
135
+ '/404',
136
+ ...model.contentEntries().map((route) => (route ? '/' + route : '/')),
137
+ ...Object.keys(manifest.tags).map((tag) => `/tags/${tag}`)
138
+ ]);
139
+ const catalogs = path.join(staticRoot, '_files/index');
140
+ if (existsSync(catalogs))
141
+ for (const name of readdirSync(catalogs)) {
142
+ if (!name.endsWith('.json')) continue;
143
+ const course = name.slice(0, -5);
144
+ const entries = JSON.parse(readFileSync(ownedFile(catalogs, name), 'utf8')) as {
145
+ path: string;
146
+ }[];
147
+ routes.add(fileRoute(course, ''));
148
+ for (const entry of entries) {
149
+ const segments = entry.path.split('/');
150
+ for (let i = 1; i <= segments.length; i++)
151
+ routes.add(fileRoute(course, segments.slice(0, i).join('/')));
152
+ }
153
+ }
154
+ const aliases: Record<string, string> = Object.create(null);
155
+ for (const name of Object.keys(staticAssets))
156
+ aliases[urlPath(name)] = edition === 'public' ? `/_published${urlPath(name)}` : urlPath(name);
157
+ for (const name of manifest.htmlAssets) {
158
+ const target =
159
+ edition === 'public' ? `/_published${urlPath(`${name}.html`)}` : urlPath(`${name}.html`);
160
+ if (!staticAssets[`${name}.html`]) throw new Error('Runtime HTML asset is missing');
161
+ aliases[urlPath(name)] = aliases[urlPath(`${name}.html`)] = target;
162
+ }
163
+ const navHash = captureAssetHashes(site, path.join(metadata, 'nav.json')).sha256;
164
+ const iconHash = captureAssetHashes(site, path.join(metadata, 'file-icons.json')).sha256;
165
+ const manifestHash = captureAssetHashes(site, path.join(metadata, 'manifest.json')).sha256;
166
+ const htmlKeys: Record<string, string> = Object.create(null);
167
+ for (const route of routes) {
168
+ const slug = model.pageSlugForRoute(route === '/' ? '' : route.slice(1));
169
+ htmlKeys[route] = slug
170
+ ? sha256(
171
+ JSON.stringify([
172
+ captureAssetHashes(site, path.join(metadata, `pages/${slug}.json`)).sha256,
173
+ navHash,
174
+ iconHash
175
+ ])
176
+ )
177
+ : sha256(JSON.stringify([manifestHash, navHash, iconHash, route]));
178
+ }
179
+ writeFileSync(
180
+ path.join(metadata, 'routing.json'),
181
+ JSON.stringify({ schemaVersion: 1, routes: [...routes].sort(), assets: aliases, htmlKeys })
182
+ );
183
+ return { edition, metadata, staticRoot, routes: [...routes].sort(), staticAssets, sources };
184
+ }
185
+
186
+ export function packageRuntime(
187
+ site: string,
188
+ output: string,
189
+ applicationVersion: string,
190
+ editions: CapturedEdition[],
191
+ appInventory: AssetInventory
192
+ ) {
193
+ if (
194
+ !/^[a-f0-9]{64}$/.test(applicationVersion) ||
195
+ editions.length !== 2 ||
196
+ new Set(editions.map((value) => value.edition)).size !== 2
197
+ )
198
+ throw new Error('Runtime package requires one verified public and full edition');
199
+ const pieces = [{ owner: 'application', inventory: appInventory }];
200
+ const ownership: Record<string, { owner: string; sha256: string; size: number }> =
201
+ Object.create(null);
202
+ for (const [name, value] of Object.entries(appInventory.assets))
203
+ ownership[name] = { owner: 'application', sha256: value.sha256, size: value.size };
204
+ const snapshots = { public: '', full: '' };
205
+ for (const edition of editions) {
206
+ if (edition.edition === 'public') {
207
+ const routeFile = path.join(edition.metadata, 'routing.json');
208
+ const routing = JSON.parse(readFileSync(routeFile, 'utf8'));
209
+ for (const [name, value] of Object.entries(appInventory.assets)) {
210
+ if (!name.startsWith('/_app/') && !name.startsWith('/fonts/') && name !== '/favicon.png')
211
+ continue;
212
+ const key = urlPath(name.slice(1));
213
+ const inherited = edition.staticAssets[name.slice(1)];
214
+ if (inherited && (inherited.sha256 !== value.sha256 || inherited.size !== value.size))
215
+ throw new Error('Shared application asset conflicts with public static output');
216
+ routing.assets[key] = key;
217
+ }
218
+ writeFileSync(routeFile, JSON.stringify(routing));
219
+ }
220
+ const metadataAssets: Record<string, DeploymentAsset> = Object.create(null);
221
+ for (const name of files(edition.metadata))
222
+ metadataAssets[name] = asset(site, ownedFile(edition.metadata, name), urlPath(name));
223
+ const snapshot = sha256(
224
+ JSON.stringify([
225
+ edition.edition,
226
+ Object.entries(metadataAssets).map(([name, value]) => [name, value.sha256, value.size]),
227
+ Object.entries(edition.staticAssets).map(([name, value]) => [
228
+ name,
229
+ value.sha256,
230
+ value.size
231
+ ])
232
+ ])
233
+ );
234
+ snapshots[edition.edition] = snapshot;
235
+ const assets: Record<string, DeploymentAsset> = Object.create(null);
236
+ for (const [name, value] of Object.entries(metadataAssets)) {
237
+ const physical = `/_content/${edition.edition}/${snapshot}/${name}`;
238
+ copy(ownedFile(edition.metadata, name), path.join(output, physical));
239
+ assets[physical] = value;
240
+ }
241
+ for (const [name, value] of Object.entries(edition.staticAssets)) {
242
+ const physical = edition.edition === 'public' ? `/_published/${name}` : `/${name}`;
243
+ const source = edition.sources[name];
244
+ if (!source || !lstatSync(source).isFile() || realpathSync(source) !== source)
245
+ throw new Error('Runtime source changed before packaging');
246
+ copy(source, path.join(output, physical));
247
+ assets[physical] = value;
248
+ }
249
+ const product = createAssetInventory(assets, snapshot);
250
+ pieces.push({ owner: edition.edition, inventory: product });
251
+ for (const [name, value] of Object.entries(assets))
252
+ ownership[name] = { owner: edition.edition, sha256: value.sha256, size: value.size };
253
+ }
254
+ const descriptor = Buffer.from(
255
+ JSON.stringify({ schemaVersion: 1, applicationVersion, snapshots })
256
+ );
257
+ const provenance = sha256(descriptor);
258
+ const currentPath = path.join(output, '_content/current.json');
259
+ mkdirSync(path.dirname(currentPath), { recursive: true });
260
+ writeFileSync(currentPath, descriptor); // Immutable snapshots are complete before the current pointer appears.
261
+ const control = createAssetInventory(
262
+ { '/_content/current.json': asset(site, currentPath, '/_content/current.json') },
263
+ provenance
264
+ );
265
+ pieces.push({ owner: 'current', inventory: control });
266
+ ownership['/_content/current.json'] = {
267
+ owner: 'current',
268
+ sha256: control.assets['/_content/current.json'].sha256,
269
+ size: descriptor.length
270
+ };
271
+ const inventory = mergeVerifiedInventories({
272
+ pieces,
273
+ expectedProducts: Object.fromEntries(
274
+ pieces.map((piece) => [
275
+ piece.owner,
276
+ { provenance: piece.inventory.provenance, digest: piece.inventory.digest }
277
+ ])
278
+ ),
279
+ ownership,
280
+ provenance,
281
+ controlFiles: appInventory.controlFiles
282
+ });
283
+ return {
284
+ snapshots,
285
+ provenance,
286
+ inventory,
287
+ publicRoutes: editions.find((value) => value.edition === 'public')!.routes.length
288
+ };
289
+ }
290
+
291
+ /** Install a complete product after its application, both editions and descriptor have succeeded. */
292
+ export function promoteRuntimeProduct(site: string, staged: string) {
293
+ const target = path.join(site, 'build/.svelte-kit');
294
+ const transaction = mkdtempSync(path.join(site, 'build/.runtime-promotion-'));
295
+ const previous = path.join(transaction, 'previous');
296
+ let moved = false,
297
+ preserve = false;
298
+ try {
299
+ if (existsSync(target)) {
300
+ renameSync(target, previous);
301
+ moved = true;
302
+ }
303
+ try {
304
+ renameSync(staged, target);
305
+ } catch (error) {
306
+ if (moved)
307
+ try {
308
+ renameSync(previous, target);
309
+ } catch (rollback) {
310
+ preserve = true;
311
+ throw new AggregateError(
312
+ [error, rollback],
313
+ 'Previous runtime product preserved for recovery'
314
+ );
315
+ }
316
+ throw error;
317
+ }
318
+ } finally {
319
+ if (!preserve) rmSync(transaction, { recursive: true, force: true });
320
+ }
321
+ }
@@ -0,0 +1,165 @@
1
+ import { createHash } from 'node:crypto';
2
+ import {
3
+ lstatSync,
4
+ mkdirSync,
5
+ mkdtempSync,
6
+ readFileSync,
7
+ readdirSync,
8
+ realpathSync,
9
+ renameSync,
10
+ rmSync,
11
+ writeFileSync
12
+ } from 'node:fs';
13
+ import path from 'node:path';
14
+
15
+ /** @param {string | Uint8Array} bytes */
16
+ const digest = (bytes) => createHash('sha256').update(bytes).digest('hex');
17
+
18
+ /** The exact HTML/custom records, their submission order and Pagefind version
19
+ * identify the complete index. Neither checkout paths nor filesystem timestamps
20
+ * participate. Edition is explicit even when two tiny fixtures happen to match.
21
+ * @param {{html: import('pagefind').HTMLFile[]; records: import('pagefind').CustomRecord[]; pagefindVersion: string; edition: string}} input
22
+ */
23
+ export function searchInputKey(input) {
24
+ return digest(JSON.stringify(['pagefind-inputs-v1', input]));
25
+ }
26
+
27
+ /** @param {string} file */
28
+ function safePath(file) {
29
+ return (
30
+ file.length < 1024 &&
31
+ /^[\w.-]+(?:\/[\w.-]+)*$/.test(file) &&
32
+ file.split('/').every((segment) => segment !== '.' && segment !== '..')
33
+ );
34
+ }
35
+
36
+ /** @param {string} root */
37
+ function filesIn(root) {
38
+ /** @type {string[]} */
39
+ const files = [];
40
+ /** @param {string} directory */
41
+ function visit(directory) {
42
+ for (const entry of readdirSync(path.join(root, directory), { withFileTypes: true })) {
43
+ const relative = directory ? `${directory}/${entry.name}` : entry.name;
44
+ if (!safePath(relative)) throw new Error('Invalid Pagefind output path');
45
+ if (entry.isDirectory()) visit(relative);
46
+ else if (entry.isFile()) files.push(relative);
47
+ else throw new Error('Pagefind output must contain regular files');
48
+ }
49
+ }
50
+ visit('');
51
+ return files.sort();
52
+ }
53
+
54
+ /** Copy a fully validated generation into a clean directory. Validate before
55
+ * replacing any output so a corrupt/missing cache cannot leave a partial index.
56
+ * @param {string} cache @param {string} output @param {string} key @param {string} edition
57
+ */
58
+ export function restoreSearchCache(cache, output, key, edition) {
59
+ /** @type {string | undefined} */
60
+ let temporary;
61
+ try {
62
+ const root = realpathSync(cache);
63
+ const manifestPath = path.join(root, 'manifest.json');
64
+ if (!lstatSync(manifestPath).isFile() || realpathSync(manifestPath) !== manifestPath)
65
+ return false;
66
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
67
+ if (
68
+ manifest.version !== 1 ||
69
+ manifest.key !== key ||
70
+ manifest.edition !== edition ||
71
+ !Array.isArray(manifest.files) ||
72
+ manifest.filesSha256 !== digest(JSON.stringify(manifest.files)) ||
73
+ !manifest.files.length ||
74
+ manifest.files.length > 100000
75
+ )
76
+ return false;
77
+ /** @type {Map<string, Buffer>} */
78
+ const verified = new Map();
79
+ let total = 0;
80
+ for (const file of manifest.files) {
81
+ if (
82
+ typeof file.path !== 'string' ||
83
+ !safePath(file.path) ||
84
+ verified.has(file.path) ||
85
+ !Number.isSafeInteger(file.bytes) ||
86
+ file.bytes < 0 ||
87
+ !/^[a-f0-9]{64}$/.test(file.sha256)
88
+ )
89
+ return false;
90
+ total += file.bytes;
91
+ if (total > 512 * 1024 * 1024) return false;
92
+ const source = path.join(root, 'files', file.path);
93
+ const stat = lstatSync(source);
94
+ if (!stat.isFile() || stat.size !== file.bytes || realpathSync(source) !== source)
95
+ return false;
96
+ const bytes = readFileSync(source);
97
+ if (digest(bytes) !== file.sha256) return false;
98
+ verified.set(file.path, bytes);
99
+ }
100
+ if (!verified.has('pagefind.js') || !verified.has('pagefind-entry.json')) return false;
101
+ mkdirSync(path.dirname(output), { recursive: true });
102
+ temporary = mkdtempSync(`${output}.tmp-`);
103
+ for (const [file, bytes] of verified) {
104
+ mkdirSync(path.dirname(path.join(temporary, file)), { recursive: true });
105
+ writeFileSync(path.join(temporary, file), bytes);
106
+ }
107
+ rmSync(output, { recursive: true, force: true });
108
+ renameSync(temporary, output);
109
+ temporary = undefined;
110
+ return true;
111
+ } catch {
112
+ return false;
113
+ } finally {
114
+ if (temporary) rmSync(temporary, { recursive: true, force: true });
115
+ }
116
+ }
117
+
118
+ /** Publish a successful complete index, then its manifest. Returns portable
119
+ * filenames for encrypted cache transport, including the validating manifest.
120
+ * @param {string} cacheRoot @param {string} output @param {string} key @param {string} edition
121
+ */
122
+ export function saveSearchCache(cacheRoot, output, key, edition) {
123
+ const relative = `search/${edition}/${key}`;
124
+ const cache = path.join(cacheRoot, relative);
125
+ const files = filesIn(output);
126
+ if (!files.includes('pagefind.js') || !files.includes('pagefind-entry.json'))
127
+ throw new Error('Incomplete Pagefind output');
128
+ mkdirSync(path.dirname(cache), { recursive: true });
129
+ const temporary = mkdtempSync(`${cache}.tmp-`);
130
+ try {
131
+ const manifest = {
132
+ version: 1,
133
+ key,
134
+ edition,
135
+ files: files.map((file) => {
136
+ const bytes = readFileSync(path.join(output, file));
137
+ mkdirSync(path.dirname(path.join(temporary, 'files', file)), { recursive: true });
138
+ writeFileSync(path.join(temporary, 'files', file), bytes);
139
+ return { path: file, bytes: bytes.length, sha256: digest(bytes) };
140
+ })
141
+ };
142
+ writeFileSync(
143
+ path.join(temporary, 'manifest.json'),
144
+ JSON.stringify({ ...manifest, filesSha256: digest(JSON.stringify(manifest.files)) })
145
+ );
146
+ rmSync(cache, { recursive: true, force: true });
147
+ renameSync(temporary, cache);
148
+ return [`${relative}/manifest.json`, ...files.map((file) => `${relative}/files/${file}`)];
149
+ } finally {
150
+ rmSync(temporary, { recursive: true, force: true });
151
+ }
152
+ }
153
+
154
+ /** @param {string} cacheRoot @param {string} edition @param {string[]} files */
155
+ export function selectSearchCache(cacheRoot, edition, files) {
156
+ mkdirSync(cacheRoot, { recursive: true });
157
+ const target = path.join(cacheRoot, `search-current-${edition}.json`);
158
+ const temporary = `${target}.tmp-${process.pid}`;
159
+ try {
160
+ writeFileSync(temporary, JSON.stringify(files));
161
+ renameSync(temporary, target);
162
+ } finally {
163
+ rmSync(temporary, { force: true });
164
+ }
165
+ }
@@ -0,0 +1,129 @@
1
+ /** Lightweight equivalents of the searchable content in the document routes.
2
+ * The application shell, controls, backlinks and ignored metadata are not search
3
+ * content. Keep HTML rather than custom records so Pagefind retains heading
4
+ * anchors, HTML tokenization and excerpt semantics.
5
+ */
6
+
7
+ /** @param {string} value */
8
+ export function escapeSearchHtml(value) {
9
+ return value
10
+ .replaceAll('&', '&amp;')
11
+ .replaceAll('<', '&lt;')
12
+ .replaceAll('>', '&gt;')
13
+ .replaceAll('"', '&quot;')
14
+ .replaceAll("'", '&#39;');
15
+ }
16
+
17
+ /** @param {string} slug */
18
+ function displayRoute(slug) {
19
+ return slug === 'index' ? '' : slug.endsWith('/index') ? slug.slice(0, -6) : slug;
20
+ }
21
+
22
+ /** @param {string} title @param {string} body */
23
+ function document(title, body) {
24
+ return `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>${escapeSearchHtml(title)}</title></head><body><article data-pagefind-body>${body}</article></body></html>`;
25
+ }
26
+
27
+ /** @param {string} course */
28
+ function courseFilter(course) {
29
+ return `<span data-pagefind-ignore data-pagefind-filter="course">${escapeSearchHtml(course)}</span>`;
30
+ }
31
+
32
+ /** Match the article in [...slug]/+page.svelte, including its leading-H1 fallback.
33
+ * @param {import('../../src/lib/types').PageDoc} page
34
+ */
35
+ export function noteSearchInput(page) {
36
+ if (page.locked) return;
37
+ const route = displayRoute(page.slug);
38
+ const heading = page.html.match(/^\s*<h1\b[^>]*>[\s\S]*?<\/h1>/i)?.[0];
39
+ const metadata = route
40
+ ? `<div data-pagefind-ignore>${courseFilter(route.split('/')[0])}${page.tags.map((tag) => `<span data-pagefind-filter="tag">${escapeSearchHtml(tag)}</span>`).join('')}</div><span data-pagefind-meta="title">${escapeSearchHtml(page.title)}</span>`
41
+ : '';
42
+ return {
43
+ url: `/${route}`,
44
+ content: document(
45
+ page.title,
46
+ `${metadata}${heading ? page.html : `<h1>${escapeSearchHtml(page.title)}</h1>${page.html}`}`
47
+ )
48
+ };
49
+ }
50
+
51
+ /** @param {import('../../src/lib/types').ContentManifest} manifest
52
+ * @param {(slug: string) => import('../../src/lib/types').PageDoc} loadPage
53
+ * @returns {import('pagefind').HTMLFile[]}
54
+ */
55
+ export function searchHtmlInputs(manifest, loadPage) {
56
+ /** @type {import('pagefind').HTMLFile[]} */
57
+ const inputs = [];
58
+ const routes = new Set();
59
+ for (const slug of Object.keys(manifest.pages)) {
60
+ const route = displayRoute(slug);
61
+ // The route loader prefers an exact page over a colliding folder index.
62
+ if (routes.has(route) || (slug.endsWith('/index') && manifest.pages[route])) continue;
63
+ routes.add(route);
64
+ const input = noteSearchInput(loadPage(slug));
65
+ if (input) inputs.push(input);
66
+ }
67
+ /** @type {Map<string, {name: string; count: number}>} */
68
+ const folders = new Map();
69
+ /** @param {import('../../src/lib/types').TreeNode} node @returns {number} */
70
+ function visit(node) {
71
+ const count = node.pages.length + node.children.reduce((sum, child) => sum + visit(child), 0);
72
+ folders.set(node.slug, { name: node.name, count });
73
+ return count;
74
+ }
75
+ visit(manifest.tree);
76
+ for (const folder of ['', ...manifest.folders]) {
77
+ if (routes.has(folder)) continue;
78
+ const listing = folders.get(folder);
79
+ if (!listing) continue;
80
+ routes.add(folder);
81
+ const name = folder ? folder.split('/').at(-1) || folder : listing.name;
82
+ inputs.push({
83
+ url: `/${folder}`,
84
+ content: document(
85
+ name,
86
+ `${folder ? courseFilter(folder.split('/')[0]) : ''}<h1>${escapeSearchHtml(name)}</h1><p>${listing.count} ${listing.count === 1 ? 'item' : 'items'} under this folder.</p>`
87
+ )
88
+ });
89
+ }
90
+ const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' });
91
+ const tags = Object.keys(manifest.tags).sort((a, b) => collator.compare(a, b));
92
+ inputs.push({
93
+ url: '/tags',
94
+ content: document(
95
+ 'Tag Index',
96
+ `<h1>Tag Index</h1><p>Found ${tags.length} total tags.</p><ul>${tags.map((tag) => `<li><a href="/tags/${escapeSearchHtml(tag)}">${escapeSearchHtml(tag)} ${manifest.tags[tag].length}</a></li>`).join('')}</ul>`
97
+ )
98
+ });
99
+ for (const tag of tags) {
100
+ const pages = manifest.tags[tag]
101
+ .map((slug) => manifest.pages[slug])
102
+ .filter(Boolean)
103
+ .sort(
104
+ (a, b) =>
105
+ Date.parse(b.dates.modified) - Date.parse(a.dates.modified) ||
106
+ collator.compare(a.title, b.title)
107
+ );
108
+ const title = `Tag: ${tag}`;
109
+ inputs.push({
110
+ url: `/tags/${tag}`,
111
+ content: document(
112
+ title,
113
+ `<h1>${escapeSearchHtml(title)}</h1><p>${pages.length} ${pages.length === 1 ? 'item' : 'items'} with this tag.</p><ul>${pages
114
+ .map((page) => {
115
+ const date = page.dates.modified
116
+ ? new Date(page.dates.modified).toLocaleDateString('en-US', {
117
+ year: 'numeric',
118
+ month: 'short',
119
+ day: 'numeric'
120
+ })
121
+ : '';
122
+ return `<li><a href="/${escapeSearchHtml(displayRoute(page.slug))}">${escapeSearchHtml(page.title)} ${escapeSearchHtml(date)}${page.description ? ` ${escapeSearchHtml(page.description)}` : ''}</a></li>`;
123
+ })
124
+ .join('')}</ul>`
125
+ )
126
+ });
127
+ }
128
+ return inputs.sort((a, b) => (a.url || '').localeCompare(b.url || '', 'en'));
129
+ }