@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.
- package/.github/actions/product-cache/action.yml +32 -0
- package/.github/actions/product-cache/index.mjs +450 -0
- package/LICENSE +21 -0
- package/README.md +146 -2
- package/bun.lock +1701 -0
- package/cli/runtime.test.ts +420 -0
- package/cli/runtime.ts +385 -0
- package/cli/twiki.ts +305 -0
- package/package.json +138 -4
- package/site/_headers +7 -0
- package/site/assets/fonts/OFL.txt +93 -0
- package/site/assets/fonts/OverusedGrotesk-SemiBold.ttf +0 -0
- package/site/assets/social-background.svg +38 -0
- package/site/bun.lock +1701 -0
- package/site/database/index.ts +7 -0
- package/site/database/schema.ts +95 -0
- package/site/drizzle.config.ts +7 -0
- package/site/migrations/0001_auth.sql +14 -0
- package/site/migrations/0002_site_access.sql +5 -0
- package/site/migrations/0003_github_username.sql +1 -0
- package/site/migrations/meta/0002_snapshot.json +432 -0
- package/site/migrations/meta/0003_snapshot.json +439 -0
- package/site/migrations/meta/_journal.json +27 -0
- package/site/package.json +120 -0
- package/site/src/app.d.ts +14 -0
- package/site/src/app.html +11 -0
- package/site/src/hooks.server.ts +12 -0
- package/site/src/lib/analytics-config.json +1 -0
- package/site/src/lib/analytics.ts +40 -0
- package/site/src/lib/auth-config.json +3 -0
- package/site/src/lib/components/SEO.svelte +113 -0
- package/site/src/lib/components/doc/AccessLink.svelte +27 -0
- package/site/src/lib/components/doc/Backlinks.svelte +59 -0
- package/site/src/lib/components/doc/Breadcrumbs.svelte +29 -0
- package/site/src/lib/components/doc/ContentMeta.svelte +61 -0
- package/site/src/lib/components/doc/DocPager.svelte +91 -0
- package/site/src/lib/components/doc/DocShell.svelte +115 -0
- package/site/src/lib/components/doc/LicenseNotice.svelte +26 -0
- package/site/src/lib/components/doc/LockedContent.svelte +29 -0
- package/site/src/lib/components/doc/NavTree.svelte +158 -0
- package/site/src/lib/components/doc/NoteActions.svelte +139 -0
- package/site/src/lib/components/doc/PublicationBadge.svelte +49 -0
- package/site/src/lib/components/doc/ReaderModeToggle.svelte +19 -0
- package/site/src/lib/components/doc/Sidebar.svelte +351 -0
- package/site/src/lib/components/doc/Toc.svelte +339 -0
- package/site/src/lib/components/doc/enhancements.ts +146 -0
- package/site/src/lib/components/doc/heading-tracker.svelte.ts +39 -0
- package/site/src/lib/components/doc/image-preview.ts +114 -0
- package/site/src/lib/components/doc/reader-mode.svelte.ts +40 -0
- package/site/src/lib/components/embeds/mermaid.ts +353 -0
- package/site/src/lib/components/files/FileCode.svelte +81 -0
- package/site/src/lib/components/files/FileExplorer.svelte +74 -0
- package/site/src/lib/components/files/FileHistory.svelte +170 -0
- package/site/src/lib/components/files/FileIcon.svelte +28 -0
- package/site/src/lib/components/files/FileRmd.svelte +209 -0
- package/site/src/lib/components/files/FileTabs.svelte +180 -0
- package/site/src/lib/components/files/FileTree.svelte +67 -0
- package/site/src/lib/components/files/blame-gutter.ts +45 -0
- package/site/src/lib/components/files/code-view.ts +98 -0
- package/site/src/lib/components/files/file-workspace.svelte.ts +100 -0
- package/site/src/lib/components/graph/GraphPanel.svelte +275 -0
- package/site/src/lib/components/graph/GraphView.svelte +100 -0
- package/site/src/lib/components/graph/graph-data.ts +137 -0
- package/site/src/lib/components/graph/graph-model.ts +103 -0
- package/site/src/lib/components/graph/index.ts +3 -0
- package/site/src/lib/components/graph/render-graph.ts +626 -0
- package/site/src/lib/components/popover/index.ts +1 -0
- package/site/src/lib/components/popover/link-popovers.ts +233 -0
- package/site/src/lib/components/popover/popover.css +82 -0
- package/site/src/lib/components/search/SearchButton.svelte +57 -0
- package/site/src/lib/components/search/SearchPalette.svelte +189 -0
- package/site/src/lib/components/search/index.ts +3 -0
- package/site/src/lib/components/search/pagefind-client.svelte.ts +129 -0
- package/site/src/lib/components/search/pagefind-query.ts +22 -0
- package/site/src/lib/components/search/pagefind-url.ts +12 -0
- package/site/src/lib/components/search/search-state.svelte.ts +22 -0
- package/site/src/lib/components/ui/IconButton.svelte +30 -0
- package/site/src/lib/components/ui/ThemeToggle.svelte +22 -0
- package/site/src/lib/components/ui/index.ts +1 -0
- package/site/src/lib/config.ts +9 -0
- package/site/src/lib/content-license.ts +7 -0
- package/site/src/lib/file-history.ts +37 -0
- package/site/src/lib/file-icons.ts +27 -0
- package/site/src/lib/file-tabs.ts +72 -0
- package/site/src/lib/files.ts +73 -0
- package/site/src/lib/github-user.ts +70 -0
- package/site/src/lib/metadata.ts +166 -0
- package/site/src/lib/notebook-nav.ts +63 -0
- package/site/src/lib/publication.ts +1 -0
- package/site/src/lib/rmd.ts +156 -0
- package/site/src/lib/server/access-admin.ts +33 -0
- package/site/src/lib/server/content-model.ts +253 -0
- package/site/src/lib/server/content-provider.ts +266 -0
- package/site/src/lib/server/content.ts +277 -0
- package/site/src/lib/server/runtime-content.ts +34 -0
- package/site/src/lib/site-config.json +9 -0
- package/site/src/lib/social-image.ts +13 -0
- package/site/src/lib/styles/article.css +607 -0
- package/site/src/lib/styles/foundation.css +138 -0
- package/site/src/lib/styles/print.css +115 -0
- package/site/src/lib/styles/shell.css +182 -0
- package/site/src/lib/text-exports.ts +5 -0
- package/site/src/lib/types.ts +140 -0
- package/site/src/lib/utils.ts +6 -0
- package/site/src/routes/+error.svelte +16 -0
- package/site/src/routes/+layout.server.ts +10 -0
- package/site/src/routes/+layout.svelte +72 -0
- package/site/src/routes/+layout.ts +3 -0
- package/site/src/routes/404/+page.svelte +13 -0
- package/site/src/routes/[...slug]/+page.server.ts +24 -0
- package/site/src/routes/[...slug]/+page.svelte +180 -0
- package/site/src/routes/[course]/files/[...file]/+page.svelte +373 -0
- package/site/src/routes/[course]/files/[...file]/+page.ts +38 -0
- package/site/src/routes/admin/access/+page.server.ts +53 -0
- package/site/src/routes/admin/access/+page.svelte +118 -0
- package/site/src/routes/graph.json/+server.ts +7 -0
- package/site/src/routes/index.xml/+server.ts +7 -0
- package/site/src/routes/layout.css +10 -0
- package/site/src/routes/robots.txt/+server.ts +9 -0
- package/site/src/routes/sitemap.xml/+server.ts +7 -0
- package/site/src/routes/tags/+page.server.ts +5 -0
- package/site/src/routes/tags/+page.svelte +38 -0
- package/site/src/routes/tags/[...tag]/+page.server.ts +22 -0
- package/site/src/routes/tags/[...tag]/+page.svelte +52 -0
- package/site/static/favicon.png +0 -0
- package/site/static/fonts/JetBrainsMono-Italic-VF.woff2 +0 -0
- package/site/static/fonts/JetBrainsMono-VF.woff2 +0 -0
- package/site/static/fonts/OverusedGrotesk-VF.woff2 +0 -0
- package/site/static/fonts/OverusedGroteskItalic-VF.woff2 +0 -0
- package/site/static/fonts/Virgil.woff2 +0 -0
- package/site/svelte.config.js +71 -0
- package/site/tooling/LICENSE-quartz +25 -0
- package/site/tooling/adapter.js +55 -0
- package/site/tooling/assets.ts +323 -0
- package/site/tooling/benchmark.ts +191 -0
- package/site/tooling/build.ts +407 -0
- package/site/tooling/cache.ts +133 -0
- package/site/tooling/check-diagrams.ts +164 -0
- package/site/tooling/check-links.ts +86 -0
- package/site/tooling/check-targets.ts +139 -0
- package/site/tooling/compiler.ts +1386 -0
- package/site/tooling/deploy.ts +112 -0
- package/site/tooling/fingerprint.ts +81 -0
- package/site/tooling/lib/app-version.js +92 -0
- package/site/tooling/lib/application-asset-urls.ts +27 -0
- package/site/tooling/lib/application-cache.ts +269 -0
- package/site/tooling/lib/asset-hash-cache.ts +116 -0
- package/site/tooling/lib/autotag.ts +99 -0
- package/site/tooling/lib/build-assembly.ts +148 -0
- package/site/tooling/lib/build-workspace.ts +386 -0
- package/site/tooling/lib/cache-selection.ts +70 -0
- package/site/tooling/lib/cloudflare-prewarm.js +40 -0
- package/site/tooling/lib/comments.ts +32 -0
- package/site/tooling/lib/content-cache.ts +244 -0
- package/site/tooling/lib/content-urls.ts +50 -0
- package/site/tooling/lib/course-cache.ts +298 -0
- package/site/tooling/lib/course-files.ts +281 -0
- package/site/tooling/lib/currency.ts +33 -0
- package/site/tooling/lib/deployment-config.ts +222 -0
- package/site/tooling/lib/deployment-manifest.ts +274 -0
- package/site/tooling/lib/deployment-prepare.ts +325 -0
- package/site/tooling/lib/deployment-upload.ts +293 -0
- package/site/tooling/lib/dev-git-state.ts +130 -0
- package/site/tooling/lib/dev-history.ts +86 -0
- package/site/tooling/lib/dev-social-images.ts +38 -0
- package/site/tooling/lib/dev-state.ts +107 -0
- package/site/tooling/lib/edition-builds.ts +184 -0
- package/site/tooling/lib/edition-paths.js +29 -0
- package/site/tooling/lib/empty-tag-route.js +23 -0
- package/site/tooling/lib/file-history.ts +201 -0
- package/site/tooling/lib/file-icons.ts +93 -0
- package/site/tooling/lib/file-policy.ts +38 -0
- package/site/tooling/lib/file-search.js +13 -0
- package/site/tooling/lib/git-revisions.ts +41 -0
- package/site/tooling/lib/history-pool.ts +74 -0
- package/site/tooling/lib/history-worker.ts +22 -0
- package/site/tooling/lib/knit-rmd.R +38 -0
- package/site/tooling/lib/lastmod.ts +201 -0
- package/site/tooling/lib/markdown-files.ts +51 -0
- package/site/tooling/lib/ofm.ts +634 -0
- package/site/tooling/lib/output.ts +60 -0
- package/site/tooling/lib/product-cache.ts +1006 -0
- package/site/tooling/lib/project-config.ts +288 -0
- package/site/tooling/lib/public-assets.ts +38 -0
- package/site/tooling/lib/public-links.ts +56 -0
- package/site/tooling/lib/publishing.ts +156 -0
- package/site/tooling/lib/r-runtime.ts +257 -0
- package/site/tooling/lib/rebuild-queue.ts +25 -0
- package/site/tooling/lib/rmd-preflight.ts +166 -0
- package/site/tooling/lib/rmd-preview-cache.ts +93 -0
- package/site/tooling/lib/rmd-previews.ts +129 -0
- package/site/tooling/lib/runtime-data-modules.ts +30 -0
- package/site/tooling/lib/runtime-packaging.ts +321 -0
- package/site/tooling/lib/search-cache.js +165 -0
- package/site/tooling/lib/search-inputs.js +129 -0
- package/site/tooling/lib/slug.ts +377 -0
- package/site/tooling/lib/social-images.ts +210 -0
- package/site/tooling/lib/text-exports.ts +51 -0
- package/site/tooling/lib/visit-elements.ts +17 -0
- package/site/tooling/live-test.ts +138 -0
- package/site/tooling/pipeline.ts +103 -0
- package/site/tooling/prepare.ts +3 -0
- package/site/tooling/publish.ts +28 -0
- package/site/tooling/r-cache.ts +45 -0
- package/site/tooling/r-render-profile.json +7 -0
- package/site/tooling/search.js +94 -0
- package/site/tooling/verify-deployment.ts +109 -0
- package/site/tooling/vite-content.ts +240 -0
- package/site/tooling/wrangler-paths.js +25 -0
- package/site/tooling/wrangler.ts +19 -0
- package/site/tsconfig.json +16 -0
- package/site/vite.config.ts +40 -0
- package/site/worker/access.ts +57 -0
- package/site/worker/analytics.ts +100 -0
- package/site/worker/auth-routes.ts +78 -0
- package/site/worker/auth.ts +78 -0
- package/site/worker/content-runtime.ts +226 -0
- package/site/worker/framing.ts +4 -0
- package/site/worker/gate.ts +147 -0
- package/site/worker/index.ts +9 -0
- package/site/worker/login.ts +62 -0
- package/site/worker/publication.ts +44 -0
- package/site/worker/runtime.ts +104 -0
- package/site/worker/tsconfig.json +13 -0
- 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
|
+
}
|