@twango/twiki 0.0.0-stage → 0.1.1

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,226 @@
1
+ export interface AssetBinding {
2
+ fetch(request: Request): Promise<Response>;
3
+ }
4
+
5
+ export interface ContentContext {
6
+ edition: 'public' | 'full';
7
+ snapshot: string;
8
+ applicationVersion: string;
9
+ }
10
+
11
+ export interface ContentDescriptor {
12
+ schemaVersion: 1;
13
+ applicationVersion: string;
14
+ snapshots: Record<ContentContext['edition'], string>;
15
+ }
16
+
17
+ export interface ContentRouting {
18
+ schemaVersion: 1;
19
+ routes: string[];
20
+ assets: Record<string, string>;
21
+ /** Producer hashes all HTML inputs for an exact route; omission uses the whole snapshot. */
22
+ htmlKeys?: Record<string, string>;
23
+ }
24
+
25
+ const digest = /^[a-f0-9]{64}$/;
26
+ const routingCaches = new WeakMap<AssetBinding, Map<string, Promise<ContentRouting>>>();
27
+
28
+ /** Internal content is accessible only through the trusted ASSETS binding. */
29
+ export function isInternalContentPath(pathname: string) {
30
+ for (let attempt = 0; attempt < 4; attempt++) {
31
+ pathname = pathname.replace(/\/+/g, '/');
32
+ if (/^\/(?:_content|_published)(?:\/|$)/i.test(pathname)) return true;
33
+ const normalized = new URL(pathname, 'https://content.invalid').pathname;
34
+ if (/^\/(?:_content|_published)(?:\/|$)/i.test(normalized)) return true;
35
+ try {
36
+ const decoded = decodeURIComponent(pathname);
37
+ if (decoded === pathname) return false;
38
+ pathname = decoded;
39
+ } catch {
40
+ return true;
41
+ }
42
+ }
43
+ return true;
44
+ }
45
+
46
+ async function internalJson(binding: AssetBinding, origin: string, pathname: string) {
47
+ const response = await binding.fetch(new Request(new URL(pathname, origin)));
48
+ if (response.status !== 200) throw new Error('Content snapshot unavailable');
49
+ return response.json() as Promise<unknown>;
50
+ }
51
+
52
+ /** Re-read the small pointer: a new deployment cannot inherit an old isolate's pointer. */
53
+ export async function contentDescriptor(
54
+ binding: AssetBinding,
55
+ origin: string
56
+ ): Promise<ContentDescriptor> {
57
+ const value = (await internalJson(
58
+ binding,
59
+ origin,
60
+ '/_content/current.json'
61
+ )) as Partial<ContentDescriptor> | null;
62
+ if (
63
+ !value ||
64
+ value.schemaVersion !== 1 ||
65
+ typeof value.applicationVersion !== 'string' ||
66
+ !digest.test(value.applicationVersion) ||
67
+ typeof value.snapshots?.public !== 'string' ||
68
+ !digest.test(value.snapshots.public) ||
69
+ typeof value.snapshots?.full !== 'string' ||
70
+ !digest.test(value.snapshots.full)
71
+ )
72
+ throw new Error('Invalid content snapshot descriptor');
73
+ return value as ContentDescriptor;
74
+ }
75
+
76
+ export function contentContext(
77
+ descriptor: ContentDescriptor,
78
+ edition: ContentContext['edition']
79
+ ): ContentContext {
80
+ return {
81
+ edition,
82
+ snapshot: descriptor.snapshots[edition],
83
+ applicationVersion: descriptor.applicationVersion
84
+ };
85
+ }
86
+
87
+ function safePath(value: unknown): value is string {
88
+ if (
89
+ typeof value !== 'string' ||
90
+ !value.startsWith('/') ||
91
+ value.startsWith('//') ||
92
+ value.includes('\\') ||
93
+ /[\u0000-\u0020?#]/.test(value)
94
+ )
95
+ return false;
96
+ const normalized = new URL(value, 'https://content.invalid');
97
+ return normalized.pathname === value;
98
+ }
99
+
100
+ export async function contentRouting(
101
+ binding: AssetBinding,
102
+ origin: string,
103
+ context: ContentContext
104
+ ): Promise<ContentRouting> {
105
+ let cache = routingCaches.get(binding);
106
+ if (!cache) routingCaches.set(binding, (cache = new Map()));
107
+ const key = `${context.edition}/${context.snapshot}`;
108
+ let result = cache.get(key);
109
+ if (!result) {
110
+ result = (async () => {
111
+ const value = (await internalJson(
112
+ binding,
113
+ origin,
114
+ `/_content/${key}/routing.json`
115
+ )) as Partial<ContentRouting> | null;
116
+ if (
117
+ !value ||
118
+ value.schemaVersion !== 1 ||
119
+ !Array.isArray(value.routes) ||
120
+ !value.routes.every((route) => safePath(route) && !isInternalContentPath(route)) ||
121
+ !value.assets ||
122
+ typeof value.assets !== 'object' ||
123
+ Array.isArray(value.assets) ||
124
+ !Object.entries(value.assets).every(
125
+ ([route, target]) => safePath(route) && !isInternalContentPath(route) && safePath(target)
126
+ ) ||
127
+ (value.htmlKeys !== undefined &&
128
+ (!value.htmlKeys ||
129
+ typeof value.htmlKeys !== 'object' ||
130
+ Array.isArray(value.htmlKeys) ||
131
+ !Object.entries(value.htmlKeys).every(
132
+ ([route, hash]) =>
133
+ value.routes!.includes(route) && typeof hash === 'string' && digest.test(hash)
134
+ )))
135
+ )
136
+ throw new Error('Invalid content routing inventory');
137
+ return value as ContentRouting;
138
+ })();
139
+ cache.set(key, result);
140
+ // Immutable snapshots can be memoized, but keep old deployments bounded.
141
+ if (cache.size > 4) cache.delete(cache.keys().next().value!);
142
+ result.catch(() => {
143
+ if (cache!.get(key) === result) cache!.delete(key);
144
+ });
145
+ }
146
+ return result;
147
+ }
148
+
149
+ export interface HtmlCache {
150
+ match(request: Request): Promise<Response | undefined>;
151
+ put(request: Request, response: Response): Promise<void>;
152
+ }
153
+
154
+ export interface BackgroundContext {
155
+ waitUntil(promise: Promise<unknown>): void;
156
+ }
157
+
158
+ export function versionedResponse(response: Response, context: ContentContext) {
159
+ const result = new Response(response.body, response);
160
+ result.headers.set('X-Wisconsin-Content-Version', context.snapshot);
161
+ result.headers.set('X-Wisconsin-Application-Version', context.applicationVersion);
162
+ return result;
163
+ }
164
+
165
+ function htmlRequest(request: Request, routing: ContentRouting) {
166
+ const url = new URL(request.url);
167
+ let pathname: string;
168
+ try {
169
+ pathname = decodeURIComponent(url.pathname);
170
+ } catch {
171
+ return false;
172
+ }
173
+ return (
174
+ request.method === 'GET' &&
175
+ url.search === '' &&
176
+ !request.headers.has('range') &&
177
+ !/(?:no-cache|no-store)/i.test(request.headers.get('cache-control') ?? '') &&
178
+ !/^\/(?:api|admin|login|logout)(?:\/|$)/.test(pathname) &&
179
+ !url.pathname.endsWith('/__data.json') &&
180
+ routing.routes.includes(url.pathname)
181
+ );
182
+ }
183
+
184
+ /** Called after authentication for full requests, before security headers/cookies are added. */
185
+ export async function cachedHtml(
186
+ request: Request,
187
+ context: ContentContext,
188
+ routing: ContentRouting,
189
+ cache: HtmlCache | undefined,
190
+ background: BackgroundContext,
191
+ render: () => Promise<Response>
192
+ ) {
193
+ if (!cache || !htmlRequest(request, routing)) return render();
194
+ const url = new URL(request.url);
195
+ const routeKey = routing.htmlKeys?.[url.pathname];
196
+ const dependency = routeKey ? `dependency/${routeKey}` : `snapshot/${context.snapshot}`;
197
+ url.pathname = `/_content/ssr/${context.applicationVersion}/${context.edition}/${dependency}/${encodeURIComponent(url.pathname)}`;
198
+ const key = new Request(url, { method: 'GET' });
199
+ try {
200
+ const cached = await cache.match(key);
201
+ if (cached) return cached;
202
+ } catch {
203
+ /* Cache availability must not become content availability. */
204
+ }
205
+ const response = await render();
206
+ if (
207
+ response.status === 200 &&
208
+ response.headers.get('content-type')?.toLowerCase().includes('text/html') &&
209
+ !/(?:private|no-cache|no-store)/i.test(response.headers.get('cache-control') ?? '') &&
210
+ !response.headers.has('set-cookie') &&
211
+ !response.headers.has('vary')
212
+ ) {
213
+ const stored = response.clone();
214
+ stored.headers.set('Cache-Control', 'public, max-age=3600');
215
+ try {
216
+ background.waitUntil(
217
+ Promise.resolve()
218
+ .then(() => cache.put(key, stored))
219
+ .catch(() => undefined)
220
+ );
221
+ } catch {
222
+ /* A closed background context cannot turn a rendered page into an error. */
223
+ }
224
+ }
225
+ return response;
226
+ }
@@ -0,0 +1,4 @@
1
+ export function frameOptions(response: Response) {
2
+ const contentType = response.headers.get('Content-Type')?.split(';')[0].trim().toLowerCase();
3
+ return response.ok && contentType === 'application/pdf' ? 'SAMEORIGIN' : 'DENY';
4
+ }
@@ -0,0 +1,147 @@
1
+ import { createAuth, type AuthEnv } from './auth';
2
+ import { accessForUser } from './access';
3
+ import { copyCookies, handleAuthEndpoint, redirect, signIn, signOut } from './auth-routes';
4
+ import { loginPage, returnPath } from './login';
5
+ import { publicResponse } from './publication';
6
+ import { frameOptions } from './framing';
7
+ import { proxyAnalytics } from './analytics';
8
+ import { isInternalContentPath } from './content-runtime';
9
+
10
+ function privateResponse(response: Response, cookies: Headers, request: Request) {
11
+ const result = new Response(response.body, response);
12
+ const pathname = new URL(request.url).pathname;
13
+ result.headers.set('Cache-Control', 'private, no-store');
14
+ result.headers.set('X-Content-Type-Options', 'nosniff');
15
+ result.headers.set('X-Frame-Options', frameOptions(response));
16
+ if (/^\/_files\/blobs\/[a-f0-9]{64}\.bin$/.test(pathname)) {
17
+ result.headers.set('Content-Type', 'application/octet-stream');
18
+ result.headers.set('Content-Disposition', 'attachment');
19
+ }
20
+ result.headers.set('Referrer-Policy', 'same-origin');
21
+ result.headers.set('X-Robots-Tag', 'noindex, nofollow');
22
+ copyCookies(cookies, result.headers);
23
+ return result;
24
+ }
25
+
26
+ function requireLogin(request: Request, url: URL) {
27
+ const navigation =
28
+ request.method === 'GET' &&
29
+ (request.headers.get('sec-fetch-mode') === 'navigate' ||
30
+ request.headers.get('accept')?.includes('text/html'));
31
+ if (navigation) return redirect('/login?next=' + encodeURIComponent(url.pathname + url.search));
32
+ return new Response('Unauthorized', { status: 401 });
33
+ }
34
+
35
+ async function routeRequest(
36
+ request: Request,
37
+ env: AuthEnv,
38
+ cookies: Headers,
39
+ servePublic?: () => Promise<Response | null>
40
+ ): Promise<Response | null> {
41
+ const url = new URL(request.url);
42
+ if (url.origin !== env.ORIGIN) return new Response('Misdirected request', { status: 421 });
43
+ if (isInternalContentPath(url.pathname)) return new Response('Not found', { status: 404 });
44
+ // Analytics also serves anonymous public readers, before session/database access.
45
+ if (url.pathname.startsWith('/api/analytics/')) {
46
+ if (env.DISABLE_ANALYTICS === 'true')
47
+ return Response.json({}, { headers: { 'Cache-Control': 'no-store' } });
48
+ return proxyAnalytics(
49
+ request,
50
+ fetch,
51
+ env.TWIKI_ANALYTICS_SITE_ID && env.TWIKI_ANALYTICS_ENDPOINT
52
+ ? { siteId: env.TWIKI_ANALYTICS_SITE_ID, endpoint: env.TWIKI_ANALYTICS_ENDPOINT }
53
+ : undefined
54
+ );
55
+ }
56
+ const reading = request.method === 'GET' || request.method === 'HEAD';
57
+ if (reading && url.pathname === '/fonts/OverusedGrotesk-VF.woff2') return null;
58
+ if (env.TWIKI_AUTH_MODE === 'none') {
59
+ if (reading && servePublic)
60
+ return (await servePublic()) ?? new Response('Not found', { status: 404 });
61
+ return new Response('Not found', { status: 404 });
62
+ }
63
+ if (
64
+ reading &&
65
+ servePublic &&
66
+ (!request.headers.has('cookie') ||
67
+ ['/sitemap.xml', '/index.xml', '/robots.txt'].includes(url.pathname))
68
+ ) {
69
+ const published = await servePublic();
70
+ if (published) return published;
71
+ }
72
+ const auth = createAuth(env);
73
+ if (url.pathname.startsWith('/api/auth/')) return handleAuthEndpoint(request, auth);
74
+ if (request.method === 'POST') {
75
+ if (url.pathname === '/login') return signIn(request, auth);
76
+ if (url.pathname === '/logout') return signOut(request, auth);
77
+ }
78
+
79
+ const { headers, response: session } = await auth.api.getSession({
80
+ headers: request.headers,
81
+ returnHeaders: true
82
+ });
83
+ copyCookies(headers, cookies);
84
+ const access = session && (await accessForUser(env, session.user.id));
85
+ if (reading && url.pathname === '/login') {
86
+ const next = returnPath(url.searchParams.get('next'));
87
+ if (access) return redirect(next);
88
+ const page = loginPage(
89
+ next,
90
+ url.searchParams.has('error'),
91
+ env.TWIKI_SITE_NAME,
92
+ env.TWIKI_SITE_DESCRIPTION
93
+ );
94
+ return request.method === 'HEAD' ? new Response(null, page) : page;
95
+ }
96
+ if (!access)
97
+ return reading && servePublic
98
+ ? ((await servePublic()) ?? requireLogin(request, url))
99
+ : requireLogin(request, url);
100
+ if (url.pathname === '/api/access') {
101
+ if (!reading) return new Response('Method not allowed', { status: 405 });
102
+ return new Response(
103
+ request.method === 'HEAD'
104
+ ? null
105
+ : JSON.stringify({ role: access, githubUsername: session?.user.githubUsername ?? null }),
106
+ {
107
+ headers: { 'Content-Type': 'application/json' }
108
+ }
109
+ );
110
+ }
111
+ return null;
112
+ }
113
+
114
+ export async function authenticateRequest(
115
+ request: Request,
116
+ env: AuthEnv,
117
+ serve: () => Promise<Response>,
118
+ servePublic?: () => Promise<Response | null>
119
+ ) {
120
+ const cookies = new Headers();
121
+ let published = false;
122
+ const publication =
123
+ servePublic &&
124
+ (async () => {
125
+ const response = await servePublic();
126
+ if (response) published = true;
127
+ return response;
128
+ });
129
+ let response: Response | null;
130
+ try {
131
+ response = await routeRequest(request, env, cookies, publication);
132
+ } catch {
133
+ console.error('Authentication request failed');
134
+ response = new Response('Authentication temporarily unavailable', { status: 503 });
135
+ }
136
+ if (response === null) {
137
+ try {
138
+ response = await serve();
139
+ } catch {
140
+ console.error('Content request failed');
141
+ response = new Response('Content temporarily unavailable', { status: 500 });
142
+ }
143
+ }
144
+ return published
145
+ ? publicResponse(response, request)
146
+ : privateResponse(response, cookies, request);
147
+ }
@@ -0,0 +1,9 @@
1
+ // @ts-expect-error The adapter generates this untyped module during build.
2
+ import app from '../build/.svelte-kit/svelte-worker.js';
3
+ import { createContentWorker } from './runtime';
4
+ import type { HtmlCache } from './content-runtime';
5
+
6
+ // Cache lookups are inside the gate; full-edition HTML never bypasses authorization.
7
+ const edgeCache = (globalThis as typeof globalThis & { caches?: { default?: HtmlCache } }).caches
8
+ ?.default;
9
+ export default createContentWorker(app, edgeCache);
@@ -0,0 +1,62 @@
1
+ import { siGithub } from 'simple-icons';
2
+
3
+ export function returnPath(value: unknown): string {
4
+ if (
5
+ typeof value !== 'string' ||
6
+ !value.startsWith('/') ||
7
+ value.startsWith('//') ||
8
+ /[\\\x00-\x20]/.test(value) ||
9
+ /%2f|%5c|%0[ad]/i.test(value)
10
+ )
11
+ return '/';
12
+ const path = new URL(value, 'https://local.invalid');
13
+ if (
14
+ path.pathname.startsWith('//') ||
15
+ path.pathname === '/login' ||
16
+ path.pathname.startsWith('/api/auth/') ||
17
+ path.pathname === '/logout'
18
+ )
19
+ return '/';
20
+ return path.pathname + path.search + path.hash;
21
+ }
22
+
23
+ function escape(value: string) {
24
+ return value.replace(
25
+ /[&<>"']/g,
26
+ (character) =>
27
+ ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[character]!
28
+ );
29
+ }
30
+
31
+ export function loginPage(
32
+ next: string,
33
+ failed: boolean,
34
+ name = 'twiki',
35
+ description = 'A private space for your notes.'
36
+ ) {
37
+ const nonce = crypto.randomUUID();
38
+ return new Response(
39
+ `<!doctype html>
40
+ <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
41
+ <title>Sign in · ${escape(name)}</title>
42
+ <script nonce="${nonce}">try{const mode=localStorage.getItem('mode-watcher-mode');if(mode==='light'||mode==='dark')document.documentElement.style.colorScheme=mode}catch{}</script>
43
+ <style>
44
+ @font-face{font-family:'Overused Grotesk';src:url('/fonts/OverusedGrotesk-VF.woff2') format('woff2');font-weight:300 900;font-style:normal;font-display:swap}
45
+ :root{color-scheme:light dark;--bg:light-dark(#f8f6f1,#1a1916);--text:light-dark(#1a1916,#e8e5df);--muted:light-dark(#8a8578,#7a7568);--accent:light-dark(#d35545,#e68578);font-family:'Overused Grotesk',system-ui,sans-serif;background:var(--bg);color:var(--text);-webkit-font-smoothing:antialiased}
46
+ body{margin:0;min-height:100svh;display:grid;place-items:center}main{width:min(21rem,calc(100% - 3rem))}
47
+ h1{font-size:2rem;font-weight:650;letter-spacing:-.04em;line-height:1.1;margin:0 0 .65rem}p{font-size:1rem;line-height:1.6;color:color-mix(in srgb,var(--muted),var(--text) 25%);margin:0}
48
+ button{display:flex;align-items:center;justify-content:center;gap:.65rem;width:100%;border:1px solid transparent;border-radius:.5rem;background:var(--text);color:var(--bg);padding:.8rem 1rem;font:inherit;font-weight:600;cursor:pointer;margin-top:1.5rem;transition:background-color 120ms}
49
+ button:hover{background:color-mix(in srgb,var(--text),var(--accent) 20%)}button:focus-visible{outline:2px solid var(--accent);outline-offset:4px}button svg{flex-shrink:0}.error{color:var(--accent);margin-top:1rem}
50
+ @media(prefers-reduced-motion:reduce){button{transition:none}}
51
+ </style></head><body><main><h1>${escape(name)}</h1><p>${escape(description)}</p>
52
+ ${failed ? '<p class="error" role="alert">Sign-in was unsuccessful. Please use your authorized GitHub account and try again.</p>' : ''}
53
+ <form method="post" action="/login"><input type="hidden" name="next" value="${escape(returnPath(next))}"><button><svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" focusable="false"><path d="${siGithub.path}"/></svg>Continue with GitHub</button></form>
54
+ </main></body></html>`,
55
+ {
56
+ headers: {
57
+ 'Content-Type': 'text/html; charset=utf-8',
58
+ 'Content-Security-Policy': `default-src 'none'; script-src 'nonce-${nonce}'; style-src 'unsafe-inline'; font-src 'self'; form-action 'self' https://github.com; frame-ancestors 'none'; base-uri 'none'`
59
+ }
60
+ }
61
+ );
62
+ }
@@ -0,0 +1,44 @@
1
+ import { frameOptions } from './framing';
2
+
3
+ export type PublicAssets = Record<string, string>;
4
+
5
+ export const fileBrowserShell = '/_file-browser';
6
+ export const publicFileBrowserShell = `/_published${fileBrowserShell}`;
7
+
8
+ export function isFileBrowserPath(pathname: string) {
9
+ return /^\/[^/]+\/files(?:\/|$)/.test(pathname);
10
+ }
11
+
12
+ /** Catalog routes are exact allowlisted aliases to a shared shell, not a catch-all. */
13
+ export function fileBrowserTarget(request: Request, assets: PublicAssets) {
14
+ if (!['GET', 'HEAD'].includes(request.method)) return;
15
+ const pathname = new URL(request.url).pathname;
16
+ if (!isFileBrowserPath(pathname)) return;
17
+ return assets[pathname] === publicFileBrowserShell ? fileBrowserShell : null;
18
+ }
19
+
20
+ export function publicTarget(request: Request, assets: PublicAssets) {
21
+ if (!['GET', 'HEAD'].includes(request.method)) return;
22
+ const pathname = new URL(request.url).pathname;
23
+ if (pathname.startsWith('/_published')) return;
24
+ return Object.hasOwn(assets, pathname) ? assets[pathname] : undefined;
25
+ }
26
+
27
+ export function publicResponse(response: Response, request: Request) {
28
+ const result = new Response(request.method === 'HEAD' ? null : response.body, response);
29
+ result.headers.set('Cache-Control', 'no-store');
30
+ result.headers.set('Vary', 'Cookie');
31
+ result.headers.set('X-Content-Type-Options', 'nosniff');
32
+ result.headers.set('Referrer-Policy', 'same-origin');
33
+ result.headers.delete('Set-Cookie');
34
+ result.headers.delete('X-Robots-Tag');
35
+ const pathname = new URL(request.url).pathname;
36
+ result.headers.set('X-Frame-Options', frameOptions(response));
37
+ if (pathname.startsWith('/_files/') || pathname.includes('/files') || response.status >= 400)
38
+ result.headers.set('X-Robots-Tag', 'noindex');
39
+ if (/^\/_files\/blobs\/[a-f0-9]{64}\.bin$/.test(pathname)) {
40
+ result.headers.set('Content-Type', 'application/octet-stream');
41
+ result.headers.set('Content-Disposition', 'attachment');
42
+ }
43
+ return result;
44
+ }
@@ -0,0 +1,104 @@
1
+ import { authenticateRequest } from './gate';
2
+ import type { AuthEnv } from './auth';
3
+ import { isFileBrowserPath } from './publication';
4
+ import {
5
+ cachedHtml,
6
+ contentContext,
7
+ contentDescriptor,
8
+ contentRouting,
9
+ versionedResponse,
10
+ type AssetBinding,
11
+ type BackgroundContext,
12
+ type ContentContext,
13
+ type HtmlCache
14
+ } from './content-runtime';
15
+
16
+ export interface RuntimeEnv extends AuthEnv {
17
+ ASSETS: AssetBinding;
18
+ WISCONSIN_CONTENT_CONTEXT?: ContentContext;
19
+ }
20
+
21
+ export interface RuntimeApp {
22
+ fetch(request: Request, env: RuntimeEnv, context: BackgroundContext): Promise<Response>;
23
+ }
24
+
25
+ /** Preserve file transfer semantics without forwarding session or authorization headers. */
26
+ export function assetAliasRequest(request: Request, target?: string) {
27
+ const url = new URL(request.url);
28
+ url.pathname = target ?? url.pathname;
29
+ url.search = '';
30
+ const headers = new Headers();
31
+ for (const name of [
32
+ 'range',
33
+ 'if-range',
34
+ 'if-none-match',
35
+ 'if-modified-since',
36
+ 'if-match',
37
+ 'if-unmodified-since'
38
+ ]) {
39
+ const value = request.headers.get(name);
40
+ if (value !== null) headers.set(name, value);
41
+ }
42
+ return new Request(url, { method: request.method, headers });
43
+ }
44
+
45
+ export function createContentWorker(app: RuntimeApp, cache?: HtmlCache) {
46
+ return {
47
+ fetch(request: Request, env: RuntimeEnv, background: BackgroundContext) {
48
+ const url = new URL(request.url);
49
+ let pointer: ReturnType<typeof contentDescriptor> | undefined;
50
+ const descriptor = () => (pointer ??= contentDescriptor(env.ASSETS, url.origin));
51
+ async function serve(edition: ContentContext['edition'], published: boolean) {
52
+ const context = contentContext(await descriptor(), edition);
53
+ const routing = await contentRouting(env.ASSETS, url.origin, context);
54
+ const reading = request.method === 'GET' || request.method === 'HEAD';
55
+ const routePath = url.pathname.endsWith('/__data.json')
56
+ ? url.pathname.slice(0, -'/__data.json'.length) || '/'
57
+ : url.pathname.replace(/\/$/, '') || '/';
58
+ const route = routing.routes.includes(routePath);
59
+ const target = Object.hasOwn(routing.assets, url.pathname)
60
+ ? routing.assets[url.pathname]
61
+ : undefined;
62
+ if (published && (!reading || (!route && !target)))
63
+ return isFileBrowserPath(url.pathname)
64
+ ? new Response('Not found', { status: 404 })
65
+ : null;
66
+ if (reading && !route) {
67
+ if (isFileBrowserPath(url.pathname) && !target)
68
+ return new Response('Not found', { status: 404 });
69
+ const response = await env.ASSETS.fetch(assetAliasRequest(request, target));
70
+ if (
71
+ published &&
72
+ response.status >= 300 &&
73
+ response.status < 400 &&
74
+ response.status !== 304
75
+ )
76
+ return new Response('Publication unavailable', { status: 503 });
77
+ if (response.status !== 404 || published) return versionedResponse(response, context);
78
+ }
79
+ // Trust is carried in an object supplied by this Worker, never a request field.
80
+ const trustedEnv = { ...env, WISCONSIN_CONTENT_CONTEXT: context };
81
+ return versionedResponse(
82
+ await cachedHtml(request, context, routing, cache, background, () =>
83
+ app.fetch(request, trustedEnv, background)
84
+ ),
85
+ context
86
+ );
87
+ }
88
+ return authenticateRequest(
89
+ request,
90
+ env,
91
+ async () => {
92
+ // The login page's maintained font is independent of course snapshots.
93
+ if (
94
+ ['GET', 'HEAD'].includes(request.method) &&
95
+ url.pathname === '/fonts/OverusedGrotesk-VF.woff2'
96
+ )
97
+ return env.ASSETS.fetch(request);
98
+ return (await serve('full', false))!;
99
+ },
100
+ () => serve('public', true)
101
+ );
102
+ }
103
+ };
104
+ }
@@ -0,0 +1,13 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "resolveJsonModule": true,
7
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
8
+ "strict": true,
9
+ "skipLibCheck": true,
10
+ "noEmit": true
11
+ },
12
+ "include": ["./*.ts"]
13
+ }
@@ -0,0 +1,37 @@
1
+ {
2
+ "$schema": "./node_modules/wrangler/config-schema.json",
3
+ "name": "twiki",
4
+ "compatibility_date": "2026-06-07",
5
+ "compatibility_flags": [
6
+ "nodejs_compat"
7
+ ],
8
+ "main": "build/.svelte-kit/worker.js",
9
+ "vars": {
10
+ "ORIGIN": "https://notes.example.test",
11
+ "OWNER_GITHUB_ID": "0",
12
+ "GITHUB_CLIENT_ID": "development-only"
13
+ },
14
+ "d1_databases": [
15
+ {
16
+ "binding": "DB",
17
+ "database_name": "twiki",
18
+ "database_id": "00000000-0000-0000-0000-000000000000",
19
+ "migrations_dir": "migrations"
20
+ }
21
+ ],
22
+ "assets": {
23
+ "binding": "ASSETS",
24
+ "directory": "build/.svelte-kit/cloudflare",
25
+ "html_handling": "drop-trailing-slash",
26
+ "not_found_handling": "404-page",
27
+ "run_worker_first": true
28
+ },
29
+ "workers_dev": false,
30
+ "preview_urls": false,
31
+ "routes": [
32
+ {
33
+ "pattern": "notes.example.test",
34
+ "custom_domain": true
35
+ }
36
+ ]
37
+ }