@sprig-and-prose/sprig-ui 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.
- package/README.md +120 -0
- package/dist/assets/index-B6I7oo2K.js +1 -0
- package/dist/assets/index-O71xRVzw.js +4 -0
- package/dist/assets/index-coj3G3o6.css +1 -0
- package/dist/index.html +13 -0
- package/package.json +33 -0
- package/src/App.svelte +277 -0
- package/src/cli.js +146 -0
- package/src/lib/components/ContentsCard.svelte +283 -0
- package/src/lib/components/FooterStatus.svelte +83 -0
- package/src/lib/components/GlobalSearch.svelte +428 -0
- package/src/lib/components/NodePageLayout.svelte +65 -0
- package/src/lib/components/PageHeader.svelte +116 -0
- package/src/lib/components/Prose.svelte +260 -0
- package/src/lib/components/RelationshipsSection.svelte +122 -0
- package/src/lib/components/UniverseHeader.svelte +20 -0
- package/src/lib/data/universeStore.js +350 -0
- package/src/lib/format/kind.js +26 -0
- package/src/lib/format/title.js +97 -0
- package/src/lib/router.js +39 -0
- package/src/lib/stores/describeRenderMode.js +9 -0
- package/src/lib/stores/theme.js +98 -0
- package/src/main.js +154 -0
- package/src/pages/ConceptPage.svelte +88 -0
- package/src/pages/HomePage.svelte +48 -0
- package/src/server.js +115 -0
- package/src/styles/app.css +353 -0
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
import { writable, derived, get } from 'svelte/store';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @typedef {{ id:string, name:string, title?:string, kind:string, spelledKind?:string, parent:string|null, children:string[], describe?:{raw:string, normalized:string}, dimension?:string, relationships?:Array<{edge:string, relationship?:string, to:string}> }} NodeModel
|
|
5
|
+
* @typedef {{ id:string, name:string, root:string }} UniverseModel
|
|
6
|
+
* @typedef {{ universes:Record<string, UniverseModel>, nodes:Record<string, NodeModel>, relationshipsById:Record<string, any>, relatesById:Record<string, any>, generatedAt?:string, meta?:any }} UniverseGraph
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export const universeGraph = writable(/** @type {UniverseGraph|null} */ (null));
|
|
10
|
+
export const currentUniverseName = writable(/** @type {string|null} */ (null));
|
|
11
|
+
|
|
12
|
+
const KIND_PRIORITY = ['concept', 'dimension', 'relationship', 'relates'];
|
|
13
|
+
|
|
14
|
+
function normalizeTextBlock(value) {
|
|
15
|
+
if (!value) return undefined;
|
|
16
|
+
if (typeof value === 'string') {
|
|
17
|
+
return { raw: value, normalized: value };
|
|
18
|
+
}
|
|
19
|
+
if (typeof value === 'object' && typeof value.raw === 'string') {
|
|
20
|
+
return {
|
|
21
|
+
raw: value.raw,
|
|
22
|
+
normalized: typeof value.normalized === 'string' ? value.normalized : value.raw,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function normalizeTitle(value) {
|
|
29
|
+
if (!value) return undefined;
|
|
30
|
+
if (typeof value === 'string') {
|
|
31
|
+
const trimmed = value.trim();
|
|
32
|
+
return trimmed.length > 0 ? trimmed : undefined;
|
|
33
|
+
}
|
|
34
|
+
if (typeof value === 'object' && typeof value.raw === 'string') {
|
|
35
|
+
const trimmed = value.raw.trim();
|
|
36
|
+
return trimmed.length > 0 ? trimmed : undefined;
|
|
37
|
+
}
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function endpointDisplayName(endpointId, conceptById) {
|
|
42
|
+
const concept = conceptById?.[endpointId];
|
|
43
|
+
if (concept?.name) return concept.name;
|
|
44
|
+
return endpointId;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function formatRelationshipLabel(label) {
|
|
48
|
+
if (!label) return '';
|
|
49
|
+
return String(label)
|
|
50
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
|
51
|
+
.replace(/[_-]+/g, ' ')
|
|
52
|
+
.toLowerCase()
|
|
53
|
+
.trim();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function sortByKindAndName(a, b) {
|
|
57
|
+
const kindDiff = KIND_PRIORITY.indexOf(a.kind) - KIND_PRIORITY.indexOf(b.kind);
|
|
58
|
+
if (kindDiff !== 0) return kindDiff;
|
|
59
|
+
return a.name.localeCompare(b.name);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* @param {NodeModel[]} children
|
|
64
|
+
* @returns {{ primary: NodeModel[], other: NodeModel[] }}
|
|
65
|
+
*/
|
|
66
|
+
export function organizeChildren(children) {
|
|
67
|
+
const valid = children.filter(Boolean);
|
|
68
|
+
if (valid.length === 0) return { primary: [], other: [] };
|
|
69
|
+
const primaryKind = valid[0].kind;
|
|
70
|
+
return {
|
|
71
|
+
primary: valid.filter((child) => child.kind === primaryKind),
|
|
72
|
+
other: valid.filter((child) => child.kind !== primaryKind),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Build a generic parent/child tree by nearest existing id prefix.
|
|
78
|
+
* @param {Record<string, NodeModel>} nodes
|
|
79
|
+
* @param {string} universeId
|
|
80
|
+
*/
|
|
81
|
+
function connectTree(nodes, universeId) {
|
|
82
|
+
const root = nodes[universeId];
|
|
83
|
+
if (!root) return;
|
|
84
|
+
|
|
85
|
+
const ids = Object.keys(nodes);
|
|
86
|
+
for (const id of ids) {
|
|
87
|
+
const node = nodes[id];
|
|
88
|
+
if (!node || id === universeId) continue;
|
|
89
|
+
const parts = id.split('.');
|
|
90
|
+
let parentId = universeId;
|
|
91
|
+
for (let i = parts.length - 1; i > 1; i -= 1) {
|
|
92
|
+
const candidate = parts.slice(0, i).join('.');
|
|
93
|
+
if (nodes[candidate]) {
|
|
94
|
+
parentId = candidate;
|
|
95
|
+
break;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
node.parent = parentId;
|
|
99
|
+
nodes[parentId].children.push(id);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* @param {Record<string, any>} relationshipsById
|
|
106
|
+
* @param {string | undefined} relId
|
|
107
|
+
* @param {string} edge
|
|
108
|
+
*/
|
|
109
|
+
function relationshipLabel(relationshipsById, relId, edge) {
|
|
110
|
+
const rel = relId ? relationshipsById[relId] : null;
|
|
111
|
+
if (!rel) return formatRelationshipLabel(edge);
|
|
112
|
+
if (rel.forward === edge || rel.inverse === edge) {
|
|
113
|
+
return formatRelationshipLabel(edge);
|
|
114
|
+
}
|
|
115
|
+
return formatRelationshipLabel(rel.forward || rel.inverse || edge);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* @param {any} manifest
|
|
120
|
+
* @returns {UniverseGraph}
|
|
121
|
+
*/
|
|
122
|
+
function normalizeLanguageManifest(manifest) {
|
|
123
|
+
const universeId = manifest?.universe?.id || manifest?.universe?.name;
|
|
124
|
+
if (!universeId) {
|
|
125
|
+
throw new Error('Invalid manifest: missing universe.id');
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** @type {Record<string, NodeModel>} */
|
|
129
|
+
const nodes = {};
|
|
130
|
+
nodes[universeId] = {
|
|
131
|
+
id: universeId,
|
|
132
|
+
name: manifest.universe.name || universeId,
|
|
133
|
+
title: normalizeTitle(manifest.universe.title),
|
|
134
|
+
kind: 'universe',
|
|
135
|
+
parent: null,
|
|
136
|
+
children: [],
|
|
137
|
+
describe: normalizeTextBlock(manifest.universe.describe),
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
for (const concept of Object.values(manifest?.concepts || {})) {
|
|
141
|
+
nodes[concept.id] = {
|
|
142
|
+
id: concept.id,
|
|
143
|
+
name: concept.name || concept.id,
|
|
144
|
+
title: normalizeTitle(concept.title),
|
|
145
|
+
kind: 'concept',
|
|
146
|
+
spelledKind: concept.dimension ? concept.dimension.split('.').pop() : 'concept',
|
|
147
|
+
parent: null,
|
|
148
|
+
children: [],
|
|
149
|
+
describe: normalizeTextBlock(concept.describe),
|
|
150
|
+
dimension: concept.dimension,
|
|
151
|
+
relationships: Array.isArray(concept.relationships) ? concept.relationships : [],
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
for (const dim of Object.values(manifest?.dimensions || {})) {
|
|
156
|
+
nodes[dim.id] = {
|
|
157
|
+
id: dim.id,
|
|
158
|
+
name: dim.name || dim.id,
|
|
159
|
+
title: normalizeTitle(dim.title),
|
|
160
|
+
kind: 'dimension',
|
|
161
|
+
spelledKind: 'dimension',
|
|
162
|
+
parent: null,
|
|
163
|
+
children: [],
|
|
164
|
+
describe: normalizeTextBlock(dim.describe),
|
|
165
|
+
relationships: [],
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
for (const rel of Object.values(manifest?.relationships || {})) {
|
|
170
|
+
const forward = formatRelationshipLabel(rel.forward || '');
|
|
171
|
+
const inverse = formatRelationshipLabel(rel.inverse || '');
|
|
172
|
+
const relationshipName = inverse
|
|
173
|
+
? `${forward} and ${inverse}`
|
|
174
|
+
: forward || rel.id;
|
|
175
|
+
|
|
176
|
+
nodes[rel.id] = {
|
|
177
|
+
id: rel.id,
|
|
178
|
+
name: relationshipName,
|
|
179
|
+
title: normalizeTitle(rel.title),
|
|
180
|
+
kind: 'relationship',
|
|
181
|
+
spelledKind: 'relationship',
|
|
182
|
+
parent: null,
|
|
183
|
+
children: [],
|
|
184
|
+
describe: normalizeTextBlock(rel.describe),
|
|
185
|
+
relationships: [],
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
for (const rel of Object.values(manifest?.relates || {})) {
|
|
190
|
+
const leftName = endpointDisplayName(rel.left, manifest?.concepts);
|
|
191
|
+
const rightName = endpointDisplayName(rel.right, manifest?.concepts);
|
|
192
|
+
|
|
193
|
+
nodes[rel.id] = {
|
|
194
|
+
id: rel.id,
|
|
195
|
+
name: `${leftName} and ${rightName}`,
|
|
196
|
+
title: normalizeTitle(rel.title),
|
|
197
|
+
kind: 'relates',
|
|
198
|
+
spelledKind: 'relates',
|
|
199
|
+
parent: universeId,
|
|
200
|
+
children: [],
|
|
201
|
+
describe: normalizeTextBlock(rel.describe),
|
|
202
|
+
relationships: Array.isArray(rel.relationships) ? rel.relationships : [],
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
connectTree(nodes, universeId);
|
|
207
|
+
|
|
208
|
+
return {
|
|
209
|
+
universes: {
|
|
210
|
+
[universeId]: {
|
|
211
|
+
id: universeId,
|
|
212
|
+
name: manifest.universe.name || universeId,
|
|
213
|
+
root: universeId,
|
|
214
|
+
},
|
|
215
|
+
},
|
|
216
|
+
nodes,
|
|
217
|
+
relationshipsById: manifest.relationships || {},
|
|
218
|
+
relatesById: manifest.relates || {},
|
|
219
|
+
generatedAt: manifest.generatedAt,
|
|
220
|
+
meta: manifest.meta,
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export const currentUniverse = derived([universeGraph, currentUniverseName], ([$g, $name]) =>
|
|
225
|
+
$name ? $g?.universes?.[$name] ?? null : null,
|
|
226
|
+
);
|
|
227
|
+
|
|
228
|
+
export const universeRootNode = derived([universeGraph, currentUniverse], ([$g, $u]) =>
|
|
229
|
+
$u && $g ? $g.nodes[$u.root] : null,
|
|
230
|
+
);
|
|
231
|
+
|
|
232
|
+
export const rootChildren = derived([universeGraph, universeRootNode], ([$g, $root]) => {
|
|
233
|
+
if (!$g || !$root) return { primary: [], other: [] };
|
|
234
|
+
const children = ($root.children || []).map((id) => $g.nodes[id]).filter(Boolean);
|
|
235
|
+
return organizeChildren(children);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* @param {UniverseGraph | null} graph
|
|
240
|
+
* @param {string | null} currentNodeId
|
|
241
|
+
* @returns {Array<{ otherNode: NodeModel, label: string, desc: string | null }>}
|
|
242
|
+
*/
|
|
243
|
+
export function getRelationshipsForNode(graph, currentNodeId) {
|
|
244
|
+
if (!graph || !currentNodeId) return [];
|
|
245
|
+
const node = graph.nodes[currentNodeId];
|
|
246
|
+
if (!node) return [];
|
|
247
|
+
|
|
248
|
+
/** @type {Array<{ otherNode: NodeModel, label: string, desc: string | null }>} */
|
|
249
|
+
const items = [];
|
|
250
|
+
const seen = new Set();
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* @param {string} otherNodeId
|
|
254
|
+
* @param {string} label
|
|
255
|
+
* @param {string | null} desc
|
|
256
|
+
*/
|
|
257
|
+
function pushRelationship(otherNodeId, label, desc) {
|
|
258
|
+
const otherNode = graph.nodes[otherNodeId];
|
|
259
|
+
if (!otherNode) return;
|
|
260
|
+
const key = `${otherNodeId}|${label}|${desc || ''}`;
|
|
261
|
+
if (seen.has(key)) return;
|
|
262
|
+
seen.add(key);
|
|
263
|
+
items.push({ otherNode, label, desc });
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
for (const rel of node.relationships || []) {
|
|
267
|
+
const decl = rel.relationship ? graph.relationshipsById[rel.relationship] : null;
|
|
268
|
+
pushRelationship(
|
|
269
|
+
rel.to,
|
|
270
|
+
relationshipLabel(graph.relationshipsById, rel.relationship, rel.edge),
|
|
271
|
+
normalizeTextBlock(decl?.describe)?.normalized || null,
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
for (const rel of Object.values(graph.relatesById || {})) {
|
|
276
|
+
const isLeft = rel.left === currentNodeId;
|
|
277
|
+
const isRight = rel.right === currentNodeId;
|
|
278
|
+
if (!isLeft && !isRight) continue;
|
|
279
|
+
const otherId = isLeft ? rel.right : rel.left;
|
|
280
|
+
const otherNode = graph.nodes[otherId];
|
|
281
|
+
if (!otherNode) continue;
|
|
282
|
+
const firstRel = Array.isArray(rel.relationships) ? rel.relationships[0] : null;
|
|
283
|
+
pushRelationship(
|
|
284
|
+
otherId,
|
|
285
|
+
formatRelationshipLabel(firstRel?.edge || 'relatesTo'),
|
|
286
|
+
normalizeTextBlock(rel.describe)?.normalized || null,
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
return items;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* @param {UniverseGraph | null} graph
|
|
295
|
+
* @param {NodeModel | null} node
|
|
296
|
+
* @returns {NodeModel[]}
|
|
297
|
+
*/
|
|
298
|
+
export function getAncestorChain(graph, node) {
|
|
299
|
+
if (!graph || !node?.parent) return [];
|
|
300
|
+
const ancestors = [];
|
|
301
|
+
let cursor = node.parent;
|
|
302
|
+
while (cursor) {
|
|
303
|
+
const parent = graph.nodes[cursor];
|
|
304
|
+
if (!parent) break;
|
|
305
|
+
ancestors.push(parent);
|
|
306
|
+
cursor = parent.parent;
|
|
307
|
+
}
|
|
308
|
+
return ancestors;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* @param {NodeModel | null | undefined} node
|
|
313
|
+
* @returns {string}
|
|
314
|
+
*/
|
|
315
|
+
export function getNodeRoute(node) {
|
|
316
|
+
if (!node?.id) return '#';
|
|
317
|
+
const universeId = node.id.split('.')[0];
|
|
318
|
+
if (!universeId) return '#';
|
|
319
|
+
return `/universes/${encodeURIComponent(universeId)}/node/${encodeURIComponent(node.id)}`;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* @param {UniverseGraph} graph
|
|
324
|
+
*/
|
|
325
|
+
export function autoSelectFirstUniverse(graph) {
|
|
326
|
+
if (!graph?.universes) return;
|
|
327
|
+
const universeNames = Object.keys(graph.universes);
|
|
328
|
+
const currentName = get(currentUniverseName);
|
|
329
|
+
if (!currentName || !graph.universes[currentName]) {
|
|
330
|
+
currentUniverseName.set(universeNames[0] || null);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* @param {string} url
|
|
336
|
+
* @param {typeof fetch} [fetchFn]
|
|
337
|
+
*/
|
|
338
|
+
export async function loadUniverseGraph(url = '/api/manifest', fetchFn = fetch) {
|
|
339
|
+
const res = await fetchFn(url, {
|
|
340
|
+
cache: 'no-store',
|
|
341
|
+
headers: { 'Cache-Control': 'no-cache' },
|
|
342
|
+
});
|
|
343
|
+
if (!res.ok) throw new Error(`Failed to load ${url}: ${res.status}`);
|
|
344
|
+
const raw = await res.json();
|
|
345
|
+
const data = normalizeLanguageManifest(raw);
|
|
346
|
+
universeGraph.set(data);
|
|
347
|
+
const describeRenderMode = res.headers.get('x-describe-render-mode');
|
|
348
|
+
return { data, describeRenderMode };
|
|
349
|
+
}
|
|
350
|
+
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Helpers for displaying declaration kinds
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @param {{ kind?: string, spelledKind?: string } | null | undefined} node
|
|
7
|
+
* @returns {string}
|
|
8
|
+
*/
|
|
9
|
+
export function getDisplayKind(node) {
|
|
10
|
+
if (!node) return '';
|
|
11
|
+
const spelled = typeof node.spelledKind === 'string' ? node.spelledKind.trim() : '';
|
|
12
|
+
if (spelled.length > 0) return formatKindLabel(spelled);
|
|
13
|
+
return typeof node.kind === 'string' ? node.kind : '';
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @param {string} label
|
|
18
|
+
* @returns {string}
|
|
19
|
+
*/
|
|
20
|
+
function formatKindLabel(label) {
|
|
21
|
+
if (!/[a-z][A-Z]/.test(label)) {
|
|
22
|
+
return label;
|
|
23
|
+
}
|
|
24
|
+
return label.replace(/([a-z])([A-Z])/g, '$1 $2').toLowerCase();
|
|
25
|
+
}
|
|
26
|
+
|
|
@@ -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,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parse a route and extract parameters
|
|
3
|
+
* @param {string} pathname
|
|
4
|
+
* @returns {{ route: string, params: Record<string, string> } | null}
|
|
5
|
+
*/
|
|
6
|
+
export function parseRoute(pathname) {
|
|
7
|
+
const path = pathname.replace(/^\/+|\/+$/g, '');
|
|
8
|
+
|
|
9
|
+
if (!path || path === '') {
|
|
10
|
+
return { route: 'home', params: {} };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const nodeMatch = path.match(/^universes\/([^/]+)\/node\/(.+)$/);
|
|
14
|
+
if (nodeMatch) {
|
|
15
|
+
return {
|
|
16
|
+
route: 'node',
|
|
17
|
+
params: {
|
|
18
|
+
universe: decodeURIComponent(nodeMatch[1]),
|
|
19
|
+
id: nodeMatch[2],
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Get current route from window.location
|
|
29
|
+
* @returns {{ route: string, params: Record<string, string> } | null}
|
|
30
|
+
*/
|
|
31
|
+
export function getCurrentRoute() {
|
|
32
|
+
return parseRoute(window.location.pathname);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function navigate(path) {
|
|
36
|
+
window.history.pushState({}, '', path);
|
|
37
|
+
window.dispatchEvent(new PopStateEvent('popstate'));
|
|
38
|
+
}
|
|
39
|
+
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { writable } from 'svelte/store';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Get system preference for color scheme
|
|
5
|
+
* @returns {'light' | 'dark'}
|
|
6
|
+
*/
|
|
7
|
+
function getSystemPreference() {
|
|
8
|
+
if (typeof window === 'undefined') return 'light';
|
|
9
|
+
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Get initial theme: manual preference from localStorage, or system preference
|
|
14
|
+
* @returns {'light' | 'dark' | 'auto'}
|
|
15
|
+
*/
|
|
16
|
+
function getInitialTheme() {
|
|
17
|
+
if (typeof localStorage === 'undefined') return 'auto';
|
|
18
|
+
const stored = localStorage.getItem('theme');
|
|
19
|
+
if (stored === 'light' || stored === 'dark') return stored;
|
|
20
|
+
return 'auto'; // 'auto' means use system preference
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Get effective theme (resolves 'auto' to system preference)
|
|
25
|
+
* @param {'light' | 'dark' | 'auto'} theme
|
|
26
|
+
* @returns {'light' | 'dark'}
|
|
27
|
+
*/
|
|
28
|
+
function getEffectiveTheme(theme) {
|
|
29
|
+
if (theme === 'auto') {
|
|
30
|
+
return getSystemPreference();
|
|
31
|
+
}
|
|
32
|
+
return theme;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const initial = getInitialTheme();
|
|
36
|
+
export const theme = writable(initial);
|
|
37
|
+
|
|
38
|
+
// Apply theme to document
|
|
39
|
+
function applyTheme(themeValue) {
|
|
40
|
+
if (typeof document === 'undefined') return;
|
|
41
|
+
const effective = getEffectiveTheme(themeValue);
|
|
42
|
+
document.documentElement.dataset.theme = effective;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Listen for system preference changes when theme is 'auto'
|
|
46
|
+
let mediaQuery = null;
|
|
47
|
+
let mediaQueryListener = null;
|
|
48
|
+
|
|
49
|
+
function setupSystemPreferenceListener() {
|
|
50
|
+
if (typeof window === 'undefined') return;
|
|
51
|
+
if (mediaQuery) return; // Already set up
|
|
52
|
+
|
|
53
|
+
mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
|
54
|
+
mediaQueryListener = () => {
|
|
55
|
+
// Re-read current theme value and reapply if it's 'auto'
|
|
56
|
+
const unsubscribe = theme.subscribe((t) => {
|
|
57
|
+
unsubscribe(); // Immediately unsubscribe to avoid memory leak
|
|
58
|
+
if (t === 'auto') {
|
|
59
|
+
applyTheme('auto');
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
};
|
|
63
|
+
mediaQuery.addEventListener('change', mediaQueryListener);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function cleanupSystemPreferenceListener() {
|
|
67
|
+
if (mediaQuery && mediaQueryListener) {
|
|
68
|
+
mediaQuery.removeEventListener('change', mediaQueryListener);
|
|
69
|
+
mediaQuery = null;
|
|
70
|
+
mediaQueryListener = null;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Subscribe to theme changes
|
|
75
|
+
let currentThemeValue = initial;
|
|
76
|
+
theme.subscribe((t) => {
|
|
77
|
+
currentThemeValue = t;
|
|
78
|
+
applyTheme(t);
|
|
79
|
+
if (typeof localStorage !== 'undefined') {
|
|
80
|
+
localStorage.setItem('theme', t);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Setup/cleanup system preference listener based on theme
|
|
84
|
+
if (t === 'auto') {
|
|
85
|
+
setupSystemPreferenceListener();
|
|
86
|
+
} else {
|
|
87
|
+
cleanupSystemPreferenceListener();
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// Initialize: apply theme and setup listener if needed
|
|
92
|
+
if (typeof window !== 'undefined') {
|
|
93
|
+
applyTheme(initial);
|
|
94
|
+
if (initial === 'auto') {
|
|
95
|
+
setupSystemPreferenceListener();
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|