@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,266 @@
1
+ import type { ContentManifest, NavNode, PageDoc } from '../types';
2
+ import type { FileIconTheme } from '../file-icons';
3
+ import { createContentModel } from './content-model';
4
+
5
+ export interface ContentContext {
6
+ edition: 'public' | 'full';
7
+ snapshot: string;
8
+ applicationVersion: string;
9
+ }
10
+
11
+ export interface ContentProvider {
12
+ context: ContentContext;
13
+ manifest(): Promise<ContentManifest>;
14
+ page(slug: string): Promise<PageDoc>;
15
+ nav(): Promise<NavNode[]>;
16
+ icons(): Promise<FileIconTheme>;
17
+ model(): Promise<ReturnType<typeof createContentModel>>;
18
+ }
19
+
20
+ type Assets = { fetch(request: Request): Promise<Response> };
21
+ type ContentPlatform = { env: { ASSETS: Assets; WISCONSIN_CONTENT_CONTEXT?: ContentContext } };
22
+ const providers = new WeakMap<Assets, Map<string, ContentProvider>>();
23
+ const releaseProvider = new WeakMap<ContentProvider, () => void>();
24
+ const digest = /^[a-f0-9]{64}$/;
25
+ const memoryBudget = 16 * 1024 * 1024;
26
+ const entryOverhead = 512;
27
+ const maximumPendingReads = 32;
28
+ const maximumActiveReads = 2;
29
+ type CacheEntry = {
30
+ promise: Promise<unknown>;
31
+ cost: number;
32
+ pending: boolean;
33
+ remove: () => void;
34
+ };
35
+ // The budget belongs to the isolate, rather than each binding or snapshot.
36
+ const retained = new Map<CacheEntry, true>();
37
+ let retainedBytes = 0;
38
+ let pendingReads = 0;
39
+ let activeReads = 0;
40
+ const readQueue: Array<() => void> = [];
41
+
42
+ function discard(entry: CacheEntry) {
43
+ if (retained.delete(entry)) retainedBytes -= entry.cost;
44
+ entry.remove();
45
+ }
46
+
47
+ function touch(entry: CacheEntry) {
48
+ if (retained.delete(entry)) retained.set(entry, true);
49
+ }
50
+
51
+ function trim() {
52
+ for (const entry of retained.keys()) {
53
+ if (retainedBytes <= memoryBudget) break;
54
+ if (!entry.pending) discard(entry);
55
+ }
56
+ }
57
+
58
+ function readBounded(read: () => Promise<unknown>): Promise<unknown> {
59
+ if (pendingReads >= maximumPendingReads)
60
+ return Promise.reject(new Error('Content reads temporarily busy'));
61
+ pendingReads++;
62
+ return new Promise((resolve, reject) => {
63
+ const start = () => {
64
+ activeReads++;
65
+ void Promise.resolve()
66
+ .then(read)
67
+ .then(resolve, reject)
68
+ .finally(() => {
69
+ pendingReads--;
70
+ activeReads--;
71
+ readQueue.shift()?.();
72
+ });
73
+ };
74
+ if (activeReads < maximumActiveReads) start();
75
+ else readQueue.push(start);
76
+ });
77
+ }
78
+
79
+ function validContext(context: ContentContext | undefined): context is ContentContext {
80
+ return (
81
+ !!context &&
82
+ (context.edition === 'public' || context.edition === 'full') &&
83
+ typeof context.snapshot === 'string' &&
84
+ typeof context.applicationVersion === 'string' &&
85
+ digest.test(context.snapshot) &&
86
+ digest.test(context.applicationVersion)
87
+ );
88
+ }
89
+
90
+ /** Only canonical compiler slugs become internal asset paths. */
91
+ function pageFile(slug: string) {
92
+ if (
93
+ !slug ||
94
+ slug.includes('\\') ||
95
+ slug.includes('\0') ||
96
+ slug.split('/').some((segment) => !segment || segment === '.' || segment === '..')
97
+ )
98
+ throw new Error('Invalid content slug');
99
+ return `pages/${slug.split('/').map(encodeURIComponent).join('/')}.json`;
100
+ }
101
+
102
+ function provider(
103
+ context: ContentContext,
104
+ read: (file: string) => Promise<unknown>
105
+ ): ContentProvider {
106
+ const cache = new Map<string, CacheEntry>();
107
+ function json<T>(file: string): Promise<T> {
108
+ const cached = cache.get(file);
109
+ if (cached) {
110
+ cache.delete(file);
111
+ cache.set(file, cached);
112
+ touch(cached);
113
+ return cached.promise as Promise<T>;
114
+ }
115
+ if (pendingReads >= maximumPendingReads)
116
+ return Promise.reject(new Error('Content reads temporarily busy'));
117
+ const entry: CacheEntry = {
118
+ promise: readBounded(() => read(file)),
119
+ cost: entryOverhead,
120
+ pending: true,
121
+ remove: () => {
122
+ if (cache.get(file) === entry) cache.delete(file);
123
+ }
124
+ };
125
+ entry.promise = entry.promise.then(
126
+ (value) => {
127
+ entry.pending = false;
128
+ if (cache.get(file) !== entry) return value;
129
+ // Conservative parsed-object estimate; oversized documents remain usable
130
+ // by active requests but are never kept for subsequent requests.
131
+ const cost = JSON.stringify(value).length * 3 + entryOverhead;
132
+ if (cost > memoryBudget) discard(entry);
133
+ else {
134
+ retainedBytes += cost - entry.cost;
135
+ entry.cost = cost;
136
+ trim();
137
+ }
138
+ return value;
139
+ },
140
+ (error) => {
141
+ discard(entry);
142
+ throw error;
143
+ }
144
+ );
145
+ cache.set(file, entry);
146
+ retained.set(entry, true);
147
+ retainedBytes += entry.cost;
148
+ // A secondary entry bound also limits bookkeeping for many tiny documents.
149
+ for (const candidate of cache.values()) {
150
+ if (cache.size <= 64) break;
151
+ if (!candidate.pending) discard(candidate);
152
+ }
153
+ trim();
154
+ return entry.promise as Promise<T>;
155
+ }
156
+ const result: ContentProvider = {
157
+ context,
158
+ manifest: () => json<ContentManifest>('manifest.json'),
159
+ async page(slug) {
160
+ const file = pageFile(slug);
161
+ const manifest = await result.manifest();
162
+ if (!Object.hasOwn(manifest.pages, slug)) throw new Error('Unknown content page');
163
+ const page = await json<PageDoc>(file);
164
+ if (page.slug !== slug) throw new Error('Content page does not match snapshot');
165
+ if (page.publication.public !== manifest.pages[slug].publication.public)
166
+ throw new Error('Content publication does not match snapshot');
167
+ if (
168
+ context.edition === 'public' &&
169
+ !page.publication.public &&
170
+ (!page.locked || page.html || page.markdown || page.description)
171
+ )
172
+ throw new Error('Private content in public snapshot');
173
+ return page;
174
+ },
175
+ nav: () => json<NavNode[]>('nav.json'),
176
+ icons: () => json<FileIconTheme>('file-icons.json'),
177
+ // Keep projections request-local so they cannot retain an evicted manifest.
178
+ model: () => result.manifest().then(createContentModel)
179
+ };
180
+ releaseProvider.set(result, () => {
181
+ for (const entry of cache.values()) discard(entry);
182
+ });
183
+ return result;
184
+ }
185
+
186
+ /** Application-only builds may render a 404 shell without a content checkout. */
187
+ export function emptyContentProvider(): ContentProvider {
188
+ const manifest: ContentManifest = {
189
+ generatedAt: '',
190
+ counts: {},
191
+ pages: {},
192
+ folders: [],
193
+ tags: {},
194
+ assets: [],
195
+ htmlAssets: [],
196
+ tree: { name: '', slug: '', title: '', children: [], pages: [] },
197
+ graph: { nodes: [], links: [] }
198
+ };
199
+ const file = { light: 'file', dark: 'file' };
200
+ const folder = {
201
+ light: 'folder',
202
+ dark: 'folder',
203
+ expanded: { light: 'folder-open', dark: 'folder-open' }
204
+ };
205
+ return provider(
206
+ { edition: 'public', snapshot: 'application-shell', applicationVersion: 'application-shell' },
207
+ async (fileName) => {
208
+ if (fileName === 'manifest.json') return manifest;
209
+ if (fileName === 'nav.json') return [];
210
+ if (fileName === 'file-icons.json') return { file, folder, files: {}, folders: {} };
211
+ throw new Error('No content in application-only build');
212
+ }
213
+ );
214
+ }
215
+
216
+ /** Context is supplied by the authenticated Worker, never by a client header or URL. */
217
+ export function assetContentProvider(platform: ContentPlatform): ContentProvider {
218
+ const context = platform.env.WISCONSIN_CONTENT_CONTEXT;
219
+ if (!validContext(context)) throw new Error('Missing trusted content context');
220
+ const assets = platform.env.ASSETS;
221
+ if (!assets || typeof assets.fetch !== 'function')
222
+ throw new Error('Missing content assets binding');
223
+ let snapshots = providers.get(assets);
224
+ if (!snapshots) providers.set(assets, (snapshots = new Map()));
225
+ const key = `${context.edition}:${context.snapshot}:${context.applicationVersion}`;
226
+ const cached = snapshots.get(key);
227
+ if (cached) {
228
+ snapshots.delete(key);
229
+ snapshots.set(key, cached);
230
+ return cached;
231
+ }
232
+ const selected = Object.freeze({ ...context });
233
+ const root = `/_content/${selected.edition}/${selected.snapshot}/`;
234
+ const result = provider(selected, async (file) => {
235
+ const response = await assets.fetch(
236
+ new Request(`https://wisconsin-content.internal${root}${file}`)
237
+ );
238
+ if (response.status !== 200) throw new Error('Content snapshot unavailable');
239
+ return response.json();
240
+ });
241
+ snapshots.set(key, result);
242
+ // Keep a bounded number of snapshots without sharing public/full data.
243
+ if (snapshots.size > 2) {
244
+ const oldest = snapshots.keys().next().value!;
245
+ releaseProvider.get(snapshots.get(oldest)!)?.();
246
+ snapshots.delete(oldest);
247
+ }
248
+ return result;
249
+ }
250
+
251
+ /** Async filesystem provider is exclusively selected by local development/static export. */
252
+ export function localContentProvider(
253
+ site: string,
254
+ edition: ContentContext['edition']
255
+ ): ContentProvider {
256
+ return provider({ edition, snapshot: 'local', applicationVersion: 'local' }, async (file) => {
257
+ const { readFile } = await import('node:fs/promises');
258
+ const relative =
259
+ file === 'manifest.json'
260
+ ? 'build/generated/content-manifest.json'
261
+ : file === 'nav.json' || file === 'file-icons.json'
262
+ ? `src/lib/generated/${file}`
263
+ : `build/generated/${file.split('/').map(decodeURIComponent).join('/')}`;
264
+ return JSON.parse(await readFile(`${site}/${relative}`, 'utf8'));
265
+ });
266
+ }
@@ -0,0 +1,277 @@
1
+ /**
2
+ * Cached readers for generated content used during prerendering and development.
3
+ * Keep imports relative: the content pipeline also loads this module outside SvelteKit.
4
+ */
5
+
6
+ import { existsSync, readFileSync } from 'node:fs';
7
+ import path from 'node:path';
8
+ import type {
9
+ ContentManifest,
10
+ FolderEntry,
11
+ FolderListing,
12
+ ManifestPage,
13
+ NavNode,
14
+ PageDoc,
15
+ TreeNode
16
+ } from '../types';
17
+ import { site } from '../config';
18
+
19
+ /** `vite`/`bun` are always invoked from `site/`, so cwd-relative is stable. */
20
+ const GENERATED_DIR = path.resolve(process.cwd(), 'build/generated');
21
+
22
+ let manifestCache: ContentManifest | undefined;
23
+
24
+ export function resetContentCache() {
25
+ manifestCache = undefined;
26
+ pageDocCache.clear();
27
+ }
28
+
29
+ export function getManifest(): ContentManifest {
30
+ if (!manifestCache) {
31
+ const file = path.join(GENERATED_DIR, 'content-manifest.json');
32
+ if (!existsSync(file)) {
33
+ throw new Error(`Missing ${file} — run \`bun run content:prepare\` in site/.`);
34
+ }
35
+ manifestCache = JSON.parse(readFileSync(file, 'utf-8')) as ContentManifest;
36
+ }
37
+ return manifestCache;
38
+ }
39
+
40
+ /**
41
+ * Canonical slug → display route (no leading/trailing slash; `''` = home).
42
+ * Trailing `index` segments are trimmed from URLs.
43
+ */
44
+ export function displayRoute(slug: string): string {
45
+ if (slug === 'index') return '';
46
+ if (slug.endsWith('/index')) return slug.slice(0, -'/index'.length);
47
+ return slug;
48
+ }
49
+
50
+ /** Display route → canonical page slug, or undefined if no page lives there. */
51
+ export function pageSlugForRoute(route: string): string | undefined {
52
+ const { pages } = getManifest();
53
+ if (route === '') return pages['index'] ? 'index' : undefined;
54
+ if (pages[route] && !route.endsWith('/index')) return route;
55
+ if (pages[`${route}/index`]) return `${route}/index`;
56
+ // A literal `…/index` URL is not canonical (the live site never emits it).
57
+ return undefined;
58
+ }
59
+
60
+ const pageDocCache = new Map<string, PageDoc>();
61
+
62
+ /** Read a full page document (rendered HTML + TOC + backlinks) by slug. */
63
+ export function loadPage(slug: string): PageDoc {
64
+ let doc = pageDocCache.get(slug);
65
+ if (!doc) {
66
+ const file = path.join(GENERATED_DIR, 'pages', `${slug}.json`);
67
+ doc = JSON.parse(readFileSync(file, 'utf-8')) as PageDoc;
68
+ pageDocCache.set(slug, doc);
69
+ }
70
+ return doc;
71
+ }
72
+
73
+ /** Folders that have no index page and therefore get a generated listing. */
74
+ export function listingFolders(): string[] {
75
+ const m = getManifest();
76
+ return m.folders.filter((f) => !m.pages[`${f}/index`] && !m.pages[f]);
77
+ }
78
+
79
+ /**
80
+ * Every route served by the catch-all `[...slug]` page route:
81
+ * all page display routes (including `''` = home) + generated folder listings.
82
+ * Tag routes live under `src/routes/tags/` and are not included here.
83
+ */
84
+ export function contentEntries(): string[] {
85
+ const m = getManifest();
86
+ const routes = Object.keys(m.pages).map(displayRoute);
87
+ return [...new Set(['', ...routes, ...listingFolders()])];
88
+ }
89
+
90
+ /** Natural sort, Quartz Explorer-style (numeric, case-insensitive). */
91
+ const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' });
92
+
93
+ function findTreeNode(folder: string): TreeNode | undefined {
94
+ let node: TreeNode | undefined = getManifest().tree;
95
+ if (folder === '') return node;
96
+ for (const segment of folder.split('/')) {
97
+ node = node?.children.find((c) => c.name === segment);
98
+ if (!node) return undefined;
99
+ }
100
+ return node;
101
+ }
102
+
103
+ function toNavNode(node: TreeNode): NavNode {
104
+ const indexSlug = `${node.slug}/index`;
105
+ const folders = node.children.map(toNavNode).sort((a, b) => collator.compare(a.title, b.title));
106
+ const pages = node.pages
107
+ .filter((p) => p.slug !== indexSlug)
108
+ .map(
109
+ (p): NavNode => ({
110
+ locked: getManifest().pages[p.slug]?.locked,
111
+ title: p.title,
112
+ route: `/${displayRoute(p.slug)}`,
113
+ children: [],
114
+ segment: p.slug.split('/').pop() ?? p.slug
115
+ })
116
+ )
117
+ .sort((a, b) => collator.compare(a.title, b.title));
118
+ return {
119
+ locked: getManifest().pages[indexSlug]?.locked,
120
+ // Explorer shows folder names (matching the live Quartz explorer), not
121
+ // index-page titles — those can be long ("Midterm 1 - Practice Questions").
122
+ title: node.name,
123
+ route: `/${node.slug}`, // every folder has a route: index page or listing
124
+ children: [...folders, ...pages],
125
+ segment: node.name
126
+ };
127
+ }
128
+
129
+ let navCache: NavNode[] | undefined;
130
+
131
+ /** Sidebar Explorer tree: course folders first, then root pages (minus home). */
132
+ export function navTree(): NavNode[] {
133
+ if (navCache) return navCache;
134
+ const root = getManifest().tree;
135
+ const folders = root.children.map(toNavNode).sort((a, b) => collator.compare(a.title, b.title));
136
+ const rootPages = root.pages
137
+ .filter((p) => p.slug !== 'index')
138
+ .map(
139
+ (p): NavNode => ({
140
+ title: p.title,
141
+ route: `/${displayRoute(p.slug)}`,
142
+ children: [],
143
+ segment: p.slug
144
+ })
145
+ )
146
+ .sort((a, b) => collator.compare(a.title, b.title));
147
+ navCache = [...folders, ...rootPages];
148
+ return navCache;
149
+ }
150
+
151
+ function countPages(node: TreeNode): number {
152
+ return node.pages.length + node.children.reduce((n, c) => n + countPages(c), 0);
153
+ }
154
+
155
+ /** Listing data for a folder without its own index page. */
156
+ export function folderListing(folder: string): FolderListing | undefined {
157
+ const m = getManifest();
158
+ if (folder !== '' && !m.folders.includes(folder)) return undefined;
159
+ const node = findTreeNode(folder);
160
+ if (!node) return undefined;
161
+ const subfolders: FolderEntry[] = node.children
162
+ .map((c) => ({ title: c.name, route: `/${c.slug}`, isFolder: true }))
163
+ .sort((a, b) => collator.compare(a.title, b.title));
164
+ const pages: FolderEntry[] = node.pages
165
+ .filter((p) => p.slug !== `${folder}/index`)
166
+ .map((p) => ({
167
+ title: p.title,
168
+ route: `/${displayRoute(p.slug)}`,
169
+ isFolder: false,
170
+ modified: m.pages[p.slug]?.dates.modified
171
+ }))
172
+ // Quartz folder pages list by date (newest first), then alphabetical.
173
+ .sort(
174
+ (a, b) =>
175
+ Date.parse(b.modified ?? '0') - Date.parse(a.modified ?? '0') ||
176
+ collator.compare(a.title, b.title)
177
+ );
178
+ return {
179
+ folder,
180
+ name: folder === '' ? site.name : (folder.split('/').pop() ?? folder),
181
+ entries: [
182
+ ...subfolders,
183
+ ...pages,
184
+ ...(m.fileCourses?.includes(folder)
185
+ ? [{ title: 'Files', route: `/${folder}/files`, isFolder: true }]
186
+ : [])
187
+ ],
188
+ pageCount: countPages(node)
189
+ };
190
+ }
191
+
192
+ /** All tags (case-sensitive) with page counts, alphabetical. */
193
+ export function tagIndex(): { tag: string; count: number }[] {
194
+ const { tags } = getManifest();
195
+ return Object.keys(tags)
196
+ .sort((a, b) => collator.compare(a, b))
197
+ .map((tag) => ({ tag, count: tags[tag].length }));
198
+ }
199
+
200
+ /** Pages for one tag (case-sensitive), newest first. */
201
+ export function tagPages(tag: string): ManifestPage[] | undefined {
202
+ const m = getManifest();
203
+ const slugs = m.tags[tag];
204
+ if (!slugs) return undefined;
205
+ return slugs
206
+ .map((s) => m.pages[s])
207
+ .filter((p): p is ManifestPage => !!p)
208
+ .sort(
209
+ (a, b) =>
210
+ Date.parse(b.dates.modified) - Date.parse(a.dates.modified) ||
211
+ collator.compare(a.title, b.title)
212
+ );
213
+ }
214
+
215
+ function escapeXml(s: string): string {
216
+ return s
217
+ .replaceAll('&', '&amp;')
218
+ .replaceAll('<', '&lt;')
219
+ .replaceAll('>', '&gt;')
220
+ .replaceAll('"', '&quot;');
221
+ }
222
+
223
+ function absoluteUrl(route: string): string {
224
+ return route === '' ? `${site.url}/` : `${site.url}/${route}`;
225
+ }
226
+
227
+ /** RSS feed matching the live `/index.xml` shape: latest 10 by modified date. */
228
+ export function rssXml(): string {
229
+ const m = getManifest();
230
+ const items = Object.values(m.pages)
231
+ .filter((page) => !page.locked)
232
+ .sort((a, b) => Date.parse(b.dates.modified) - Date.parse(a.dates.modified))
233
+ .slice(0, 10)
234
+ .map((p) => {
235
+ const url = absoluteUrl(displayRoute(p.slug));
236
+ const description = p.description.replaceAll(']]>', ']]&gt;');
237
+ return `<item>
238
+ <title>${escapeXml(p.title)}</title>
239
+ <link>${url}</link>
240
+ <guid>${url}</guid>
241
+ <description><![CDATA[ ${description} ]]></description>
242
+ ${p.dates.published || p.dates.modified ? `<pubDate>${new Date(p.dates.published || p.dates.modified).toUTCString()}</pubDate>` : ''}
243
+ </item>`;
244
+ })
245
+ .join('');
246
+ return `<?xml version="1.0" encoding="UTF-8" ?>
247
+ <rss version="2.0">
248
+ <channel>
249
+ <title>${escapeXml(site.title)}</title>
250
+ <link>${site.url}</link>
251
+ <description>Last 10 notes on ${escapeXml(site.title)}</description>
252
+ <generator>${site.url}</generator>
253
+ ${items}
254
+ </channel>
255
+ </rss>`;
256
+ }
257
+
258
+ /** Sitemap matching the live `/sitemap.xml` shape: every page, ISO lastmod. */
259
+ export function sitemapXml(): string {
260
+ const m = getManifest();
261
+ const urls = Object.values(m.pages)
262
+ .filter((page) => !page.locked)
263
+ .map(
264
+ (p) => `<url>
265
+ <loc>${escapeXml(absoluteUrl(displayRoute(p.slug)))}</loc>
266
+ ${p.dates.modified ? `<lastmod>${p.dates.modified}</lastmod>` : ''}
267
+ </url>`
268
+ )
269
+ .join('');
270
+ const folders = m.fileCourses
271
+ ? contentEntries()
272
+ .filter((route) => !pageSlugForRoute(route))
273
+ .map((route) => `<url><loc>${escapeXml(absoluteUrl(route))}</loc></url>`)
274
+ .join('')
275
+ : '';
276
+ return `<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml">${urls}${folders}</urlset>`;
277
+ }
@@ -0,0 +1,34 @@
1
+ import { building, dev } from '$app/environment';
2
+ import { error } from '@sveltejs/kit';
3
+ import {
4
+ assetContentProvider,
5
+ emptyContentProvider,
6
+ localContentProvider
7
+ } from './content-provider';
8
+
9
+ export function requestContent(platform: App.Platform | undefined) {
10
+ if (building && import.meta.env.VITE_STATIC_EXPORT !== 'true') return emptyContentProvider();
11
+ if (dev || building)
12
+ return localContentProvider(
13
+ process.cwd(),
14
+ import.meta.env.VITE_PUBLIC_EDITION === 'true' ? 'public' : 'full'
15
+ );
16
+ if (!platform) error(503, 'Content unavailable');
17
+ try {
18
+ const content = assetContentProvider(platform);
19
+ const available = <T>(read: Promise<T>): Promise<T> =>
20
+ read.catch(() => {
21
+ error(503, 'Content unavailable');
22
+ });
23
+ return {
24
+ context: content.context,
25
+ manifest: () => available(content.manifest()),
26
+ page: (slug: string) => available(content.page(slug)),
27
+ nav: () => available(content.nav()),
28
+ icons: () => available(content.icons()),
29
+ model: () => available(content.model())
30
+ };
31
+ } catch {
32
+ error(503, 'Content unavailable');
33
+ }
34
+ }
@@ -0,0 +1,9 @@
1
+ {
2
+ "name": "twiki",
3
+ "url": "http://localhost:5173",
4
+ "title": "twiki",
5
+ "description": "Notes, ideas, and reference material.",
6
+ "author": { "name": "", "url": "", "github": "", "email": "" },
7
+ "locale": "en_US",
8
+ "language": "en"
9
+ }
@@ -0,0 +1,13 @@
1
+ import { site } from './config';
2
+
3
+ export const socialImageSize = { width: 1200, height: 630 };
4
+
5
+ export function socialImagePath(slug?: string) {
6
+ return slug
7
+ ? `/_og/notes/${slug.split('/').map(encodeURIComponent).join('/')}.png`
8
+ : '/_og/default.png';
9
+ }
10
+
11
+ export function socialImageUrl(slug?: string) {
12
+ return site.url + socialImagePath(slug);
13
+ }