glossarist 0.3.4 → 0.3.5

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,6 +1,6 @@
1
1
  {
2
2
  "name": "glossarist",
3
- "version": "0.3.4",
3
+ "version": "0.3.5",
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",
@@ -5,7 +5,6 @@ const _items = Symbol('items');
5
5
  export class ConceptCollection {
6
6
  constructor(concepts = []) {
7
7
  this[_items] = Array.from(concepts);
8
- return new Proxy(this, _handler);
9
8
  }
10
9
 
11
10
  get length() { return this[_items].length; }
@@ -23,10 +22,35 @@ export class ConceptCollection {
23
22
  splice(...args) { return this[_items].splice(...args); }
24
23
  set(index, item) { this[_items][index] = item; }
25
24
 
25
+ toArray() { return [...this[_items]]; }
26
+
26
27
  byId(id) {
27
28
  return this[_items].find(c => c.id === id || c.termid === id);
28
29
  }
29
30
 
31
+ /**
32
+ * Find a concept by id and version.
33
+ *
34
+ * The version is matched against a top-level `version` field on
35
+ * the concept instance (e.g. `concept.version`). The deployment
36
+ * is responsible for setting this field on bibliographic records;
37
+ * the data model does not enforce its presence or type.
38
+ *
39
+ * If `version` is null, the method falls back to `byId(id)`. This
40
+ * supports the "version-agnostic" lookup for datasets where
41
+ * version is not tracked.
42
+ *
43
+ * @param {string} id
44
+ * @param {string | null} version
45
+ * @returns {Concept | null}
46
+ */
47
+ byIdAnd(id, version) {
48
+ if (version == null) return this.byId(id);
49
+ return this[_items].find(c =>
50
+ (c.id === id || c.termid === id) && c.version === version
51
+ ) ?? null;
52
+ }
53
+
30
54
  byPrefix(prefix) {
31
55
  return new ConceptCollection(this[_items].filter(c => c.id.startsWith(prefix)));
32
56
  }
@@ -90,27 +114,3 @@ export class ConceptCollection {
90
114
  slice(...args) { return new ConceptCollection(this[_items].slice(...args)); }
91
115
  concat(...args) { return new ConceptCollection(this[_items].concat(...args)); }
92
116
  }
93
-
94
- const _handler = {
95
- get(target, prop, receiver) {
96
- if (typeof prop === 'string' && /^\d+$/.test(prop)) {
97
- return target[_items][Number(prop)];
98
- }
99
- if (prop === 'length') return target[_items].length;
100
- const value = Reflect.get(target, prop, receiver);
101
- return typeof value === 'function' ? value.bind(target) : value;
102
- },
103
- set(target, prop, value) {
104
- if (typeof prop === 'string' && /^\d+$/.test(prop)) {
105
- target[_items][Number(prop)] = value;
106
- return true;
107
- }
108
- return Reflect.set(target, prop, value);
109
- },
110
- has(target, prop) {
111
- if (typeof prop === 'string' && /^\d+$/.test(prop)) {
112
- return Number(prop) in target[_items];
113
- }
114
- return Reflect.has(target, prop);
115
- },
116
- };
package/src/gcr-reader.js CHANGED
@@ -367,12 +367,42 @@ export class GcrPackage {
367
367
  return map;
368
368
  }
369
369
 
370
- /** @private @param {string} filePath @returns {Promise<string | null>} */
371
- async _readText(filePath) {
370
+ /**
371
+ * Read a text file from the package.
372
+ * @param {string} filePath
373
+ * @returns {Promise<string | null>} null if the file doesn't exist
374
+ */
375
+ async readText(filePath) {
372
376
  const entry = this._zip.file(filePath);
373
377
  if (!entry) return null;
374
378
  return entry.async('text');
375
379
  }
380
+
381
+ /**
382
+ * List all entry paths in the package.
383
+ * @returns {Array<{ path: string, dir: boolean }>}
384
+ */
385
+ entryPaths() {
386
+ const entries = [];
387
+ this._zip.forEach((relativePath, entry) => {
388
+ entries.push({ path: relativePath, dir: !!entry.dir });
389
+ });
390
+ return entries;
391
+ }
392
+
393
+ /**
394
+ * Check whether a specific entry exists in the package.
395
+ * @param {string} filePath
396
+ * @returns {boolean}
397
+ */
398
+ hasEntry(filePath) {
399
+ return this._zip.file(filePath) != null;
400
+ }
401
+
402
+ /** @private @deprecated Use readText instead */
403
+ async _readText(filePath) {
404
+ return this.readText(filePath);
405
+ }
376
406
  }
377
407
 
378
408
  // --- Concept YAML parsing ---
package/src/index.d.ts CHANGED
@@ -34,6 +34,16 @@ export { conceptUuid, localizedConceptUuid, uuidV5 } from './uuid';
34
34
  // Reference resolution
35
35
  export { ReferenceResolver, Reference, referenceResolver } from './reference-resolver';
36
36
 
37
+ export type MentionParseResult = {
38
+ kind: 'cite-ref' | 'numeric' | 'unresolved';
39
+ key?: string;
40
+ label?: string | null;
41
+ id?: string;
42
+ raw: string;
43
+ };
44
+
45
+ export function parseMention(raw: string): MentionParseResult;
46
+
37
47
  // V1 support
38
48
  export { V1Reader, migrateV1ToV2 } from './v1-reader';
39
49
 
package/src/index.js CHANGED
@@ -8,6 +8,8 @@ export { ManagedConceptCollection } from './managed-concept-collection.js';
8
8
  export { validateConcept, validateRegister, validateGcrPackage, createConceptValidator, ValidationError, ValidationRule, ValidationResult, RegisterValidator, GcrValidator } from './validators/index.js';
9
9
  export { conceptUuid, localizedConceptUuid, uuidV5 } from './uuid.js';
10
10
  export { ReferenceResolver, Reference, referenceResolver } from './reference-resolver.js';
11
+ export { parseMention } from './reference-mention.js';
12
+ export { ReferenceClassifier } from './render-classification.js';
11
13
  export { V1Reader, migrateV1ToV2 } from './v1-reader.js';
12
14
  export { GlossaristError, InvalidInputError, YamlParseError } from './errors.js';
13
15
 
@@ -4,6 +4,7 @@ import { Citation } from './citation.js';
4
4
  export class ConceptSource extends GlossaristModel {
5
5
  constructor(data = {}) {
6
6
  super();
7
+ this.id = data.id ?? null;
7
8
  this.status = data.status ?? null;
8
9
  this.type = data.type ?? null;
9
10
  this.origin = data.origin
@@ -14,6 +15,7 @@ export class ConceptSource extends GlossaristModel {
14
15
 
15
16
  toJSON() {
16
17
  const obj = {};
18
+ if (this.id != null) obj.id = this.id;
17
19
  if (this.status != null) obj.status = this.status;
18
20
  if (this.type != null) obj.type = this.type;
19
21
  if (this.origin != null) obj.origin = this.origin.toJSON();
@@ -30,6 +30,11 @@ export class Concept extends GlossaristModel {
30
30
  return Object.keys(this._rawLocalizations);
31
31
  }
32
32
 
33
+ get languageCodes() {
34
+ return this.languages;
35
+ }
36
+
37
+ /** @deprecated Use localization(lang) for model access, or toJSON().localizations for raw data */
33
38
  get localizations() {
34
39
  return this._rawLocalizations;
35
40
  }
@@ -68,6 +73,45 @@ export class Concept extends GlossaristModel {
68
73
  return lang in this._rawLocalizations;
69
74
  }
70
75
 
76
+ /**
77
+ * Find a source by its local id within this concept.
78
+ *
79
+ * The lookup walks concept-level, localization-level, and
80
+ * designation-level sources in that order, returning the first
81
+ * source whose `id` matches. Sources without an `id` are
82
+ * skipped. The id is local to the concept; uniqueness is
83
+ * enforced by the validator (see CiteRefIntegrityRule).
84
+ *
85
+ * @param {string} id
86
+ * @returns {ConceptSource | null}
87
+ */
88
+ findSourceById(id) {
89
+ if (typeof id !== 'string' || id.length === 0) return null;
90
+
91
+ // 1. Concept-level sources.
92
+ for (const source of this.sources) {
93
+ if (source.id === id) return source;
94
+ }
95
+
96
+ // 2. Localization-level sources.
97
+ for (const lang of this.languages) {
98
+ const lc = this.localization(lang);
99
+ if (!lc) continue;
100
+ for (const source of lc.sources) {
101
+ if (source.id === id) return source;
102
+ }
103
+
104
+ // 3. Designation-level sources.
105
+ for (const designation of lc.terms) {
106
+ for (const source of designation.sources) {
107
+ if (source.id === id) return source;
108
+ }
109
+ }
110
+ }
111
+
112
+ return null;
113
+ }
114
+
71
115
  toJSON() {
72
116
  const obj = { id: this.id };
73
117
  if (this.term != null) obj.term = this.term;
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Mention parser for {{...}} inline references in concept text.
3
+ *
4
+ * Pure function: takes a raw mention body (the text inside
5
+ * {{...}}) and returns a structured MentionParseResult. The
6
+ * extractor (in src/reference-resolver.js) consumes the
7
+ * structured form to emit Reference objects.
8
+ *
9
+ * Two outcomes in v8:
10
+ * - 'cite-ref': the mention is {{cite:<key>}} or
11
+ * {{cite:<key>,<label>}}. The extractor looks the key up in
12
+ * the current concept's sources list.
13
+ * - 'numeric': the mention is a bare dotted or dashed id
14
+ * like {{3.1.1.1}} or {{103-01-02}}. Resolves to a
15
+ * same-dataset concept.
16
+ * - 'unresolved': the mention did not match a recognized
17
+ * form. The extractor silently drops it.
18
+ *
19
+ * The full v6 form-aware parser (URI schemes, short-ids,
20
+ * quoting) is aspirational; v8 only supports the two forms
21
+ * above plus a catch-all unresolved case.
22
+ *
23
+ * @typedef {Object} MentionParseResult
24
+ * @property {'cite-ref' | 'numeric' | 'unresolved'} kind
25
+ * @property {string} [key] — for 'cite-ref': the local key
26
+ * @property {string} [label] — for 'cite-ref': the inline label
27
+ * @property {string} [id] — for 'numeric': the bare id
28
+ * @property {string} raw — the original mention body
29
+ */
30
+
31
+ const NUMERIC_RE = /^\d+(?:[.-]\d+)+$/;
32
+
33
+ /**
34
+ * Parse the body of a {{...}} mention (without the braces).
35
+ *
36
+ * The function is pure: no I/O, no model lookups, no state.
37
+ * Resolution of the parsed result is the extractor's job.
38
+ *
39
+ * @param {string} raw — the trimmed text inside {{...}}
40
+ * @returns {MentionParseResult}
41
+ */
42
+ export function parseMention(raw) {
43
+ const body = raw.trim();
44
+
45
+ // 1. cite:<key> form, with optional ,<label> after the key.
46
+ // The key must not contain a comma (the comma is the
47
+ // label separator). Labels can be quoted (CSV-style) to
48
+ // contain commas; if not quoted, the label is the text
49
+ // up to the next comma or the end of the mention. The
50
+ // label may be empty.
51
+ const citeMatch = body.match(/^cite:([^,}]+)(?:,(.*))?$/);
52
+ if (citeMatch) {
53
+ const label = citeMatch[2] !== undefined ? unquoteLabel(citeMatch[2].trim()) : null;
54
+ return {
55
+ kind: 'cite-ref',
56
+ key: citeMatch[1].trim(),
57
+ label,
58
+ raw: body,
59
+ };
60
+ }
61
+
62
+ // 2. Bare numeric id: same-dataset concept id.
63
+ if (NUMERIC_RE.test(body)) {
64
+ return {
65
+ kind: 'numeric',
66
+ id: body,
67
+ raw: body,
68
+ };
69
+ }
70
+
71
+ // 3. Anything else is unresolved at the parse layer.
72
+ return {
73
+ kind: 'unresolved',
74
+ raw: body,
75
+ };
76
+ }
77
+
78
+ /**
79
+ * Strip surrounding double quotes from a label, unescaping
80
+ * CSV-style "" to a single ". If the input is not quoted,
81
+ * return it unchanged.
82
+ */
83
+ function unquoteLabel(label) {
84
+ if (label.length >= 2 && label.startsWith('"') && label.endsWith('"')) {
85
+ return label.slice(1, -1).replace(/""/g, '"');
86
+ }
87
+ return label;
88
+ }
@@ -1,11 +1,35 @@
1
1
  import { ConceptRef } from './models/concept-ref.js';
2
+ import { parseMention } from './reference-mention.js';
2
3
 
3
4
  export class Reference {
4
- constructor(type, target, relationship, source) {
5
+ /**
6
+ * @param {string} type — the structural kind of the reference
7
+ * ('concept', 'dataset', 'bibliography', 'typed-ref',
8
+ * 'standard').
9
+ * @param {string | null} target — the legacy flat display
10
+ * string. Kept for backward compat with callers that only
11
+ * read `r.target`.
12
+ * @param {string | null} [relationship] — the type of the
13
+ * relationship that produced this reference (e.g. 'see',
14
+ * 'supersedes', 'source').
15
+ * @param {string | null} [source] — a JSON-pointer-ish path
16
+ * indicating where in the concept the reference was
17
+ * extracted from.
18
+ * @param {object} [extras] — additional fields (v8+):
19
+ * `citation`, `sourceId`, `resolution`, `lookupKey`,
20
+ * `label`, `quoted`, `uri`. All optional.
21
+ */
22
+ constructor(type, target, relationship, source, extras = {}) {
5
23
  this.type = type;
6
24
  this.target = target;
7
25
  this.relationship = relationship ?? null;
8
26
  this.source = source ?? null;
27
+
28
+ this.uri = extras.uri ?? null;
29
+ this.citation = extras.citation ?? null;
30
+ this.sourceId = extras.sourceId ?? null;
31
+ this.resolution = extras.resolution ?? null;
32
+ this.lookupKey = extras.lookupKey ?? null;
9
33
  }
10
34
  }
11
35
 
@@ -18,6 +42,21 @@ function refTarget(rc) {
18
42
  }
19
43
 
20
44
  export class ReferenceResolver {
45
+ /**
46
+ * Extract all embedded references from a concept's localizations.
47
+ *
48
+ * Walks definitions, notes, examples, and annotations text.
49
+ * For each `{{...}}` mention, runs `parseMention` to
50
+ * classify the form, then dispatches:
51
+ * - 'cite-ref' → look up the key in concept.sources; emit
52
+ * Bibliography Reference with the Citation.
53
+ * - 'numeric' → emit Concept Reference with the bare id
54
+ * (existing behavior).
55
+ * - 'unresolved' → do not emit a Reference.
56
+ *
57
+ * @param {Concept} concept
58
+ * @returns {Reference[]}
59
+ */
21
60
  extractReferences(concept) {
22
61
  const refs = [];
23
62
 
@@ -42,47 +81,267 @@ export class ReferenceResolver {
42
81
  }
43
82
  }
44
83
 
45
- const texts = [
46
- ...(lc.definitions?.map(d => d.content ?? '') ?? []),
47
- ...(lc.notes?.map(n => typeof n === 'object' ? (n.content ?? '') : String(n)) ?? []),
48
- ];
49
- for (const text of texts) {
50
- refs.push(..._extractEmbedded(text));
84
+ const texts = this._collectTexts(lc, lang);
85
+ for (const { text, source } of texts) {
86
+ for (const ref of this._extractFromText(text, source, concept)) {
87
+ refs.push(ref);
88
+ }
51
89
  }
52
90
  }
53
91
 
54
92
  return refs;
55
93
  }
56
94
 
57
- resolveReference(ref, collection) {
58
- if (ref.type !== 'concept') return null;
59
- return collection.byId(ref.target);
95
+ /**
96
+ * Collect all text fields from a localized concept, paired
97
+ * with diagnostic source paths.
98
+ *
99
+ * @param {LocalizedConcept} lc
100
+ * @param {string} lang
101
+ * @returns {{text: string, source: string}[]}
102
+ */
103
+ _collectTexts(lc, lang) {
104
+ const out = [];
105
+ for (let i = 0; (lc.definitions ?? [])[i]; i++) {
106
+ const content = lc.definitions[i]?.content;
107
+ if (typeof content === 'string') {
108
+ out.push({ text: content, source: `localizations.${lang}.definitions[${i}].content` });
109
+ }
110
+ }
111
+ for (let i = 0; (lc.notes ?? [])[i]; i++) {
112
+ const content = typeof lc.notes[i] === 'object'
113
+ ? (lc.notes[i]?.content ?? '')
114
+ : String(lc.notes[i] ?? '');
115
+ if (content) {
116
+ out.push({ text: content, source: `localizations.${lang}.notes[${i}].content` });
117
+ }
118
+ }
119
+ for (let i = 0; (lc.examples ?? [])[i]; i++) {
120
+ const content = lc.examples[i]?.content;
121
+ if (typeof content === 'string') {
122
+ out.push({ text: content, source: `localizations.${lang}.examples[${i}].content` });
123
+ }
124
+ }
125
+ for (let i = 0; (lc.annotations ?? [])[i]; i++) {
126
+ const content = lc.annotations[i]?.content;
127
+ if (typeof content === 'string') {
128
+ out.push({ text: content, source: `localizations.${lang}.annotations[${i}].content` });
129
+ }
130
+ }
131
+ return out;
132
+ }
133
+
134
+ /**
135
+ * Walk a single text string and emit References for each
136
+ * `{{...}}` mention.
137
+ *
138
+ * @param {string} text
139
+ * @param {string} source — diagnostic path
140
+ * @param {Concept} concept — the owning concept (for cite-ref lookup)
141
+ * @returns {Reference[]}
142
+ */
143
+ _extractFromText(text, source, concept) {
144
+ const refs = [];
145
+ const re = /\{\{([^{}]*?)\}\}/g;
146
+ let m;
147
+ while ((m = re.exec(text)) !== null) {
148
+ const parsed = parseMention(m[1]);
149
+ switch (parsed.kind) {
150
+ case 'cite-ref':
151
+ refs.push(this._resolveCiteRef(parsed, source, concept));
152
+ break;
153
+ case 'numeric':
154
+ refs.push(new Reference('concept', parsed.id, 'embedded', source));
155
+ break;
156
+ case 'unresolved':
157
+ // Silently dropped. The mention is either non-reference
158
+ // text (e.g. math, code) or a form we don't support.
159
+ break;
160
+ }
161
+ }
162
+ return refs;
163
+ }
164
+
165
+ /**
166
+ * Resolve a `cite-ref` parser result against the concept's
167
+ * sources list. Emits a Bibliography Reference with the
168
+ * resolved Citation (if found) or an unresolved Reference
169
+ * (if not).
170
+ *
171
+ * @param {MentionParseResult} parsed
172
+ * @param {string} source — diagnostic path
173
+ * @param {Concept} concept — the owning concept
174
+ * @returns {Reference}
175
+ */
176
+ _resolveCiteRef(parsed, source, concept) {
177
+ const sourceEntry = concept?.findSourceById(parsed.key) ?? null;
178
+ if (!sourceEntry) {
179
+ return new Reference(
180
+ 'bibliography',
181
+ parsed.label ?? parsed.key,
182
+ null,
183
+ source,
184
+ {
185
+ sourceId: parsed.key,
186
+ citation: null,
187
+ resolution: { kind: 'unresolved', reason: 'no-source' },
188
+ },
189
+ );
190
+ }
191
+ const displayTarget = parsed.label
192
+ ?? sourceEntry.origin?.toString()
193
+ ?? sourceEntry.id;
194
+ return new Reference(
195
+ 'bibliography',
196
+ displayTarget,
197
+ null,
198
+ source,
199
+ {
200
+ sourceId: sourceEntry.id,
201
+ citation: sourceEntry.origin,
202
+ resolution: { kind: 'resolved', sourceId: sourceEntry.id },
203
+ },
204
+ );
205
+ }
206
+
207
+ /**
208
+ * Resolve a single reference against a registry (a map of
209
+ * datasetId → { concepts, register? }). The registry may also
210
+ * include 'bibliography:<source>' keys for bibliographic
211
+ * datasets.
212
+ *
213
+ * For a `type: 'bibliography'` Reference with an inline
214
+ * `citation`, the resolver first tries the bibliography
215
+ * registry (matching `citation.ref` by source/id/version);
216
+ * if not found, returns the inline Citation as a
217
+ * self-contained fallback.
218
+ *
219
+ * For a `type: 'bibliography'` Reference with a `uri` and
220
+ * `resolution.kind === 'bibliography-namespace'`, the
221
+ * resolver tries the bibliography registry by
222
+ * `resolution.source/id/version`.
223
+ *
224
+ * For `type: 'concept'` References with a `lookupKey.id`
225
+ * (id-match, short-id, or numeric), the resolver looks up
226
+ * the id in `lookupKey.dataset`'s ConceptCollection.
227
+ *
228
+ * Backward compat: when the second argument is a
229
+ * ConceptCollection (has `byId` but no `concepts` field), it
230
+ * is treated as a one-key registry of one default dataset.
231
+ */
232
+ resolveReference(ref, registry) {
233
+ if (ref == null) return null;
234
+
235
+ // Backward-compat: single ConceptCollection becomes a
236
+ // one-key registry.
237
+ if (isConceptCollection(registry)) {
238
+ registry = { _default: { concepts: registry } };
239
+ }
240
+ if (registry == null) return null;
241
+
242
+ // 1. cite:key form (Bibliography with inline Citation).
243
+ if (ref.type === 'bibliography' && ref.citation) {
244
+ const bioRecord = this._resolveBibliographyRecord(
245
+ ref.citation.ref,
246
+ registry,
247
+ );
248
+ if (bioRecord) return bioRecord;
249
+ return ref.citation;
250
+ }
251
+
252
+ // 2. URI form (urn:... or https:...) with
253
+ // bibliography-namespace resolution.
254
+ if (ref.uri) {
255
+ if (ref.resolution?.kind === 'bibliography-namespace'
256
+ || (ref.resolution?.source && !ref.resolution?.datasetId)) {
257
+ const bioRecord = this._resolveBibliographyRecord(
258
+ ref.resolution,
259
+ registry,
260
+ );
261
+ if (bioRecord) return bioRecord;
262
+ }
263
+ // Concept URI lookup (for non-bibliography URIs).
264
+ if (ref.resolution?.datasetId) {
265
+ const coll = registry[ref.resolution.datasetId]?.concepts;
266
+ if (coll) {
267
+ return coll.byId(ref.resolution.conceptId);
268
+ }
269
+ }
270
+ return null;
271
+ }
272
+
273
+ // 3. Same-dataset concept id (numeric mention, id-match, etc.).
274
+ if (ref.lookupKey?.id) {
275
+ const coll = registry[ref.lookupKey.dataset]?.concepts;
276
+ if (coll) return coll.byId(ref.lookupKey.id);
277
+ return null;
278
+ }
279
+
280
+ // 3b. Backward-compat: a concept ref with a `target` (id)
281
+ // but no `lookupKey` is looked up in the single
282
+ // collection (backward-compat one-key registry).
283
+ if (ref.type === 'concept' && ref.target) {
284
+ const defaultColl = registry._default?.concepts;
285
+ if (defaultColl) return defaultColl.byId(ref.target);
286
+ // Try every dataset in the registry as a fallback.
287
+ for (const entry of Object.values(registry)) {
288
+ if (entry?.concepts?.byId(ref.target)) {
289
+ return entry.concepts.byId(ref.target);
290
+ }
291
+ }
292
+ return null;
293
+ }
294
+
295
+ // 4. Unanchored designation — search is a separate concern
296
+ // (plan 06). For v8, return null.
297
+ if (ref.lookupKey?.designation) {
298
+ return null;
299
+ }
300
+
301
+ return null;
302
+ }
303
+
304
+ /**
305
+ * Try to resolve a Citation::Ref against the bibliography
306
+ * registry. The ref has shape { source, id, version? }.
307
+ *
308
+ * Returns the matching bibliographic record (a Concept), or
309
+ * null if no match.
310
+ */
311
+ _resolveBibliographyRecord(citationRef, registry) {
312
+ if (!citationRef?.source || !citationRef?.id) return null;
313
+ const bioColl = registry[`bibliography:${citationRef.source}`]?.concepts;
314
+ if (!bioColl) return null;
315
+ if (citationRef.version) {
316
+ return bioColl.byIdAnd(citationRef.id, citationRef.version);
317
+ }
318
+ return bioColl.byId(citationRef.id);
60
319
  }
61
320
 
62
- resolveAll(concept, collection) {
321
+ resolveAll(concept, registry) {
63
322
  const resolved = new Map();
64
323
  for (const ref of this.extractReferences(concept)) {
65
- if (ref.type === 'concept') {
66
- resolved.set(ref.target, this.resolveReference(ref, collection));
324
+ if (ref.type === 'concept' || ref.type === 'bibliography') {
325
+ const target = this.resolveReference(ref, registry);
326
+ if (target != null) {
327
+ const key = ref.target ?? ref.uri ?? ref.sourceId;
328
+ if (key != null) resolved.set(key, target);
329
+ }
67
330
  }
68
331
  }
69
332
  return resolved;
70
333
  }
71
334
  }
72
335
 
73
- const CONCEPT_REF_RE = /\{\{([^}]+)\}\}|\b(\d+(?:\.\d+)+)\b/g;
74
-
75
- function _extractEmbedded(text) {
76
- const refs = [];
77
- let m;
78
- CONCEPT_REF_RE.lastIndex = 0;
79
- while ((m = CONCEPT_REF_RE.exec(text)) !== null) {
80
- const target = m[1] ?? m[2];
81
- if (target && /^\d+(\.\d+)+$/.test(target)) {
82
- refs.push(new Reference('concept', target, 'embedded', null));
83
- }
84
- }
85
- return refs;
336
+ /**
337
+ * Type-guard for the single-collection case (backward compat).
338
+ * A ConceptCollection has `byId` but no `concepts` field.
339
+ */
340
+ function isConceptCollection(x) {
341
+ return x != null
342
+ && typeof x === 'object'
343
+ && typeof x.byId === 'function'
344
+ && !('concepts' in x);
86
345
  }
87
346
 
88
347
  export const referenceResolver = new ReferenceResolver();