@glossarist/concept-browser 0.7.66 → 0.7.68

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 (70) hide show
  1. package/cli/index.mjs +38 -33
  2. package/package.json +2 -2
  3. package/scripts/__tests__/cli-script-contract.test.mjs +39 -0
  4. package/scripts/__tests__/first-non-empty.test.mjs +51 -0
  5. package/scripts/__tests__/process-about-pages.test.mjs +106 -0
  6. package/scripts/build-edges.js +10 -2
  7. package/scripts/fetch-datasets.mjs +61 -54
  8. package/scripts/generate-data.mjs +40 -26
  9. package/scripts/lib/first-non-empty.mjs +19 -0
  10. package/scripts/process-about-pages.mjs +63 -15
  11. package/scripts/validate-shacl.mjs +1 -1
  12. package/src/__tests__/concept-rdf-view.test.ts +21 -13
  13. package/src/__tests__/perf/serialization-perf.test.ts +13 -30
  14. package/src/__tests__/tc204-deployment.test.ts +139 -0
  15. package/src/__tests__/use-rdf-document.test.ts +125 -0
  16. package/src/adapters/types.ts +7 -0
  17. package/src/components/ConceptRdfView.vue +4 -4
  18. package/src/components/use-rdf-document.ts +137 -0
  19. package/src/composables/useDatasetSeries.ts +1 -1
  20. package/src/__tests__/concept-rdf/agents-emitter.test.ts +0 -110
  21. package/src/__tests__/concept-rdf/bibliography-emitter.test.ts +0 -159
  22. package/src/__tests__/concept-rdf/build-activity-emitter.test.ts +0 -119
  23. package/src/__tests__/concept-rdf/coerce-date.test.ts +0 -97
  24. package/src/__tests__/concept-rdf/concept-emitter.test.ts +0 -258
  25. package/src/__tests__/concept-rdf/dataset-emitter.test.ts +0 -224
  26. package/src/__tests__/concept-rdf/differential.test.ts +0 -96
  27. package/src/__tests__/concept-rdf/group-emitter.test.ts +0 -109
  28. package/src/__tests__/concept-rdf/image-variant-emitter.test.ts +0 -135
  29. package/src/__tests__/concept-rdf/jsonld-writer.test.ts +0 -109
  30. package/src/__tests__/concept-rdf/nonverbal-rep.test.ts +0 -177
  31. package/src/__tests__/concept-rdf/property-based.test.ts +0 -179
  32. package/src/__tests__/concept-rdf/provenance.test.ts +0 -110
  33. package/src/__tests__/concept-rdf/quad-isomorphism.test.ts +0 -43
  34. package/src/__tests__/concept-rdf/quad-isomorphism.ts +0 -47
  35. package/src/__tests__/concept-rdf/rdf-components.test.ts +0 -145
  36. package/src/__tests__/concept-rdf/rdf-graph.test.ts +0 -115
  37. package/src/__tests__/concept-rdf/round-trip.test.ts +0 -243
  38. package/src/__tests__/concept-rdf/scoped-examples.test.ts +0 -126
  39. package/src/__tests__/concept-rdf/sections-builder.test.ts +0 -94
  40. package/src/__tests__/concept-rdf/shacl-conformance.test.ts +0 -110
  41. package/src/__tests__/concept-rdf/shape-consistency.test.ts +0 -138
  42. package/src/__tests__/concept-rdf/snapshot-generation.test.ts +0 -75
  43. package/src/__tests__/concept-rdf/table-formula-emitter.test.ts +0 -142
  44. package/src/__tests__/concept-rdf/turtle-writer.test.ts +0 -114
  45. package/src/__tests__/concept-rdf/use-rdf-document.test.ts +0 -246
  46. package/src/__tests__/concept-rdf/version-emitter.test.ts +0 -120
  47. package/src/__tests__/concept-rdf/vocabulary-ssot.test.ts +0 -100
  48. package/src/__tests__/perf/bundle-layout.test.ts +0 -50
  49. package/src/components/concept-rdf/agents-emitter.ts +0 -82
  50. package/src/components/concept-rdf/bibliography-emitter.ts +0 -83
  51. package/src/components/concept-rdf/build-activity-emitter.ts +0 -89
  52. package/src/components/concept-rdf/concept-emitter.ts +0 -443
  53. package/src/components/concept-rdf/dataset-emitter.ts +0 -95
  54. package/src/components/concept-rdf/group-emitter.ts +0 -69
  55. package/src/components/concept-rdf/image-variant-emitter.ts +0 -46
  56. package/src/components/concept-rdf/jsonld-writer.ts +0 -82
  57. package/src/components/concept-rdf/predicates.ts +0 -261
  58. package/src/components/concept-rdf/provenance.ts +0 -80
  59. package/src/components/concept-rdf/rdf-graph.ts +0 -211
  60. package/src/components/concept-rdf/sections-builder.ts +0 -62
  61. package/src/components/concept-rdf/table-formula-emitter.ts +0 -101
  62. package/src/components/concept-rdf/turtle-writer.ts +0 -116
  63. package/src/components/concept-rdf/use-rdf-document.ts +0 -72
  64. package/src/components/concept-rdf/version-emitter.ts +0 -65
  65. package/src/components/concept-rdf/vocabulary-emitter.ts +0 -62
  66. /package/src/components/{concept-rdf/RdfInstanceHeader.vue → RdfInstanceHeader.vue} +0 -0
  67. /package/src/components/{concept-rdf/RdfInstanceSection.vue → RdfInstanceSection.vue} +0 -0
  68. /package/src/components/{concept-rdf/RdfPrefixLegend.vue → RdfPrefixLegend.vue} +0 -0
  69. /package/src/components/{concept-rdf/RdfSourcePanel.vue → RdfSourcePanel.vue} +0 -0
  70. /package/src/components/{concept-rdf/rdf-prefixes.ts → rdf-prefixes.ts} +0 -0
@@ -14,16 +14,35 @@
14
14
  //
15
15
  // Output shape: { title: string, html: string }
16
16
 
17
- import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, statSync } from 'node:fs';
18
- import { join, basename, extname } from 'node:path';
17
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, statSync, realpathSync } from 'node:fs';
18
+ import { join, basename, extname, resolve } from 'node:path';
19
19
  import { cwd } from 'node:process';
20
+ import { fileURLToPath } from 'node:url';
20
21
 
21
- const ROOT = cwd();
22
- const PUBLIC_PAGES = join(ROOT, 'public', 'pages');
23
- const DATASETS_DIR = join(ROOT, '.datasets');
24
- const GROUPS_CONTENT_DIR = join(ROOT, 'site-content', 'groups');
22
+ import { loadSiteConfig } from './load-site-config.mjs';
25
23
 
26
- function renderMarkdown(text) {
24
+ export function loadSiteConfigForAbout() {
25
+ try {
26
+ const { config } = loadSiteConfig([]);
27
+ return config;
28
+ } catch {
29
+ return null;
30
+ }
31
+ }
32
+
33
+ // Resolve roots at call time, not at module load — supports test
34
+ // environments that chdir between module import and main() call.
35
+ export function roots() {
36
+ const ROOT = cwd();
37
+ return {
38
+ ROOT,
39
+ PUBLIC_PAGES: join(ROOT, 'public', 'pages'),
40
+ DATASETS_DIR: join(ROOT, '.datasets'),
41
+ GROUPS_CONTENT_DIR: join(ROOT, 'site-content', 'groups'),
42
+ };
43
+ }
44
+
45
+ export function renderMarkdown(text) {
27
46
  // Minimal markdown → HTML. For production, use a real parser.
28
47
  // This handles headings, paragraphs, bold, italic, links, lists.
29
48
  const lines = text.split('\n');
@@ -65,7 +84,7 @@ function inline(text) {
65
84
  .replace(/`(.+?)`/g, '<code>$1</code>');
66
85
  }
67
86
 
68
- function processAboutDir(sourceDir, outputPrefix) {
87
+ export function processAboutDir(sourceDir, outputPrefix, publicPagesDir) {
69
88
  if (!existsSync(sourceDir)) return 0;
70
89
  let count = 0;
71
90
 
@@ -104,8 +123,8 @@ function processAboutDir(sourceDir, outputPrefix) {
104
123
  }
105
124
 
106
125
  const output = { title, html };
107
- mkdirSync(PUBLIC_PAGES, { recursive: true });
108
- writeFileSync(join(PUBLIC_PAGES, outputName), JSON.stringify(output));
126
+ mkdirSync(publicPagesDir, { recursive: true });
127
+ writeFileSync(join(publicPagesDir, outputName), JSON.stringify(output));
109
128
  count++;
110
129
  console.log(` Compiled: ${file} → public/pages/${outputName}`);
111
130
  }
@@ -113,20 +132,41 @@ function processAboutDir(sourceDir, outputPrefix) {
113
132
  return count;
114
133
  }
115
134
 
116
- function main() {
135
+ export function main() {
136
+ const { ROOT, PUBLIC_PAGES, DATASETS_DIR, GROUPS_CONTENT_DIR } = roots();
117
137
  mkdirSync(PUBLIC_PAGES, { recursive: true });
118
138
  let total = 0;
119
139
 
120
- // Dataset about pages
140
+ // Dataset about pages — discover from .datasets/<id>/about/ (fetched
141
+ // or copied) OR from site-config localPath overrides for datasets that
142
+ // aren't materialized under .datasets/.
143
+ const siteConfig = loadSiteConfigForAbout();
144
+ const localPathMap = new Map();
145
+ for (const ds of siteConfig?.datasets || []) {
146
+ if (ds.id && ds.localPath) {
147
+ localPathMap.set(ds.id, resolve(ROOT, ds.localPath));
148
+ }
149
+ }
150
+
151
+ const seenIds = new Set();
121
152
  if (existsSync(DATASETS_DIR)) {
122
153
  for (const dsId of readdirSync(DATASETS_DIR)) {
154
+ seenIds.add(dsId);
123
155
  const aboutDir = join(DATASETS_DIR, dsId, 'about');
124
156
  if (existsSync(aboutDir) && statSync(aboutDir).isDirectory()) {
125
157
  console.log(`Processing dataset: ${dsId}`);
126
- total += processAboutDir(aboutDir, `dataset-${dsId}-about`);
158
+ total += processAboutDir(aboutDir, `dataset-${dsId}-about`, PUBLIC_PAGES);
127
159
  }
128
160
  }
129
161
  }
162
+ for (const [dsId, dsPath] of localPathMap) {
163
+ if (seenIds.has(dsId)) continue;
164
+ const aboutDir = join(dsPath, 'about');
165
+ if (existsSync(aboutDir) && statSync(aboutDir).isDirectory()) {
166
+ console.log(`Processing dataset (localPath): ${dsId}`);
167
+ total += processAboutDir(aboutDir, `dataset-${dsId}-about`, PUBLIC_PAGES);
168
+ }
169
+ }
130
170
 
131
171
  // Group about pages
132
172
  if (existsSync(GROUPS_CONTENT_DIR)) {
@@ -134,7 +174,7 @@ function main() {
134
174
  const aboutDir = join(GROUPS_CONTENT_DIR, groupId, 'about');
135
175
  if (existsSync(aboutDir) && statSync(aboutDir).isDirectory()) {
136
176
  console.log(`Processing group: ${groupId}`);
137
- total += processAboutDir(aboutDir, `group-${groupId}-about`);
177
+ total += processAboutDir(aboutDir, `group-${groupId}-about`, PUBLIC_PAGES);
138
178
  }
139
179
  }
140
180
  }
@@ -142,4 +182,12 @@ function main() {
142
182
  console.log(total > 0 ? `\nCompiled ${total} about page(s).` : '\nNo about pages found.');
143
183
  }
144
184
 
145
- main();
185
+ // realpathSync dereferences symlinks and monorepo hoists so the
186
+ // comparison is stable across npx, symlinked binaries, and workspace
187
+ // installs. Without this, `process.argv[1]` may string-differ from
188
+ // `import.meta.url` even when they point to the same file.
189
+ const isDirectInvocation = process.argv[1]
190
+ && realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
191
+ if (isDirectInvocation) {
192
+ main();
193
+ }
@@ -26,7 +26,7 @@ import { join, extname, dirname, resolve } from 'node:path';
26
26
  import { fileURLToPath } from 'node:url';
27
27
  import { Parser as N3Parser } from 'n3';
28
28
  import rdfDataset from '@rdfjs/dataset';
29
- import { validateShacl, quadsToDataset } from 'glossarist/rdf';
29
+ import { validateShacl, quadsToDataset } from 'glossarist/rdf/shacl';
30
30
 
31
31
  const __dirname = dirname(fileURLToPath(import.meta.url));
32
32
  const VENDORED_SHAPES = resolve(__dirname, '..', 'data', 'concept-model', 'shapes', 'glossarist.shacl.ttl');
@@ -64,14 +64,19 @@ async function mountFixture(uri: string, concept: Concept) {
64
64
  return wrapper;
65
65
  }
66
66
 
67
- describe('ConceptRdfView — Turtle emission', () => {
68
- it('declares the skosxl: prefix (not xl:)', async () => {
67
+ // Contract tests for the migrated view. The view delegates RDF emission
68
+ // to glossarist-js via useRdfDocument. These tests verify the view still
69
+ // renders, the panel still opens, and RDF is produced — without coupling
70
+ // to the specific turtle format details (which are tested in glossarist-js's
71
+ // own suite).
72
+ describe('ConceptRdfView — Turtle emission contract', () => {
73
+ it('declares @prefix gloss: and skos: and skosxl:', async () => {
69
74
  const wrapper = await mountRdfView();
70
75
  await openRdfSourcePanel(wrapper);
71
- const pre = wrapper.find('pre');
72
- const text = pre.text();
76
+ const text = wrapper.find('pre').text();
77
+ expect(text).toContain('@prefix gloss: <https://www.glossarist.org/ontologies/>');
78
+ expect(text).toContain('@prefix skos: <http://www.w3.org/2004/02/skos/core#>');
73
79
  expect(text).toContain('@prefix skosxl: <http://www.w3.org/2008/05/skos-xl#>');
74
- expect(text).not.toContain('@prefix xl:');
75
80
  });
76
81
 
77
82
  it('uses skosxl: consistently (no stray xl: references)', async () => {
@@ -83,34 +88,37 @@ describe('ConceptRdfView — Turtle emission', () => {
83
88
  expect(text).toContain('skosxl:literalForm');
84
89
  });
85
90
 
86
- it('emits BOTH skosxl:prefLabel (reified) AND skos:prefLabel (direct literal)', async () => {
91
+ it('emits skosxl:prefLabel and skos:prefLabel both', async () => {
87
92
  const wrapper = await mountRdfView();
88
93
  await openRdfSourcePanel(wrapper);
89
94
  const text = wrapper.find('pre').text();
90
- expect(text).toMatch(/skosxl:prefLabel\s+<[^>]+\/eng\/desig\//);
95
+ // glossarist-js emits the skosxl:prefLabel link (object may be bnode or URI)
96
+ expect(text).toMatch(/skosxl:prefLabel\s+/);
97
+ // And the direct SKOS literal
91
98
  expect(text).toMatch(/skos:prefLabel "atomic data unit"@eng/);
92
99
  });
93
100
 
94
- it('emits BOTH skosxl:altLabel AND skos:altLabel for non-preferred designations', async () => {
101
+ it('emits skosxl:altLabel and skos:altLabel both for admitted', async () => {
95
102
  const wrapper = await mountRdfView();
96
103
  await openRdfSourcePanel(wrapper);
97
104
  const text = wrapper.find('pre').text();
98
- expect(text).toMatch(/skosxl:altLabel\s+<[^>]+\/eng\/desig\//);
105
+ expect(text).toMatch(/skosxl:altLabel\s+/);
99
106
  expect(text).toMatch(/skos:altLabel "ADU"@eng/);
100
107
  });
101
108
 
102
- it('emits skos:definition as a direct language-tagged literal', async () => {
109
+ it('emits skos:definition with language tag', async () => {
103
110
  const wrapper = await mountRdfView();
104
111
  await openRdfSourcePanel(wrapper);
105
112
  const text = wrapper.find('pre').text();
106
113
  expect(text).toMatch(/skos:definition "A data unit that cannot be subdivided\."@eng/);
107
114
  });
108
115
 
109
- it('emits gloss:hasDefinition with a typed DetailedDefinition and language-tagged rdf:value', async () => {
116
+ it('emits gloss:hasDefinition with DetailedDefinition typed resource', async () => {
110
117
  const wrapper = await mountRdfView();
111
118
  await openRdfSourcePanel(wrapper);
112
119
  const text = wrapper.find('pre').text();
113
- expect(text).toMatch(/gloss:hasDefinition \[ (a|rdf:type) gloss:DetailedDefinition ; rdf:value "[^"]+"@eng \]/);
120
+ expect(text).toContain('gloss:DetailedDefinition');
121
+ expect(text).toMatch(/rdf:value "[^"]+"@eng/);
114
122
  });
115
123
  });
116
124
 
@@ -121,7 +129,7 @@ describe('ConceptRdfView — Layer 6 fixture corpus snapshots', () => {
121
129
  const text = wrapper.find('pre').text();
122
130
  expect(text).toContain('@prefix gloss:');
123
131
  expect(text).toContain('@prefix skos:');
124
- expect(text).toContain(`@prefix skosxl:`);
132
+ expect(text).toContain('@prefix skosxl:');
125
133
  expect(text).toContain(fixture.uri);
126
134
  });
127
135
 
@@ -1,8 +1,6 @@
1
1
  import { describe, it, expect } from 'vitest';
2
2
  import { Concept } from 'glossarist';
3
- import { emitConceptGraph } from '../../components/concept-rdf/concept-emitter';
4
- import { writeTurtle } from '../../components/concept-rdf/turtle-writer';
5
- import { writeJsonLd } from '../../components/concept-rdf/jsonld-writer';
3
+ import { conceptToQuads, collectQuads, writeTurtleSync, PREFIXES } from 'glossarist/rdf';
6
4
  import type { ConceptFixture } from '../__fixtures__/concepts';
7
5
  import { CONCEPT_FIXTURES } from '../__fixtures__/concepts';
8
6
 
@@ -41,9 +39,9 @@ describe('Layer 7 — serialization performance regression', () => {
41
39
  const concepts = makeConcepts(TARGET_CONCEPT_COUNT);
42
40
 
43
41
  const start = performance.now();
44
- for (const { concept, uri } of concepts) {
45
- const { graph } = emitConceptGraph(concept, uri);
46
- writeTurtle(graph);
42
+ for (const { concept } of concepts) {
43
+ const quads = collectQuads(conceptToQuads(concept, { registerId: 'perf', uriBase: BASE }));
44
+ writeTurtleSync(quads, { prefixes: PREFIXES });
47
45
  }
48
46
  const elapsed = performance.now() - start;
49
47
 
@@ -52,27 +50,12 @@ describe('Layer 7 — serialization performance regression', () => {
52
50
  expect(elapsed).toBeLessThan(TIME_BUDGET_MS);
53
51
  });
54
52
 
55
- it(`emits JSON-LD for ${TARGET_CONCEPT_COUNT} concepts under ${TIME_BUDGET_MS}ms`, () => {
56
- const concepts = makeConcepts(TARGET_CONCEPT_COUNT);
57
-
58
- const start = performance.now();
59
- for (const { concept, uri } of concepts) {
60
- const { graph } = emitConceptGraph(concept, uri);
61
- writeJsonLd(graph);
62
- }
63
- const elapsed = performance.now() - start;
64
-
65
- // eslint-disable-next-line no-console
66
- console.log(`Layer 7 perf: emitted ${TARGET_CONCEPT_COUNT} concepts to JSON-LD in ${elapsed.toFixed(0)}ms`);
67
- expect(elapsed).toBeLessThan(TIME_BUDGET_MS);
68
- });
69
-
70
53
  it('per-concept emit cost stays below 5ms on average', () => {
71
54
  const concepts = makeConcepts(100);
72
55
  const start = performance.now();
73
- for (const { concept, uri } of concepts) {
74
- const { graph } = emitConceptGraph(concept, uri);
75
- writeTurtle(graph);
56
+ for (const { concept } of concepts) {
57
+ const quads = collectQuads(conceptToQuads(concept, { registerId: 'perf', uriBase: BASE }));
58
+ writeTurtleSync(quads, { prefixes: PREFIXES });
76
59
  }
77
60
  const elapsed = performance.now() - start;
78
61
  const perConcept = elapsed / 100;
@@ -84,9 +67,9 @@ describe('Layer 7 — scale stress (P4: 10 000 concepts)', () => {
84
67
  it(`emits ${SCALE_CONCEPT_COUNT} concepts to Turtle under ${SCALE_TIME_BUDGET_MS}ms`, () => {
85
68
  const concepts = makeConcepts(SCALE_CONCEPT_COUNT);
86
69
  const start = performance.now();
87
- for (const { concept, uri } of concepts) {
88
- const { graph } = emitConceptGraph(concept, uri);
89
- writeTurtle(graph);
70
+ for (const { concept } of concepts) {
71
+ const quads = collectQuads(conceptToQuads(concept, { registerId: 'perf', uriBase: BASE }));
72
+ writeTurtleSync(quads, { prefixes: PREFIXES });
90
73
  }
91
74
  const elapsed = performance.now() - start;
92
75
  // eslint-disable-next-line no-console
@@ -113,9 +96,9 @@ describe('Layer 7 — scale stress (P4: 10 000 concepts)', () => {
113
96
 
114
97
  function timeTurtle(concepts: { concept: Concept; uri: string }[]): number {
115
98
  const start = performance.now();
116
- for (const { concept, uri } of concepts) {
117
- const { graph } = emitConceptGraph(concept, uri);
118
- writeTurtle(graph);
99
+ for (const { concept } of concepts) {
100
+ const quads = collectQuads(conceptToQuads(concept, { registerId: 'perf', uriBase: BASE }));
101
+ writeTurtleSync(quads, { prefixes: PREFIXES });
119
102
  }
120
103
  return performance.now() - start;
121
104
  }
@@ -0,0 +1,139 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { groupManifestsIntoSeries } from '../composables/useDatasetSeries';
3
+ import type { Manifest } from '../adapters/types';
4
+
5
+ // Integration tests for the isotc204.geolexica.org deployment shape:
6
+ // three editions of ISO 14812 (2022 / 2025 / ed3-draft) wired as a
7
+ // lineage-series group with an explicit `current` pointing at the draft.
8
+ //
9
+ // These tests verify the exact config shape that tc204 PR #28 uses, so
10
+ // regressions specific to that deployment are caught here rather than
11
+ // in production.
12
+
13
+ function makeManifest(overrides: Partial<Manifest> & { id: string }): Manifest {
14
+ const base: Manifest = {
15
+ id: overrides.id,
16
+ title: overrides.title ?? overrides.id,
17
+ description: overrides.description ?? '',
18
+ owner: overrides.owner ?? '',
19
+ baseUrl: overrides.baseUrl ?? '/',
20
+ languages: overrides.languages ?? ['eng'],
21
+ conceptCount: overrides.conceptCount ?? 0,
22
+ conceptUrlTemplate: overrides.conceptUrlTemplate ?? '/concept/{id}',
23
+ indexUrl: overrides.indexUrl ?? '/index.json',
24
+ contextUrl: overrides.contextUrl ?? '/context.json',
25
+ uriBase: overrides.uriBase ?? 'https://isotc204.geolexica.org',
26
+ status: overrides.status ?? 'valid',
27
+ schemaVersion: overrides.schemaVersion ?? '3',
28
+ tags: overrides.tags ?? [],
29
+ lastUpdated: overrides.lastUpdated ?? '',
30
+ sourceRepo: overrides.sourceRepo ?? '',
31
+ chunkSize: overrides.chunkSize ?? 0,
32
+ datasetUri: overrides.datasetUri ?? '',
33
+ };
34
+ return { ...base, ...overrides } as Manifest;
35
+ }
36
+
37
+ const TC204_GROUP = {
38
+ id: 'isotc204',
39
+ datasets: ['isotc204-ed3', 'isotc204-2025', 'isotc204-2022'],
40
+ kind: 'lineage' as const,
41
+ current: 'isotc204-ed3',
42
+ label: 'ISO/TC 204 ITS Vocabulary',
43
+ };
44
+
45
+ describe('tc204 deployment: multi-edition lineage series', () => {
46
+ it('groups all three editions into one series', () => {
47
+ const manifests = [
48
+ makeManifest({ id: 'isotc204-2022', status: 'valid', year: 2022, title: 'Edition 1' }),
49
+ makeManifest({ id: 'isotc204-2025', status: 'valid', year: 2025, title: 'Edition 2' }),
50
+ makeManifest({ id: 'isotc204-ed3', status: 'draft', year: 2026, title: 'Edition 3 draft' }),
51
+ ];
52
+ const series = groupManifestsIntoSeries(manifests, undefined, [TC204_GROUP]);
53
+ expect(series.length).toBe(1);
54
+ expect(series[0].members.length).toBe(3);
55
+ expect(series[0].members.map(m => m.id)).toContain('isotc204-ed3');
56
+ });
57
+
58
+ it('honors explicit current=isotc204-ed3 (the draft)', () => {
59
+ // tc204 marks ed3 (draft) as current because it's the active working
60
+ // edition, even though it's not yet published. The fallback logic
61
+ // would pick the newest *valid* member, which would be 2025. The
62
+ // explicit `current` override must win.
63
+ const manifests = [
64
+ makeManifest({ id: 'isotc204-2022', status: 'valid', year: 2022 }),
65
+ makeManifest({ id: 'isotc204-2025', status: 'valid', year: 2025 }),
66
+ makeManifest({ id: 'isotc204-ed3', status: 'draft', year: 2026 }),
67
+ ];
68
+ const series = groupManifestsIntoSeries(manifests, undefined, [TC204_GROUP]);
69
+ expect(series[0].current?.id).toBe('isotc204-ed3');
70
+ });
71
+
72
+ it('sorts members by year ascending — ed3 (2026) sorts LAST, not first', () => {
73
+ // Without manifest.year, extractYear('isotc204-ed3') returns undefined
74
+ // and the member sorts as year=0 (oldest). With register.yaml:year
75
+ // propagated to manifest.year, ed3 correctly sorts as the newest.
76
+ // This is the tc204-specific regression: drafts without a year in
77
+ // their id must still be positioned via the explicit year field.
78
+ const manifests = [
79
+ makeManifest({ id: 'isotc204-ed3', status: 'draft', year: 2026, title: 'E3' }),
80
+ makeManifest({ id: 'isotc204-2025', status: 'valid', year: 2025, title: 'E2' }),
81
+ makeManifest({ id: 'isotc204-2022', status: 'valid', year: 2022, title: 'E1' }),
82
+ ];
83
+ const series = groupManifestsIntoSeries(manifests, undefined, [TC204_GROUP]);
84
+ const ids = series[0].members.map(m => m.id);
85
+ expect(ids).toEqual(['isotc204-2022', 'isotc204-2025', 'isotc204-ed3']);
86
+ });
87
+
88
+ it('includes stub members for configured-but-unfetched editions', () => {
89
+ // tc204's ed3 GCR is generated from a draft ontology; it may be
90
+ // unavailable on some builds. The series deliberately includes
91
+ // stub members for every configured dataset so navigation links
92
+ // survive partial deployments.
93
+ const manifests = [
94
+ makeManifest({ id: 'isotc204-2022', status: 'valid', year: 2022 }),
95
+ makeManifest({ id: 'isotc204-2025', status: 'valid', year: 2025 }),
96
+ // isotc204-ed3 missing — GCR download may have failed
97
+ ];
98
+ const series = groupManifestsIntoSeries(manifests, undefined, [TC204_GROUP]);
99
+ // All 3 configured members appear; the missing one is a stub
100
+ expect(series[0].members.length).toBe(3);
101
+ const ed3 = series[0].members.find(m => m.id === 'isotc204-ed3');
102
+ expect(ed3).toBeDefined();
103
+ expect(ed3?.status).toBe('unknown');
104
+ });
105
+
106
+ it('honors configured current even when the member is a stub', () => {
107
+ // When ed3 fails to load AND the config says current=ed3, the stub
108
+ // member is still marked as current. This is deliberate: the config
109
+ // is the source of truth for which edition is "current". A stub
110
+ // current signals "this edition exists but isn't loaded" — the UI
111
+ // can surface that state.
112
+ //
113
+ // TODO: consider falling back to newest valid when the configured
114
+ // current has no manifest. Filed as a follow-up.
115
+ const manifests = [
116
+ makeManifest({ id: 'isotc204-2022', status: 'valid', year: 2022 }),
117
+ makeManifest({ id: 'isotc204-2025', status: 'valid', year: 2025 }),
118
+ ];
119
+ const series = groupManifestsIntoSeries(manifests, undefined, [TC204_GROUP]);
120
+ expect(series[0].current?.id).toBe('isotc204-ed3');
121
+ expect(series[0].current?.status).toBe('unknown');
122
+ });
123
+ });
124
+
125
+ describe('tc204 deployment: group about discovery', () => {
126
+ // tc204 ships group about content at:
127
+ // site-content/groups/isotc204/about/about.eng.md
128
+ // The build pipeline's ABOUT step must find and compile it.
129
+ // See scripts/__tests__/process-about-pages.test.mjs for the
130
+ // filesystem discovery contract.
131
+ it('site-content/groups/<id>/about/ layout is discoverable', async () => {
132
+ // Smoke check: the process-about-pages discovery logic handles
133
+ // the tc204 layout. The actual discovery test is in
134
+ // scripts/__tests__/process-about-pages.test.mjs (group case).
135
+ // Here we just verify the path convention matches.
136
+ const expectedPath = 'site-content/groups/isotc204/about/about.eng.md';
137
+ expect(expectedPath).toMatch(/^site-content\/groups\/[^/]+\/about\/about\.\w+\.(md|adoc|html)$/);
138
+ });
139
+ });
@@ -0,0 +1,125 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { ref } from 'vue';
3
+ import { Concept } from 'glossarist';
4
+ import { useRdfDocument } from '../components/use-rdf-document';
5
+
6
+ function makeConcept(): Concept {
7
+ return Concept.fromJSON({
8
+ id: '3.1.1',
9
+ uri: 'https://glossarist.org/test/concept/3.1.1',
10
+ status: 'valid',
11
+ localizations: {
12
+ eng: {
13
+ language_code: 'eng',
14
+ entry_status: 'valid',
15
+ terms: [
16
+ { type: 'expression', designation: 'atomic data unit', normative_status: 'preferred' },
17
+ { type: 'expression', designation: 'ADU', normative_status: 'admitted' },
18
+ ],
19
+ definition: [{ content: 'A data unit that cannot be subdivided.' }],
20
+ notes: [{ content: 'Note here.' }],
21
+ },
22
+ },
23
+ });
24
+ }
25
+
26
+ function makeConceptWithNonVerbal(): Concept {
27
+ return Concept.fromJSON({
28
+ id: '3.1.2',
29
+ uri: 'https://glossarist.org/test/concept/3.1.2',
30
+ status: 'valid',
31
+ localizations: {
32
+ eng: {
33
+ language_code: 'eng',
34
+ entry_status: 'valid',
35
+ terms: [{ type: 'expression', designation: 'angle of repose', normative_status: 'preferred' }],
36
+ definition: [{ content: 'Angle formed by a material at rest.' }],
37
+ non_verbal_rep: [
38
+ { type: 'figure', caption: 'Angle of repose diagram', description: 'Schematic diagram showing the angle', images: [{ src: 'fig_A.23.svg' }] },
39
+ { type: 'formula', caption: 'tan(θ) = μ' },
40
+ ],
41
+ },
42
+ },
43
+ });
44
+ }
45
+
46
+ // Contract tests for the migrated composable. The composable now consumes
47
+ // glossarist-js's conceptToQuads + provenanceToQuads + quadSectionsToClassInstances
48
+ // + writeTurtleSync directly. These tests assert the wiring contract, not the
49
+ // detailed RDF emission (which is glossarist-js's responsibility and tested
50
+ // in its own suite).
51
+ describe('useRdfDocument — wiring contract', () => {
52
+ it('returns reactive sections, turtle, jsonld, and typeChain', () => {
53
+ const c = ref(makeConcept());
54
+ const doc = useRdfDocument(() => c.value, () => c.value.uri ?? '');
55
+ expect(doc.sections.value.length).toBeGreaterThan(0);
56
+ expect(typeof doc.turtle.value).toBe('string');
57
+ expect(doc.turtle.value.length).toBeGreaterThan(0);
58
+ expect(typeof doc.jsonld.value).toBe('string');
59
+ expect(doc.jsonld.value.length).toBeGreaterThan(0);
60
+ expect(Array.isArray(doc.typeChain.value)).toBe(true);
61
+ expect(doc.typeChain.value.length).toBeGreaterThan(0);
62
+ });
63
+
64
+ it('emits a parseable JSON-LD document with @graph', () => {
65
+ const c = ref(makeConcept());
66
+ const { jsonld } = useRdfDocument(() => c.value, () => c.value.uri ?? '');
67
+ const parsed = JSON.parse(jsonld.value);
68
+ expect(parsed['@graph']).toBeDefined();
69
+ expect(Array.isArray(parsed['@graph'])).toBe(true);
70
+ expect(parsed['@graph'].length).toBeGreaterThan(0);
71
+ });
72
+
73
+ it('emits turtle with @prefix declarations', () => {
74
+ const c = ref(makeConcept());
75
+ const { turtle } = useRdfDocument(() => c.value, () => c.value.uri ?? '');
76
+ expect(turtle.value).toMatch(/@prefix gloss: /);
77
+ expect(turtle.value).toMatch(/@prefix skos: /);
78
+ expect(turtle.value).toMatch(/@prefix skosxl: /);
79
+ });
80
+
81
+ it('includes the concept resource in turtle output', () => {
82
+ const c = ref(makeConcept());
83
+ const { turtle } = useRdfDocument(() => c.value, () => c.value.uri ?? '');
84
+ expect(turtle.value).toContain('3.1.1');
85
+ expect(turtle.value).toMatch(/gloss:Concept/);
86
+ });
87
+
88
+ it('attaches provenance (prov:wasGeneratedBy) to the concept resource', () => {
89
+ const c = ref(makeConcept());
90
+ const { turtle } = useRdfDocument(() => c.value, () => c.value.uri ?? '');
91
+ expect(turtle.value).toMatch(/prov:wasGeneratedBy/);
92
+ expect(turtle.value).toMatch(/activity\/serializers\/concept-browser\//);
93
+ });
94
+
95
+ it('first section is the concept itself with classId gloss:Concept', () => {
96
+ const c = ref(makeConcept());
97
+ const { sections } = useRdfDocument(() => c.value, () => c.value.uri ?? '');
98
+ expect(sections.value[0].classId).toBe('gloss:Concept');
99
+ });
100
+
101
+ it('recomputes when the concept changes', () => {
102
+ const c = ref(makeConcept());
103
+ const { sections } = useRdfDocument(() => c.value, () => c.value.uri ?? '');
104
+ const firstCount = sections.value.length;
105
+ c.value = makeConceptWithNonVerbal();
106
+ // Different concept should produce different section count or order
107
+ expect(sections.value).toBeDefined();
108
+ // Both should produce at least one section
109
+ expect(firstCount).toBeGreaterThan(0);
110
+ expect(sections.value.length).toBeGreaterThan(0);
111
+ });
112
+
113
+ it('emits non-verbal rep quads when the concept has them', () => {
114
+ const c = ref(makeConceptWithNonVerbal());
115
+ const { turtle } = useRdfDocument(() => c.value, () => c.value.uri ?? '');
116
+ // glossarist-js emits hasNonVerbalRep for each NVR
117
+ expect(turtle.value).toMatch(/gloss:hasNonVerbalRep/);
118
+ });
119
+
120
+ it('typeChain returns the canonical concept hierarchy', () => {
121
+ const c = ref(makeConcept());
122
+ const { typeChain } = useRdfDocument(() => c.value, () => c.value.uri ?? '');
123
+ expect(typeChain.value).toEqual(['owl:Thing', 'skos:Concept', 'gloss:Concept']);
124
+ });
125
+ });
@@ -70,6 +70,13 @@ export interface Manifest {
70
70
  ref?: string;
71
71
  refAliases?: string[];
72
72
  editionStatus?: string;
73
+ /**
74
+ * Edition year, sourced from register.yaml:year. When the dataset id
75
+ * doesn't contain a parseable year (e.g. "isotc204-ed3"), this field
76
+ * is the only way to position the edition correctly in a lineage
77
+ * timeline. Falls back to extractYear(id/ref/title) at the consumer.
78
+ */
79
+ year?: number;
73
80
  ordering?: string;
74
81
  sections?: ManifestSection[];
75
82
  languageStats?: Record<string, { terms: number; definitions: number }>;
@@ -1,11 +1,11 @@
1
1
  <script setup lang="ts">
2
2
  import { computed } from 'vue';
3
3
  import type { Concept } from 'glossarist';
4
- import RdfInstanceHeader from './concept-rdf/RdfInstanceHeader.vue';
5
- import RdfInstanceSection from './concept-rdf/RdfInstanceSection.vue';
6
- import RdfSourcePanel from './concept-rdf/RdfSourcePanel.vue';
4
+ import RdfInstanceHeader from './RdfInstanceHeader.vue';
5
+ import RdfInstanceSection from './RdfInstanceSection.vue';
6
+ import RdfSourcePanel from './RdfSourcePanel.vue';
7
7
  import ErrorBoundary from './ErrorBoundary.vue';
8
- import { useRdfDocument } from './concept-rdf/use-rdf-document';
8
+ import { useRdfDocument } from './use-rdf-document';
9
9
 
10
10
  const props = defineProps<{
11
11
  concept: Concept;