Skip to content

Commit de02c9a

Browse files
committed
feat(examples): lazy load demo code references
- Allow CodeReferenceV2 samples to load code payloads after hydration - Add split demo manifest output with virtual demo modules - Export split manifest types for docs consumers
1 parent e1198d9 commit de02c9a

5 files changed

Lines changed: 273 additions & 15 deletions

File tree

src/lib/components/CodeReferenceV2.svelte

Lines changed: 170 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -36,14 +36,9 @@
3636
import { AnimatePresence, MotionButton, MotionSpan } from '@humanspeak/svelte-motion'
3737
import CheckIcon from '@lucide/svelte/icons/check'
3838
import CopyIcon from '@lucide/svelte/icons/copy'
39-
import { onDestroy } from 'svelte'
39+
import { onDestroy, onMount } from 'svelte'
4040
41-
interface CodeSample {
42-
/** Stable identifier — surfaces as the small-caps tag on the left of
43-
* the header strip. */
44-
id: string
45-
/** Display title rendered as the header's `<h3>`. */
46-
label: string
41+
interface CodeSamplePayload {
4742
/** The raw code text. Used for the copy button and as the plain-text
4843
* fallback when `html` is not provided. */
4944
code: string
@@ -56,15 +51,51 @@
5651
html?: { light: string; dark: string }
5752
}
5853
54+
interface EagerCodeSample extends CodeSamplePayload {
55+
/** Stable identifier — surfaces as the small-caps tag on the left of
56+
* the header strip. */
57+
id: string
58+
/** Display title rendered as the header's `<h3>`. */
59+
label: string
60+
}
61+
62+
interface LazyCodeSample {
63+
/** Stable identifier — surfaces as the small-caps tag on the left of
64+
* the header strip. */
65+
id: string
66+
/** Display title rendered as the header's `<h3>`. */
67+
label: string
68+
/** Load the raw/highlighted code on demand. Supports both a plain
69+
* payload and an ESM module default export so consumers can pass
70+
* `() => import('virtual:docs-kit/demo/...')` directly. */
71+
load: () => Promise<CodeSamplePayload | { default: CodeSamplePayload }>
72+
/** Override component-level lazy preloading for this sample. */
73+
preload?: 'idle' | false
74+
}
75+
76+
type CodeSample = EagerCodeSample | LazyCodeSample
77+
type IdleRuntime = typeof globalThis & {
78+
requestIdleCallback?: (callback: () => void) => number
79+
cancelIdleCallback?: (id: number) => void
80+
}
81+
82+
function isLazySample(sample: CodeSample): sample is LazyCodeSample {
83+
return 'load' in sample
84+
}
85+
5986
interface Props {
6087
samples: CodeSample[]
6188
/** Columns at desktop width. Default `auto`, which uses the number of
6289
* samples up to 3 (so 1 sample = 1 col, 4 samples = still 3 cols
6390
* wrapping). Pass an integer to force a specific column count. */
6491
columns?: number | 'auto'
92+
/** Lazy sample preload strategy. `idle` fetches after hydration so
93+
* code drawers are warm without blocking the initial page. Eager
94+
* samples are unaffected. */
95+
preload?: 'idle' | false
6596
}
6697
67-
const { samples, columns = 'auto' }: Props = $props()
98+
const { samples, columns = 'auto', preload = 'idle' }: Props = $props()
6899
69100
const colCount = $derived(
70101
columns === 'auto' ? Math.min(samples.length, 3) : Math.max(1, columns)
@@ -75,6 +106,11 @@
75106
// show "copied" independently if the user clicks through them quickly.
76107
let copiedId = $state<string | null>(null)
77108
let copyResetTimer: ReturnType<typeof setTimeout> | null = null
109+
let loadedById = $state<Record<string, CodeSamplePayload>>({})
110+
let loadingById = $state<Record<string, boolean>>({})
111+
let errorById = $state<Record<string, boolean>>({})
112+
const pendingById = new Map<string, Promise<CodeSamplePayload | null>>()
113+
const cancelPreloads: Array<() => void> = []
78114
79115
const showCopyFeedback = (sampleId: string) => {
80116
copiedId = sampleId
@@ -85,18 +121,88 @@
85121
}, 1600)
86122
}
87123
124+
const getPayload = (sample: CodeSample): CodeSamplePayload | null => {
125+
if (!isLazySample(sample)) return sample
126+
return loadedById[sample.id] ?? null
127+
}
128+
129+
const unwrapPayload = (
130+
payload: CodeSamplePayload | { default: CodeSamplePayload }
131+
): CodeSamplePayload => ('default' in payload ? payload.default : payload)
132+
133+
const loadSample = async (sample: CodeSample): Promise<CodeSamplePayload | null> => {
134+
if (!isLazySample(sample)) return sample
135+
136+
const cached = loadedById[sample.id]
137+
if (cached) return cached
138+
139+
const pending = pendingById.get(sample.id)
140+
if (pending) return pending
141+
142+
loadingById = { ...loadingById, [sample.id]: true }
143+
errorById = { ...errorById, [sample.id]: false }
144+
145+
const next = sample
146+
.load()
147+
.then((payload) => {
148+
const loaded = unwrapPayload(payload)
149+
loadedById = { ...loadedById, [sample.id]: loaded }
150+
return loaded
151+
})
152+
.catch(() => {
153+
errorById = { ...errorById, [sample.id]: true }
154+
return null
155+
})
156+
.finally(() => {
157+
loadingById = { ...loadingById, [sample.id]: false }
158+
pendingById.delete(sample.id)
159+
})
160+
161+
pendingById.set(sample.id, next)
162+
return next
163+
}
164+
165+
const scheduleIdlePreload = (sample: LazyCodeSample) => {
166+
const runtime = globalThis as IdleRuntime
167+
168+
const run = () => {
169+
void loadSample(sample)
170+
}
171+
172+
if (runtime.requestIdleCallback && runtime.cancelIdleCallback) {
173+
const id = runtime.requestIdleCallback(run)
174+
cancelPreloads.push(() => runtime.cancelIdleCallback?.(id))
175+
return
176+
}
177+
178+
const id = globalThis.setTimeout(run, 250)
179+
cancelPreloads.push(() => globalThis.clearTimeout(id))
180+
}
181+
88182
const copy = async (sample: CodeSample) => {
183+
const payload = await loadSample(sample)
184+
if (!payload) return
185+
89186
showCopyFeedback(sample.id)
90187
if (typeof navigator === 'undefined' || !navigator.clipboard) return
91188
try {
92-
await navigator.clipboard.writeText(sample.code)
189+
await navigator.clipboard.writeText(payload.code)
93190
} catch {
94191
/* clipboard blocked — fail quiet, the user can select + copy */
95192
}
96193
}
97194
195+
onMount(() => {
196+
for (const sample of samples) {
197+
if (!isLazySample(sample)) continue
198+
const samplePreload = sample.preload ?? preload
199+
if (samplePreload === 'idle') scheduleIdlePreload(sample)
200+
}
201+
})
202+
98203
onDestroy(() => {
99204
if (copyResetTimer) clearTimeout(copyResetTimer)
205+
for (const cancel of cancelPreloads) cancel()
100206
})
101207
102208
const copyPress = { scale: 0.96 }
@@ -106,6 +212,9 @@
106212

107213
<div class="dk-coderef" style="--dk-coderef-cols: {colCount}">
108214
{#each samples as sample (sample.id)}
215+
{@const payload = getPayload(sample)}
216+
{@const isLoading = Boolean(loadingById[sample.id])}
217+
{@const hasError = Boolean(errorById[sample.id])}
109218
<article class="dk-coderef-cell">
110219
<header class="dk-coderef-head">
111220
<div class="dk-coderef-meta">
@@ -119,6 +228,8 @@
119228
onclick={() => copy(sample)}
120229
whileTap={copyPress}
121230
whileHover={copyHover}
231+
disabled={isLoading}
232+
aria-busy={isLoading}
122233
>
123234
<AnimatePresence initial={false}>
124235
{#if copiedId === sample.id}
@@ -133,6 +244,17 @@
133244
<CheckIcon size={11} />
134245
<span>copied</span>
135246
</MotionSpan>
247+
{:else if isLoading}
248+
<MotionSpan
249+
key="loading"
250+
class="dk-coderef-copy-state loading-state"
251+
initial={{ opacity: 1, y: 0 }}
252+
animate={{ opacity: 1, y: 0 }}
253+
exit={{ opacity: 0, y: 0 }}
254+
transition={copyStateTransition}
255+
>
256+
<span>loading</span>
257+
</MotionSpan>
136258
{:else}
137259
<MotionSpan
138260
key="copy"
@@ -150,13 +272,23 @@
150272
</MotionButton>
151273
</header>
152274
<div class="dk-coderef-code">
153-
{#if sample.html}
275+
{#if payload?.html}
154276
<!-- eslint-disable-next-line svelte/no-at-html-tags -- shiki-rendered HTML supplied by the consumer; treat as trusted input -->
155-
<div class="shiki-light">{@html sample.html.light}</div>
277+
<div class="shiki-light">{@html payload.html.light}</div>
156278
<!-- eslint-disable-next-line svelte/no-at-html-tags -- shiki-rendered HTML supplied by the consumer; treat as trusted input -->
157-
<div class="shiki-dark">{@html sample.html.dark}</div>
279+
<div class="shiki-dark">{@html payload.html.dark}</div>
280+
{:else if payload}
281+
<pre><code>{payload.code}</code></pre>
282+
{:else if hasError}
283+
<div class="dk-coderef-placeholder">
284+
<strong>failed to load code</strong>
285+
<span>try reopening the panel</span>
286+
</div>
158287
{:else}
159-
<pre><code>{sample.code}</code></pre>
288+
<div class="dk-coderef-placeholder">
289+
<strong>loading code</strong>
290+
<span>fetching the highlighted snippet</span>
291+
</div>
160292
{/if}
161293
</div>
162294
</article>
@@ -251,6 +383,10 @@
251383
color-mix(in srgb, var(--brut-accent) 10%, transparent)
252384
);
253385
}
386+
.dk-coderef :global(.dk-coderef-copy:disabled) {
387+
cursor: progress;
388+
opacity: 0.72;
389+
}
254390
.dk-coderef :global(.dk-coderef-copy-state) {
255391
display: inline-flex;
256392
align-items: center;
@@ -267,6 +403,9 @@
267403
.dk-coderef :global(.dk-coderef-copy-state.copied-state) {
268404
color: var(--brut-accent);
269405
}
406+
.dk-coderef :global(.dk-coderef-copy-state.loading-state) {
407+
color: var(--brut-ink-3);
408+
}
270409
271410
/* ── Code area — fills the cell so the scrollbar sits flush ───── */
272411
.dk-coderef-code {
@@ -309,6 +448,24 @@
309448
font-family: inherit;
310449
background: transparent;
311450
}
451+
.dk-coderef-placeholder {
452+
display: grid;
453+
min-height: 180px;
454+
place-content: center;
455+
gap: 6px;
456+
padding: 28px 18px;
457+
background: var(--brut-bg);
458+
color: var(--brut-ink-3);
459+
text-align: center;
460+
}
461+
.dk-coderef-placeholder strong {
462+
color: var(--brut-ink);
463+
font-size: 12px;
464+
text-transform: uppercase;
465+
}
466+
.dk-coderef-placeholder span {
467+
font-size: 11px;
468+
}
312469
313470
/* Light/dark switching for shiki-rendered samples. Prose.css scopes
314471
its toggle to `.prose`, so we re-state it here on the grid root to

src/lib/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,8 @@ export type { NavItem, NavSection } from './types/nav.js'
4747
export {
4848
formatSheetLabel,
4949
type DemoManifestEntry,
50+
type DemoManifestIndex,
51+
type DemoManifestIndexEntry,
5052
type ExampleSection
5153
} from './types/example-section.js'
5254

src/lib/types/example-section.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,23 @@ export interface DemoManifestEntry {
7676
html?: { light: string; dark: string }
7777
}
7878

79+
/**
80+
* Shape of one entry in the split `demo-manifest.json` emitted when
81+
* `demoManifestPlugin({ split: true })` is enabled. The heavy source and
82+
* Shiki HTML live behind `importPath`, which can be loaded after hydration
83+
* or when a code panel opens.
84+
*/
85+
export interface DemoManifestIndexEntry {
86+
/** Virtual module import path for this demo's full `DemoManifestEntry`. */
87+
importPath: string
88+
/** Size of the copyable source text in bytes. Useful for diagnostics. */
89+
codeBytes: number
90+
/** Combined size of the light + dark Shiki HTML in bytes. */
91+
htmlBytes: number
92+
}
93+
94+
export type DemoManifestIndex = Record<string, DemoManifestIndexEntry>
95+
7996
/**
8097
* Format an index + total into the brutalist sheet label, e.g.
8198
* `formatSheetLabel(0, 12) === "SHEET 01 / 12"`. Pure presentation — drop in

src/lib/virtual-demo.d.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
declare module 'virtual:docs-kit/demo/*' {
2+
interface DemoManifestEntry {
3+
code: string
4+
lang: string
5+
html?: { light: string; dark: string }
6+
}
7+
8+
const entry: DemoManifestEntry
9+
export default entry
10+
}

0 commit comments

Comments
 (0)