@glossarist/concept-browser 0.7.74 → 0.7.77

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 CHANGED
@@ -1,519 +1,195 @@
1
1
  # Glossarist Concept Browser
2
2
 
3
- A statically deployable single-page application for browsing terminology datasets. Built with Vue 3, TypeScript, and Tailwind CSS. Add new datasets with zero code changes — just configure `site-config.yml`.
3
+ A statically deployable terminology browser. Install as an npm package, configure via YAML, build, deploy.
4
4
 
5
- **Live sites:**
6
- - [GeoLexica](https://www.geolexica.org) — IEC Electropedia + ISO/TC 211 + more
7
- - [VIML](https://www.oimlsmart.org/vocab/) — OIML International Vocabulary of Legal Metrology
8
- - [OIML Terms](https://metanorma.github.io/oiml-terms/) — OIML G 18 terminology
9
-
10
- ---
11
-
12
- ## Features
13
-
14
- - **Multi-dataset browsing** — Concepts from multiple terminology registers in one place
15
- - **Full multilingual support** — Definitions, notes, and examples in all available languages with i18n UI
16
- - **Concept history timeline** — Review dates, decisions, and change notes per language
17
- - **Relation sphere** — 3D sphere visualization of a concept's neighborhood with d3 force simulation, per-relation-type colors, and degree filtering
18
- - **Edition series** — Sidebar timeline for vocabulary edition groups (e.g. VIML 1968/2000/2013/2022) with current-edition markers and supersession chain navigation
19
- - **Cross-reference graph** — D3 force-directed graph showing concept relationships with dataset filtering
20
- - **Dataset groups** — Organize datasets into lineage series, topic bundles, publication families, or curated collections. Each group kind has distinct sidebar rendering
21
- - **Light/dark mode colors** — Per-dataset `{ light, dark }` color pairs. Per-relation-type colors configurable via site-config
22
- - **RDF / SHACL outputs** — Every concept emits SHACL-conformant Turtle + JSON-LD. Dataset-level `dcat:Dataset`, group-level `dcat:DatasetSeries`/`dcat:Catalog`, vocabulary SKOS ConceptSchemes, bibliography `dcterms:BibliographicResource`, build provenance `prov:Activity`
23
- - **Rich sidebar provenance** — Publication reference, owner, status, concept/language counts, sections tree from manifest data
24
- - **Math rendering** — KaTeX rendering for AsciiMath notation in definitions (`stem:[...]`)
25
- - **Responsive design** — Mobile-first layout with light/dark mode
26
- - **Static deployment** — No server required. Deploy to any static host
27
-
28
- ---
29
-
30
- ## Quick Start (deployment repo)
31
-
32
- A deployment repo is a separate repository that provides a `site-config.yml`, dataset source files, and content pages. The concept-browser is installed as an npm package.
5
+ ## Quick Start
33
6
 
34
7
  ```bash
35
- # In your deployment repo:
36
- npm install --ignore-scripts @glossarist/concept-browser
37
- npm install --prefix node_modules/@glossarist/concept-browser sharp 2>/dev/null || true
38
- npx concept-browser build
39
- ```
40
-
41
- The CLI reads `site-config.yml` from the working directory, fetches/generates data, and builds the SPA into `dist/`.
8
+ # 1. Create your project
9
+ mkdir my-dictionary && cd my-dictionary
10
+ npm init -y
11
+
12
+ # 2. Install
13
+ npm install @glossarist/concept-browser
14
+
15
+ # 3. Create site-config.yml
16
+ cat > site-config.yml << 'EOF'
17
+ id: my-dictionary
18
+ title: My Dictionary
19
+ uri_base: https://example.com
20
+ datasets:
21
+ - id: my-vocab
22
+ title: My Vocabulary
23
+ local_path: datasets/my-vocab # or gcr_package: https://...
24
+ EOF
42
25
 
43
- ### Quick Start (development)
26
+ # 4. Build
27
+ npx concept-browser build
44
28
 
45
- ```bash
46
- git clone https://github.com/glossarist/concept-browser.git
47
- cd concept-browser
48
- npm install
49
- npm run dev
50
- # Open http://localhost:5173
29
+ # 5. Deploy
30
+ # Upload dist/ to any static host (GitHub Pages, Netlify, S3, etc.)
51
31
  ```
52
32
 
53
- The dev server serves pre-built data from `public/data/`. If no data is present yet, run the data pipeline first (see below).
54
-
55
- ---
56
-
57
- ## CLI Reference
33
+ ## How It Works
58
34
 
59
35
  ```
60
- concept-browser <command> [options]
61
-
62
- Commands:
63
- fetch Fetch/update datasets (from GCR packages, local paths, or source repos)
64
- generate Convert harmonized YAML concepts to JSON-LD static files + RDF artifacts
65
- edges Build cross-reference edges from generated concept data
66
- build Full pipeline: fetch + generate + edges + vite build
67
- site Same as build (alias)
68
- doctor Run diagnostic checks (node version, deps, shapes, data integrity)
69
- normalize NFC-normalize YAML concept files in-place
70
-
71
- Options:
72
- --site <id> Site config ID (looks for site-config.yml in CWD)
73
-
74
- Environment:
75
- SITE_CONFIG Site config file path (highest priority)
76
- SITE_ID Site config ID (same as --site)
77
- GITHUB_TOKEN GitHub token for private repos
36
+ Your project (CWD) Package (read-only)
37
+ ├── site-config.yml ──┐
38
+ ├── datasets/ ─┤
39
+ │ └── my-vocab/ ─┤ ┌──────────────────────────┐
40
+ │ └── concepts/*.yaml ─┤ │ @glossarist/concept-browser │
41
+ ├── public/ ─┤ ──> │ │
42
+ │ └── (logos, favicons) ─┤ │ generate-data.mjs │
43
+ ├── .cb-content/ ← generated │ │ bridge-to-astro.mjs │
44
+ └── dist/ ← output │ │ astro build │
45
+ ──┘ └──────────────────────────┘
78
46
  ```
79
47
 
80
- ---
48
+ The package reads your config and data, generates `.cb-content/` (content collections) and `dist/` (static site) in your project. The package itself (`node_modules/`) is never modified.
81
49
 
82
- ## Data Pipeline
50
+ ## Configuration
83
51
 
84
- ```
85
- site-config.yml
86
- └─> scripts/fetch-datasets.mjs (fetch from GCR, localPath, or sourceRepo)
87
- └─> .datasets/{id}/concepts/*.yaml
88
- └─> scripts/generate-data.mjs (YAML → JSON-LD + RDF artifacts)
89
- └─> public/data/{id}/
90
- ├── manifest.json Dataset metadata (ref, owner, stats)
91
- ├── index.json Concept listing (chunked for large sets)
92
- ├── edges.json Pre-computed cross-reference + domain edges
93
- ├── domain-nodes.json Domain classification nodes
94
- ├── {register}.ttl Dataset-level RDF (dcat:Dataset + skos:ConceptScheme + sections)
95
- ├── bib.ttl Bibliography graph (dcterms:BibliographicResource)
96
- └── concepts/*.json Individual concept documents
97
- └─> public/data/_vocab.ttl Vocabulary graph (SKOS ConceptSchemes)
98
- └─> public/data/activity/ Build provenance (prov:Activity per build)
99
- └─> public/data/agents.ttl Contributor records (foaf:Person)
100
- └─> public/data/versions.ttl Version chain (prov:Entity)
101
- └─> scripts/build-edges.js (extract graph + domain edges)
102
- ```
103
-
104
- ---
52
+ Everything is in `site-config.yml`. See [`site-config.example.yml`](site-config.example.yml) for all options.
105
53
 
106
- ## Configuration: `site-config.yml`
54
+ ### Dataset location
107
55
 
108
- All configuration lives in a single file. The CLI reads it from the current working directory.
109
-
110
- ### Top-level fields
56
+ Each dataset specifies where its source data lives. Three options:
111
57
 
112
58
  ```yaml
113
- id: viml # Site identifier
114
- domain: viml.oiml.info # Primary domain
115
- basePath: /oiml-viml/ # URL subpath for GitHub Pages deployment
116
- title: VIML # Site title
117
- subtitle: International Vocabulary... # Short description
118
- description: Terminology from... # Longer description
119
-
120
- uiLanguages: # Available UI languages
121
- - code: eng
122
- label: English
123
- - code: fra
124
- label: Français
125
-
126
- translations: # Localized title/subtitle/description
127
- fra:
128
- subtitle: Vocabulaire international de métrologie légale
129
- description: Terminologie du Vocabulaire international...
130
-
131
- defaults:
132
- language: eng # Default concept language
133
-
134
- copyright: "OIML" # Footer copyright text
135
- ```
59
+ datasets:
60
+ # Option A: Local directory with concepts/*.yaml
61
+ - id: my-vocab
62
+ local_path: datasets/my-vocab # relative to project root
136
63
 
137
- ### Dataset configuration
64
+ # Option B: GCR package (auto-downloaded at build time)
65
+ - id: iev
66
+ gcr_package: https://github.com/org/repo/releases/download/v1.0/my-vocab.gcr
138
67
 
139
- ```yaml
140
- datasets:
141
- - id: viml
142
- uri: "urn:oiml:pub:v:1:2022"
143
- sourceRepo: https://github.com/metanorma/oiml-viml
144
- localPath: viml-glossarist # Local dataset directory (relative to CWD)
145
- title: "VIML — International Vocabulary of Legal Metrology"
146
- description: "Terminology definitions from..."
147
- owner: OIML
148
- ref: "OIML V 1:2022" # Publication reference (shown in sidebar provenance)
149
- color: "#004996"
150
- tags: [metrology, legal, oiml]
151
- languageOrder: [eng, fra]
68
+ # Option C: Git repository (cloned at build time)
69
+ - id: iso-terms
70
+ source_repo: https://github.com/org/iso-terms
152
71
  ```
153
72
 
154
- #### Dataset field reference
73
+ ### Colors
155
74
 
156
- | Field | Required | Description |
157
- |-------|----------|-------------|
158
- | `id` | yes | URL-safe identifier used in routes and data paths |
159
- | `uri` | no | URI pattern for this dataset |
160
- | `sourceRepo` | no | Git repository URL containing concept YAML files |
161
- | `gcrPackage` | no | URL to a pre-built `.gcr` ZIP archive (alternative to sourceRepo) |
162
- | `localPath` | no | Local directory with `concepts/` and `register.yaml` (relative to CWD) |
163
- | `title` | no | Display name. Falls back to `register.yaml` name field |
164
- | `description` | no | Shown on home page and about page |
165
- | `owner` | no | Organization name shown in sidebar provenance |
166
- | `ref` | no | Publication reference shown in sidebar provenance (e.g., "OIML V 1:2022") |
167
- | `color` | no | Accent color — single hex (`"#004996"`) or `{ light, dark }` pair |
168
- | `tags` | no | Array of labels shown on dataset card |
169
- | `languageOrder` | no | Array of ISO 639-2 codes controlling display order |
170
- | `translations` | no | Localized title and description per language |
75
+ Each dataset can have a color — single hex or light/dark pair:
171
76
 
172
- #### Dataset source resolution
173
-
174
- The CLI resolves dataset sources in this priority order:
77
+ ```yaml
78
+ datasets:
79
+ - id: my-vocab
80
+ color: "#2563eb" # single color (both modes)
175
81
 
176
- 1. **`.gcr/{id}.gcr`** on disk — extract to `.datasets/{id}/`
177
- 2. **`gcrPackage`** URL — download and extract
178
- 3. **`localPath`** — copy `concepts/` and `register.yaml` from a local directory
179
- 4. **`sourceRepo`** — git clone to `.datasets/{id}/`
82
+ - id: legacy-vocab
83
+ color:
84
+ light: "#004996" # light mode
85
+ dark: "#3B82F6" # dark mode
86
+ ```
180
87
 
181
- ### Base path for subpath deployment
88
+ Colors appear in the sidebar (color dot per dataset), sphere cards (top bar + tint), badges, and relationship edges. If omitted, a palette color is assigned automatically.
182
89
 
183
- Set `basePath` in `site-config.yml` to deploy to a subdirectory (e.g., GitHub Pages at `metanorma.github.io/my-site/`):
90
+ Group colors work the same way:
184
91
 
185
92
  ```yaml
186
- basePath: /my-site/
93
+ dataset_groups:
94
+ - id: my-vocab-series
95
+ color: "#d97706"
187
96
  ```
188
97
 
189
- This configures Vite's `base` so all asset paths, router paths, and data fetches use the correct prefix. The `BASE_PATH` environment variable can override this if needed.
98
+ ### Logo
190
99
 
191
- ### Branding and favicon
100
+ Place logo SVGs/PNGs in `public/` and reference them in `branding`:
192
101
 
193
102
  ```yaml
194
103
  branding:
195
- primaryColor: "#004996"
196
- darkColor: "#003366"
197
- fonts:
198
- header:
199
- family: "Source Serif 4"
200
- source: "google"
201
- weights: [400, 600]
202
- body:
203
- family: "Source Sans 3"
204
- source: "google"
205
- weights: [400, 500, 700]
206
104
  logo:
207
- path: /logos/oiml-logo.svg # URL path served to browsers
208
- alt: OIML
209
- localPath: logos/oiml-logo.svg # Local file for favicon generation
210
- localLight: logos/oiml-logo-icon-light.svg # Light variant (dark mode)
211
- localDark: logos/oiml-logo-icon-dark.svg # Dark variant (light mode)
212
- ownerName: OIML
213
- ownerUrl: "https://www.oiml.org"
105
+ alt: My Dictionary
106
+ light: /images/logo-light.svg # shown in light mode
107
+ dark: /images/logo-dark.svg # shown in dark mode
214
108
  ```
215
109
 
216
- Favicons are auto-generated from the logo SVG using the `favicons` package. Resolution order: `branding.favicon` → `branding.logo.localPath` → package default `public/favicon.svg`.
110
+ If no logo is configured, the Glossarist logo is shown by default. The footer always shows the Glossarist logo ("Powered by Glossarist").
217
111
 
218
- ### Site features
112
+ Favicons work the same way — place `favicon.svg`, `favicon.ico`, and `apple-touch-icon*.png` in `public/`. If not provided, the Glossarist favicon is used.
219
113
 
220
- ```yaml
221
- features:
222
- news: false # enable news posts
223
- stats: true # show statistics dashboard
224
- graph: true # enable concept graph visualization
225
- about: true # enable about page
226
- search: true # enable full-text search
227
- poweredBy:
228
- title: "Glossarist"
229
- url: "https://glossarist.org"
230
- ```
114
+ ### About pages
231
115
 
232
- ### Content pages
116
+ Place JSON files in `public/pages/`:
233
117
 
234
- ```yaml
235
- pages:
236
- - type: about
237
- route: about
238
- title: About
239
- icon: info
240
- source: about.md
241
- translations:
242
- fra:
243
- title: À propos
244
- source: about-fra.md
245
118
  ```
246
-
247
- ### Dataset groups
248
-
249
- When a site has many datasets, you can group them in the sidebar navigation. Each group has a `kind` that determines its visual treatment and semantic assumptions.
250
-
251
- ```yaml
252
- datasetGroups:
253
- - id: viml
254
- label: "VIML — International Vocabulary of Legal Metrology"
255
- kind: lineage # edition series (same vocabulary, different years)
256
- current: viml-2022 # current edition for the series
257
- color:
258
- light: "#004996"
259
- dark: "#3B82F6"
260
- datasets: [viml-2022, viml-2013, viml-2000, viml-1968]
261
- translations:
262
- fra:
263
- label: "VIML — Vocabulaire international de métrologie légale"
264
-
265
- - id: vim
266
- label: "VIM — International Vocabulary of Metrology"
267
- kind: lineage
268
- current: vim-2012
269
- color:
270
- light: "#005A9C"
271
- dark: "#2196F3"
272
- datasets: [vim-2012, vim-2010, vim-2007, vim-1993]
119
+ public/
120
+ └── pages/
121
+ ├── about.json → /about
122
+ └── my-vocab-about.json → /my-vocab-about
273
123
  ```
274
124
 
275
- #### Group kinds
276
-
277
- | Kind | Glyph | Description | Sidebar rendering |
278
- |------|-------|-------------|-------------------|
279
- | `lineage` | ⏳ | Editions of the same vocabulary over time | Compact timeline with year badges + current-edition star (✦) |
280
- | `topic` | ◆ | Different vocabularies on the same subject | Standard list entries |
281
- | `family` | ✦ | Related vocabularies from the same publisher | Standard list entries |
282
- | `collection` | ❖ | Curated bundle of datasets | Standard list entries |
283
- | `default` | ▸ | No special semantics (backward compat) | Standard list entries |
284
-
285
- For `lineage` groups:
286
- - The `current` field identifies the newest valid edition. If omitted, the newest member by year is auto-detected.
287
- - Members are displayed as a timeline with year + reference + status badges.
288
- - When an edition is active, the full expansion (sub-pages, sections tree, provenance) appears below the timeline entry — same as non-series datasets.
289
- - A supersession chain card (`ConceptEditionRail`) appears in the concept detail sidebar, showing how the concept evolved across editions.
290
-
291
- #### Group field reference
292
-
293
- | Field | Required | Description |
294
- |-------|----------|-------------|
295
- | `id` | yes | Unique identifier for the group |
296
- | `label` | yes | Display name shown as the collapsible group header |
297
- | `kind` | no | Group kind: `lineage`, `topic`, `family`, `collection`, `default`. Defaults to `default` |
298
- | `current` | no | For `lineage`: dataset id of the current (newest valid) edition |
299
- | `description` | no | Short description of the group |
300
- | `color` | no | Accent color — single hex or `{ light, dark }` pair |
301
- | `datasets` | yes | Ordered array of dataset IDs belonging to this group |
302
- | `translations` | no | Localized label and description per language |
303
-
304
- Datasets not assigned to any group appear at the bottom of the dataset list.
125
+ Each file:
305
126
 
306
- ### Internationalization
307
-
308
- All localized text uses language maps — a `translations` field keyed by ISO 639-2 language codes. The top-level `title` and `description` fields hold the default-language text. The `translations` map provides overrides for additional languages:
309
-
310
- ```yaml
311
- datasets:
312
- - id: viml-2022
313
- title: "OIML V 1:2022"
314
- description: "Current edition with 135 terms..."
315
- translations:
316
- fra:
317
- title: "OIML V 1:2022"
318
- description: "Édition actuelle comprenant 135 termes..."
319
-
320
- datasetGroups:
321
- - id: viml
322
- label: "International Vocabulary of Legal Metrology"
323
- translations:
324
- fra:
325
- label: "Vocabulaire international de métrologie légale"
127
+ ```json
128
+ {
129
+ "title": "About This Dictionary",
130
+ "html": "<p>Content here. Supports full HTML.</p>"
131
+ }
326
132
  ```
327
133
 
328
- This pattern applies everywhere localized text appears: site-level translations, dataset translations, group translations, and page translations. Do not use language suffixes like `_fra` — use nested language maps instead.
134
+ The page appears in the sidebar navigation automatically.
329
135
 
330
- ### Color system
136
+ ### Full example
331
137
 
332
- Dataset and group colors accept either a single hex string (backward compat) or a `{ light, dark }` pair:
138
+ See [`site-config.example.yml`](site-config.example.yml) for all options including UI languages, fonts, features, and dataset groups.
333
139
 
334
- ```yaml
335
- # Single hex (applied to both modes)
336
- color: "#004996"
140
+ ## CLI Commands
337
141
 
338
- # Explicit light/dark pair
339
- color:
340
- light: "#004996"
341
- dark: "#3B82F6"
342
142
  ```
343
-
344
- Relationship-type colors default from `data/colors.json` but can be overridden per deployment:
345
-
346
- ```yaml
347
- colors:
348
- relationshipType:
349
- supersedes:
350
- light: "#FF0000"
351
- dark: "#FF5555"
352
- dataset:
353
- viml-2022:
354
- light: "#004996"
355
- dark: "#3B82F6"
143
+ npx concept-browser fetch # Download datasets from GCR/repos
144
+ npx concept-browser generate # Convert YAML → JSON-LD + RDF artifacts
145
+ npx concept-browser build # Full pipeline: fetch + generate + build site
356
146
  ```
357
147
 
358
- CSS variables are emitted on document root by `useColorTheme()`:
359
- - `--rel-{category}-{light|dark}` per relationship category
360
- - `--rel-type-{type}-{light|dark}` per relationship type
361
- - `--concept-status-{status}-{light|dark}` per concept status
362
- - `--group-kind-{kind}-{light|dark}` per group kind
363
- - `--ds-{light|dark}` per dataset (scoped via `[data-ds]`)
148
+ ## .gitignore for Your Project
364
149
 
365
- ### Cross-reference mapping
150
+ Add these to your project's `.gitignore`:
366
151
 
367
- ```yaml
368
- crossReferences:
369
- refPrefixMap:
370
- IEV: iev
371
- urnStandardMap:
372
- "14812": isotc204
373
152
  ```
374
-
375
- ---
376
-
377
- ## Deployment
378
-
379
- ### GitHub Pages (recommended)
380
-
381
- 1. Create a deployment repo with `site-config.yml`, dataset source, and content pages
382
- 2. Add a GitHub Actions workflow:
383
-
384
- ```yaml
385
- jobs:
386
- build:
387
- runs-on: ubuntu-latest
388
- steps:
389
- - uses: actions/checkout@v4
390
- - uses: actions/setup-node@v4
391
- with:
392
- node-version: 20
393
- - name: Install concept-browser
394
- run: |
395
- npm install --ignore-scripts @glossarist/concept-browser
396
- npm install --prefix node_modules/@glossarist/concept-browser sharp 2>/dev/null || true
397
- - name: Build site
398
- run: npx concept-browser build
399
- env:
400
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
401
- - name: Upload artifact
402
- uses: actions/upload-pages-artifact@v3
403
- with:
404
- path: dist
405
- deploy:
406
- needs: build
407
- runs-on: ubuntu-latest
408
- if: ${{ github.ref == 'refs/heads/main' }}
409
- steps:
410
- - uses: actions/deploy-pages@v4
153
+ .cb-content/
154
+ dist/
155
+ .datasets/
156
+ .gcr/
157
+ public/data/
411
158
  ```
412
159
 
413
- All configuration (base path, dataset sources, branding) comes from `site-config.yml` — no dataset-specific environment variables needed.
414
-
415
- ### Other hosting platforms
416
-
417
- The build produces static files in `dist/` with an SPA `404.html` fallback:
418
-
419
- - **Netlify:** Build command `npx concept-browser build`, publish directory `dist`, add `_redirects` with `/* /index.html 200`
420
- - **Vercel:** Framework Vite, build command `npx concept-browser build`, output directory `dist`
421
- - **AWS S3 + CloudFront:** Upload `dist/`, error document `index.html`, configure CloudFront for SPA routing
422
-
423
- ### Known deployments
424
-
425
- Sites currently powered by `@glossarist/concept-browser`. When cutting a release with breaking changes, bump `@glossarist/concept-browser` in each consumer repo and redeploy.
160
+ ## Data Format
426
161
 
427
- | Repo | Site |
428
- |---|---|
429
- | [`geolexica/geolexica.github.io`](https://github.com/geolexica/geolexica.github.io) | https://www.geolexica.org |
430
- | [`geolexica/isotc204.geolexica.org`](https://github.com/geolexica/isotc204.geolexica.org) | https://isotc204.geolexica.org |
431
- | [`geolexica/isotc211.geolexica.org`](https://github.com/geolexica/isotc211.geolexica.org) | https://isotc211.geolexica.org |
432
- | [`geolexica/osgeo.geolexica.org`](https://github.com/geolexica/osgeo.geolexica.org) | https://osgeo.geolexica.org |
433
- | [`oimlsmart/vocab`](https://github.com/oimlsmart/vocab) | https://www.oimlsmart.org/vocab/ (VIML)¹ |
434
- | [`metanorma/oiml-terms`](https://github.com/metanorma/oiml-terms) | https://metanorma.github.io/oiml-terms/ |
435
- | [`glossarist/iala-vocab`](https://github.com/glossarist/iala-vocab) | https://glossarist.github.io/iala-vocab/ |
436
- | [`metanorma/iso-10303-2-vocab`](https://github.com/metanorma/iso-10303-2-vocab) | https://metanorma.github.io/iso-10303-2-vocab/ |
437
-
438
- To add a new deployment here, open a PR against this README.
439
-
440
- ¹ The VIML deployment moved from `metanorma/oiml-viml` to [`oimlsmart/vocab`](https://github.com/oimlsmart/vocab) (GitHub redirects the old name automatically), and its live URL moved from `metanorma.github.io/oiml-viml/` to `www.oimlsmart.org/vocab/`. The old `metanorma.github.io/oiml-viml/` URL returns 404 (Pages doesn't follow repo renames across orgs).
441
-
442
- ---
443
-
444
- ## Architecture
445
-
446
- ### Tech Stack
447
-
448
- - **Vue 3** + **TypeScript** + **Vite**
449
- - **Pinia** (state management)
450
- - **Vue Router** (SPA navigation)
451
- - **Tailwind CSS 3** (utility-first styling)
452
- - **D3.js** (force-directed graph + relation sphere)
453
- - **KaTeX** (math rendering)
454
- - **n3** + **rdf-validate-shacl** (RDF parsing + SHACL validation)
455
- - **fast-check** (property-based fuzz testing)
456
- - **Vitest** with happy-dom
457
-
458
- ### Project structure
459
-
460
- ```
461
- src/
462
- ├── adapters/ Data access layer (DatasetAdapter, AdapterFactory, UriRouter)
463
- ├── components/ Vue components (AppSidebar, ConceptDetail, RelationSphere, etc.)
464
- │ ├── concept-rdf/ RDF graph IR + emitters + writers (concept, dataset, group, vocab, etc.)
465
- │ └── groups/ OCP group renderer dispatcher + per-kind sidebar components
466
- ├── composables/ Vue composables (useDatasetSeries, useColorTheme, useSphereProjection)
467
- ├── config/ Site config types, group-types registry, group-renderers registry
468
- ├── data/ Static data (taxonomies.json, colors.json, concept-model shapes)
469
- ├── graph/ Graph engine for concept relationships
470
- ├── stores/ Pinia stores (vocabulary, ui)
471
- ├── views/ Page-level components (HomeView, DatasetView, ConceptView, GroupView)
472
- ├── i18n/ Internationalization (YAML locale files, auto-discovered)
473
- └── utils/ Utilities (color-theme, dataset-style, relationship-categories, content-renderer)
474
-
475
- cli/
476
- └── index.mjs CLI entry point (fetch, generate, edges, build, doctor, normalize)
477
-
478
- scripts/
479
- ├── fetch-datasets.mjs Clone + harmonize source repos
480
- ├── generate-data.mjs Convert YAML → JSON-LD + RDF artifacts
481
- ├── build-edges.js Extract cross-reference edges
482
- ├── process-about-pages.mjs Compile markdown/AsciiDoc about pages
483
- ├── validate-shacl.mjs SHACL validation gate
484
- └── lib/ Build-time emitters (vocab, dataset, group, agents, version, etc.)
485
- ```
162
+ Each concept is a YAML file in `datasets/{id}/concepts/`:
486
163
 
164
+ ```yaml
487
165
  ---
488
-
489
- ## i18n
490
-
491
- UI translations are YAML files in `src/i18n/locales/`, auto-discovered via `import.meta.glob`. To add a new language:
492
-
493
- 1. Copy `eng.yml` to `{lang-code}.yml`
494
- 2. Translate all values
495
- 3. Add the language to `uiLanguages` in `site-config.yml`
496
-
166
+ data:
167
+ identifier: "1-1-1"
168
+ localized_concepts:
169
+ eng: <uuid>
170
+ sources:
171
+ - type: authoritative
172
+ origin:
173
+ ref:
174
+ source: "ISO 9000"
175
+ definition:
176
+ - content: "The concept definition text."
177
+ terms:
178
+ - type: expression
179
+ normative_status: preferred
180
+ designation: "quality"
181
+ language_code: eng
497
182
  ---
498
-
499
- ## Testing
500
-
501
- ```bash
502
- npm test # Run all tests (Vitest with happy-dom)
503
- npm run test:watch # Watch mode
504
- npm run mutation:test # Stryker mutation testing (scoped to RDF emitters, ~3-5 min)
505
183
  ```
506
184
 
507
- Test coverage includes:
508
- - 1357+ tests across unit, round-trip, SHACL conformance, property-based fuzz, and build-integration layers
509
- - 7-fixture concept corpus (minimal, multilingual, full-relationships, with-sources, with-non-verbal, with-dates, withdrawn)
510
- - SHACL conformance gate: all fixtures validated against canonical shapes
511
- - Property-based fuzz: 200 iterations × 4 invariants via fast-check
512
- - 10,000-concept scale stress test (< 100ms)
513
- - Stryker mutation testing scoped to concept/dataset/bibliography emitters
185
+ Or use a Glossarist GCR package — set `gcr_package` in your dataset config and the CLI downloads it automatically.
514
186
 
515
- ---
187
+ ## Deployment
188
+
189
+ The build outputs a static site to `dist/`. Deploy it anywhere:
516
190
 
517
- ## License
191
+ - **GitHub Pages**: Push `dist/` to `gh-pages` branch
192
+ - **Netlify/Vercel**: Set build command to `npx concept-browser build`, publish directory to `dist/`
193
+ - **S3/CloudFront**: `aws s3 sync dist/ s3://my-bucket/`
518
194
 
519
- This project is part of the [Glossarist](https://github.com/glossarist) ecosystem.
195
+ No server required. All data is pre-built into static JSON files.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glossarist/concept-browser",
3
- "version": "0.7.74",
3
+ "version": "0.7.77",
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": {
@@ -920,6 +920,9 @@ async function processDataset(dir, register, opts) {
920
920
 
921
921
  const dsRefMaps = { ...refMaps, register, designationLookup };
922
922
 
923
+ const sourceMap = new Map();
924
+ const relTypeCounts = {};
925
+
923
926
  for (let i = 0; i < files.length; i++) {
924
927
  const file = files[i];
925
928
  try {
@@ -954,6 +957,28 @@ async function processDataset(dir, register, opts) {
954
957
  status: conceptYaml.eng?.entry_status || 'valid',
955
958
  });
956
959
 
960
+ const conceptSourceRefs = new Set();
961
+ const allSourceArrays = [
962
+ ...(conceptYaml._sources || []),
963
+ ...opts.languages.flatMap(l => conceptYaml[l]?.sources || []),
964
+ ];
965
+ for (const src of allSourceArrays) {
966
+ const ref = src.origin?.ref?.source || src.origin?.ref?.id || 'Unknown';
967
+ conceptSourceRefs.add(ref);
968
+ if (!sourceMap.has(ref)) {
969
+ sourceMap.set(ref, { ref, types: new Set(), conceptIds: [] });
970
+ }
971
+ if (src.type) sourceMap.get(ref).types.add(src.type);
972
+ }
973
+ for (const ref of conceptSourceRefs) {
974
+ sourceMap.get(ref)?.conceptIds.push(termid);
975
+ }
976
+
977
+ for (const r of conceptYaml._related || []) {
978
+ const type = r.type || 'unknown';
979
+ relTypeCounts[type] = (relTypeCounts[type] || 0) + 1;
980
+ }
981
+
957
982
  for (const lang of opts.languages) {
958
983
  const lc = conceptYaml[lang];
959
984
  if (lc) {
@@ -1072,6 +1097,23 @@ async function processDataset(dir, register, opts) {
1072
1097
  }
1073
1098
  }
1074
1099
 
1100
+ const sourceStats = Array.from(sourceMap.values())
1101
+ .map(s => ({
1102
+ ref: s.ref,
1103
+ types: Array.from(s.types),
1104
+ conceptCount: s.conceptIds.length,
1105
+ conceptIds: s.conceptIds,
1106
+ }))
1107
+ .sort((a, b) => b.conceptCount - a.conceptCount);
1108
+ const totalRelationships = Object.values(relTypeCounts).reduce((a, b) => a + b, 0);
1109
+
1110
+ writeJson(path.join(DATA, register, 'stats.json'), {
1111
+ sourceCount: sourceStats.length,
1112
+ sources: sourceStats,
1113
+ relationshipCount: totalRelationships,
1114
+ relationshipTypes: relTypeCounts,
1115
+ });
1116
+
1075
1117
  const manifest = {
1076
1118
  id: register,
1077
1119
  datasetUri: opts.datasetUri,
@@ -1082,6 +1124,8 @@ async function processDataset(dir, register, opts) {
1082
1124
  baseUrl: `/data/${register}`,
1083
1125
  languages: opts.languages,
1084
1126
  conceptCount: concepts.length,
1127
+ sourceCount: sourceStats.length,
1128
+ relationshipCount: totalRelationships,
1085
1129
  conceptUrlTemplate: '{baseUrl}/concepts/{conceptId}.json',
1086
1130
  indexUrl: '{baseUrl}/index.json',
1087
1131
  contextUrl: 'https://glossarist.org/ns/context.jsonld',
@@ -136,7 +136,7 @@ const filteredGlobalPages = computed(() =>
136
136
 
137
137
  // Show only standard dataset pages (Concepts, Statistics, About)
138
138
  const filteredDatasetPages = computed(() =>
139
- datasetPages.value.filter(p => ['', 'stats', 'about'].includes(p.route || ''))
139
+ datasetPages.value.filter(p => ['', 'stats', 'sources', 'about'].includes(p.route || ''))
140
140
  );
141
141
 
142
142
  const currentManifest = computed(() => store.manifests.get(currentDataset.value));
@@ -9,6 +9,7 @@ const icons: Record<string, string> = {
9
9
  chart: 'M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z',
10
10
  list: 'M4 6h16M4 10h16M4 14h16M4 18h16',
11
11
  schema: 'M4 5a1 1 0 011-1h14a1 1 0 011 1v2a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM4 13a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H5a1 1 0 01-1-1v-6zM16 13a1 1 0 011-1h2a1 1 0 011 1v6a1 1 0 01-1 1h-2a1 1 0 01-1-1v-6z',
12
+ database: 'M4 7c0 1.1 3.6 2 8 2s8-.9 8-2M4 7c0-1.1 3.6-2 8-2s8 .9 8 2M4 7v10c0 1.1 3.6 2 8 2s8-.9 8-2V7M4 12c0 1.1 3.6 2 8 2s8-.9 8-2',
12
13
  };
13
14
 
14
15
  defineProps<{ name: string }>();
@@ -157,6 +157,9 @@ function synthesizeDatasetPages(features?: Record<string, unknown>, pages?: Page
157
157
  if (features?.stats !== false && !declaredRoutes.has('stats')) {
158
158
  result.push({ type: 'stats', route: 'stats', title: 'Statistics', icon: 'chart', datasetScoped: true });
159
159
  }
160
+ if (!declaredRoutes.has('sources')) {
161
+ result.push({ type: 'sources', route: 'sources', title: 'Sources', icon: 'database', datasetScoped: true });
162
+ }
160
163
  if (features?.about !== false && !declaredRoutes.has('about')) {
161
164
  result.push({ type: 'about', route: 'about', title: 'About', icon: 'info', datasetScoped: true });
162
165
  }
@@ -9,6 +9,7 @@ nav.ontology: Ontology
9
9
  nav.concepts: Concepts
10
10
  nav.stats: Statistics
11
11
  nav.about: About
12
+ nav.sources: Sources
12
13
  nav.navigation: Navigation
13
14
  nav.datasets: Datasets
14
15
 
@@ -121,6 +122,8 @@ lang.termOnlyIn: Term only in
121
122
  # Dataset view
122
123
  dataset.concepts: concepts
123
124
  dataset.languages: languages
125
+ dataset.sources: sources
126
+ dataset.relationships: relationships
124
127
  dataset.filterPlaceholder: "Filter concepts... (press /)"
125
128
  dataset.of: of
126
129
  dataset.in: in
@@ -15,6 +15,7 @@ export async function getStaticPaths() {
15
15
  for (const ds of datasets) {
16
16
  paths.push({ params: { path: `dataset/${ds.data.id}/about` } });
17
17
  paths.push({ params: { path: `dataset/${ds.data.id}/stats` } });
18
+ paths.push({ params: { path: `dataset/${ds.data.id}/sources` } });
18
19
  for (const g of await getCollection('groups')) {
19
20
  paths.push({ params: { path: `group/${g.data.id}/about` } });
20
21
  }
@@ -31,6 +31,12 @@ export const routes: RouteRecordRaw[] = [
31
31
  component: () => import('../views/PageView.vue'),
32
32
  props: true,
33
33
  },
34
+ {
35
+ path: '/dataset/:registerId/sources',
36
+ name: 'sources',
37
+ component: () => import('../views/SourcesView.vue'),
38
+ props: true,
39
+ },
34
40
  {
35
41
  path: '/group/:groupId',
36
42
  name: 'group',
@@ -331,6 +331,10 @@ function clearSection() {
331
331
  <router-link :to="{ name: 'about', params: { registerId } }" class="badge badge-purple hover:opacity-80 transition-opacity">
332
332
  {{ t('nav.about') }}
333
333
  </router-link>
334
+ <router-link v-if="manifest.sourceCount" :to="{ name: 'sources', params: { registerId } }" class="badge badge-gray hover:opacity-80 transition-opacity">
335
+ {{ manifest.sourceCount }} {{ t('dataset.sources') }}
336
+ </router-link>
337
+ <span v-if="manifest.relationshipCount" class="badge badge-yellow">{{ manifest.relationshipCount.toLocaleString() }} {{ t('dataset.relationships') }}</span>
334
338
  </div>
335
339
  </div>
336
340
 
@@ -0,0 +1,134 @@
1
+ <script setup lang="ts">
2
+ import { ref, computed, watch } from 'vue';
3
+ import { useVocabularyStore } from '../stores/vocabulary';
4
+ import { useDatasetLoader } from '../composables/use-dataset-loader';
5
+ import { useDsStyle } from '../utils/dataset-style';
6
+ import { useI18n } from '../i18n';
7
+
8
+ const props = defineProps<{ registerId?: string }>();
9
+
10
+ const store = useVocabularyStore();
11
+ const { getColor } = useDsStyle();
12
+ const { loading, localError, ensureLoaded, resolvedId } = useDatasetLoader(() => props.registerId);
13
+ const { t } = useI18n();
14
+
15
+ const manifest = computed(() => store.manifests.get(resolvedId.value));
16
+
17
+ interface SourceEntry {
18
+ ref: string;
19
+ types: string[];
20
+ conceptCount: number;
21
+ conceptIds: string[];
22
+ }
23
+
24
+ const stats = ref<{ sources: SourceEntry[]; sourceCount: number; relationshipCount: number; relationshipTypes: Record<string, number> } | null>(null);
25
+ const expandedSource = ref<string | null>(null);
26
+ const filterText = ref('');
27
+
28
+ async function loadStats() {
29
+ if (!resolvedId.value) return;
30
+ const base = import.meta.env.BASE_URL;
31
+ try {
32
+ const resp = await fetch(`${base}data/${resolvedId.value}/stats.json`);
33
+ if (resp.ok) {
34
+ stats.value = await resp.json();
35
+ }
36
+ } catch (e) {
37
+ console.warn('Failed to load stats:', e);
38
+ }
39
+ }
40
+
41
+ watch(() => resolvedId.value, () => { loadStats(); }, { immediate: true });
42
+
43
+ const filteredSources = computed(() => {
44
+ if (!stats.value?.sources) return [];
45
+ const q = filterText.value.toLowerCase().trim();
46
+ if (!q) return stats.value.sources;
47
+ return stats.value.sources.filter(s =>
48
+ s.ref.toLowerCase().includes(q) ||
49
+ s.types.some(t => t.toLowerCase().includes(q))
50
+ );
51
+ });
52
+
53
+ function sourceTypeBadge(type: string): string {
54
+ if (type === 'authoritative') return 'badge-green';
55
+ if (type === 'lineage') return 'badge-yellow';
56
+ return 'badge-gray';
57
+ }
58
+ </script>
59
+
60
+ <template>
61
+ <div class="max-w-4xl mx-auto">
62
+ <div v-if="loading" class="flex items-center justify-center py-20">
63
+ <div class="skeleton h-8 w-64"></div>
64
+ </div>
65
+
66
+ <div v-else-if="localError" class="card p-8 text-center">
67
+ <p class="text-red-600">{{ localError }}</p>
68
+ </div>
69
+
70
+ <template v-else-if="manifest">
71
+ <div class="mb-6">
72
+ <div class="text-xs text-ink-400 mb-2">
73
+ <router-link :to="{ name: 'dataset', params: { registerId } }" class="hover:text-blue-600">{{ manifest.title }}</router-link>
74
+ <span class="mx-1">›</span>
75
+ <span>{{ t('nav.sources') }}</span>
76
+ </div>
77
+ <h1 class="font-serif text-3xl text-ink-800 dark:text-ink-100">{{ t('nav.sources') }}</h1>
78
+ <p class="text-ink-400 mt-1" v-if="stats">{{ stats.sourceCount }} {{ t('dataset.sources') }} · {{ manifest.conceptCount.toLocaleString() }} {{ t('dataset.concepts') }}</p>
79
+ </div>
80
+
81
+ <div v-if="!stats" class="card p-8 text-center text-ink-400">
82
+ Loading sources…
83
+ </div>
84
+
85
+ <template v-else>
86
+ <div class="card p-3 mb-4">
87
+ <input
88
+ v-model="filterText"
89
+ type="text"
90
+ :placeholder="'Filter sources...'"
91
+ class="w-full px-3 py-2 text-sm border border-ink-100 dark:border-ink-700 rounded-lg bg-surface-raised dark:bg-ink-800 text-ink-700 dark:text-ink-200 focus:outline-none focus:border-blue-400"
92
+ />
93
+ </div>
94
+
95
+ <div class="space-y-2">
96
+ <div v-for="src in filteredSources" :key="src.ref" class="card overflow-hidden">
97
+ <button
98
+ class="w-full text-left p-4 flex items-center gap-3 hover:bg-ink-50/50 dark:hover:bg-ink-800/30 transition-colors"
99
+ @click="expandedSource = expandedSource === src.ref ? null : src.ref"
100
+ >
101
+ <span
102
+ class="w-3 h-3 rounded-full flex-shrink-0"
103
+ :style="{ backgroundColor: getColor(resolvedId) }"
104
+ ></span>
105
+ <div class="flex-1 min-w-0">
106
+ <div class="font-medium text-ink-800 dark:text-ink-100 truncate">{{ src.ref }}</div>
107
+ <div class="flex items-center gap-1.5 mt-1">
108
+ <span v-for="tp in src.types" :key="tp" class="badge text-[9px]" :class="sourceTypeBadge(tp)">{{ tp }}</span>
109
+ <span class="text-xs text-ink-400">{{ src.conceptCount }} {{ t('dataset.concepts') }}</span>
110
+ </div>
111
+ </div>
112
+ <svg class="w-4 h-4 text-ink-300 transition-transform flex-shrink-0" :class="{ 'rotate-180': expandedSource === src.ref }" fill="none" stroke="currentColor" viewBox="0 0 24 24">
113
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
114
+ </svg>
115
+ </button>
116
+
117
+ <div v-if="expandedSource === src.ref" class="border-t border-ink-100/60 dark:border-ink-700/40 p-4">
118
+ <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-1">
119
+ <router-link
120
+ v-for="cid in src.conceptIds"
121
+ :key="cid"
122
+ :to="{ name: 'concept', params: { registerId, conceptId: cid } }"
123
+ class="block px-2 py-1 rounded text-sm text-ink-600 dark:text-ink-300 hover:bg-blue-50 dark:hover:bg-blue-900/20 hover:text-blue-700 dark:hover:text-blue-300 transition-colors"
124
+ >
125
+ <code class="text-xs text-ink-400 font-mono mr-1">{{ cid }}</code>
126
+ </router-link>
127
+ </div>
128
+ </div>
129
+ </div>
130
+ </div>
131
+ </template>
132
+ </template>
133
+ </div>
134
+ </template>
@@ -1,5 +1,5 @@
1
1
  <script setup lang="ts">
2
- import { computed } from 'vue';
2
+ import { ref, computed, watch } from 'vue';
3
3
  import { useVocabularyStore } from '../stores/vocabulary';
4
4
  import { useDsStyle } from '../utils/dataset-style';
5
5
  import { useDatasetLoader } from '../composables/use-dataset-loader';
@@ -15,51 +15,37 @@ const { t } = useI18n();
15
15
 
16
16
  const manifest = computed(() => store.manifests.get(resolvedId.value));
17
17
 
18
- interface LangStat {
19
- lang: string;
20
- terms: number;
21
- definitions: number;
22
- }
18
+ interface LangStat { lang: string; terms: number; definitions: number; }
23
19
 
24
- const stats = computed(() => {
20
+ const langStats = computed(() => {
25
21
  const m = manifest.value;
26
22
  if (!m) return { langs: [], total: 0 };
27
-
28
- // Scan the index for actual language coverage
29
23
  const adapter = store.datasets.get(resolvedId.value);
30
24
  const conceptCounts: Record<string, number> = {};
31
-
32
25
  if (adapter) {
33
- const concepts = adapter.getConcepts();
34
- for (const c of concepts) {
26
+ for (const c of adapter.getConcepts()) {
35
27
  if (!c) continue;
36
28
  for (const lang of Object.keys(c.designations)) {
37
29
  conceptCounts[lang] = (conceptCounts[lang] || 0) + 1;
38
30
  }
39
31
  }
40
32
  }
41
-
42
- // Merge with manifest languageStats (for definition counts)
43
33
  const ls = m.languageStats || {};
44
34
  const allLangs = new Set([...Object.keys(conceptCounts), ...m.languages]);
45
-
46
35
  const langs: LangStat[] = [...allLangs].map(lang => ({
47
36
  lang,
48
37
  terms: ls[lang]?.terms ?? conceptCounts[lang] ?? 0,
49
38
  definitions: ls[lang]?.definitions ?? conceptCounts[lang] ?? 0,
50
39
  }));
51
-
52
- // Sort: eng first, then by term count descending
53
40
  langs.sort((a, b) => {
54
41
  if (a.lang === 'eng') return -1;
55
42
  if (b.lang === 'eng') return 1;
56
43
  return b.terms - a.terms;
57
44
  });
58
-
59
45
  return { langs, total: m.conceptCount };
60
46
  });
61
47
 
62
- const maxTerms = computed(() => Math.max(...stats.value.langs.map(l => l.terms), 1));
48
+ const maxTerms = computed(() => Math.max(...langStats.value.langs.map(l => l.terms), 1));
63
49
 
64
50
  function coverageColor(ratio: number): string {
65
51
  if (ratio >= 0.8) return 'bg-emerald-500';
@@ -67,11 +53,49 @@ function coverageColor(ratio: number): string {
67
53
  if (ratio >= 0.25) return 'bg-amber-500';
68
54
  return 'bg-red-400';
69
55
  }
56
+
57
+ interface StatsData {
58
+ sourceCount: number;
59
+ sources: Array<{ ref: string; types: string[]; conceptCount: number }>;
60
+ relationshipCount: number;
61
+ relationshipTypes: Record<string, number>;
62
+ }
63
+
64
+ const statsData = ref<StatsData | null>(null);
65
+
66
+ async function loadStats() {
67
+ if (!resolvedId.value) return;
68
+ const base = import.meta.env.BASE_URL;
69
+ try {
70
+ const resp = await fetch(`${base}data/${resolvedId.value}/stats.json`);
71
+ if (resp.ok) statsData.value = await resp.json();
72
+ } catch {}
73
+ }
74
+
75
+ watch(() => resolvedId.value, () => { loadStats(); }, { immediate: true });
76
+
77
+ const relEntries = computed(() => {
78
+ if (!statsData.value?.relationshipTypes) return [];
79
+ return Object.entries(statsData.value.relationshipTypes)
80
+ .sort(([, a], [, b]) => b - a);
81
+ });
82
+
83
+ const maxRel = computed(() => Math.max(...relEntries.value.map(([, n]) => n), 1));
84
+
85
+ const topSources = computed(() => {
86
+ if (!statsData.value?.sources) return [];
87
+ return statsData.value.sources.slice(0, 10);
88
+ });
89
+
90
+ function sourceTypeBadge(type: string): string {
91
+ if (type === 'authoritative') return 'badge-green';
92
+ if (type === 'lineage') return 'badge-yellow';
93
+ return 'badge-gray';
94
+ }
70
95
  </script>
71
96
 
72
97
  <template>
73
98
  <div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
74
- <!-- Breadcrumb -->
75
99
  <nav aria-label="Breadcrumb" class="flex items-center gap-1.5 text-sm text-ink-400 mb-6">
76
100
  <router-link :to="{ name: 'home' }" class="hover:text-ink-700 transition-colors">{{ t('nav.home') }}</router-link>
77
101
  <span class="text-ink-200">/</span>
@@ -84,9 +108,7 @@ function coverageColor(ratio: number): string {
84
108
  <div class="animate-pulse space-y-6">
85
109
  <div class="h-8 bg-ink-100 rounded w-32"></div>
86
110
  <div class="h-4 bg-ink-100 rounded w-64"></div>
87
- <div class="card overflow-hidden">
88
- <div class="h-80 bg-ink-50"></div>
89
- </div>
111
+ <div class="card overflow-hidden"><div class="h-80 bg-ink-50"></div></div>
90
112
  </div>
91
113
  </template>
92
114
  <template v-else-if="localError">
@@ -99,46 +121,83 @@ function coverageColor(ratio: number): string {
99
121
  <template v-else-if="manifest">
100
122
  <h1 class="font-serif text-3xl text-ink-800 mb-2">{{ t('stats.title') }}</h1>
101
123
  <p class="text-ink-400 mb-8">
102
- {{ t('stats.summary', { count: stats.total.toLocaleString(), langCount: String(manifest.languages.length) }) }}
124
+ {{ t('stats.summary', { count: langStats.total.toLocaleString(), langCount: String(manifest.languages.length) }) }}
103
125
  </p>
104
126
 
105
- <!-- Language stats table -->
106
- <div class="card -mx-4 sm:mx-0 overflow-x-auto">
107
- <table class="w-full text-sm">
108
- <thead>
109
- <tr class="border-b border-ink-100/60 bg-ink-50">
110
- <th class="text-left px-5 py-3 text-ink-600 font-medium text-xs uppercase tracking-wide">{{ t('stats.language') }}</th>
111
- <th class="text-right px-5 py-3 text-ink-600 font-medium text-xs uppercase tracking-wide">{{ t('stats.terms') }}</th>
112
- <th class="text-right px-5 py-3 text-ink-600 font-medium text-xs uppercase tracking-wide">{{ t('stats.definitions') }}</th>
113
- <th class="px-5 py-3 text-ink-600 font-medium w-40"></th>
114
- </tr>
115
- </thead>
116
- <tbody>
117
- <tr v-for="s in stats.langs" :key="s.lang" class="border-b border-ink-50 last:border-0">
118
- <td class="px-5 py-3">
119
- <div class="flex items-center gap-2">
120
- <span class="text-xs font-semibold text-ink-500 bg-ink-50 px-1.5 py-0.5 rounded">{{ langLabel(s.lang) }}</span>
121
- <span class="font-medium text-ink-800">{{ langName(s.lang) }}</span>
122
- <span class="text-ink-300 text-xs">({{ s.lang }})</span>
123
- </div>
124
- </td>
125
- <td class="text-right px-5 py-3 font-mono text-ink-700">{{ s.terms.toLocaleString() }}</td>
126
- <td class="text-right px-5 py-3 font-mono text-ink-700">{{ s.definitions.toLocaleString() }}</td>
127
- <td class="px-5 py-3">
128
- <div class="flex items-center gap-2">
129
- <div class="h-2 rounded-full bg-ink-50 overflow-hidden flex-1">
130
- <div
131
- class="h-full rounded-full transition-all duration-500"
132
- :class="coverageColor(s.terms / maxTerms)"
133
- :style="{ width: (s.terms / maxTerms * 100) + '%' }"
134
- ></div>
135
- </div>
136
- <span class="text-xs text-ink-300 w-10 text-right tabular-nums">{{ Math.round(s.terms / maxTerms * 100) }}%</span>
137
- </div>
138
- </td>
139
- </tr>
140
- </tbody>
141
- </table>
127
+ <div v-if="statsData" class="flex flex-wrap gap-2 mb-8">
128
+ <span class="badge badge-purple">{{ statsData.sourceCount }} {{ t('dataset.sources') }}</span>
129
+ <span class="badge badge-yellow">{{ statsData.relationshipCount.toLocaleString() }} {{ t('dataset.relationships') }}</span>
130
+ </div>
131
+
132
+ <div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
133
+ <div>
134
+ <h2 class="section-label mb-3">{{ t('stats.language') }}</h2>
135
+ <div class="card overflow-x-auto">
136
+ <table class="w-full text-sm">
137
+ <thead>
138
+ <tr class="border-b border-ink-100/60 bg-ink-50">
139
+ <th class="text-left px-4 py-2.5 text-ink-600 font-medium text-xs uppercase tracking-wide">{{ t('stats.language') }}</th>
140
+ <th class="text-right px-4 py-2.5 text-ink-600 font-medium text-xs uppercase tracking-wide">{{ t('stats.terms') }}</th>
141
+ <th class="text-right px-4 py-2.5 text-ink-600 font-medium text-xs uppercase tracking-wide">{{ t('stats.definitions') }}</th>
142
+ </tr>
143
+ </thead>
144
+ <tbody>
145
+ <tr v-for="s in langStats.langs" :key="s.lang" class="border-b border-ink-50 last:border-0">
146
+ <td class="px-4 py-2.5">
147
+ <span class="text-xs font-semibold text-ink-500 bg-ink-50 px-1.5 py-0.5 rounded">{{ langLabel(s.lang) }}</span>
148
+ <span class="ml-2 font-medium text-ink-800">{{ langName(s.lang) }}</span>
149
+ </td>
150
+ <td class="text-right px-4 py-2.5 font-mono text-ink-700">{{ s.terms.toLocaleString() }}</td>
151
+ <td class="text-right px-4 py-2.5 font-mono text-ink-700">{{ s.definitions.toLocaleString() }}</td>
152
+ </tr>
153
+ </tbody>
154
+ </table>
155
+ </div>
156
+ </div>
157
+
158
+ <div v-if="relEntries.length">
159
+ <h2 class="section-label mb-3">{{ t('dataset.relationships') }}</h2>
160
+ <div class="card p-4 space-y-3">
161
+ <div v-for="[type, count] in relEntries" :key="type">
162
+ <div class="flex items-center justify-between mb-1">
163
+ <span class="text-sm font-medium text-ink-700">{{ type }}</span>
164
+ <span class="text-sm font-mono text-ink-500">{{ count.toLocaleString() }}</span>
165
+ </div>
166
+ <div class="h-2 rounded-full bg-ink-50 overflow-hidden">
167
+ <div class="h-full rounded-full bg-blue-500 transition-all duration-500" :style="{ width: (count / maxRel * 100) + '%' }"></div>
168
+ </div>
169
+ </div>
170
+ </div>
171
+ </div>
172
+ </div>
173
+
174
+ <div v-if="topSources.length" class="mt-8">
175
+ <div class="flex items-center justify-between mb-3">
176
+ <h2 class="section-label">{{ t('dataset.sources') }}</h2>
177
+ <router-link :to="{ name: 'sources', params: { registerId: resolvedId } }" class="text-xs text-blue-600 hover:underline">
178
+ View all {{ statsData?.sourceCount }} →
179
+ </router-link>
180
+ </div>
181
+ <div class="card overflow-x-auto">
182
+ <table class="w-full text-sm">
183
+ <thead>
184
+ <tr class="border-b border-ink-100/60 bg-ink-50">
185
+ <th class="text-left px-4 py-2.5 text-ink-600 font-medium text-xs uppercase tracking-wide">Source</th>
186
+ <th class="text-left px-4 py-2.5 text-ink-600 font-medium text-xs uppercase tracking-wide">Type</th>
187
+ <th class="text-right px-4 py-2.5 text-ink-600 font-medium text-xs uppercase tracking-wide">Concepts</th>
188
+ </tr>
189
+ </thead>
190
+ <tbody>
191
+ <tr v-for="src in topSources" :key="src.ref" class="border-b border-ink-50 last:border-0">
192
+ <td class="px-4 py-2.5 font-medium text-ink-800">{{ src.ref }}</td>
193
+ <td class="px-4 py-2.5">
194
+ <span v-for="tp in src.types" :key="tp" class="badge text-[9px] mr-1" :class="sourceTypeBadge(tp)">{{ tp }}</span>
195
+ </td>
196
+ <td class="text-right px-4 py-2.5 font-mono text-ink-700">{{ src.conceptCount.toLocaleString() }}</td>
197
+ </tr>
198
+ </tbody>
199
+ </table>
200
+ </div>
142
201
  </div>
143
202
  </template>
144
203
  </div>