@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,260 @@
1
+ <script>
2
+ import { describeRenderMode } from '../stores/describeRenderMode.js';
3
+
4
+ /** @type {import('../data/universeStore.js').TextBlock | { raw?:string, normalized?:string } | undefined} */
5
+ export let textBlock;
6
+
7
+ /**
8
+ * Parse text into paragraphs (plain mode)
9
+ */
10
+ const toParagraphs = (s) => {
11
+ if (!s) return [];
12
+ // Split on blank lines; preserve paragraphs.
13
+ const parts = s.split(/\n\s*\n/g);
14
+ return parts.map((p) =>
15
+ p
16
+ .split('\n') // soft breaks
17
+ .map((x) => x.trim())
18
+ .filter(Boolean)
19
+ .join(' ')
20
+ ).filter(Boolean);
21
+ };
22
+
23
+ /**
24
+ * Parse text into structured blocks (lists mode)
25
+ * Returns array of { type: 'paragraph' | 'unordered-list' | 'ordered-list', content: string | string[] }
26
+ */
27
+ const parseMarkdownLite = (s) => {
28
+ if (!s) return [];
29
+
30
+ // Split by blank lines to get blocks
31
+ const blocks = s.split(/\n\s*\n/g).filter(block => block.trim());
32
+ const result = [];
33
+
34
+ const unorderedPattern = /^[-—*]\s+/;
35
+ const orderedPattern = /^\d+\.\s+/;
36
+
37
+ for (const block of blocks) {
38
+ const lines = block.split('\n').map(line => line.trim()).filter(Boolean);
39
+ if (lines.length === 0) continue;
40
+
41
+ // Find where list items start (if any)
42
+ let unorderedStart = -1;
43
+ let orderedStart = -1;
44
+
45
+ for (let i = 0; i < lines.length; i++) {
46
+ if (unorderedStart === -1 && unorderedPattern.test(lines[i])) {
47
+ unorderedStart = i;
48
+ }
49
+ if (orderedStart === -1 && orderedPattern.test(lines[i])) {
50
+ orderedStart = i;
51
+ }
52
+ }
53
+
54
+ // Handle unordered list
55
+ if (unorderedStart >= 0 && (orderedStart === -1 || unorderedStart < orderedStart)) {
56
+ // Extract leading paragraph text (if any)
57
+ if (unorderedStart > 0) {
58
+ const leadingText = lines.slice(0, unorderedStart).join(' ');
59
+ if (leadingText.trim()) {
60
+ result.push({ type: 'paragraph', content: leadingText });
61
+ }
62
+ }
63
+
64
+ // Extract list items (from first list item to end of block, or until non-list item)
65
+ const listItems = [];
66
+ let currentItem = null;
67
+ for (let i = unorderedStart; i < lines.length; i++) {
68
+ if (unorderedPattern.test(lines[i])) {
69
+ // If we have a current item, save it before starting a new one
70
+ if (currentItem !== null) {
71
+ listItems.push(currentItem);
72
+ }
73
+ const match = lines[i].match(/^[-—*]\s+(.+)$/);
74
+ currentItem = match[1];
75
+ } else {
76
+ // Continuation line - append to current item
77
+ if (currentItem !== null) {
78
+ currentItem += ' ' + lines[i];
79
+ } else {
80
+ // Stop at first non-list item if we haven't started a list yet
81
+ break;
82
+ }
83
+ }
84
+ }
85
+ // Don't forget the last item
86
+ if (currentItem !== null) {
87
+ listItems.push(currentItem);
88
+ }
89
+
90
+ if (listItems.length > 0) {
91
+ result.push({ type: 'unordered-list', content: listItems });
92
+ }
93
+ continue;
94
+ }
95
+
96
+ // Handle ordered list
97
+ if (orderedStart >= 0) {
98
+ // Extract leading paragraph text (if any)
99
+ if (orderedStart > 0) {
100
+ const leadingText = lines.slice(0, orderedStart).join(' ');
101
+ if (leadingText.trim()) {
102
+ result.push({ type: 'paragraph', content: leadingText });
103
+ }
104
+ }
105
+
106
+ // Extract list items
107
+ const listItems = [];
108
+ let currentItem = null;
109
+ for (let i = orderedStart; i < lines.length; i++) {
110
+ if (orderedPattern.test(lines[i])) {
111
+ // If we have a current item, save it before starting a new one
112
+ if (currentItem !== null) {
113
+ listItems.push(currentItem);
114
+ }
115
+ const match = lines[i].match(/^\d+\.\s+(.+)$/);
116
+ currentItem = match[1];
117
+ } else {
118
+ // Continuation line - append to current item
119
+ if (currentItem !== null) {
120
+ currentItem += ' ' + lines[i];
121
+ } else {
122
+ // Stop at first non-list item if we haven't started a list yet
123
+ break;
124
+ }
125
+ }
126
+ }
127
+ // Don't forget the last item
128
+ if (currentItem !== null) {
129
+ listItems.push(currentItem);
130
+ }
131
+
132
+ if (listItems.length > 0) {
133
+ result.push({ type: 'ordered-list', content: listItems });
134
+ }
135
+ continue;
136
+ }
137
+
138
+ // Otherwise, treat as paragraph (preserve literal text)
139
+ const paragraphText = lines.join(' ');
140
+ result.push({ type: 'paragraph', content: paragraphText });
141
+ }
142
+
143
+ return result;
144
+ };
145
+
146
+ $: text = textBlock?.normalized ?? textBlock?.raw ?? '';
147
+ $: mode = $describeRenderMode;
148
+ $: paragraphs = mode === 'plain' ? toParagraphs(text) : null;
149
+ $: blocks = mode === 'lists' ? parseMarkdownLite(text) : null;
150
+ </script>
151
+
152
+ {#if mode === 'plain'}
153
+ {#if paragraphs && paragraphs.length}
154
+ <div class="prose sp-prose">
155
+ {#each paragraphs as p}
156
+ <p>{p}</p>
157
+ {/each}
158
+ </div>
159
+ {:else}
160
+ <div class="empty">No description yet.</div>
161
+ {/if}
162
+ {:else if mode === 'lists'}
163
+ {#if blocks && blocks.length}
164
+ <div class="prose sp-prose">
165
+ {#each blocks as block}
166
+ {#if block.type === 'paragraph'}
167
+ <p>{block.content}</p>
168
+ {:else if block.type === 'unordered-list'}
169
+ <ul>
170
+ {#each block.content as item}
171
+ <li>{item}</li>
172
+ {/each}
173
+ </ul>
174
+ {:else if block.type === 'ordered-list'}
175
+ <ol>
176
+ {#each block.content as item}
177
+ <li>{item}</li>
178
+ {/each}
179
+ </ol>
180
+ {/if}
181
+ {/each}
182
+ </div>
183
+ {:else}
184
+ <div class="empty">No description yet.</div>
185
+ {/if}
186
+ {/if}
187
+
188
+ <style>
189
+ .prose {
190
+ max-width: 70ch;
191
+ font-size: var(--sp-font-body);
192
+ line-height: var(--sp-line-body);
193
+ }
194
+
195
+ p {
196
+ font-size: var(--sp-font-body);
197
+ line-height: var(--sp-line-body);
198
+ margin-bottom: var(--sp-space-4);
199
+ color: var(--text-color);
200
+ }
201
+
202
+ p:last-child {
203
+ margin-bottom: 0;
204
+ }
205
+
206
+ ul {
207
+ list-style: none;
208
+ padding-left: 0;
209
+ margin-bottom: var(--sp-space-6);
210
+ }
211
+
212
+ ul li {
213
+ font-size: var(--sp-font-body);
214
+ line-height: var(--sp-line-loose);
215
+ color: var(--text-secondary);
216
+ margin-bottom: var(--sp-space-3);
217
+ padding-left: var(--sp-space-6);
218
+ position: relative;
219
+ }
220
+
221
+ ul li::before {
222
+ content: '—';
223
+ position: absolute;
224
+ left: 0;
225
+ color: var(--text-tertiary);
226
+ }
227
+
228
+ ul li:last-child {
229
+ margin-bottom: 0;
230
+ }
231
+
232
+ ol {
233
+ margin-bottom: var(--sp-space-6);
234
+ padding-left: var(--sp-space-6);
235
+ }
236
+
237
+ ol li {
238
+ font-size: var(--sp-font-body);
239
+ line-height: var(--sp-line-body);
240
+ margin-bottom: var(--sp-space-3);
241
+ color: var(--text-color);
242
+ }
243
+
244
+ ol li:last-child {
245
+ margin-bottom: 0;
246
+ }
247
+
248
+ .empty {
249
+ color: var(--text-tertiary);
250
+ font-size: var(--sp-font-small);
251
+ padding: 10px 0;
252
+ }
253
+
254
+ @media (max-width: 480px) {
255
+ ul li {
256
+ padding-left: 20px;
257
+ }
258
+ }
259
+ </style>
260
+
@@ -0,0 +1,20 @@
1
+ <script>
2
+ import PageHeader from './PageHeader.svelte';
3
+ import { getDisplayTitle } from '../format/title.js';
4
+
5
+ /** @type {{ node: any }} */
6
+ export let node;
7
+
8
+ const firstSentence = (s) => {
9
+ if (!s) return '';
10
+ const idx = s.indexOf('.');
11
+ return idx >= 0 ? s.slice(0, idx + 1) : s;
12
+ };
13
+
14
+ $: subtitle = node.describe?.normalized || node.describe?.raw
15
+ ? firstSentence(node.describe?.normalized ?? node.describe?.raw)
16
+ : null;
17
+ </script>
18
+
19
+ <PageHeader title={getDisplayTitle(node)} {subtitle} />
20
+
@@ -0,0 +1,252 @@
1
+ import { writable, derived } from 'svelte/store';
2
+
3
+ /**
4
+ * @typedef {{ line:number, col:number, offset:number }} Pos
5
+ * @typedef {{ file:string, start:Pos, end:Pos }} SourceSpan
6
+ * @typedef {{ raw:string, normalized?:string, source:SourceSpan }} TextBlock
7
+ * @typedef {{ repository:string, paths:string[], describe?:TextBlock, source:SourceSpan }} ReferenceBlock
8
+ * @typedef {{ id:string, kind:'universe'|'anthology'|'series'|'book'|'chapter'|'concept'|'relates', name:string, title?:string, parent?:string, children:string[], container?:string, describe?:TextBlock, endpoints?:string[], from?:Record<string, any>, references?:ReferenceBlock[] }} NodeModel
9
+ * @typedef {{ name:string, root:string }} UniverseModel
10
+ * @typedef {{ version:1, universes:Record<string, UniverseModel>, nodes:Record<string, NodeModel>, edges:Record<string, any>, diagnostics:any[], repositories?:Record<string, any>, generatedAt?:string }} UniverseGraph
11
+ */
12
+
13
+ export const universeGraph = writable(/** @type {UniverseGraph|null} */ (null));
14
+
15
+ export const currentUniverseName = writable('Amaranthine');
16
+
17
+ export const currentUniverse = derived(
18
+ [universeGraph, currentUniverseName],
19
+ ([$g, $name]) => $g?.universes?.[$name] ?? null,
20
+ );
21
+
22
+ export const universeRootNode = derived(
23
+ [universeGraph, currentUniverse],
24
+ ([$g, $u]) => ($u && $g ? $g.nodes[$u.root] : null),
25
+ );
26
+
27
+ export const rootChildren = derived(
28
+ [universeGraph, universeRootNode],
29
+ ([$g, $root]) => {
30
+ if (!$g || !$root) return [];
31
+ return ($root.children || [])
32
+ .map((id) => $g.nodes[id])
33
+ .filter(Boolean)
34
+ .filter((node) => node.kind !== 'relates'); // Exclude relates nodes from contents
35
+ },
36
+ );
37
+
38
+ export const rootAnthologies = derived(
39
+ [universeGraph, universeRootNode],
40
+ ([$g, $root]) => {
41
+ if (!$g || !$root) return [];
42
+ return ($root.children || [])
43
+ .map((id) => $g.nodes[id])
44
+ .filter(Boolean)
45
+ .filter((node) => node.kind === 'anthology');
46
+ },
47
+ );
48
+
49
+ export const rootUngroupedSeries = derived(
50
+ [universeGraph, universeRootNode],
51
+ ([$g, $root]) => {
52
+ if (!$g || !$root) return [];
53
+ const allSeries = ($root.children || [])
54
+ .map((id) => $g.nodes[id])
55
+ .filter(Boolean)
56
+ .filter((node) => node.kind === 'series');
57
+
58
+ // Filter out series that have an anthology parent
59
+ return allSeries.filter((series) => {
60
+ if (!series.parent) return true;
61
+ const parent = $g.nodes[series.parent];
62
+ return !parent || parent.kind !== 'anthology';
63
+ });
64
+ },
65
+ );
66
+
67
+ export const currentSeriesId = writable(/** @type {string|null} */ (null));
68
+
69
+ export const currentSeries = derived(
70
+ [universeGraph, currentSeriesId],
71
+ ([$g, $seriesId]) => ($g && $seriesId ? $g.nodes[$seriesId] : null),
72
+ );
73
+
74
+ export const seriesChildren = derived(
75
+ [universeGraph, currentSeries],
76
+ ([$g, $series]) => {
77
+ if (!$g || !$series) return [];
78
+ return ($series.children || []).map((id) => $g.nodes[id]).filter(Boolean);
79
+ },
80
+ );
81
+
82
+ export const currentAnthologyId = writable(/** @type {string|null} */ (null));
83
+
84
+ export const currentAnthology = derived(
85
+ [universeGraph, currentAnthologyId],
86
+ ([$g, $anthologyId]) => ($g && $anthologyId ? $g.nodes[$anthologyId] : null),
87
+ );
88
+
89
+ export const anthologySeries = derived(
90
+ [universeGraph, currentAnthology],
91
+ ([$g, $anthology]) => {
92
+ if (!$g || !$anthology) return [];
93
+ return ($anthology.children || [])
94
+ .map((id) => $g.nodes[id])
95
+ .filter(Boolean)
96
+ .filter((node) => node.kind === 'series');
97
+ },
98
+ );
99
+
100
+ /**
101
+ * Get contextual relationships for a node
102
+ * @param {UniverseGraph | null} graph
103
+ * @param {string | null} currentNodeId
104
+ * @returns {Array<{ relNode: any, otherNode: any, label: string, desc: string | null }>}
105
+ */
106
+ export function getRelationshipsForNode(graph, currentNodeId) {
107
+ if (!graph || !currentNodeId) return [];
108
+
109
+ const relates = Object.values(graph.nodes).filter(
110
+ (n) => n.kind === 'relates',
111
+ );
112
+ const relationships = [];
113
+
114
+ for (const relNode of relates) {
115
+ if (!relNode.endpoints || !Array.isArray(relNode.endpoints)) continue;
116
+
117
+ // Check if current node is actually an endpoint of this relationship
118
+ if (!relNode.endpoints.includes(currentNodeId)) continue;
119
+
120
+ // Find the other endpoint (not the current one)
121
+ const otherId = relNode.endpoints.find((id) => id !== currentNodeId);
122
+ if (!otherId) continue;
123
+
124
+ const otherNode = graph.nodes[otherId];
125
+ if (!otherNode) continue; // Skip if other node not found
126
+
127
+ // Compute label
128
+ let label = 'related to';
129
+ const fromView = relNode.from?.[currentNodeId];
130
+ if (fromView?.relationships?.values?.length > 0) {
131
+ label = fromView.relationships.values[0];
132
+ }
133
+
134
+ // Compute description
135
+ let desc = null;
136
+ if (fromView?.describe?.normalized) {
137
+ desc = fromView.describe.normalized;
138
+ } else if (relNode.describe?.normalized) {
139
+ desc = relNode.describe.normalized;
140
+ }
141
+
142
+ relationships.push({
143
+ relNode,
144
+ otherNode,
145
+ label,
146
+ desc,
147
+ });
148
+ }
149
+
150
+ // Sort: primary by kind (series, book, chapter, concept), secondary by name
151
+ const kindOrder = { series: 0, book: 1, chapter: 2, concept: 3 };
152
+ relationships.sort((a, b) => {
153
+ const aKindOrder =
154
+ a.otherNode.kind in kindOrder ? kindOrder[a.otherNode.kind] : 999;
155
+ const bKindOrder =
156
+ b.otherNode.kind in kindOrder ? kindOrder[b.otherNode.kind] : 999;
157
+ if (aKindOrder !== bKindOrder) return aKindOrder - bKindOrder;
158
+ return a.otherNode.name.localeCompare(b.otherNode.name);
159
+ });
160
+
161
+ return relationships;
162
+ }
163
+
164
+ export const seriesRelationships = derived(
165
+ [universeGraph, currentSeriesId],
166
+ ([$g, $seriesId]) => {
167
+ return getRelationshipsForNode($g, $seriesId);
168
+ },
169
+ );
170
+
171
+ /**
172
+ * Get ancestor chain for a node (parent → grandparent → ...)
173
+ * Returns array of ancestor nodes in order from immediate parent to root ancestor
174
+ * @param {UniverseGraph | null} graph
175
+ * @param {NodeModel | null} node
176
+ * @returns {NodeModel[]}
177
+ */
178
+ export function getAncestorChain(graph, node) {
179
+ if (!graph || !node || !node.parent) return [];
180
+
181
+ const ancestors = [];
182
+ let currentParentId = node.parent;
183
+
184
+ while (currentParentId) {
185
+ const parentNode = graph.nodes[currentParentId];
186
+ if (!parentNode) break;
187
+
188
+ ancestors.push(parentNode);
189
+
190
+ // Stop at series level (series don't have parents in the hierarchy)
191
+ if (parentNode.kind === 'series') break;
192
+
193
+ currentParentId = parentNode.parent;
194
+ }
195
+
196
+ return ancestors;
197
+ }
198
+
199
+ /**
200
+ * Generate route for a node based on its kind
201
+ * @param {any} node
202
+ * @returns {string}
203
+ */
204
+ export function getNodeRoute(node) {
205
+ if (!node || !node.id) return '#';
206
+
207
+ const parts = node.id.split(':');
208
+ if (parts.length < 3) return `#${node.id}`;
209
+
210
+ const universe = parts[0];
211
+ const kind = parts[1];
212
+ const name = parts[2];
213
+
214
+ if (kind === 'series') {
215
+ return `/universes/${universe}/series/${name}`;
216
+ }
217
+
218
+ if (kind === 'anthology') {
219
+ return `/universes/${universe}/anthology/${name}`;
220
+ }
221
+
222
+ // For other kinds, use generic concept route with URL-encoded node ID
223
+ return `/universes/${universe}/concept/${encodeURIComponent(node.id)}`;
224
+ }
225
+
226
+ /**
227
+ * Fetch UniverseGraph JSON from a static path.
228
+ * @param {string} url
229
+ * @param {typeof fetch} [fetchFn] - Optional fetch function
230
+ */
231
+ export async function loadUniverseGraph(
232
+ url = '/api/manifest',
233
+ fetchFn = fetch,
234
+ ) {
235
+ // Add cache-busting headers to ensure we get fresh data
236
+ const res = await fetchFn(url, {
237
+ cache: 'no-store',
238
+ headers: {
239
+ 'Cache-Control': 'no-cache',
240
+ },
241
+ });
242
+ if (!res.ok) throw new Error(`Failed to load ${url}: ${res.status}`);
243
+ /** @type {UniverseGraph} */
244
+ const data = await res.json();
245
+ universeGraph.set(data);
246
+ const describeRenderMode = res.headers.get('x-describe-render-mode');
247
+ return {
248
+ data,
249
+ describeRenderMode,
250
+ };
251
+ }
252
+
@@ -0,0 +1,97 @@
1
+ /**
2
+ * @fileoverview Utility for deriving human-friendly display titles from node identifiers
3
+ */
4
+
5
+ /**
6
+ * Derives a human-friendly display title from a node or identifier string.
7
+ *
8
+ * @param {any} nodeOrId - Node object with optional `title`, `name`, `id`, `slug`, or `identifier` fields, or a string identifier
9
+ * @param {{}} [options] - Optional configuration (currently unused, reserved for future use)
10
+ * @returns {string} Human-friendly display title, or empty string if no usable identifier found
11
+ *
12
+ * @example
13
+ * getDisplayTitle({ title: "Custom Title" }) // "Custom Title"
14
+ * getDisplayTitle({ name: "CountBasedEffect" }) // "Count Based Effect"
15
+ * getDisplayTitle({ name: "APIResponse" }) // "API Response"
16
+ * getDisplayTitle({ name: "effect_type" }) // "Effect Type"
17
+ * getDisplayTitle("PlayerID") // "Player ID"
18
+ */
19
+ export function getDisplayTitle(nodeOrId, options = {}) {
20
+ // Handle explicit title override
21
+ if (nodeOrId && typeof nodeOrId === 'object' && typeof nodeOrId.title === 'string') {
22
+ const title = nodeOrId.title.trim();
23
+ if (title.length > 0) {
24
+ return title;
25
+ }
26
+ }
27
+
28
+ // Extract identifier string
29
+ let identifier = null;
30
+
31
+ if (typeof nodeOrId === 'string') {
32
+ identifier = nodeOrId;
33
+ } else if (nodeOrId && typeof nodeOrId === 'object') {
34
+ // Check in order: name, id, slug, identifier
35
+ identifier = nodeOrId.name || nodeOrId.id || nodeOrId.slug || nodeOrId.identifier || null;
36
+ }
37
+
38
+ if (!identifier || typeof identifier !== 'string' || identifier.trim().length === 0) {
39
+ return '';
40
+ }
41
+
42
+ // Derive title from identifier
43
+ return deriveTitleFromIdentifier(identifier);
44
+ }
45
+
46
+ /**
47
+ * Derives a human-friendly title from an identifier string.
48
+ * Handles PascalCase, camelCase, snake_case, kebab-case, and preserves acronyms.
49
+ *
50
+ * @param {string} identifier - The identifier string to convert
51
+ * @returns {string} Human-friendly title
52
+ *
53
+ * @private
54
+ */
55
+ function deriveTitleFromIdentifier(identifier) {
56
+ // Normalize: replace underscores and hyphens with spaces
57
+ let normalized = identifier
58
+ .replace(/[_-]/g, ' ')
59
+ .trim();
60
+
61
+ // Split on case boundaries (PascalCase/camelCase)
62
+ // This regex finds positions where:
63
+ // - A lowercase letter is followed by an uppercase letter (camelCase boundary)
64
+ // - An uppercase letter is followed by an uppercase letter followed by a lowercase letter (PascalCase boundary like "APIResponse")
65
+ // - A digit is followed by a letter or vice versa
66
+ normalized = normalized.replace(/([a-z])([A-Z])/g, '$1 $2'); // camelCase: "countBased" -> "count Based"
67
+ normalized = normalized.replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2'); // PascalCase acronyms: "APIResponse" -> "API Response"
68
+ normalized = normalized.replace(/([0-9])([A-Za-z])/g, '$1 $2'); // Numbers: "Item2" -> "Item 2"
69
+ normalized = normalized.replace(/([A-Za-z])([0-9])/g, '$1 $2'); // Numbers: "Item2" -> "Item 2"
70
+
71
+ // Split into words and process each
72
+ const words = normalized
73
+ .split(/\s+/)
74
+ .filter(word => word.length > 0)
75
+ .map(word => {
76
+ // Detect acronyms: 2+ consecutive uppercase letters
77
+ // Only preserve as acronym if it's a known common acronym
78
+ // This handles cases like "APIResponse" -> "API Response" but "SIMPLE" -> "Simple"
79
+ const isAcronymPattern = word.length >= 2 && word === word.toUpperCase() && /^[A-Z]+$/.test(word);
80
+
81
+ // Common acronyms that should be preserved: API, UUID, ID, HTTP, HTTPS, XML, JSON, etc.
82
+ const commonAcronyms = ['API', 'UUID', 'ID', 'HTTP', 'HTTPS', 'XML', 'JSON', 'URL', 'URI', 'HTML', 'CSS', 'JS', 'TS', 'AB'];
83
+ if (isAcronymPattern && commonAcronyms.includes(word)) {
84
+ // It's a known acronym, preserve as-is
85
+ return word;
86
+ }
87
+
88
+ // Title-case the word: first letter uppercase, rest lowercase
89
+ if (word.length === 0) return word;
90
+ if (word.length === 1) return word.toUpperCase();
91
+ return word[0].toUpperCase() + word.slice(1).toLowerCase();
92
+ });
93
+
94
+ // Join words with single spaces and collapse multiple spaces
95
+ return words.join(' ').replace(/\s+/g, ' ').trim();
96
+ }
97
+
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Checks if a path contains a wildcard pattern
3
+ * @param {string} path - File path to check
4
+ * @returns {boolean} - True if path contains '*'
5
+ */
6
+ export function isWildcardPath(path) {
7
+ return typeof path === 'string' && path.includes('*');
8
+ }
9
+
@@ -0,0 +1,65 @@
1
+ import { get } from 'svelte/store';
2
+ import { universeGraph } from '../data/universeStore.js';
3
+ import { isWildcardPath } from './isWildcardPath.js';
4
+
5
+ /**
6
+ * Builds a URL for a file path in a repository
7
+ * @param {string} repository - Repository key (e.g., "amaranthine")
8
+ * @param {string} path - File path (e.g., "/backends/api/src/routers/players.js" or "/data/actions/*.yaml")
9
+ * @returns {string | null} - Full URL to the file/folder, or null if repository is unconfigured
10
+ */
11
+ export function linkForPath(repository, path) {
12
+ const graph = get(universeGraph);
13
+ if (!graph?.repositories) {
14
+ return null;
15
+ }
16
+
17
+ const repoConfig = graph.repositories[repository];
18
+ if (!repoConfig) {
19
+ return null;
20
+ }
21
+
22
+ const { kind, options } = repoConfig;
23
+ const defaultBranch = options?.defaultBranch || 'main';
24
+
25
+ // Handle GitHub repositories
26
+ if (kind === 'sprig-repository-github') {
27
+ // Prefer url if available, otherwise build from owner/repo
28
+ let baseUrl;
29
+ if (options?.url) {
30
+ baseUrl = options.url;
31
+ } else {
32
+ const owner = options?.owner;
33
+ const repo = options?.repo;
34
+ if (!owner || !repo) {
35
+ return null;
36
+ }
37
+ baseUrl = `https://github.com/${owner}/${repo}`;
38
+ }
39
+
40
+ const normalizedBaseUrl = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl;
41
+
42
+ // Ensure path begins with /
43
+ const normalizedPath = path.startsWith('/') ? path : `/${path}`;
44
+
45
+ // Handle wildcard paths - link to folder view
46
+ if (isWildcardPath(path)) {
47
+ // Derive folder by taking everything up to the last '/'
48
+ // e.g., "/data/actions/*.yaml" -> "/data/actions"
49
+ const lastSlashIndex = normalizedPath.lastIndexOf('/');
50
+ if (lastSlashIndex > 0) {
51
+ const folderPath = normalizedPath.slice(0, lastSlashIndex);
52
+ // Build GitHub tree URL: baseUrl/tree/branch/folder
53
+ return `${normalizedBaseUrl}/tree/${defaultBranch}${folderPath}`;
54
+ }
55
+ // Fallback: if no slash, link to repo root
56
+ return `${normalizedBaseUrl}/tree/${defaultBranch}`;
57
+ }
58
+
59
+ // Build GitHub blob URL: baseUrl/blob/branch/path
60
+ return `${normalizedBaseUrl}/blob/${defaultBranch}${normalizedPath}`;
61
+ }
62
+
63
+ return null;
64
+ }
65
+