glossarist 0.4.1 → 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.
Files changed (39) hide show
  1. package/data/concept-model/README.md +44 -0
  2. package/data/concept-model/shapes/glossarist.shacl.ttl +704 -0
  3. package/package.json +24 -4
  4. package/src/concept-collection.js +57 -0
  5. package/src/concept-parser.js +1 -1
  6. package/src/concept-reader.js +1 -1
  7. package/src/concept-serializer.js +1 -1
  8. package/src/gcr-reader.js +1 -1
  9. package/src/index.js +15 -0
  10. package/src/models/bibliography-data.js +1 -1
  11. package/src/models/concept.js +6 -0
  12. package/src/models/dataset-color.js +79 -0
  13. package/src/models/designation.js +54 -0
  14. package/src/models/gcr-metadata.js +1 -1
  15. package/src/models/index.d.ts +121 -4
  16. package/src/models/index.js +13 -0
  17. package/src/models/register.js +99 -1
  18. package/src/models/relation-categories.js +151 -0
  19. package/src/models/relation-colors.js +78 -0
  20. package/src/rdf/deterministic-id.js +17 -0
  21. package/src/rdf/document-writer.js +87 -0
  22. package/src/rdf/gloss-concept.js +43 -0
  23. package/src/rdf/gloss-designation.js +72 -0
  24. package/src/rdf/gloss-detailed-definition.js +69 -0
  25. package/src/rdf/gloss-localized-concept.js +72 -0
  26. package/src/rdf/gloss-source.js +51 -0
  27. package/src/rdf/index.d.ts +107 -0
  28. package/src/rdf/index.js +27 -0
  29. package/src/rdf/normalize-enum.js +23 -0
  30. package/src/rdf/predicates.d.ts +443 -0
  31. package/src/rdf/predicates.js +245 -0
  32. package/src/rdf/prefixes.js +31 -0
  33. package/src/rdf/shacl.js +98 -0
  34. package/src/rdf/terms.js +15 -0
  35. package/src/transforms/concept-to-gloss.transform.js +75 -0
  36. package/src/transforms/index.d.ts +26 -0
  37. package/src/transforms/index.js +1 -0
  38. package/src/v1-reader.js +1 -1
  39. package/src/validators/gcr-validator.js +1 -1
@@ -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
+ }
@@ -0,0 +1,151 @@
1
+ // Categorization SSOT for the 52 glossarist relationship types.
2
+ //
3
+ // Each type in RELATIONSHIP_TYPES belongs to exactly one category. The
4
+ // categories are semantic buckets used for filtering and visualization
5
+ // in concept-browser. Bringing the categorization into glossarist-js
6
+ // means all consumers see the same buckets.
7
+
8
+ import { RELATIONSHIP_TYPES } from './related-concept.js';
9
+
10
+ // Category definitions. The `types` array is the exhaustive list of
11
+ // relationship-type local names that fall into the category. Each type
12
+ // must appear in exactly one category (verified by MECE test).
13
+ export const RELATION_CATEGORIES = Object.freeze({
14
+ lifecycle: Object.freeze({
15
+ label: 'Lifecycle',
16
+ description: 'Concept lifecycle transitions: supersession, deprecation, replacement.',
17
+ types: Object.freeze([
18
+ 'deprecates', 'deprecated_by',
19
+ 'supersedes', 'superseded_by',
20
+ 'replaces', 'replaced_by',
21
+ 'invalidates', 'invalidated_by',
22
+ 'retires', 'retired_by',
23
+ ]),
24
+ }),
25
+
26
+ hierarchical: Object.freeze({
27
+ label: 'Hierarchical',
28
+ description: 'Broader/narrower relations, including SKOS and ISO 25964 generic/partitive/instantial.',
29
+ types: Object.freeze([
30
+ 'broader', 'narrower',
31
+ 'broader_generic', 'narrower_generic',
32
+ 'broader_partitive', 'narrower_partitive',
33
+ 'broader_instantial', 'narrower_instantial',
34
+ ]),
35
+ }),
36
+
37
+ associative: Object.freeze({
38
+ label: 'Associative',
39
+ description: 'Generic see-also references between concepts.',
40
+ types: Object.freeze([
41
+ 'see', 'related_concept',
42
+ 'related_concept_broader', 'related_concept_narrower',
43
+ 'references',
44
+ ]),
45
+ }),
46
+
47
+ comparative: Object.freeze({
48
+ label: 'Comparative',
49
+ description: 'Compare / contrast relations between concepts.',
50
+ types: Object.freeze(['compare', 'contrast']),
51
+ }),
52
+
53
+ spatiotemporal: Object.freeze({
54
+ label: 'Spatiotemporal',
55
+ description: 'Sequential, spatial, and temporal relations.',
56
+ types: Object.freeze([
57
+ 'sequentially_related_concept',
58
+ 'spatially_related_concept',
59
+ 'temporally_related_concept',
60
+ ]),
61
+ }),
62
+
63
+ lexical: Object.freeze({
64
+ label: 'Lexical',
65
+ description: 'Lexical relations between designations across languages.',
66
+ types: Object.freeze(['homograph', 'false_friend']),
67
+ }),
68
+
69
+ mapping: Object.freeze({
70
+ label: 'Mapping',
71
+ description: 'SKOS mapping properties for cross-vocabulary alignment.',
72
+ types: Object.freeze([
73
+ 'equivalent', 'close_match',
74
+ 'broad_match', 'narrow_match', 'related_match',
75
+ ]),
76
+ }),
77
+
78
+ conceptInstance: Object.freeze({
79
+ label: 'Concept / Instance',
80
+ description: 'ISO 19135 concept-to-concept relations: definition, part, instance, inheritance.',
81
+ types: Object.freeze([
82
+ 'has_concept', 'is_concept_of',
83
+ 'instance_of', 'has_instance',
84
+ 'has_definition', 'definition_of',
85
+ 'has_part', 'is_part_of',
86
+ 'inherits', 'inherited_by',
87
+ ]),
88
+ }),
89
+
90
+ versioning: Object.freeze({
91
+ label: 'Versioning',
92
+ description: 'Version lineage within the same concept identity.',
93
+ types: Object.freeze([
94
+ 'has_version', 'version_of',
95
+ 'current_version', 'current_version_of',
96
+ ]),
97
+ }),
98
+ });
99
+
100
+ // Reverse index built once. Maps each type local name to its category.
101
+ const _CATEGORY_BY_TYPE = (() => {
102
+ const map = new Map();
103
+ for (const [cat, def] of Object.entries(RELATION_CATEGORIES)) {
104
+ for (const t of def.types) map.set(t, cat);
105
+ }
106
+ return map;
107
+ })();
108
+
109
+ /**
110
+ * Returns the category key for the given relationship type, or null
111
+ * when the type is not categorized (e.g. an unrecognized custom type).
112
+ */
113
+ export function categoryOf(type) {
114
+ return _CATEGORY_BY_TYPE.get(type) ?? null;
115
+ }
116
+
117
+ /**
118
+ * Returns the category definition ({ label, description, types }) for
119
+ * the given category key, or null when the key is unknown.
120
+ */
121
+ export function categoryDefinition(categoryKey) {
122
+ return RELATION_CATEGORIES[categoryKey] ?? null;
123
+ }
124
+
125
+ /**
126
+ * MECE sanity check. Returns the list of relationship types that are
127
+ * NOT categorized (i.e. present in RELATIONSHIP_TYPES but missing from
128
+ * RELATION_CATEGORIES). Used by the test suite to verify SSOT drift.
129
+ */
130
+ export function uncategorizedTypes() {
131
+ return RELATIONSHIP_TYPES.filter(t => !_CATEGORY_BY_TYPE.has(t));
132
+ }
133
+
134
+ /**
135
+ * MECE sanity check. Returns a list of { type, categories } entries
136
+ * for any type that appears in MORE than one category. Should be
137
+ * empty when the categorization is mutually exclusive.
138
+ */
139
+ export function duplicatedTypes() {
140
+ const counts = new Map();
141
+ for (const def of Object.values(RELATION_CATEGORIES)) {
142
+ for (const t of def.types) {
143
+ counts.set(t, (counts.get(t) ?? 0) + 1);
144
+ }
145
+ }
146
+ const out = [];
147
+ for (const [type, count] of counts) {
148
+ if (count > 1) out.push({ type, count });
149
+ }
150
+ return out;
151
+ }
@@ -0,0 +1,78 @@
1
+ // Default color palette for relation categories and individual types.
2
+ //
3
+ // The defaults mirror concept-browser/data/colors.json. Per-deployment
4
+ // overrides pass through resolveRelationColor(type, { overrides, mode })
5
+ // and take precedence over category defaults.
6
+
7
+ import { categoryOf, categoryDefinition } from './relation-categories.js';
8
+
9
+ // One { light, dark } pair per category. Keeping these in glossarist-js
10
+ // lets the defaults ship with the library; consumers only need to
11
+ // customize when they want to deviate.
12
+ export const RELATION_COLOR_DEFAULTS = Object.freeze({
13
+ byCategory: Object.freeze({
14
+ lifecycle: { light: '#B43A2E', dark: '#F87171' },
15
+ hierarchical: { light: '#1F6FEB', dark: '#6EA8FE' },
16
+ associative: { light: '#6B7280', dark: '#9CA3AF' },
17
+ comparative: { light: '#A855F7', dark: '#C4B5FD' },
18
+ spatiotemporal: { light: '#0D9488', dark: '#5EEAD4' },
19
+ lexical: { light: '#D97706', dark: '#FBBF24' },
20
+ mapping: { light: '#2563EB', dark: '#93BBFD' },
21
+ conceptInstance:{ light: '#65A30D', dark: '#A3E635' },
22
+ versioning: { light: '#9333EA', dark: '#C4B5FD' },
23
+ }),
24
+
25
+ // Per-type overrides. Use the same { light, dark } shape. Keys are
26
+ // relationship-type local names. Empty by default — consumers may
27
+ // populate this through `overrides.byType` at call time.
28
+ byType: Object.freeze({}),
29
+ });
30
+
31
+ /**
32
+ * Resolves the color for a relationship type.
33
+ *
34
+ * Resolution order:
35
+ * 1. overrides.byType[type]
36
+ * 2. RELATION_COLOR_DEFAULTS.byType[type]
37
+ * 3. overrides.byCategory[category]
38
+ * 4. RELATION_COLOR_DEFAULTS.byCategory[category]
39
+ * 5. null (uncategorized or unrecognized type)
40
+ *
41
+ * @param {string} type - relationship type local name
42
+ * @param {{ overrides?: { byType?: object, byCategory?: object }, mode?: 'light'|'dark' }} [options]
43
+ * @returns {string | null}
44
+ */
45
+ export function resolveRelationColor(type, options = {}) {
46
+ const mode = options.mode ?? 'light';
47
+ const overrides = options.overrides ?? {};
48
+
49
+ const typeOverride = overrides.byType?.[type] ?? RELATION_COLOR_DEFAULTS.byType[type];
50
+ if (typeOverride) return pickMode(typeOverride, mode);
51
+
52
+ const category = categoryOf(type);
53
+ if (!category) return null;
54
+
55
+ const categoryOverride = overrides.byCategory?.[category] ?? RELATION_COLOR_DEFAULTS.byCategory[category];
56
+ if (!categoryOverride) return null;
57
+
58
+ return pickMode(categoryOverride, mode);
59
+ }
60
+
61
+ /**
62
+ * Returns the { light, dark } pair for a category, merging overrides
63
+ * over defaults. Useful for UIs that want to render a legend.
64
+ */
65
+ export function categoryColorPair(categoryKey, overrides = {}) {
66
+ const def = categoryDefinition(categoryKey);
67
+ if (!def) return null;
68
+ const fromOverride = overrides.byCategory?.[categoryKey];
69
+ const fromDefault = RELATION_COLOR_DEFAULTS.byCategory[categoryKey];
70
+ if (!fromDefault && !fromOverride) return null;
71
+ return { ...(fromDefault ?? {}), ...(fromOverride ?? {}) };
72
+ }
73
+
74
+ function pickMode(pair, mode) {
75
+ if (pair == null) return null;
76
+ if (typeof pair === 'string') return pair;
77
+ return pair[mode] ?? pair.light ?? pair.dark ?? null;
78
+ }
@@ -0,0 +1,17 @@
1
+ import { createHash } from 'node:crypto';
2
+
3
+ // 12-char hex prefix, mirroring glossarist-ruby's
4
+ // `Digest::MD5.hexdigest(content)[0..11]`. Same content → same ID across
5
+ // processes, machines, and language runtimes (Ruby vs JS).
6
+ export function deterministicId(...parts) {
7
+ const seed = parts.filter(p => p !== null && p !== undefined).join('|');
8
+ return createHash('md5').update(seed).digest('hex').slice(0, 12);
9
+ }
10
+
11
+ // Stable blank-node label for an RDF fragment identified by its parent
12
+ // subject + role + index. Useful for reified resources like DetailedDefinition,
13
+ // Designation, ConceptSource where the bnode ID must be deterministic to
14
+ // produce byte-equivalent Turtle across runs.
15
+ export function deterministicBnode(subject, role, index) {
16
+ return `_:b${deterministicId(subject, role, index)}`;
17
+ }
@@ -0,0 +1,87 @@
1
+ // Quads → canonical Turtle / N-Triples / JSON-LD document.
2
+ //
3
+ // Turtle is produced via N3.Writer. JSON-LD via the `jsonld` package
4
+ // (fromRDF + compact against the canonical context). N-Triples is provided
5
+ // as a stable fallback that doesn't depend on prefix maps.
6
+ import { Writer as N3Writer } from 'n3';
7
+ import jsonld from 'jsonld';
8
+ import { PREFIXES } from './predicates.js';
9
+
10
+ // Collects all quads yielded by an emitter into an array. Useful for tests
11
+ // and for callers that want to inspect the quad stream directly.
12
+ export function collectQuads(quadsIterable) {
13
+ const out = [];
14
+ for (const q of quadsIterable) out.push(q);
15
+ return out;
16
+ }
17
+
18
+ // Returns a Turtle serialization of `quads` using the canonical prefix map
19
+ // generated from the vendored concept-model JSON-LD context. Quads are
20
+ // sorted deterministically so the same input always produces byte-equivalent
21
+ // output across runs.
22
+ export function writeTurtle(quads, { prefixes = PREFIXES } = {}) {
23
+ const sorted = sortQuads(quads);
24
+ return new Promise((resolve, reject) => {
25
+ const writer = new N3Writer({ prefixes, format: 'Turtle' });
26
+ for (const q of sorted) writer.addQuad(q);
27
+ writer.end((err, result) => {
28
+ if (err) reject(err);
29
+ else resolve(result);
30
+ });
31
+ });
32
+ }
33
+
34
+ // Returns an N-Triples serialization of `quads` — no prefixes, no bnode
35
+ // shortening. Useful when consumers need a totally stable wire format.
36
+ export function writeNTriples(quads) {
37
+ const sorted = sortQuads(quads);
38
+ return new Promise((resolve, reject) => {
39
+ const writer = new N3Writer({ format: 'N-Triples' });
40
+ for (const q of sorted) writer.addQuad(q);
41
+ writer.end((err, result) => {
42
+ if (err) reject(err);
43
+ else resolve(result);
44
+ });
45
+ });
46
+ }
47
+
48
+ // Returns a compacted JSON-LD serialization of `quads`. The context defaults
49
+ // to the canonical glossarist JSON-LD context derived from the vendored
50
+ // PREFIXES. Quads are sorted first for deterministic output.
51
+ export async function writeJsonld(quads, { context = defaultJsonldContext() } = {}) {
52
+ const sorted = sortQuads(quads);
53
+ const nquads = await writeNTriples(sorted);
54
+ const expanded = await jsonld.fromRDF(nquads, { format: 'application/n-quads' });
55
+ const compacted = await jsonld.compact(expanded, context);
56
+ return JSON.stringify(compacted, null, 2);
57
+ }
58
+
59
+ function defaultJsonldContext() {
60
+ const ctx = {};
61
+ for (const [prefix, uri] of Object.entries(PREFIXES)) {
62
+ ctx[prefix] = uri;
63
+ }
64
+ return ctx;
65
+ }
66
+
67
+ // Sort by (subject.value, predicate.value, object.termType, object.value,
68
+ // graph.value). Bnodes sort by their generated ID which is deterministic
69
+ // because we use content-addressed IDs.
70
+ export function sortQuads(quads) {
71
+ return [...quads].sort(compareQuad);
72
+ }
73
+
74
+ function compareQuad(a, b) {
75
+ let cmp = cmpTerm(a.subject, b.subject);
76
+ if (cmp !== 0) return cmp;
77
+ cmp = cmpTerm(a.predicate, b.predicate);
78
+ if (cmp !== 0) return cmp;
79
+ cmp = cmpTerm(a.object, b.object);
80
+ if (cmp !== 0) return cmp;
81
+ return cmpTerm(a.graph, b.graph);
82
+ }
83
+
84
+ function cmpTerm(a, b) {
85
+ if (a.termType !== b.termType) return a.termType.localeCompare(b.termType);
86
+ return String(a.value).localeCompare(String(b.value));
87
+ }
@@ -0,0 +1,43 @@
1
+ // Concept → RDF quads. Mirrors glossarist-ruby's `Rdf::GlossConcept`.
2
+ //
3
+ // URI shape: `<uriBase>/<registerId>/concept/<conceptId>`.
4
+ // Localized concepts: `<uriBase>/<registerId>/concept/<conceptId>/<lang>`.
5
+ import { PRED } from './predicates.js';
6
+ import { WELL_KNOWN } from './prefixes.js';
7
+ import { localizedConceptToQuads } from './gloss-localized-concept.js';
8
+ import { conceptSourceToQuads } from './gloss-source.js';
9
+ import { namedNode, literal, quad } from './terms.js';
10
+
11
+ export function conceptUri(concept, { registerId, uriBase }) {
12
+ const id = String(concept.id ?? concept.termid ?? '');
13
+ const base = String(uriBase ?? '').replace(/\/+$/, '');
14
+ return `${base}/${registerId}/concept/${id}`;
15
+ }
16
+
17
+ export function* conceptToQuads(concept, options) {
18
+ const subjectUri = conceptUri(concept, options);
19
+ const s = namedNode(subjectUri);
20
+
21
+ yield quad(s, namedNode(WELL_KNOWN.rdfType), namedNode(PRED.gloss.Concept));
22
+ yield quad(s, namedNode(WELL_KNOWN.rdfType), namedNode(WELL_KNOWN.skosConcept));
23
+
24
+ yield quad(s, namedNode(PRED.gloss.identifier), literal(String(concept.id ?? concept.termid ?? '')));
25
+
26
+ if (concept.status) {
27
+ yield quad(s, namedNode(PRED.gloss.hasStatus), namedNode(`${PRED.gloss.$ns}status/${concept.status}`));
28
+ }
29
+
30
+ for (const lang of concept.languages) {
31
+ const lc = concept.localization(lang);
32
+ if (!lc) continue;
33
+ const lcUri = `${subjectUri}/${lang}`;
34
+ yield quad(s, namedNode(PRED.gloss.hasLocalization), namedNode(lcUri));
35
+ yield* localizedConceptToQuads(lc, { parentUri: subjectUri, language: lang });
36
+ }
37
+
38
+ let srcIndex = 0;
39
+ for (const source of concept.sources ?? []) {
40
+ yield* conceptSourceToQuads(source, { subjectUri, index: srcIndex });
41
+ srcIndex += 1;
42
+ }
43
+ }
@@ -0,0 +1,72 @@
1
+ // Designation → RDF quads. Mirrors glossarist-ruby's
2
+ // `Rdf::GlossDesignation` + the per-subtype classes (GlossExpression,
3
+ // GlossAbbreviation, etc.). The ontology class for each subtype is
4
+ // provided by the model itself via Designation#rdfClass (OCP: new
5
+ // subtypes register without editing this emitter).
6
+ import { PRED, PREFIXES } from './predicates.js';
7
+ import { SKOSXL, WELL_KNOWN } from './prefixes.js';
8
+ import { deterministicBnode } from './deterministic-id.js';
9
+ import { normalizeEnum } from './normalize-enum.js';
10
+ import { namedNode, literal, quad } from './terms.js';
11
+
12
+ const XSD_BOOLEAN = 'http://www.w3.org/2001/XMLSchema#boolean';
13
+
14
+ // Boolean flag fields on designation subtypes. Each entry maps a model
15
+ // field name to its ontology predicate. Only emitted when the value is
16
+ // truthy, so legacy data without the fields round-trips unchanged.
17
+ const BOOLEAN_FLAGS = [
18
+ ['acronym', PRED.gloss.isAcronym],
19
+ ['initialism', PRED.gloss.isInitialism],
20
+ ['truncation', PRED.gloss.isTruncation],
21
+ ['international', PRED.gloss.isInternational],
22
+ ['absent', PRED.gloss.isAbsent],
23
+ ];
24
+
25
+ // Returns the SKOS-XL predicate (as a string URI) appropriate for this
26
+ // designation's normative status. Mirrors `skosxl_label_for` in Ruby.
27
+ // The dispatch lives on the Designation model so a new status requires
28
+ // a single edit there, not here.
29
+ export function skosxlLabelPredicate(designation) {
30
+ return designation.skosxlLabelPredicate(PREFIXES.skosxl);
31
+ }
32
+
33
+ // Returns the matching plain-SKOS predicate URI (skos:prefLabel etc.).
34
+ // Used when emitting direct SKOS alongside reified SKOS-XL.
35
+ export function skosLabelPredicate(designation) {
36
+ return designation.skosLabelPredicate(PREFIXES.skos);
37
+ }
38
+
39
+ // Emits quads for one designation. Yields them lazily so callers can
40
+ // compose many designations into a single stream without intermediate
41
+ // arrays.
42
+ //
43
+ // `subjectUri` is the URI of the LocalizedConcept that owns this designation.
44
+ // `index` is the position of the designation within its parent's terms list
45
+ // — used to make the bnode ID deterministic.
46
+ export function* designationToQuads(designation, { subjectUri, language, index }) {
47
+ const desigSubject = deterministicBnode(subjectUri, 'desig', index);
48
+
49
+ const labelPredicate = namedNode(skosxlLabelPredicate(designation));
50
+ yield quad(namedNode(subjectUri), labelPredicate, namedNode(desigSubject));
51
+
52
+ yield quad(namedNode(desigSubject), namedNode(WELL_KNOWN.rdfType), namedNode(`${PRED.gloss.$ns}${designation.rdfClass()}`));
53
+ yield quad(namedNode(desigSubject), namedNode(WELL_KNOWN.rdfType), namedNode(WELL_KNOWN.skosxlLabel));
54
+ yield quad(namedNode(desigSubject), namedNode(SKOSXL.literalForm), literal(designation.designation ?? '', language));
55
+
56
+ if (designation.normativeStatus) {
57
+ const statusToken = normalizeEnum(designation.normativeStatus);
58
+ if (statusToken) {
59
+ const statusUri = `${PRED.gloss.$ns}norm/${statusToken}`;
60
+ yield quad(namedNode(desigSubject), namedNode(PRED.gloss.normativeStatus), namedNode(statusUri));
61
+ }
62
+ }
63
+
64
+ // Subtype-specific boolean flags. Skipping when falsy preserves
65
+ // backward compatibility with simpler designations.
66
+ const booleanType = namedNode(XSD_BOOLEAN);
67
+ for (const [field, predicate] of BOOLEAN_FLAGS) {
68
+ if (designation[field]) {
69
+ yield quad(namedNode(desigSubject), namedNode(predicate), literal('true', booleanType));
70
+ }
71
+ }
72
+ }
@@ -0,0 +1,69 @@
1
+ // DetailedDefinition → RDF quads. Mirrors glossarist-ruby's
2
+ // `Rdf::GlossDetailedDefinition`. Used for definitions, notes, examples,
3
+ // and annotations — they all share the same shape (content + optional
4
+ // sources + nested examples).
5
+ //
6
+ // The emitter takes a `linkPredicate` (e.g. PRED.gloss.hasDefinition,
7
+ // PRED.gloss.hasNote, PRED.gloss.hasExample) so callers can reuse it for
8
+ // any role.
9
+ import { PRED } from './predicates.js';
10
+ import { WELL_KNOWN } from './prefixes.js';
11
+ import { deterministicBnode } from './deterministic-id.js';
12
+ import { namedNode, literal, quad } from './terms.js';
13
+
14
+ export function* detailedDefinitionToQuads(definition, {
15
+ subjectUri, language, index, role,
16
+ }) {
17
+ const linkPredicate = linkPredicateFor(role);
18
+ const defSubject = deterministicBnode(subjectUri, role, index);
19
+
20
+ yield quad(namedNode(subjectUri), namedNode(linkPredicate), namedNode(defSubject));
21
+ yield quad(namedNode(defSubject), namedNode(WELL_KNOWN.rdfType), namedNode(PRED.gloss.DetailedDefinition));
22
+
23
+ if (definition.content) {
24
+ yield quad(
25
+ namedNode(defSubject),
26
+ namedNode(WELL_KNOWN.rdfValue),
27
+ literal(definition.content, language),
28
+ );
29
+ }
30
+
31
+ // Plain SKOS direct literal — so non-SKOS-XL consumers see definitions.
32
+ // The reified form above carries the same text with sources/examples;
33
+ // this direct literal makes SPARQL `?c skos:definition ?d` work.
34
+ const directPredicate = directSkosPredicateFor(role);
35
+ if (directPredicate && definition.content) {
36
+ yield quad(namedNode(subjectUri), namedNode(directPredicate), literal(definition.content, language));
37
+ }
38
+
39
+ let exampleIndex = 0;
40
+ for (const example of definition.examples ?? []) {
41
+ yield* detailedDefinitionToQuads(example, {
42
+ subjectUri: defSubject, language, index: exampleIndex, role: 'hasScopedExample',
43
+ });
44
+ exampleIndex += 1;
45
+ }
46
+ }
47
+
48
+ function linkPredicateFor(role) {
49
+ switch (role) {
50
+ case 'hasDefinition': return PRED.gloss.hasDefinition;
51
+ case 'hasNote': return PRED.gloss.hasNote;
52
+ case 'hasExample': return PRED.gloss.hasExample;
53
+ case 'hasScopedExample': return PRED.gloss.hasScopedExample;
54
+ case 'hasAnnotation': return PRED.gloss.hasAnnotation;
55
+ default: throw new Error(`Unknown detailed-definition role: ${role}`);
56
+ }
57
+ }
58
+
59
+ function directSkosPredicateFor(role) {
60
+ switch (role) {
61
+ case 'hasDefinition': return PRED.skos.definition;
62
+ case 'hasNote': return PRED.skos.scopeNote;
63
+ case 'hasExample': return PRED.skos.example;
64
+ case 'hasScopedExample': return PRED.skos.example;
65
+ // No direct SKOS counterpart for annotations.
66
+ case 'hasAnnotation': return null;
67
+ default: return null;
68
+ }
69
+ }