@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,24 @@
1
+ import { error } from '@sveltejs/kit';
2
+ import type { EntryGenerator, PageServerLoad } from './$types';
3
+ import { requestContent } from '$lib/server/runtime-content';
4
+ import type { TocEntry } from '$lib/types';
5
+
6
+ export const entries: EntryGenerator = async () => {
7
+ if (import.meta.env.VITE_STATIC_EXPORT !== 'true') return [];
8
+ return (await requestContent(undefined).model()).contentEntries().map((slug) => ({ slug }));
9
+ };
10
+
11
+ export const load: PageServerLoad = async ({ params, platform }) => {
12
+ const route = params.slug.replace(/^\/+|\/+$/g, '');
13
+ const content = requestContent(platform);
14
+ const model = await content.model();
15
+ const slug = model.pageSlugForRoute(route);
16
+ if (slug) {
17
+ const { markdown: _markdown, ...page } = await content.page(slug);
18
+ const toc: TocEntry[] = page.toc.map((t) => ({ id: t.slug, text: t.text, level: t.depth + 1 }));
19
+ return { kind: 'page' as const, route, page, toc };
20
+ }
21
+ const listing = model.folderListing(route);
22
+ if (listing) return { kind: 'folder' as const, route, listing, toc: [] as TocEntry[] };
23
+ error(404, 'Page not found');
24
+ };
@@ -0,0 +1,180 @@
1
+ <script lang="ts">
2
+ import Folder from '@lucide/svelte/icons/folder';
3
+ import FileText from '@lucide/svelte/icons/file-text';
4
+ import NoteActions from '$lib/components/doc/NoteActions.svelte';
5
+ import { textExportUrl } from '$lib/text-exports';
6
+ import SEO from '$lib/components/SEO.svelte';
7
+ import Backlinks from '$lib/components/doc/Backlinks.svelte';
8
+ import Breadcrumbs from '$lib/components/doc/Breadcrumbs.svelte';
9
+ import ContentMeta from '$lib/components/doc/ContentMeta.svelte';
10
+ import LicenseNotice from '$lib/components/doc/LicenseNotice.svelte';
11
+ import LockedContent from '$lib/components/doc/LockedContent.svelte';
12
+ import { enhanceArticle } from '$lib/components/doc/enhancements';
13
+ import { linkPopovers } from '$lib/components/popover';
14
+ import type { PageData } from './$types';
15
+ import { curator, noteSchema, pageAuthor } from '$lib/metadata';
16
+ import { socialImageUrl } from '$lib/social-image';
17
+
18
+ interface Props {
19
+ data: PageData;
20
+ }
21
+
22
+ const { data }: Props = $props();
23
+
24
+ const articleKey = $derived(data.kind === 'page' ? JSON.stringify(data.page) : '');
25
+
26
+ const canonical = $derived(data.route === '' ? '/' : `/${data.route}`);
27
+ const author = $derived(data.kind === 'page' ? pageAuthor(data.page) : undefined);
28
+ // Split only a leading H1, retaining its original markup and deep-link id.
29
+ const heading = $derived(
30
+ data.kind === 'page' ? (data.page.html.match(/^\s*<h1\b[^>]*>[\s\S]*?<\/h1>/i)?.[0] ?? '') : ''
31
+ );
32
+ const body = $derived(data.kind === 'page' ? data.page.html.slice(heading.length) : '');
33
+
34
+ // Pagefind permits only the final comma-separated filter to use inline
35
+ // `key:value` syntax. Emit one hidden capture element per value instead so
36
+ // course and every tag become independent, repeatable filters.
37
+ const pagefindCourse = $derived.by(() => {
38
+ if (data.route === '') return undefined;
39
+ return data.route.split('/')[0];
40
+ });
41
+
42
+ function shortDate(iso?: string): string {
43
+ return iso
44
+ ? new Date(iso).toLocaleDateString('en-US', {
45
+ year: 'numeric',
46
+ month: 'short',
47
+ day: 'numeric'
48
+ })
49
+ : '';
50
+ }
51
+ </script>
52
+
53
+ <svelte:head>
54
+ <link rel="alternate" type="text/plain" title="LLM index" href="/llms.txt" />
55
+ {#if data.kind === 'page' && !data.page.locked}
56
+ <link rel="alternate" type="text/markdown" href={textExportUrl(data.page.slug)} />
57
+ <link rel="alternate" type="text/plain" href={textExportUrl(data.page.slug, 'txt')} />
58
+ {/if}
59
+ </svelte:head>
60
+
61
+ {#if data.kind === 'page'}
62
+ <SEO
63
+ title={data.route === '' ? undefined : data.page.title}
64
+ description={data.page.description}
65
+ {canonical}
66
+ type="article"
67
+ noindex={!data.page.publication.public}
68
+ jsonLd={noteSchema(canonical, data.page)}
69
+ image={socialImageUrl(data.page.slug)}
70
+ modified={data.page.dates.modified}
71
+ published={data.page.dates.published}
72
+ {author}
73
+ tags={data.page.tags}
74
+ />
75
+
76
+ <Breadcrumbs route={data.route} current={data.page.title} />
77
+
78
+ {#key articleKey}
79
+ <article
80
+ class="prose dark:prose-invert max-w-none"
81
+ data-pagefind-body={data.page.locked ? undefined : true}
82
+ data-pagefind-ignore={data.page.locked ? true : undefined}
83
+ {@attach enhanceArticle(articleKey)}
84
+ {@attach linkPopovers(articleKey)}
85
+ >
86
+ {#if data.route !== ''}
87
+ <div class="hidden" data-pagefind-ignore>
88
+ <span data-pagefind-filter="course">{pagefindCourse}</span>
89
+ {#each data.page.tags as tag (tag)}
90
+ <span data-pagefind-filter="tag">{tag}</span>
91
+ {/each}
92
+ </div>
93
+ <!-- Frontmatter title beats Pagefind's h1-scraping (some pages lack an h1). -->
94
+ <span class="sr-only" data-pagefind-meta="title">{data.page.title}</span>
95
+ {/if}
96
+ {#if heading}
97
+ {@html heading}
98
+ {:else if data.kind === 'page'}
99
+ <h1>{data.page.title}</h1>
100
+ {/if}
101
+ <div class="doc-meta" data-pagefind-ignore>
102
+ <ContentMeta
103
+ modified={data.route ? data.page.dates?.modified : undefined}
104
+ published={data.route ? data.page.dates?.published : undefined}
105
+ author={data.page.locked ? undefined : (author ?? curator)}
106
+ curated={!author}
107
+ readingTime={data.route ? data.page.readingTime : undefined}
108
+ publication={data.page.publication}
109
+ />
110
+ </div>
111
+ {#if data.page.locked}
112
+ <LockedContent />
113
+ {#if data.page.toc.length}
114
+ <div class="mt-6 space-y-3" aria-label="Page outline">
115
+ {#each data.page.toc as heading (heading.slug)}
116
+ <div
117
+ id={heading.slug}
118
+ class="scroll-mt-20 border-l border-border pl-3 text-sm text-muted"
119
+ style:margin-left={`${heading.depth * 0.75}rem`}
120
+ >
121
+ {heading.text}
122
+ </div>
123
+ {/each}
124
+ </div>
125
+ {/if}
126
+ {:else}
127
+ {#key data.page.slug}
128
+ <NoteActions slug={data.page.slug} />
129
+ {/key}
130
+ {@html body}
131
+ {/if}
132
+ </article>
133
+ {/key}
134
+ {#if data.page.license}
135
+ <div class="mt-6"><LicenseNotice license={data.page.license} /></div>
136
+ {/if}
137
+
138
+ <Backlinks backlinks={data.page.backlinks} />
139
+ {:else}
140
+ <SEO
141
+ title={data.listing.name}
142
+ description={`Browse ${data.listing.name} course notes and files. Public material is readable; other content requires access.`}
143
+ {canonical}
144
+ />
145
+
146
+ <Breadcrumbs route={data.route} current={data.listing.name} />
147
+
148
+ <article class="prose dark:prose-invert max-w-none" data-pagefind-body>
149
+ {#if pagefindCourse}
150
+ <span class="hidden" data-pagefind-ignore data-pagefind-filter="course">{pagefindCourse}</span
151
+ >
152
+ {/if}
153
+ <h1>{data.listing.name}</h1>
154
+ <p class="text-muted">
155
+ {data.listing.pageCount}
156
+ {data.listing.pageCount === 1 ? 'item' : 'items'} under this folder.
157
+ </p>
158
+ </article>
159
+
160
+ <ul class="m-0 mt-4 list-none p-0">
161
+ {#each data.listing.entries as entry (entry.route)}
162
+ <li>
163
+ <a
164
+ class="flex items-center gap-3 rounded-md px-3 py-2 text-sm text-text no-underline hover:bg-surface"
165
+ href={entry.route}
166
+ >
167
+ {#if entry.isFolder}
168
+ <Folder class="size-4 shrink-0 text-muted" aria-hidden="true" />
169
+ {:else}
170
+ <FileText class="size-4 shrink-0 text-muted" aria-hidden="true" />
171
+ {/if}
172
+ <span class="min-w-0 flex-1 truncate font-medium">{entry.title}</span>
173
+ {#if entry.modified}
174
+ <span class="shrink-0 text-xs text-muted">{shortDate(entry.modified)}</span>
175
+ {/if}
176
+ </a>
177
+ </li>
178
+ {/each}
179
+ </ul>
180
+ {/if}
@@ -0,0 +1,373 @@
1
+ <script lang="ts">
2
+ import SEO from '$lib/components/SEO.svelte';
3
+ import LicenseNotice from '$lib/components/doc/LicenseNotice.svelte';
4
+ import LockedContent from '$lib/components/doc/LockedContent.svelte';
5
+ import BookOpen from '@lucide/svelte/icons/book-open';
6
+ import Check from '@lucide/svelte/icons/check';
7
+ import ChevronRight from '@lucide/svelte/icons/chevron-right';
8
+ import Copy from '@lucide/svelte/icons/copy';
9
+ import Download from '@lucide/svelte/icons/download';
10
+ import Pin from '@lucide/svelte/icons/pin';
11
+ import File from '@lucide/svelte/icons/file';
12
+ import ArrowUpRight from '@lucide/svelte/icons/arrow-up-right';
13
+ import History from '@lucide/svelte/icons/history';
14
+ import GitCommitHorizontal from '@lucide/svelte/icons/git-commit-horizontal';
15
+ import ArrowLeft from '@lucide/svelte/icons/arrow-left';
16
+ import { fileRoute, fileSize } from '$lib/files';
17
+ import FileIcon from '$lib/components/files/FileIcon.svelte';
18
+ import FileTabs from '$lib/components/files/FileTabs.svelte';
19
+ import FileCode from '$lib/components/files/FileCode.svelte';
20
+ import FileHistory from '$lib/components/files/FileHistory.svelte';
21
+ import type { FileBlame, FileChange } from '$lib/file-history';
22
+ import { fileWorkspace } from '$lib/components/files/file-workspace.svelte';
23
+ import { sameFile } from '$lib/file-tabs';
24
+ import { beforeNavigate } from '$app/navigation';
25
+ import { onMount, tick, untrack } from 'svelte';
26
+ import type { PageData } from './$types';
27
+ let { data }: { data: PageData } = $props();
28
+ const workspace = fileWorkspace();
29
+ let viewport = $state<HTMLElement>();
30
+ let historyOpen = $state(false);
31
+ let blaming = $state(false);
32
+ let blame = $state<FileBlame>();
33
+ let selectedCommit = $state('');
34
+ let sourceText = $state<string>();
35
+ let rmdMode = $state<'read' | 'interactive' | 'source'>(
36
+ untrack(() => (data.rmd ? 'read' : 'source'))
37
+ );
38
+ let diff = $state<{ text: string; commit: FileChange }>();
39
+ $effect(() => {
40
+ data.course;
41
+ data.path;
42
+ diff = undefined;
43
+ blame = undefined;
44
+ selectedCommit = '';
45
+ sourceText = undefined;
46
+ rmdMode = data.rmd ? 'read' : 'source';
47
+ });
48
+ const activeTab = $derived(workspace.tabs.find((tab) => sameFile(tab, data)));
49
+ function rememberPosition() {
50
+ if (data.file && viewport && !diff) workspace.remember(data.course, data.path, viewport);
51
+ }
52
+ beforeNavigate(rememberPosition);
53
+ onMount(() => {
54
+ window.addEventListener('pagehide', rememberPosition);
55
+ return () => window.removeEventListener('pagehide', rememberPosition);
56
+ });
57
+ $effect(() => {
58
+ const { course, path, file } = data;
59
+ if (!workspace.ready || !viewport) return;
60
+ const position = untrack(() => {
61
+ if (file) workspace.open(course, path);
62
+ return workspace.tabs.find((tab) => sameFile(tab, { course, path }));
63
+ });
64
+ void tick().then(() => {
65
+ if (viewport && data.course === course && data.path === path)
66
+ viewport.scrollTo({
67
+ top: position?.top ?? 0,
68
+ left: position?.left ?? 0,
69
+ behavior: 'instant'
70
+ });
71
+ });
72
+ });
73
+ let copied = $state(false);
74
+ let copyFailed = $state(false);
75
+ const name = $derived(data.path.split('/').at(-1) || data.course);
76
+ const segments = $derived(data.path.split('/').filter(Boolean));
77
+ const action =
78
+ 'inline-flex items-center gap-1.5 rounded px-2 py-1.5 text-xs text-muted no-underline hover:bg-surface hover:text-text';
79
+ $effect(() => {
80
+ data.path;
81
+ copied = false;
82
+ copyFailed = false;
83
+ });
84
+ async function copy() {
85
+ try {
86
+ await navigator.clipboard.writeText(diff?.text ?? sourceText ?? '');
87
+ copied = true;
88
+ copyFailed = false;
89
+ } catch {
90
+ copyFailed = true;
91
+ }
92
+ }
93
+ </script>
94
+
95
+ <SEO
96
+ title={`${name} · Files`}
97
+ description=""
98
+ canonical={fileRoute(data.course, data.path)}
99
+ noindex
100
+ />
101
+
102
+ <div class="flex h-full min-h-0 flex-col" data-pagefind-ignore>
103
+ <div class="flex shrink-0 flex-col border-b border-border">
104
+ <nav
105
+ class="flex items-center gap-1 overflow-x-auto px-3 py-1.5 text-xs whitespace-nowrap text-muted"
106
+ aria-label="File breadcrumbs"
107
+ >
108
+ <a class="hover:text-text" href={`/${encodeURIComponent(data.course)}`}>{data.course}</a>
109
+ <ChevronRight size={12} />
110
+ <a class="hover:text-text" href={fileRoute(data.course)}>Files</a>
111
+ {#each segments as segment, i}
112
+ <ChevronRight size={12} />
113
+ <a
114
+ class="hover:text-text"
115
+ href={fileRoute(data.course, segments.slice(0, i + 1).join('/'))}
116
+ aria-current={i === segments.length - 1 ? 'page' : undefined}>{segment}</a
117
+ >
118
+ {/each}
119
+ </nav>
120
+ <header
121
+ class="order-first flex min-w-0 items-center border-b border-border bg-surface pr-2 max-sm:flex-col max-sm:items-stretch"
122
+ >
123
+ {#if !data.file && !workspace.tabs.length}<div
124
+ class="flex min-w-0 items-center gap-2 border-r border-border bg-bg px-3 py-2"
125
+ >
126
+ <FileIcon {name} folder={!data.file} expanded={!data.file} />
127
+ <h1 class="m-0 break-all font-mono text-xs font-medium">{name}</h1>
128
+ </div>{:else}<h1 class="sr-only">{name}</h1>{/if}
129
+ <FileTabs course={data.course} path={data.path} isFile={!!data.file} />
130
+ {#if data.file}
131
+ <div class="flex shrink-0 items-center gap-1 overflow-x-auto">
132
+ {#if /\.rmd$/i.test(data.path) && !data.file.locked && !diff}
133
+ <div
134
+ class="flex shrink-0 rounded border border-border"
135
+ role="group"
136
+ aria-label="Worksheet view"
137
+ >
138
+ {#each [['read', 'Read'], ['interactive', 'Interactive'], ['source', 'Source']] as [mode, label]}
139
+ <button
140
+ class={action}
141
+ disabled={mode === 'read' && !data.rmd}
142
+ class:opacity-50={mode === 'read' && !data.rmd}
143
+ class:bg-bg={rmdMode === mode}
144
+ aria-pressed={rmdMode === mode}
145
+ onclick={() => (rmdMode = mode as typeof rmdMode)}>{label}</button
146
+ >
147
+ {/each}
148
+ </div>
149
+ {/if}
150
+ {#if data.file.history}
151
+ {#if data.file.kind === 'text'}<button
152
+ class={action}
153
+ title="Toggle blame"
154
+ aria-label="Toggle blame"
155
+ aria-pressed={blaming}
156
+ onclick={() => (blaming = !blaming)}><GitCommitHorizontal size={14} /></button
157
+ >{/if}
158
+ <button
159
+ class={action}
160
+ title="File history"
161
+ aria-label="File history"
162
+ aria-expanded={historyOpen}
163
+ onclick={() => (historyOpen = !historyOpen)}><History size={14} /></button
164
+ >
165
+ {/if}
166
+ {#if !activeTab?.pinned}<button
167
+ class={action}
168
+ title="Keep this preview open"
169
+ aria-label="Keep file open"
170
+ disabled={!workspace.ready}
171
+ onclick={() => workspace.open(data.course, data.path, true)}><Pin size={14} /></button
172
+ >{/if}
173
+ {#if data.file.note}<a class={action} href={data.file.note}
174
+ ><BookOpen size={14} />Read note</a
175
+ >{/if}
176
+ {#if data.file.kind === 'text' || diff}<button
177
+ class={action}
178
+ disabled={!diff && sourceText === undefined}
179
+ onclick={copy}
180
+ >{#if copied}<Check size={14} />Copied{:else}<Copy size={14} />Copy{/if}</button
181
+ >{/if}
182
+ {#if data.file.download}
183
+ {#if data.file.kind === 'pdf' || data.file.kind === 'image'}<a
184
+ class={action}
185
+ href={data.file.download}
186
+ target="_blank"
187
+ rel="noopener noreferrer"><ArrowUpRight size={14} />Open</a
188
+ >{/if}
189
+ <a class={action} href={data.file.download} download={name}
190
+ ><Download size={14} />Download</a
191
+ >
192
+ {/if}
193
+ </div>
194
+ {/if}
195
+ </header>
196
+ </div>
197
+ {#if copyFailed}<p class="px-3 py-2 text-xs text-muted" role="status">
198
+ Clipboard unavailable. Select and copy the code below.
199
+ </p>{/if}
200
+ {#if diff}<div
201
+ class="flex items-center gap-2 border-b border-border px-3 py-1.5 text-xs text-muted"
202
+ >
203
+ <button
204
+ class="flex shrink-0 items-center gap-1 text-accent"
205
+ onclick={() => {
206
+ diff = undefined;
207
+ selectedCommit = '';
208
+ }}><ArrowLeft size={12} />Back to file</button
209
+ >
210
+ <span class="truncate">{diff.commit.subject} · {diff.commit.id.slice(0, 7)}</span>
211
+ </div>{/if}
212
+ <div class="relative flex min-h-0 min-w-0 flex-1 overflow-hidden">
213
+ {#if data.file?.locked}
214
+ <div class="min-w-0 flex-1 overflow-auto p-4"><LockedContent /></div>
215
+ {:else if rmdMode === 'read' && data.rmd && !diff}
216
+ <div class="min-h-0 min-w-0 flex-1 overflow-auto bg-surface p-4 sm:p-8" bind:this={viewport}>
217
+ <article
218
+ class="rmd-reading prose mx-auto max-w-3xl rounded border border-border bg-bg p-5 shadow-sm sm:p-10 dark:prose-invert"
219
+ aria-label="Rendered worksheet"
220
+ >
221
+ <h1>{data.rmd.title}</h1>
222
+ {@html data.rmd.html}
223
+ </article>
224
+ </div>
225
+ {:else if rmdMode === 'interactive' && /\.rmd$/i.test(data.path) && data.file?.download && !diff}
226
+ <div class="min-h-0 min-w-0 flex-1">
227
+ {#key `${data.course}/${data.path}`}
228
+ {#await import('$lib/components/files/FileRmd.svelte')}
229
+ <p class="p-6 text-sm text-muted" role="status">Opening interactive worksheet…</p>
230
+ {:then component}
231
+ <component.default
232
+ url={data.file.download}
233
+ course={data.course}
234
+ path={data.path}
235
+ files={data.files}
236
+ onload={(text) => (sourceText = text)}
237
+ />
238
+ {:catch}
239
+ <p class="p-6 text-sm text-muted" role="alert">
240
+ Could not open the interactive worksheet. Switch to Read or reload to try again.
241
+ </p>
242
+ {/await}
243
+ {/key}
244
+ </div>
245
+ {:else if data.file?.kind === 'text' || diff}
246
+ <div class="min-h-0 min-w-0 flex-1">
247
+ {#key `${data.course}/${data.path}/${diff?.commit.id ?? ''}`}
248
+ <FileCode
249
+ text={diff?.text}
250
+ url={data.file?.download}
251
+ onload={(text) => {
252
+ if (!diff) sourceText = text;
253
+ }}
254
+ filename={diff ? 'change.diff' : name}
255
+ blame={diff ? undefined : blame}
256
+ oncommit={(id) => {
257
+ selectedCommit = id;
258
+ historyOpen = true;
259
+ }}
260
+ onready={(element) => {
261
+ if (!diff) viewport = element;
262
+ }}
263
+ />
264
+ {/key}
265
+ </div>
266
+ {:else}
267
+ <div class="file-content min-h-0 flex-1 overflow-auto" bind:this={viewport}>
268
+ {#if data.file?.kind === 'pdf'}
269
+ <iframe class="block h-full w-full border-0" title={name} src={data.file.download}
270
+ ></iframe>
271
+ {:else if data.file?.kind === 'image'}
272
+ <div class="flex h-full justify-center bg-surface">
273
+ <img class="max-h-full max-w-full object-contain" src={data.file.download} alt={name} />
274
+ </div>
275
+ {:else if data.file}
276
+ <div
277
+ class="flex h-full flex-col items-center justify-center px-3 text-center text-sm text-muted"
278
+ >
279
+ <File class="mx-auto mb-3" size={28} />
280
+ <p>
281
+ {data.file.download
282
+ ? 'No preview for this file type. Download it to open locally.'
283
+ : 'This file exceeds the hosting size limit and is not available for download.'}
284
+ </p>
285
+ </div>
286
+ {:else}
287
+ <ul class="m-0 list-none divide-y divide-border p-0" aria-label="Directory contents">
288
+ {#each data.entries ?? [] as entry (entry.path)}
289
+ <li>
290
+ <a
291
+ class="flex min-w-0 items-center gap-3 px-3 py-2.5 text-sm text-text no-underline hover:bg-surface"
292
+ href={fileRoute(data.course, entry.path)}
293
+ onclick={(event) => {
294
+ if (entry.file) workspace.activate(event, data.course, entry.path);
295
+ }}
296
+ >
297
+ <FileIcon name={entry.name} folder={!!entry.children} />
298
+ <span class="min-w-0 flex-1 truncate">{entry.name}</span><span
299
+ class="shrink-0 text-xs text-muted"
300
+ >{entry.file
301
+ ? entry.file.locked
302
+ ? 'Locked'
303
+ : fileSize(entry.file.size)
304
+ : `${entry.children?.length} items`}</span
305
+ >
306
+ </a>
307
+ </li>
308
+ {:else}<li class="p-4 text-sm text-muted">No browsable files in this course.</li>{/each}
309
+ </ul>
310
+ {/if}
311
+ </div>
312
+ {/if}
313
+ {#if data.file?.history}
314
+ {#key data.file.history}
315
+ <FileHistory
316
+ url={data.file.history}
317
+ bind:open={historyOpen}
318
+ bind:blaming
319
+ bind:selected={selectedCommit}
320
+ onblame={(value) => (blame = value)}
321
+ onview={(text, commit) => {
322
+ rememberPosition();
323
+ diff = { text, commit };
324
+ }}
325
+ />
326
+ {/key}
327
+ {/if}
328
+ </div>
329
+ {#if data.file?.license}
330
+ <details class="shrink-0 border-t border-border px-3 text-xs text-muted">
331
+ <summary class="cursor-pointer py-1.5">{data.file.license.name} · Attribution</summary>
332
+ <LicenseNotice license={data.file.license} />
333
+ </details>
334
+ {/if}
335
+ <footer
336
+ class="flex shrink-0 items-center justify-between border-t border-border bg-surface px-3 py-1 text-[0.6875rem] text-muted"
337
+ >
338
+ <span
339
+ >{diff
340
+ ? `${diff.text.split('\n').length} lines · Diff`
341
+ : sourceText !== undefined
342
+ ? `${sourceText.split('\n').length} lines · UTF-8`
343
+ : data.file?.kind === 'pdf'
344
+ ? 'PDF'
345
+ : data.file?.kind === 'image'
346
+ ? 'Image'
347
+ : 'Files'}</span
348
+ >
349
+ <span
350
+ >{data.file
351
+ ? `${fileSize(data.file.size)} · Read-only`
352
+ : `${data.entries?.length ?? 0} items`}</span
353
+ >
354
+ </footer>
355
+ </div>
356
+
357
+ <style>
358
+ .rmd-reading {
359
+ font-family: 'KaTeX_Main', Georgia, 'Times New Roman', serif;
360
+ line-height: 1.8;
361
+ }
362
+ .rmd-reading :global(:is(h1, h2, h3, h4, h5, h6)) {
363
+ font-family: inherit;
364
+ }
365
+ .rmd-reading :global(pre) {
366
+ white-space: pre-wrap;
367
+ overflow-wrap: anywhere;
368
+ }
369
+ .rmd-reading :global(img) {
370
+ margin-inline: auto;
371
+ background: white;
372
+ }
373
+ </style>
@@ -0,0 +1,38 @@
1
+ import { error } from '@sveltejs/kit';
2
+ import {
3
+ directoryEntries,
4
+ fileIndexUrl,
5
+ fileTree,
6
+ type CourseFile,
7
+ type RmdPreview
8
+ } from '$lib/files';
9
+ import type { PageLoad } from './$types';
10
+
11
+ // File routes share a static shell; only their catalog and selected assets are loaded.
12
+ export const prerender = false;
13
+ export const ssr = false;
14
+
15
+ export const load: PageLoad = async ({ params, fetch }) => {
16
+ const response = await fetch(fileIndexUrl(params.course));
17
+ if (!response.ok) error(response.status === 404 ? 404 : 503, 'Files unavailable');
18
+ const files: CourseFile[] = await response.json();
19
+ const file = files.find((entry) => entry.path === params.file);
20
+ const entries = file ? undefined : directoryEntries(fileTree(files), params.file);
21
+ if (!file && !entries) error(404, 'File not found');
22
+ let rmd: RmdPreview | undefined;
23
+ if (file?.rmdPreview && !file.locked) {
24
+ const preview = await fetch(file.rmdPreview);
25
+ if (!preview.ok) error(503, 'Worksheet preview unavailable');
26
+ rmd = await preview.json();
27
+ }
28
+ return {
29
+ kind: 'file-browser' as const,
30
+ course: params.course,
31
+ path: params.file,
32
+ files,
33
+ file,
34
+ rmd,
35
+ entries,
36
+ toc: []
37
+ };
38
+ };
@@ -0,0 +1,53 @@
1
+ import { githubUsername } from '$lib/github-user';
2
+ import { fail } from '@sveltejs/kit';
3
+ import { requireOwner, revokeAccess } from '$lib/server/access-admin';
4
+ import type { Actions, PageServerLoad } from './$types';
5
+ import { sql } from 'drizzle-orm';
6
+ import { database } from '../../../../database';
7
+ import { siteAccess } from '../../../../database/schema';
8
+
9
+ export const prerender = false;
10
+
11
+ export const load: PageServerLoad = async (event) => {
12
+ const env = await requireOwner(event);
13
+ const grants = await database(env.DB)
14
+ .select()
15
+ .from(siteAccess)
16
+ .orderBy(sql`${siteAccess.githubLogin} COLLATE NOCASE`);
17
+ return { grants, ownerId: env.OWNER_GITHUB_ID };
18
+ };
19
+
20
+ export const actions: Actions = {
21
+ add: async (event) => {
22
+ const env = await requireOwner(event);
23
+ const form = await event.request.formData();
24
+ // The authenticated owner may grant any GitHub ID. The browser resolves the
25
+ // profile; the ID controls access and the login is only a display label.
26
+ const id = form.get('githubId');
27
+ const login = form.get('githubLogin');
28
+ if (
29
+ typeof id !== 'string' ||
30
+ !/^[1-9]\d*$/.test(id) ||
31
+ !Number.isSafeInteger(Number(id)) ||
32
+ typeof login !== 'string' ||
33
+ !githubUsername.test(login)
34
+ )
35
+ return fail(400, { error: 'Resolve a valid GitHub account before adding access.' });
36
+ if (id === env.OWNER_GITHUB_ID)
37
+ return fail(400, { error: 'You already have permanent owner access.' });
38
+ await database(env.DB)
39
+ .insert(siteAccess)
40
+ .values({ githubId: id, githubLogin: login, createdAt: Date.now() })
41
+ .onConflictDoUpdate({ target: siteAccess.githubId, set: { githubLogin: login } });
42
+ return { message: `@${login} can now sign in and read all courses.` };
43
+ },
44
+ revoke: async (event) => {
45
+ const env = await requireOwner(event);
46
+ const id = (await event.request.formData()).get('githubId');
47
+ if (typeof id !== 'string' || !/^[1-9]\d*$/.test(id))
48
+ return fail(400, { error: 'Invalid GitHub account ID.' });
49
+ if (id === env.OWNER_GITHUB_ID) return fail(400, { error: 'Owner access cannot be revoked.' });
50
+ await revokeAccess(env, id);
51
+ return { message: 'Access revoked. Existing sessions have been signed out.' };
52
+ }
53
+ };