glossarist 0.4.38 → 0.4.40

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 (41) hide show
  1. package/package.json +1 -1
  2. package/src/models/base.ts +18 -1
  3. package/src/models/citation.ts +98 -0
  4. package/src/models/{concept-reference.js → concept-reference.ts} +21 -7
  5. package/src/models/designation-relationship.ts +39 -0
  6. package/src/models/designation.ts +297 -0
  7. package/src/models/figure.ts +110 -0
  8. package/src/models/formula.ts +36 -0
  9. package/src/models/gcr-metadata.ts +118 -0
  10. package/src/models/gcr-statistics.ts +76 -0
  11. package/src/models/hyperedge-colors.ts +87 -0
  12. package/src/models/non-verb-rep.ts +48 -0
  13. package/src/models/non-verbal-entity.ts +65 -0
  14. package/src/models/non-verbal-reference.ts +73 -0
  15. package/src/models/non-verbal-references.ts +29 -0
  16. package/src/models/register.ts +315 -0
  17. package/src/models/registrable.ts +48 -0
  18. package/src/models/{related-concept.js → related-concept.ts} +49 -25
  19. package/src/models/relation-categories.ts +167 -0
  20. package/src/models/{relation-colors.js → relation-colors.ts} +36 -23
  21. package/src/models/section.ts +61 -0
  22. package/src/models/shared-non-verbal-entity.ts +36 -0
  23. package/src/models/table.ts +36 -0
  24. package/src/models/citation.js +0 -68
  25. package/src/models/designation-relationship.js +0 -27
  26. package/src/models/designation.js +0 -213
  27. package/src/models/figure.js +0 -75
  28. package/src/models/formula.js +0 -25
  29. package/src/models/gcr-metadata.js +0 -68
  30. package/src/models/gcr-statistics.js +0 -51
  31. package/src/models/hyperedge-colors.js +0 -71
  32. package/src/models/non-verb-rep.js +0 -33
  33. package/src/models/non-verbal-entity.js +0 -46
  34. package/src/models/non-verbal-reference.js +0 -35
  35. package/src/models/non-verbal-references.js +0 -16
  36. package/src/models/register.js +0 -253
  37. package/src/models/registrable.js +0 -25
  38. package/src/models/relation-categories.js +0 -182
  39. package/src/models/section.js +0 -44
  40. package/src/models/shared-non-verbal-entity.js +0 -28
  41. package/src/models/table.js +0 -25
@@ -0,0 +1,315 @@
1
+ import { GlossaristModel } from './base.js';
2
+ import { Section } from './section.js';
3
+ import type { SectionJson } from './section.js';
4
+ import { resolveColor } from './dataset-color.js';
5
+ import type { DatasetColor } from './dataset-color.js';
6
+ import type { LocalizedString } from './generic-member.js';
7
+
8
+ export const REGISTER_STATUSES: ReadonlyArray<string> = Object.freeze([
9
+ 'current',
10
+ 'superseded',
11
+ 'withdrawn',
12
+ 'draft',
13
+ ]);
14
+
15
+ export interface RegisterJson {
16
+ schema_version?: string;
17
+ schemaVersion?: string;
18
+ id?: string | null;
19
+ name?: string | LocalizedString | null;
20
+ ref?: string | null;
21
+ refAliases?: ReadonlyArray<string>;
22
+ ref_aliases?: ReadonlyArray<string>;
23
+ year?: number | string | null;
24
+ urn?: string | null;
25
+ urnAliases?: ReadonlyArray<string>;
26
+ urn_aliases?: ReadonlyArray<string>;
27
+ status?: string | null;
28
+ supersedes?: string | null;
29
+ owner?: string | null;
30
+ sourceRepo?: string | null;
31
+ source_repo?: string | null;
32
+ tags?: ReadonlyArray<string>;
33
+ languages?: ReadonlyArray<string>;
34
+ languageOrder?: ReadonlyArray<string>;
35
+ language_order?: ReadonlyArray<string>;
36
+ ordering?: string | null;
37
+ logo?: Record<string, unknown> | null;
38
+ color?: DatasetColor | null;
39
+ description?: LocalizedString;
40
+ about?: LocalizedString;
41
+ provenance?: ReadonlyArray<Record<string, unknown>>;
42
+ contributors?: ReadonlyArray<Record<string, unknown>>;
43
+ sections?: ReadonlyArray<SectionJson | Section>;
44
+ [key: string]: unknown;
45
+ }
46
+
47
+ export class Register extends GlossaristModel {
48
+ readonly schemaVersion: string;
49
+ readonly id: string | null;
50
+ readonly name: string | LocalizedString | null;
51
+ readonly ref: string | null;
52
+ readonly refAliases: ReadonlyArray<string>;
53
+ readonly year: number | string | null;
54
+ readonly urn: string | null;
55
+ readonly urnAliases: ReadonlyArray<string>;
56
+ readonly status: string | null;
57
+ readonly supersedes: string | null;
58
+ readonly owner: string | null;
59
+ readonly sourceRepo: string | null;
60
+ readonly tags: ReadonlyArray<string>;
61
+ readonly languages: ReadonlyArray<string>;
62
+ readonly languageOrder: ReadonlyArray<string>;
63
+ readonly ordering: string | null;
64
+ readonly logo: Record<string, unknown> | null;
65
+ readonly color: DatasetColor | null;
66
+ readonly description: LocalizedString;
67
+ readonly about: LocalizedString;
68
+ readonly provenance: ReadonlyArray<Record<string, unknown>>;
69
+ readonly contributors: ReadonlyArray<Record<string, unknown>>;
70
+ readonly sections: ReadonlyArray<Section>;
71
+ private readonly _raw: Record<string, unknown>;
72
+
73
+ constructor(data: RegisterJson = {}) {
74
+ super();
75
+ this.schemaVersion = data.schema_version ?? data.schemaVersion ?? '3';
76
+ this.id = data.id ?? null;
77
+ this.name = (data.name as string | LocalizedString | null) ?? null;
78
+ this.ref = data.ref ?? null;
79
+ this.refAliases = data.refAliases ?? data.ref_aliases ?? [];
80
+ this.year = (data.year as number | string | null) ?? null;
81
+ this.urn = data.urn ?? null;
82
+ this.urnAliases = data.urnAliases ?? data.urn_aliases ?? [];
83
+ this.status = data.status ?? null;
84
+ this.supersedes = data.supersedes ?? null;
85
+ this.owner = data.owner ?? null;
86
+ this.sourceRepo = data.sourceRepo ?? data.source_repo ?? null;
87
+ this.tags = (data.tags as ReadonlyArray<string>) ?? [];
88
+ this.languages = (data.languages as ReadonlyArray<string>) ?? [];
89
+ this.languageOrder = data.languageOrder ?? data.language_order ?? [];
90
+ this.ordering = data.ordering ?? null;
91
+ this.logo = (data.logo as Record<string, unknown> | null) ?? null;
92
+ this.color = (data.color as DatasetColor | null) ?? null;
93
+ this.description = (data.description as LocalizedString) ?? {};
94
+ this.about = (data.about as LocalizedString) ?? {};
95
+ this.provenance = (data.provenance as ReadonlyArray<Record<string, unknown>>) ?? [];
96
+ this.contributors = (data.contributors as ReadonlyArray<Record<string, unknown>>) ?? [];
97
+ this.sections = (data.sections ?? []).map((s) =>
98
+ s instanceof Section ? s : new Section(s as SectionJson),
99
+ );
100
+ this._raw = this._extractRaw(data);
101
+ }
102
+
103
+ private _extractRaw(data: RegisterJson): Record<string, unknown> {
104
+ const known = new Set([
105
+ 'schema_version', 'schemaVersion',
106
+ 'id', 'name', 'ref', 'refAliases', 'ref_aliases',
107
+ 'year', 'urn', 'urnAliases', 'urn_aliases',
108
+ 'status', 'supersedes', 'owner',
109
+ 'sourceRepo', 'source_repo', 'tags',
110
+ 'languages', 'languageOrder', 'language_order',
111
+ 'ordering', 'logo', 'color', 'description', 'about',
112
+ 'provenance', 'contributors', 'sections',
113
+ ]);
114
+ const extra: Record<string, unknown> = {};
115
+ for (const [k, v] of Object.entries(data)) {
116
+ if (!known.has(k)) extra[k] = v;
117
+ }
118
+ return extra;
119
+ }
120
+
121
+ sectionById(id: string): Section | null {
122
+ for (const section of this.sections) {
123
+ if (section.id === id) return section;
124
+ const found = section.descendantById(id);
125
+ if (found) return found;
126
+ }
127
+ return null;
128
+ }
129
+
130
+ /**
131
+ * Walks the section tree upward from `sectionId`, returning the
132
+ * ancestor chain in immediate-parent-first order. The returned array
133
+ * does NOT include sectionId itself. Returns [] when sectionId is
134
+ * unknown or is a top-level section.
135
+ */
136
+ sectionAncestorIds(sectionId: string | null | undefined): string[] {
137
+ if (!sectionId) return [];
138
+ for (const root of this.sections) {
139
+ const chain = _ancestorChain(root, sectionId);
140
+ if (chain) return chain;
141
+ }
142
+ return [];
143
+ }
144
+
145
+ /**
146
+ * Returns the closure of `sectionId`: the section plus all of its
147
+ * ancestors. Concept-section membership tests should intersect the
148
+ * concept's section list with this closure.
149
+ */
150
+ sectionClosure(sectionId: string | null | undefined): string[] {
151
+ return sectionId ? [sectionId, ...this.sectionAncestorIds(sectionId)] : [];
152
+ }
153
+
154
+ /**
155
+ * Walks the section tree DOWNWARD from `sectionId`, returning all
156
+ * descendant section IDs in pre-order.
157
+ */
158
+ sectionDescendantIds(sectionId: string | null | undefined): string[] {
159
+ if (!sectionId) return [];
160
+ const section = this.sectionById(sectionId);
161
+ if (!section) return [];
162
+ const out: string[] = [];
163
+ for (const child of section.children) {
164
+ if (child.id) out.push(child.id);
165
+ out.push(...this.sectionDescendantIds(child.id));
166
+ }
167
+ return out;
168
+ }
169
+
170
+ sectionDescendantClosure(sectionId: string | null | undefined): string[] {
171
+ return sectionId ? [sectionId, ...this.sectionDescendantIds(sectionId)] : [];
172
+ }
173
+
174
+ /**
175
+ * Returns all section IDs the concept belongs to: the concept's own
176
+ * sections plus every ancestor of each.
177
+ */
178
+ conceptSectionIds(concept: { sections?: unknown; groups?: unknown }): string[] {
179
+ const own = conceptSectionIdList(concept);
180
+ if (own.length === 0) return [];
181
+ const closure = new Set<string>();
182
+ for (const id of own) {
183
+ for (const ancestor of this.sectionClosure(id)) closure.add(ancestor);
184
+ }
185
+ return [...closure];
186
+ }
187
+
188
+ sectionName(sectionId: string, lang: string): string | null {
189
+ const section = this.sectionById(sectionId);
190
+ return section ? section.name(lang) : null;
191
+ }
192
+
193
+ /**
194
+ * Localized display name. Returns the best-matching name string for
195
+ * the requested language, falling back to English, then to the first
196
+ * available language, then to null.
197
+ */
198
+ displayName(lang: string): string | null {
199
+ if (this.name == null) return null;
200
+ if (typeof this.name === 'string') return this.name;
201
+ if (typeof this.name === 'object') {
202
+ return (
203
+ this.name[lang] ??
204
+ this.name['eng'] ??
205
+ (Object.values(this.name)[0] as string | undefined) ??
206
+ null
207
+ );
208
+ }
209
+ return null;
210
+ }
211
+
212
+ resolvedColor(mode: 'light' | 'dark'): string | null {
213
+ return resolveColor(this.color, mode);
214
+ }
215
+
216
+ override toJSON(): RegisterJson {
217
+ const obj = { ...this._raw, schema_version: this.schemaVersion } as RegisterJson;
218
+ if (this.id != null) obj.id = this.id;
219
+ if (this.name != null) obj.name = this.name;
220
+ if (this.ref != null) obj.ref = this.ref;
221
+ if (this.refAliases.length > 0) obj.refAliases = [...this.refAliases];
222
+ if (this.year != null) obj.year = this.year;
223
+ if (this.urn != null) obj.urn = this.urn;
224
+ if (this.urnAliases.length > 0) obj.urnAliases = [...this.urnAliases];
225
+ if (this.status != null) obj.status = this.status;
226
+ if (this.supersedes != null) obj.supersedes = this.supersedes;
227
+ if (this.owner != null) obj.owner = this.owner;
228
+ if (this.sourceRepo != null) obj.sourceRepo = this.sourceRepo;
229
+ if (this.tags.length > 0) obj.tags = [...this.tags];
230
+ if (this.languages.length > 0) obj.languages = [...this.languages];
231
+ if (this.languageOrder.length > 0) obj.languageOrder = [...this.languageOrder];
232
+ if (this.ordering != null) obj.ordering = this.ordering;
233
+ if (this.logo != null) obj.logo = { ...this.logo };
234
+ if (this.color != null) {
235
+ obj.color =
236
+ typeof this.color === 'object' && this.color !== null
237
+ ? { ...this.color }
238
+ : this.color;
239
+ }
240
+ if (Object.keys(this.description).length > 0) obj.description = { ...this.description };
241
+ if (Object.keys(this.about).length > 0) obj.about = { ...this.about };
242
+ if (this.provenance.length > 0) obj.provenance = this.provenance.map((p) => ({ ...p }));
243
+ if (this.contributors.length > 0) obj.contributors = this.contributors.map((c) => ({ ...c }));
244
+ if (this.sections.length > 0) obj.sections = this.sections.map((s) => s.toJSON());
245
+ return obj;
246
+ }
247
+
248
+ static override fromJSON(data: RegisterJson): Register {
249
+ const instance = new Register(data);
250
+ const snakeToCamel = (k: string): string =>
251
+ k.replace(/_([a-z])/g, (_, c: string) => c.toUpperCase());
252
+ return new Proxy(instance, {
253
+ get(target: Register, prop: string | symbol): unknown {
254
+ if (typeof prop === 'string') {
255
+ if (prop in target) return (target as unknown as Record<string, unknown>)[prop];
256
+ if (prop in target._raw) return target._raw[prop];
257
+ const camel = snakeToCamel(prop);
258
+ if (camel in target) return (target as unknown as Record<string, unknown>)[camel];
259
+ }
260
+ return undefined;
261
+ },
262
+ });
263
+ }
264
+ }
265
+
266
+ /**
267
+ * Recursive walk: returns the ancestor chain of `targetId` within the
268
+ * subtree rooted at `section`, or null if targetId is not in this
269
+ * subtree.
270
+ */
271
+ function _ancestorChain(
272
+ section: Section,
273
+ targetId: string,
274
+ ancestors: string[] = [],
275
+ ): string[] | null {
276
+ if (section.id === targetId) return [...ancestors].reverse();
277
+ for (const child of section.children) {
278
+ const childId = child.id;
279
+ if (childId == null) continue;
280
+ const nextAncestors = section.id != null ? [...ancestors, section.id] : [...ancestors];
281
+ const found = _ancestorChain(child, targetId, nextAncestors);
282
+ if (found) return found;
283
+ }
284
+ return null;
285
+ }
286
+
287
+ /**
288
+ * Returns the list of section IDs a concept claims membership in.
289
+ * Concepts may carry section IDs via either `sections` (preferred) or
290
+ * `groups` (legacy). Each entry may be a string or an object with an
291
+ * `id` field; both forms are flattened to a string list.
292
+ */
293
+ function conceptSectionIdList(concept: { sections?: unknown; groups?: unknown }): string[] {
294
+ if (!concept) return [];
295
+ const fromSections = concept.sections
296
+ ? _flattenSectionIds(concept.sections)
297
+ : [];
298
+ const fromGroups = concept.groups ? _flattenSectionIds(concept.groups) : [];
299
+ return [...fromSections, ...fromGroups];
300
+ }
301
+
302
+ function _flattenSectionIds(value: unknown): string[] {
303
+ if (!Array.isArray(value)) return [];
304
+ const out: string[] = [];
305
+ for (const entry of value) {
306
+ if (typeof entry === 'string') {
307
+ out.push(entry);
308
+ } else if (entry && typeof entry === 'object') {
309
+ const e = entry as { id?: string; sectionId?: string; ref?: { id?: string } };
310
+ const id = e.id ?? e.sectionId ?? e.ref?.id;
311
+ if (id) out.push(String(id));
312
+ }
313
+ }
314
+ return out;
315
+ }
@@ -0,0 +1,48 @@
1
+ import { GlossaristModel } from './base.js';
2
+
3
+ /**
4
+ * Per-class registry of named subtypes. The registry is keyed by
5
+ * `type` discriminator and maps to the constructor function. Used by
6
+ * NonVerbalEntity (Figure/Table/Formula), NonVerbalReference
7
+ * (Figure/Table/Formula references), and any future RegistrableModel
8
+ * subclass that needs polymorphic dispatch on a string discriminator.
9
+ *
10
+ * Stored as a WeakMap keyed by the base class function, so each base
11
+ * gets its own namespace (NonVerbalEntity.register does not collide
12
+ * with NonVerbalReference.register).
13
+ *
14
+ * The registry stores constructors as `Function` because TS's strict
15
+ * variance on constructor params makes a typed constraint reject
16
+ * subclasses with narrower param types. The dispatch site casts the
17
+ * result back to the specific subclass.
18
+ */
19
+
20
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- see docstring.
21
+ type AnyCtor = new (...args: any[]) => any;
22
+
23
+ const _registries = new WeakMap<object, Map<string, AnyCtor>>();
24
+
25
+ export class RegistrableModel extends GlossaristModel {
26
+ static _registry(): Map<string, AnyCtor> {
27
+ const existing = _registries.get(this) ?? new Map();
28
+ if (!_registries.has(this)) _registries.set(this, existing);
29
+ return existing;
30
+ }
31
+
32
+ static register(type: string, cls: AnyCtor): void {
33
+ this._registry().set(type, cls);
34
+ }
35
+
36
+ static fromData<T extends RegistrableModel>(
37
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- any ctor signature accepted.
38
+ this: new (...args: any[]) => T,
39
+ data: unknown,
40
+ ): T {
41
+ if (data instanceof this) return data;
42
+ const type = (data as { type?: string } | null)?.type;
43
+ const Self = this as unknown as { _registry(): Map<string, AnyCtor> };
44
+ const registry = Self._registry();
45
+ const Cls = type ? (registry.get(type) ?? (this as AnyCtor)) : (this as AnyCtor);
46
+ return new (Cls as new (data: unknown) => T)(data);
47
+ }
48
+ }
@@ -1,7 +1,9 @@
1
1
  import { GlossaristModel } from './base.js';
2
2
  import { ConceptRef } from './concept-ref.js';
3
+ import type { ConceptRefJson } from './concept-ref.js';
4
+ import type { LocalizedString } from './generic-member.js';
3
5
 
4
- export const RELATIONSHIP_TYPES = Object.freeze([
6
+ export const RELATIONSHIP_TYPES: ReadonlyArray<string> = Object.freeze([
5
7
  // Lifecycle (ISO 10241-1)
6
8
  'deprecates', 'deprecated_by', 'supersedes', 'superseded_by',
7
9
  'replaces', 'replaced_by', 'invalidates', 'invalidated_by',
@@ -25,10 +27,9 @@ export const RELATIONSHIP_TYPES = Object.freeze([
25
27
  // Associative (ISO 10241-1 / ISO 25964)
26
28
  'see', 'related_concept', 'related_concept_broader', 'related_concept_narrower',
27
29
  'references',
28
- // ExternalConcept resolution (Glossarist extension — v2 partitive-relation)
29
- // Direction: real concept `provides` substance for an external concept;
30
- // external concept `provided_by` real concept. Stored on the
31
- // ExternalConcept. See TODO.partitive-relation-v2 item 07.
30
+ // ExternalConcept resolution (Glossarist extension). Direction: real
31
+ // concept `provides` substance for an external concept; external concept
32
+ // `provided_by` real concept. Stored on the ExternalConcept.
32
33
  'provides', 'provided_by',
33
34
  // Comparative (ISO 10241-1)
34
35
  'compare', 'contrast',
@@ -38,56 +39,79 @@ export const RELATIONSHIP_TYPES = Object.freeze([
38
39
  'homograph', 'false_friend',
39
40
  ]);
40
41
 
42
+ export type RelationshipType = string;
43
+
44
+ export interface RelatedConceptJson {
45
+ type?: RelationshipType;
46
+ content?: LocalizedString | string | null;
47
+ ref?: ConceptRefJson | ConceptRef | null;
48
+ }
49
+
41
50
  export class RelatedConcept extends GlossaristModel {
42
- constructor(data = {}) {
51
+ readonly type: RelationshipType;
52
+ readonly content: LocalizedString | null;
53
+ readonly ref: ConceptRef | null;
54
+
55
+ constructor(data: RelatedConceptJson = {}) {
43
56
  super();
44
57
  this.type = data.type ?? 'see';
45
- this.content = normalizeContent(data.content);
58
+ this.content = _normalizeContent(data.content);
46
59
  this.ref = data.ref
47
- ? (data.ref instanceof ConceptRef ? data.ref : new ConceptRef(data.ref))
60
+ ? data.ref instanceof ConceptRef
61
+ ? data.ref
62
+ : new ConceptRef(data.ref)
48
63
  : null;
49
64
  }
50
65
 
51
- // Convenience reader: returns the first available string value
52
- // from the localized hash, or null if absent. Mirrors
53
- // PartitiveHyperedge#contentString.
54
- get contentString() {
66
+ /**
67
+ * Convenience reader: returns the first available string value from
68
+ * the localized hash, or null if absent.
69
+ */
70
+ get contentString(): string | null {
55
71
  if (!this.content) return null;
56
72
  const values = Object.values(this.content);
57
- return values.length > 0 ? values[0] : null;
73
+ return values.length > 0 ? (values[0] ?? null) : null;
58
74
  }
59
75
 
60
- toJSON() {
61
- const obj = { type: this.type };
76
+ override toJSON(): RelatedConceptJson {
77
+ const obj: RelatedConceptJson = { type: this.type };
62
78
  if (this.content != null) obj.content = this.content;
63
79
  if (this.ref != null) obj.ref = this.ref.toJSON();
64
80
  return obj;
65
81
  }
66
82
 
67
- static identityOf(value) {
83
+ static identityOf(
84
+ value: {
85
+ type?: string;
86
+ ref?: { source?: string | null; id?: string | null } | null;
87
+ } | null | undefined,
88
+ ): string {
68
89
  const v = value ?? {};
69
- const ref = v?.ref;
90
+ const ref = v.ref;
70
91
  return `${v.type ?? ''}|${ref?.source ?? ''}|${ref?.id ?? ''}`;
71
92
  }
72
93
 
73
- identity() {
94
+ override identity(): string {
74
95
  return RelatedConcept.identityOf(this);
75
96
  }
76
97
 
77
- static fromJSON(data) {
98
+ static override fromJSON(data: RelatedConceptJson): RelatedConcept {
78
99
  return new RelatedConcept(data);
79
100
  }
80
101
  }
81
102
 
82
- // Content is a localized string (language → text). Plain strings on
83
- // input are normalized to `{ default: '...' }` so callers always see
84
- // the same shape. Mirrors PartitiveHyperedge's normalizeContent.
85
- function normalizeContent(value) {
103
+ function _normalizeContent(
104
+ value: LocalizedString | string | null | undefined,
105
+ ): LocalizedString | null {
86
106
  if (value == null) return null;
87
107
  if (typeof value === 'string') return { default: value };
88
108
  if (typeof value === 'object') {
89
- const entries = Object.entries(value).filter(([, v]) => typeof v === 'string');
90
- return entries.length > 0 ? Object.fromEntries(entries) : null;
109
+ const entries = Object.entries(value).filter(
110
+ ([, v]) => typeof v === 'string',
111
+ );
112
+ return entries.length > 0
113
+ ? (Object.fromEntries(entries) as LocalizedString)
114
+ : null;
91
115
  }
92
116
  return null;
93
117
  }
@@ -0,0 +1,167 @@
1
+ // Categorization SSOT for the 52 glossarist relationship types.
2
+
3
+ import { RELATIONSHIP_TYPES } from './related-concept.js';
4
+
5
+ export interface CategoryDefinition {
6
+ label: string;
7
+ description: string;
8
+ types: ReadonlyArray<string>;
9
+ }
10
+
11
+ type CategoryKey =
12
+ | 'lifecycle'
13
+ | 'hierarchical'
14
+ | 'associative'
15
+ | 'comparative'
16
+ | 'spatiotemporal'
17
+ | 'lexical'
18
+ | 'mapping'
19
+ | 'definitional';
20
+
21
+ /**
22
+ * Each type in RELATIONSHIP_TYPES belongs to exactly one category. The
23
+ * categories mirror the concept-model taxonomy. Adding a new type
24
+ * means adding a taxonomy entry — no code changes here.
25
+ */
26
+ export const RELATION_CATEGORIES: Readonly<Record<CategoryKey, CategoryDefinition>> = Object.freeze({
27
+ lifecycle: Object.freeze({
28
+ label: 'Lifecycle',
29
+ description: 'Concept lifecycle transitions: supersession, deprecation, replacement.',
30
+ types: Object.freeze([
31
+ 'deprecates', 'deprecated_by',
32
+ 'supersedes', 'superseded_by',
33
+ 'replaces', 'replaced_by',
34
+ 'invalidates', 'invalidated_by',
35
+ 'retires', 'retired_by',
36
+ ]),
37
+ }) as CategoryDefinition,
38
+
39
+ hierarchical: Object.freeze({
40
+ label: 'Hierarchical',
41
+ description: 'Broader/narrower relations, including SKOS and ISO 25964 generic/partitive/instantial.',
42
+ types: Object.freeze([
43
+ 'broader', 'narrower',
44
+ 'broader_generic', 'narrower_generic',
45
+ 'broader_partitive', 'narrower_partitive',
46
+ 'broader_instantial', 'narrower_instantial',
47
+ ]),
48
+ }) as CategoryDefinition,
49
+
50
+ associative: Object.freeze({
51
+ label: 'Associative',
52
+ description: 'Generic see-also references between concepts.',
53
+ types: Object.freeze([
54
+ 'see', 'related_concept',
55
+ 'related_concept_broader', 'related_concept_narrower',
56
+ 'references',
57
+ ]),
58
+ }) as CategoryDefinition,
59
+
60
+ comparative: Object.freeze({
61
+ label: 'Comparative',
62
+ description: 'Compare / contrast relations between concepts.',
63
+ types: Object.freeze(['compare', 'contrast']),
64
+ }) as CategoryDefinition,
65
+
66
+ spatiotemporal: Object.freeze({
67
+ label: 'Spatiotemporal',
68
+ description: 'Sequential, spatial, and temporal relations.',
69
+ types: Object.freeze([
70
+ 'sequentially_related_concept',
71
+ 'spatially_related_concept',
72
+ 'temporally_related_concept',
73
+ ]),
74
+ }) as CategoryDefinition,
75
+
76
+ lexical: Object.freeze({
77
+ label: 'Lexical',
78
+ description: 'Lexical relations between designations across languages.',
79
+ types: Object.freeze(['homograph', 'false_friend']),
80
+ }) as CategoryDefinition,
81
+
82
+ mapping: Object.freeze({
83
+ label: 'Mapping',
84
+ description: 'SKOS mapping properties for cross-vocabulary alignment.',
85
+ types: Object.freeze([
86
+ 'equivalent', 'close_match',
87
+ 'broad_match', 'narrow_match', 'related_match',
88
+ ]),
89
+ }) as CategoryDefinition,
90
+
91
+ definitional: Object.freeze({
92
+ label: 'Definitional',
93
+ description: 'ISO 19135 concept-to-concept relations: definition, part, instance, inheritance, versioning.',
94
+ types: Object.freeze([
95
+ 'has_concept', 'is_concept_of',
96
+ 'instance_of', 'has_instance',
97
+ 'has_definition', 'definition_of',
98
+ 'has_part', 'is_part_of',
99
+ 'inherits', 'inherited_by',
100
+ 'has_version', 'version_of',
101
+ 'current_version', 'current_version_of',
102
+ 'provides', 'provided_by',
103
+ ]),
104
+ }) as CategoryDefinition,
105
+ });
106
+
107
+ const _CATEGORY_BY_TYPE: ReadonlyMap<string, string> = (() => {
108
+ const map = new Map<string, string>();
109
+ for (const [cat, def] of Object.entries(RELATION_CATEGORIES)) {
110
+ for (const t of def.types) map.set(t, cat);
111
+ }
112
+ return map;
113
+ })();
114
+
115
+ const _CATEGORY_ALIASES: Readonly<Record<string, string>> = {
116
+ conceptInstance: 'definitional',
117
+ versioning: 'definitional',
118
+ };
119
+
120
+ /**
121
+ * Returns the category key for the given relationship type, or null
122
+ * when the type is not categorized.
123
+ */
124
+ export function categoryOf(type: string): string | null {
125
+ return _CATEGORY_BY_TYPE.get(type) ?? null;
126
+ }
127
+
128
+ /**
129
+ * Returns the category definition for the given category key.
130
+ * Accepts both canonical names (definitional) and aliases from
131
+ * earlier glossarist-js versions.
132
+ */
133
+ export function categoryDefinition(categoryKey: string): CategoryDefinition | null {
134
+ const canonical = _CATEGORY_ALIASES[categoryKey] ?? categoryKey;
135
+ return (RELATION_CATEGORIES as Readonly<Record<string, CategoryDefinition>>)[canonical] ?? null;
136
+ }
137
+
138
+ /**
139
+ * MECE sanity check. Returns the list of relationship types that are
140
+ * NOT categorized.
141
+ */
142
+ export function uncategorizedTypes(): string[] {
143
+ return RELATIONSHIP_TYPES.filter((t) => !_CATEGORY_BY_TYPE.has(t));
144
+ }
145
+
146
+ /**
147
+ * MECE sanity check. Returns a list of { type, count } entries for any
148
+ * type that appears in MORE than one category.
149
+ */
150
+ export function duplicatedTypes(): ReadonlyArray<{ type: string; count: number }> {
151
+ const counts = new Map<string, number>();
152
+ for (const def of Object.values(RELATION_CATEGORIES)) {
153
+ for (const t of def.types) {
154
+ counts.set(t, (counts.get(t) ?? 0) + 1);
155
+ }
156
+ }
157
+ const out: { type: string; count: number }[] = [];
158
+ for (const [type, count] of counts) {
159
+ if (count > 1) out.push({ type, count });
160
+ }
161
+ return out;
162
+ }
163
+
164
+ export {
165
+ RELATION_CATEGORIES as RELATION_CATEGORIES_READONLY,
166
+ type CategoryKey as RelationCategoryKey,
167
+ };