@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.
- 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,233 @@
|
|
|
1
|
+
/** Svelte attachment for cached, anchor-aware previews of internal links. */
|
|
2
|
+
|
|
3
|
+
import { computePosition, flip, inline, shift } from '@floating-ui/dom';
|
|
4
|
+
import type { Attachment } from 'svelte/attachments';
|
|
5
|
+
import './popover.css';
|
|
6
|
+
|
|
7
|
+
const SHOW_DELAY_MS = 300;
|
|
8
|
+
const HIDE_DELAY_MS = 150;
|
|
9
|
+
const ANCHOR_SCROLL_OFFSET = 12;
|
|
10
|
+
|
|
11
|
+
type PopoverContent =
|
|
12
|
+
| { kind: 'html'; contentType: string; element: HTMLElement }
|
|
13
|
+
| { kind: 'image'; contentType: string; src: string }
|
|
14
|
+
| { kind: 'pdf'; contentType: string; src: string };
|
|
15
|
+
|
|
16
|
+
/** Per-pathname cache of fetched + parsed popover content (module-level: survives navigations). */
|
|
17
|
+
const contentCache = new Map<string, Promise<PopoverContent | null>>();
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Rebase relative `href`/`src` attributes of a fetched document against the
|
|
21
|
+
* target page URL, so links/images inside the popover resolve from the
|
|
22
|
+
* *current* page. Port of `normalizeRelativeURLs` (quartz/util/path.ts).
|
|
23
|
+
*/
|
|
24
|
+
function normalizeRelativeURLs(root: ParentNode, base: URL): void {
|
|
25
|
+
const rebase = (el: Element, attr: string) => {
|
|
26
|
+
const value = el.getAttribute(attr);
|
|
27
|
+
if (value === null) return;
|
|
28
|
+
try {
|
|
29
|
+
const abs = new URL(value, base);
|
|
30
|
+
el.setAttribute(attr, abs.pathname + abs.search + abs.hash);
|
|
31
|
+
} catch {
|
|
32
|
+
/* leave malformed URLs untouched */
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
for (const el of root.querySelectorAll('[href=""], [href^="./"], [href^="../"]')) {
|
|
36
|
+
rebase(el, 'href');
|
|
37
|
+
}
|
|
38
|
+
for (const el of root.querySelectorAll('[src=""], [src^="./"], [src^="../"]')) {
|
|
39
|
+
rebase(el, 'src');
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function fetchContent(targetUrl: URL): Promise<PopoverContent | null> {
|
|
44
|
+
const response = await fetch(targetUrl.toString()).catch(() => null);
|
|
45
|
+
if (!response || !response.ok) return null;
|
|
46
|
+
|
|
47
|
+
const contentType = (response.headers.get('Content-Type') ?? '').split(';')[0].trim();
|
|
48
|
+
if (contentType.startsWith('image/')) {
|
|
49
|
+
return { kind: 'image', contentType, src: targetUrl.toString() };
|
|
50
|
+
}
|
|
51
|
+
if (contentType === 'application/pdf') {
|
|
52
|
+
return { kind: 'pdf', contentType, src: targetUrl.toString() };
|
|
53
|
+
}
|
|
54
|
+
if (!contentType.startsWith('text/html')) return null;
|
|
55
|
+
|
|
56
|
+
const doc = new DOMParser().parseFromString(await response.text(), 'text/html');
|
|
57
|
+
normalizeRelativeURLs(doc, targetUrl);
|
|
58
|
+
// Prefix every id so popover content never collides with the host page's
|
|
59
|
+
// heading ids (Quartz `popover-internal-` convention; anchor scroll relies on it).
|
|
60
|
+
for (const el of doc.querySelectorAll('[id]')) {
|
|
61
|
+
el.id = `popover-internal-${el.id}`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const article = doc.querySelector<HTMLElement>('article[data-pagefind-body]');
|
|
65
|
+
if (!article) return null;
|
|
66
|
+
article.removeAttribute('data-pagefind-body');
|
|
67
|
+
for (const el of article.querySelectorAll('script, style, link, [data-island]')) el.remove();
|
|
68
|
+
|
|
69
|
+
return { kind: 'html', contentType, element: document.importNode(article, true) };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Attachment factory. Apply to the rendered-content container; re-created per
|
|
74
|
+
* route via the `dep` key (same pattern as `copyButtons`/`calloutFold`).
|
|
75
|
+
*/
|
|
76
|
+
export function linkPopovers(_dep: unknown): Attachment<HTMLElement> {
|
|
77
|
+
return (node) => {
|
|
78
|
+
// Hover intent is meaningless on touch; bail entirely (CSS double-guards).
|
|
79
|
+
if (!window.matchMedia('(hover: hover) and (pointer: fine)').matches) return;
|
|
80
|
+
|
|
81
|
+
let popoverEl: HTMLDivElement | null = null;
|
|
82
|
+
let popoverInner: HTMLDivElement | null = null;
|
|
83
|
+
let showTimer: ReturnType<typeof setTimeout> | undefined;
|
|
84
|
+
let hideTimer: ReturnType<typeof setTimeout> | undefined;
|
|
85
|
+
/** Generation counter: bumping it invalidates in-flight show() work. */
|
|
86
|
+
let generation = 0;
|
|
87
|
+
|
|
88
|
+
function ensurePopover(): { el: HTMLDivElement; inner: HTMLDivElement } {
|
|
89
|
+
if (popoverEl && popoverInner) return { el: popoverEl, inner: popoverInner };
|
|
90
|
+
popoverEl = document.createElement('div');
|
|
91
|
+
popoverEl.className = 'popover';
|
|
92
|
+
popoverEl.setAttribute('role', 'tooltip');
|
|
93
|
+
popoverInner = document.createElement('div');
|
|
94
|
+
popoverInner.className = 'popover-inner';
|
|
95
|
+
popoverEl.appendChild(popoverInner);
|
|
96
|
+
// Keep the card open while the pointer is over it (scrollable preview).
|
|
97
|
+
popoverEl.addEventListener('mouseenter', cancelHide);
|
|
98
|
+
popoverEl.addEventListener('mouseleave', scheduleHide);
|
|
99
|
+
document.body.appendChild(popoverEl);
|
|
100
|
+
return { el: popoverEl, inner: popoverInner };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function hide(): void {
|
|
104
|
+
generation++;
|
|
105
|
+
clearTimeout(showTimer);
|
|
106
|
+
clearTimeout(hideTimer);
|
|
107
|
+
popoverEl?.classList.remove('active-popover');
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function scheduleHide(): void {
|
|
111
|
+
clearTimeout(showTimer);
|
|
112
|
+
clearTimeout(hideTimer);
|
|
113
|
+
hideTimer = setTimeout(hide, HIDE_DELAY_MS);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function cancelHide(): void {
|
|
117
|
+
clearTimeout(hideTimer);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function show(link: HTMLAnchorElement, coords: { x: number; y: number }) {
|
|
121
|
+
const myGeneration = ++generation;
|
|
122
|
+
|
|
123
|
+
const targetUrl = new URL(link.href);
|
|
124
|
+
if (targetUrl.origin !== window.location.origin) return;
|
|
125
|
+
const hash = decodeURIComponent(targetUrl.hash);
|
|
126
|
+
targetUrl.hash = '';
|
|
127
|
+
targetUrl.search = '';
|
|
128
|
+
|
|
129
|
+
const cacheKey = targetUrl.pathname;
|
|
130
|
+
let pending = contentCache.get(cacheKey);
|
|
131
|
+
if (!pending) {
|
|
132
|
+
pending = fetchContent(targetUrl);
|
|
133
|
+
contentCache.set(cacheKey, pending);
|
|
134
|
+
// Don't poison the cache on transient network failure.
|
|
135
|
+
pending.then((content) => {
|
|
136
|
+
if (content === null) contentCache.delete(cacheKey);
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const content = await pending;
|
|
141
|
+
if (content === null || myGeneration !== generation) return;
|
|
142
|
+
|
|
143
|
+
const { el, inner } = ensurePopover();
|
|
144
|
+
inner.replaceChildren();
|
|
145
|
+
inner.scrollTop = 0;
|
|
146
|
+
inner.dataset.contentType = content.contentType;
|
|
147
|
+
|
|
148
|
+
switch (content.kind) {
|
|
149
|
+
case 'image': {
|
|
150
|
+
const img = document.createElement('img');
|
|
151
|
+
img.src = content.src;
|
|
152
|
+
img.alt = new URL(content.src).pathname;
|
|
153
|
+
inner.className = 'popover-inner';
|
|
154
|
+
inner.appendChild(img);
|
|
155
|
+
break;
|
|
156
|
+
}
|
|
157
|
+
case 'pdf': {
|
|
158
|
+
const iframe = document.createElement('iframe');
|
|
159
|
+
iframe.src = content.src;
|
|
160
|
+
iframe.title = new URL(content.src).pathname;
|
|
161
|
+
inner.className = 'popover-inner';
|
|
162
|
+
inner.appendChild(iframe);
|
|
163
|
+
break;
|
|
164
|
+
}
|
|
165
|
+
case 'html': {
|
|
166
|
+
inner.className = 'popover-inner prose prose-sm dark:prose-invert';
|
|
167
|
+
// Clone so the cached tree is never mutated by the live DOM.
|
|
168
|
+
inner.appendChild(content.element.cloneNode(true));
|
|
169
|
+
break;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const { x, y } = await computePosition(link, el, {
|
|
174
|
+
strategy: 'fixed',
|
|
175
|
+
middleware: [inline({ x: coords.x, y: coords.y }), shift({ padding: 12 }), flip()]
|
|
176
|
+
});
|
|
177
|
+
if (myGeneration !== generation) return;
|
|
178
|
+
el.style.transform = `translate(${x.toFixed()}px, ${y.toFixed()}px)`;
|
|
179
|
+
el.classList.add('active-popover');
|
|
180
|
+
|
|
181
|
+
if (hash !== '' && content.kind === 'html') {
|
|
182
|
+
const heading = inner.querySelector<HTMLElement>(
|
|
183
|
+
`#popover-internal-${CSS.escape(hash.slice(1))}`
|
|
184
|
+
);
|
|
185
|
+
if (heading) {
|
|
186
|
+
inner.scroll({ top: heading.offsetTop - ANCHOR_SCROLL_OFFSET, behavior: 'instant' });
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const onKeydown = (event: KeyboardEvent) => {
|
|
192
|
+
if (event.key === 'Escape' && popoverEl?.classList.contains('active-popover')) {
|
|
193
|
+
hide();
|
|
194
|
+
}
|
|
195
|
+
};
|
|
196
|
+
document.addEventListener('keydown', onKeydown);
|
|
197
|
+
|
|
198
|
+
const links = Array.from(node.querySelectorAll<HTMLAnchorElement>('a.internal')).filter(
|
|
199
|
+
(link) => link.dataset.noPopover !== 'true'
|
|
200
|
+
);
|
|
201
|
+
const cleanups = links.map((link) => {
|
|
202
|
+
const onEnter = (event: MouseEvent) => {
|
|
203
|
+
cancelHide();
|
|
204
|
+
clearTimeout(showTimer);
|
|
205
|
+
showTimer = setTimeout(
|
|
206
|
+
() => void show(link, { x: event.clientX, y: event.clientY }),
|
|
207
|
+
SHOW_DELAY_MS
|
|
208
|
+
);
|
|
209
|
+
};
|
|
210
|
+
const onLeave = () => {
|
|
211
|
+
clearTimeout(showTimer);
|
|
212
|
+
scheduleHide();
|
|
213
|
+
};
|
|
214
|
+
link.addEventListener('mouseenter', onEnter);
|
|
215
|
+
link.addEventListener('mouseleave', onLeave);
|
|
216
|
+
return () => {
|
|
217
|
+
link.removeEventListener('mouseenter', onEnter);
|
|
218
|
+
link.removeEventListener('mouseleave', onLeave);
|
|
219
|
+
};
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
return () => {
|
|
223
|
+
generation++;
|
|
224
|
+
clearTimeout(showTimer);
|
|
225
|
+
clearTimeout(hideTimer);
|
|
226
|
+
document.removeEventListener('keydown', onKeydown);
|
|
227
|
+
cleanups.forEach((fn) => fn());
|
|
228
|
+
popoverEl?.remove();
|
|
229
|
+
popoverEl = null;
|
|
230
|
+
popoverInner = null;
|
|
231
|
+
};
|
|
232
|
+
};
|
|
233
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/* Fixed positioning keeps previews outside the article layout. */
|
|
2
|
+
|
|
3
|
+
.popover {
|
|
4
|
+
position: fixed;
|
|
5
|
+
top: 0;
|
|
6
|
+
left: 0;
|
|
7
|
+
z-index: 60;
|
|
8
|
+
overflow: visible;
|
|
9
|
+
padding: 0.75rem; /* hover bridge between the anchor and the card */
|
|
10
|
+
will-change: transform;
|
|
11
|
+
visibility: hidden;
|
|
12
|
+
opacity: 0;
|
|
13
|
+
transition:
|
|
14
|
+
opacity 0.2s ease,
|
|
15
|
+
visibility 0.2s ease;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
.popover.active-popover {
|
|
19
|
+
visibility: visible;
|
|
20
|
+
opacity: 1;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
.popover > .popover-inner {
|
|
24
|
+
position: relative;
|
|
25
|
+
width: 28rem;
|
|
26
|
+
max-width: calc(100vw - 2rem);
|
|
27
|
+
max-height: 20rem;
|
|
28
|
+
padding: 0.25rem 1rem 1rem;
|
|
29
|
+
overflow: auto;
|
|
30
|
+
overscroll-behavior: contain;
|
|
31
|
+
white-space: normal;
|
|
32
|
+
font-family: var(--font-sans);
|
|
33
|
+
font-weight: initial;
|
|
34
|
+
font-style: initial;
|
|
35
|
+
line-height: 1.55;
|
|
36
|
+
border: 1px solid var(--color-border);
|
|
37
|
+
background-color: var(--color-bg);
|
|
38
|
+
border-radius: 0.5rem;
|
|
39
|
+
box-shadow:
|
|
40
|
+
0 4px 12px rgb(0 0 0 / 0.08),
|
|
41
|
+
0 12px 32px rgb(0 0 0 / 0.12);
|
|
42
|
+
cursor: default;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
:root.dark .popover > .popover-inner {
|
|
46
|
+
box-shadow:
|
|
47
|
+
0 4px 12px rgb(0 0 0 / 0.35),
|
|
48
|
+
0 12px 32px rgb(0 0 0 / 0.45);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
.popover > .popover-inner h1 {
|
|
52
|
+
font-size: 1.25rem;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/* Asset previews (internal links can target images / PDFs). */
|
|
56
|
+
.popover > .popover-inner[data-content-type*='image'],
|
|
57
|
+
.popover > .popover-inner[data-content-type*='pdf'] {
|
|
58
|
+
padding: 0;
|
|
59
|
+
max-height: 100%;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
.popover > .popover-inner[data-content-type*='image'] img {
|
|
63
|
+
margin: 0;
|
|
64
|
+
display: block;
|
|
65
|
+
border-radius: 0.5rem;
|
|
66
|
+
max-width: 100%;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
.popover > .popover-inner[data-content-type*='pdf'] iframe {
|
|
70
|
+
display: block;
|
|
71
|
+
width: 100%;
|
|
72
|
+
height: 20rem;
|
|
73
|
+
border: 0;
|
|
74
|
+
border-radius: 0.5rem;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/* Touch / coarse-pointer devices: popovers are disabled in JS too, this is a belt. */
|
|
78
|
+
@media (hover: none), (pointer: coarse) {
|
|
79
|
+
.popover {
|
|
80
|
+
display: none !important;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { onMount } from 'svelte';
|
|
3
|
+
import Command from '@lucide/svelte/icons/command';
|
|
4
|
+
import Search from '@lucide/svelte/icons/search';
|
|
5
|
+
import type { ClassValue } from 'svelte/elements';
|
|
6
|
+
import { cn } from '$lib/utils';
|
|
7
|
+
import IconButton from '../ui/IconButton.svelte';
|
|
8
|
+
import { openSearch } from './search-state.svelte';
|
|
9
|
+
|
|
10
|
+
interface Props {
|
|
11
|
+
variant?: 'sidebar' | 'icon';
|
|
12
|
+
class?: ClassValue;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const { variant = 'sidebar', class: className }: Props = $props();
|
|
16
|
+
|
|
17
|
+
// Resolved after mount so SSR output is deterministic (no hydration mismatch).
|
|
18
|
+
let isMac = $state(false);
|
|
19
|
+
let ready = $state(false);
|
|
20
|
+
onMount(() => {
|
|
21
|
+
isMac = /Mac|iP(hone|ad|od)/.test(navigator.platform);
|
|
22
|
+
ready = true;
|
|
23
|
+
});
|
|
24
|
+
</script>
|
|
25
|
+
|
|
26
|
+
{#if variant === 'icon'}
|
|
27
|
+
<IconButton
|
|
28
|
+
aria-label="Search"
|
|
29
|
+
title="Search"
|
|
30
|
+
class={cn('size-8 rounded-md border border-border bg-transparent text-text', className)}
|
|
31
|
+
onclick={openSearch}
|
|
32
|
+
>
|
|
33
|
+
<Search class="size-4" />
|
|
34
|
+
</IconButton>
|
|
35
|
+
{:else}
|
|
36
|
+
<button
|
|
37
|
+
type="button"
|
|
38
|
+
class={cn(
|
|
39
|
+
'flex w-full cursor-pointer items-center gap-2 rounded-md border border-border bg-transparent px-2.5 py-1.5 text-sm text-text/60 transition-colors hover:bg-surface focus-visible:ring-2 focus-visible:ring-accent focus-visible:outline-none',
|
|
40
|
+
className
|
|
41
|
+
)}
|
|
42
|
+
onclick={openSearch}
|
|
43
|
+
disabled={!ready}
|
|
44
|
+
>
|
|
45
|
+
<Search class="size-4" />
|
|
46
|
+
<span>Search</span>
|
|
47
|
+
<kbd
|
|
48
|
+
class="ml-auto rounded border border-border px-1.5 py-0.5 font-sans text-[0.65rem] text-text/60"
|
|
49
|
+
>
|
|
50
|
+
<span class="sr-only">{isMac ? 'Command K' : 'Ctrl K'}</span>
|
|
51
|
+
<span class="flex items-center gap-0.5" aria-hidden="true">
|
|
52
|
+
{#if isMac}<Command class="size-3" />{:else}Ctrl{/if}
|
|
53
|
+
<span>K</span>
|
|
54
|
+
</span>
|
|
55
|
+
</kbd>
|
|
56
|
+
</button>
|
|
57
|
+
{/if}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { Command, Dialog } from 'bits-ui';
|
|
3
|
+
import { goto } from '$app/navigation';
|
|
4
|
+
import Search from '@lucide/svelte/icons/search';
|
|
5
|
+
import {
|
|
6
|
+
ensureIndex,
|
|
7
|
+
resetIndex,
|
|
8
|
+
searchClient,
|
|
9
|
+
searchPagefind,
|
|
10
|
+
type SearchGroup
|
|
11
|
+
} from './pagefind-client.svelte';
|
|
12
|
+
import { parseSearchQuery } from './pagefind-query';
|
|
13
|
+
import { closeSearch, searchState, toggleSearch } from './search-state.svelte';
|
|
14
|
+
|
|
15
|
+
let query = $state('');
|
|
16
|
+
let course = $state('');
|
|
17
|
+
let tag = $state('');
|
|
18
|
+
let groups = $state<SearchGroup[]>([]);
|
|
19
|
+
|
|
20
|
+
const courseOptions = $derived(Object.entries(searchClient.filters.course ?? {}).sort());
|
|
21
|
+
const tagOptions = $derived(Object.entries(searchClient.filters.tag ?? {}).sort());
|
|
22
|
+
const parsedQuery = $derived(parseSearchQuery(query));
|
|
23
|
+
|
|
24
|
+
const activeFilters = $derived.by(() => {
|
|
25
|
+
const f: Record<string, string[]> = {};
|
|
26
|
+
if (course) f.course = [course];
|
|
27
|
+
if (parsedQuery.tag) f.tag = [parsedQuery.tag];
|
|
28
|
+
else if (tag) f.tag = [tag];
|
|
29
|
+
return f;
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
$effect(() => {
|
|
33
|
+
const parsed = parsedQuery;
|
|
34
|
+
const filters = activeFilters;
|
|
35
|
+
if (searchClient.status !== 'ready' || (parsed.term !== null && parsed.term.length < 2)) {
|
|
36
|
+
groups = [];
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
void searchPagefind(parsed.term, filters).then((out) => {
|
|
40
|
+
// null = superseded by a newer keystroke; keep current results.
|
|
41
|
+
if (out !== null) groups = out;
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
async function navigate(url: string) {
|
|
46
|
+
closeSearch();
|
|
47
|
+
// Heading ids are baked into the prerendered HTML, so a plain goto
|
|
48
|
+
// lands on the section anchor directly.
|
|
49
|
+
await goto(url);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function isEditable(target: EventTarget | null): boolean {
|
|
53
|
+
if (!(target instanceof HTMLElement)) return false;
|
|
54
|
+
return (
|
|
55
|
+
target instanceof HTMLInputElement ||
|
|
56
|
+
target instanceof HTMLTextAreaElement ||
|
|
57
|
+
target instanceof HTMLSelectElement ||
|
|
58
|
+
target.isContentEditable
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function onKeydown(event: KeyboardEvent) {
|
|
63
|
+
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k') {
|
|
64
|
+
event.preventDefault();
|
|
65
|
+
toggleSearch();
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
if (
|
|
69
|
+
event.key === '/' &&
|
|
70
|
+
!event.metaKey &&
|
|
71
|
+
!event.ctrlKey &&
|
|
72
|
+
!event.altKey &&
|
|
73
|
+
!searchState.open &&
|
|
74
|
+
!isEditable(event.target)
|
|
75
|
+
) {
|
|
76
|
+
event.preventDefault();
|
|
77
|
+
toggleSearch();
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function retry() {
|
|
82
|
+
resetIndex();
|
|
83
|
+
void ensureIndex();
|
|
84
|
+
}
|
|
85
|
+
</script>
|
|
86
|
+
|
|
87
|
+
<svelte:window onkeydown={onKeydown} />
|
|
88
|
+
|
|
89
|
+
<Dialog.Root bind:open={searchState.open}>
|
|
90
|
+
<Dialog.Portal>
|
|
91
|
+
<Dialog.Overlay class="fixed inset-0 z-50 bg-black/40" />
|
|
92
|
+
<Dialog.Content
|
|
93
|
+
class="fixed top-[15vh] left-1/2 z-50 w-full max-w-xl -translate-x-1/2 overflow-hidden rounded-lg border border-border bg-bg shadow-2xl"
|
|
94
|
+
>
|
|
95
|
+
<Dialog.Title class="sr-only">Search</Dialog.Title>
|
|
96
|
+
<Command.Root shouldFilter={false} label="Search site">
|
|
97
|
+
<div class="flex items-center gap-2 border-b border-border px-3">
|
|
98
|
+
<Search class="size-4 shrink-0 text-text/60" />
|
|
99
|
+
<Command.Input
|
|
100
|
+
bind:value={query}
|
|
101
|
+
placeholder="Search… or #tag"
|
|
102
|
+
class="h-11 w-full bg-transparent text-sm text-text outline-none placeholder:text-text/50"
|
|
103
|
+
/>
|
|
104
|
+
</div>
|
|
105
|
+
{#if searchClient.status === 'ready' && (courseOptions.length > 0 || tagOptions.length > 0)}
|
|
106
|
+
<div class="flex items-center gap-2 border-b border-border px-3 py-2">
|
|
107
|
+
{#if courseOptions.length > 0}
|
|
108
|
+
<select
|
|
109
|
+
bind:value={course}
|
|
110
|
+
aria-label="Filter by course"
|
|
111
|
+
class="max-w-[50%] cursor-pointer rounded-md border border-border bg-bg px-2 py-1 text-xs text-text/80 outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
|
112
|
+
>
|
|
113
|
+
<option value="">All courses</option>
|
|
114
|
+
{#each courseOptions as [value, count] (value)}
|
|
115
|
+
<option {value}>{value} ({count})</option>
|
|
116
|
+
{/each}
|
|
117
|
+
</select>
|
|
118
|
+
{/if}
|
|
119
|
+
{#if tagOptions.length > 0}
|
|
120
|
+
<select
|
|
121
|
+
bind:value={tag}
|
|
122
|
+
aria-label="Filter by tag"
|
|
123
|
+
class="max-w-[50%] cursor-pointer rounded-md border border-border bg-bg px-2 py-1 text-xs text-text/80 outline-none focus-visible:ring-2 focus-visible:ring-accent"
|
|
124
|
+
>
|
|
125
|
+
<option value="">All tags</option>
|
|
126
|
+
{#each tagOptions as [value, count] (value)}
|
|
127
|
+
<option {value}>{value} ({count})</option>
|
|
128
|
+
{/each}
|
|
129
|
+
</select>
|
|
130
|
+
{/if}
|
|
131
|
+
</div>
|
|
132
|
+
{/if}
|
|
133
|
+
<Command.List class="max-h-[60vh] overflow-y-auto p-2">
|
|
134
|
+
{#if searchClient.status === 'loading' || searchClient.status === 'idle'}
|
|
135
|
+
<div class="block px-3 py-6 text-center text-sm text-text/60">Loading index…</div>
|
|
136
|
+
{:else if searchClient.status === 'missing'}
|
|
137
|
+
<div
|
|
138
|
+
class="flex flex-col items-center gap-2 px-3 py-6 text-center text-sm text-text/60"
|
|
139
|
+
>
|
|
140
|
+
{#if import.meta.env.DEV}
|
|
141
|
+
<span>No search index in dev — Pagefind indexes the built site.</span>
|
|
142
|
+
<code class="rounded-sm bg-surface px-1.5 py-0.5 text-xs">bun run build</code>
|
|
143
|
+
{:else}
|
|
144
|
+
<span>Couldn't load the search index.</span>
|
|
145
|
+
<button
|
|
146
|
+
type="button"
|
|
147
|
+
class="cursor-pointer rounded-md border border-border px-2.5 py-1 text-text transition-colors hover:bg-surface"
|
|
148
|
+
onclick={retry}
|
|
149
|
+
>
|
|
150
|
+
Retry
|
|
151
|
+
</button>
|
|
152
|
+
{/if}
|
|
153
|
+
</div>
|
|
154
|
+
{:else}
|
|
155
|
+
<Command.Empty class="block px-3 py-6 text-center text-sm text-text/60">
|
|
156
|
+
{parsedQuery.term !== null && parsedQuery.term.length < 2
|
|
157
|
+
? 'Type at least 2 characters…'
|
|
158
|
+
: 'No results'}
|
|
159
|
+
</Command.Empty>
|
|
160
|
+
{#each groups as group (group.id)}
|
|
161
|
+
<Command.Group value={group.id}>
|
|
162
|
+
<Command.GroupHeading class="px-3 pt-3 pb-1 text-xs font-semibold text-text/60">
|
|
163
|
+
{group.title}
|
|
164
|
+
</Command.GroupHeading>
|
|
165
|
+
<Command.GroupItems>
|
|
166
|
+
{#each group.sections as section, i (`${group.id}:${i}`)}
|
|
167
|
+
<Command.Item
|
|
168
|
+
value={`${group.id}:${i}`}
|
|
169
|
+
onSelect={() => navigate(section.url)}
|
|
170
|
+
class="flex cursor-pointer flex-col gap-0.5 rounded-md px-3 py-2 data-selected:bg-surface"
|
|
171
|
+
>
|
|
172
|
+
<span class="text-sm text-text">{section.title}</span>
|
|
173
|
+
<span
|
|
174
|
+
class="truncate text-xs text-text/60 [&_mark]:rounded-sm [&_mark]:bg-accent/20 [&_mark]:text-text"
|
|
175
|
+
>
|
|
176
|
+
<!-- eslint-disable-next-line svelte/no-at-html-tags -- Pagefind excerpt over our own prerendered corpus -->
|
|
177
|
+
{@html section.excerpt}
|
|
178
|
+
</span>
|
|
179
|
+
</Command.Item>
|
|
180
|
+
{/each}
|
|
181
|
+
</Command.GroupItems>
|
|
182
|
+
</Command.Group>
|
|
183
|
+
{/each}
|
|
184
|
+
{/if}
|
|
185
|
+
</Command.List>
|
|
186
|
+
</Command.Root>
|
|
187
|
+
</Dialog.Content>
|
|
188
|
+
</Dialog.Portal>
|
|
189
|
+
</Dialog.Root>
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { cleanResultUrl } from './pagefind-url';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Lazy Pagefind client (cca's `search-client.svelte.ts` rewired from
|
|
5
|
+
* minisearch to Pagefind's chunked static index).
|
|
6
|
+
*
|
|
7
|
+
* The index is produced AFTER `vite build` by the deployment adapter
|
|
8
|
+
* (`pagefind --site build/.svelte-kit/cloudflare`), which drops a self-contained
|
|
9
|
+
* `/pagefind/` bundle into the deployed static assets. We lazy-import its JS
|
|
10
|
+
* API on first palette open, so search costs nothing until used. The Vite dev
|
|
11
|
+
* server never has that bundle — `status: 'missing'` drives the dev fallback
|
|
12
|
+
* message in the palette.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/* Minimal typings for the Pagefind browser API (no published types). */
|
|
16
|
+
interface PagefindSubResult {
|
|
17
|
+
title: string;
|
|
18
|
+
url: string;
|
|
19
|
+
excerpt: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface PagefindFragment {
|
|
23
|
+
url: string;
|
|
24
|
+
excerpt: string;
|
|
25
|
+
meta: Record<string, string>;
|
|
26
|
+
sub_results: PagefindSubResult[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface PagefindRawResult {
|
|
30
|
+
id: string;
|
|
31
|
+
data(): Promise<PagefindFragment>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface PagefindModule {
|
|
35
|
+
init(): Promise<void>;
|
|
36
|
+
options(opts: Record<string, unknown>): Promise<void>;
|
|
37
|
+
filters(): Promise<Record<string, Record<string, number>>>;
|
|
38
|
+
search(
|
|
39
|
+
query: string | null,
|
|
40
|
+
options?: { filters?: Record<string, string[]> }
|
|
41
|
+
): Promise<{ results: PagefindRawResult[] }>;
|
|
42
|
+
debouncedSearch(
|
|
43
|
+
query: string,
|
|
44
|
+
options?: { filters?: Record<string, string[]> },
|
|
45
|
+
debounceMs?: number
|
|
46
|
+
): Promise<{ results: PagefindRawResult[] } | null>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** One section-level hit (deep link) within a page. */
|
|
50
|
+
export interface SearchSection {
|
|
51
|
+
title: string;
|
|
52
|
+
url: string;
|
|
53
|
+
/** Pagefind-generated excerpt HTML (`<mark>` around matched terms). */
|
|
54
|
+
excerpt: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Search hits grouped by page and section. */
|
|
58
|
+
export interface SearchGroup {
|
|
59
|
+
id: string;
|
|
60
|
+
title: string;
|
|
61
|
+
url: string;
|
|
62
|
+
sections: SearchSection[];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export const searchClient = $state<{
|
|
66
|
+
status: 'idle' | 'loading' | 'ready' | 'missing';
|
|
67
|
+
/** filter name -> value -> result count, from the built index. */
|
|
68
|
+
filters: Record<string, Record<string, number>>;
|
|
69
|
+
}>({ status: 'idle', filters: {} });
|
|
70
|
+
|
|
71
|
+
let pagefind: PagefindModule | undefined;
|
|
72
|
+
|
|
73
|
+
export async function ensureIndex(): Promise<void> {
|
|
74
|
+
if (searchClient.status === 'ready' || searchClient.status === 'loading') return;
|
|
75
|
+
searchClient.status = 'loading';
|
|
76
|
+
try {
|
|
77
|
+
// Runtime URL import — kept out of Vite's module graph on purpose.
|
|
78
|
+
const bundleUrl = '/pagefind/pagefind.js';
|
|
79
|
+
pagefind = (await import(/* @vite-ignore */ bundleUrl)) as PagefindModule;
|
|
80
|
+
await pagefind.init();
|
|
81
|
+
searchClient.filters = await pagefind.filters();
|
|
82
|
+
searchClient.status = 'ready';
|
|
83
|
+
} catch {
|
|
84
|
+
pagefind = undefined;
|
|
85
|
+
searchClient.status = 'missing';
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Allow the palette's Retry button to re-attempt a failed load. */
|
|
90
|
+
export function resetIndex(): void {
|
|
91
|
+
if (searchClient.status === 'missing') searchClient.status = 'idle';
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const PAGE_LIMIT = 10;
|
|
95
|
+
const SECTIONS_PER_PAGE = 4;
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Run a debounced Pagefind query. Returns `null` when this call was
|
|
99
|
+
* superseded by a newer keystroke (Pagefind's debouncedSearch contract) —
|
|
100
|
+
* callers must keep previous results in that case.
|
|
101
|
+
*/
|
|
102
|
+
export async function searchPagefind(
|
|
103
|
+
query: string | null,
|
|
104
|
+
filters: Record<string, string[]>
|
|
105
|
+
): Promise<SearchGroup[] | null> {
|
|
106
|
+
if (!pagefind) return [];
|
|
107
|
+
const opts = Object.keys(filters).length > 0 ? { filters } : undefined;
|
|
108
|
+
const res =
|
|
109
|
+
query === null
|
|
110
|
+
? await pagefind.search(null, opts)
|
|
111
|
+
: await pagefind.debouncedSearch(query, opts, 120);
|
|
112
|
+
if (res === null) return null;
|
|
113
|
+
|
|
114
|
+
const top = res.results.slice(0, PAGE_LIMIT);
|
|
115
|
+
const fragments = await Promise.all(top.map((r) => r.data()));
|
|
116
|
+
return fragments.map((frag, i) => {
|
|
117
|
+
const url = cleanResultUrl(frag.url);
|
|
118
|
+
const title = frag.meta?.title || url;
|
|
119
|
+
let sections: SearchSection[] = (frag.sub_results ?? [])
|
|
120
|
+
.slice(0, SECTIONS_PER_PAGE)
|
|
121
|
+
.map((sub) => ({
|
|
122
|
+
title: sub.title || title,
|
|
123
|
+
url: cleanResultUrl(sub.url),
|
|
124
|
+
excerpt: sub.excerpt
|
|
125
|
+
}));
|
|
126
|
+
if (sections.length === 0) sections = [{ title, url, excerpt: frag.excerpt }];
|
|
127
|
+
return { id: top[i].id, title, url, sections };
|
|
128
|
+
});
|
|
129
|
+
}
|