@sprig-and-prose/sprig-ui-csr 0.1.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.
@@ -0,0 +1,657 @@
1
+ <script>
2
+ import { currentSeries, seriesChildren, seriesRelationships, universeGraph, currentUniverseName, universeRootNode, getNodeRoute, currentSeriesId } from '../lib/data/universeStore.js';
3
+ import { getDisplayTitle } from '../lib/format/title.js';
4
+ import Prose from '../lib/components/Prose.svelte';
5
+ import ContentsCard from '../lib/components/ContentsCard.svelte';
6
+ import FooterStatus from '../lib/components/FooterStatus.svelte';
7
+ import PageHeader from '../lib/components/PageHeader.svelte';
8
+ import { linkForPath } from '../lib/references/linkForPath.js';
9
+ import { linkForRepository } from '../lib/references/linkForRepository.js';
10
+ import { isWildcardPath } from '../lib/references/isWildcardPath.js';
11
+
12
+ /** @type {{ universe: string, series: string }} */
13
+ export let params;
14
+
15
+ // Update stores reactively when params change
16
+ $: {
17
+ const seriesId = `${params.universe}:series:${params.series}`;
18
+ currentSeriesId.set(seriesId);
19
+ currentUniverseName.set(params.universe);
20
+ }
21
+
22
+ /**
23
+ * @typedef {{ raw?:string, normalized?:string, source?:any }} TextBlock
24
+ * @typedef {{ repository:string, paths:string[]|Array<{path:string, describe?:TextBlock}>, describe?:TextBlock }} ReferenceBlock
25
+ */
26
+
27
+ $: subtitle = (() => {
28
+ if (!$currentSeries || !$universeGraph) {
29
+ return $universeRootNode
30
+ ? `A series in <a href="/">${getDisplayTitle($universeRootNode) || params.universe}</a>`
31
+ : `A series in ${params.universe}`;
32
+ }
33
+
34
+ // Check if series belongs to an anthology
35
+ const parentId = $currentSeries.parent;
36
+ if (parentId) {
37
+ const parentNode = $universeGraph.nodes[parentId];
38
+ if (parentNode && parentNode.kind === 'anthology') {
39
+ // Series is in an anthology
40
+ const anthologyRoute = getNodeRoute(parentNode);
41
+ const anthologyName = getDisplayTitle(parentNode);
42
+ const universeLink = $universeRootNode
43
+ ? `<a href="/">${getDisplayTitle($universeRootNode) || params.universe}</a>`
44
+ : params.universe;
45
+ return `A series in <a href="${anthologyRoute}">${anthologyName}</a> (in ${universeLink})`;
46
+ }
47
+ }
48
+
49
+ // Series is not in an anthology
50
+ return $universeRootNode
51
+ ? `A series in <a href="/">${getDisplayTitle($universeRootNode) || params.universe}</a>`
52
+ : `A series in ${params.universe}`;
53
+ })();
54
+
55
+ /**
56
+ * Normalize reference paths to handle both Shape A (strings) and Shape B (objects)
57
+ * @param {ReferenceBlock} ref
58
+ * @returns {Array<{path:string, describe?:TextBlock}>}
59
+ */
60
+ function normalizePaths(ref) {
61
+ if (!ref.paths || ref.paths.length === 0) return [];
62
+
63
+ // Shape B: paths is array of objects
64
+ if (typeof ref.paths[0] === 'object' && ref.paths[0] !== null && 'path' in ref.paths[0]) {
65
+ return ref.paths.map((/** @type {{path:string, describe?:TextBlock}} */ p) => ({
66
+ path: p.path,
67
+ describe: p.describe,
68
+ }));
69
+ }
70
+
71
+ // Shape A: paths is array of strings
72
+ return ref.paths.map((/** @type {string} */ p) => ({
73
+ path: p,
74
+ describe: undefined, // No per-path describe in Shape A
75
+ }));
76
+ }
77
+
78
+ // Group references by repository, preserving source order
79
+ $: groupedReferences = (() => {
80
+ const refs = $currentSeries?.references || [];
81
+ if (refs.length === 0) return [];
82
+
83
+ const groups = new Map();
84
+ const order = [];
85
+
86
+ for (const ref of refs) {
87
+ const repo = ref.repository;
88
+ if (!groups.has(repo)) {
89
+ groups.set(repo, {
90
+ referenceGroups: [],
91
+ });
92
+ order.push(repo);
93
+ }
94
+
95
+ const group = groups.get(repo);
96
+ const normalizedPaths = normalizePaths(ref);
97
+ const pathEntries = normalizedPaths.map((pathEntry) => ({
98
+ path: pathEntry.path,
99
+ perPathDescribe: pathEntry.describe,
100
+ }));
101
+
102
+ group.referenceGroups.push({
103
+ paths: pathEntries,
104
+ groupDescribe: ref.describe?.normalized || undefined,
105
+ });
106
+ }
107
+
108
+ return order.map((repo) => ({
109
+ repository: repo,
110
+ referenceGroups: groups.get(repo).referenceGroups,
111
+ }));
112
+ })();
113
+
114
+ // Expand/collapse state per repository group
115
+ let expandedGroups = new Set();
116
+
117
+ // Reactive repository URLs and path link generator - need to read from store reactively
118
+ $: repositoryUrls = (() => {
119
+ const graph = $universeGraph;
120
+ if (!graph?.repositories) return {};
121
+ const urls = {};
122
+ for (const [repoName, repoConfig] of Object.entries(graph.repositories)) {
123
+ const { kind, options } = repoConfig;
124
+ if (kind === 'sprig-repository-github') {
125
+ if (options?.url) {
126
+ urls[repoName] = options.url.endsWith('/') ? options.url.slice(0, -1) : options.url;
127
+ } else if (options?.owner && options?.repo) {
128
+ urls[repoName] = `https://github.com/${options.owner}/${options.repo}`;
129
+ }
130
+ }
131
+ }
132
+ return urls;
133
+ })();
134
+
135
+ // Helper function to generate path URLs reactively
136
+ function getPathUrl(repository, path) {
137
+ const graph = $universeGraph;
138
+ if (!graph?.repositories) return null;
139
+ const repoConfig = graph.repositories[repository];
140
+ if (!repoConfig) return null;
141
+ const { kind, options } = repoConfig;
142
+ const defaultBranch = options?.defaultBranch || 'main';
143
+
144
+ if (kind === 'sprig-repository-github') {
145
+ let baseUrl;
146
+ if (options?.url) {
147
+ baseUrl = options.url;
148
+ } else if (options?.owner && options?.repo) {
149
+ baseUrl = `https://github.com/${options.owner}/${options.repo}`;
150
+ } else {
151
+ return null;
152
+ }
153
+ const normalizedBaseUrl = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl;
154
+ const normalizedPath = path.startsWith('/') ? path : `/${path}`;
155
+
156
+ if (isWildcardPath(path)) {
157
+ const lastSlashIndex = normalizedPath.lastIndexOf('/');
158
+ if (lastSlashIndex > 0) {
159
+ const folderPath = normalizedPath.slice(0, lastSlashIndex);
160
+ return `${normalizedBaseUrl}/tree/${defaultBranch}${folderPath}`;
161
+ }
162
+ return `${normalizedBaseUrl}/tree/${defaultBranch}`;
163
+ }
164
+ return `${normalizedBaseUrl}/blob/${defaultBranch}${normalizedPath}`;
165
+ }
166
+ return null;
167
+ }
168
+
169
+ /**
170
+ * @param {string} repository
171
+ */
172
+ function toggleGroup(repository) {
173
+ if (expandedGroups.has(repository)) {
174
+ expandedGroups.delete(repository);
175
+ } else {
176
+ expandedGroups.add(repository);
177
+ }
178
+ expandedGroups = expandedGroups; // Trigger reactivity
179
+ }
180
+
181
+ const DEFAULT_VISIBLE_PATHS = 3;
182
+
183
+ /**
184
+ * Get the anthology node for a series, if it belongs to one
185
+ * @param {any} graph
186
+ * @param {any} seriesNode
187
+ * @returns {any|null}
188
+ */
189
+ function getAnthologyForSeries(graph, seriesNode) {
190
+ if (!seriesNode?.parent || !graph) return null;
191
+ const parent = graph.nodes[seriesNode.parent];
192
+ return parent?.kind === 'anthology' ? parent : null;
193
+ }
194
+
195
+ /**
196
+ * Get the anthology context for any node by finding its parent series
197
+ * @param {any} graph
198
+ * @param {any} node
199
+ * @returns {any|null}
200
+ */
201
+ function getAnthologyForNode(graph, node) {
202
+ if (!node || !graph) return null;
203
+
204
+ if (node.kind === 'series') {
205
+ return getAnthologyForSeries(graph, node);
206
+ }
207
+
208
+ let currentParentId = node.parent;
209
+ while (currentParentId) {
210
+ const parentNode = graph.nodes[currentParentId];
211
+ if (!parentNode) break;
212
+
213
+ if (parentNode.kind === 'series') {
214
+ return getAnthologyForSeries(graph, parentNode);
215
+ }
216
+
217
+ currentParentId = parentNode.parent;
218
+ }
219
+
220
+ return null;
221
+ }
222
+
223
+ $: currentSeriesAnthology = $currentSeries && $universeGraph
224
+ ? getAnthologyForSeries($universeGraph, $currentSeries)
225
+ : null;
226
+ </script>
227
+
228
+ {#if $currentSeries}
229
+ <PageHeader title={getDisplayTitle($currentSeries)} {subtitle} />
230
+
231
+ <div class="grid">
232
+ <section class="narrative">
233
+ {#if $currentSeries.describe}
234
+ <Prose textBlock={$currentSeries.describe} />
235
+ {/if}
236
+ </section>
237
+
238
+ <aside class="index">
239
+ <ContentsCard children={$seriesChildren} />
240
+ </aside>
241
+ </div>
242
+
243
+ {#if $seriesRelationships.length > 0}
244
+ <section class="relationships">
245
+ <h2 class="relationships-title">Relationships</h2>
246
+ <ul class="relationships-list">
247
+ {#each $seriesRelationships as rel}
248
+ {@const otherAnthology = $universeGraph
249
+ ? getAnthologyForNode($universeGraph, rel.otherNode)
250
+ : null}
251
+ {@const isDifferentAnthology = otherAnthology && (
252
+ !currentSeriesAnthology || currentSeriesAnthology.id !== otherAnthology.id
253
+ )}
254
+ {@const displayName = isDifferentAnthology
255
+ ? `${getDisplayTitle(rel.otherNode)} (${getDisplayTitle(otherAnthology)})`
256
+ : getDisplayTitle(rel.otherNode)}
257
+ <li class="relationship-item">
258
+ <a class="relationship-link sprig-link" href={getNodeRoute(rel.otherNode)}>
259
+ <span class="relationship-label">{rel.label}</span>
260
+ <span class="relationship-separator">→</span>
261
+ <span class="relationship-name">{displayName}</span>
262
+ <span class="relationship-kind">{rel.otherNode.kind}</span>
263
+ </a>
264
+ {#if rel.desc}
265
+ <p class="relationship-description">{rel.desc}</p>
266
+ {/if}
267
+ </li>
268
+ {/each}
269
+ </ul>
270
+ </section>
271
+ {/if}
272
+
273
+ {#if groupedReferences.length > 0}
274
+ <section class="references">
275
+ <h2 class="references-title">References</h2>
276
+ <p class="references-subtitle">Where this concept appears in code and docs.</p>
277
+
278
+ {#each groupedReferences as group}
279
+ {@const repoUrl = repositoryUrls[group.repository] || null}
280
+ {@const allPaths = group.referenceGroups.flatMap(rg => rg.paths)}
281
+ {@const isExpanded = expandedGroups.has(group.repository)}
282
+ {@const visibleRefGroups = (() => {
283
+ if (isExpanded) return group.referenceGroups;
284
+ let pathCount = 0;
285
+ const visible = [];
286
+ for (const refGroup of group.referenceGroups) {
287
+ if (pathCount + refGroup.paths.length <= DEFAULT_VISIBLE_PATHS) {
288
+ visible.push(refGroup);
289
+ pathCount += refGroup.paths.length;
290
+ } else {
291
+ break;
292
+ }
293
+ }
294
+ return visible;
295
+ })()}
296
+ {@const hiddenCount = allPaths.length - visibleRefGroups.reduce((sum, rg) => sum + rg.paths.length, 0)}
297
+
298
+ <div class="reference-group">
299
+ {#if repoUrl}
300
+ <a class="reference-repo-pill" href={repoUrl} target="_blank" rel="noopener noreferrer">
301
+ <span class="reference-repo-label">repo</span>
302
+ <span class="reference-repo-name">{group.repository}</span>
303
+ </a>
304
+ {:else}
305
+ <div class="reference-repo-pill reference-repo-pill--no-link">
306
+ <span class="reference-repo-label">repo</span>
307
+ <span class="reference-repo-name">{group.repository}</span>
308
+ </div>
309
+ {/if}
310
+
311
+ <ul class="reference-paths-list">
312
+ {#each visibleRefGroups as refGroup, refGroupIndex}
313
+ {#each refGroup.paths as pathEntry, pathIndex}
314
+ {@const path = pathEntry.path}
315
+ {@const url = getPathUrl(group.repository, path)}
316
+ {@const isWildcard = isWildcardPath(path)}
317
+ {@const isLastInRefGroup = pathIndex === refGroup.paths.length - 1}
318
+ {@const isFirstInRefGroup = pathIndex === 0}
319
+ {@const isFirstRefGroup = refGroupIndex === 0}
320
+ <li class="reference-path-item" class:reference-path-item--tight={!isLastInRefGroup} class:reference-path-item--spaced={isFirstInRefGroup && !isFirstRefGroup}>
321
+ {#if url}
322
+ <a class="reference-path-link sprig-link sprig-link--quiet" href={url} target="_blank" rel="noopener noreferrer">
323
+ <span class="reference-path-text">{path}</span>
324
+ {#if isWildcard}
325
+ <span class="reference-path-wildcard">(pattern)</span>
326
+ {/if}
327
+ </a>
328
+ {:else}
329
+ <span class="reference-path-text">{path}</span>
330
+ {#if isWildcard}
331
+ <span class="reference-path-wildcard">(pattern)</span>
332
+ {/if}
333
+ {/if}
334
+ {#if pathEntry.perPathDescribe?.normalized}
335
+ <p class="reference-description reference-description--per-path">{pathEntry.perPathDescribe.normalized}</p>
336
+ {/if}
337
+ </li>
338
+ {/each}
339
+ {#if refGroup.groupDescribe}
340
+ <li class="reference-path-item reference-path-item--describe">
341
+ <p class="reference-description reference-description--group">{refGroup.groupDescribe}</p>
342
+ </li>
343
+ {/if}
344
+ {/each}
345
+ </ul>
346
+
347
+ {#if hiddenCount > 0 && !isExpanded}
348
+ <button
349
+ class="reference-expand-button"
350
+ on:click={() => toggleGroup(group.repository)}
351
+ type="button"
352
+ >
353
+ + {hiddenCount} more path{hiddenCount === 1 ? '' : 's'}
354
+ </button>
355
+ {/if}
356
+ {#if isExpanded && hiddenCount > 0}
357
+ <button
358
+ class="reference-expand-button"
359
+ on:click={() => toggleGroup(group.repository)}
360
+ type="button"
361
+ >
362
+ Show less
363
+ </button>
364
+ {/if}
365
+ </div>
366
+ {/each}
367
+ </section>
368
+ {/if}
369
+
370
+ {#if $universeGraph && $universeRootNode}
371
+ <FooterStatus graph={$universeGraph} root={$universeRootNode} />
372
+ {/if}
373
+ {:else}
374
+ <div class="loading">Loading…</div>
375
+ {/if}
376
+
377
+ <style>
378
+ .grid {
379
+ display: grid;
380
+ grid-template-columns: 1fr 350px;
381
+ gap: 64px;
382
+ align-items: start;
383
+ max-width: 1200px;
384
+ margin: 0 auto;
385
+ width: 100%;
386
+ }
387
+
388
+ .narrative {
389
+ max-width: 680px;
390
+ min-width: 0;
391
+ width: 100%;
392
+ }
393
+
394
+ .index {
395
+ min-width: 0;
396
+ width: 100%;
397
+ }
398
+
399
+ .relationships {
400
+ margin-top: 32px;
401
+ padding-top: 24px;
402
+ border-top: 1px solid var(--hairline);
403
+ }
404
+
405
+ .relationships-title {
406
+ font-family: var(--font-ui);
407
+ font-size: var(--sp-font-tiny);
408
+ letter-spacing: 0.02em;
409
+ text-transform: none;
410
+ color: var(--text-secondary);
411
+ margin: 0 0 0.75rem 0;
412
+ }
413
+
414
+ .relationships-list {
415
+ list-style: none;
416
+ padding: 0;
417
+ margin: 0;
418
+ }
419
+
420
+ .relationship-item {
421
+ margin-bottom: 2rem;
422
+ }
423
+
424
+ .relationship-item:last-child {
425
+ margin-bottom: 0;
426
+ }
427
+
428
+ .relationship-link {
429
+ display: flex;
430
+ align-items: center;
431
+ gap: 8px;
432
+ padding: 4px 0;
433
+ }
434
+
435
+ .relationship-link.sprig-link {
436
+ text-decoration: none;
437
+ }
438
+
439
+ .relationship-link.sprig-link .relationship-name {
440
+ text-decoration: underline;
441
+ text-decoration-thickness: 1px;
442
+ text-underline-offset: 0.1875rem;
443
+ text-decoration-color: var(--sprig-link-underline);
444
+ }
445
+
446
+ .relationship-link.sprig-link:hover .relationship-name {
447
+ text-decoration-thickness: 1.5px;
448
+ text-decoration-color: var(--sprig-link-underline-hover);
449
+ }
450
+
451
+ .relationship-label {
452
+ font-family: var(--font-ui);
453
+ font-size: var(--sp-font-tiny);
454
+ color: var(--text-secondary);
455
+ }
456
+
457
+ .relationship-separator {
458
+ font-family: var(--font-ui);
459
+ font-size: var(--sp-font-tiny);
460
+ color: var(--text-tertiary);
461
+ opacity: 0.6;
462
+ }
463
+
464
+ .relationship-name {
465
+ font-family: var(--font-prose);
466
+ font-size: var(--sp-font-small);
467
+ color: inherit;
468
+ }
469
+
470
+ .relationship-kind {
471
+ font-family: var(--font-ui);
472
+ font-size: var(--sp-font-tiny);
473
+ color: var(--text-tertiary);
474
+ margin-left: auto;
475
+ }
476
+
477
+ .relationship-description {
478
+ margin: 8px 0 0 0;
479
+ font-family: var(--font-prose);
480
+ font-size: var(--sp-font-body);
481
+ color: var(--text-secondary);
482
+ line-height: 1.6;
483
+ max-width: 70ch;
484
+ }
485
+
486
+ .loading {
487
+ color: var(--text-tertiary);
488
+ padding: 24px 0;
489
+ }
490
+
491
+ .references {
492
+ margin-top: 32px;
493
+ padding-top: 24px;
494
+ border-top: 1px solid var(--hairline);
495
+ }
496
+
497
+ .references-title {
498
+ font-family: var(--font-ui);
499
+ font-size: var(--sp-font-tiny);
500
+ letter-spacing: 0.02em;
501
+ text-transform: none;
502
+ color: var(--text-secondary);
503
+ margin: 0 0 0.75rem 0;
504
+ }
505
+
506
+ .references-subtitle {
507
+ font-family: var(--font-ui);
508
+ font-size: var(--sp-font-tiny);
509
+ color: var(--text-tertiary);
510
+ margin: 0 0 1.5rem 0;
511
+ }
512
+
513
+ .reference-group {
514
+ margin-bottom: 2rem;
515
+ }
516
+
517
+ .reference-group:last-child {
518
+ margin-bottom: 0;
519
+ }
520
+
521
+ .reference-repo-pill {
522
+ display: inline-flex;
523
+ align-items: center;
524
+ gap: 6px;
525
+ padding: 4px 10px;
526
+ background: var(--card-bg);
527
+ border-radius: 6px;
528
+ text-decoration: none;
529
+ color: inherit;
530
+ margin-bottom: 0.75rem;
531
+ transition: opacity 0.2s;
532
+ }
533
+
534
+ .reference-repo-pill:hover {
535
+ opacity: 0.8;
536
+ }
537
+
538
+ .reference-repo-pill--no-link {
539
+ cursor: default;
540
+ }
541
+
542
+ .reference-repo-pill--no-link:hover {
543
+ opacity: 1;
544
+ }
545
+
546
+ .reference-repo-label {
547
+ font-family: var(--font-ui);
548
+ font-size: var(--sp-font-tiny);
549
+ letter-spacing: 0.05em;
550
+ text-transform: lowercase;
551
+ color: var(--text-tertiary);
552
+ font-weight: 400;
553
+ }
554
+
555
+ .reference-repo-name {
556
+ font-family: var(--font-ui);
557
+ font-size: var(--sp-font-tiny);
558
+ color: var(--text-secondary);
559
+ font-weight: 400;
560
+ }
561
+
562
+ .reference-paths-list {
563
+ list-style: none;
564
+ padding: 0;
565
+ margin: 0;
566
+ }
567
+
568
+ .reference-path-item {
569
+ margin-bottom: 0.75rem;
570
+ }
571
+
572
+ .reference-path-item:last-child {
573
+ margin-bottom: 0;
574
+ }
575
+
576
+ .reference-path-item--tight {
577
+ margin-bottom: 0.25rem;
578
+ }
579
+
580
+ .reference-path-item--spaced {
581
+ margin-top: 0.75rem;
582
+ }
583
+
584
+ .reference-path-item--describe {
585
+ margin-top: 0.5rem;
586
+ margin-bottom: 0.75rem;
587
+ }
588
+
589
+ .reference-path-link {
590
+ display: block;
591
+ padding: 4px 0;
592
+ }
593
+
594
+ .reference-path-text {
595
+ font-family: ui-monospace, 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
596
+ font-size: var(--sp-font-body);
597
+ color: inherit;
598
+ font-variant-numeric: tabular-nums;
599
+ letter-spacing: 0.01em;
600
+ }
601
+
602
+ .reference-path-wildcard {
603
+ font-family: var(--font-ui);
604
+ font-size: var(--sp-font-tiny);
605
+ color: var(--text-tertiary);
606
+ margin-left: 4px;
607
+ font-weight: 400;
608
+ }
609
+
610
+ .reference-description {
611
+ margin: 6px 0 0 0;
612
+ font-family: var(--font-prose);
613
+ font-size: var(--sp-font-body);
614
+ color: var(--text-secondary);
615
+ line-height: 1.5;
616
+ max-width: 70ch;
617
+ }
618
+
619
+ .reference-description--per-path {
620
+ margin-top: 6px;
621
+ }
622
+
623
+ .reference-description--group {
624
+ margin: 0;
625
+ }
626
+
627
+ .reference-expand-button {
628
+ background: none;
629
+ border: none;
630
+ color: var(--text-tertiary);
631
+ font-family: var(--font-ui);
632
+ font-size: var(--sp-font-tiny);
633
+ padding: 4px 0;
634
+ margin-top: 0.5rem;
635
+ cursor: pointer;
636
+ text-decoration: underline;
637
+ text-underline-offset: 2px;
638
+ }
639
+
640
+ .reference-expand-button:hover {
641
+ opacity: 0.8;
642
+ }
643
+
644
+ @media (max-width: 768px) {
645
+ .grid {
646
+ grid-template-columns: 1fr;
647
+ gap: 32px;
648
+ }
649
+ }
650
+
651
+ @media (max-width: 480px) {
652
+ .grid {
653
+ gap: 32px;
654
+ }
655
+ }
656
+ </style>
657
+