glossarist 0.4.2 → 0.4.3

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,13 +1,15 @@
1
1
  {
2
2
  "name": "glossarist",
3
- "version": "0.4.2",
3
+ "version": "0.4.3",
4
4
  "description": "JavaScript SDK for Glossarist GCR packages — read, write, validate, and manage terminology concepts",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
7
7
  "types": "src/index.d.ts",
8
8
  "sideEffects": false,
9
9
  "files": [
10
- "src"
10
+ "src",
11
+ "data/concept-model/shapes/glossarist.shacl.ttl",
12
+ "data/concept-model/README.md"
11
13
  ],
12
14
  "exports": {
13
15
  ".": {
@@ -34,10 +36,23 @@
34
36
  "types": "./src/validators/index.d.ts",
35
37
  "import": "./src/validators/index.js",
36
38
  "default": "./src/validators/index.js"
39
+ },
40
+ "./rdf": {
41
+ "types": "./src/rdf/index.d.ts",
42
+ "import": "./src/rdf/index.js",
43
+ "default": "./src/rdf/index.js"
44
+ },
45
+ "./transforms": {
46
+ "types": "./src/transforms/index.d.ts",
47
+ "import": "./src/transforms/index.js",
48
+ "default": "./src/transforms/index.js"
37
49
  }
38
50
  },
39
51
  "scripts": {
40
52
  "lint": "eslint src/ test/",
53
+ "sync:model": "node scripts/sync-concept-model.mjs",
54
+ "gen:predicates": "node scripts/gen-predicates.mjs",
55
+ "prebuild": "npm run gen:predicates",
41
56
  "pretest": "node test/fixtures/build-fixtures.js",
42
57
  "test": "find test -name '*.test.js' | sort | xargs node --test",
43
58
  "test:verbose": "find test -name '*.test.js' | sort | xargs node --test --test-reporter spec",
@@ -68,8 +83,13 @@
68
83
  "access": "public"
69
84
  },
70
85
  "dependencies": {
86
+ "@rdfjs/data-model": "^2.0.0",
87
+ "@rdfjs/dataset": "^2.0.0",
71
88
  "js-yaml": "^5.0.0",
72
- "jszip": "^3.10.1"
89
+ "jsonld": "^8.3.3",
90
+ "jszip": "^3.10.1",
91
+ "n3": "^1.17.0",
92
+ "rdf-validate-shacl": "^0.4.0"
73
93
  },
74
94
  "devDependencies": {
75
95
  "@eslint/js": "^10.0.1",
@@ -65,6 +65,43 @@ export class ConceptCollection {
65
65
  }));
66
66
  }
67
67
 
68
+ /**
69
+ * Cascading section membership filter. Returns concepts whose
70
+ * section closure includes `sectionId`.
71
+ *
72
+ * A concept in section "3.1.1" matches `bySection('3.1')` and
73
+ * `bySection('3')` because the section closure walks ancestors
74
+ * transitively (mirrors owl:TransitiveProperty on
75
+ * gloss:hasParentSection in the concept-model ontology).
76
+ *
77
+ * Two calling conventions:
78
+ *
79
+ * bySection('3.1', { register }) // register expands each concept's section closure
80
+ * bySection(['3.1', '3']) // pre-expanded target set (any of these must match)
81
+ *
82
+ * Concept-side membership is read from `concept.sections` if set,
83
+ * otherwise from `concept.groups` (a flat list of section IDs).
84
+ */
85
+ bySection(sectionId, options = {}) {
86
+ if (Array.isArray(sectionId)) {
87
+ const targetSet = new Set(sectionId);
88
+ return new ConceptCollection(this[_items].filter(c => {
89
+ const ids = options.register
90
+ ? options.register.conceptSectionIds(c)
91
+ : _flatConceptSectionIds(c);
92
+ return ids.some(id => targetSet.has(id));
93
+ }));
94
+ }
95
+ if (!options.register) {
96
+ throw new Error('bySection(sectionId) requires { register } to expand the concept section closures');
97
+ }
98
+ const target = sectionId;
99
+ return new ConceptCollection(this[_items].filter(c => {
100
+ const ids = options.register.conceptSectionIds(c);
101
+ return ids.includes(target);
102
+ }));
103
+ }
104
+
68
105
  index() {
69
106
  const map = new Map();
70
107
  for (const c of this[_items]) map.set(c.id, c);
@@ -106,3 +143,23 @@ export class ConceptCollection {
106
143
  slice(...args) { return new ConceptCollection(this[_items].slice(...args)); }
107
144
  concat(...args) { return new ConceptCollection(this[_items].concat(...args)); }
108
145
  }
146
+
147
+ // Standalone helper for the array-closure case where no register is
148
+ // available. Mirrors the lookup done inside Register#conceptSectionIds
149
+ // but without ancestor expansion (the closure is already provided).
150
+ function _flatConceptSectionIds(concept) {
151
+ if (!concept) return [];
152
+ const out = [];
153
+ for (const source of [concept.sections, concept.groups]) {
154
+ if (!Array.isArray(source)) continue;
155
+ for (const entry of source) {
156
+ if (typeof entry === 'string') {
157
+ out.push(entry);
158
+ } else if (entry && typeof entry === 'object') {
159
+ const id = entry.id ?? entry.sectionId ?? entry.ref?.id;
160
+ if (id) out.push(String(id));
161
+ }
162
+ }
163
+ }
164
+ return out;
165
+ }
package/src/index.js CHANGED
@@ -56,3 +56,18 @@ export {
56
56
  isKnownEntityType,
57
57
  parseEntityPath,
58
58
  } from './entity-directory.js';
59
+
60
+ // RDF serialization layer (WS C). Mirrors lib/glossarist/rdf/.
61
+ export {
62
+ PRED, PREFIXES, SKOSXL, WELL_KNOWN,
63
+ deterministicId, deterministicBnode,
64
+ conceptUri, conceptToQuads,
65
+ localizedConceptUri, localizedConceptToQuads,
66
+ designationToQuads, skosLabelPredicate, skosxlLabelPredicate,
67
+ detailedDefinitionToQuads,
68
+ conceptSourceToQuads,
69
+ collectQuads, writeTurtle, writeNTriples, writeJsonld, sortQuads,
70
+ validateShacl, loadShapes, quadsToDataset,
71
+ } from './rdf/index.js';
72
+
73
+ export { ConceptToGlossTransform } from './transforms/concept-to-gloss.transform.js';
@@ -19,6 +19,12 @@ export class Concept extends GlossaristModel {
19
19
 
20
20
  this.relatedConcepts = _mapInstances(data.relatedConcepts ?? data.related ?? data.related_concepts ?? [], RelatedConcept);
21
21
  this.domains = _normalizeDomains(data.domains, data.groups);
22
+ this.groups = Array.isArray(data.groups)
23
+ ? data.groups.map(g => typeof g === 'string' ? g : (g?.id ?? g?.sectionId ?? null)).filter(Boolean)
24
+ : [];
25
+ this.sections = Array.isArray(data.sections)
26
+ ? data.sections.map(s => typeof s === 'string' ? s : (s?.id ?? s?.sectionId ?? null)).filter(Boolean)
27
+ : [];
22
28
  this.tags = Array.isArray(data.tags) ? [...data.tags] : [];
23
29
  this.dates = _mapInstances(data.dates ?? [], ConceptDate);
24
30
  this.sources = _mapInstances(data.sources ?? [], ConceptSource);
@@ -0,0 +1,79 @@
1
+ // Per-dataset color spec, light/dark variants.
2
+ //
3
+ // A DatasetColor may be:
4
+ // - a single hex string (legacy, applied to both modes)
5
+ // - an explicit { light, dark } pair
6
+ //
7
+ // Round-trips through Register.toJSON unchanged so the data lives in
8
+ // the GCR package. Consumers call resolvedColor(mode) to pick the
9
+ // right hex for the current UI mode.
10
+
11
+ /**
12
+ * @typedef {string} DatasetColorSpec
13
+ * @typedef {{ light: string, dark: string }} DatasetColorPair
14
+ * @typedef {DatasetColorSpec | DatasetColorPair} DatasetColor
15
+ */
16
+
17
+ export const COLOR_MODES = Object.freeze(['light', 'dark']);
18
+
19
+ /**
20
+ * Returns the right hex for the given mode. Falls back to the single
21
+ * hex when the spec is a string. Returns null when the spec is null
22
+ * or when the requested mode is missing from a pair.
23
+ *
24
+ * @param {DatasetColor | null | undefined} color
25
+ * @param {'light' | 'dark'} mode
26
+ * @returns {string | null}
27
+ */
28
+ export function resolveColor(color, mode) {
29
+ if (color == null) return null;
30
+ if (typeof color === 'string') return color;
31
+ if (typeof color === 'object') {
32
+ if (!mode) return color.light ?? color.dark ?? null;
33
+ return color[mode] ?? null;
34
+ }
35
+ return null;
36
+ }
37
+
38
+ /**
39
+ * True when the spec declares an explicit {light, dark} object.
40
+ * Lets callers distinguish "single hex" from "per-mode pair" without
41
+ * repeating the typeof check.
42
+ */
43
+ export function isColorPair(color) {
44
+ return color != null && typeof color === 'object';
45
+ }
46
+
47
+ /**
48
+ * Validates a color spec. A valid spec is either:
49
+ * - a string matching /^#[0-9a-fA-F]{3,8}$/
50
+ * - an object with `light` and `dark` string fields, each matching
51
+ * the same pattern
52
+ *
53
+ * Returns null when valid, or an error message describing the issue.
54
+ */
55
+ export function validateColor(color) {
56
+ if (color == null) return null;
57
+ if (typeof color === 'string') return hexError(color);
58
+ if (typeof color === 'object') {
59
+ if (color.light == null && color.dark == null) {
60
+ return 'color pair must have at least one of `light` or `dark`';
61
+ }
62
+ if (color.light != null) {
63
+ const e = hexError(color.light);
64
+ if (e) return `color.light: ${e}`;
65
+ }
66
+ if (color.dark != null) {
67
+ const e = hexError(color.dark);
68
+ if (e) return `color.dark: ${e}`;
69
+ }
70
+ return null;
71
+ }
72
+ return `unexpected color type ${typeof color}`;
73
+ }
74
+
75
+ const HEX_RE = /^#[0-9a-fA-F]{3,8}$/;
76
+ function hexError(s) {
77
+ if (typeof s !== 'string') return `expected string, got ${typeof s}`;
78
+ return HEX_RE.test(s) ? null : `not a valid hex color: ${JSON.stringify(s)}`;
79
+ }
@@ -5,6 +5,16 @@ import { GrammarInfo } from './grammar-info.js';
5
5
  import { RelatedConcept } from './related-concept.js';
6
6
  import { DesignationRelationship, DESIGNATION_RELATIONSHIP_TYPES } from './designation-relationship.js';
7
7
 
8
+ // Maps a normative-status local-name to the matching SKOS/SKOS-XL label
9
+ // predicate local-name. Lives on the model so a new status requires a
10
+ // single edit here, not edits in every emitter.
11
+ const SKOS_LABEL_BY_NORMATIVE_STATUS = Object.freeze({
12
+ preferred: 'prefLabel',
13
+ deprecated: 'hiddenLabel',
14
+ admitted: 'altLabel',
15
+ deprecated_: 'altLabel',
16
+ });
17
+
8
18
  export class Designation extends RegistrableModel {
9
19
  constructor(data = {}) {
10
20
  super();
@@ -53,6 +63,34 @@ export class Designation extends RegistrableModel {
53
63
  return obj;
54
64
  }
55
65
 
66
+ // RDF class local-name for this designation subtype. Override in
67
+ // subclasses that map to a different ontology class.
68
+ rdfClass() {
69
+ return 'Expression';
70
+ }
71
+
72
+ // SKOS label predicate URI (skos:prefLabel / skos:altLabel / skos:hiddenLabel)
73
+ // appropriate for this designation's normative status. Falls back to
74
+ // skos:altLabel for unknown statuses.
75
+ skosLabelPredicate(skosNs) {
76
+ const label = this._skosLabelLocalName();
77
+ return `${skosNs}${label}`;
78
+ }
79
+
80
+ // SKOS-XL label predicate URI (skosxl:prefLabel / etc.).
81
+ skosxlLabelPredicate(skosxlNs) {
82
+ const label = this._skosLabelLocalName();
83
+ return `${skosxlNs}${label}`;
84
+ }
85
+
86
+ _skosLabelLocalName() {
87
+ const status = String(this.normativeStatus ?? '')
88
+ .split(/[/#]/)
89
+ .pop()
90
+ .trim();
91
+ return SKOS_LABEL_BY_NORMATIVE_STATUS[status] ?? 'altLabel';
92
+ }
93
+
56
94
  static fromJSON(data) {
57
95
  return Designation.fromData(data);
58
96
  }
@@ -87,6 +125,10 @@ export class Abbreviation extends Expression {
87
125
  this.truncation = data.truncation ?? false;
88
126
  }
89
127
 
128
+ rdfClass() {
129
+ return 'Abbreviation';
130
+ }
131
+
90
132
  toJSON() {
91
133
  const obj = super.toJSON();
92
134
  if (this.acronym) obj.acronym = true;
@@ -101,6 +143,10 @@ export class Abbreviation extends Expression {
101
143
  Designation.register('abbreviation', Abbreviation);
102
144
 
103
145
  export class Symbol extends Designation {
146
+ rdfClass() {
147
+ return 'Symbol';
148
+ }
149
+
104
150
  static fromJSON(data) { return new Symbol(data); }
105
151
  }
106
152
 
@@ -112,6 +158,10 @@ export class LetterSymbol extends Symbol {
112
158
  this.text = data.text ?? null;
113
159
  }
114
160
 
161
+ rdfClass() {
162
+ return 'LetterSymbol';
163
+ }
164
+
115
165
  toJSON() {
116
166
  const obj = super.toJSON();
117
167
  if (this.text != null) obj.text = this.text;
@@ -130,6 +180,10 @@ export class GraphicalSymbol extends Symbol {
130
180
  this.image = data.image ?? null;
131
181
  }
132
182
 
183
+ rdfClass() {
184
+ return 'GraphicalSymbol';
185
+ }
186
+
133
187
  toJSON() {
134
188
  const obj = super.toJSON();
135
189
  if (this.text != null) obj.text = this.text;
@@ -42,6 +42,8 @@ export class GlossaristModel {
42
42
  static fromJSON(data: Record<string, unknown>): GlossaristModel;
43
43
  equals(other: GlossaristModel): boolean;
44
44
  clone(): GlossaristModel;
45
+ protected _lazy<T>(cacheKey: string, rawKey: string, wrapFn: (item: any) => T): T[];
46
+ protected _serialize(obj: Record<string, unknown>, jsonKey: string, cacheKey: string, rawKey: string): void;
45
47
  }
46
48
 
47
49
  export class Concept extends GlossaristModel {
@@ -117,6 +119,9 @@ export class Designation extends GlossaristModel {
117
119
  readonly pronunciations: Pronunciation[];
118
120
  readonly sources: ConceptSource[];
119
121
  readonly related: (RelatedConcept | DesignationRelationship)[];
122
+ rdfClass(): string;
123
+ skosLabelPredicate(skosNs: string): string;
124
+ skosxlLabelPredicate(skosxlNs: string): string;
120
125
  static register(type: string, cls: typeof Designation): void;
121
126
  static fromData(data: Record<string, unknown>): Designation;
122
127
  static fromJSON(data: Record<string, unknown>): Designation;
@@ -250,13 +255,125 @@ export class DetailedDefinition extends GlossaristModel {
250
255
  static fromJSON(data: Record<string, unknown>): DetailedDefinition;
251
256
  }
252
257
 
253
- export class NonVerbRep extends GlossaristModel {
254
- readonly type: string | null;
255
- readonly ref: string | null;
256
- readonly text: string | null;
258
+ export class RegistrableModel extends GlossaristModel {
259
+ static register(type: string, cls: typeof RegistrableModel): void;
260
+ static fromData(data: Record<string, unknown>): RegistrableModel;
261
+ }
262
+
263
+ export class FigureImage extends GlossaristModel {
264
+ constructor(data?: Record<string, unknown>);
265
+ readonly src: string | null;
266
+ readonly format: string | null;
267
+ readonly role: string | null;
268
+ readonly width: number | null;
269
+ readonly height: number | null;
270
+ readonly scale: number | null;
271
+ static fromJSON(data: Record<string, unknown>): FigureImage;
272
+ }
273
+
274
+ export class NonVerbalEntity extends RegistrableModel {
275
+ constructor(data?: Record<string, unknown>);
276
+ readonly caption: Record<string, string> | null;
277
+ readonly description: Record<string, string> | null;
278
+ readonly alt: Record<string, string> | null;
257
279
  readonly sources: ConceptSource[];
280
+ findById(targetId: string): NonVerbalEntity | null;
281
+ allIds(): string[];
282
+ static fromJSON(data: Record<string, unknown>): NonVerbalEntity;
283
+ }
284
+
285
+ export class SharedNonVerbalEntity extends NonVerbalEntity {
286
+ constructor(data?: Record<string, unknown>);
287
+ readonly id: string | null;
288
+ readonly identifier: string | null;
289
+ findById(targetId: string): SharedNonVerbalEntity | null;
290
+ allIds(): string[];
291
+ static fromJSON(data: Record<string, unknown>): SharedNonVerbalEntity;
292
+ }
293
+
294
+ export class Figure extends SharedNonVerbalEntity {
295
+ constructor(data?: Record<string, unknown>);
296
+ readonly images: FigureImage[];
297
+ readonly subfigures: Figure[];
298
+ findById(targetId: string): Figure | null;
299
+ allIds(): string[];
300
+ static fromJSON(data: Record<string, unknown>): Figure;
301
+ }
302
+
303
+ export class Table extends SharedNonVerbalEntity {
304
+ constructor(data?: Record<string, unknown>);
305
+ readonly content: Record<string, unknown> | null;
306
+ readonly format: string | null;
307
+ static fromJSON(data: Record<string, unknown>): Table;
308
+ }
309
+
310
+ export class Formula extends SharedNonVerbalEntity {
311
+ constructor(data?: Record<string, unknown>);
312
+ readonly expression: Record<string, string> | null;
313
+ readonly notation: string | null;
314
+ static fromJSON(data: Record<string, unknown>): Formula;
315
+ }
316
+
317
+ export const NON_VERBAL_TYPES: readonly string[];
318
+
319
+ export class NonVerbRep extends NonVerbalEntity {
320
+ readonly type: string | null;
321
+ readonly images: FigureImage[];
322
+ static fromJSON(data: Record<string, unknown>): NonVerbRep;
258
323
  }
259
324
 
325
+ export class NonVerbalReference extends RegistrableModel {
326
+ constructor(data?: Record<string, unknown>);
327
+ readonly entityId: string | null;
328
+ readonly display: string | null;
329
+ readonly dedupKey: readonly [string, string | null];
330
+ static fromJSON(data: Record<string, unknown> | string): NonVerbalReference;
331
+ static register(type: string, cls: typeof NonVerbalReference): void;
332
+ }
333
+
334
+ export class FigureReference extends NonVerbalReference {
335
+ static fromJSON(data: Record<string, unknown> | string): FigureReference;
336
+ }
337
+
338
+ export class TableReference extends NonVerbalReference {
339
+ static fromJSON(data: Record<string, unknown> | string): TableReference;
340
+ }
341
+
342
+ export class FormulaReference extends NonVerbalReference {
343
+ static fromJSON(data: Record<string, unknown> | string): FormulaReference;
344
+ }
345
+
346
+ export class BibliographyEntry extends GlossaristModel {
347
+ constructor(data?: Record<string, unknown>);
348
+ readonly id: string | null;
349
+ readonly reference: string | null;
350
+ readonly title: string | null;
351
+ readonly link: string | null;
352
+ readonly type: string | null;
353
+ static fromJSON(data: Record<string, unknown>): BibliographyEntry;
354
+ }
355
+
356
+ export class BibliographyData extends GlossaristModel {
357
+ constructor(data?: Record<string, unknown>);
358
+ readonly entries: BibliographyEntry[];
359
+ find(id: string): BibliographyEntry | null;
360
+ readonly keys: string[];
361
+ toYAML(): string;
362
+ toJSON(): { bibliography: BibliographyEntry[] };
363
+ static fromYAML(yamlString: string): BibliographyData;
364
+ static fromJSON(data: Record<string, unknown>): BibliographyData;
365
+ }
366
+
367
+ export function fetchLocalizedString(
368
+ hash: Record<string, string> | null,
369
+ lang: string,
370
+ fallback?: string | null,
371
+ ): string | null;
372
+
373
+ export function localizedStringIsEmpty(hash: Record<string, string> | null): boolean;
374
+
375
+ export function localizedStringIsPresent(hash: Record<string, string> | null): boolean;
376
+
260
377
  export class GcrStatistics extends GlossaristModel {
261
378
  readonly totalConcepts: number;
262
379
  readonly conceptsWithDefinitions: number;
@@ -1,5 +1,18 @@
1
1
  export { Register, REGISTER_STATUSES } from './register.js';
2
2
  export { Section, ORDERING_METHODS } from './section.js';
3
+ export { resolveColor, isColorPair, validateColor, COLOR_MODES } from './dataset-color.js';
4
+ export {
5
+ RELATION_CATEGORIES,
6
+ categoryOf,
7
+ categoryDefinition,
8
+ uncategorizedTypes,
9
+ duplicatedTypes,
10
+ } from './relation-categories.js';
11
+ export {
12
+ RELATION_COLOR_DEFAULTS,
13
+ resolveRelationColor,
14
+ categoryColorPair,
15
+ } from './relation-colors.js';
3
16
  export { GlossaristModel } from './base.js';
4
17
  export { Concept } from './concept.js';
5
18
  export { LocalizedConcept } from './localized-concept.js';
@@ -1,5 +1,6 @@
1
1
  import { GlossaristModel } from './base.js';
2
2
  import { Section } from './section.js';
3
+ import { resolveColor } from './dataset-color.js';
3
4
 
4
5
  export const REGISTER_STATUSES = Object.freeze([
5
6
  'current',
@@ -27,6 +28,7 @@ export class Register extends GlossaristModel {
27
28
  this.languageOrder = data.languageOrder ?? data.language_order ?? [];
28
29
  this.ordering = data.ordering ?? null;
29
30
  this.logo = data.logo ?? null;
31
+ this.color = data.color ?? null;
30
32
  this.description = data.description ?? {};
31
33
  this.about = data.about ?? {};
32
34
  this.provenance = data.provenance ?? [];
@@ -45,7 +47,7 @@ export class Register extends GlossaristModel {
45
47
  'status', 'supersedes', 'owner',
46
48
  'sourceRepo', 'source_repo', 'tags',
47
49
  'languages', 'languageOrder', 'language_order',
48
- 'ordering', 'logo', 'description', 'about',
50
+ 'ordering', 'logo', 'color', 'description', 'about',
49
51
  'provenance', 'contributors', 'sections',
50
52
  ]);
51
53
  const extra = {};
@@ -64,11 +66,60 @@ export class Register extends GlossaristModel {
64
66
  return null;
65
67
  }
66
68
 
69
+ // Walks the section tree upward from `sectionId`, returning the
70
+ // ancestor chain in immediate-parent-first order. The returned array
71
+ // does NOT include sectionId itself. Returns [] when sectionId is
72
+ // unknown or is a top-level section.
73
+ //
74
+ // Mirrors glossarist-ruby's section-cascading walk (commit 43dca6b)
75
+ // and the ontology's owl:TransitiveProperty declaration on
76
+ // gloss:hasParentSection.
77
+ sectionAncestorIds(sectionId) {
78
+ if (!sectionId) return [];
79
+ for (const root of this.sections) {
80
+ const chain = _ancestorChain(root, sectionId);
81
+ if (chain) return chain;
82
+ }
83
+ return [];
84
+ }
85
+
86
+ // Returns the closure of `sectionId`: the section plus all of its
87
+ // ancestors. Concept-section membership tests should intersect the
88
+ // concept's section list with this closure so a concept in section
89
+ // "3.1.1" matches a filter on "3.1" or "3".
90
+ sectionClosure(sectionId) {
91
+ return [sectionId, ...this.sectionAncestorIds(sectionId)];
92
+ }
93
+
94
+ // Returns all section IDs the concept belongs to: the concept's own
95
+ // sections plus every ancestor of each. Concept-side section
96
+ // membership comes from `concept.sections` if set, otherwise from
97
+ // `concept.groups` (a flat list of section IDs as strings).
98
+ //
99
+ // Returns the union (deduped) so callers can do a single
100
+ // intersection test.
101
+ conceptSectionIds(concept) {
102
+ const own = conceptSectionIdList(concept);
103
+ if (own.length === 0) return [];
104
+ const closure = new Set();
105
+ for (const id of own) {
106
+ for (const ancestor of this.sectionClosure(id)) closure.add(ancestor);
107
+ }
108
+ return [...closure];
109
+ }
110
+
67
111
  sectionName(sectionId, lang) {
68
112
  const section = this.sectionById(sectionId);
69
113
  return section ? section.name(lang) : null;
70
114
  }
71
115
 
116
+ // Returns the hex color for the requested UI mode, falling back to
117
+ // the single hex when the spec is a string. Returns null when no
118
+ // color is set or when the requested mode is missing.
119
+ resolvedColor(mode) {
120
+ return resolveColor(this.color, mode);
121
+ }
122
+
72
123
  toJSON() {
73
124
  const obj = { ...this._raw, schema_version: this.schemaVersion };
74
125
  if (this.id != null) obj.id = this.id;
@@ -86,6 +137,11 @@ export class Register extends GlossaristModel {
86
137
  if (this.languageOrder.length > 0) obj.languageOrder = [...this.languageOrder];
87
138
  if (this.ordering != null) obj.ordering = this.ordering;
88
139
  if (this.logo != null) obj.logo = { ...this.logo };
140
+ if (this.color != null) {
141
+ obj.color = (typeof this.color === 'object' && this.color !== null)
142
+ ? { ...this.color }
143
+ : this.color;
144
+ }
89
145
  if (Object.keys(this.description).length > 0) obj.description = { ...this.description };
90
146
  if (Object.keys(this.about).length > 0) obj.about = { ...this.about };
91
147
  if (this.provenance.length > 0) obj.provenance = this.provenance.map(p => ({ ...p }));
@@ -108,3 +164,45 @@ export class Register extends GlossaristModel {
108
164
  });
109
165
  }
110
166
  }
167
+
168
+ // Recursive walk: returns the ancestor chain of `targetId` within the
169
+ // subtree rooted at `section`, or null if targetId is not in this
170
+ // subtree. The chain is immediate-parent-first; the section itself is
171
+ // not included. Built root-first, then reversed for the documented order.
172
+ function _ancestorChain(section, targetId, ancestors = []) {
173
+ if (section.id === targetId) return [...ancestors].reverse();
174
+ for (const child of section.children) {
175
+ const found = _ancestorChain(child, targetId, [...ancestors, section.id]);
176
+ if (found) return found;
177
+ }
178
+ return null;
179
+ }
180
+
181
+ // Returns the list of section IDs a concept claims membership in.
182
+ // Concepts may carry section IDs via either `sections` (preferred) or
183
+ // `groups` (legacy). Each entry may be a string or an object with an
184
+ // `id` field; both forms are flattened to a string list.
185
+ function conceptSectionIdList(concept) {
186
+ if (!concept) return [];
187
+ const fromSections = concept.sections
188
+ ? _flattenSectionIds(concept.sections)
189
+ : [];
190
+ const fromGroups = concept.groups
191
+ ? _flattenSectionIds(concept.groups)
192
+ : [];
193
+ return [...fromSections, ...fromGroups];
194
+ }
195
+
196
+ function _flattenSectionIds(value) {
197
+ if (!Array.isArray(value)) return [];
198
+ const out = [];
199
+ for (const entry of value) {
200
+ if (typeof entry === 'string') {
201
+ out.push(entry);
202
+ } else if (entry && typeof entry === 'object') {
203
+ const id = entry.id ?? entry.sectionId ?? entry.ref?.id;
204
+ if (id) out.push(String(id));
205
+ }
206
+ }
207
+ return out;
208
+ }