@twango/twiki 0.0.0-stage → 0.1.0-rc.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (225) hide show
  1. package/.github/actions/product-cache/action.yml +32 -0
  2. package/.github/actions/product-cache/index.mjs +450 -0
  3. package/LICENSE +21 -0
  4. package/README.md +146 -2
  5. package/bun.lock +1701 -0
  6. package/cli/runtime.test.ts +420 -0
  7. package/cli/runtime.ts +385 -0
  8. package/cli/twiki.ts +305 -0
  9. package/package.json +138 -4
  10. package/site/_headers +7 -0
  11. package/site/assets/fonts/OFL.txt +93 -0
  12. package/site/assets/fonts/OverusedGrotesk-SemiBold.ttf +0 -0
  13. package/site/assets/social-background.svg +38 -0
  14. package/site/bun.lock +1701 -0
  15. package/site/database/index.ts +7 -0
  16. package/site/database/schema.ts +95 -0
  17. package/site/drizzle.config.ts +7 -0
  18. package/site/migrations/0001_auth.sql +14 -0
  19. package/site/migrations/0002_site_access.sql +5 -0
  20. package/site/migrations/0003_github_username.sql +1 -0
  21. package/site/migrations/meta/0002_snapshot.json +432 -0
  22. package/site/migrations/meta/0003_snapshot.json +439 -0
  23. package/site/migrations/meta/_journal.json +27 -0
  24. package/site/package.json +120 -0
  25. package/site/src/app.d.ts +14 -0
  26. package/site/src/app.html +11 -0
  27. package/site/src/hooks.server.ts +12 -0
  28. package/site/src/lib/analytics-config.json +1 -0
  29. package/site/src/lib/analytics.ts +40 -0
  30. package/site/src/lib/auth-config.json +3 -0
  31. package/site/src/lib/components/SEO.svelte +113 -0
  32. package/site/src/lib/components/doc/AccessLink.svelte +27 -0
  33. package/site/src/lib/components/doc/Backlinks.svelte +59 -0
  34. package/site/src/lib/components/doc/Breadcrumbs.svelte +29 -0
  35. package/site/src/lib/components/doc/ContentMeta.svelte +61 -0
  36. package/site/src/lib/components/doc/DocPager.svelte +91 -0
  37. package/site/src/lib/components/doc/DocShell.svelte +115 -0
  38. package/site/src/lib/components/doc/LicenseNotice.svelte +26 -0
  39. package/site/src/lib/components/doc/LockedContent.svelte +29 -0
  40. package/site/src/lib/components/doc/NavTree.svelte +158 -0
  41. package/site/src/lib/components/doc/NoteActions.svelte +139 -0
  42. package/site/src/lib/components/doc/PublicationBadge.svelte +49 -0
  43. package/site/src/lib/components/doc/ReaderModeToggle.svelte +19 -0
  44. package/site/src/lib/components/doc/Sidebar.svelte +351 -0
  45. package/site/src/lib/components/doc/Toc.svelte +339 -0
  46. package/site/src/lib/components/doc/enhancements.ts +146 -0
  47. package/site/src/lib/components/doc/heading-tracker.svelte.ts +39 -0
  48. package/site/src/lib/components/doc/image-preview.ts +114 -0
  49. package/site/src/lib/components/doc/reader-mode.svelte.ts +40 -0
  50. package/site/src/lib/components/embeds/mermaid.ts +353 -0
  51. package/site/src/lib/components/files/FileCode.svelte +81 -0
  52. package/site/src/lib/components/files/FileExplorer.svelte +74 -0
  53. package/site/src/lib/components/files/FileHistory.svelte +170 -0
  54. package/site/src/lib/components/files/FileIcon.svelte +28 -0
  55. package/site/src/lib/components/files/FileRmd.svelte +209 -0
  56. package/site/src/lib/components/files/FileTabs.svelte +180 -0
  57. package/site/src/lib/components/files/FileTree.svelte +67 -0
  58. package/site/src/lib/components/files/blame-gutter.ts +45 -0
  59. package/site/src/lib/components/files/code-view.ts +98 -0
  60. package/site/src/lib/components/files/file-workspace.svelte.ts +100 -0
  61. package/site/src/lib/components/graph/GraphPanel.svelte +275 -0
  62. package/site/src/lib/components/graph/GraphView.svelte +100 -0
  63. package/site/src/lib/components/graph/graph-data.ts +137 -0
  64. package/site/src/lib/components/graph/graph-model.ts +103 -0
  65. package/site/src/lib/components/graph/index.ts +3 -0
  66. package/site/src/lib/components/graph/render-graph.ts +626 -0
  67. package/site/src/lib/components/popover/index.ts +1 -0
  68. package/site/src/lib/components/popover/link-popovers.ts +233 -0
  69. package/site/src/lib/components/popover/popover.css +82 -0
  70. package/site/src/lib/components/search/SearchButton.svelte +57 -0
  71. package/site/src/lib/components/search/SearchPalette.svelte +189 -0
  72. package/site/src/lib/components/search/index.ts +3 -0
  73. package/site/src/lib/components/search/pagefind-client.svelte.ts +129 -0
  74. package/site/src/lib/components/search/pagefind-query.ts +22 -0
  75. package/site/src/lib/components/search/pagefind-url.ts +12 -0
  76. package/site/src/lib/components/search/search-state.svelte.ts +22 -0
  77. package/site/src/lib/components/ui/IconButton.svelte +30 -0
  78. package/site/src/lib/components/ui/ThemeToggle.svelte +22 -0
  79. package/site/src/lib/components/ui/index.ts +1 -0
  80. package/site/src/lib/config.ts +9 -0
  81. package/site/src/lib/content-license.ts +7 -0
  82. package/site/src/lib/file-history.ts +37 -0
  83. package/site/src/lib/file-icons.ts +27 -0
  84. package/site/src/lib/file-tabs.ts +72 -0
  85. package/site/src/lib/files.ts +73 -0
  86. package/site/src/lib/github-user.ts +70 -0
  87. package/site/src/lib/metadata.ts +166 -0
  88. package/site/src/lib/notebook-nav.ts +63 -0
  89. package/site/src/lib/publication.ts +1 -0
  90. package/site/src/lib/rmd.ts +156 -0
  91. package/site/src/lib/server/access-admin.ts +33 -0
  92. package/site/src/lib/server/content-model.ts +253 -0
  93. package/site/src/lib/server/content-provider.ts +266 -0
  94. package/site/src/lib/server/content.ts +277 -0
  95. package/site/src/lib/server/runtime-content.ts +34 -0
  96. package/site/src/lib/site-config.json +9 -0
  97. package/site/src/lib/social-image.ts +13 -0
  98. package/site/src/lib/styles/article.css +607 -0
  99. package/site/src/lib/styles/foundation.css +138 -0
  100. package/site/src/lib/styles/print.css +115 -0
  101. package/site/src/lib/styles/shell.css +182 -0
  102. package/site/src/lib/text-exports.ts +5 -0
  103. package/site/src/lib/types.ts +140 -0
  104. package/site/src/lib/utils.ts +6 -0
  105. package/site/src/routes/+error.svelte +16 -0
  106. package/site/src/routes/+layout.server.ts +10 -0
  107. package/site/src/routes/+layout.svelte +72 -0
  108. package/site/src/routes/+layout.ts +3 -0
  109. package/site/src/routes/404/+page.svelte +13 -0
  110. package/site/src/routes/[...slug]/+page.server.ts +24 -0
  111. package/site/src/routes/[...slug]/+page.svelte +180 -0
  112. package/site/src/routes/[course]/files/[...file]/+page.svelte +373 -0
  113. package/site/src/routes/[course]/files/[...file]/+page.ts +38 -0
  114. package/site/src/routes/admin/access/+page.server.ts +53 -0
  115. package/site/src/routes/admin/access/+page.svelte +118 -0
  116. package/site/src/routes/graph.json/+server.ts +7 -0
  117. package/site/src/routes/index.xml/+server.ts +7 -0
  118. package/site/src/routes/layout.css +10 -0
  119. package/site/src/routes/robots.txt/+server.ts +9 -0
  120. package/site/src/routes/sitemap.xml/+server.ts +7 -0
  121. package/site/src/routes/tags/+page.server.ts +5 -0
  122. package/site/src/routes/tags/+page.svelte +38 -0
  123. package/site/src/routes/tags/[...tag]/+page.server.ts +22 -0
  124. package/site/src/routes/tags/[...tag]/+page.svelte +52 -0
  125. package/site/static/favicon.png +0 -0
  126. package/site/static/fonts/JetBrainsMono-Italic-VF.woff2 +0 -0
  127. package/site/static/fonts/JetBrainsMono-VF.woff2 +0 -0
  128. package/site/static/fonts/OverusedGrotesk-VF.woff2 +0 -0
  129. package/site/static/fonts/OverusedGroteskItalic-VF.woff2 +0 -0
  130. package/site/static/fonts/Virgil.woff2 +0 -0
  131. package/site/svelte.config.js +71 -0
  132. package/site/tooling/LICENSE-quartz +25 -0
  133. package/site/tooling/adapter.js +55 -0
  134. package/site/tooling/assets.ts +323 -0
  135. package/site/tooling/benchmark.ts +191 -0
  136. package/site/tooling/build.ts +407 -0
  137. package/site/tooling/cache.ts +133 -0
  138. package/site/tooling/check-diagrams.ts +164 -0
  139. package/site/tooling/check-links.ts +86 -0
  140. package/site/tooling/check-targets.ts +139 -0
  141. package/site/tooling/compiler.ts +1386 -0
  142. package/site/tooling/deploy.ts +112 -0
  143. package/site/tooling/fingerprint.ts +81 -0
  144. package/site/tooling/lib/app-version.js +92 -0
  145. package/site/tooling/lib/application-asset-urls.ts +27 -0
  146. package/site/tooling/lib/application-cache.ts +269 -0
  147. package/site/tooling/lib/asset-hash-cache.ts +116 -0
  148. package/site/tooling/lib/autotag.ts +99 -0
  149. package/site/tooling/lib/build-assembly.ts +148 -0
  150. package/site/tooling/lib/build-workspace.ts +386 -0
  151. package/site/tooling/lib/cache-selection.ts +70 -0
  152. package/site/tooling/lib/cloudflare-prewarm.js +40 -0
  153. package/site/tooling/lib/comments.ts +32 -0
  154. package/site/tooling/lib/content-cache.ts +244 -0
  155. package/site/tooling/lib/content-urls.ts +50 -0
  156. package/site/tooling/lib/course-cache.ts +298 -0
  157. package/site/tooling/lib/course-files.ts +281 -0
  158. package/site/tooling/lib/currency.ts +33 -0
  159. package/site/tooling/lib/deployment-config.ts +222 -0
  160. package/site/tooling/lib/deployment-manifest.ts +274 -0
  161. package/site/tooling/lib/deployment-prepare.ts +325 -0
  162. package/site/tooling/lib/deployment-upload.ts +293 -0
  163. package/site/tooling/lib/dev-git-state.ts +130 -0
  164. package/site/tooling/lib/dev-history.ts +86 -0
  165. package/site/tooling/lib/dev-social-images.ts +38 -0
  166. package/site/tooling/lib/dev-state.ts +107 -0
  167. package/site/tooling/lib/edition-builds.ts +184 -0
  168. package/site/tooling/lib/edition-paths.js +29 -0
  169. package/site/tooling/lib/empty-tag-route.js +23 -0
  170. package/site/tooling/lib/file-history.ts +201 -0
  171. package/site/tooling/lib/file-icons.ts +93 -0
  172. package/site/tooling/lib/file-policy.ts +38 -0
  173. package/site/tooling/lib/file-search.js +13 -0
  174. package/site/tooling/lib/git-revisions.ts +41 -0
  175. package/site/tooling/lib/history-pool.ts +74 -0
  176. package/site/tooling/lib/history-worker.ts +22 -0
  177. package/site/tooling/lib/knit-rmd.R +38 -0
  178. package/site/tooling/lib/lastmod.ts +201 -0
  179. package/site/tooling/lib/markdown-files.ts +51 -0
  180. package/site/tooling/lib/ofm.ts +634 -0
  181. package/site/tooling/lib/output.ts +60 -0
  182. package/site/tooling/lib/product-cache.ts +1006 -0
  183. package/site/tooling/lib/project-config.ts +288 -0
  184. package/site/tooling/lib/public-assets.ts +38 -0
  185. package/site/tooling/lib/public-links.ts +56 -0
  186. package/site/tooling/lib/publishing.ts +156 -0
  187. package/site/tooling/lib/r-runtime.ts +257 -0
  188. package/site/tooling/lib/rebuild-queue.ts +25 -0
  189. package/site/tooling/lib/rmd-preflight.ts +166 -0
  190. package/site/tooling/lib/rmd-preview-cache.ts +93 -0
  191. package/site/tooling/lib/rmd-previews.ts +129 -0
  192. package/site/tooling/lib/runtime-data-modules.ts +30 -0
  193. package/site/tooling/lib/runtime-packaging.ts +321 -0
  194. package/site/tooling/lib/search-cache.js +165 -0
  195. package/site/tooling/lib/search-inputs.js +129 -0
  196. package/site/tooling/lib/slug.ts +377 -0
  197. package/site/tooling/lib/social-images.ts +210 -0
  198. package/site/tooling/lib/text-exports.ts +51 -0
  199. package/site/tooling/lib/visit-elements.ts +17 -0
  200. package/site/tooling/live-test.ts +138 -0
  201. package/site/tooling/pipeline.ts +103 -0
  202. package/site/tooling/prepare.ts +3 -0
  203. package/site/tooling/publish.ts +28 -0
  204. package/site/tooling/r-cache.ts +45 -0
  205. package/site/tooling/r-render-profile.json +7 -0
  206. package/site/tooling/search.js +94 -0
  207. package/site/tooling/verify-deployment.ts +109 -0
  208. package/site/tooling/vite-content.ts +240 -0
  209. package/site/tooling/wrangler-paths.js +25 -0
  210. package/site/tooling/wrangler.ts +19 -0
  211. package/site/tsconfig.json +16 -0
  212. package/site/vite.config.ts +40 -0
  213. package/site/worker/access.ts +57 -0
  214. package/site/worker/analytics.ts +100 -0
  215. package/site/worker/auth-routes.ts +78 -0
  216. package/site/worker/auth.ts +78 -0
  217. package/site/worker/content-runtime.ts +226 -0
  218. package/site/worker/framing.ts +4 -0
  219. package/site/worker/gate.ts +147 -0
  220. package/site/worker/index.ts +9 -0
  221. package/site/worker/login.ts +62 -0
  222. package/site/worker/publication.ts +44 -0
  223. package/site/worker/runtime.ts +104 -0
  224. package/site/worker/tsconfig.json +13 -0
  225. package/site/wrangler.jsonc +37 -0
@@ -0,0 +1,180 @@
1
+ <script lang="ts">
2
+ import { goto } from '$app/navigation';
3
+ import { tick } from 'svelte';
4
+ import { flip } from 'svelte/animate';
5
+ import { prefersReducedMotion } from 'svelte/motion';
6
+ import X from '@lucide/svelte/icons/x';
7
+ import { fileRoute } from '$lib/files';
8
+ import { sameFile, tabLabel, type FileTab } from '$lib/file-tabs';
9
+ import { fileWorkspace } from './file-workspace.svelte';
10
+ import FileIcon from './FileIcon.svelte';
11
+ const { course, path, isFile }: { course: string; path: string; isFile: boolean } = $props();
12
+ const workspace = fileWorkspace();
13
+ const tabs = $derived(
14
+ workspace.ready
15
+ ? workspace.tabs
16
+ : isFile
17
+ ? [{ course, path, pinned: false, top: 0, left: 0 }]
18
+ : []
19
+ );
20
+ let strip = $state<HTMLElement>();
21
+ let dragging = $state<FileTab>();
22
+ let preview = $state({ left: 0, top: 0, width: 0, height: 0 });
23
+ let gesture: { tab: FileTab; x: number; left: number; centers: number[] } | undefined;
24
+ let suppressClick = false;
25
+
26
+ function startDrag(event: PointerEvent, tab: FileTab) {
27
+ if (
28
+ event.button !== 0 ||
29
+ event.ctrlKey ||
30
+ event.metaKey ||
31
+ event.altKey ||
32
+ event.shiftKey ||
33
+ !strip
34
+ )
35
+ return;
36
+ suppressClick = false;
37
+ const bounds = (event.currentTarget as HTMLElement).parentElement!.getBoundingClientRect();
38
+ preview = { left: bounds.left, top: bounds.top, width: bounds.width, height: bounds.height };
39
+ gesture = {
40
+ tab,
41
+ x: event.clientX,
42
+ left: bounds.left,
43
+ centers: Array.from(strip.children, (child) => {
44
+ const element = child as HTMLElement;
45
+ return element.offsetLeft + element.offsetWidth / 2;
46
+ })
47
+ };
48
+ }
49
+
50
+ function drag(event: PointerEvent) {
51
+ if (!gesture || !strip) return;
52
+ if (event.buttons === 0) return endDrag();
53
+ if (!dragging && Math.abs(event.clientX - gesture.x) < 6) return;
54
+ if (!dragging) {
55
+ dragging = gesture.tab;
56
+ strip.setPointerCapture(event.pointerId);
57
+ }
58
+ preview.left = gesture.left + event.clientX - gesture.x;
59
+ const bounds = strip.getBoundingClientRect();
60
+ if (event.clientX < bounds.left + 32) strip.scrollLeft -= 16;
61
+ else if (event.clientX > bounds.right - 32) strip.scrollLeft += 16;
62
+ const x = event.clientX - bounds.left + strip.scrollLeft;
63
+ const destination = gesture.centers.reduce(
64
+ (nearest, center, index, centers) =>
65
+ Math.abs(center - x) < Math.abs(centers[nearest] - x) ? index : nearest,
66
+ 0
67
+ );
68
+ workspace.move(gesture.tab, destination);
69
+ }
70
+
71
+ function endDrag() {
72
+ if (!gesture) return;
73
+ suppressClick = !!dragging;
74
+ dragging = undefined;
75
+ gesture = undefined;
76
+ }
77
+
78
+ function activate(event: MouseEvent, tab: FileTab) {
79
+ if (suppressClick) {
80
+ event.preventDefault();
81
+ suppressClick = false;
82
+ return;
83
+ }
84
+ workspace.activate(event, tab.course, tab.path);
85
+ }
86
+
87
+ async function reorderWithKeyboard(event: KeyboardEvent, tab: FileTab) {
88
+ if (!event.altKey || !['ArrowLeft', 'ArrowRight'].includes(event.key)) return;
89
+ event.preventDefault();
90
+ const link = event.currentTarget as HTMLElement;
91
+ workspace.move(
92
+ tab,
93
+ tabs.findIndex((item) => sameFile(item, tab)) + (event.key === 'ArrowLeft' ? -1 : 1)
94
+ );
95
+ await tick();
96
+ link.focus({ preventScroll: true });
97
+ }
98
+ $effect(() => {
99
+ course;
100
+ path;
101
+ tabs.length;
102
+ void tick().then(() => {
103
+ const active = strip?.querySelector<HTMLElement>('[aria-current="page"]');
104
+ if (!active || !strip) return;
105
+ const bounds = strip.getBoundingClientRect();
106
+ const item = active.getBoundingClientRect();
107
+ if (item.left < bounds.left) strip.scrollLeft += item.left - bounds.left;
108
+ else if (item.right > bounds.right) strip.scrollLeft += item.right - bounds.right;
109
+ });
110
+ });
111
+ async function close(tab: FileTab) {
112
+ const next = workspace.close(tab);
113
+ if (sameFile(tab, { course, path })) await goto(fileRoute(next?.course ?? course, next?.path));
114
+ }
115
+ </script>
116
+
117
+ <nav
118
+ class="relative flex min-w-0 flex-1 overflow-x-auto select-none [scrollbar-width:thin]"
119
+ aria-label="Open files"
120
+ bind:this={strip}
121
+ onpointermove={drag}
122
+ onpointerup={endDrag}
123
+ onpointercancel={endDrag}
124
+ onlostpointercapture={(event) => {
125
+ if (event.target === strip) endDrag();
126
+ }}
127
+ onpointerleave={() => {
128
+ if (!dragging) endDrag();
129
+ }}
130
+ >
131
+ {#each tabs as tab (`${tab.course}/${tab.path}`)}
132
+ <div
133
+ animate:flip={{
134
+ duration: prefersReducedMotion.current || (dragging && sameFile(dragging, tab)) ? 0 : 160
135
+ }}
136
+ class="group flex shrink-0 items-center border-r border-border"
137
+ class:bg-bg={isFile && sameFile(tab, { course, path })}
138
+ class:opacity-0={dragging && sameFile(dragging, tab)}
139
+ >
140
+ <a
141
+ class="flex min-w-0 touch-pan-y items-center gap-2 py-2 pl-3 pr-1 font-mono text-xs text-muted no-underline aria-[current=page]:text-text"
142
+ class:italic={!tab.pinned}
143
+ href={fileRoute(tab.course, tab.path)}
144
+ aria-current={isFile && sameFile(tab, { course, path }) ? 'page' : undefined}
145
+ title={`${tab.course}/${tab.path}${!tab.pinned ? ' — double-click to keep open' : ''}`}
146
+ draggable="false"
147
+ ondragstart={(event) => event.preventDefault()}
148
+ aria-keyshortcuts="Alt+ArrowLeft Alt+ArrowRight"
149
+ onpointerdown={(event) => startDrag(event, tab)}
150
+ onclick={(event) => activate(event, tab)}
151
+ onkeydown={(event) => reorderWithKeyboard(event, tab)}
152
+ >
153
+ <FileIcon name={tab.path.split('/').at(-1)!} /><span class="max-w-64 truncate"
154
+ >{tabLabel(tab, tabs)}</span
155
+ >
156
+ </a>
157
+ <button
158
+ class="m-0.5 flex size-6 shrink-0 cursor-pointer items-center justify-center rounded text-muted hover:bg-surface hover:text-text"
159
+ aria-label={`Close ${tabLabel(tab, tabs)}`}
160
+ disabled={!workspace.ready}
161
+ onclick={() => close(tab)}><X size={12} /></button
162
+ >
163
+ </div>
164
+ {/each}
165
+ </nav>
166
+
167
+ {#if dragging}
168
+ <div
169
+ aria-hidden="true"
170
+ data-drag-preview
171
+ class="pointer-events-none fixed z-50 flex items-center gap-2 border border-border bg-bg px-3 font-mono text-xs text-text shadow-md"
172
+ style:left={`${preview.left}px`}
173
+ style:top={`${preview.top}px`}
174
+ style:width={`${preview.width}px`}
175
+ style:height={`${preview.height}px`}
176
+ >
177
+ <FileIcon name={dragging.path.split('/').at(-1)!} />
178
+ <span class="truncate">{tabLabel(dragging, tabs)}</span>
179
+ </div>
180
+ {/if}
@@ -0,0 +1,67 @@
1
+ <script lang="ts">
2
+ import ChevronRight from '@lucide/svelte/icons/chevron-right';
3
+ import LockKeyhole from '@lucide/svelte/icons/lock-keyhole';
4
+ import FileIcon from './FileIcon.svelte';
5
+ import { fileWorkspace } from './file-workspace.svelte';
6
+ import { fileRoute, type FileNode } from '$lib/files';
7
+ import Self from './FileTree.svelte';
8
+ let {
9
+ nodes,
10
+ course,
11
+ current,
12
+ focus,
13
+ expanded = $bindable({})
14
+ }: {
15
+ nodes: FileNode[];
16
+ course: string;
17
+ current: string;
18
+ focus: string;
19
+ expanded?: Record<string, boolean>;
20
+ } = $props();
21
+ const uid = $props.id();
22
+ const workspace = fileWorkspace();
23
+ function open(node: FileNode) {
24
+ return expanded[node.path] ?? (focus === node.path || focus.startsWith(node.path + '/'));
25
+ }
26
+ </script>
27
+
28
+ <ul class="m-0 list-none p-0 select-none">
29
+ {#each nodes as node (node.path)}
30
+ <li>
31
+ <div class="flex min-w-0 items-center rounded hover:bg-surface">
32
+ {#if node.children}
33
+ <button
34
+ class="flex size-6 shrink-0 cursor-pointer items-center justify-center text-muted"
35
+ aria-label={`Expand ${node.name}`}
36
+ aria-expanded={open(node)}
37
+ aria-controls={`${uid}-${node.path}`}
38
+ onclick={() => (expanded = { ...expanded, [node.path]: !open(node) })}
39
+ >
40
+ <ChevronRight size={12} class={open(node) ? 'rotate-90' : ''} />
41
+ </button>
42
+ {:else}<span class="w-6 shrink-0"></span>{/if}
43
+ <a
44
+ class="flex min-w-0 flex-1 items-center gap-1.5 py-1 pr-1 text-xs text-muted no-underline hover:text-text aria-[current=page]:text-accent"
45
+ href={fileRoute(course, node.path)}
46
+ title={node.name}
47
+ aria-current={current === node.path ? 'page' : undefined}
48
+ onclick={(event) => {
49
+ if (node.file) workspace.activate(event, course, node.path);
50
+ }}
51
+ >
52
+ <FileIcon name={node.name} folder={!!node.children} expanded={open(node)} />
53
+ <span class="truncate">{node.name}</span>
54
+ {#if node.file?.locked}<LockKeyhole
55
+ class="ml-auto size-3 shrink-0"
56
+ aria-label="Locked"
57
+ />{/if}
58
+ </a>
59
+ </div>
60
+ {#if node.children && open(node)}
61
+ <div id={`${uid}-${node.path}`} class="ml-3 border-l border-border pl-1">
62
+ <Self nodes={node.children} {course} {current} {focus} bind:expanded />
63
+ </div>
64
+ {/if}
65
+ </li>
66
+ {/each}
67
+ </ul>
@@ -0,0 +1,45 @@
1
+ import { gutter, GutterMarker } from '@codemirror/view';
2
+ import { blameAt, type FileBlame, type FileCommit } from '$lib/file-history';
3
+
4
+ class BlameMarker extends GutterMarker {
5
+ constructor(
6
+ readonly commit: FileCommit,
7
+ readonly first: boolean,
8
+ readonly select: (id: string) => void
9
+ ) {
10
+ super();
11
+ }
12
+ eq(other: BlameMarker) {
13
+ return this.commit.id === other.commit.id && this.first === other.first;
14
+ }
15
+ toDOM() {
16
+ const button = document.createElement('button');
17
+ button.className = 'file-blame-line';
18
+ button.tabIndex = this.first ? 0 : -1;
19
+ button.textContent = this.first ? `${this.commit.id.slice(0, 7)} ${this.commit.author}` : '│';
20
+ button.title = `${this.commit.author} · ${this.commit.date.slice(0, 10)}\n${this.commit.subject}`;
21
+ button.setAttribute(
22
+ 'aria-label',
23
+ `Commit ${this.commit.id.slice(0, 7)}: ${this.commit.subject}`
24
+ );
25
+ button.onclick = () => this.select(this.commit.id);
26
+ return button;
27
+ }
28
+ }
29
+
30
+ export function blameGutter(blame: FileBlame, select: (id: string) => void) {
31
+ return gutter({
32
+ class: 'file-blame',
33
+ lineMarker(view, line) {
34
+ const number = view.state.doc.lineAt(line.from).number;
35
+ const found = blameAt(blame, number);
36
+ return found?.commit
37
+ ? new BlameMarker(
38
+ found.commit,
39
+ found.start === number || line.from === view.viewport.from,
40
+ select
41
+ )
42
+ : null;
43
+ }
44
+ });
45
+ }
@@ -0,0 +1,98 @@
1
+ import { Compartment, EditorState } from '@codemirror/state';
2
+ import { EditorView, drawSelection, keymap, lineNumbers } from '@codemirror/view';
3
+ import { defaultKeymap } from '@codemirror/commands';
4
+ import { search, searchKeymap } from '@codemirror/search';
5
+ import {
6
+ defaultHighlightStyle,
7
+ LanguageDescription,
8
+ syntaxHighlighting
9
+ } from '@codemirror/language';
10
+ import { languages } from '@codemirror/language-data';
11
+ import type { FileBlame } from '$lib/file-history';
12
+ import { blameGutter } from './blame-gutter';
13
+
14
+ const theme = EditorView.theme({
15
+ '.file-blame-line': {
16
+ display: 'block',
17
+ width: '13rem',
18
+ height: '19.8px',
19
+ padding: '0 8px',
20
+ overflow: 'hidden',
21
+ textOverflow: 'ellipsis',
22
+ whiteSpace: 'nowrap',
23
+ textAlign: 'left',
24
+ cursor: 'pointer',
25
+ font: 'inherit'
26
+ },
27
+ '&': {
28
+ height: '100%',
29
+ fontSize: '12px',
30
+ color: 'var(--color-text)',
31
+ backgroundColor: 'var(--color-bg)'
32
+ },
33
+ '&.cm-focused': { outline: 'none' },
34
+ '.cm-scroller': {
35
+ fontFamily: 'var(--font-mono, monospace)',
36
+ lineHeight: '1.65',
37
+ overflow: 'auto'
38
+ },
39
+ '.cm-content': { padding: '8px 0' },
40
+ '.cm-line': { padding: '0 8px' },
41
+ '.cm-gutters': {
42
+ backgroundColor: 'var(--color-bg)',
43
+ color: 'var(--color-muted)',
44
+ borderRight: '1px solid var(--color-border)'
45
+ },
46
+ '.cm-lineNumbers .cm-gutterElement': { minWidth: '3rem', padding: '0 8px' },
47
+ '.cm-selectionBackground, &.cm-focused .cm-selectionBackground': {
48
+ backgroundColor: 'color-mix(in srgb, var(--color-accent) 25%, transparent)'
49
+ },
50
+ '.cm-panels': { backgroundColor: 'var(--color-surface)', color: 'var(--color-text)' },
51
+ '.cm-textfield, .cm-button': {
52
+ background: 'var(--color-bg)',
53
+ color: 'var(--color-text)',
54
+ border: '1px solid var(--color-border)'
55
+ },
56
+ '.cm-searchMatch': { backgroundColor: 'color-mix(in srgb, var(--color-accent) 25%, transparent)' }
57
+ });
58
+
59
+ export function createCodeView(parent: HTMLElement, text: string, filename: string) {
60
+ const language = new Compartment();
61
+ const blame = new Compartment();
62
+ const view = new EditorView({
63
+ parent,
64
+ state: EditorState.create({
65
+ doc: text,
66
+ extensions: [
67
+ EditorState.readOnly.of(true),
68
+ EditorView.editable.of(false),
69
+ EditorView.contentAttributes.of({ tabindex: '0', 'aria-label': 'Source code' }),
70
+ blame.of([]),
71
+ lineNumbers(),
72
+ drawSelection(),
73
+ search({ top: true }),
74
+ keymap.of([...searchKeymap, ...defaultKeymap]),
75
+ syntaxHighlighting(defaultHighlightStyle),
76
+ language.of([]),
77
+ theme
78
+ ]
79
+ })
80
+ });
81
+ view.scrollDOM.classList.add('file-content');
82
+ let disposed = false;
83
+ void LanguageDescription.matchFilename(languages, filename)
84
+ ?.load()
85
+ .then((extension) => {
86
+ if (!disposed) view.dispatch({ effects: language.reconfigure(extension) });
87
+ })
88
+ .catch(() => {});
89
+ return {
90
+ view,
91
+ setBlame: (data: FileBlame | undefined, select: (id: string) => void) =>
92
+ view.dispatch({ effects: blame.reconfigure(data ? blameGutter(data, select) : []) }),
93
+ destroy: () => {
94
+ disposed = true;
95
+ view.destroy();
96
+ }
97
+ };
98
+ }
@@ -0,0 +1,100 @@
1
+ import { getContext, setContext } from 'svelte';
2
+ import { goto } from '$app/navigation';
3
+ import { fileRoute } from '$lib/files';
4
+ import { moveTab, openFile, restoreTabs, sameFile, type FileTab } from '$lib/file-tabs';
5
+ import { page } from '$app/state';
6
+
7
+ const context = Symbol('file-workspace');
8
+ function storageKey() {
9
+ return page.data.edition === 'public' ? 'wisconsin-public-file-tabs' : 'wisconsin-file-tabs';
10
+ }
11
+
12
+ class FileWorkspace {
13
+ tabs = $state<FileTab[]>([]);
14
+ ready = $state(false);
15
+ private lastClick?: { course: string; path: string; time: number };
16
+ private touchNavigation?: ReturnType<typeof setTimeout>;
17
+
18
+ activate(event: MouseEvent, course: string, path: string) {
19
+ if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey)
20
+ return;
21
+ const repeated =
22
+ event.detail > 0 &&
23
+ this.lastClick &&
24
+ sameFile(this.lastClick, { course, path }) &&
25
+ event.timeStamp - this.lastClick.time < 500;
26
+ const pinned = event.detail > 1 || !!repeated;
27
+ this.lastClick =
28
+ pinned || event.detail === 0 ? undefined : { course, path, time: event.timeStamp };
29
+ this.open(course, path, pinned);
30
+ if ('pointerType' in event && event.pointerType === 'touch') {
31
+ event.preventDefault();
32
+ clearTimeout(this.touchNavigation);
33
+ if (pinned) void goto(fileRoute(course, path));
34
+ else
35
+ this.touchNavigation = setTimeout(() => {
36
+ void goto(fileRoute(course, path));
37
+ }, 300);
38
+ }
39
+ }
40
+
41
+ restore() {
42
+ try {
43
+ this.tabs = restoreTabs(sessionStorage.getItem(storageKey()));
44
+ } catch {
45
+ this.tabs = [];
46
+ }
47
+ this.ready = true;
48
+ }
49
+
50
+ private save() {
51
+ if (!this.ready) return;
52
+ try {
53
+ sessionStorage.setItem(storageKey(), JSON.stringify(this.tabs));
54
+ } catch {}
55
+ }
56
+
57
+ open(course: string, path: string, pinned = false) {
58
+ this.tabs = openFile(this.tabs, course, path, pinned);
59
+ this.save();
60
+ }
61
+
62
+ remember(course: string, path: string, viewport: HTMLElement) {
63
+ this.tabs = this.tabs.map((tab) =>
64
+ sameFile(tab, { course, path })
65
+ ? { ...tab, top: viewport.scrollTop, left: viewport.scrollLeft }
66
+ : tab
67
+ );
68
+ this.save();
69
+ }
70
+
71
+ close(tab: FileTab) {
72
+ const index = this.tabs.findIndex((item) => sameFile(item, tab));
73
+ this.tabs = this.tabs.filter((item) => !sameFile(item, tab));
74
+ this.save();
75
+ return this.tabs[Math.min(index, this.tabs.length - 1)];
76
+ }
77
+
78
+ move(tab: FileTab, destination: number) {
79
+ this.lastClick = undefined;
80
+ const reordered = moveTab(this.tabs, tab, destination);
81
+ if (reordered === this.tabs) return;
82
+ this.tabs = reordered;
83
+ this.save();
84
+ }
85
+
86
+ clear() {
87
+ clearTimeout(this.touchNavigation);
88
+ this.lastClick = undefined;
89
+ this.tabs = [];
90
+ this.save();
91
+ }
92
+ }
93
+
94
+ export function provideFileWorkspace() {
95
+ return setContext(context, new FileWorkspace());
96
+ }
97
+
98
+ export function fileWorkspace() {
99
+ return getContext<FileWorkspace>(context);
100
+ }