@glossarist/concept-browser 0.7.66 → 0.7.67

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 (67) 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 +38 -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__/use-rdf-document.test.ts +125 -0
  15. package/src/components/ConceptRdfView.vue +4 -4
  16. package/src/components/use-rdf-document.ts +137 -0
  17. package/src/__tests__/concept-rdf/agents-emitter.test.ts +0 -110
  18. package/src/__tests__/concept-rdf/bibliography-emitter.test.ts +0 -159
  19. package/src/__tests__/concept-rdf/build-activity-emitter.test.ts +0 -119
  20. package/src/__tests__/concept-rdf/coerce-date.test.ts +0 -97
  21. package/src/__tests__/concept-rdf/concept-emitter.test.ts +0 -258
  22. package/src/__tests__/concept-rdf/dataset-emitter.test.ts +0 -224
  23. package/src/__tests__/concept-rdf/differential.test.ts +0 -96
  24. package/src/__tests__/concept-rdf/group-emitter.test.ts +0 -109
  25. package/src/__tests__/concept-rdf/image-variant-emitter.test.ts +0 -135
  26. package/src/__tests__/concept-rdf/jsonld-writer.test.ts +0 -109
  27. package/src/__tests__/concept-rdf/nonverbal-rep.test.ts +0 -177
  28. package/src/__tests__/concept-rdf/property-based.test.ts +0 -179
  29. package/src/__tests__/concept-rdf/provenance.test.ts +0 -110
  30. package/src/__tests__/concept-rdf/quad-isomorphism.test.ts +0 -43
  31. package/src/__tests__/concept-rdf/quad-isomorphism.ts +0 -47
  32. package/src/__tests__/concept-rdf/rdf-components.test.ts +0 -145
  33. package/src/__tests__/concept-rdf/rdf-graph.test.ts +0 -115
  34. package/src/__tests__/concept-rdf/round-trip.test.ts +0 -243
  35. package/src/__tests__/concept-rdf/scoped-examples.test.ts +0 -126
  36. package/src/__tests__/concept-rdf/sections-builder.test.ts +0 -94
  37. package/src/__tests__/concept-rdf/shacl-conformance.test.ts +0 -110
  38. package/src/__tests__/concept-rdf/shape-consistency.test.ts +0 -138
  39. package/src/__tests__/concept-rdf/snapshot-generation.test.ts +0 -75
  40. package/src/__tests__/concept-rdf/table-formula-emitter.test.ts +0 -142
  41. package/src/__tests__/concept-rdf/turtle-writer.test.ts +0 -114
  42. package/src/__tests__/concept-rdf/use-rdf-document.test.ts +0 -246
  43. package/src/__tests__/concept-rdf/version-emitter.test.ts +0 -120
  44. package/src/__tests__/concept-rdf/vocabulary-ssot.test.ts +0 -100
  45. package/src/__tests__/perf/bundle-layout.test.ts +0 -50
  46. package/src/components/concept-rdf/agents-emitter.ts +0 -82
  47. package/src/components/concept-rdf/bibliography-emitter.ts +0 -83
  48. package/src/components/concept-rdf/build-activity-emitter.ts +0 -89
  49. package/src/components/concept-rdf/concept-emitter.ts +0 -443
  50. package/src/components/concept-rdf/dataset-emitter.ts +0 -95
  51. package/src/components/concept-rdf/group-emitter.ts +0 -69
  52. package/src/components/concept-rdf/image-variant-emitter.ts +0 -46
  53. package/src/components/concept-rdf/jsonld-writer.ts +0 -82
  54. package/src/components/concept-rdf/predicates.ts +0 -261
  55. package/src/components/concept-rdf/provenance.ts +0 -80
  56. package/src/components/concept-rdf/rdf-graph.ts +0 -211
  57. package/src/components/concept-rdf/sections-builder.ts +0 -62
  58. package/src/components/concept-rdf/table-formula-emitter.ts +0 -101
  59. package/src/components/concept-rdf/turtle-writer.ts +0 -116
  60. package/src/components/concept-rdf/use-rdf-document.ts +0 -72
  61. package/src/components/concept-rdf/version-emitter.ts +0 -65
  62. package/src/components/concept-rdf/vocabulary-emitter.ts +0 -62
  63. /package/src/components/{concept-rdf/RdfInstanceHeader.vue → RdfInstanceHeader.vue} +0 -0
  64. /package/src/components/{concept-rdf/RdfInstanceSection.vue → RdfInstanceSection.vue} +0 -0
  65. /package/src/components/{concept-rdf/RdfPrefixLegend.vue → RdfPrefixLegend.vue} +0 -0
  66. /package/src/components/{concept-rdf/RdfSourcePanel.vue → RdfSourcePanel.vue} +0 -0
  67. /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,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
+ });
@@ -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;
@@ -0,0 +1,137 @@
1
+ import type { ComputedRef } from 'vue';
2
+ import { computed } from 'vue';
3
+ import type { Concept } from 'glossarist';
4
+ import {
5
+ conceptToQuads,
6
+ provenanceToQuads,
7
+ quadSectionsToClassInstances,
8
+ writeTurtleSync,
9
+ collectQuads,
10
+ PREFIXES,
11
+ type ClassInstance,
12
+ type PropValue,
13
+ type Quad,
14
+ } from 'glossarist/rdf';
15
+
16
+ export type { ClassInstance, PropValue };
17
+
18
+ export interface RdfDocument {
19
+ sections: ComputedRef<ClassInstance[]>;
20
+ turtle: ComputedRef<string>;
21
+ jsonld: ComputedRef<string>;
22
+ typeChain: ComputedRef<string[]>;
23
+ }
24
+
25
+ export interface UseRdfDocumentOptions {
26
+ readonly registerId?: string;
27
+ readonly uriBase?: string;
28
+ readonly lazy?: boolean;
29
+ }
30
+
31
+ const SERIALIZER_VERSION =
32
+ typeof __CONCEPT_BROWSER_VERSION__ !== 'undefined'
33
+ ? __CONCEPT_BROWSER_VERSION__
34
+ : 'dev';
35
+
36
+ function formatJsonLd(quads: readonly Quad[]): string {
37
+ // Minimal sync JSON-LD serializer: @context + @graph array of nodes.
38
+ // Predicates and types are emitted as compact IRIs; values are emitted
39
+ // as their term-type-appropriate JSON form.
40
+ const context: Record<string, string> = {};
41
+ for (const prefix of ['gloss', 'skos', 'skosxl', 'dcterms', 'prov', 'rdf', 'rdfs', 'xsd', 'owl']) {
42
+ // Skip — handled by RDF_PREFIXES for the Vue UI; JSON-LD context is
43
+ // minimal here because glossarist/rdf's writeJsonld is async and
44
+ // the composable must stay sync.
45
+ }
46
+
47
+ // Group quads by subject
48
+ const bySubject = new Map<string, { subject: string; quads: Quad[] }>();
49
+ const order: string[] = [];
50
+ for (const q of quads) {
51
+ const s = q.subject.value;
52
+ if (!bySubject.has(s)) {
53
+ bySubject.set(s, { subject: s, quads: [] });
54
+ order.push(s);
55
+ }
56
+ bySubject.get(s)!.quads.push(q);
57
+ }
58
+
59
+ const nodes = order.map(s => {
60
+ const entry = bySubject.get(s)!;
61
+ const node: Record<string, unknown> = { '@id': entry.subject };
62
+ const types: string[] = [];
63
+ const props: Record<string, unknown[]> = {};
64
+
65
+ for (const q of entry.quads) {
66
+ if (q.predicate.value === 'http://www.w3.org/1999/02/22-rdf-syntax-ns#type') {
67
+ types.push(q.object.value);
68
+ continue;
69
+ }
70
+ const pred = q.predicate.value;
71
+ const val = termToJsonLd(q.object);
72
+ if (!props[pred]) props[pred] = [];
73
+ props[pred].push(val);
74
+ }
75
+
76
+ if (types.length === 1) node['@type'] = types[0];
77
+ else if (types.length > 1) node['@type'] = types;
78
+
79
+ for (const [pred, vals] of Object.entries(props)) {
80
+ node[pred] = vals.length === 1 ? vals[0] : vals;
81
+ }
82
+ return node;
83
+ });
84
+
85
+ return JSON.stringify({ '@context': context, '@graph': nodes }, null, 2);
86
+ }
87
+
88
+ function termToJsonLd(term: Quad['object']): unknown {
89
+ switch (term.termType) {
90
+ case 'NamedNode':
91
+ return { '@id': term.value };
92
+ case 'Literal':
93
+ if (term.language) return { '@value': term.value, '@language': term.language };
94
+ if (term.datatype && term.datatype.value !== 'http://www.w3.org/2001/XMLSchema#string') {
95
+ return { '@value': term.value, '@type': term.datatype.value };
96
+ }
97
+ return term.value;
98
+ case 'BlankNode':
99
+ return { '@id': `_:${term.value}` };
100
+ default:
101
+ return term.value;
102
+ }
103
+ }
104
+
105
+ export function useRdfDocument(
106
+ getConcept: () => Concept,
107
+ getConceptUri: () => string,
108
+ options: UseRdfDocumentOptions = {},
109
+ ): RdfDocument {
110
+ const emission = computed(() => {
111
+ const concept = getConcept();
112
+ const uri = getConceptUri();
113
+ const conceptQuads = collectQuads(
114
+ conceptToQuads(concept, {
115
+ registerId: options.registerId ?? '',
116
+ uriBase: options.uriBase ?? '',
117
+ }),
118
+ );
119
+ const provenanceQuads = collectQuads(
120
+ provenanceToQuads({
121
+ subjectUri: uri,
122
+ serializer: 'concept-browser',
123
+ serializerVersion: SERIALIZER_VERSION,
124
+ generatedAt: new Date().toISOString(),
125
+ }),
126
+ );
127
+ return [...conceptQuads, ...provenanceQuads];
128
+ });
129
+
130
+ const sections = computed(() => quadSectionsToClassInstances(emission.value));
131
+ const turtle = computed(() => writeTurtleSync(emission.value, { prefixes: PREFIXES }));
132
+ const jsonld = computed(() => formatJsonLd(emission.value));
133
+
134
+ const typeChain = computed(() => ['owl:Thing', 'skos:Concept', 'gloss:Concept']);
135
+
136
+ return { sections, turtle, jsonld, typeChain };
137
+ }