@glossarist/concept-browser 0.7.88 → 0.7.90

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glossarist/concept-browser",
3
- "version": "0.7.88",
3
+ "version": "0.7.90",
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": {
@@ -0,0 +1,29 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { normalizeBibliography } from '../lib/bibliography.mjs';
3
+
4
+ describe('normalizeBibliography', () => {
5
+ it('wraps a bare array into { bibliography: [...] }', () => {
6
+ const entries = [{ id: 'ISO1' }, { id: 'ISO2' }];
7
+ expect(normalizeBibliography(entries)).toEqual({ bibliography: entries });
8
+ });
9
+
10
+ it('passes through an already-wrapped object', () => {
11
+ const wrapped = { bibliography: [{ id: 'ISO1' }] };
12
+ expect(normalizeBibliography(wrapped)).toBe(wrapped);
13
+ });
14
+
15
+ it('returns an empty wrapper for null/undefined', () => {
16
+ expect(normalizeBibliography(null)).toEqual({ bibliography: [] });
17
+ expect(normalizeBibliography(undefined)).toEqual({ bibliography: [] });
18
+ });
19
+
20
+ it('returns an empty wrapper for junk input (non-array, non-wrapped)', () => {
21
+ expect(normalizeBibliography('string')).toEqual({ bibliography: [] });
22
+ expect(normalizeBibliography(42)).toEqual({ bibliography: [] });
23
+ expect(normalizeBibliography({ other: 'shape' })).toEqual({ bibliography: [] });
24
+ });
25
+
26
+ it('handles empty array input', () => {
27
+ expect(normalizeBibliography([])).toEqual({ bibliography: [] });
28
+ });
29
+ });
@@ -14,6 +14,7 @@ import { buildVersionHistoryTurtle } from './lib/version-turtle.mjs';
14
14
  import { buildBibliographyTurtle } from './lib/bibliography-turtle.mjs';
15
15
  import { ttlLit } from './lib/turtle-escape.mjs';
16
16
  import { firstNonEmpty } from './lib/first-non-empty.mjs';
17
+ import { normalizeBibliography } from './lib/bibliography.mjs';
17
18
 
18
19
  // MECE partitive multiplicity: 2 independent axes (ISO 704:2022).
19
20
  // presence × count
@@ -1296,9 +1297,10 @@ async function processDataset(dir, register, opts) {
1296
1297
  // Copy bibliography.yaml → bibliography.json
1297
1298
  const bibPath = path.join(sourceRoot, 'bibliography.yaml');
1298
1299
  if (fs.existsSync(bibPath)) {
1299
- const bibData = readYaml(bibPath);
1300
+ const rawBib = readYaml(bibPath);
1301
+ const bibData = normalizeBibliography(rawBib);
1300
1302
  writeJson(path.join(DATA, register, 'bibliography.json'), bibData);
1301
- const bibCount = Array.isArray(bibData?.bibliography) ? bibData.bibliography.length : 0;
1303
+ const bibCount = bibData.bibliography.length;
1302
1304
  console.log(` Copied bibliography (${bibCount} entries)`);
1303
1305
  }
1304
1306
 
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Normalize bibliography data into the { bibliography: [...] } shape
3
+ * expected by BibliographyData.fromJSON.
4
+ *
5
+ * Accepts either:
6
+ * - A bare array of entries (the canonical YAML source form).
7
+ * - An already-wrapped { bibliography: [...] } object.
8
+ * - Anything else (treated as empty).
9
+ *
10
+ * Ported from origin/fix/bibliography-yaml-array-wrap (2026-06-25).
11
+ * That branch fell 153 commits behind main and was deleted in the
12
+ * 2026-07-29 stale-branch purge; this is the unmerged work.
13
+ */
14
+ export function normalizeBibliography(raw) {
15
+ if (Array.isArray(raw)) return { bibliography: raw };
16
+ if (raw && Array.isArray(raw.bibliography)) return raw;
17
+ return { bibliography: [] };
18
+ }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Cross-repo MECE multiplicity table SSOT spec.
3
+ *
4
+ * Audit (AUDIT-2026-07-29 P2-1): pin that the multiplicity mapping
5
+ * table is consistent with glossarist-js's SSOT (multiplicityFromPair).
6
+ *
7
+ * The concept-browser doesn't redefine the table — it re-exports
8
+ * `multiplicityFromPair` from glossarist-js via
9
+ * `src/utils/partitive-multiplicity.ts`. This spec ensures that
10
+ * re-export stays correct, and that the 5 valid combinations and
11
+ * the 1 invalid combination match the documented contract.
12
+ *
13
+ * Sister specs:
14
+ * - glossarist-js test/models/partitive-relation-v4.test.js
15
+ * - glossarist-ruby spec/unit/v3/multiplicity_spec.rb
16
+ * - concept-model spec/multiplicity_spec.rb
17
+ *
18
+ * All four must agree. This spec is the concept-browser side of
19
+ * that cross-repo contract.
20
+ */
21
+ import { describe, it, expect } from 'vitest';
22
+ import {
23
+ partitiveMultiplicityName,
24
+ rakeStrokeStyle,
25
+ type PartitivePresence,
26
+ type PartitiveCount,
27
+ } from '../utils/partitive-multiplicity';
28
+
29
+ describe('cross-repo MECE multiplicity SSOT contract', () => {
30
+ // The 5 valid combinations are the source of truth. Any change
31
+ // here MUST be mirrored in:
32
+ // - glossarist-js src/models/multiplicity.js (NAME_BY_PAIR)
33
+ // - glossarist-ruby lib/glossarist/v3/multiplicity.rb (NAME_BY_PAIR)
34
+ // - concept-model lib/glossarist/concept_model/multiplicity.rb (MAPPING)
35
+ // - concept-model schemas/v3/concept.yaml (partitive_member.not.allOf)
36
+ // - concept-model ontologies/shapes/glossarist.shacl.ttl (VacuousComboShape)
37
+ const VALID_COMBINATIONS: Array<{
38
+ presence: PartitivePresence;
39
+ count: PartitiveCount;
40
+ iso704Name: string;
41
+ }> = [
42
+ { presence: 'required', count: 'exactly_one', iso704Name: 'compulsory' },
43
+ { presence: 'optional', count: 'exactly_one', iso704Name: 'optional' },
44
+ { presence: 'required', count: 'multiple', iso704Name: 'compulsory_multiple' },
45
+ { presence: 'optional', count: 'multiple', iso704Name: 'optional_multiple' },
46
+ { presence: 'required', count: 'at_least_one', iso704Name: 'compulsory_at_least_one' },
47
+ ];
48
+
49
+ it('maps the 5 valid (presence, count) pairs to ISO 704 names', () => {
50
+ for (const { presence, count, iso704Name } of VALID_COMBINATIONS) {
51
+ expect(partitiveMultiplicityName(presence, count)).toBe(iso704Name);
52
+ }
53
+ });
54
+
55
+ it('rejects the vacuous (optional, at_least_one) combination', () => {
56
+ // This combination is invalid because "may exist, ≥1 if it does"
57
+ // collapses to "may exist in any number" = (optional, multiple).
58
+ // The model rejects it at construction; multiplicityFromPair
59
+ // rejects it at name lookup. Both must surface the error.
60
+ expect(() => partitiveMultiplicityName('optional', 'at_least_one'))
61
+ .toThrow(/Invalid multiplicity combination/);
62
+ });
63
+
64
+ it('every valid combination produces a renderable rakeStrokeStyle', () => {
65
+ // The renderer must accept every valid combination — no missing
66
+ // branches in the switch. If multiplicityFromPair succeeds,
67
+ // rakeStrokeStyle must succeed too.
68
+ for (const { presence, count } of VALID_COMBINATIONS) {
69
+ const style = rakeStrokeStyle(presence, count, false);
70
+ expect(style.lineCount).toBeGreaterThanOrEqual(1);
71
+ expect(style.lineCount).toBeLessThanOrEqual(2);
72
+ expect(style.strokeWidth).toBeGreaterThan(0);
73
+ }
74
+ });
75
+
76
+ it('the table has exactly 5 entries (no drift)', () => {
77
+ expect(VALID_COMBINATIONS).toHaveLength(5);
78
+ });
79
+
80
+ it('no two pairs map to the same ISO 704 name', () => {
81
+ const names = VALID_COMBINATIONS.map(c => c.iso704Name);
82
+ expect(new Set(names).size).toBe(names.length);
83
+ });
84
+ });
@@ -0,0 +1,153 @@
1
+ /**
2
+ * Bridge reverse-map round-trip specs.
3
+ *
4
+ * Audit (AUDIT-2026-07-29 P2-1): pin that loading legacy
5
+ * gl:multiplicity (or older gl:certainty) wire data produces a
6
+ * glossarist-js v4 model with the correct MECE presence × count
7
+ * fields. The migration path lives in model-bridge.ts and must
8
+ * agree with multiplicityFromPair(p, c) at the SSOT level.
9
+ *
10
+ * This spec also covers the invalid-combination case: legacy data
11
+ * carrying the vacuous (optional, at_least_one) combo via
12
+ * gl:multiplicity cannot exist — there's no ISO 704 name for it —
13
+ * so we don't test that path here.
14
+ */
15
+ import { describe, it, expect } from 'vitest';
16
+ import { conceptFromJson } from '../adapters/model-bridge';
17
+
18
+ interface PartitiveMemberLike {
19
+ presence: string;
20
+ count: string;
21
+ is_delimiting: boolean;
22
+ }
23
+
24
+ function loadPartitives(doc: Record<string, unknown>): PartitiveMemberLike[] {
25
+ const concept = conceptFromJson(doc as any);
26
+ const relations = (concept as any).partitiveRelations ?? [];
27
+ return relations.flatMap((r: any) =>
28
+ (r.partitives ?? []).map((m: any) => ({
29
+ presence: m.presence,
30
+ count: m.count,
31
+ is_delimiting: m.is_delimiting,
32
+ })),
33
+ );
34
+ }
35
+
36
+ const BASE_DOC = {
37
+ '@type': 'gl:Concept',
38
+ '@id': 'https://example.org/vim/concept/1.3',
39
+ 'gl:identifier': '1.3',
40
+ 'gl:localizedConcept': {},
41
+ };
42
+
43
+ function docWithRelation(members: unknown[]) {
44
+ return {
45
+ ...BASE_DOC,
46
+ 'gl:partitiveRelations': [
47
+ {
48
+ 'gl:comprehensive': { 'gl:source': 'VIM', 'gl:id': '1.3' },
49
+ 'gl:hasPartitive': members,
50
+ 'gl:completeness': 'complete',
51
+ },
52
+ ],
53
+ };
54
+ }
55
+
56
+ describe('bridge reverse-map: legacy gl:multiplicity → MECE presence × count', () => {
57
+ it('compulsory → required + exactly_one', () => {
58
+ const doc = docWithRelation([
59
+ { 'gl:ref': { 'gl:source': 'VIM', 'gl:id': '1.4' }, 'gl:multiplicity': 'compulsory' },
60
+ { 'gl:ref': { 'gl:source': 'VIM', 'gl:id': '1.5' }, 'gl:multiplicity': 'compulsory' },
61
+ ]);
62
+ const members = loadPartitives(doc);
63
+ expect(members[0]).toEqual({ presence: 'required', count: 'exactly_one', is_delimiting: false });
64
+ expect(members[1]).toEqual({ presence: 'required', count: 'exactly_one', is_delimiting: false });
65
+ });
66
+
67
+ it('optional → optional + exactly_one', () => {
68
+ const doc = docWithRelation([
69
+ { 'gl:ref': { 'gl:source': 'VIM', 'gl:id': '1.4' }, 'gl:multiplicity': 'optional' },
70
+ { 'gl:ref': { 'gl:source': 'VIM', 'gl:id': '1.5' }, 'gl:multiplicity': 'compulsory' },
71
+ ]);
72
+ const members = loadPartitives(doc);
73
+ expect(members[0]).toEqual({ presence: 'optional', count: 'exactly_one', is_delimiting: false });
74
+ });
75
+
76
+ it('compulsory_multiple → required + multiple', () => {
77
+ const doc = docWithRelation([
78
+ { 'gl:ref': { 'gl:source': 'VIM', 'gl:id': '1.4' }, 'gl:multiplicity': 'compulsory_multiple' },
79
+ { 'gl:ref': { 'gl:source': 'VIM', 'gl:id': '1.5' }, 'gl:multiplicity': 'compulsory' },
80
+ ]);
81
+ const members = loadPartitives(doc);
82
+ expect(members[0]).toEqual({ presence: 'required', count: 'multiple', is_delimiting: false });
83
+ });
84
+
85
+ it('optional_multiple → optional + multiple', () => {
86
+ const doc = docWithRelation([
87
+ { 'gl:ref': { 'gl:source': 'VIM', 'gl:id': '1.4' }, 'gl:multiplicity': 'optional_multiple' },
88
+ { 'gl:ref': { 'gl:source': 'VIM', 'gl:id': '1.5' }, 'gl:multiplicity': 'compulsory' },
89
+ ]);
90
+ const members = loadPartitives(doc);
91
+ expect(members[0]).toEqual({ presence: 'optional', count: 'multiple', is_delimiting: false });
92
+ });
93
+
94
+ it('compulsory_at_least_one → required + at_least_one', () => {
95
+ const doc = docWithRelation([
96
+ { 'gl:ref': { 'gl:source': 'VIM', 'gl:id': '1.4' }, 'gl:multiplicity': 'compulsory_at_least_one' },
97
+ { 'gl:ref': { 'gl:source': 'VIM', 'gl:id': '1.5' }, 'gl:multiplicity': 'compulsory' },
98
+ ]);
99
+ const members = loadPartitives(doc);
100
+ expect(members[0]).toEqual({ presence: 'required', count: 'at_least_one', is_delimiting: false });
101
+ });
102
+
103
+ it('legacy gl:certainty confirmed → required + exactly_one (v2 wire)', () => {
104
+ const doc = docWithRelation([
105
+ { 'gl:ref': { 'gl:source': 'VIM', 'gl:id': '1.4' }, 'gl:certainty': 'confirmed' },
106
+ { 'gl:ref': { 'gl:source': 'VIM', 'gl:id': '1.5' }, 'gl:certainty': 'confirmed' },
107
+ ]);
108
+ const members = loadPartitives(doc);
109
+ expect(members[0]).toEqual({ presence: 'required', count: 'exactly_one', is_delimiting: false });
110
+ });
111
+
112
+ it('legacy gl:certainty possible → optional + exactly_one (v2 wire)', () => {
113
+ const doc = docWithRelation([
114
+ { 'gl:ref': { 'gl:source': 'VIM', 'gl:id': '1.4' }, 'gl:certainty': 'possible' },
115
+ { 'gl:ref': { 'gl:source': 'VIM', 'gl:id': '1.5' }, 'gl:certainty': 'confirmed' },
116
+ ]);
117
+ const members = loadPartitives(doc);
118
+ expect(members[0]).toEqual({ presence: 'optional', count: 'exactly_one', is_delimiting: false });
119
+ });
120
+ });
121
+
122
+ describe('bridge: native MECE gl:presence + gl:count passes through', () => {
123
+ it('presence=optional + count=multiple loads natively', () => {
124
+ const doc = docWithRelation([
125
+ { 'gl:ref': { 'gl:source': 'VIM', 'gl:id': '1.4' }, 'gl:presence': 'optional', 'gl:count': 'multiple' },
126
+ { 'gl:ref': { 'gl:source': 'VIM', 'gl:id': '1.5' }, 'gl:presence': 'required', 'gl:count': 'exactly_one' },
127
+ ]);
128
+ const members = loadPartitives(doc);
129
+ expect(members[0]).toEqual({ presence: 'optional', count: 'multiple', is_delimiting: false });
130
+ expect(members[1]).toEqual({ presence: 'required', count: 'exactly_one', is_delimiting: false });
131
+ });
132
+
133
+ it('gl:isDelimiting=true preserved', () => {
134
+ const doc = docWithRelation([
135
+ { 'gl:ref': { 'gl:source': 'VIM', 'gl:id': '1.4' }, 'gl:isDelimiting': true },
136
+ { 'gl:ref': { 'gl:source': 'VIM', 'gl:id': '1.5' } },
137
+ ]);
138
+ const members = loadPartitives(doc);
139
+ expect(members[0].is_delimiting).toBe(true);
140
+ expect(members[1].is_delimiting).toBe(false);
141
+ });
142
+ });
143
+
144
+ describe('bridge: defaults when no multiplicity fields present', () => {
145
+ it('member with no presence/count/multiplicity/certainty defaults to required + exactly_one', () => {
146
+ const doc = docWithRelation([
147
+ { 'gl:ref': { 'gl:source': 'VIM', 'gl:id': '1.4' } },
148
+ { 'gl:ref': { 'gl:source': 'VIM', 'gl:id': '1.5' } },
149
+ ]);
150
+ const members = loadPartitives(doc);
151
+ expect(members[0]).toEqual({ presence: 'required', count: 'exactly_one', is_delimiting: false });
152
+ });
153
+ });
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Wire-key SSOT contract.
3
+ *
4
+ * Audit-driven (AUDIT-2026-07-29 P1-3).
5
+ *
6
+ * The GL record is the single source of truth for the JSON-LD wire-format
7
+ * keys used across src/adapters/. This spec pins:
8
+ *
9
+ * - Every entry is a string starting with 'gl:' (or '@' for JSON-LD core)
10
+ * - No two entries share the same value (injectivity — one key, one name)
11
+ * - The set of values matches what's declared in jsonld-types.ts
12
+ * (catches drift if someone adds a key to one but not the other)
13
+ */
14
+ import { describe, it, expect } from 'vitest';
15
+ import { GL } from '../adapters/wire-keys';
16
+
17
+ describe('GL wire-key SSOT', () => {
18
+ it('every value is a non-empty string', () => {
19
+ for (const value of Object.values(GL)) {
20
+ expect(typeof value).toBe('string');
21
+ expect(value.length).toBeGreaterThan(0);
22
+ }
23
+ });
24
+
25
+ it('every value starts with gl: or @ (JSON-LD namespace)', () => {
26
+ for (const value of Object.values(GL)) {
27
+ expect(value.startsWith('gl:') || value.startsWith('@')).toBe(true);
28
+ }
29
+ });
30
+
31
+ it('no two entries share the same value (injectivity)', () => {
32
+ const values = Object.values(GL);
33
+ expect(new Set(values).size).toBe(values.length);
34
+ });
35
+
36
+ it('MECE keys are present (presence, count, isDelimiting, multiplicity, certainty)', () => {
37
+ expect(GL.PRESENCE).toBe('gl:presence');
38
+ expect(GL.COUNT).toBe('gl:count');
39
+ expect(GL.IS_DELIMITING).toBe('gl:isDelimiting');
40
+ expect(GL.MULTIPLICITY).toBe('gl:multiplicity');
41
+ expect(GL.CERTAINTY).toBe('gl:certainty');
42
+ });
43
+
44
+ it('JSON-LD core keys are present', () => {
45
+ expect(GL.ID).toBe('@id');
46
+ expect(GL.TYPE).toBe('@type');
47
+ });
48
+
49
+ it('LOCAL_ID is distinct from ID (one is gl:id, the other @id)', () => {
50
+ // Both represent "id" but in different namespaces:
51
+ // GL.ID (@id) — JSON-LD IRI for the concept
52
+ // GL.LOCAL_ID (gl:id) — source-local id within ConceptSource/Origin/Ref
53
+ expect(GL.ID).toBe('@id');
54
+ expect(GL.LOCAL_ID).toBe('gl:id');
55
+ expect(GL.ID).not.toBe(GL.LOCAL_ID);
56
+ });
57
+
58
+ it('count matches documented set (no drift)', () => {
59
+ // Update this number when adding a new key.
60
+ // The point of this test: when adding a key, you remember to update
61
+ // the count; when removing, you notice the test fails. Catches
62
+ // accidental drift in either direction.
63
+ expect(Object.keys(GL).length).toBe(87);
64
+ });
65
+ });
@@ -0,0 +1,159 @@
1
+ /**
2
+ * WeakMap bridges for fields not yet in glossarist-js's published d.ts.
3
+ *
4
+ * Each bridge attaches data the model doesn't expose via typed
5
+ * properties. Getters retrieve it; attachBridges() populates it after
6
+ * Concept.fromJSON creates the model instances.
7
+ *
8
+ * Remove each bridge when glossarist-js publishes native support
9
+ * (tracked by PR glossarist/glossarist-js#31 and BRIDGES.md).
10
+ */
11
+ import {
12
+ Concept,
13
+ LocalizedConcept,
14
+ ConceptRef,
15
+ Citation,
16
+ DetailedDefinition,
17
+ } from 'glossarist';
18
+
19
+ // Annotations: LocalizedConcept.annotations
20
+ const extraAnnotations = new WeakMap<LocalizedConcept, DetailedDefinition[]>();
21
+
22
+ export function getAnnotations(lc: LocalizedConcept): DetailedDefinition[] {
23
+ return extraAnnotations.get(lc) ?? [];
24
+ }
25
+
26
+ // Designation relationship targets: RelatedConcept.target (string)
27
+ const designationTargets = new WeakMap<object, string>();
28
+
29
+ export function getDesignationTarget(rc: { type?: string | null; content?: string | null; target?: string | null; ref?: any }): string | null {
30
+ return designationTargets.get(rc) ?? rc.target ?? null;
31
+ }
32
+
33
+ // ConceptRef text: human-readable label alongside source/id
34
+ const refTexts = new WeakMap<ConceptRef, string>();
35
+
36
+ export function getRefText(ref: ConceptRef): string | null {
37
+ return refTexts.get(ref) ?? null;
38
+ }
39
+
40
+ // RelatedConcept.sourceId: links a citation reference back to its source entry
41
+ const relatedSourceIds = new WeakMap<object, string>();
42
+
43
+ export function getRelatedSourceId(rc: object): string | null {
44
+ return relatedSourceIds.get(rc) ?? null;
45
+ }
46
+
47
+ // RelatedConcept.citation: embedded citation data for cite-ref references
48
+ const relatedCitations = new WeakMap<object, Record<string, unknown>>();
49
+
50
+ export function getRelatedCitation(rc: object): Record<string, unknown> | null {
51
+ return relatedCitations.get(rc) ?? null;
52
+ }
53
+
54
+ // Relationship types whose target is a designation string, not a concept ref.
55
+ export const DESIGNATION_REL_TYPES = new Set(['abbreviated_form_for', 'short_form_for']);
56
+
57
+ export function attachSourcedFromToSources(
58
+ sources: readonly { sourced_from?: unknown }[],
59
+ rawSources: unknown,
60
+ ): void {
61
+ if (!Array.isArray(rawSources)) return;
62
+ for (let i = 0; i < sources.length && i < rawSources.length; i++) {
63
+ const raw = rawSources[i] as Record<string, unknown> | undefined;
64
+ if (raw?.sourced_from && Array.isArray(raw.sourced_from)) {
65
+ (sources[i] as { sourced_from: unknown }).sourced_from = raw.sourced_from.map(
66
+ (sf: unknown) => sf instanceof Citation ? sf : Citation.fromJSON(sf as Record<string, unknown>)
67
+ );
68
+ }
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Attach bridged fields (ref text, sourceId, citation) to RelatedConcept instances
74
+ * from the raw deserialized data. Called after Concept.fromJSON creates the model
75
+ * instances, since RelatedConcept.fromJSON only reads type/content/ref.
76
+ */
77
+ function attachRelatedBridges(
78
+ modelRelated: Array<{ type?: string | null; content?: Record<string, string> | string | null; ref?: any; related?: any[] }>,
79
+ rawRelated: unknown[],
80
+ ): void {
81
+ for (const rawRel of rawRelated) {
82
+ if (!rawRel || typeof rawRel !== 'object') continue;
83
+ const rel = rawRel as Record<string, unknown>;
84
+ const relType = rel.type as string | undefined;
85
+ const rc = relType ? modelRelated.find(r => r.type === relType) : undefined;
86
+ if (!rc) continue;
87
+
88
+ // Ref text
89
+ if (rc.ref) {
90
+ const rawRef = rel.ref as Record<string, unknown> | undefined;
91
+ if (rawRef?.text && typeof rawRef.text === 'string') {
92
+ refTexts.set(rc.ref, rawRef.text);
93
+ }
94
+ }
95
+
96
+ // Designation target
97
+ if (rel.target && typeof rel.target === 'string') {
98
+ designationTargets.set(rc, rel.target);
99
+ }
100
+
101
+ // Source ID — links citation reference back to the ConceptSource entry
102
+ if (rel.sourceId && typeof rel.sourceId === 'string') {
103
+ relatedSourceIds.set(rc, rel.sourceId);
104
+ }
105
+
106
+ // Citation — embedded origin data for cite-ref references
107
+ if (rel.citation && typeof rel.citation === 'object') {
108
+ relatedCitations.set(rc, rel.citation as Record<string, unknown>);
109
+ }
110
+ }
111
+ }
112
+
113
+ export function attachBridges(concept: Concept, localizations: Record<string, unknown>): void {
114
+ for (const lang of concept.languages) {
115
+ const lc = concept.localization(lang);
116
+ const raw = localizations[lang];
117
+ if (!lc || !raw || typeof raw !== 'object') continue;
118
+ const rawObj = raw as Record<string, unknown>;
119
+
120
+ // Attach sourced_from to localization-level sources
121
+ attachSourcedFromToSources(lc.sources as unknown as { sourced_from?: unknown }[], rawObj.sources);
122
+
123
+ // Annotations
124
+ const annList = rawObj.annotations;
125
+ if (Array.isArray(annList) && annList.length > 0) {
126
+ extraAnnotations.set(lc, annList.map((a: Record<string, unknown>) =>
127
+ DetailedDefinition.fromJSON({ content: (a.content as string) ?? '' }) as DetailedDefinition,
128
+ ));
129
+ }
130
+
131
+ // Designation-level relationship targets, ref text, sourceId, citation
132
+ const rawTerms = rawObj.terms;
133
+ if (Array.isArray(rawTerms)) {
134
+ for (let ti = 0; ti < rawTerms.length; ti++) {
135
+ const rawTerm = rawTerms[ti];
136
+ if (!rawTerm || typeof rawTerm !== 'object') continue;
137
+ const rawT = rawTerm as Record<string, unknown>;
138
+ const rawDesignation = rawT.designation as string | undefined;
139
+ if (!rawDesignation) continue;
140
+ const designation = lc.terms.find(d => d.designation === rawDesignation);
141
+ if (!designation) continue;
142
+ const rawRelated = rawT.related;
143
+ if (!Array.isArray(rawRelated)) continue;
144
+ attachRelatedBridges(designation.related, rawRelated);
145
+ // Attach sourced_from to designation-level sources
146
+ attachSourcedFromToSources(
147
+ designation.sources as unknown as { sourced_from?: unknown }[],
148
+ rawT.sources,
149
+ );
150
+ }
151
+ }
152
+
153
+ // Localization-level related concepts
154
+ const rawRelated = rawObj.related;
155
+ if (Array.isArray(rawRelated)) {
156
+ attachRelatedBridges(lc.related, rawRelated);
157
+ }
158
+ }
159
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Top-level Concept loading: JSON-LD detection, Concept.fromJSON
3
+ * orchestration, summary projection, URI construction.
4
+ *
5
+ * This module is the orchestration layer — mappers, bridges, and
6
+ * partitive mapping live in their respective sibling modules.
7
+ */
8
+ import { Concept } from 'glossarist';
9
+ import type { ConceptSummary } from '../types';
10
+ import { ConceptIdentity } from '../concept-identity';
11
+ import { GL } from '../wire-keys';
12
+ import { isJsonLd, mapLocalizedFromJsonLd, mapRelatedFromJsonLd } from './mappers';
13
+ import { mapPartitiveRelationFromJsonLd } from './partitive';
14
+ import { normalizeEntityRefs } from './entity-refs';
15
+ import { attachBridges, attachSourcedFromToSources } from './bridges';
16
+ import type { JsonLdConcept } from './jsonld-types';
17
+
18
+ function conceptFromJsonLd(doc: JsonLdConcept): Concept {
19
+ const id = String(doc[GL.IDENTIFIER] ?? doc['@id']?.split('/').pop() ?? '');
20
+ const localizations: Record<string, unknown> = {};
21
+
22
+ const rawLc = doc[GL.LOCALIZED_CONCEPT] ?? {};
23
+ for (const [lang, lc] of Object.entries(rawLc)) {
24
+ if (lc && typeof lc === 'object') {
25
+ localizations[lang] = mapLocalizedFromJsonLd(lc);
26
+ }
27
+ }
28
+
29
+ const related = (doc[GL.RELATED] ?? []).map(mapRelatedFromJsonLd);
30
+ const partitiveRelations = (doc[GL.PARTITIVE_RELATIONS] ?? [])
31
+ .map(mapPartitiveRelationFromJsonLd)
32
+ .filter((r): r is Record<string, unknown> => r !== null);
33
+ const tagsArr = doc[GL.TAGS];
34
+ const tags = Array.isArray(tagsArr) ? [...tagsArr] : [];
35
+
36
+ const concept = Concept.fromJSON({
37
+ id,
38
+ term: doc[GL.TERM] ?? null,
39
+ uri: doc['@id'] ?? null,
40
+ localizations,
41
+ related,
42
+ partitive_relations: partitiveRelations,
43
+ tags,
44
+ figures: normalizeEntityRefs(doc[GL.FIGURE_REF]),
45
+ tables: normalizeEntityRefs(doc[GL.TABLE_REF]),
46
+ formulas: normalizeEntityRefs(doc[GL.FORMULA_REF]),
47
+ status: null,
48
+ });
49
+
50
+ attachBridges(concept, localizations);
51
+ return concept;
52
+ }
53
+
54
+ export function conceptFromJson(doc: Record<string, unknown>): Concept {
55
+ if (isJsonLd(doc)) {
56
+ return conceptFromJsonLd(doc as JsonLdConcept);
57
+ }
58
+ const concept = Concept.fromJSON(doc);
59
+ const locs = (doc as Record<string, unknown>).localizations as Record<string, unknown> | undefined;
60
+ if (locs) attachBridges(concept, locs);
61
+ attachSourcedFromToSources(
62
+ concept.sources as unknown as { sourced_from?: unknown }[],
63
+ (doc as Record<string, unknown>).sources,
64
+ );
65
+ return concept;
66
+ }
67
+
68
+ export function conceptToSummary(concept: Concept): ConceptSummary {
69
+ const designations: Record<string, string> = {};
70
+ for (const lang of concept.languages) {
71
+ const lc = concept.localization(lang);
72
+ if (lc?.primaryDesignation) {
73
+ designations[lang] = lc.primaryDesignation;
74
+ }
75
+ }
76
+ return {
77
+ id: concept.id,
78
+ designations,
79
+ eng: designations.eng || Object.values(designations)[0] || '',
80
+ status: concept.status ?? 'valid',
81
+ };
82
+ }
83
+
84
+ export function conceptUri(concept: Concept, registerId: string, uriBase: string): string {
85
+ if (concept.uri) return concept.uri;
86
+ return new ConceptIdentity(concept.id, registerId, uriBase).uri;
87
+ }