glossarist 0.3.4 → 0.3.6

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.
@@ -1,20 +1,25 @@
1
1
  import { ValidationRule } from './validation-rule.js';
2
2
 
3
- /**
4
- * GLS-305: Enforces Citation#ref is a proper Citation.Ref object
5
- * and RelatedConcept#ref has at least source or id.
6
- */
3
+ const _langs = (concept) =>
4
+ concept.languages ?? (concept.localizations ? Object.keys(concept.localizations) : []);
5
+
6
+ const _loc = (concept, lang) =>
7
+ typeof concept.localization === 'function' ? concept.localization(lang) : concept.localizations?.[lang];
8
+
9
+ const _eachLocalization = (concept, fn) => {
10
+ for (const lang of _langs(concept)) {
11
+ const lc = _loc(concept, lang);
12
+ if (lc) fn(lang, lc);
13
+ }
14
+ };
15
+
7
16
  export class RefShapeRule extends ValidationRule {
8
17
  constructor() { super('ref-shape'); }
9
18
 
10
- validate(value, path) {
11
- const errors = [];
12
-
13
- // Check sources in localizations
14
- const localizations = value.localizations || {};
19
+ validate(concept, path, result) {
15
20
  let sourceIdx = 0;
16
- for (const [lang, lc] of Object.entries(localizations)) {
17
- const sources = lc.sources || [];
21
+ _eachLocalization(concept, (lang, lc) => {
22
+ const sources = lc.sources ?? [];
18
23
  for (let i = 0; i < sources.length; i++) {
19
24
  sourceIdx++;
20
25
  const origin = sources[i].origin;
@@ -22,195 +27,254 @@ export class RefShapeRule extends ValidationRule {
22
27
 
23
28
  const ref = origin.ref;
24
29
  if (!ref) {
25
- errors.push(...this.error(
30
+ this.addIssue(result,
26
31
  `${path}localizations.${lang}.sources[${i}].origin.ref`,
27
- `source ${sourceIdx} origin has nil ref (expected Citation.Ref hash)`,
28
- ));
32
+ `source ${sourceIdx} origin has nil ref (expected Citation.Ref hash)`);
29
33
  } else if (!ref.source && !ref.id) {
30
- errors.push(...this.error(
34
+ this.addIssue(result,
31
35
  `${path}localizations.${lang}.sources[${i}].origin.ref`,
32
- `source ${sourceIdx} origin.ref has neither source nor id`,
33
- ));
36
+ `source ${sourceIdx} origin.ref has neither source nor id`);
34
37
  }
35
38
  }
36
- }
39
+ });
37
40
 
38
- // Check related concepts
39
- const related = value.related || [];
41
+ const related = concept.relatedConcepts ?? concept.related ?? [];
40
42
  for (let i = 0; i < related.length; i++) {
41
43
  const ref = related[i].ref;
42
44
  if (!ref) continue;
43
45
  if (!ref.source && !ref.id) {
44
- errors.push(...this.error(
46
+ this.addIssue(result,
45
47
  `${path}related[${i}].ref`,
46
- `related concept ${i + 1} has empty ref (no source or id)`,
47
- ));
48
+ `related concept ${i + 1} has empty ref (no source or id)`);
48
49
  }
49
50
  }
50
-
51
- return errors;
52
51
  }
53
52
  }
54
53
 
55
- /**
56
- * GLS-308: Locality must have type and reference_from when present.
57
- */
58
54
  export class LocalityCompletenessRule extends ValidationRule {
59
55
  constructor() { super('locality-completeness', 'warning'); }
60
56
 
61
- validate(value, path) {
62
- const errors = [];
63
- const localizations = value.localizations || {};
64
-
65
- for (const [lang, lc] of Object.entries(localizations)) {
66
- const sources = lc.sources || [];
57
+ validate(concept, path, result) {
58
+ _eachLocalization(concept, (lang, lc) => {
59
+ const sources = lc.sources ?? [];
67
60
  for (let i = 0; i < sources.length; i++) {
68
61
  const origin = sources[i].origin;
69
62
  if (!origin || !origin.locality) continue;
70
63
 
71
64
  const loc = origin.locality;
72
65
  if (!loc.type) {
73
- errors.push(...this.error(
66
+ this.addIssue(result,
74
67
  `${path}localizations.${lang}.sources[${i}].origin.locality.type`,
75
- `source locality has no type`,
76
- ));
68
+ `source locality has no type`);
77
69
  }
78
- if (!loc.reference_from) {
79
- errors.push(...this.error(
70
+ if (!loc.reference_from && !loc.referenceFrom) {
71
+ this.addIssue(result,
80
72
  `${path}localizations.${lang}.sources[${i}].origin.locality.reference_from`,
81
- `source locality has no reference_from`,
82
- ));
73
+ `source locality has no reference_from`);
83
74
  }
84
75
  }
85
- }
86
-
87
- return errors;
76
+ });
88
77
  }
89
78
  }
90
79
 
91
- /**
92
- * GLS-017: Localization map consistency.
93
- */
94
80
  export class LocalizationConsistencyRule extends ValidationRule {
95
81
  constructor() { super('localization-consistency'); }
96
82
 
97
- validate(value, path) {
98
- const errors = [];
99
- const localizations = value.localizations || {};
100
- const data = value.raw?.data || value;
101
-
83
+ validate(concept, path, result) {
84
+ const langs = _langs(concept);
85
+ const data = concept.raw?.data || concept;
102
86
  const declaredLangs = data.localized_concepts
103
87
  ? Object.keys(data.localized_concepts)
104
- : Object.keys(localizations);
88
+ : langs;
105
89
 
106
90
  for (const lang of declaredLangs) {
107
- if (!localizations[lang]) {
108
- errors.push(...this.error(
91
+ if (!concept.hasLocalization?.(lang) && !(concept.localizations?.[lang])) {
92
+ this.addIssue(result,
109
93
  `${path}localizations.${lang}`,
110
- `localized_concepts map has '${lang}' but no localization loaded`,
111
- ));
94
+ `localized_concepts map has '${lang}' but no localization loaded`);
112
95
  }
113
96
  }
114
-
115
- return errors;
116
97
  }
117
98
  }
118
99
 
119
- /**
120
- * GLS-010: Schema version should be 3.
121
- */
122
100
  export class SchemaVersionRule extends ValidationRule {
123
101
  constructor() { super('schema-version', 'warning'); }
124
102
 
125
- validate(value, path) {
126
- const errors = [];
127
- const version = value.schemaVersion || value.schema_version;
103
+ validate(concept, path, result) {
104
+ const version = concept.schemaVersion ?? concept.schema_version;
128
105
 
129
106
  if (version && String(version) !== '3') {
130
- errors.push(...this.error(
107
+ this.addIssue(result,
131
108
  `${path}schema_version`,
132
- `schema_version is '${version}', expected '3'`,
133
- ));
109
+ `schema_version is '${version}', expected '3'`);
134
110
  }
135
-
136
- return errors;
137
111
  }
138
112
  }
139
113
 
140
- /**
141
- * GLS-309: Domain references need concept_id or urn.
142
- */
143
114
  export class DomainRefRule extends ValidationRule {
144
115
  constructor() { super('domain-ref', 'warning'); }
145
116
 
146
- validate(value, path) {
147
- const errors = [];
148
- const domains = value.domains || [];
117
+ validate(concept, path, result) {
118
+ const domains = concept.domains || [];
149
119
 
150
120
  for (let i = 0; i < domains.length; i++) {
151
121
  const domain = domains[i];
152
- if (!domain.concept_id && !domain.urn) {
153
- errors.push(...this.error(
122
+ const json = typeof domain.toJSON === 'function' ? domain.toJSON() : domain;
123
+ if (!json.concept_id && !json.urn) {
124
+ this.addIssue(result,
154
125
  `${path}domains[${i}]`,
155
- `domain ${i + 1} has neither concept_id nor urn`,
156
- ));
126
+ `domain ${i + 1} has neither concept_id nor urn`);
157
127
  }
158
128
  }
159
-
160
- return errors;
161
129
  }
162
130
  }
163
131
 
164
- /**
165
- * GLS-016: UUID format validation.
166
- */
167
132
  export class UuidFormatRule extends ValidationRule {
168
133
  constructor() { super('uuid-format'); }
169
134
 
170
- validate(value, path) {
171
- const errors = [];
135
+ validate(concept, path, result) {
172
136
  const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
173
- const id = value.id || value.uuid;
137
+ const id = concept.id || concept.uuid;
174
138
 
175
139
  if (id && !UUID_RE.test(String(id))) {
176
- // Only flag if it looks like it's supposed to be a UUID
177
140
  if (String(id).includes('-') && String(id).length > 20) {
178
- errors.push(...this.error(
141
+ this.addIssue(result,
179
142
  `${path}id`,
180
- `concept ID '${id}' is not valid UUID format`,
181
- ));
143
+ `concept ID '${id}' is not valid UUID format`);
182
144
  }
183
145
  }
184
-
185
- return errors;
186
146
  }
187
147
  }
188
148
 
189
- /**
190
- * GLS-310: URN format validation for sources.
191
- */
192
149
  export class SourceUrnFormatRule extends ValidationRule {
193
150
  constructor() { super('source-urn-format', 'warning'); }
194
151
 
195
- validate(value, path) {
196
- const errors = [];
152
+ validate(concept, path, result) {
197
153
  const URN_RE = /^urn:[a-z0-9][a-z0-9-]{0,31}:[a-z0-9()+,\-.:=@;$_!*'%/?#]+$/i;
198
154
 
199
- const localizations = value.localizations || {};
200
- for (const [lang, lc] of Object.entries(localizations)) {
201
- const sources = lc.sources || [];
155
+ _eachLocalization(concept, (lang, lc) => {
156
+ const sources = lc.sources ?? [];
202
157
  for (let i = 0; i < sources.length; i++) {
203
- const source = sources[i].origin?.ref?.source;
158
+ const source = lc.sources[i].origin?.ref?.source;
204
159
  if (!source || !source.startsWith('urn:')) continue;
205
160
  if (!URN_RE.test(source)) {
206
- errors.push(...this.error(
161
+ this.addIssue(result,
207
162
  `${path}localizations.${lang}.sources[${i}].origin.ref.source`,
208
- `malformed URN '${source}'`,
209
- ));
163
+ `malformed URN '${source}'`);
210
164
  }
211
165
  }
166
+ });
167
+ }
168
+ }
169
+
170
+ const CITE_MENTION_RE = /\{\{\s*cite:([^,}\s]+)[^}]*?\}\}/g;
171
+
172
+ function _findCiteMentions(concept) {
173
+ const mentions = [];
174
+ const walkText = (text, source) => {
175
+ if (typeof text !== 'string' || text.length === 0) return;
176
+ CITE_MENTION_RE.lastIndex = 0;
177
+ let m;
178
+ while ((m = CITE_MENTION_RE.exec(text)) !== null) {
179
+ mentions.push({ key: m[1].trim(), source });
212
180
  }
181
+ };
213
182
 
214
- return errors;
183
+ for (const lang of _langs(concept)) {
184
+ const lc = _loc(concept, lang);
185
+ if (!lc) continue;
186
+
187
+ for (let i = 0; (lc.definitions ?? [])[i]; i++) {
188
+ walkText(lc.definitions[i]?.content, `localizations.${lang}.definitions[${i}].content`);
189
+ }
190
+ for (let i = 0; (lc.notes ?? [])[i]; i++) {
191
+ const content = typeof lc.notes[i] === 'object'
192
+ ? (lc.notes[i]?.content ?? '')
193
+ : String(lc.notes[i] ?? '');
194
+ walkText(content, `localizations.${lang}.notes[${i}].content`);
195
+ }
196
+ for (let i = 0; (lc.examples ?? [])[i]; i++) {
197
+ walkText(lc.examples[i]?.content, `localizations.${lang}.examples[${i}].content`);
198
+ }
199
+ for (let i = 0; (lc.annotations ?? [])[i]; i++) {
200
+ walkText(lc.annotations[i]?.content, `localizations.${lang}.annotations[${i}].content`);
201
+ }
202
+ }
203
+
204
+ return mentions;
205
+ }
206
+
207
+ function _findDuplicateSourceIds(concept) {
208
+ const seen = new Map();
209
+ const record = (source) => {
210
+ if (source?.id == null) return;
211
+ if (!seen.has(source.id)) seen.set(source.id, []);
212
+ seen.get(source.id).push(source);
213
+ };
214
+
215
+ for (const source of (concept.sources ?? [])) record(source);
216
+ for (const lang of _langs(concept)) {
217
+ const lc = _loc(concept, lang);
218
+ if (!lc) continue;
219
+ for (const source of (lc.sources ?? [])) record(source);
220
+ for (const designation of (lc.terms ?? [])) {
221
+ for (const source of (designation.sources ?? [])) record(source);
222
+ }
223
+ }
224
+
225
+ const duplicates = new Map();
226
+ for (const [id, sources] of seen) {
227
+ if (sources.length > 1) duplicates.set(id, sources);
228
+ }
229
+ return duplicates;
230
+ }
231
+
232
+ function _collectSourceIds(concept) {
233
+ const ids = new Set();
234
+ for (const source of (concept.sources ?? [])) {
235
+ if (source?.id != null) ids.add(source.id);
236
+ }
237
+ for (const lang of _langs(concept)) {
238
+ const lc = _loc(concept, lang);
239
+ if (!lc) continue;
240
+ for (const source of (lc.sources ?? [])) {
241
+ if (source?.id != null) ids.add(source.id);
242
+ }
243
+ for (const designation of (lc.terms ?? [])) {
244
+ for (const source of (designation.sources ?? [])) {
245
+ if (source?.id != null) ids.add(source.id);
246
+ }
247
+ }
248
+ }
249
+ return ids;
250
+ }
251
+
252
+ export class CiteRefIntegrityRule extends ValidationRule {
253
+ constructor() {
254
+ super('cite-ref-integrity', 'warning');
255
+ }
256
+
257
+ validate(concept, path, result) {
258
+ // 1. Check unique source ids.
259
+ const duplicates = _findDuplicateSourceIds(concept);
260
+ for (const [id] of duplicates) {
261
+ this.addIssue(result,
262
+ `${path}sources`,
263
+ `duplicate source id "${id}" in concept "${concept.id ?? ''}"`);
264
+ }
265
+
266
+ // 2. Check that every inline {{cite:<key>}} mention resolves.
267
+ const mentions = _findCiteMentions(concept);
268
+ if (mentions.length === 0) return;
269
+
270
+ const knownIds = _collectSourceIds(concept);
271
+
272
+ for (const { key, source } of mentions) {
273
+ if (!knownIds.has(key)) {
274
+ this.addIssue(result,
275
+ source,
276
+ `inline {{cite:${key}}} does not resolve to any source in concept "${concept.id ?? ''}"`);
277
+ }
278
+ }
215
279
  }
216
280
  }
@@ -8,4 +8,8 @@ export class ValidationError {
8
8
  toString() {
9
9
  return `[${this.severity.toUpperCase()}] ${this.path}: ${this.message}`;
10
10
  }
11
+
12
+ toJSON() {
13
+ return { path: this.path, message: this.message, severity: this.severity };
14
+ }
11
15
  }
@@ -1,34 +1,52 @@
1
+ import { ValidationError } from './validation-error.js';
2
+
1
3
  export class ValidationResult {
2
4
  constructor() {
3
- this.errors = [];
4
- this.warnings = [];
5
+ this._issues = [];
5
6
  }
6
7
 
7
8
  get valid() {
8
- return this.errors.length === 0;
9
+ return this._issues.filter(e => e.severity === 'error').length === 0;
10
+ }
11
+
12
+ get errors() {
13
+ return this._issues.filter(e => e.severity === 'error');
14
+ }
15
+
16
+ get warnings() {
17
+ return this._issues.filter(e => e.severity === 'warning');
9
18
  }
10
19
 
11
- addError(message) {
12
- this.errors.push(message);
20
+ addError(pathOrMessage, message) {
21
+ if (message === undefined) {
22
+ this._issues.push(new ValidationError('', pathOrMessage, 'error'));
23
+ } else {
24
+ this._issues.push(new ValidationError(pathOrMessage, message, 'error'));
25
+ }
13
26
  return this;
14
27
  }
15
28
 
16
- addWarning(message) {
17
- this.warnings.push(message);
29
+ addWarning(pathOrMessage, message) {
30
+ if (message === undefined) {
31
+ this._issues.push(new ValidationError('', pathOrMessage, 'warning'));
32
+ } else {
33
+ this._issues.push(new ValidationError(pathOrMessage, message, 'warning'));
34
+ }
18
35
  return this;
19
36
  }
20
37
 
21
38
  merge(other) {
22
- for (const e of other.errors) this.errors.push(e);
23
- for (const w of other.warnings) this.warnings.push(w);
39
+ if (other instanceof ValidationResult) {
40
+ for (const issue of other._issues) this._issues.push(issue);
41
+ }
24
42
  return this;
25
43
  }
26
44
 
27
45
  toJSON() {
28
46
  return {
29
47
  valid: this.valid,
30
- errors: [...this.errors],
31
- warnings: [...this.warnings],
48
+ errors: this.errors.map(e => e.toJSON ? e.toJSON() : e),
49
+ warnings: this.warnings.map(e => e.toJSON ? e.toJSON() : e),
32
50
  };
33
51
  }
34
52
  }
@@ -1,16 +1,18 @@
1
- import { ValidationError } from './validation-error.js';
2
-
3
1
  export class ValidationRule {
4
2
  constructor(name, severity = 'error') {
5
3
  this.name = name;
6
4
  this.severity = severity;
7
5
  }
8
6
 
9
- validate(_value, _path) {
7
+ validate(_value, _path, _result) {
10
8
  throw new Error(`${this.constructor.name} must implement validate()`);
11
9
  }
12
10
 
13
- error(path, message) {
14
- return [new ValidationError(path, message, this.severity)];
11
+ addIssue(result, path, message) {
12
+ if (this.severity === 'warning') {
13
+ result.addWarning(path, message);
14
+ } else {
15
+ result.addError(path, message);
16
+ }
15
17
  }
16
18
  }