@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,81 @@
1
+ <script lang="ts">
2
+ import type { FileBlame } from '$lib/file-history';
3
+ import type { createCodeView } from './code-view';
4
+ let {
5
+ text,
6
+ url,
7
+ filename,
8
+ onready,
9
+ onload,
10
+ blame,
11
+ oncommit = () => {}
12
+ }: {
13
+ text?: string;
14
+ url?: string;
15
+ filename: string;
16
+ onready?: (viewport: HTMLElement) => void;
17
+ onload?: (text: string) => void;
18
+ blame?: FileBlame;
19
+ oncommit?: (id: string) => void;
20
+ } = $props();
21
+ let editor = $state.raw<ReturnType<typeof createCodeView>>();
22
+ let host: HTMLDivElement;
23
+ let failed = $state(false);
24
+ let loading = $state(true);
25
+ let attempt = $state(0);
26
+ $effect(() => {
27
+ editor?.setBlame(blame, oncommit);
28
+ });
29
+ $effect(() => {
30
+ const source = text,
31
+ asset = url,
32
+ name = filename;
33
+ attempt;
34
+ const controller = new AbortController();
35
+ let created: ReturnType<typeof createCodeView> | undefined;
36
+ failed = false;
37
+ loading = true;
38
+ const read = async () => {
39
+ if (source !== undefined) return source;
40
+ if (!asset) throw new Error('File unavailable');
41
+ const response = await fetch(asset, { signal: controller.signal });
42
+ if (!response.ok) throw new Error('File unavailable');
43
+ return response.text();
44
+ };
45
+ void Promise.all([import('./code-view'), read()])
46
+ .then(([{ createCodeView }, content]) => {
47
+ if (controller.signal.aborted) return;
48
+ onload?.(content);
49
+ created = createCodeView(host, content, name);
50
+ editor = created;
51
+ loading = false;
52
+ created.view.requestMeasure({
53
+ read: () => null,
54
+ write: () => {
55
+ if (!controller.signal.aborted && created) onready?.(created.view.scrollDOM);
56
+ }
57
+ });
58
+ })
59
+ .catch(() => {
60
+ if (!controller.signal.aborted) {
61
+ failed = true;
62
+ loading = false;
63
+ }
64
+ });
65
+ return () => {
66
+ controller.abort();
67
+ created?.destroy();
68
+ editor = undefined;
69
+ };
70
+ });
71
+ </script>
72
+
73
+ <div class="relative h-full min-w-0">
74
+ {#if failed}<p class="absolute inset-x-0 top-0 p-3 text-xs text-muted" role="alert">
75
+ Could not load the file. <button class="text-accent" onclick={() => attempt++}>Retry</button>
76
+ </p>
77
+ {:else if loading}<p class="absolute inset-x-0 top-0 p-3 text-xs text-muted" role="status">
78
+ Loading file…
79
+ </p>{/if}
80
+ <div class="file-code h-full min-w-0" bind:this={host}></div>
81
+ </div>
@@ -0,0 +1,74 @@
1
+ <script lang="ts">
2
+ import { page } from '$app/state';
3
+ import { tick } from 'svelte';
4
+ import { fileIndexUrl, fileRoute, fileTree, type CourseFile } from '$lib/files';
5
+ import FileTree from './FileTree.svelte';
6
+
7
+ const { course }: { course: string } = $props();
8
+ let viewport = $state<HTMLDivElement>();
9
+ let files = $state<CourseFile[]>();
10
+ let failed = $state(false);
11
+ let attempt = $state(0);
12
+ const browsing = $derived(page.data.kind === 'file-browser');
13
+ const current = $derived(browsing ? (page.params.file ?? '') : '');
14
+ const focus = $derived(
15
+ browsing
16
+ ? current
17
+ : (files?.find((file) => file.note === decodeURI(page.url.pathname))?.path ?? '')
18
+ );
19
+ const tree = $derived(fileTree(files ?? []));
20
+
21
+ $effect(() => {
22
+ current;
23
+ files;
24
+ if (!viewport) return;
25
+ let cancelled = false;
26
+ void tick().then(() => {
27
+ if (cancelled || !viewport) return;
28
+ const active = viewport.querySelector<HTMLElement>('[aria-current="page"]');
29
+ if (!active) return;
30
+ const bounds = viewport.getBoundingClientRect();
31
+ const row = active.getBoundingClientRect();
32
+ if (row.top < bounds.top) viewport.scrollTop += row.top - bounds.top;
33
+ else if (row.bottom > bounds.bottom) viewport.scrollTop += row.bottom - bounds.bottom;
34
+ });
35
+ return () => {
36
+ cancelled = true;
37
+ };
38
+ });
39
+ $effect(() => {
40
+ attempt;
41
+ const controller = new AbortController();
42
+ failed = false;
43
+ fetch(fileIndexUrl(course), { signal: controller.signal })
44
+ .then(async (response) => {
45
+ if (!response.ok) throw new Error('Files unavailable');
46
+ const result: CourseFile[] = await response.json();
47
+ if (!controller.signal.aborted) files = result;
48
+ })
49
+ .catch(() => {
50
+ if (!controller.signal.aborted) failed = true;
51
+ });
52
+ return () => controller.abort();
53
+ });
54
+ </script>
55
+
56
+ <section class="flex h-full min-h-0 flex-col" aria-label="Course files" data-pagefind-ignore>
57
+ <div
58
+ bind:this={viewport}
59
+ class="min-h-0 flex-1 overflow-y-auto overscroll-contain [scrollbar-width:thin]"
60
+ >
61
+ {#if failed}
62
+ <p class="px-2 py-2 text-xs text-muted">
63
+ Couldn’t load files. <button class="cursor-pointer text-accent" onclick={() => attempt++}
64
+ >Retry</button
65
+ >
66
+ </p>
67
+ {:else if files}
68
+ <a class="mb-1 block px-2 py-1 text-xs text-muted hover:text-accent" href={fileRoute(course)}
69
+ >Browse directory <span class="float-right">{files.length} files</span></a
70
+ >
71
+ <FileTree nodes={tree} {course} {current} {focus} />
72
+ {:else}<p class="px-2 py-2 text-xs text-muted" role="status">Loading files…</p>{/if}
73
+ </div>
74
+ </section>
@@ -0,0 +1,170 @@
1
+ <script lang="ts">
2
+ import { onDestroy } from 'svelte';
3
+ import { fly } from 'svelte/transition';
4
+ import { prefersReducedMotion } from 'svelte/motion';
5
+ import X from '@lucide/svelte/icons/x';
6
+ import type { FileHistory, FileBlame, FileChange } from '$lib/file-history';
7
+ let {
8
+ url,
9
+ open = $bindable(false),
10
+ blaming = $bindable(false),
11
+ selected = $bindable(''),
12
+ onblame,
13
+ onview
14
+ }: {
15
+ url: string;
16
+ open?: boolean;
17
+ blaming?: boolean;
18
+ selected?: string;
19
+ onblame: (blame: FileBlame | undefined) => void;
20
+ onview: (text: string, commit: FileChange) => void;
21
+ } = $props();
22
+ let history = $state<FileHistory>();
23
+ let blame = $state<FileBlame>();
24
+ let error = $state('');
25
+ let loadingDiff = $state('');
26
+ let pending: Promise<void> | undefined;
27
+ const controller = new AbortController();
28
+ let selection = 0;
29
+ onDestroy(() => controller.abort());
30
+ async function load() {
31
+ if (pending) return pending;
32
+ pending = (async () => {
33
+ try {
34
+ const response = await fetch(url, { signal: controller.signal });
35
+ if (!response.ok) throw new Error('History unavailable');
36
+ history = await response.json();
37
+ } catch {
38
+ if (!controller.signal.aborted) error = 'Could not load history.';
39
+ }
40
+ })();
41
+ return pending;
42
+ }
43
+ $effect(() => {
44
+ if (open || blaming) void load();
45
+ });
46
+ $effect(() => {
47
+ if (!blaming || !history?.blame) {
48
+ onblame(undefined);
49
+ return;
50
+ }
51
+ if (blame) {
52
+ onblame(blame);
53
+ return;
54
+ }
55
+ const request = new AbortController();
56
+ void fetch(history.blame, { signal: request.signal })
57
+ .then(async (response) => {
58
+ if (!response.ok) throw new Error('Blame unavailable');
59
+ const result: FileBlame = await response.json();
60
+ if (!request.signal.aborted) blame = result;
61
+ })
62
+ .catch(() => {
63
+ if (!request.signal.aborted) {
64
+ error = 'Could not load blame.';
65
+ open = true;
66
+ }
67
+ });
68
+ return () => request.abort();
69
+ });
70
+ $effect(() => {
71
+ if (blaming && history && !history.blame) {
72
+ blaming = false;
73
+ open = true;
74
+ }
75
+ });
76
+ async function view(commit: FileChange) {
77
+ selected = commit.id;
78
+ const request = ++selection;
79
+ loadingDiff = '';
80
+ if (!commit.diff) return;
81
+ loadingDiff = commit.id;
82
+ error = '';
83
+ try {
84
+ const response = await fetch(commit.diff, { signal: controller.signal });
85
+ if (!response.ok) throw new Error('Diff unavailable');
86
+ const text = await response.text();
87
+ if (request === selection && selected === commit.id && !controller.signal.aborted)
88
+ onview(text, commit);
89
+ } catch {
90
+ if (!controller.signal.aborted && request === selection)
91
+ error = 'Could not load this change.';
92
+ } finally {
93
+ if (request === selection) loadingDiff = '';
94
+ }
95
+ }
96
+ </script>
97
+
98
+ {#if open}
99
+ <aside
100
+ aria-label="File history"
101
+ class="absolute inset-y-0 right-0 z-20 flex w-80 max-w-full shrink-0 flex-col border-l border-border bg-bg md:static"
102
+ transition:fly={{ x: 8, duration: prefersReducedMotion.current ? 0 : 140 }}
103
+ >
104
+ <header class="flex items-center justify-between border-b border-border px-3 py-2 text-xs">
105
+ <span>History</span><button
106
+ class="text-muted hover:text-text"
107
+ aria-label="Close history"
108
+ onclick={() => (open = false)}><X size={14} /></button
109
+ >
110
+ </header>
111
+ {#if error}<div class="p-3 text-xs text-muted" role="alert">
112
+ {error}
113
+ <button
114
+ class="text-accent"
115
+ onclick={() => {
116
+ pending = undefined;
117
+ error = '';
118
+ void load();
119
+ }}>Retry</button
120
+ >
121
+ </div>{/if}
122
+ <div class="min-h-0 flex-1 overflow-y-auto">
123
+ {#if history}
124
+ {#if selected && blame?.commits[selected] && !history.commits.some((commit) => commit.id === selected)}
125
+ <div class="border-b border-border p-3 text-xs">
126
+ <p>{blame.commits[selected].subject}</p>
127
+ <p class="mt-1 text-muted">
128
+ {selected.slice(0, 7)} · This commit is from a merged branch. The list below follows first-parent
129
+ history.
130
+ </p>
131
+ </div>
132
+ {/if}
133
+ {#if history.notice}<p class="border-b border-border p-3 text-xs text-muted">
134
+ {history.notice}
135
+ </p>{/if}
136
+ <ol class="m-0 list-none divide-y divide-border p-0">
137
+ {#each history.commits as commit (commit.id)}
138
+ <li>
139
+ <button
140
+ class="w-full px-3 py-2.5 text-left hover:bg-surface"
141
+ class:bg-surface={selected === commit.id}
142
+ aria-pressed={selected === commit.id}
143
+ onclick={() => view(commit)}
144
+ >
145
+ <span class="block break-words text-xs text-text">{commit.subject}</span>
146
+ <span class="mt-1 block text-[0.6875rem] text-muted"
147
+ >{commit.author} · {commit.date.slice(0, 10)} ·
148
+ <span class="font-mono">{commit.id.slice(0, 7)}</span></span
149
+ >
150
+ {#if commit.previousPath}<span
151
+ class="mt-1 block break-all text-[0.6875rem] text-muted"
152
+ >Renamed from {commit.previousPath}</span
153
+ >{/if}
154
+ {#if selected === commit.id && commit.unavailable}<span
155
+ class="mt-1 block text-xs text-muted">{commit.unavailable}</span
156
+ >{/if}
157
+ {#if loadingDiff === commit.id}<span class="block text-xs text-muted" role="status"
158
+ >Loading changes…</span
159
+ >{/if}
160
+ </button>
161
+ </li>
162
+ {:else}<li class="p-3 text-xs text-muted">No committed history.</li>{/each}
163
+ </ol>
164
+ {:else if !error}<p class="p-3 text-xs text-muted" role="status">Loading history…</p>{/if}
165
+ </div>
166
+ {#if history}<footer class="border-t border-border px-3 py-2 text-[0.6875rem] text-muted">
167
+ First-parent history · {history.revision.slice(0, 7)}
168
+ </footer>{/if}
169
+ </aside>
170
+ {/if}
@@ -0,0 +1,28 @@
1
+ <script lang="ts">
2
+ import { getContext } from 'svelte';
3
+ const theme = getContext<() => FileIconTheme>('wisconsin-file-icons');
4
+ import { selectFileIcon, type FileIconTheme } from '$lib/file-icons';
5
+ const {
6
+ name,
7
+ folder = false,
8
+ expanded = false
9
+ }: { name: string; folder?: boolean; expanded?: boolean } = $props();
10
+ const icon = $derived(selectFileIcon(theme(), name, folder, expanded));
11
+ </script>
12
+
13
+ <span class="inline-flex size-4 shrink-0" aria-hidden="true" data-file-icon>
14
+ <img
15
+ src={`/_files/icons/${icon.light}.svg`}
16
+ class={icon.light !== icon.dark ? 'size-4 dark:hidden' : 'size-4'}
17
+ width="16"
18
+ height="16"
19
+ alt=""
20
+ />
21
+ {#if icon.light !== icon.dark}<img
22
+ src={`/_files/icons/${icon.dark}.svg`}
23
+ class="hidden size-4 dark:block"
24
+ width="16"
25
+ height="16"
26
+ alt=""
27
+ />{/if}
28
+ </span>
@@ -0,0 +1,209 @@
1
+ <script lang="ts">
2
+ import { onMount } from 'svelte';
3
+ import type { WebR } from 'webr';
4
+ import type { CourseFile } from '$lib/files';
5
+ import { parseRmd, type RmdBlock } from '$lib/rmd';
6
+ let {
7
+ url,
8
+ course,
9
+ path,
10
+ files,
11
+ onload
12
+ }: {
13
+ url: string;
14
+ course: string;
15
+ path: string;
16
+ files: CourseFile[];
17
+ onload: (text: string) => void;
18
+ } = $props();
19
+ type Block = RmdBlock & { output?: string; plots?: string[]; done?: boolean };
20
+ let title = $state('');
21
+ let blocks = $state<Block[]>([]);
22
+ let status = $state('Loading worksheet…');
23
+ let busy = $state(false);
24
+ let loaded = $state(false);
25
+ let runtime: WebR | undefined;
26
+ let runtimeReady = false;
27
+ let generation = 0;
28
+ const abort = new AbortController();
29
+ const button =
30
+ 'rounded border border-border px-3 py-1.5 text-xs hover:bg-surface disabled:opacity-50';
31
+
32
+ onMount(() => {
33
+ void (async () => {
34
+ try {
35
+ const response = await fetch(url, { signal: abort.signal });
36
+ if (!response.ok) throw new Error(`Worksheet unavailable (${response.status})`);
37
+ const source = await response.text();
38
+ const parsed = await parseRmd(source, course, path, files);
39
+ if (abort.signal.aborted) return;
40
+ onload(source);
41
+ title = parsed.title;
42
+ blocks = parsed.blocks;
43
+ loaded = true;
44
+ status =
45
+ 'Run chunks in order, or run the whole worksheet. Edits last until you leave this view.';
46
+ } catch (error) {
47
+ if (!abort.signal.aborted) status = String(error);
48
+ }
49
+ })();
50
+ return () => {
51
+ abort.abort();
52
+ generation++;
53
+ runtime?.close();
54
+ };
55
+ });
56
+
57
+ function reset() {
58
+ generation++;
59
+ runtime?.close();
60
+ runtime = undefined;
61
+ runtimeReady = false;
62
+ busy = false;
63
+ for (const block of blocks) {
64
+ block.output = '';
65
+ block.plots = [];
66
+ block.done = false;
67
+ }
68
+ status = 'R session reset. Run the worksheet to start again.';
69
+ }
70
+
71
+ async function start(ticket: number) {
72
+ if (runtime) return runtime;
73
+ status = 'Loading R… The first run downloads the runtime.';
74
+ const { WebR, ChannelType } = await import('webr');
75
+ if (ticket !== generation) throw new Error('Session reset');
76
+ const r = new WebR({
77
+ baseUrl: 'https://webr.r-wasm.org/v0.5.8/',
78
+ channelType: ChannelType.PostMessage
79
+ });
80
+ runtime = r;
81
+ await r.init();
82
+ await r.evalRVoid('options(max.print=1000)');
83
+ if (blocks.some((b) => b.kind === 'r' && /\bknitr::/.test(b.code))) {
84
+ status = 'Loading knitr…';
85
+ await r.installPackages(['knitr']);
86
+ }
87
+ const directory = path.split('/').slice(0, -1).join('/');
88
+ for (const file of files.filter(
89
+ (f) =>
90
+ !f.locked &&
91
+ f.download &&
92
+ /\.(csv|tsv|txt|rds|rda|rdata)$/i.test(f.path) &&
93
+ f.path.split('/').slice(0, -1).join('/') === directory
94
+ )) {
95
+ const response = await fetch(file.download!, { signal: abort.signal });
96
+ if (!response.ok) throw new Error(`Could not load ${file.path} (${response.status})`);
97
+ await r.FS.writeFile(
98
+ `/home/web_user/${file.path.split('/').at(-1)}`,
99
+ new Uint8Array(await response.arrayBuffer())
100
+ );
101
+ }
102
+ runtimeReady = true;
103
+ return r;
104
+ }
105
+
106
+ async function run(selected?: Block) {
107
+ if (busy) return;
108
+ busy = true;
109
+ const ticket = generation;
110
+ try {
111
+ const r = await start(ticket);
112
+ if (ticket !== generation) return;
113
+ const shelter = await new r.Shelter();
114
+ try {
115
+ for (const block of selected ? [selected] : blocks) {
116
+ if (ticket !== generation) return;
117
+ if (block.kind !== 'r' || (!selected && !block.evaluate)) continue;
118
+ status = `Running ${block.label}…`;
119
+ block.done = false;
120
+ block.output = '';
121
+ block.plots = [];
122
+ const result = await shelter.captureR(block.code, {
123
+ withAutoprint: true,
124
+ captureGraphics: true
125
+ });
126
+ try {
127
+ if (ticket !== generation) return;
128
+ block.output = result.output
129
+ .map((o) =>
130
+ typeof o.data === 'string' ? o.data : (o.data?.message ?? String(o.data))
131
+ )
132
+ .join('\n');
133
+ block.plots = result.images.map((image) => {
134
+ const canvas = document.createElement('canvas');
135
+ canvas.width = image.width;
136
+ canvas.height = image.height;
137
+ canvas.getContext('2d')!.drawImage(image, 0, 0);
138
+ return canvas.toDataURL();
139
+ });
140
+ block.done = true;
141
+ if (result.output.some((o) => o.type === 'error'))
142
+ throw new Error(`${block.label} failed. Fix the code and run it again.`);
143
+ } finally {
144
+ result.images.forEach((image) => image.close());
145
+ }
146
+ await shelter.purge();
147
+ }
148
+ status = 'Finished. R runs locally in your browser.';
149
+ } finally {
150
+ if (ticket === generation) await shelter.purge();
151
+ }
152
+ } catch (error) {
153
+ if (ticket === generation) {
154
+ status = String(error);
155
+ if (!runtimeReady) {
156
+ runtime?.close();
157
+ runtime = undefined;
158
+ }
159
+ }
160
+ } finally {
161
+ if (ticket === generation) busy = false;
162
+ }
163
+ }
164
+ </script>
165
+
166
+ <div class="h-full overflow-auto p-4 sm:p-6">
167
+ <div class="mx-auto max-w-3xl">
168
+ <div class="mb-5 flex flex-wrap gap-2">
169
+ <button class={button} disabled={!loaded || busy} onclick={() => run()}>Run all</button>
170
+ <button class={button} disabled={!loaded} onclick={reset}
171
+ >{busy ? 'Stop and reset' : 'Reset session'}</button
172
+ >
173
+ </div>
174
+ <p class="mb-6 text-sm text-muted" role="status">{status}</p>
175
+ <h1 class="mb-6 text-2xl font-semibold">{title}</h1>
176
+ {#each blocks as block, index}
177
+ {#if block.kind === 'markdown'}
178
+ <div class="prose max-w-none dark:prose-invert">{@html block.html}</div>
179
+ {:else}
180
+ <section class="my-5 min-w-0 rounded border border-border" aria-label={block.label}>
181
+ <details open={block.show}>
182
+ <summary class="cursor-pointer px-3 py-2 text-xs text-muted"
183
+ >{block.label}{!block.evaluate ? ' · skipped by Run all' : ''}</summary
184
+ >
185
+ <textarea
186
+ class="block w-full resize-y bg-surface p-3 font-mono text-sm"
187
+ aria-label={`R code ${index}`}
188
+ bind:value={block.code}
189
+ rows={Math.min(18, Math.max(3, block.code.split('\n').length))}
190
+ spellcheck="false"
191
+ disabled={busy}
192
+ ></textarea>
193
+ </details>
194
+ <div class="flex items-center gap-3 px-3 py-2">
195
+ <button class={button} disabled={busy} onclick={() => run(block)}>Run chunk</button>
196
+ {#if block.done}<span class="text-xs text-muted">Done</span>{/if}
197
+ </div>
198
+ {#if block.output}<pre
199
+ class="max-h-96 overflow-auto border-t border-border p-3 text-xs">{block.output}</pre>{/if}
200
+ {#each block.plots ?? [] as plot}<img
201
+ class="w-full bg-white"
202
+ src={plot}
203
+ alt={`R plot from ${block.label}`}
204
+ />{/each}
205
+ </section>
206
+ {/if}
207
+ {/each}
208
+ </div>
209
+ </div>