@glossarist/concept-browser 0.7.68 → 0.7.71

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.
Files changed (60) hide show
  1. package/cli/index.mjs +28 -9
  2. package/package.json +14 -8
  3. package/public/favicon.svg +1 -4
  4. package/scripts/bridge-to-astro.mjs +155 -0
  5. package/scripts/generate-data.mjs +13 -0
  6. package/scripts/load-site-config.mjs +73 -1
  7. package/src/__tests__/tc204-deployment.test.ts +21 -0
  8. package/src/adapters/DatasetAdapter.ts +1 -0
  9. package/src/adapters/model-bridge.ts +50 -12
  10. package/src/adapters/non-verbal/glossarist-augment.d.ts +5 -1
  11. package/src/adapters/types.ts +1 -0
  12. package/src/assets/glossarist-logo-full.svg +1 -0
  13. package/src/astro-islands/app.ts +3 -0
  14. package/src/components/AppFooter.vue +3 -2
  15. package/src/components/AppHeader.vue +4 -7
  16. package/src/components/AppSidebar.vue +8 -5
  17. package/src/components/ConceptDetail.vue +12 -0
  18. package/src/components/DesignationList.vue +15 -4
  19. package/src/components/LanguageDetail.vue +6 -0
  20. package/src/components/RelationSphere.vue +36 -32
  21. package/src/components/site/SeoMeta.astro +23 -0
  22. package/src/components/site/SiteFooter.astro +14 -0
  23. package/src/components/site/SiteHeader.astro +25 -0
  24. package/src/components/site/SiteSidebar.astro +56 -0
  25. package/src/content.config.ts +85 -0
  26. package/src/i18n/locales/eng.yml +1 -0
  27. package/src/i18n/locales/fra.yml +1 -0
  28. package/src/islands/AppIsland.vue +7 -0
  29. package/src/islands/BibliographyIsland.vue +26 -0
  30. package/src/islands/ConceptDetailIsland.vue +27 -0
  31. package/src/islands/ConceptDiffIsland.vue +214 -0
  32. package/src/islands/ConceptIsland.vue +102 -0
  33. package/src/islands/GraphIsland.vue +15 -0
  34. package/src/islands/GroupRendererIsland.vue +15 -0
  35. package/src/islands/HeaderIsland.vue +10 -0
  36. package/src/islands/NonVerbalIsland.vue +75 -0
  37. package/src/islands/RelationSphereIsland.vue +20 -0
  38. package/src/islands/SearchIsland.vue +23 -0
  39. package/src/islands/SeriesCardIsland.vue +12 -0
  40. package/src/islands/ShortcutsIsland.vue +13 -0
  41. package/src/islands/SidebarIsland.vue +15 -0
  42. package/src/islands/SupersessionDiffIsland.vue +267 -0
  43. package/src/islands/app-entry.ts +8 -0
  44. package/src/islands/app.ts +4 -0
  45. package/src/layouts/Default.astro +70 -0
  46. package/src/lib/site-config.ts +26 -0
  47. package/src/pages/404.astro +9 -0
  48. package/src/pages/[...path].astro +29 -0
  49. package/src/pages/dataset/[id]/concept/[cid].astro +23 -0
  50. package/src/pages/dataset/[id]/index.astro +16 -0
  51. package/src/pages/graph.astro +5 -0
  52. package/src/pages/group/[id]/index.astro +16 -0
  53. package/src/pages/index.astro +10 -0
  54. package/src/pages/search.astro +5 -0
  55. package/src/styles/tailwind.css +503 -0
  56. package/src/views/ConceptView.vue +18 -23
  57. package/src/views/StatsView.vue +1 -1
  58. package/tsconfig.json +2 -1
  59. package/vite.config.ts +3 -1
  60. package/postcss.config.js +0 -13
@@ -319,17 +319,20 @@ const activeSectionId = computed(() => {
319
319
  <button
320
320
  v-else
321
321
  @click="goToDataset(ds.id)"
322
- class="w-full text-left px-3 py-2 rounded-lg text-sm border-l-2"
322
+ class="w-full text-left px-3 py-2 rounded-lg text-sm border-l-2 flex items-start gap-2"
323
323
  :class="[
324
324
  currentDataset === ds.id
325
325
  ? 'text-ink-800 dark:text-ink-50'
326
326
  : 'border-transparent text-ink-600 dark:text-ink-300 hover:bg-ink-50 dark:hover:bg-ink-700 hover:text-ink-800 dark:hover:text-ink-50'
327
327
  ]"
328
- :style="currentDataset === ds.id ? { borderLeftColor: getColor(ds.id), borderLeftWidth: '2px' } : {}"
328
+ :style="{ borderLeftColor: currentDataset === ds.id ? getColor(ds.id) : 'transparent' }"
329
329
  >
330
- <div class="font-medium truncate leading-snug">{{ localizedDatasetField(ds.id, 'title', ds.title) }}</div>
331
- <div v-if="ds.loaded" class="text-xs mt-0.5" :class="currentDataset === ds.id ? 'text-ink-400 dark:text-ink-300' : 'text-ink-300 dark:text-ink-400'">
332
- {{ ds.conceptCount.toLocaleString() }} {{ t('home.concepts').toLowerCase() }}
330
+ <span class="w-2 h-2 rounded-full flex-shrink-0 mt-1.5" :style="{ backgroundColor: getColor(ds.id) }"></span>
331
+ <div class="min-w-0 flex-1">
332
+ <div class="font-medium truncate leading-snug">{{ localizedDatasetField(ds.id, 'title', ds.title) }}</div>
333
+ <div v-if="ds.loaded" class="text-xs mt-0.5" :class="currentDataset === ds.id ? 'text-ink-400 dark:text-ink-300' : 'text-ink-300 dark:text-ink-400'">
334
+ {{ ds.conceptCount.toLocaleString() }} {{ t('home.concepts').toLowerCase() }}
335
+ </div>
333
336
  </div>
334
337
  </button>
335
338
 
@@ -448,6 +448,12 @@ const nonVerbalReps = computed(() => {
448
448
  <span v-if="!src.origin && src.modification" class="text-ink-400">{{ src.modification }}</span>
449
449
  </div>
450
450
  <div v-if="src.modification" class="text-xs text-ink-300 mt-1">{{ src.modification }}</div>
451
+ <div v-if="src.sourced_from?.length" class="text-xs text-ink-400 mt-1">
452
+ <span class="text-ink-300">{{ t('concept.sourcedFrom') }}:</span>
453
+ <div v-for="(sf, sfi) in src.sourced_from" :key="'sf'+sfi" class="ml-2">
454
+ <CitationDisplay v-if="sf" :citation="sf" :register-id="registerId" />
455
+ </div>
456
+ </div>
451
457
  </div>
452
458
  </div>
453
459
 
@@ -614,6 +620,12 @@ const nonVerbalReps = computed(() => {
614
620
  <CitationDisplay v-if="src.origin" :citation="src.origin" />
615
621
  </div>
616
622
  <div v-if="src.modification" class="text-ink-300 mt-0.5">{{ src.modification }}</div>
623
+ <div v-if="src.sourced_from?.length" class="text-ink-400 mt-0.5">
624
+ <span class="text-ink-300">{{ t('concept.sourcedFrom') }}:</span>
625
+ <div v-for="(sf, sfi) in src.sourced_from" :key="'sf'+sfi" class="ml-2">
626
+ <CitationDisplay v-if="sf" :citation="sf" />
627
+ </div>
628
+ </div>
617
629
  </div>
618
630
  </div>
619
631
  </div>
@@ -4,8 +4,11 @@ import { designationTypeInfo, normativeStatusInfo, abbreviationDetails, termType
4
4
  import { relationshipLabel } from '../utils/relationship-categories';
5
5
  import { langName } from '../utils/lang';
6
6
  import { getDesignationTarget } from '../adapters/model-bridge';
7
+ import { useI18n } from '../i18n';
7
8
  import CitationDisplay from './CitationDisplay.vue';
8
9
 
10
+ const { t } = useI18n();
11
+
9
12
  const props = defineProps<{
10
13
  designations: Designation[];
11
14
  renderedDesignations: Map<string, string>;
@@ -57,10 +60,18 @@ function resolvedLabel(dr: { content: string | null; ref?: { source: string | nu
57
60
  <span v-if="d.system" class="badge text-[10px] bg-gray-50 text-gray-600">system: {{ d.system }}</span>
58
61
  </div>
59
62
  <div v-if="d.sources?.length" class="mt-1 space-y-0.5">
60
- <div v-for="(ds, dsi) in d.sources" :key="'ds'+dsi" class="text-xs text-ink-400 flex items-center gap-1.5">
61
- <span v-if="ds.type" class="badge text-[9px]" :class="sourceTypeInfo(ds.type).color">{{ sourceTypeInfo(ds.type).label }}</span>
62
- <CitationDisplay v-if="ds.origin" :citation="ds.origin" :register-id="registerId" />
63
- <span v-else-if="ds.modification" class="text-ink-300">{{ ds.modification }}</span>
63
+ <div v-for="(ds, dsi) in d.sources" :key="'ds'+dsi" class="text-xs text-ink-400">
64
+ <div class="flex items-center gap-1.5">
65
+ <span v-if="ds.type" class="badge text-[9px]" :class="sourceTypeInfo(ds.type).color">{{ sourceTypeInfo(ds.type).label }}</span>
66
+ <CitationDisplay v-if="ds.origin" :citation="ds.origin" :register-id="registerId" />
67
+ <span v-else-if="ds.modification" class="text-ink-300">{{ ds.modification }}</span>
68
+ </div>
69
+ <div v-if="ds.sourced_from?.length" class="ml-4 mt-0.5">
70
+ <span class="text-ink-300 text-[9px]">{{ t('concept.sourcedFrom') }}:</span>
71
+ <div v-for="(sf, sfi) in ds.sourced_from" :key="'sf'+sfi" class="ml-2">
72
+ <CitationDisplay v-if="sf" :citation="sf" :register-id="registerId" />
73
+ </div>
74
+ </div>
64
75
  </div>
65
76
  </div>
66
77
  <div v-if="d.related?.length" class="mt-0.5 space-y-0.5">
@@ -176,6 +176,12 @@ function handleContentClick(e: MouseEvent) {
176
176
  <div class="text-ink-700">
177
177
  <CitationDisplay v-if="src.origin" :citation="src.origin" />
178
178
  </div>
179
+ <div v-if="src.sourced_from?.length" class="text-xs text-ink-400 mt-1">
180
+ <span class="text-ink-300">{{ t('concept.sourcedFrom') }}:</span>
181
+ <div v-for="(sf, sfi) in src.sourced_from" :key="'sf'+sfi" class="ml-2">
182
+ <CitationDisplay v-if="sf" :citation="sf" />
183
+ </div>
184
+ </div>
179
185
  </div>
180
186
  </div>
181
187
  </div>
@@ -238,6 +238,7 @@ function hashSeed(s: string): number {
238
238
  function buildGraph() {
239
239
  const focusUri = conceptUri(props.concept, props.registerId, props.manifest.uriBase);
240
240
  const focusId = props.concept?.id || props.registerId;
241
+ const focusNodeId = `${props.registerId}/${focusId}`;
241
242
  const lang = sphereLang.value;
242
243
  const maxDepth = degree.value;
243
244
 
@@ -255,7 +256,7 @@ function buildGraph() {
255
256
  props.concept?.localization?.('eng') ??
256
257
  (conceptLangs.length > 0 ? props.concept?.localization?.(conceptLangs[0]) : undefined);
257
258
  const focusNode: SNode = {
258
- id: focusId,
259
+ id: focusNodeId,
259
260
  term: getPreferredTerm(focusLc ?? null, focusId),
260
261
  ref: props.manifest.ref || props.registerId,
261
262
  register: props.registerId,
@@ -265,9 +266,9 @@ function buildGraph() {
265
266
  vx: 0, vy: 0, vz: 0,
266
267
  };
267
268
 
268
- const nodeMap = new Map<string, SNode>([[focusId, focusNode]]);
269
+ const nodeMap = new Map<string, SNode>([[focusNodeId, focusNode]]);
269
270
  const visited: Map<string, number> = new Map([[focusUri, 0]]); // uri → depth
270
- const idToUri = new Map<string, string>([[focusId, focusUri]]); // for inter-neighbor edge pass
271
+ const idToUri = new Map<string, string>([[focusNodeId, focusUri]]); // for inter-neighbor edge pass
271
272
  const resultLinks: SLink[] = [];
272
273
  const linkKeys = new Set<string>(); /* dedupe edges */
273
274
  const queue: Array<{ uri: string; depth: number; id: string }> = [];
@@ -280,7 +281,7 @@ function buildGraph() {
280
281
  const otherUri = isOutgoing ? edge.target : edge.source;
281
282
  if (!otherUri || visited.has(otherUri)) continue;
282
283
  const parsed = UriRouter.parseUri(otherUri);
283
- if (!parsed || parsed.conceptId === focusId) continue;
284
+ if (!parsed || (parsed.conceptId === focusId && parsed.registerId === props.registerId)) continue;
284
285
  visited.set(otherUri, 1);
285
286
  depth1List.push({ uri: otherUri, parsed, isOutgoing, edge });
286
287
  }
@@ -288,9 +289,10 @@ function buildGraph() {
288
289
  /* Second pass: place depth-1 nodes on an even ring around the focus. */
289
290
  const depth1Total = depth1List.length;
290
291
  depth1List.forEach(({ uri, parsed, isOutgoing, edge }, i) => {
292
+ const nid = `${parsed.registerId}/${parsed.conceptId}`;
291
293
  const pos = fibonacciSpherePosition(1, i, depth1Total, hashSeed(parsed.conceptId));
292
- nodeMap.set(parsed.conceptId, {
293
- id: parsed.conceptId,
294
+ nodeMap.set(nid, {
295
+ id: nid,
294
296
  term: edge.label || parsed.conceptId,
295
297
  ref: parsed.registerId,
296
298
  register: parsed.registerId,
@@ -299,9 +301,9 @@ function buildGraph() {
299
301
  x: pos.x, y: pos.y, z: pos.z,
300
302
  vx: 0, vy: 0, vz: 0,
301
303
  });
302
- idToUri.set(parsed.conceptId, uri);
303
- const src = isOutgoing ? focusId : parsed.conceptId;
304
- const tgt = isOutgoing ? parsed.conceptId : focusId;
304
+ idToUri.set(nid, uri);
305
+ const src = isOutgoing ? focusNodeId : nid;
306
+ const tgt = isOutgoing ? nid : focusNodeId;
305
307
  const key = `${src}\0${tgt}\0${edge.type}`;
306
308
  if (!linkKeys.has(key)) {
307
309
  linkKeys.add(key);
@@ -313,7 +315,7 @@ function buildGraph() {
313
315
  depth: 1,
314
316
  });
315
317
  }
316
- queue.push({ uri, depth: 1, id: parsed.conceptId });
318
+ queue.push({ uri, depth: 1, id: `${parsed.registerId}/${parsed.conceptId}` });
317
319
  });
318
320
 
319
321
  /* BFS deeper levels using graph engine (has cross-dataset edges) */
@@ -332,14 +334,17 @@ function buildGraph() {
332
334
  const otherUri = isOutgoing ? edge.target : edge.source;
333
335
  if (!otherUri || visited.has(otherUri)) continue;
334
336
  const parsed = UriRouter.parseUri(otherUri);
335
- if (!parsed || parsed.conceptId === focusId || nodeMap.has(parsed.conceptId)) continue;
337
+ if (!parsed) continue;
338
+ const childId = `${parsed.registerId}/${parsed.conceptId}`;
339
+ if (childId === focusNodeId || nodeMap.has(childId)) continue;
336
340
  visited.set(otherUri, depth + 1);
337
341
  children.push({ uri: otherUri, parsed, isOutgoing, edge });
338
342
  }
339
343
  children.forEach(({ uri: cUri, parsed, isOutgoing, edge }, i) => {
344
+ const cid = `${parsed.registerId}/${parsed.conceptId}`;
340
345
  const pos = fibonacciSpherePosition(depth + 1, i, children.length, hashSeed(parsed.conceptId));
341
- nodeMap.set(parsed.conceptId, {
342
- id: parsed.conceptId,
346
+ nodeMap.set(cid, {
347
+ id: cid,
343
348
  term: parsed.conceptId,
344
349
  ref: parsed.registerId,
345
350
  register: parsed.registerId,
@@ -348,9 +353,9 @@ function buildGraph() {
348
353
  x: pos.x, y: pos.y, z: pos.z,
349
354
  vx: 0, vy: 0, vz: 0,
350
355
  });
351
- idToUri.set(parsed.conceptId, cUri);
352
- const src = isOutgoing ? id : parsed.conceptId;
353
- const tgt = isOutgoing ? parsed.conceptId : id;
356
+ idToUri.set(cid, cUri);
357
+ const src = isOutgoing ? id : cid;
358
+ const tgt = isOutgoing ? cid : id;
354
359
  const key = `${src}\0${tgt}\0${edge.type}`;
355
360
  if (!linkKeys.has(key)) {
356
361
  linkKeys.add(key);
@@ -362,7 +367,7 @@ function buildGraph() {
362
367
  depth: depth + 1,
363
368
  });
364
369
  }
365
- queue.push({ uri: cUri, depth: depth + 1, id: parsed.conceptId });
370
+ queue.push({ uri: cUri, depth: depth + 1, id: cid });
366
371
  });
367
372
  }
368
373
 
@@ -795,13 +800,8 @@ function renderDOM() {
795
800
  The top bar gives instant dataset identification at any angle; the
796
801
  tinted background ties same-dataset cards together visually. */
797
802
  el.style.setProperty('--ds-color', dsColor);
798
- if (n.depth !== 0) {
799
- el.style.borderTop = `4px solid ${dsColor}`;
800
- el.style.background = `linear-gradient(180deg, color-mix(in srgb, ${dsColor} 10%, var(--surface)) 0%, var(--surface) 30%)`;
801
- } else {
802
- /* Focus keeps a solid surface — it's already distinguished by the
803
- blue ring; adding a tint would compete. */
804
- }
803
+ el.style.borderTop = `4px solid ${dsColor}`;
804
+ el.style.background = `linear-gradient(180deg, color-mix(in srgb, ${dsColor} 10%, var(--surface)) 0%, var(--surface) 30%)`;
805
805
 
806
806
  el.innerHTML = `
807
807
  <div class="sp-ref">${n.register} · ${n.conceptId}</div>
@@ -978,7 +978,7 @@ function navigate(newConceptId: string, newRegisterId: string) {
978
978
  /* ── Watch concept change ───────────────────────────────── */
979
979
  /* When props.concept changes (via store.viewConcept from sphere click,
980
980
  or external navigation), animate the transition with a SLERP tween. */
981
- let lastConceptId = '';
981
+ let lastConceptKey = '';
982
982
 
983
983
  /* UI language change → sync sphere language (unless user has explicitly
984
984
  chosen a different one via the Language selector). We track that with
@@ -1017,10 +1017,12 @@ async function rebuildForLangChange() {
1017
1017
  await loadNeighborTerms();
1018
1018
  }
1019
1019
 
1020
- watch(() => props.concept?.id, (newId) => {
1021
- if (!newId || newId === lastConceptId) return;
1022
- const wasFirst = !lastConceptId;
1023
- lastConceptId = newId;
1020
+ watch(() => [props.concept?.id, props.registerId] as const, ([newId, newReg]) => {
1021
+ if (!newId) return;
1022
+ const key = `${newReg}/${newId}`;
1023
+ if (key === lastConceptKey) return;
1024
+ const wasFirst = !lastConceptKey;
1025
+ lastConceptKey = key;
1024
1026
 
1025
1027
  if (wasFirst) {
1026
1028
  /* Initial load — fresh placement, no tween */
@@ -1290,7 +1292,7 @@ async function waitForCanvas(): Promise<void> {
1290
1292
 
1291
1293
  onMounted(async () => {
1292
1294
  window.addEventListener('resize', onResize);
1293
- lastConceptId = props.concept?.id || '';
1295
+ lastConceptKey = `${props.registerId}/${props.concept?.id || ''}`;
1294
1296
  await waitForCanvas();
1295
1297
 
1296
1298
  /* Setup pan/zoom via d3-zoom on the canvas.
@@ -1493,8 +1495,10 @@ defineExpose({ navigate });
1493
1495
  }
1494
1496
  :deep(.sp-rel) { margin-left: auto; }
1495
1497
  :deep(.sp-node.focus) {
1496
- border: 2px solid var(--blue); z-index: 20; cursor: default;
1497
- box-shadow: 0 4px 12px rgba(0,0,0,0.08), 0 0 0 4px rgba(37,99,235,0.1);
1498
+ z-index: 20; cursor: default;
1499
+ outline: 2px solid var(--blue);
1500
+ outline-offset: 3px;
1501
+ box-shadow: 0 0 12px 4px rgba(37,99,235,0.25), 0 0 24px 8px rgba(37,99,235,0.12);
1498
1502
  }
1499
1503
  :deep(.sp-focus-badge) {
1500
1504
  position: absolute; top: -10px; right: 12px; font-size: 9px; color: white;
@@ -0,0 +1,23 @@
1
+ ---
2
+ // SEO meta tags for Astro pages.
3
+ interface Props {
4
+ title: string;
5
+ description?: string;
6
+ canonicalUrl?: string;
7
+ type?: string;
8
+ image?: string;
9
+ }
10
+ const { title, description = '', canonicalUrl, type = 'website', image } = Astro.props;
11
+ const site = Astro.site?.toString().replace(/\/$/, '') || '';
12
+ const canonical = canonicalUrl || `${site}${Astro.url.pathname}`;
13
+ const ogImage = image || `${site}/og-image.png`;
14
+ ---
15
+ <meta property="og:title" content={title} />
16
+ <meta property="og:description" content={description} />
17
+ <meta property="og:type" content={type} />
18
+ <meta property="og:url" content={canonical} />
19
+ <meta property="og:image" content={ogImage} />
20
+ <meta name="twitter:card" content="summary_large_image" />
21
+ <meta name="twitter:title" content={title} />
22
+ <meta name="twitter:description" content={description} />
23
+ <link rel="canonical" href={canonical} />
@@ -0,0 +1,14 @@
1
+ ---
2
+ import { getSiteConfig } from '../../lib/site-config';
3
+ const siteConfig = await getSiteConfig();
4
+ ---
5
+ <footer class="border-t border-ink-100/60 dark:border-ink-700/40 py-6 mt-12">
6
+ <div class="max-w-[1600px] mx-auto px-4 flex items-center justify-between text-xs text-ink-400">
7
+ <span>{siteConfig.title || 'Glossarist'}</span>
8
+ <div class="flex items-center gap-4">
9
+ <a href="/about" class="hover:text-ink-600 dark:hover:text-ink-300">About</a>
10
+ <a href="/contributors" class="hover:text-ink-600 dark:hover:text-ink-300">Contributors</a>
11
+ {siteConfig.branding?.poweredBy && <span>{siteConfig.branding.poweredBy}</span>}
12
+ </div>
13
+ </div>
14
+ </footer>
@@ -0,0 +1,25 @@
1
+ ---
2
+ interface Props { title: string }
3
+ const { title } = Astro.props;
4
+ ---
5
+ <header class="border-b border-ink-100/60 dark:border-ink-700/40 bg-surface dark:bg-ink-900 sticky top-0 z-30">
6
+ <div class="max-w-[1600px] mx-auto px-4 h-14 flex items-center justify-between gap-4">
7
+ <div class="flex items-center gap-3">
8
+ <button id="sidebar-toggle" class="lg:hidden p-1.5 rounded hover:bg-ink-100/50 dark:hover:bg-ink-800/40" aria-label="Toggle sidebar">
9
+ <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/></svg>
10
+ </button>
11
+ <a href="/" class="text-lg font-bold text-ink-800 dark:text-ink-100 hover:text-blue-600 dark:hover:text-blue-400 transition-colors">
12
+ {title}
13
+ </a>
14
+ </div>
15
+ <div class="flex items-center gap-2">
16
+ <a href="/search" class="p-1.5 rounded hover:bg-ink-100/50 dark:hover:bg-ink-800/40 text-ink-500 dark:text-ink-400" aria-label="Search">
17
+ <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg>
18
+ </a>
19
+ <button id="theme-toggle" class="p-1.5 rounded hover:bg-ink-100/50 dark:hover:bg-ink-800/40 text-ink-500 dark:text-ink-400" aria-label="Toggle dark mode">
20
+ <svg class="w-5 h-5 dark:hidden" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"/></svg>
21
+ <svg class="w-5 h-5 hidden dark:block" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"/></svg>
22
+ </button>
23
+ </div>
24
+ </div>
25
+ </header>
@@ -0,0 +1,56 @@
1
+ ---
2
+ import { getCollection } from 'astro:content';
3
+ import { getSiteConfig } from '../../lib/site-config';
4
+
5
+ interface Props { activeDataset?: string; activeGroup?: string }
6
+ const { activeDataset, activeGroup } = Astro.props;
7
+
8
+ const datasets = await getCollection('datasets');
9
+ const groups = await getCollection('groups');
10
+ const siteConfig = await getSiteConfig();
11
+ ---
12
+ <aside id="sidebar" class="hidden lg:block w-64 shrink-0 border-r border-ink-100/60 dark:border-ink-700/40 min-h-[calc(100vh-3.5rem)] p-3">
13
+ <nav class="space-y-4 text-sm">
14
+ <a href="/" class={`block px-2 py-1.5 rounded ${!activeDataset && !activeGroup ? 'bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300' : 'text-ink-600 dark:text-ink-300 hover:bg-ink-50 dark:hover:bg-ink-800/40'}`}>
15
+ Home
16
+ </a>
17
+ <a href="/search" class="block px-2 py-1.5 rounded text-ink-600 dark:text-ink-300 hover:bg-ink-50 dark:hover:bg-ink-800/40">Search</a>
18
+ <a href="/graph" class="block px-2 py-1.5 rounded text-ink-600 dark:text-ink-300 hover:bg-ink-50 dark:hover:bg-ink-800/40">Graph</a>
19
+
20
+ {groups.length > 0 ? (
21
+ groups.map(g => (
22
+ <div>
23
+ <a href={`/group/${g.data.id}`} class={`block px-2 py-1.5 font-medium rounded ${activeGroup === g.data.id ? 'bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300' : 'text-ink-700 dark:text-ink-200 hover:bg-ink-50 dark:hover:bg-ink-800/40'}`}>
24
+ {g.data.label}
25
+ </a>
26
+ <ul class="mt-1 space-y-0.5 ml-2">
27
+ {g.data.datasets.map((id: string) => {
28
+ const ds = datasets.find(d => d.data.id === id);
29
+ if (!ds) return null;
30
+ return (
31
+ <li>
32
+ <a href={`/dataset/${id}`} class={`block px-2 py-1 rounded text-xs ${activeDataset === id ? 'bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-300 font-medium' : 'text-ink-500 dark:text-ink-400 hover:bg-ink-50 dark:hover:bg-ink-800/30'}`}>
33
+ {ds.data.title.length > 28 ? ds.data.title.slice(0, 25) + '…' : ds.data.title}
34
+ </a>
35
+ </li>
36
+ );
37
+ })}
38
+ </ul>
39
+ </div>
40
+ ))
41
+ ) : (
42
+ <div>
43
+ <h3 class="px-2 py-1 text-xs font-semibold text-ink-400 uppercase tracking-wider">Datasets</h3>
44
+ <ul class="space-y-0.5">
45
+ {datasets.map(ds => (
46
+ <li>
47
+ <a href={`/dataset/${ds.data.id}`} class={`block px-2 py-1 rounded text-xs ${activeDataset === ds.data.id ? 'bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-300 font-medium' : 'text-ink-500 dark:text-ink-400 hover:bg-ink-50 dark:hover:bg-ink-800/30'}`}>
48
+ {ds.data.title.length > 28 ? ds.data.title.slice(0, 25) + '…' : ds.data.title}
49
+ </a>
50
+ </li>
51
+ ))}
52
+ </ul>
53
+ </div>
54
+ )}
55
+ </nav>
56
+ </aside>
@@ -0,0 +1,85 @@
1
+ import { defineCollection, z } from 'astro:content';
2
+ import { glob } from 'astro/loaders';
3
+
4
+ const datasets = defineCollection({
5
+ loader: glob({ pattern: '**/*.json', base: './src/content/datasets' }),
6
+ schema: z.object({
7
+ id: z.string(),
8
+ title: z.string(),
9
+ description: z.string(),
10
+ uri: z.string().optional(),
11
+ uriBase: z.string().optional().default(''),
12
+ year: z.number().optional(),
13
+ status: z.string().optional(),
14
+ ref: z.string().optional(),
15
+ refAliases: z.array(z.string()).optional(),
16
+ owner: z.string().optional(),
17
+ languages: z.array(z.string()).default(['eng']),
18
+ conceptCount: z.number().default(0),
19
+ color: z.union([z.string(), z.object({ light: z.string(), dark: z.string() })]).optional(),
20
+ tags: z.array(z.string()).default([]),
21
+ sections: z.array(z.any()).optional(),
22
+ sourceRepo: z.string().optional(),
23
+ lastUpdated: z.string().optional(),
24
+ }),
25
+ });
26
+
27
+ const groups = defineCollection({
28
+ loader: glob({ pattern: '**/*.json', base: './src/content/groups' }),
29
+ schema: z.object({
30
+ id: z.string(),
31
+ label: z.string(),
32
+ kind: z.enum(['lineage', 'topic', 'family', 'collection', 'default']).default('default'),
33
+ description: z.string().optional(),
34
+ current: z.string().optional(),
35
+ datasets: z.array(z.string()).default([]),
36
+ color: z.union([z.string(), z.object({ light: z.string(), dark: z.string() })]).optional(),
37
+ }),
38
+ });
39
+
40
+ const concepts = defineCollection({
41
+ loader: glob({ pattern: '**/*.json', base: './src/content/concepts' }),
42
+ schema: z.object({
43
+ registerId: z.string(),
44
+ conceptId: z.string(),
45
+ uri: z.string(),
46
+ status: z.string().default('valid'),
47
+ designations: z.record(z.string(), z.string()).default({}),
48
+ eng: z.string().optional(),
49
+ definition: z.record(z.string(), z.string()).optional(),
50
+ groups: z.array(z.string()).default([]),
51
+ tags: z.array(z.string()).default([]),
52
+ languages: z.array(z.string()).optional(),
53
+ localizations: z.record(z.string(), z.object({
54
+ languageCode: z.string().optional(),
55
+ terms: z.array(z.union([
56
+ z.string(),
57
+ z.object({ designation: z.string(), normativeStatus: z.string().optional() }),
58
+ ])).default([]),
59
+ definitions: z.array(z.union([
60
+ z.string(),
61
+ z.object({ content: z.string() }),
62
+ ])).default([]),
63
+ notes: z.array(z.union([
64
+ z.string(),
65
+ z.object({ content: z.string() }),
66
+ ])).default([]),
67
+ examples: z.array(z.union([
68
+ z.string(),
69
+ z.object({ content: z.string() }),
70
+ ])).default([]),
71
+ })).optional(),
72
+ }),
73
+ });
74
+
75
+ const pages = defineCollection({
76
+ loader: glob({ pattern: '**/*.{md,json}', base: './src/content/pages' }),
77
+ schema: z.object({
78
+ title: z.string(),
79
+ type: z.string().optional(),
80
+ html: z.string().optional(),
81
+ route: z.string().optional(),
82
+ }),
83
+ });
84
+
85
+ export const collections = { datasets, groups, concepts, pages };
@@ -64,6 +64,7 @@ concept.metadata: Metadata
64
64
  concept.noData: No data available for this language.
65
65
  concept.lifecycleDates: Lifecycle dates
66
66
  concept.conceptSources: Concept sources
67
+ concept.sourcedFrom: Sourced from
67
68
  concept.incoming: Incoming
68
69
  concept.outgoing: Outgoing
69
70
  concept.lang: lang
@@ -64,6 +64,7 @@ concept.metadata: Métadonnées
64
64
  concept.noData: Aucune donnée disponible pour cette langue.
65
65
  concept.lifecycleDates: Dates du cycle de vie
66
66
  concept.conceptSources: Sources du concept
67
+ concept.sourcedFrom: Issu de
67
68
  concept.incoming: Entrantes
68
69
  concept.outgoing: Sortantes
69
70
  concept.lang: lang.
@@ -0,0 +1,7 @@
1
+ <template>
2
+ <App />
3
+ </template>
4
+
5
+ <script setup lang="ts">
6
+ import App from '../App.vue';
7
+ </script>
@@ -0,0 +1,26 @@
1
+ <template>
2
+ <section v-if="entries.length" class="bibliography-island">
3
+ <h2 class="text-sm font-semibold text-ink-500 uppercase tracking-wider mb-3">Bibliography</h2>
4
+ <ul class="space-y-1">
5
+ <li v-for="entry in entries" :key="entry.id" class="text-sm flex items-start gap-2">
6
+ <span class="text-ink-400 font-mono text-xs shrink-0">[{{ entry.id }}]</span>
7
+ <div>
8
+ <span class="text-ink-600 dark:text-ink-300">{{ entry.reference }}</span>
9
+ <a v-if="entry.link" :href="entry.link" target="_blank" class="text-blue-600 hover:underline text-xs ml-2">↗</a>
10
+ </div>
11
+ </li>
12
+ </ul>
13
+ </section>
14
+ </template>
15
+
16
+ <script setup lang="ts">
17
+ defineProps<{
18
+ entries: Array<{
19
+ id: string;
20
+ reference: string;
21
+ title?: string;
22
+ link?: string;
23
+ type?: string;
24
+ }>;
25
+ }>();
26
+ </script>
@@ -0,0 +1,27 @@
1
+ <template>
2
+ <!-- Full concept detail using the REAL ConceptDetail.vue -->
3
+ <ErrorBoundary title="Concept view failed" :retry-key="conceptUri">
4
+ <ConceptDetail
5
+ :concept="concept"
6
+ :register-id="registerId"
7
+ :concept-uri="conceptUri"
8
+ />
9
+ </ErrorBoundary>
10
+ </template>
11
+
12
+ <script setup lang="ts">
13
+ import { onErrorCaptured } from 'vue';
14
+ import ConceptDetail from '../components/ConceptDetail.vue';
15
+ import ErrorBoundary from '../components/ErrorBoundary.vue';
16
+
17
+ defineProps<{
18
+ concept: any;
19
+ registerId: string;
20
+ conceptUri: string;
21
+ }>();
22
+
23
+ onErrorCaptured((err) => {
24
+ console.error('ConceptDetail island error:', err);
25
+ return false;
26
+ });
27
+ </script>