@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,275 @@
1
+ <script lang="ts">
2
+ import { onMount, tick } from 'svelte';
3
+ import { page } from '$app/state';
4
+ import { Spring, prefersReducedMotion } from 'svelte/motion';
5
+ import Maximize2 from '@lucide/svelte/icons/maximize-2';
6
+ import X from '@lucide/svelte/icons/x';
7
+ import {
8
+ addToVisited,
9
+ idForRoute,
10
+ loadGraph,
11
+ localGraphConfig,
12
+ globalGraphConfig,
13
+ type GraphData
14
+ } from './graph-data';
15
+ import GraphView from './GraphView.svelte';
16
+ import IconButton from '../ui/IconButton.svelte';
17
+
18
+ let data = $state<GraphData | null>(null);
19
+ let failed = $state(false);
20
+ let renderReady = $state(false);
21
+ let expanded = $state(false);
22
+ let closing = $state(false);
23
+ let wide = $state(false);
24
+ let mode = $state<'local' | 'global'>('local');
25
+ let dialog: HTMLDialogElement;
26
+ let slot: HTMLDivElement;
27
+ let opener: HTMLElement | null = null;
28
+ const bounds = new Spring(
29
+ { x: 0, y: 0, width: 0, height: 0 },
30
+ { stiffness: 0.16, damping: 0.85, precision: 0.5 }
31
+ );
32
+ const currentId = $derived(data ? idForRoute(page.url.pathname, data) : undefined);
33
+ function homeBounds() {
34
+ const rect = slot.getBoundingClientRect();
35
+ return wide
36
+ ? { x: rect.x, y: rect.y, width: rect.width, height: rect.height }
37
+ : { x: innerWidth / 2, y: innerHeight / 2, width: 1, height: 1 };
38
+ }
39
+ async function openGraph() {
40
+ if (!data || expanded) return;
41
+ opener = document.activeElement instanceof HTMLElement ? document.activeElement : null;
42
+ await bounds.set(homeBounds(), { instant: true });
43
+ expanded = true;
44
+ if (!wide) mode = 'global';
45
+ await tick();
46
+ dialog.showModal();
47
+ void bounds.set(
48
+ { x: 0, y: 0, width: innerWidth, height: innerHeight },
49
+ { instant: prefersReducedMotion.current }
50
+ );
51
+ }
52
+ async function closeGraph() {
53
+ if (!expanded || closing) return;
54
+ closing = true;
55
+ await bounds.set(homeBounds(), { instant: prefersReducedMotion.current }).catch(() => {});
56
+ dialog.close();
57
+ expanded = false;
58
+ closing = false;
59
+ mode = 'local';
60
+ await tick();
61
+ opener?.focus({ preventScroll: true });
62
+ }
63
+ onMount(() => {
64
+ const breakpoint = getComputedStyle(document.documentElement)
65
+ .getPropertyValue('--breakpoint-rail')
66
+ .trim();
67
+ const media = window.matchMedia(`(width >= ${breakpoint})`);
68
+ const updateWidth = () => (wide = media.matches);
69
+ const resize = () => {
70
+ if (expanded)
71
+ void bounds.set(
72
+ closing ? homeBounds() : { x: 0, y: 0, width: innerWidth, height: innerHeight },
73
+ { instant: true }
74
+ );
75
+ };
76
+ updateWidth();
77
+ media.addEventListener('change', updateWidth);
78
+ window.addEventListener('resize', resize);
79
+ let cancelled = false;
80
+ void Promise.all([loadGraph(), import('./render-graph')]).then(
81
+ ([graph]) => {
82
+ if (!cancelled) data = graph;
83
+ },
84
+ () => {
85
+ if (!cancelled) failed = true;
86
+ }
87
+ );
88
+ return () => {
89
+ cancelled = true;
90
+ media.removeEventListener('change', updateWidth);
91
+ window.removeEventListener('resize', resize);
92
+ };
93
+ });
94
+ $effect(() => {
95
+ if (currentId) addToVisited(currentId);
96
+ });
97
+ $effect(() => {
98
+ if (!expanded) return;
99
+ const previous = document.documentElement.style.overflow;
100
+ document.documentElement.style.overflow = 'hidden';
101
+ return () => {
102
+ document.documentElement.style.overflow = previous;
103
+ };
104
+ });
105
+ function onKeydown(event: KeyboardEvent) {
106
+ if (data && event.key === 'g' && (event.ctrlKey || event.metaKey) && !event.shiftKey) {
107
+ event.preventDefault();
108
+ void (expanded ? closeGraph() : openGraph());
109
+ }
110
+ }
111
+ </script>
112
+
113
+ <svelte:window onkeydown={onKeydown} />
114
+
115
+ <button
116
+ type="button"
117
+ class="doc-compact-graph cursor-pointer rounded-md border border-border px-3 py-1.5 text-sm hover:bg-surface disabled:opacity-50"
118
+ disabled={!data}
119
+ onclick={openGraph}>Open global graph</button
120
+ >
121
+ <section
122
+ class="graph-panel min-w-0 shrink-0"
123
+ class:expanded
124
+ class:compact={!wide}
125
+ aria-label="Graph view"
126
+ >
127
+ <div class="relative h-(--graph-preview-height)" data-graph-outer bind:this={slot}>
128
+ <dialog
129
+ bind:this={dialog}
130
+ class:expanded
131
+ class:closing
132
+ role={expanded ? 'dialog' : 'group'}
133
+ aria-label="Graph canvas"
134
+ oncancel={(event) => {
135
+ event.preventDefault();
136
+ void closeGraph();
137
+ }}
138
+ style={expanded
139
+ ? `left:${bounds.current.x}px;top:${bounds.current.y}px;width:${bounds.current.width}px;height:${bounds.current.height}px`
140
+ : undefined}
141
+ >
142
+ {#if failed}
143
+ <p class="status">Graph unavailable.</p>
144
+ {:else if data && (currentId || mode === 'global') && (wide || expanded)}
145
+ <GraphView
146
+ {data}
147
+ {currentId}
148
+ config={mode === 'local' ? localGraphConfig : globalGraphConfig}
149
+ global={expanded}
150
+ onready={() => (renderReady = true)}
151
+ onerror={() => (failed = true)}
152
+ onnavigate={() => {
153
+ dialog.close();
154
+ expanded = false;
155
+ closing = false;
156
+ mode = 'local';
157
+ }}
158
+ />
159
+ {#if !renderReady}<div
160
+ class="status pointer-events-none absolute inset-0"
161
+ aria-hidden="true"
162
+ >
163
+ <span class="size-5 animate-pulse rounded-full bg-subtle"></span>
164
+ </div>{/if}
165
+ {:else}<p class="status">{data ? 'This page is not in the graph.' : 'Loading graph…'}</p>{/if}
166
+ <h3
167
+ class="pointer-events-none absolute top-1 left-1 m-0 rounded bg-bg/80 px-1.5 py-1 text-xs font-medium text-muted"
168
+ >
169
+ Graph View
170
+ </h3>
171
+ {#if expanded}
172
+ <div class="graph-modes" aria-label="Graph scope">
173
+ <button
174
+ aria-pressed={mode === 'local'}
175
+ disabled={!currentId || closing}
176
+ onclick={() => (mode = 'local')}>Local</button
177
+ >
178
+ <button
179
+ aria-pressed={mode === 'global'}
180
+ disabled={closing}
181
+ onclick={() => (mode = 'global')}>Global</button
182
+ >
183
+ </div>
184
+ {/if}
185
+ <IconButton
186
+ class="graph-expand absolute top-1 right-1 size-6 rounded"
187
+ aria-label={expanded ? 'Close' : 'Expand graph'}
188
+ title={expanded ? 'Close graph (Escape)' : 'Expand graph'}
189
+ disabled={!data || closing}
190
+ onclick={() => (expanded ? closeGraph() : openGraph())}
191
+ >
192
+ {#if expanded}<X size={16} />{:else}<Maximize2 size={14} />{/if}
193
+ </IconButton>
194
+ </dialog>
195
+ </div>
196
+ </section>
197
+
198
+ <style>
199
+ dialog {
200
+ display: block;
201
+ position: relative;
202
+ inset: auto;
203
+ margin: 0;
204
+ padding: 0;
205
+ width: 100%;
206
+ height: var(--graph-preview-height);
207
+ max-width: none;
208
+ max-height: none;
209
+ border: 0;
210
+ overflow: hidden;
211
+ color: var(--color-text);
212
+ background: var(--color-bg);
213
+ }
214
+ dialog.expanded {
215
+ position: fixed;
216
+ }
217
+ dialog::backdrop {
218
+ background: rgb(0 0 0 / 0.35);
219
+ animation: backdrop-in 180ms ease-out;
220
+ }
221
+ dialog.closing::backdrop {
222
+ opacity: 0;
223
+ transition: opacity 180ms;
224
+ }
225
+ .status {
226
+ display: flex;
227
+ align-items: center;
228
+ justify-content: center;
229
+ height: 100%;
230
+ font-size: 0.75rem;
231
+ color: var(--color-muted);
232
+ }
233
+ .graph-modes {
234
+ position: absolute;
235
+ top: 0.5rem;
236
+ left: 50%;
237
+ transform: translateX(-50%);
238
+ display: flex;
239
+ padding: 0.125rem;
240
+ border: 1px solid var(--color-border);
241
+ border-radius: 0.375rem;
242
+ background: var(--color-bg);
243
+ }
244
+ .graph-modes button {
245
+ padding: 0.25rem 0.75rem;
246
+ border-radius: 0.25rem;
247
+ font-size: 0.75rem;
248
+ cursor: pointer;
249
+ }
250
+ .graph-modes button[aria-pressed='true'] {
251
+ background: var(--color-surface);
252
+ color: var(--color-accent);
253
+ }
254
+ .graph-modes button:focus-visible {
255
+ outline: 2px solid var(--color-accent);
256
+ }
257
+ @keyframes backdrop-in {
258
+ from {
259
+ opacity: 0;
260
+ }
261
+ to {
262
+ opacity: 1;
263
+ }
264
+ }
265
+ .graph-panel.compact.expanded {
266
+ display: block;
267
+ height: 0;
268
+ }
269
+ @media (prefers-reduced-motion: reduce) {
270
+ dialog::backdrop {
271
+ animation: none;
272
+ transition: none;
273
+ }
274
+ }
275
+ </style>
@@ -0,0 +1,100 @@
1
+ <script lang="ts">
2
+ /**
3
+ * Persistent graph renderer shell, shared by the inline and expanded view.
4
+ *
5
+ * The actual rendering is a faithful port of Quartz's pixi.js + d3-force
6
+ * implementation living in ./render-graph.ts, loaded via dynamic
7
+ * `import()` from an $effect so pixi/d3/tween never touch the page
8
+ * bundle. This component only owns the container element, the lazy
9
+ * mount/destroy lifecycle, theme-flip re-renders and navigation.
10
+ */
11
+ import type { ClassValue } from 'svelte/elements';
12
+ import { goto } from '$app/navigation';
13
+ import { cn } from '$lib/utils';
14
+ import { hrefForId, type GraphConfig, type GraphData } from './graph-data';
15
+
16
+ interface Props {
17
+ data: GraphData;
18
+ /** Node id of the page being viewed (Quartz's `slug`). */
19
+ currentId?: string;
20
+ /** Quartz D3Config equivalent (localGraphConfig / globalGraphConfig). */
21
+ config: GraphConfig;
22
+ /**
23
+ * Global graph: wheel zoom works bare; the local graph requires
24
+ * ctrl/meta (Quartz's .global-graph-container class check).
25
+ */
26
+ global?: boolean;
27
+ class?: ClassValue;
28
+ /** Called when a node navigation starts (lets the expanded view close). */
29
+ onnavigate?: () => void;
30
+ /** Called after Pixi has drawn the first node/link frame. */
31
+ onready?: () => void;
32
+ /** Called when the renderer cannot initialize. */
33
+ onerror?: (error: unknown) => void;
34
+ }
35
+
36
+ const {
37
+ data,
38
+ currentId,
39
+ config,
40
+ global = false,
41
+ class: className,
42
+ onnavigate,
43
+ onready,
44
+ onerror
45
+ }: Props = $props();
46
+
47
+ let container = $state<HTMLDivElement | null>(null);
48
+
49
+ // Quartz re-renders the graph on its `themechange` event; the equivalent
50
+ // here is watching the .dark class flip on <html> (mode-watcher toggles it).
51
+ let themeTick = $state(0);
52
+ $effect(() => {
53
+ const observer = new MutationObserver(() => themeTick++);
54
+ observer.observe(document.documentElement, {
55
+ attributes: true,
56
+ attributeFilter: ['class']
57
+ });
58
+ return () => observer.disconnect();
59
+ });
60
+
61
+ $effect(() => {
62
+ // reactive deps: re-render on data / current page / theme changes
63
+ void themeTick;
64
+ const el = container;
65
+ const args = [data, currentId, config] as const;
66
+ if (!el) return;
67
+
68
+ let cancelled = false;
69
+ let cleanup: (() => void) | undefined;
70
+ // lazy: pixi.js + d3 + tween.js load only when a graph actually mounts
71
+ void import('./render-graph')
72
+ .then(async ({ renderGraph }) => {
73
+ if (cancelled) return;
74
+ cleanup = await renderGraph(el, args[0], args[1], args[2], false, (id) => {
75
+ onnavigate?.();
76
+ void goto(hrefForId(id));
77
+ });
78
+ if (cancelled) cleanup();
79
+ else onready?.();
80
+ })
81
+ .catch((error: unknown) => {
82
+ if (!cancelled) {
83
+ console.error('Knowledge graph renderer failed', error);
84
+ onerror?.(error);
85
+ }
86
+ });
87
+ return () => {
88
+ cancelled = true;
89
+ cleanup?.();
90
+ };
91
+ });
92
+ </script>
93
+
94
+ <div
95
+ bind:this={container}
96
+ data-expanded={global}
97
+ class={cn('h-full w-full overflow-hidden', className)}
98
+ role="img"
99
+ aria-label="Knowledge graph"
100
+ ></div>
@@ -0,0 +1,137 @@
1
+ /** Lazily fetch and cache the prerendered /graph.json endpoint. */
2
+
3
+ export interface GraphNode {
4
+ id: string;
5
+ title: string;
6
+ tags: string[];
7
+ }
8
+
9
+ export interface GraphLink {
10
+ source: string;
11
+ target: string;
12
+ }
13
+
14
+ export interface GraphData {
15
+ nodes: GraphNode[];
16
+ links: GraphLink[];
17
+ }
18
+
19
+ let graphPromise: Promise<GraphData> | undefined;
20
+
21
+ /** Fetch (once) and cache the sitewide graph. */
22
+ export function loadGraph(fetcher: typeof fetch = fetch): Promise<GraphData> {
23
+ graphPromise ??= fetcher('/graph.json').then((res) => {
24
+ if (!res.ok) {
25
+ graphPromise = undefined; // allow retry on transient failure
26
+ throw new Error(`Failed to load /graph.json: ${res.status}`);
27
+ }
28
+ return res.json() as Promise<GraphData>;
29
+ });
30
+ return graphPromise;
31
+ }
32
+
33
+ // Node ordering affects the seeded force layout; keep it stable in graph-model.ts.
34
+
35
+ export interface GraphConfig {
36
+ drag: boolean;
37
+ zoom: boolean;
38
+ depth: number;
39
+ scale: number;
40
+ repelForce: number;
41
+ centerForce: number;
42
+ linkDistance: number;
43
+ fontSize: number;
44
+ opacityScale: number;
45
+ removeTags: string[];
46
+ showTags: boolean;
47
+ focusOnHover: boolean;
48
+ enableRadial: boolean;
49
+ centerCurrentNode: boolean;
50
+ }
51
+
52
+ export const localGraphConfig: GraphConfig = {
53
+ drag: true,
54
+ zoom: true,
55
+ depth: 2,
56
+ scale: 1.1,
57
+ repelForce: 0.5,
58
+ centerForce: 0.3,
59
+ linkDistance: 30,
60
+ fontSize: 0.6,
61
+ opacityScale: 1,
62
+ showTags: true,
63
+ removeTags: [],
64
+ focusOnHover: false,
65
+ enableRadial: false,
66
+ centerCurrentNode: true
67
+ };
68
+
69
+ export const globalGraphConfig: GraphConfig = {
70
+ drag: true,
71
+ zoom: true,
72
+ depth: -1,
73
+ scale: 0.9,
74
+ repelForce: 0.5,
75
+ centerForce: 0.2,
76
+ linkDistance: 30,
77
+ fontSize: 0.6,
78
+ opacityScale: 1,
79
+ showTags: true,
80
+ removeTags: [],
81
+ focusOnHover: true,
82
+ enableRadial: true,
83
+ centerCurrentNode: false
84
+ };
85
+
86
+ // ---------------------------------------------------------------------------
87
+ // Visited tracking — Quartz keeps a `graph-visited` set in localStorage and
88
+ // tints visited nodes differently from unvisited ones.
89
+ // ---------------------------------------------------------------------------
90
+
91
+ const visitedKey = 'graph-visited';
92
+
93
+ export function getVisited(): Set<string> {
94
+ try {
95
+ return new Set(JSON.parse(localStorage.getItem(visitedKey) ?? '[]'));
96
+ } catch {
97
+ return new Set();
98
+ }
99
+ }
100
+
101
+ export function addToVisited(id: string): void {
102
+ try {
103
+ const visited = getVisited();
104
+ visited.add(id);
105
+ localStorage.setItem(visitedKey, JSON.stringify([...visited]));
106
+ } catch {
107
+ // localStorage unavailable (private mode etc.) — visited is cosmetic
108
+ }
109
+ }
110
+
111
+ // ---------------------------------------------------------------------------
112
+ // Route ↔ node id mapping
113
+ // ---------------------------------------------------------------------------
114
+
115
+ /** Display-form node id → site href (SvelteKit route, trailingSlash 'never'). */
116
+ export function hrefForId(id: string): string {
117
+ if (id === '/' || id === '') return '/';
118
+ return `/${id.replace(/\/+$/, '')}`;
119
+ }
120
+
121
+ /**
122
+ * Current pathname → graph node id, or undefined when the route has no node
123
+ * (generated folder listings, 404). `/tags/<tag>` pages resolve to the
124
+ * synthesized `tags/<tag>` node when any page carries that tag, matching
125
+ * Quartz where tag pages are part of the content index.
126
+ */
127
+ export function idForRoute(pathname: string, data: GraphData): string | undefined {
128
+ const route = decodeURIComponent(pathname).replace(/^\/+|\/+$/g, '');
129
+ if (route === '') return data.nodes.some((n) => n.id === '/') ? '/' : undefined;
130
+ if (data.nodes.some((n) => n.id === route)) return route;
131
+ if (data.nodes.some((n) => n.id === `${route}/`)) return `${route}/`; // folder index page
132
+ if (route.startsWith('tags/')) {
133
+ const tag = route.slice('tags/'.length);
134
+ if (data.nodes.some((n) => n.tags.includes(tag))) return route;
135
+ }
136
+ return undefined;
137
+ }
@@ -0,0 +1,103 @@
1
+ import type { GraphData, GraphLink } from './graph-data';
2
+
3
+ interface GraphSelectionConfig {
4
+ depth: number;
5
+ showTags: boolean;
6
+ removeTags: string[];
7
+ }
8
+
9
+ export interface OrderedGraphElements {
10
+ nodeIds: string[];
11
+ links: GraphLink[];
12
+ }
13
+
14
+ /**
15
+ * Select and order the graph exactly like Quartz's graph.inline.ts.
16
+ *
17
+ * The order is observable: d3-force initializes nodes by array index, and its
18
+ * link force iterates links in insertion order. An adjacency-map BFS returns
19
+ * the same set but produces a visibly different layout, so retain Quartz's
20
+ * per-page outgoing/tag link order and outgoing-before-incoming worklist.
21
+ */
22
+ export function orderedGraphElements(
23
+ fullData: GraphData,
24
+ slug: string | undefined,
25
+ config: GraphSelectionConfig
26
+ ): OrderedGraphElements {
27
+ const data = new Map(fullData.nodes.map((node) => [node.id, node]));
28
+ const validLinks = new Set(data.keys());
29
+ const outgoingBySource = new Map<string, GraphLink[]>();
30
+ for (const link of fullData.links) {
31
+ if (!validLinks.has(link.source) || !validLinks.has(link.target)) continue;
32
+ const outgoing = outgoingBySource.get(link.source);
33
+ if (outgoing) outgoing.push(link);
34
+ else outgoingBySource.set(link.source, [link]);
35
+ }
36
+
37
+ const links: GraphLink[] = [];
38
+ const tags: string[] = [];
39
+ for (const [source, details] of data) {
40
+ for (const link of outgoingBySource.get(source) ?? []) {
41
+ links.push({ source: link.source, target: link.target });
42
+ }
43
+
44
+ if (!config.showTags) continue;
45
+ const localTags = details.tags
46
+ .filter((tag) => !config.removeTags.includes(tag))
47
+ .map((tag) => `tags/${tag}`);
48
+ tags.push(...localTags.filter((tag) => !tags.includes(tag)));
49
+ for (const tag of localTags) links.push({ source, target: tag });
50
+ }
51
+
52
+ const neighbourhood = new Set<string>();
53
+ if (config.depth >= 0 && slug !== undefined) {
54
+ const outgoing = new Map<string, string[]>();
55
+ const incoming = new Map<string, string[]>();
56
+ for (const link of links) {
57
+ const targets = outgoing.get(link.source);
58
+ if (targets) targets.push(link.target);
59
+ else outgoing.set(link.source, [link.target]);
60
+ const sources = incoming.get(link.target);
61
+ if (sources) sources.push(link.source);
62
+ else incoming.set(link.target, [link.source]);
63
+ }
64
+
65
+ // Quartz uses a sentinel worklist and deliberately doesn't de-duplicate
66
+ // queued nodes. Keep that traversal order while avoiding repeated scans
67
+ // through the complete link array.
68
+ const sentinel = Symbol('depth');
69
+ const worklist: (string | typeof sentinel)[] = [slug, sentinel];
70
+ let cursor = 0;
71
+ let remainingDepth = config.depth;
72
+ while (remainingDepth >= 0 && cursor < worklist.length) {
73
+ const current = worklist[cursor++];
74
+ if (current === sentinel) {
75
+ remainingDepth--;
76
+ worklist.push(sentinel);
77
+ continue;
78
+ }
79
+
80
+ neighbourhood.add(current);
81
+ worklist.push(...(outgoing.get(current) ?? []), ...(incoming.get(current) ?? []));
82
+ }
83
+ } else {
84
+ validLinks.forEach((id) => neighbourhood.add(id));
85
+ if (config.showTags) tags.forEach((tag) => neighbourhood.add(tag));
86
+ }
87
+
88
+ return {
89
+ nodeIds: [...neighbourhood],
90
+ links: links.filter((link) => neighbourhood.has(link.source) && neighbourhood.has(link.target))
91
+ };
92
+ }
93
+
94
+ /** Translate a simulation coordinate into canvas space; zero is valid. */
95
+ export function canvasPosition(
96
+ x: number | undefined,
97
+ y: number | undefined,
98
+ width: number,
99
+ height: number
100
+ ): { x: number; y: number } | undefined {
101
+ if (x === undefined || y === undefined) return undefined;
102
+ return { x: x + width / 2, y: y + height / 2 };
103
+ }
@@ -0,0 +1,3 @@
1
+ // Ported from Quartz's graph.inline.ts. Dynamically import to defer Pixi and D3.
2
+ export { default as GraphPanel } from './GraphPanel.svelte';
3
+ export * from './graph-data';