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.
@@ -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
+ }
@@ -0,0 +1,72 @@
1
+ // LocalizedConcept → RDF quads. Mirrors glossarist-ruby's
2
+ // `Rdf::GlossLocalizedConcept` + the `EmitsExtraTriples` hook.
3
+ //
4
+ // Emits BOTH reified SKOS-XL labels AND direct SKOS literals, so consumers
5
+ // that don't implement SKOS-XL still see labels/definitions/notes/examples
6
+ // as plain literals. This is the same shape glossarist-ruby emits after
7
+ // WS-B Phase 1.
8
+ import { PRED, PREFIXES } from './predicates.js';
9
+ import { WELL_KNOWN } from './prefixes.js';
10
+ import { designationToQuads, skosLabelPredicate } from './gloss-designation.js';
11
+ import { detailedDefinitionToQuads } from './gloss-detailed-definition.js';
12
+ import { conceptSourceToQuads } from './gloss-source.js';
13
+ import { namedNode, literal, quad } from './terms.js';
14
+ const DCTERMS_LANGUAGE = `${PREFIXES.dcterms}language`;
15
+
16
+ export function localizedConceptUri(parentUri, language) {
17
+ return `${parentUri}/${language}`;
18
+ }
19
+
20
+ export function* localizedConceptToQuads(localizedConcept, { parentUri, language }) {
21
+ const subjectUri = localizedConceptUri(parentUri, language);
22
+ const s = namedNode(subjectUri);
23
+
24
+ yield quad(s, namedNode(WELL_KNOWN.rdfType), namedNode(PRED.gloss.LocalizedConcept));
25
+ yield quad(s, namedNode(WELL_KNOWN.rdfType), namedNode(WELL_KNOWN.skosConcept));
26
+ yield quad(s, namedNode(PRED.gloss.isLocalizationOf), namedNode(parentUri));
27
+ yield quad(s, namedNode(DCTERMS_LANGUAGE), literal(language));
28
+
29
+ if (localizedConcept.entryStatus) {
30
+ yield quad(s, namedNode(PRED.gloss.hasEntryStatus), namedNode(`${PRED.gloss.$ns}entstatus/${localizedConcept.entryStatus}`));
31
+ }
32
+ if (localizedConcept.domain) {
33
+ yield quad(s, namedNode(PRED.gloss.domain), literal(localizedConcept.domain));
34
+ }
35
+ if (localizedConcept.release) {
36
+ yield quad(s, namedNode(PRED.gloss.release), literal(localizedConcept.release));
37
+ }
38
+ if (localizedConcept.script) {
39
+ yield quad(s, namedNode(PRED.gloss.script), literal(localizedConcept.script));
40
+ }
41
+ if (localizedConcept.system) {
42
+ yield quad(s, namedNode(PRED.gloss.conversionSystem), literal(localizedConcept.system));
43
+ }
44
+
45
+ let desigIndex = 0;
46
+ for (const designation of localizedConcept.terms ?? []) {
47
+ yield* designationToQuads(designation, { subjectUri, language, index: desigIndex });
48
+ // Direct SKOS literal alongside the reified SKOS-XL form.
49
+ yield quad(s, namedNode(skosLabelPredicate(designation)), literal(designation.designation, language));
50
+ desigIndex += 1;
51
+ }
52
+
53
+ yield* definitionsToQuads(localizedConcept.definitions, { subjectUri, language, role: 'hasDefinition' });
54
+ yield* definitionsToQuads(localizedConcept.notes, { subjectUri, language, role: 'hasNote' });
55
+ yield* definitionsToQuads(localizedConcept.examples, { subjectUri, language, role: 'hasExample' });
56
+ yield* definitionsToQuads(localizedConcept.annotations, { subjectUri, language, role: 'hasAnnotation' });
57
+
58
+ let srcIndex = 0;
59
+ for (const source of localizedConcept.sources ?? []) {
60
+ yield* conceptSourceToQuads(source, { subjectUri, index: srcIndex });
61
+ srcIndex += 1;
62
+ }
63
+ }
64
+
65
+ function* definitionsToQuads(definitions, { subjectUri, language, role }) {
66
+ if (!definitions) return;
67
+ let i = 0;
68
+ for (const def of definitions) {
69
+ yield* detailedDefinitionToQuads(def, { subjectUri, language, index: i, role });
70
+ i += 1;
71
+ }
72
+ }
@@ -0,0 +1,51 @@
1
+ // ConceptSource → RDF quads. Mirrors glossarist-ruby's
2
+ // `Rdf::GlossConceptSource` + `Rdf::GlossCitation`. Sources are reified
3
+ // resources linked from their parent (concept or localized concept or
4
+ // definition) via `gloss:hasSource`.
5
+ import { PRED } from './predicates.js';
6
+ import { WELL_KNOWN } from './prefixes.js';
7
+ import { deterministicBnode } from './deterministic-id.js';
8
+ import { normalizeEnum } from './normalize-enum.js';
9
+ import { namedNode, literal, quad } from './terms.js';
10
+
11
+ export function* conceptSourceToQuads(source, { subjectUri, index }) {
12
+ const srcSubject = deterministicBnode(subjectUri, 'source', index);
13
+
14
+ yield quad(namedNode(subjectUri), namedNode(PRED.gloss.hasSource), namedNode(srcSubject));
15
+ yield quad(namedNode(srcSubject), namedNode(WELL_KNOWN.rdfType), namedNode(PRED.gloss.ConceptSource));
16
+
17
+ const statusToken = normalizeEnum(source.status);
18
+ if (statusToken) {
19
+ yield quad(namedNode(srcSubject), namedNode(PRED.gloss.sourceStatus), namedNode(`${PRED.gloss.$ns}srcstatus/${statusToken}`));
20
+ }
21
+ const typeToken = normalizeEnum(source.type);
22
+ if (typeToken) {
23
+ yield quad(namedNode(srcSubject), namedNode(PRED.gloss.sourceType), namedNode(`${PRED.gloss.$ns}srctype/${typeToken}`));
24
+ }
25
+ if (source.modification) {
26
+ yield quad(namedNode(srcSubject), namedNode(PRED.gloss.modification), literal(source.modification));
27
+ }
28
+
29
+ if (source.origin) {
30
+ yield* citationToQuads(source.origin, { subjectUri: srcSubject });
31
+ }
32
+ }
33
+
34
+ function* citationToQuads(citation, { subjectUri }) {
35
+ const citSubject = deterministicBnode(subjectUri, 'origin', 0);
36
+ yield quad(namedNode(subjectUri), namedNode(PRED.gloss.sourceOrigin), namedNode(citSubject));
37
+ yield quad(namedNode(citSubject), namedNode(WELL_KNOWN.rdfType), namedNode(PRED.gloss.Citation));
38
+
39
+ if (citation.original) {
40
+ yield quad(namedNode(citSubject), namedNode(PRED.gloss.citationOriginal), literal(citation.original));
41
+ }
42
+ if (citation.link) {
43
+ yield quad(namedNode(citSubject), namedNode(PRED.gloss.citationLink), literal(citation.link));
44
+ }
45
+
46
+ if (citation.ref) {
47
+ if (citation.ref.source) yield quad(namedNode(citSubject), namedNode(PRED.gloss.citationRefSource), literal(citation.ref.source));
48
+ if (citation.ref.id) yield quad(namedNode(citSubject), namedNode(PRED.gloss.citationRefId), literal(citation.ref.id));
49
+ if (citation.ref.version) yield quad(namedNode(citSubject), namedNode(PRED.gloss.citationRefVersion), literal(citation.ref.version));
50
+ }
51
+ }