@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,351 @@
1
+ <script lang="ts">
2
+ import { page } from '$app/state';
3
+ import { tick, untrack } from 'svelte';
4
+ import { fade } from 'svelte/transition';
5
+ import { prefersReducedMotion } from 'svelte/motion';
6
+ import BookOpen from '@lucide/svelte/icons/book-open';
7
+ import FolderCode from '@lucide/svelte/icons/folder-code';
8
+ import ChevronsUpDown from '@lucide/svelte/icons/chevrons-up-down';
9
+ import LogOut from '@lucide/svelte/icons/log-out';
10
+ import LogIn from '@lucide/svelte/icons/log-in';
11
+ import { siGithub } from 'simple-icons';
12
+ const publicEdition = $derived(page.data.edition === 'public');
13
+ import type { NavNode } from '$lib/types';
14
+ import { isNotebookRoute } from '$lib/notebook-nav';
15
+ import { authEnabled, site } from '$lib/config';
16
+ import { ThemeToggle } from '$lib/components/ui';
17
+ import IconButton from '$lib/components/ui/IconButton.svelte';
18
+ import NavTree from './NavTree.svelte';
19
+ import ReaderModeToggle from './ReaderModeToggle.svelte';
20
+ import AccessLink from './AccessLink.svelte';
21
+ import FileExplorer from '$lib/components/files/FileExplorer.svelte';
22
+ import { fileWorkspace } from '$lib/components/files/file-workspace.svelte';
23
+ import { SearchButton } from '$lib/components/search';
24
+
25
+ const { nav }: { nav: NavNode[] } = $props();
26
+ const workspace = fileWorkspace();
27
+ const uid = $props.id();
28
+ const path = $derived(decodeURI(page.url.pathname));
29
+ const course = $derived(
30
+ nav.find((node) => node.children.length && path.split('/')[1] === node.segment)
31
+ );
32
+ const courseNodes = $derived(
33
+ (course?.children ?? [])
34
+ .map((node) => ({
35
+ ...node,
36
+ title:
37
+ node.segment === 'README'
38
+ ? 'Overview'
39
+ : ((
40
+ {
41
+ lectures: 'Lectures',
42
+ homework: 'Homework',
43
+ exams: 'Exams',
44
+ projects: 'Projects'
45
+ } as Record<string, string>
46
+ )[node.title] ?? node.title)
47
+ }))
48
+ .sort((a, b) => Number(b.segment === 'README') - Number(a.segment === 'README'))
49
+ );
50
+ let choosing = $state(false);
51
+ let explorer = $state<'notes' | 'files'>(
52
+ untrack(() =>
53
+ page.data.kind === 'file-browser' && !isNotebookRoute(nav, path) ? 'files' : 'notes'
54
+ )
55
+ );
56
+ $effect(() => {
57
+ explorer = page.data.kind === 'file-browser' && !isNotebookRoute(nav, path) ? 'files' : 'notes';
58
+ });
59
+ let query = $state('');
60
+ let search = $state<HTMLInputElement>();
61
+ let switcher: HTMLButtonElement;
62
+ let viewport = $state<HTMLElement>();
63
+ let expanded = $state<Record<string, boolean>>({});
64
+ function semester(node: NavNode) {
65
+ const match = /^(sp|su|fa|wi)(\d{2})-/.exec(node.segment);
66
+ return match
67
+ ? `${{ sp: 'Spring', su: 'Summer', fa: 'Fall', wi: 'Winter' }[match[1]]} 20${match[2]}`
68
+ : 'Other';
69
+ }
70
+ function label(node: NavNode) {
71
+ return node.segment
72
+ .replace(/^(sp|su|fa|wi)\d{2}-/, '')
73
+ .replace(/([a-z])(\d)/, '$1 $2')
74
+ .toUpperCase();
75
+ }
76
+ function landing(node: NavNode) {
77
+ return node.children.find((child) => child.segment === 'README')?.route ?? node.route ?? '/';
78
+ }
79
+ function semesterOrder(node: NavNode) {
80
+ const match = /^(sp|su|fa|wi)(\d{2})-/.exec(node.segment);
81
+ return match ? Number(match[2]) * 10 + ['wi', 'sp', 'su', 'fa'].indexOf(match[1]) : -1;
82
+ }
83
+ const groups = $derived.by(() => {
84
+ const result = new Map<string, NavNode[]>();
85
+ for (const node of [...nav].sort(
86
+ (a, b) =>
87
+ semesterOrder(b) - semesterOrder(a) ||
88
+ label(a).localeCompare(label(b), undefined, { numeric: true })
89
+ )) {
90
+ if (
91
+ !node.children.length ||
92
+ !`${label(node)} ${semester(node)} ${node.title}`
93
+ .toLowerCase()
94
+ .includes(query.toLowerCase().trim())
95
+ )
96
+ continue;
97
+ const group = semester(node);
98
+ result.set(group, [...(result.get(group) ?? []), node]);
99
+ }
100
+ return [...result];
101
+ });
102
+ async function toggleCourses() {
103
+ choosing = !choosing;
104
+ query = '';
105
+ if (choosing) {
106
+ await tick();
107
+ search?.focus();
108
+ }
109
+ }
110
+ $effect(() => {
111
+ const current = path;
112
+ untrack(() => {
113
+ choosing = false;
114
+ query = '';
115
+ const next = { ...expanded };
116
+ function reveal(nodes: NavNode[]): boolean {
117
+ let found = false;
118
+ for (const node of nodes) {
119
+ const childActive = reveal(node.children);
120
+ if (childActive && node.route) next[node.route] = true;
121
+ if (childActive || node.route === current) found = true;
122
+ }
123
+ return found;
124
+ }
125
+ reveal(nav);
126
+ expanded = next;
127
+ });
128
+ });
129
+ $effect(() => {
130
+ path;
131
+ if (!viewport || explorer !== 'notes') return;
132
+ const timer = setTimeout(() => {
133
+ const link = viewport?.querySelector<HTMLElement>('[aria-current="page"]');
134
+ if (!link || !viewport || !link.getClientRects().length) return;
135
+ const bounds = viewport.getBoundingClientRect(),
136
+ row = link.getBoundingClientRect();
137
+ if (row.top < bounds.top || row.bottom > bounds.bottom)
138
+ viewport.scrollTo({
139
+ top:
140
+ viewport.scrollTop + row.top - bounds.top - viewport.clientHeight / 2 + row.height / 2,
141
+ behavior: prefersReducedMotion.current ? 'instant' : 'smooth'
142
+ });
143
+ }, 200);
144
+ return () => clearTimeout(timer);
145
+ });
146
+ </script>
147
+
148
+ <div class="doc-sidebar-content flex h-full min-h-0 flex-col gap-3">
149
+ <a class="text-[1.05rem] font-bold tracking-[-0.01em] text-text no-underline" href="/"
150
+ >{site.name}</a
151
+ >
152
+ <SearchButton />
153
+ <button
154
+ class="course-switcher"
155
+ bind:this={switcher}
156
+ aria-expanded={choosing || !course}
157
+ aria-controls={`${uid}-courses`}
158
+ onclick={toggleCourses}
159
+ >
160
+ <span
161
+ ><small>{course ? semester(course) : 'Workspace'}</small><strong
162
+ >{course ? label(course) : 'Browse courses'}</strong
163
+ ></span
164
+ >
165
+ <ChevronsUpDown size={15} />
166
+ </button>
167
+ {#if course && !choosing}
168
+ <div
169
+ class="inline-flex shrink-0 self-start gap-0.5 rounded-full bg-surface p-0.5"
170
+ role="group"
171
+ aria-label="Explorer view"
172
+ >
173
+ {#each ['notes', 'files'] as view}
174
+ <button
175
+ class="flex cursor-pointer items-center gap-1.5 rounded-full px-2.5 py-1 text-[0.6875rem] leading-4 text-muted aria-pressed:bg-bg aria-pressed:text-text"
176
+ aria-pressed={explorer === view}
177
+ aria-controls={`${uid}-courses`}
178
+ onclick={() => (explorer = view as 'notes' | 'files')}
179
+ >
180
+ {#if view === 'notes'}<BookOpen size={12} />Notes{:else}<FolderCode size={12} />Files{/if}
181
+ </button>
182
+ {/each}
183
+ </div>
184
+ {/if}
185
+ <nav
186
+ id={`${uid}-courses`}
187
+ class="course-navigation min-h-0 flex-1 overflow-y-auto"
188
+ aria-label="Documentation"
189
+ bind:this={viewport}
190
+ >
191
+ {#if choosing || !course}
192
+ <div>
193
+ <input
194
+ bind:this={search}
195
+ bind:value={query}
196
+ aria-label="Find a course"
197
+ placeholder="Find a course…"
198
+ onkeydown={(event) => {
199
+ if (event.key === 'Escape') {
200
+ choosing = false;
201
+ query = '';
202
+ switcher.focus();
203
+ }
204
+ }}
205
+ />
206
+ {#each groups as [name, courses]}
207
+ <p class="group-label">{name}</p>
208
+ {#each courses as item}
209
+ <a
210
+ class="course-option"
211
+ href={landing(item)}
212
+ onclick={() => (choosing = false)}
213
+ aria-current={item === course ? 'true' : undefined}>{label(item)}</a
214
+ >
215
+ {/each}
216
+ {/each}
217
+ {#if !groups.length}<p class="empty">No courses match “{query}”.</p>{/if}
218
+ <div class="workspace-links">
219
+ <a class="course-option" href="/">Workspace home</a>
220
+ {#each nav.filter((node) => !node.children.length && node.route) as node}<a
221
+ class="course-option"
222
+ href={node.route}>{node.title}</a
223
+ >{/each}
224
+ </div>
225
+ </div>
226
+ {:else}
227
+ {#key course.segment}
228
+ {#if explorer === 'notes'}
229
+ <div in:fade={{ duration: prefersReducedMotion.current ? 0 : 120 }}>
230
+ <NavTree nodes={courseNodes} bind:expanded />
231
+ </div>
232
+ {:else}
233
+ <FileExplorer course={course.segment} />
234
+ {/if}
235
+ {/key}
236
+ {/if}
237
+ </nav>
238
+ <div class="flex shrink-0 items-center justify-between border-t border-border pt-3">
239
+ <a
240
+ href="https://github.com/twangodev/twiki"
241
+ target="_blank"
242
+ rel="noopener noreferrer"
243
+ aria-label="Source on GitHub"
244
+ title="Source on GitHub"
245
+ class="inline-flex size-9 items-center justify-center rounded-lg text-muted hover:bg-surface hover:text-text"
246
+ >
247
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
248
+ <path d={siGithub.path} />
249
+ </svg>
250
+ </a>
251
+ <ThemeToggle /><ReaderModeToggle />
252
+ {#if authEnabled}
253
+ <AccessLink />
254
+ {#if publicEdition}
255
+ <a
256
+ href={`/login?next=${encodeURIComponent(page.url.pathname)}`}
257
+ data-sveltekit-reload
258
+ aria-label="Sign in"
259
+ title="Sign in"
260
+ class="inline-flex size-9 items-center justify-center rounded-lg text-muted hover:bg-surface hover:text-text"
261
+ ><LogIn size={16} /></a
262
+ >
263
+ {:else}
264
+ <form method="post" action="/logout" onsubmit={() => workspace.clear()}>
265
+ <IconButton type="submit" aria-label="Sign out" title="Sign out"
266
+ ><LogOut size={16} /></IconButton
267
+ >
268
+ </form>
269
+ {/if}
270
+ {/if}
271
+ </div>
272
+ </div>
273
+
274
+ <style>
275
+ .course-switcher {
276
+ display: flex;
277
+ justify-content: space-between;
278
+ align-items: center;
279
+ text-align: left;
280
+ padding: 0.5rem;
281
+ border: 1px solid var(--color-border);
282
+ border-radius: 0.375rem;
283
+ cursor: pointer;
284
+ color: var(--color-text);
285
+ }
286
+ .course-switcher small,
287
+ .course-switcher strong {
288
+ display: block;
289
+ }
290
+ .course-switcher small {
291
+ font-size: 0.625rem;
292
+ color: var(--color-muted);
293
+ margin-bottom: 0.125rem;
294
+ }
295
+ .course-switcher strong {
296
+ font-size: 0.8125rem;
297
+ font-weight: 600;
298
+ }
299
+ .course-navigation {
300
+ scrollbar-width: thin;
301
+ overflow-anchor: none;
302
+ overscroll-behavior: contain;
303
+ }
304
+ input {
305
+ width: 100%;
306
+ padding: 0.5rem;
307
+ font-size: 0.8125rem;
308
+ border: 1px solid var(--color-border);
309
+ border-radius: 0.25rem;
310
+ background: var(--color-bg);
311
+ }
312
+ .group-label {
313
+ margin: 1rem 0 0.25rem;
314
+ padding-inline: 0.5rem;
315
+ font-size: 0.625rem;
316
+ letter-spacing: 0.06em;
317
+ text-transform: uppercase;
318
+ color: var(--color-muted);
319
+ }
320
+ .course-option {
321
+ display: block;
322
+ padding: 0.375rem 0.5rem;
323
+ color: var(--color-text);
324
+ font-size: 0.8125rem;
325
+ text-decoration: none;
326
+ border-radius: 0.25rem;
327
+ }
328
+ .course-option:hover,
329
+ .course-switcher:hover {
330
+ background: var(--color-surface);
331
+ }
332
+ .course-option[aria-current] {
333
+ color: var(--color-accent);
334
+ }
335
+ .workspace-links {
336
+ border-top: 1px solid var(--color-border);
337
+ margin-top: 1rem;
338
+ padding-top: 0.5rem;
339
+ }
340
+ .empty {
341
+ font-size: 0.8125rem;
342
+ color: var(--color-muted);
343
+ padding: 0.5rem;
344
+ }
345
+ button:focus-visible,
346
+ a:focus-visible,
347
+ input:focus-visible {
348
+ outline: 2px solid var(--color-accent);
349
+ outline-offset: -1px;
350
+ }
351
+ </style>
@@ -0,0 +1,339 @@
1
+ <script lang="ts">
2
+ import { page } from '$app/state';
3
+ import { untrack } from 'svelte';
4
+ import { Spring, prefersReducedMotion } from 'svelte/motion';
5
+ import { slide } from 'svelte/transition';
6
+ import { cubicOut } from 'svelte/easing';
7
+ import type { TocEntry } from '$lib/types';
8
+
9
+ const uid = $props.id();
10
+ const { active }: { active: string } = $props();
11
+ const items = $derived((page.data.toc as TocEntry[] | undefined) ?? []);
12
+ type Node = TocEntry & { children: Node[]; ancestors: string[] };
13
+ const tree = $derived.by(() => {
14
+ const roots: Node[] = [],
15
+ parents: Node[] = [];
16
+ for (const item of items) {
17
+ while (parents.length && parents[parents.length - 1].level >= item.level) parents.pop();
18
+ const node: Node = { ...item, children: [], ancestors: parents.map((p) => p.id) };
19
+ (parents.at(-1)?.children ?? roots).push(node);
20
+ parents.push(node);
21
+ }
22
+ return roots;
23
+ });
24
+ const nodes = $derived.by(() => {
25
+ const result = new Map<string, Node>();
26
+ function visit(branch: Node[]) {
27
+ for (const node of branch) {
28
+ result.set(node.id, node);
29
+ visit(node.children);
30
+ }
31
+ }
32
+ visit(tree);
33
+ return result;
34
+ });
35
+ let focused = $state('');
36
+ let overrides = $state<Record<string, boolean>>({});
37
+ let expandAll = $state(false);
38
+ let list = $state<HTMLDivElement>();
39
+ let markerVisible = $state(false);
40
+ const marker = new Spring({ y: 0, height: 0 }, { stiffness: 0.2, damping: 0.85 });
41
+ const documentRoot = $derived(tree.length === 1 && tree[0].level === 1 ? tree[0].id : '');
42
+ function isOpen(node: Node): boolean {
43
+ if (nodes.get(focused)?.ancestors.includes(node.id)) return true;
44
+ return (
45
+ expandAll ||
46
+ (overrides[node.id] ??
47
+ Boolean(
48
+ node.id === documentRoot ||
49
+ nodes.get(active)?.ancestors.includes(node.id) ||
50
+ active === node.id
51
+ ))
52
+ );
53
+ }
54
+ function measureMarker() {
55
+ const link = Array.from(
56
+ list?.querySelectorAll<HTMLAnchorElement>('a[aria-current="location"]') ?? []
57
+ ).find((link) => link.getAttribute('href') === `#${encodeURIComponent(active)}`);
58
+ markerVisible = Boolean(link && link.getClientRects().length);
59
+ if (!link || !markerVisible || !list) return;
60
+ const box = link.getBoundingClientRect();
61
+ void marker.set(
62
+ { y: box.top - list.getBoundingClientRect().top, height: box.height },
63
+ { instant: prefersReducedMotion.current }
64
+ );
65
+ }
66
+ function followActive() {
67
+ const viewport = list?.closest<HTMLElement>('.doc-toc');
68
+ const link = Array.from(
69
+ list?.querySelectorAll<HTMLAnchorElement>('a[aria-current="location"]') ?? []
70
+ ).find((link) => link.getAttribute('href') === `#${encodeURIComponent(active)}`);
71
+ if (!link?.getClientRects().length) return;
72
+ if (viewport && viewport.clientHeight < viewport.scrollHeight) {
73
+ const box = link.getBoundingClientRect();
74
+ const bounds = viewport.getBoundingClientRect();
75
+ const headerHeight =
76
+ viewport.querySelector('.toc-header')?.getBoundingClientRect().height ?? 0;
77
+ const top = bounds.top + headerHeight + 24;
78
+ const bottom = bounds.bottom - 12;
79
+ // Leave room for nearby headings instead of riding the viewport edge.
80
+ const cushion = Math.min(48, Math.max(0, (bottom - top - box.height) / 4));
81
+ const delta =
82
+ box.top < top
83
+ ? box.top - top - cushion
84
+ : box.bottom > bottom
85
+ ? box.bottom - bottom + cushion
86
+ : 0;
87
+ if (Math.abs(delta) > 1)
88
+ viewport.scrollTo({
89
+ top: viewport.scrollTop + delta,
90
+ behavior: prefersReducedMotion.current ? 'instant' : 'smooth'
91
+ });
92
+ }
93
+ }
94
+ $effect(() => {
95
+ items;
96
+ untrack(() => {
97
+ overrides = {};
98
+ expandAll = false;
99
+ focused = '';
100
+ });
101
+ });
102
+ $effect(() => {
103
+ active;
104
+ expandAll;
105
+ overrides;
106
+ focused;
107
+ if (!list) return;
108
+ let followTimer: ReturnType<typeof setTimeout>;
109
+ function measure() {
110
+ measureMarker();
111
+ // Branch slides resize on every frame. Follow once they settle, rather
112
+ // than repeatedly restarting a scroll against a moving target.
113
+ clearTimeout(followTimer);
114
+ followTimer = setTimeout(followActive, prefersReducedMotion.current ? 0 : 80);
115
+ }
116
+ const frame = requestAnimationFrame(measure);
117
+ const observer = new ResizeObserver(measure);
118
+ observer.observe(list);
119
+ const viewport = list.closest('.doc-toc');
120
+ if (viewport) observer.observe(viewport);
121
+ return () => {
122
+ cancelAnimationFrame(frame);
123
+ clearTimeout(followTimer);
124
+ observer.disconnect();
125
+ };
126
+ });
127
+ </script>
128
+
129
+ {#snippet branches(branch: Node[], depth = 0)}
130
+ <ul class="toc-branches" class:nested={depth > 0 && depth <= 2}>
131
+ {#each branch as item (item.id)}
132
+ <li>
133
+ {#if depth > 2 && branch[0] === item}
134
+ <div class="parent-context">↳ {nodes.get(item.ancestors.at(-1) ?? '')?.text}</div>
135
+ {/if}
136
+ <div class="toc-row">
137
+ {#if item.children.length}
138
+ <button
139
+ class="branch-toggle"
140
+ aria-label={`Toggle ${item.text} sections`}
141
+ aria-expanded={isOpen(item)}
142
+ aria-controls={`${uid}-${item.id}`}
143
+ onfocus={() => (focused = item.id)}
144
+ onblur={() => (focused = '')}
145
+ onclick={() => {
146
+ overrides = { ...overrides, [item.id]: !isOpen(item) };
147
+ expandAll = false;
148
+ }}
149
+ >
150
+ <svg
151
+ class:open={isOpen(item)}
152
+ width="12"
153
+ height="12"
154
+ viewBox="0 0 16 16"
155
+ aria-hidden="true"><path d="m6 4 4 4-4 4" /></svg
156
+ >
157
+ </button>
158
+ {:else}<span class="leaf-spacer"></span>{/if}
159
+ <a
160
+ class:parent={item.children.length > 0}
161
+ href={`#${encodeURIComponent(item.id)}`}
162
+ aria-current={active === item.id ? 'location' : undefined}
163
+ onfocus={() => (focused = item.id)}
164
+ onblur={() => (focused = '')}>{item.text}</a
165
+ >
166
+ </div>
167
+ <div id={`${uid}-${item.id}`}>
168
+ {#if item.children.length && isOpen(item)}
169
+ <div
170
+ onoutrostart={(event) => {
171
+ // Outgoing Svelte blocks retain their old attributes until the slide ends.
172
+ // Remove their stale current marker and keyboard/accessibility targets now.
173
+ event.currentTarget.inert = true;
174
+ event.currentTarget
175
+ .querySelectorAll('[aria-current]')
176
+ .forEach((link) => link.removeAttribute('aria-current'));
177
+ }}
178
+ onintrostart={(event) => {
179
+ event.currentTarget.inert = false;
180
+ }}
181
+ transition:slide={{
182
+ duration: prefersReducedMotion.current ? 0 : 180,
183
+ easing: cubicOut
184
+ }}
185
+ >
186
+ {@render branches(item.children, depth + (item.id === documentRoot ? 0 : 1))}
187
+ </div>
188
+ {/if}
189
+ </div>
190
+ </li>
191
+ {/each}
192
+ </ul>
193
+ {/snippet}
194
+
195
+ {#if items.length > 0}
196
+ <nav aria-label="On this page">
197
+ <div class="toc-header">
198
+ <p>On this page</p>
199
+ {#if items.some((item) => nodes.get(item.id)?.children.length)}
200
+ <button
201
+ class="mode-toggle"
202
+ onclick={() => {
203
+ expandAll = !expandAll;
204
+ overrides = {};
205
+ }}>{expandAll ? 'Focus' : 'Expand all'}</button
206
+ >
207
+ {/if}
208
+ </div>
209
+ <div class="toc-list" bind:this={list}>
210
+ <span
211
+ class="position-marker"
212
+ aria-hidden="true"
213
+ style:opacity={markerVisible ? 1 : 0}
214
+ style:transform={`translateY(${marker.current.y}px)`}
215
+ style:height={`${marker.current.height}px`}
216
+ ></span>
217
+ {@render branches(tree)}
218
+ </div>
219
+ </nav>
220
+ {/if}
221
+
222
+ <style>
223
+ nav {
224
+ font-size: 0.8125rem;
225
+ }
226
+ .toc-header {
227
+ display: flex;
228
+ align-items: center;
229
+ justify-content: space-between;
230
+ gap: 0.5rem;
231
+ margin-bottom: 0.375rem;
232
+ }
233
+ p {
234
+ margin: 0;
235
+ font-size: 0.7rem;
236
+ font-weight: 700;
237
+ letter-spacing: 0.08em;
238
+ text-transform: uppercase;
239
+ color: var(--color-muted);
240
+ }
241
+ button {
242
+ color: var(--color-muted);
243
+ cursor: pointer;
244
+ border-radius: 0.25rem;
245
+ }
246
+ .mode-toggle {
247
+ font-size: 0.6875rem;
248
+ padding: 0.25rem;
249
+ }
250
+ .toc-list {
251
+ position: relative;
252
+ padding-left: 0.25rem;
253
+ }
254
+ .position-marker {
255
+ position: absolute;
256
+ top: 0;
257
+ left: 0;
258
+ width: 2px;
259
+ border-radius: 2px;
260
+ background: var(--color-accent);
261
+ pointer-events: none;
262
+ }
263
+ .toc-branches {
264
+ list-style: none;
265
+ margin: 0;
266
+ padding: 0;
267
+ }
268
+ .toc-branches.nested {
269
+ margin-left: 0.5rem;
270
+ padding-left: 0.5rem;
271
+ border-left: 1px solid var(--color-border);
272
+ }
273
+ .toc-row {
274
+ display: flex;
275
+ align-items: flex-start;
276
+ }
277
+ .branch-toggle,
278
+ .leaf-spacer {
279
+ flex: 0 0 1rem;
280
+ width: 1rem;
281
+ height: 1.625rem;
282
+ display: grid;
283
+ place-items: center;
284
+ }
285
+ svg {
286
+ fill: none;
287
+ stroke: currentColor;
288
+ stroke-width: 1.5;
289
+ transition: transform 180ms;
290
+ }
291
+ svg.open {
292
+ transform: rotate(90deg);
293
+ }
294
+ a {
295
+ display: block;
296
+ min-width: 0;
297
+ flex: 1;
298
+ border-radius: 0.25rem;
299
+ padding: 0.25rem;
300
+ color: var(--color-muted);
301
+ line-height: 1.4;
302
+ overflow-wrap: anywhere;
303
+ text-decoration: none;
304
+ transition:
305
+ color 120ms,
306
+ background-color 120ms;
307
+ }
308
+ a.parent {
309
+ font-weight: 500;
310
+ color: var(--color-text);
311
+ }
312
+ a[aria-current],
313
+ a:hover,
314
+ button:hover {
315
+ color: var(--color-accent);
316
+ }
317
+ a[aria-current] {
318
+ background: var(--color-surface);
319
+ }
320
+ a:focus-visible,
321
+ button:focus-visible {
322
+ outline: 2px solid var(--color-accent);
323
+ outline-offset: 1px;
324
+ }
325
+ .parent-context {
326
+ padding: 0.25rem 0.25rem 0 1.25rem;
327
+ font-size: 0.625rem;
328
+ color: var(--color-muted);
329
+ overflow: hidden;
330
+ text-overflow: ellipsis;
331
+ white-space: nowrap;
332
+ }
333
+ @media (prefers-reduced-motion: reduce) {
334
+ svg,
335
+ a {
336
+ transition: none;
337
+ }
338
+ }
339
+ </style>