@glossarist/concept-browser 0.7.69 → 0.7.73

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 (57) hide show
  1. package/astro.config.mjs +55 -0
  2. package/cli/index.mjs +28 -9
  3. package/package.json +15 -9
  4. package/public/favicon.svg +1 -4
  5. package/scripts/bridge-to-astro.mjs +149 -0
  6. package/scripts/generate-data.mjs +12 -0
  7. package/src/adapters/model-bridge.ts +50 -12
  8. package/src/adapters/non-verbal/glossarist-augment.d.ts +5 -1
  9. package/src/assets/glossarist-logo-full.svg +1 -0
  10. package/src/astro-islands/app.ts +3 -0
  11. package/src/components/AppFooter.vue +3 -2
  12. package/src/components/AppHeader.vue +4 -7
  13. package/src/components/AppSidebar.vue +8 -5
  14. package/src/components/ConceptDetail.vue +12 -0
  15. package/src/components/DesignationList.vue +15 -4
  16. package/src/components/LanguageDetail.vue +6 -0
  17. package/src/components/RelationSphere.vue +36 -32
  18. package/src/components/site/SeoMeta.astro +23 -0
  19. package/src/components/site/SiteFooter.astro +14 -0
  20. package/src/components/site/SiteHeader.astro +25 -0
  21. package/src/components/site/SiteSidebar.astro +56 -0
  22. package/src/content.config.ts +88 -0
  23. package/src/i18n/locales/eng.yml +1 -0
  24. package/src/i18n/locales/fra.yml +1 -0
  25. package/src/islands/AppIsland.vue +7 -0
  26. package/src/islands/BibliographyIsland.vue +26 -0
  27. package/src/islands/ConceptDetailIsland.vue +27 -0
  28. package/src/islands/ConceptDiffIsland.vue +214 -0
  29. package/src/islands/ConceptIsland.vue +102 -0
  30. package/src/islands/GraphIsland.vue +15 -0
  31. package/src/islands/GroupRendererIsland.vue +15 -0
  32. package/src/islands/HeaderIsland.vue +10 -0
  33. package/src/islands/NonVerbalIsland.vue +75 -0
  34. package/src/islands/RelationSphereIsland.vue +20 -0
  35. package/src/islands/SearchIsland.vue +23 -0
  36. package/src/islands/SeriesCardIsland.vue +12 -0
  37. package/src/islands/ShortcutsIsland.vue +13 -0
  38. package/src/islands/SidebarIsland.vue +15 -0
  39. package/src/islands/SupersessionDiffIsland.vue +267 -0
  40. package/src/islands/app-entry.ts +8 -0
  41. package/src/islands/app.ts +4 -0
  42. package/src/layouts/Default.astro +70 -0
  43. package/src/lib/site-config.ts +26 -0
  44. package/src/pages/404.astro +9 -0
  45. package/src/pages/[...path].astro +29 -0
  46. package/src/pages/dataset/[id]/concept/[cid].astro +23 -0
  47. package/src/pages/dataset/[id]/index.astro +16 -0
  48. package/src/pages/graph.astro +5 -0
  49. package/src/pages/group/[id]/index.astro +16 -0
  50. package/src/pages/index.astro +10 -0
  51. package/src/pages/search.astro +5 -0
  52. package/src/styles/tailwind.css +503 -0
  53. package/src/views/ConceptView.vue +18 -23
  54. package/src/views/StatsView.vue +1 -1
  55. package/tsconfig.json +2 -1
  56. package/vite.config.ts +3 -1
  57. package/postcss.config.js +0 -13
@@ -0,0 +1,55 @@
1
+ import { defineConfig } from 'astro/config';
2
+ import vue from '@astrojs/vue';
3
+ import sitemap from '@astrojs/sitemap';
4
+ import tailwindcss from '@tailwindcss/vite';
5
+ import yaml from 'js-yaml';
6
+ import { readFileSync } from 'fs';
7
+ import { fileURLToPath } from 'url';
8
+ import { resolve, dirname } from 'path';
9
+
10
+ const __dirname = dirname(fileURLToPath(import.meta.url));
11
+ const pkg = JSON.parse(readFileSync(resolve(__dirname, 'package.json'), 'utf-8'));
12
+ const pkgVersion = pkg.version;
13
+
14
+ const site = process.env.SITE_URL || 'https://www.geolexica.org';
15
+ const base = process.env.BASE_PATH || '/';
16
+
17
+ export default defineConfig({
18
+ root: __dirname,
19
+ site,
20
+ base,
21
+ output: 'static',
22
+ publicDir: resolve(process.cwd(), 'public'),
23
+ integrations: [
24
+ vue({ appEntrypoint: '/src/islands/app-entry' }),
25
+ sitemap(),
26
+ ],
27
+ i18n: {
28
+ defaultLocale: 'eng',
29
+ locales: ['eng', 'fra', 'deu', 'jpn', 'ara', 'zho', 'rus', 'spa', 'kor', 'swe', 'pol', 'fin', 'dan', 'nld', 'msa'],
30
+ routing: {
31
+ prefixDefaultLocale: false,
32
+ },
33
+ },
34
+ prefetch: {
35
+ prefetchAll: true,
36
+ defaultStrategy: 'viewport',
37
+ },
38
+ vite: {
39
+ plugins: [
40
+ tailwindcss(),
41
+ {
42
+ name: 'yaml-transform',
43
+ enforce: 'pre',
44
+ transform(code, id) {
45
+ if (!id.endsWith('.yml') && !id.endsWith('.yaml')) return;
46
+ const data = yaml.load(code);
47
+ return { code: `export default ${JSON.stringify(data)}`, map: null };
48
+ },
49
+ },
50
+ ],
51
+ define: {
52
+ __CONCEPT_BROWSER_VERSION__: JSON.stringify(pkgVersion),
53
+ },
54
+ },
55
+ });
package/cli/index.mjs CHANGED
@@ -173,15 +173,34 @@ Environment:
173
173
  }
174
174
  }
175
175
 
176
- // Run vite build using the package's vite.config.ts via programmatic API
177
- console.log(`\n=== BUILD SPA ===\n`);
178
- const viteConfig = resolve(pkgRoot, 'vite.config.ts');
179
- const { build: viteBuild } = await import('vite');
180
- await viteBuild({
181
- configFile: viteConfig,
182
- root: pkgRoot,
183
- mode: 'production',
184
- });
176
+ // Bridge data to Astro content collections
177
+ console.log(`\n=== BRIDGE DATA ===\n`);
178
+ const bridge = resolve(pkgRoot, 'scripts', 'bridge-to-astro.mjs');
179
+ if (existsSync(bridge)) {
180
+ await import(`file://${bridge}`);
181
+ }
182
+
183
+ // Run Astro build (TODO.astro/22)
184
+ console.log(`\n=== BUILD ASTRO ===\n`);
185
+ const astroConfig = resolve(pkgRoot, 'astro.config.mjs');
186
+ if (existsSync(astroConfig)) {
187
+ try {
188
+ const { build: astroBuild } = await import('astro');
189
+ await astroBuild({ root: pkgRoot, logLevel: 'info' });
190
+ } catch (e) {
191
+ console.warn(` Astro build failed (${e.message}), falling back to Vite SPA`);
192
+ console.log(`\n=== BUILD SPA ===\n`);
193
+ const viteConfig = resolve(pkgRoot, 'vite.config.ts');
194
+ const { build: viteBuild } = await import('vite');
195
+ await viteBuild({ configFile: viteConfig, root: pkgRoot, mode: 'production' });
196
+ }
197
+ } else {
198
+ // Fall back to Vite SPA if Astro config doesn't exist
199
+ console.log(`\n=== BUILD SPA ===\n`);
200
+ const viteConfig = resolve(pkgRoot, 'vite.config.ts');
201
+ const { build: viteBuild } = await import('vite');
202
+ await viteBuild({ configFile: viteConfig, root: pkgRoot, mode: 'production' });
203
+ }
185
204
 
186
205
  // Run postbuild (404 page) via dynamic import
187
206
  const postbuild = resolve(pkgRoot, 'scripts', 'generate-404.js');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glossarist/concept-browser",
3
- "version": "0.7.69",
3
+ "version": "0.7.73",
4
4
  "description": "Vue SPA for browsing Glossarist terminology datasets with cross-reference resolution, graph visualization, and multi-language support",
5
5
  "type": "module",
6
6
  "bin": {
@@ -23,29 +23,34 @@
23
23
  "test": "vitest run",
24
24
  "test:watch": "vitest",
25
25
  "mutation:test": "stryker run stryker.config.mjs",
26
- "validate:shacl": "node scripts/validate-shacl.mjs"
26
+ "validate:shacl": "node scripts/validate-shacl.mjs",
27
+ "dev:astro": "astro dev",
28
+ "build:astro": "astro build",
29
+ "preview:astro": "astro preview"
27
30
  },
28
31
  "dependencies": {
32
+ "@astrojs/sitemap": "^3.7.3",
33
+ "@astrojs/vue": "^7.0.1",
29
34
  "@plurimath/plurimath": "^0.2.2",
30
- "@vitejs/plugin-vue": "^5.2.3",
31
- "autoprefixer": "^10.4.21",
35
+ "@vitejs/plugin-vue": "^6.0.7",
36
+ "astro": "^7.0.7",
32
37
  "d3": "^7.9.0",
33
38
  "favicons": "^7.2.0",
34
- "glossarist": "^0.4.15",
39
+ "glossarist": "^0.4.17",
35
40
  "js-yaml": "^4.1.0",
36
41
  "jszip": "^3.10.1",
37
42
  "pinia": "^2.3.1",
38
- "postcss": "^8.5.3",
39
43
  "sharp": "^0.34.5",
40
- "tailwindcss": "^3.4.17",
41
- "vite": "^6.3.5",
44
+ "vite": "^8.1.4",
42
45
  "vue": "^3.5.13",
43
46
  "vue-router": "^4.5.1"
44
47
  },
45
48
  "devDependencies": {
49
+ "@astrojs/check": "^0.9.9",
46
50
  "@rdfjs/dataset": "^2.0.2",
47
51
  "@stryker-mutator/core": "^9.6.1",
48
52
  "@stryker-mutator/vitest-runner": "^9.6.1",
53
+ "@tailwindcss/vite": "^4.3.2",
49
54
  "@types/d3": "^7.4.3",
50
55
  "@types/n3": "^1.26.1",
51
56
  "@vue/test-utils": "^2.4.8",
@@ -53,6 +58,7 @@
53
58
  "happy-dom": "^20.9.0",
54
59
  "n3": "^1.26.0",
55
60
  "rdf-validate-shacl": "^0.4.5",
61
+ "tailwindcss": "^4.3.2",
56
62
  "typescript": "~5.7.3",
57
63
  "vitest": "^4.1.5",
58
64
  "vue-tsc": "^2.2.8"
@@ -87,11 +93,11 @@
87
93
  "src",
88
94
  "data",
89
95
  "public/favicon.svg",
96
+ "astro.config.mjs",
90
97
  "index.html",
91
98
  "vite.config.ts",
92
99
  "tsconfig.json",
93
100
  "tailwind.config.js",
94
- "postcss.config.js",
95
101
  "env.d.ts"
96
102
  ]
97
103
  }
@@ -1,4 +1 @@
1
- <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
2
- <rect width="32" height="32" rx="6" fill="#2563eb"/>
3
- <text x="16" y="22" text-anchor="middle" font-family="system-ui" font-weight="bold" font-size="18" fill="white">G</text>
4
- </svg>
1
+ <?xml version="1.0" encoding="UTF-8"?><svg id="uuid-e2e4f56e-6766-4eaf-8ba1-80c056213369" xmlns="http://www.w3.org/2000/svg" width="183.1" height="172.48" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 183.1 172.48"><defs><linearGradient id="uuid-526bfbac-cffb-4a71-b92d-c02fefae5080" x1="51.31" y1="161.8" x2="102.32" y2="73.02" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#3fb6b0"/><stop offset=".07" stop-color="#5cc5be"/><stop offset=".15" stop-color="#78d3cc"/><stop offset=".24" stop-color="#8fe0d8"/><stop offset=".34" stop-color="#a1e9e1"/><stop offset=".47" stop-color="#aef0e7"/><stop offset=".63" stop-color="#b5f3ea"/><stop offset="1" stop-color="#b8f5ec"/></linearGradient><linearGradient id="uuid-1b1e62d8-3f5c-498e-8428-628e5706abf6" x1="0" y1="59.58" x2="183.1" y2="59.58" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#2d4164"/><stop offset=".47" stop-color="#2d4164" stop-opacity=".94"/><stop offset="1" stop-color="#2d4164" stop-opacity=".8"/></linearGradient><linearGradient id="uuid-b44d9113-c74c-48d0-8db3-7071ba63ee91" x1="71.68" y1="31.06" x2="111.41" y2="31.06" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#2d4164"/><stop offset=".28" stop-color="#2c4365"/><stop offset="1" stop-color="#588ac1"/></linearGradient><linearGradient id="uuid-ef83f354-a6ba-455c-a262-42ad1af8aab0" x1="76.66" y1="69.37" x2="183.1" y2="69.37" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#34798a" stop-opacity=".7"/><stop offset="1" stop-color="#62c4dd"/></linearGradient><linearGradient id="uuid-9c4f3d11-41f0-40bc-8383-a054e148eefb" x1="81.4" y1="129.46" x2="127.22" y2="50.28" gradientUnits="userSpaceOnUse"><stop offset=".2" stop-color="#456399"/><stop offset="1" stop-color="#3a5c80" stop-opacity=".7"/></linearGradient><linearGradient id="uuid-75c138e5-3eb7-4084-88c8-ea7b28bec669" x1="88.72" y1="146.48" x2="54.34" y2="146.48" xlink:href="#uuid-b44d9113-c74c-48d0-8db3-7071ba63ee91"/><linearGradient id="uuid-648153de-b6a2-4cdb-bf68-e5b0950e981e" x1="86.16" y1="99.24" x2="47.06" y2="25.85" gradientUnits="userSpaceOnUse"><stop offset=".2" stop-color="#456399"/><stop offset="1" stop-color="#3a5c80" stop-opacity=".7"/></linearGradient><linearGradient id="uuid-e1874ccf-ad77-48dc-8e9c-b19bc34aaaf5" x1="89.58" y1="107.94" x2="63.88" y2="57.26" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#456399" stop-opacity=".5"/><stop offset="1" stop-color="#97a4b7" stop-opacity=".8"/></linearGradient></defs><path d="m87.41,59.48l-61.18,106.11c-1.63,2.85.6,6.85,3.98,6.88h37.88l63.56-112.75-44.23-.25Z" style="fill:url(#uuid-526bfbac-cffb-4a71-b92d-c02fefae5080); stroke-width:0px;"/><path d="m40.39,141.03c.29.28,1.01,2.7,1.61,4.91.67,2.49,2.77,4.38,5.33,4.68,21.2,2.5,21.4,20.71,21.4,20.71l22.5-39.92c-23.71,14.02-41.88,6.43-40.64-8.09-2.71,4.71-7.5,12.99-10.21,17.7Z" style="fill:#3a5c80; stroke-width:0px;"/><path d="m75.5,80.4s-14.96,25.41-18.41,31.59c8.71-8.42,17.69,2.79,24.11-8.66,8.13-14.5-5.7-22.93-5.7-22.93Z" style="fill:#60abbc; stroke-width:0px;"/><path d="m113.82,172.48h38.28c4.52-.01,6.66-3.57,4.43-7.48-6.55-11.51-59.96-105.41-59.96-105.41l-43.04-.37,60.29,113.27Z" style="fill:#b8f5ec; stroke-width:0px;"/><path d="m106.47,91.98c-9.24,2.3-34.69,12.42-21.32,20.21,12.57,7.31,28.11.2,32.05-11.73,1.79-4.66-2.29-10.67-10.74-8.47Z" style="fill:#3fb6b0; stroke-width:0px;"/><path d="m137.32,131.2l-13.09-23.07c.37,24.63-35.5,17.21-35.5,17.21l24.95,46.87c-1-25.77,10.54-35.8,23.65-41Z" style="fill:#7ee0d4; stroke-width:0px;"/><path d="m57.35,132.11s0-.01.01-.02c0,0,0,0,0,0" style="fill:#3a5c80; stroke-width:0px;"/><path d="m183.1,52.52c-.1-3.25-2.07-5.56-6-5.59-13.09-.07-163.15,0-163.15,0L0,72.26h183.1v-19.75Z" style="fill:url(#uuid-1b1e62d8-3f5c-498e-8428-628e5706abf6); stroke-width:0px;"/><path d="m71.68,47s19.67-31.93,27.9-31.9c10.36,0,15.93,31.9,8.24,31.83l-36.15.07Z" style="fill:url(#uuid-b44d9113-c74c-48d0-8db3-7071ba63ee91); stroke-width:0px;"/><path d="m183.1,59.91l-.13,10.94c.07,6.28-2.04,8.39-8.33,8.31h-97.99l10.97-19.59,95.47.33Z" style="fill:url(#uuid-ef83f354-a6ba-455c-a262-42ad1af8aab0); opacity:.78; stroke-width:0px;"/><polygon points="7.1 59.58 0 72.26 80.52 72.26 87.63 59.58 7.1 59.58" style="fill:#3a5c80; opacity:.8; stroke-width:0px;"/><path d="m117.54,47s-65.24,112.78-69.85,120.82c-1.1,1.92.38,4.63,2.65,4.65.71,0,17.74,0,17.74,0l70.73-125.47h-21.27Z" style="fill:url(#uuid-9c4f3d11-41f0-40bc-8383-a054e148eefb); opacity:.8; stroke-width:0px;"/><path d="m84.52,142.79c.55-.64.79-1.48,1.27-2.18,10.98-16.02-12.03-16.74-17.61-8.28l-12.79,23.17c-.14.24-.26.48-.38.72-3.39,7.29,6.71,13.38,11.92,7.26l17.59-20.69Z" style="fill:url(#uuid-75c138e5-3eb7-4084-88c8-ea7b28bec669); opacity:.8; stroke-width:0px;"/><path d="m124.71,126.57s0-.01-.01-.02c0,0,0,0,0,0,0,0,0,.02,0,.02Z" style="fill:#2e4365; stroke-width:0px;"/><polygon points="136.69 172.48 113.82 172.48 53.53 59.58 73.24 59.58 136.69 172.48" style="fill:url(#uuid-648153de-b6a2-4cdb-bf68-e5b0950e981e); opacity:.75; stroke-width:0px;"/><path d="m73.39,59.58l33.44,59.63c2.82,7.65-7.17,26.67-12.28,17.06L53.53,59.58h19.86Z" style="fill:url(#uuid-e1874ccf-ad77-48dc-8e9c-b19bc34aaaf5); stroke-width:0px;"/><path d="m115.87,135.34l18.22,32.5s-36.61-14.66-18.22-32.5Z" style="fill:#4977a4; stroke-width:0px;"/></svg>
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Data bridge: converts public/data/*.json into Astro content collections.
3
+ *
4
+ * Reads from the CONSUMER's CWD: public/data/datasets.json, public/data/{id}/manifest.json
5
+ * Writes to the CONSUMER's CWD: .cb-content/{datasets,groups,concepts,pages}/
6
+ *
7
+ * The package directory (node_modules/@glossarist/concept-browser/) is NEVER written to.
8
+ */
9
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync } from 'fs';
10
+ import { join } from 'path';
11
+
12
+ const CWD = process.cwd();
13
+ const PUBLIC_DATA = join(CWD, 'public', 'data');
14
+ const PUBLIC_ROOT = join(CWD, 'public');
15
+ const CONTENT_DIR = join(CWD, '.cb-content');
16
+
17
+ function readJson(p) {
18
+ if (!existsSync(p)) return null;
19
+ return JSON.parse(readFileSync(p, 'utf-8'));
20
+ }
21
+
22
+ function writeJson(dir, name, data) {
23
+ mkdirSync(dir, { recursive: true });
24
+ writeFileSync(join(dir, name), JSON.stringify(data, null, 2));
25
+ }
26
+
27
+ // --- Datasets ---
28
+ const registryPath = join(PUBLIC_ROOT, 'datasets.json');
29
+ const siteConfigPath = join(PUBLIC_ROOT, 'site-config.json');
30
+ const registry = readJson(registryPath) || [];
31
+ const siteConfig = readJson(siteConfigPath) || {};
32
+
33
+ console.log(`Bridging ${registry.length} datasets to content collections...`);
34
+
35
+ for (const reg of registry) {
36
+ const id = reg.id;
37
+ const manifestPath = join(PUBLIC_DATA, id, 'manifest.json');
38
+ const manifest = readJson(manifestPath);
39
+ if (!manifest) {
40
+ console.warn(` Skipping ${id}: no manifest.json`);
41
+ continue;
42
+ }
43
+
44
+ const summary = reg.summary || {};
45
+ const entry = {
46
+ id: manifest.id || id,
47
+ title: summary.title || manifest.title || id,
48
+ description: summary.description || manifest.description || '',
49
+ uri: manifest.datasetUri || reg.datasetUri || '',
50
+ uriBase: manifest.uriBase || reg.uriBase || '',
51
+ year: manifest.year ?? summary.year ?? undefined,
52
+ status: manifest.status || summary.status || 'valid',
53
+ ref: manifest.ref || reg.ref || undefined,
54
+ refAliases: manifest.refAliases || reg.refAliases || undefined,
55
+ owner: summary.owner || manifest.owner || '',
56
+ languages: manifest.languages || summary.languages || ['eng'],
57
+ conceptCount: manifest.conceptCount || summary.conceptCount || 0,
58
+ color: summary.color || manifest.color || undefined,
59
+ tags: summary.tags || manifest.tags || [],
60
+ sections: manifest.sections || [],
61
+ sourceRepo: manifest.sourceRepo || '',
62
+ lastUpdated: manifest.lastUpdated || '',
63
+ };
64
+
65
+ // Remove undefined values
66
+ Object.keys(entry).forEach(k => entry[k] === undefined && delete entry[k]);
67
+
68
+ writeJson(join(CONTENT_DIR, 'datasets'), `${id}.json`, entry);
69
+ console.log(` Dataset: ${id} (${entry.conceptCount} concepts)`);
70
+
71
+ // --- Concepts for this dataset ---
72
+ const indexPath = join(PUBLIC_DATA, id, 'index.json');
73
+ const index = readJson(indexPath);
74
+ if (index && index.concepts) {
75
+ for (const concept of index.concepts) {
76
+ if (!concept) continue;
77
+ const conceptEntry = {
78
+ registerId: id,
79
+ conceptId: concept.id,
80
+ uri: `${entry.uriBase || ''}/${id}/concept/${concept.id}`,
81
+ status: concept.status || 'valid',
82
+ designations: concept.designations || {},
83
+ eng: concept.eng || concept.designations?.eng || '',
84
+ groups: concept.groups || [],
85
+ tags: [],
86
+ };
87
+
88
+ const fullConceptPath = join(PUBLIC_DATA, id, 'concepts', `${concept.id}.json`);
89
+ const fullConcept = readJson(fullConceptPath);
90
+ if (fullConcept) {
91
+ const lc = fullConcept['gl:localizedConcept'] || fullConcept.localizedConcept || {};
92
+ const localizations = {};
93
+ for (const [lang, loc] of Object.entries(lc)) {
94
+ localizations[lang] = {
95
+ languageCode: loc['gl:languageCode'] || lang,
96
+ terms: (loc['gl:designation'] || []).map(d => ({ designation: d['gl:term'] || d.term || '', normativeStatus: d['gl:normativeStatus'] || d.normativeStatus })).filter(d => d.designation),
97
+ definitions: (loc['gl:definition'] || []).map(d => ({ content: d['gl:content'] || d.content || '' })).filter(d => d.content),
98
+ notes: (loc['gl:notes'] || []).map(n => ({ content: n['gl:content'] || n.content || '' })).filter(n => n.content),
99
+ examples: (loc['gl:examples'] || []).map(e => ({ content: e['gl:content'] || e.content || '' })).filter(e => e.content),
100
+ };
101
+ }
102
+ conceptEntry.localizations = localizations;
103
+ conceptEntry.languages = Object.keys(localizations);
104
+ }
105
+
106
+ writeJson(
107
+ join(CONTENT_DIR, 'concepts', id),
108
+ `${concept.id}.json`,
109
+ conceptEntry
110
+ );
111
+ }
112
+ console.log(` ${index.concepts.filter(Boolean).length} concepts bridged`);
113
+ }
114
+ }
115
+
116
+ // --- Groups ---
117
+ const groups = siteConfig.datasetGroups || [];
118
+ for (const g of groups) {
119
+ const entry = {
120
+ id: g.id,
121
+ label: g.label || g.id,
122
+ kind: g.kind || (g.series ? 'lineage' : 'default'),
123
+ description: g.description || '',
124
+ current: g.current || undefined,
125
+ datasets: g.datasets || [],
126
+ color: g.color || undefined,
127
+ };
128
+ Object.keys(entry).forEach(k => entry[k] === undefined && delete entry[k]);
129
+ writeJson(join(CONTENT_DIR, 'groups'), `${g.id}.json`, entry);
130
+ console.log(` Group: ${g.id} (${entry.datasets.length} datasets)`);
131
+ }
132
+
133
+ // --- Pages (from public/pages/*.json) ---
134
+ const pagesDir = join(CWD, 'public', 'pages');
135
+ if (existsSync(pagesDir)) {
136
+ mkdirSync(join(CONTENT_DIR, 'pages'), { recursive: true });
137
+ for (const file of readdirSync(pagesDir)) {
138
+ if (!file.endsWith('.json')) continue;
139
+ const page = readJson(join(pagesDir, file));
140
+ if (!page) continue;
141
+ // Write as a minimal markdown file for the pages content collection
142
+ const slug = file.replace('.json', '');
143
+ const md = `---\ntitle: "${page.title || slug}"\ntype: "page"\n---\n\n${page.html || ''}\n`;
144
+ writeFileSync(join(CONTENT_DIR, 'pages', `${slug}.md`), md);
145
+ }
146
+ console.log(' Pages bridged from public/pages/');
147
+ }
148
+
149
+ console.log('Data bridge complete.');
@@ -273,6 +273,7 @@ function sourcesToJsonLd(sources) {
273
273
  if (s.id) doc['gl:id'] = s.id;
274
274
  if (s.type) doc['gl:sourceType'] = s.type;
275
275
  if (s.status) doc['gl:sourceStatus'] = s.status;
276
+ if (s.modification) doc['gl:modification'] = s.modification;
276
277
  if (s.origin) {
277
278
  const origin = { '@type': 'gl:Citation' };
278
279
  const ref = refToJsonLd(s.origin.ref);
@@ -282,6 +283,17 @@ function sourcesToJsonLd(sources) {
282
283
  if (s.origin.link) origin['gl:link'] = s.origin.link;
283
284
  doc['gl:origin'] = origin;
284
285
  }
286
+ if (s.sourced_from && s.sourced_from.length) {
287
+ doc['gl:sourcedFrom'] = s.sourced_from.map(sf => {
288
+ const cite = { '@type': 'gl:Citation' };
289
+ const ref = refToJsonLd(sf.ref);
290
+ if (ref) cite['gl:ref'] = ref;
291
+ const loc = localityToJsonLd(sf.locality);
292
+ if (loc) cite['gl:locality'] = loc;
293
+ if (sf.link) cite['gl:link'] = sf.link;
294
+ return cite;
295
+ });
296
+ }
285
297
  return doc;
286
298
  });
287
299
  }
@@ -103,6 +103,8 @@ interface JsonLdSource {
103
103
  'gl:sourceStatus'?: string;
104
104
  'gl:modification'?: string;
105
105
  'gl:origin'?: JsonLdOrigin;
106
+ 'gl:sourcedFrom'?: JsonLdOrigin[];
107
+ 'gl:sourced_from'?: JsonLdOrigin[];
106
108
  }
107
109
 
108
110
  interface JsonLdRelated {
@@ -216,6 +218,21 @@ export function getRelatedCitation(rc: object): Record<string, unknown> | null {
216
218
  // Relationship types whose target is a designation string, not a concept ref.
217
219
  const DESIGNATION_REL_TYPES = new Set(['abbreviated_form_for', 'short_form_for']);
218
220
 
221
+ function attachSourcedFromToSources(
222
+ sources: readonly { sourced_from?: unknown }[],
223
+ rawSources: unknown,
224
+ ): void {
225
+ if (!Array.isArray(rawSources)) return;
226
+ for (let i = 0; i < sources.length && i < rawSources.length; i++) {
227
+ const raw = rawSources[i] as Record<string, unknown> | undefined;
228
+ if (raw?.sourced_from && Array.isArray(raw.sourced_from)) {
229
+ (sources[i] as { sourced_from: unknown }).sourced_from = raw.sourced_from.map(
230
+ (sf: unknown) => sf instanceof Citation ? sf : Citation.fromJSON(sf as Record<string, unknown>)
231
+ );
232
+ }
233
+ }
234
+ }
235
+
219
236
  function attachBridges(concept: Concept, localizations: Record<string, unknown>): void {
220
237
  for (const lang of concept.languages) {
221
238
  const lc = concept.localization(lang);
@@ -223,6 +240,9 @@ function attachBridges(concept: Concept, localizations: Record<string, unknown>)
223
240
  if (!lc || !raw || typeof raw !== 'object') continue;
224
241
  const rawObj = raw as Record<string, unknown>;
225
242
 
243
+ // Attach sourced_from to localization-level sources
244
+ attachSourcedFromToSources(lc.sources as unknown as { sourced_from?: unknown }[], rawObj.sources);
245
+
226
246
  // Annotations
227
247
  const annList = rawObj.annotations;
228
248
  if (Array.isArray(annList) && annList.length > 0) {
@@ -234,7 +254,8 @@ function attachBridges(concept: Concept, localizations: Record<string, unknown>)
234
254
  // Designation-level relationship targets, ref text, sourceId, citation
235
255
  const rawTerms = rawObj.terms;
236
256
  if (Array.isArray(rawTerms)) {
237
- for (const rawTerm of rawTerms) {
257
+ for (let ti = 0; ti < rawTerms.length; ti++) {
258
+ const rawTerm = rawTerms[ti];
238
259
  if (!rawTerm || typeof rawTerm !== 'object') continue;
239
260
  const rawT = rawTerm as Record<string, unknown>;
240
261
  const rawDesignation = rawT.designation as string | undefined;
@@ -244,6 +265,11 @@ function attachBridges(concept: Concept, localizations: Record<string, unknown>)
244
265
  const rawRelated = rawT.related;
245
266
  if (!Array.isArray(rawRelated)) continue;
246
267
  attachRelatedBridges(designation.related, rawRelated);
268
+ // Attach sourced_from to designation-level sources
269
+ attachSourcedFromToSources(
270
+ designation.sources as unknown as { sourced_from?: unknown }[],
271
+ rawT.sources,
272
+ );
247
273
  }
248
274
  }
249
275
 
@@ -408,6 +434,19 @@ function mapDetailedDefinitionFromJsonLd(d: any): Record<string, unknown> {
408
434
  return result;
409
435
  }
410
436
 
437
+ function mapOriginFromJsonLd(o: JsonLdOrigin): Record<string, unknown> {
438
+ const origin: Record<string, unknown> = {};
439
+ const ref = mapRefFromJsonLd(o['gl:ref']);
440
+ if (ref) origin.ref = ref;
441
+ const loc = mapLocalityFromJsonLd(o['gl:locality']);
442
+ if (loc) origin.locality = loc;
443
+ if (o['gl:link']) origin.link = o['gl:link'];
444
+ if (o['gl:id']) origin.id = o['gl:id'];
445
+ if (o['gl:version']) origin.version = o['gl:version'];
446
+ if (o['gl:source']) origin.source = o['gl:source'];
447
+ return origin;
448
+ }
449
+
411
450
  function mapSourceFromJsonLd(s: JsonLdSource): Record<string, unknown> {
412
451
  const result: Record<string, unknown> = {};
413
452
  if (s['gl:id']) result.id = s['gl:id'];
@@ -416,17 +455,12 @@ function mapSourceFromJsonLd(s: JsonLdSource): Record<string, unknown> {
416
455
  if (s['gl:modification']) result.modification = s['gl:modification'];
417
456
 
418
457
  if (s['gl:origin']) {
419
- const origin: Record<string, unknown> = {};
420
- const o = s['gl:origin'];
421
- const ref = mapRefFromJsonLd(o['gl:ref']);
422
- if (ref) origin.ref = ref;
423
- const loc = mapLocalityFromJsonLd(o['gl:locality']);
424
- if (loc) origin.locality = loc;
425
- if (o['gl:link']) origin.link = o['gl:link'];
426
- if (o['gl:id']) origin.id = o['gl:id'];
427
- if (o['gl:version']) origin.version = o['gl:version'];
428
- if (o['gl:source']) origin.source = o['gl:source'];
429
- result.origin = origin;
458
+ result.origin = mapOriginFromJsonLd(s['gl:origin']);
459
+ }
460
+
461
+ const sf = s['gl:sourcedFrom'] ?? s['gl:sourced_from'];
462
+ if (sf?.length) {
463
+ result.sourced_from = sf.map(item => mapOriginFromJsonLd(item));
430
464
  }
431
465
 
432
466
  return result;
@@ -614,6 +648,10 @@ export function conceptFromJson(doc: Record<string, unknown>): Concept {
614
648
  const concept = Concept.fromJSON(doc);
615
649
  const locs = (doc as Record<string, unknown>).localizations as Record<string, unknown> | undefined;
616
650
  if (locs) attachBridges(concept, locs);
651
+ attachSourcedFromToSources(
652
+ concept.sources as unknown as { sourced_from?: unknown }[],
653
+ (doc as Record<string, unknown>).sources,
654
+ );
617
655
  return concept;
618
656
  }
619
657
 
@@ -11,9 +11,13 @@
11
11
  // DELETE this file when upstream ships proper declarations — tracked by
12
12
  // PR glossarist/glossarist-js#31 (targets v0.4.3+).
13
13
 
14
- import type { ConceptSource, GlossaristModel } from 'glossarist';
14
+ import type { ConceptSource, Citation, GlossaristModel } from 'glossarist';
15
15
 
16
16
  declare module 'glossarist' {
17
+ interface ConceptSource {
18
+ sourced_from?: Citation[];
19
+ }
20
+
17
21
  class RegistrableModel extends GlossaristModel {
18
22
  static register(type: string, cls: typeof RegistrableModel): void;
19
23
  static fromData(data: Record<string, unknown>): RegistrableModel;
@@ -0,0 +1 @@
1
+ <?xml version="1.0" encoding="UTF-8"?><svg id="uuid-fffeb195-1711-411a-b374-52204ad82fd6" xmlns="http://www.w3.org/2000/svg" width="183.1" height="172.48" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 183.1 172.48"><defs><linearGradient id="uuid-6317b9cc-2e5f-41e2-85f1-7443049bc3fc" x1="51.31" y1="161.8" x2="102.32" y2="73.02" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#3fb6b0"/><stop offset=".07" stop-color="#5cc5be"/><stop offset=".15" stop-color="#78d3cc"/><stop offset=".24" stop-color="#8fe0d8"/><stop offset=".34" stop-color="#a1e9e1"/><stop offset=".47" stop-color="#aef0e7"/><stop offset=".63" stop-color="#b5f3ea"/><stop offset="1" stop-color="#b8f5ec"/></linearGradient><linearGradient id="uuid-ea13e7e7-b134-4ab8-aa9c-2ec35d57d2e0" x1="0" y1="59.58" x2="183.1" y2="59.58" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#2d4164"/><stop offset=".47" stop-color="#2d4164" stop-opacity=".94"/><stop offset="1" stop-color="#2d4164" stop-opacity=".8"/></linearGradient><linearGradient id="uuid-b04c0104-3d2b-4e91-a646-00a62f882d94" x1="71.68" y1="31.06" x2="111.41" y2="31.06" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#2d4164"/><stop offset=".28" stop-color="#2c4365"/><stop offset="1" stop-color="#588ac1"/></linearGradient><linearGradient id="uuid-3270c642-e286-4980-b9d4-c5a586959dc8" x1="76.66" y1="69.37" x2="183.1" y2="69.37" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#34798a" stop-opacity=".7"/><stop offset="1" stop-color="#62c4dd"/></linearGradient><linearGradient id="uuid-b517cc88-1e38-4408-9af5-0022fab7d735" x1="81.4" y1="129.46" x2="127.22" y2="50.28" gradientUnits="userSpaceOnUse"><stop offset=".2" stop-color="#456399"/><stop offset="1" stop-color="#3a5c80" stop-opacity=".7"/></linearGradient><linearGradient id="uuid-30648514-5669-4d95-96c9-4dab36559efb" x1="88.72" y1="146.48" x2="54.34" y2="146.48" xlink:href="#uuid-b04c0104-3d2b-4e91-a646-00a62f882d94"/><linearGradient id="uuid-ab3522e9-c0cd-4474-8859-9f36cdf98f81" x1="86.16" y1="99.24" x2="47.06" y2="25.85" gradientUnits="userSpaceOnUse"><stop offset=".2" stop-color="#456399"/><stop offset="1" stop-color="#3a5c80" stop-opacity=".7"/></linearGradient><linearGradient id="uuid-4f227c7d-7a65-4e56-9321-c6145c47210b" x1="89.58" y1="107.94" x2="63.88" y2="57.26" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#456399" stop-opacity=".5"/><stop offset="1" stop-color="#97a4b7" stop-opacity=".8"/></linearGradient></defs><path d="m87.41,59.48l-61.18,106.11c-1.63,2.85.6,6.85,3.98,6.88h37.88l63.56-112.75-44.23-.25Z" style="fill:url(#uuid-6317b9cc-2e5f-41e2-85f1-7443049bc3fc); stroke-width:0px;"/><path d="m40.39,141.03c.29.28,1.01,2.7,1.61,4.91.67,2.49,2.77,4.38,5.33,4.68,21.2,2.5,21.4,20.71,21.4,20.71l22.5-39.92c-23.71,14.02-41.88,6.43-40.64-8.09-2.71,4.71-7.5,12.99-10.21,17.7Z" style="fill:#3a5c80; stroke-width:0px;"/><path d="m75.5,80.4s-14.96,25.41-18.41,31.59c8.71-8.42,17.69,2.79,24.11-8.66,8.13-14.5-5.7-22.93-5.7-22.93Z" style="fill:#60abbc; stroke-width:0px;"/><path d="m113.82,172.48h38.28c4.52-.01,6.66-3.57,4.43-7.48-6.55-11.51-59.96-105.41-59.96-105.41l-43.04-.37,60.29,113.27Z" style="fill:#b8f5ec; stroke-width:0px;"/><path d="m106.47,91.98c-9.24,2.3-34.69,12.42-21.32,20.21,12.57,7.31,28.11.2,32.05-11.73,1.79-4.66-2.29-10.67-10.74-8.47Z" style="fill:#3fb6b0; stroke-width:0px;"/><path d="m137.32,131.2l-13.09-23.07c.37,24.63-35.5,17.21-35.5,17.21l24.95,46.87c-1-25.77,10.54-35.8,23.65-41Z" style="fill:#7ee0d4; stroke-width:0px;"/><path d="m57.35,132.11s0-.01.01-.02c0,0,0,0,0,0" style="fill:#3a5c80; stroke-width:0px;"/><path d="m183.1,52.52c-.1-3.25-2.07-5.56-6-5.59-13.09-.07-163.15,0-163.15,0L0,72.26h183.1v-19.75Z" style="fill:url(#uuid-ea13e7e7-b134-4ab8-aa9c-2ec35d57d2e0); stroke-width:0px;"/><path d="m71.68,47s19.67-31.93,27.9-31.9c10.36,0,15.93,31.9,8.24,31.83l-36.15.07Z" style="fill:url(#uuid-b04c0104-3d2b-4e91-a646-00a62f882d94); stroke-width:0px;"/><path d="m183.1,59.91l-.13,10.94c.07,6.28-2.04,8.39-8.33,8.31h-97.99l10.97-19.59,95.47.33Z" style="fill:url(#uuid-3270c642-e286-4980-b9d4-c5a586959dc8); opacity:.78; stroke-width:0px;"/><polygon points="7.1 59.58 0 72.26 80.52 72.26 87.63 59.58 7.1 59.58" style="fill:#3a5c80; opacity:.8; stroke-width:0px;"/><path d="m117.54,47s-65.24,112.78-69.85,120.82c-1.1,1.92.38,4.63,2.65,4.65.71,0,17.74,0,17.74,0l70.73-125.47h-21.27Z" style="fill:url(#uuid-b517cc88-1e38-4408-9af5-0022fab7d735); opacity:.8; stroke-width:0px;"/><path d="m84.52,142.79c.55-.64.79-1.48,1.27-2.18,10.98-16.02-12.03-16.74-17.61-8.28l-12.79,23.17c-.14.24-.26.48-.38.72-3.39,7.29,6.71,13.38,11.92,7.26l17.59-20.69Z" style="fill:url(#uuid-30648514-5669-4d95-96c9-4dab36559efb); opacity:.8; stroke-width:0px;"/><path d="m124.71,126.57s0-.01-.01-.02c0,0,0,0,0,0,0,0,0,.02,0,.02Z" style="fill:#2e4365; stroke-width:0px;"/><polygon points="136.69 172.48 113.82 172.48 53.53 59.58 73.24 59.58 136.69 172.48" style="fill:url(#uuid-ab3522e9-c0cd-4474-8859-9f36cdf98f81); opacity:.75; stroke-width:0px;"/><path d="m73.39,59.58l33.44,59.63c2.82,7.65-7.17,26.67-12.28,17.06L53.53,59.58h19.86Z" style="fill:url(#uuid-4f227c7d-7a65-4e56-9321-c6145c47210b); stroke-width:0px;"/><path d="m115.87,135.34l18.22,32.5s-36.61-14.66-18.22-32.5Z" style="fill:#4977a4; stroke-width:0px;"/></svg>
@@ -0,0 +1,3 @@
1
+ import { createPinia } from 'pinia';
2
+ export const pinia = createPinia();
3
+ export default pinia;
@@ -2,7 +2,8 @@
2
2
  import { computed } from 'vue';
3
3
  import { useSiteConfig } from '../config/use-site-config';
4
4
  import { useI18n } from '../i18n';
5
- import glossaristLogo from '../assets/glossarist-logo.svg';
5
+
6
+ const glossaristLogo = new URL('../assets/glossarist-logo.svg', import.meta.url).href;
6
7
 
7
8
  const { config } = useSiteConfig();
8
9
  const { t } = useI18n();
@@ -58,7 +59,7 @@ const ownerUrl = computed(() => config.value?.branding?.ownerUrl || '/');
58
59
  >{{ link.label }}</a>
59
60
  <span class="text-ink-200">|</span>
60
61
  <span class="text-xs inline-flex items-center gap-1.5">
61
- <img :src="glossaristLogo" alt="" class="w-4 h-4 opacity-80" />
62
+ <img :src="glossaristLogo" alt="Glossarist" class="w-4 h-4 opacity-80" />
62
63
  <a :href="poweredBy.url" target="_blank" rel="noopener" class="concept-link">{{ poweredBy.message }}</a>
63
64
  </span>
64
65
  </div>
@@ -6,6 +6,8 @@ import { useSiteConfig } from '../config/use-site-config';
6
6
  import { useI18n } from '../i18n';
7
7
  import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
8
8
 
9
+ const glossaristLogo = new URL('../assets/glossarist-logo.svg', import.meta.url).href;
10
+
9
11
  const router = useRouter();
10
12
  const ui = useUiStore();
11
13
  const store = useVocabularyStore();
@@ -76,13 +78,8 @@ onBeforeUnmount(() => document.removeEventListener('click', closeLangOnOutside))
76
78
  class="h-8 max-w-[48px] object-contain rounded"
77
79
  />
78
80
  </div>
79
- <div v-else class="w-8 h-8 rounded-lg flex items-center justify-center transition-colors" style="background-color: var(--brand-dark)">
80
- <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
81
- <path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/>
82
- <path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/>
83
- <line x1="9" y1="7" x2="17" y2="7"/>
84
- <line x1="9" y1="11" x2="15" y2="11"/>
85
- </svg>
81
+ <div v-else class="h-8 flex items-center">
82
+ <img :src="glossaristLogo" alt="Glossarist" class="h-8 max-w-[48px] object-contain rounded" />
86
83
  </div>
87
84
  <span class="font-serif text-lg text-ink-800 leading-none hidden sm:inline">{{ localizedTitle }}</span>
88
85
  </button>
@@ -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>