fhirsmith 0.9.6 → 0.10.0

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 (49) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/library/folder-content-loader.js +91 -0
  3. package/npmprojector/npmprojector.js +2 -6
  4. package/package.json +1 -1
  5. package/packages/package-crawler.js +123 -6
  6. package/packages/packages.js +104 -0
  7. package/publisher/publisher.js +290 -12
  8. package/registry/crawler.js +85 -6
  9. package/registry/registry.js +24 -7
  10. package/server.js +11 -2
  11. package/stats.js +26 -18
  12. package/translations/Messages.properties +2 -1
  13. package/tx/cs/cs-cs.js +8 -0
  14. package/tx/cs/cs-loinc.js +1 -0
  15. package/tx/cs/cs-provider-list.js +2 -1
  16. package/tx/cs/cs-snomed.js +142 -59
  17. package/tx/data/snomed-testing.cache +0 -0
  18. package/tx/html/home-metrics.liquid +10 -10
  19. package/tx/library/canonical-resource.js +4 -2
  20. package/tx/library/codesystem.js +31 -0
  21. package/tx/library/conceptmap.js +24 -0
  22. package/tx/library/designations.js +27 -20
  23. package/tx/library/renderer.js +303 -22
  24. package/tx/library/ucum-types.js +4 -1
  25. package/tx/library/valueset.js +46 -0
  26. package/tx/library.js +65 -21
  27. package/tx/operation-context.js +122 -27
  28. package/tx/params.js +36 -8
  29. package/tx/provider.js +6 -3
  30. package/tx/tx-html.js +34 -0
  31. package/tx/tx.js +92 -30
  32. package/tx/vs/vs-vsac.js +157 -9
  33. package/tx/workers/cache-control.js +186 -0
  34. package/tx/workers/{related.js → compare.js} +27 -27
  35. package/tx/workers/expand.js +100 -96
  36. package/tx/workers/lookup.js +6 -0
  37. package/tx/workers/metadata.js +11 -6
  38. package/tx/workers/read.js +1 -1
  39. package/tx/workers/translate.js +20 -29
  40. package/tx/workers/validate.js +18 -10
  41. package/tx/workers/worker.js +134 -47
  42. package/tx/xversion/xv-bundle.js +1 -2
  43. package/tx/xversion/xv-codesystem.js +5 -2
  44. package/tx/xversion/xv-parameters.js +4 -4
  45. package/tx/xversion/xv-resource.js +2 -2
  46. package/tx/xversion/xv-terminologyCapabilities.js +11 -6
  47. package/tx/xversion/xv-valueset.js +7 -7
  48. package/publisher/task-draft.js +0 -463
  49. package/tx/data/OperationDefinition-ValueSet-related.json +0 -133
@@ -46,7 +46,7 @@ class SearchFilterText {
46
46
  validateParameter(value, 'value', String);
47
47
  validateOptionalParameter(returnRating, 'returnRating', Boolean);
48
48
 
49
- if (this.null) {
49
+ if (this.isNull) {
50
50
  return returnRating ? {passes: true, rating: 0} : true;
51
51
  }
52
52
 
@@ -610,35 +610,42 @@ class Designations {
610
610
 
611
611
  const matchTypes = [LangMatchType.FULL, LangMatchType.LANG_REGION, LangMatchType.LANG];
612
612
 
613
- for (const matchType of matchTypes) {
614
- for (const cd of this.designations) {
615
- if (this._langMatches(lang, cd.language, matchType) && this.isDisplay(cd)) {
616
- if (supplements && cd.source) {
617
- supplements.add(cd.source);
613
+ for (const activeOnly of [true, false]) {
614
+ for (const matchType of matchTypes) {
615
+ for (const cd of this.designations) {
616
+ if (this._langMatches(lang, cd.language, matchType) && this.isDisplay(cd) && (!activeOnly || cd.isActive())) {
617
+ if (supplements && cd.source) {
618
+ supplements.add(cd.source);
619
+ }
620
+ return cd;
618
621
  }
619
- return cd;
620
622
  }
621
- }
622
- for (const cd of this.designations) {
623
- if (this._langMatches(lang, cd.language, matchType) && this._isPreferred(cd)) {
624
- if (supplements && cd.source) {
625
- supplements.add(cd.source);
623
+ for (const cd of this.designations) {
624
+ if (this._langMatches(lang, cd.language, matchType) && this._isPreferred(cd)&& (!activeOnly || cd.isActive())) {
625
+ if (supplements && cd.source) {
626
+ supplements.add(cd.source);
627
+ }
628
+ return cd;
626
629
  }
627
- return cd;
628
630
  }
629
- }
630
- for (const cd of this.designations) {
631
- if (this._langMatches(lang, cd.language, matchType)) {
632
- if (supplements && cd.source) {
633
- supplements.add(cd.source);
631
+ for (const cd of this.designations) {
632
+ if (this._langMatches(lang, cd.language, matchType) && (!activeOnly || cd.isActive())) {
633
+ if (supplements && cd.source) {
634
+ supplements.add(cd.source);
635
+ }
636
+ return cd;
634
637
  }
635
- return cd;
636
638
  }
637
639
  }
638
640
  }
639
641
  }
640
642
  for (const cd of this.designations) {
641
- if (!cd.language && this.isDisplay(cd)) {
643
+ if (!cd.language && this.isDisplay(cd) && cd.isActive()) {
644
+ if (supplements && cd.source) {
645
+ supplements.add(cd.source);
646
+ }
647
+ return cd;
648
+ } if (!cd.language && this.isDisplay(cd)) {
642
649
  if (supplements && cd.source) {
643
650
  supplements.add(cd.source);
644
651
  }
@@ -3,6 +3,27 @@ const {Extensions} = require("./extensions");
3
3
  const {div} = require("../../library/html");
4
4
  const {getValuePrimitive} = require("../../library/utilities");
5
5
  const {getValueName} = require("../../library/utilities");
6
+ const {InvalidError} = require("./errors");
7
+
8
+ // Valid FHIR value sets used when validating values before rendering, so that
9
+ // illegal input produces an error that names the offending value rather than
10
+ // being silently rendered or causing a generic crash downstream.
11
+ const VALID_FILTER_OPS = new Set([
12
+ '=', 'is-a', 'descendent-of', 'is-not-a', 'regex', 'in', 'not-in',
13
+ 'generalizes', 'child-of', 'descendent-leaf', 'exists'
14
+ ]);
15
+ const VALID_CONCEPTMAP_RELATIONSHIPS = new Set([
16
+ 'related-to', 'equivalent', 'source-is-narrower-than-target',
17
+ 'source-is-broader-than-target', 'not-related-to'
18
+ ]);
19
+ const VALID_CONCEPTMAP_EQUIVALENCES = new Set([
20
+ 'relatedto', 'equivalent', 'equal', 'wider', 'subsumes', 'narrower',
21
+ 'specializes', 'inexact', 'unmatched', 'disjoint'
22
+ ]);
23
+ const VALID_CODESYSTEM_CONTENT = new Set([
24
+ 'not-present', 'example', 'fragment', 'complete', 'supplement'
25
+ ]);
26
+ const VALID_PUBLICATION_STATUS = new Set(['draft', 'active', 'retired', 'unknown']);
6
27
 
7
28
  /**
8
29
  * @typedef {Object} TerminologyLinkResolver
@@ -239,10 +260,12 @@ class Renderer {
239
260
  }
240
261
 
241
262
  renderMetadataLastUpdated(res, tbl) {
242
- if (res.meta?.version) {
263
+ if (res.meta?.lastUpdated) {
264
+ this._requireValidDate(res.meta.lastUpdated, 'meta.lastUpdated');
243
265
  let tr = tbl.tr();
244
- tr.td().b().tx(this.translate('RES_REND_UPDATED'));
245
- tr.td().tx(this.displayDate(res.meta.version));
266
+ // RES_REND_UPDATED is "Last updated: {0}" — supply the formatted date as
267
+ // the {0} parameter so the placeholder is substituted.
268
+ tr.td().b().tx(this.translate('RES_REND_UPDATED', [this.displayDate(res.meta.lastUpdated)]));
246
269
  }
247
270
  }
248
271
 
@@ -318,7 +341,7 @@ class Renderer {
318
341
  }
319
342
 
320
343
  renderLinkComma(x, uri) {
321
- let {desc, url} = this.linkResolver ? this.linkResolver.resolveURL(this.opContext, uri) : null;
344
+ let {desc, url} = (this.linkResolver ? this.linkResolver.resolveURL(this.opContext, uri) : null) || {};
322
345
  if (url) {
323
346
  x.commaItem(desc, url);
324
347
  } else {
@@ -328,10 +351,17 @@ class Renderer {
328
351
 
329
352
 
330
353
  async renderCoding(x, coding) {
354
+ if (coding === null || coding === undefined || typeof coding !== 'object') {
355
+ this._invalid('Coding', coding, 'invalid Coding');
356
+ }
357
+ if (coding.code !== undefined && coding.code !== null &&
358
+ (typeof coding.code !== 'string' || coding.code.trim() === '')) {
359
+ this._invalid('Coding.code', coding.code, 'invalid code');
360
+ }
331
361
  let {
332
362
  desc,
333
363
  url
334
- } = this.linkResolver ? await this.linkResolver.resolveCode(this.opContext, coding.system, coding.version, coding.code) : null;
364
+ } = (this.linkResolver ? await this.linkResolver.resolveCode(this.opContext, coding.system, coding.version, coding.code) : null) || {};
335
365
  if (url) {
336
366
  x.ah(url).tx(desc);
337
367
  } else {
@@ -347,10 +377,254 @@ class Renderer {
347
377
  return this.opContext.i18n.formatPhrasePlural(msgId, this.opContext.langs, num,[]);
348
378
  }
349
379
 
350
- async renderValueSet(vs) {
351
- if (vs.json) {
352
- vs = vs.json;
380
+ /**
381
+ * Determine the BCP-47 locale to use for formatting, derived from the user's
382
+ * requested languages (parsed from the Accept-Language header). The region
383
+ * subtag (e.g. US vs GB) is what selects day/month ordering and month names.
384
+ * Falls back to 'en-US' when no usable language is available.
385
+ * @returns {string} a BCP-47 locale tag accepted by Intl
386
+ * @private
387
+ */
388
+ _formatLocale() {
389
+ const langs = this.opContext && this.opContext.langs;
390
+ if (langs) {
391
+ for (const lang of langs) {
392
+ if (lang.language && lang.language !== '*') {
393
+ let tag = lang.language;
394
+ if (lang.script) tag += '-' + lang.script;
395
+ if (lang.region) tag += '-' + lang.region;
396
+ try {
397
+ if (Intl.DateTimeFormat.supportedLocalesOf(tag).length > 0) {
398
+ return tag;
399
+ }
400
+ // Locale is structurally valid but not supported by the runtime;
401
+ // Intl will still resolve it to a sensible fallback, so use it.
402
+ return tag;
403
+ } catch (_) {
404
+ // Structurally invalid tag — skip and try the next language.
405
+ }
406
+ }
407
+ }
408
+ }
409
+ return 'en-US';
410
+ }
411
+
412
+ // ── Validation helpers ──────────────────────────────────────────────────────
413
+ // These exist so that illegal input is reported with an error that names the
414
+ // offending field and value, rather than being silently rendered or causing a
415
+ // generic "Cannot read properties of null" style crash further down.
416
+
417
+ /**
418
+ * Render a value for inclusion in an error message, distinguishing null,
419
+ * undefined, objects, and primitives, and truncating long values.
420
+ * @private
421
+ */
422
+ _showValue(value) {
423
+ if (value === undefined) return 'undefined';
424
+ if (value === null) return 'null';
425
+ if (typeof value === 'object') {
426
+ let s;
427
+ try { s = JSON.stringify(value); } catch (_) { s = Object.prototype.toString.call(value); }
428
+ return s.length > 80 ? s.slice(0, 77) + '...' : s;
429
+ }
430
+ const s = String(value);
431
+ return `'${s.length > 80 ? s.slice(0, 77) + '...' : s}'`;
432
+ }
433
+
434
+ /**
435
+ * Throw an InvalidError that identifies the offending value and its location.
436
+ * @param {string} path - dotted path to the offending element (e.g. "ConceptMap.group[0].target[0].relationship")
437
+ * @param {*} value - the offending value
438
+ * @param {string} what - short description of what is wrong (e.g. "invalid ConceptMap relationship")
439
+ * @private
440
+ */
441
+ _invalid(path, value, what) {
442
+ throw new InvalidError(`${what} at ${path}: ${this._showValue(value)}`);
443
+ }
444
+
445
+ /**
446
+ * Validate, unwrap, and type-check a resource before rendering. Accepts either
447
+ * a raw resource object or a wrapper exposing `.json`. Throws an InvalidError
448
+ * naming the problem when the input is missing, not an object, or of the wrong
449
+ * resourceType.
450
+ * @param {*} res - the resource (or wrapper) to render
451
+ * @param {string} expectedType - the FHIR resourceType this method renders
452
+ * @param {string} method - the calling method name, for the error message
453
+ * @returns {object} the unwrapped resource object
454
+ * @private
455
+ */
456
+ _resolveResource(res, expectedType, method) {
457
+ if (res === null || res === undefined) {
458
+ throw new InvalidError(`${method}: no resource supplied (got ${res === null ? 'null' : 'undefined'})`);
459
+ }
460
+ if (typeof res !== 'object' || Array.isArray(res)) {
461
+ throw new InvalidError(`${method}: expected a ${expectedType} resource object but got ${Array.isArray(res) ? 'an array' : typeof res}`);
462
+ }
463
+ const r = (res.json !== undefined && res.json !== null) ? res.json : res;
464
+ if (r === null || typeof r !== 'object' || Array.isArray(r)) {
465
+ throw new InvalidError(`${method}: expected a ${expectedType} resource object but got ${this._showValue(r)}`);
353
466
  }
467
+ if (r.resourceType !== undefined && r.resourceType !== expectedType) {
468
+ throw new InvalidError(`${method}: expected resourceType '${expectedType}' but found ${this._showValue(r.resourceType)}`);
469
+ }
470
+ return r;
471
+ }
472
+
473
+ /**
474
+ * True when `value` is a syntactically valid FHIR date/dateTime/instant.
475
+ * Used both to validate (via _requireValidDate) and to drive display.
476
+ * @private
477
+ */
478
+ _isValidFhirDate(value) {
479
+ if (typeof value !== 'string') return false;
480
+ if (/^\d{4}$/.test(value)) return true;
481
+
482
+ let m = /^(\d{4})-(\d{2})$/.exec(value);
483
+ if (m) {
484
+ const y = Number(m[1]), mo = Number(m[2]);
485
+ const d = new Date(Date.UTC(y, mo - 1, 1));
486
+ return !isNaN(d.getTime()) && d.getUTCFullYear() === y && d.getUTCMonth() === mo - 1;
487
+ }
488
+
489
+ m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
490
+ if (m) {
491
+ const y = Number(m[1]), mo = Number(m[2]), da = Number(m[3]);
492
+ const d = new Date(Date.UTC(y, mo - 1, da));
493
+ return !isNaN(d.getTime()) && d.getUTCFullYear() === y &&
494
+ d.getUTCMonth() === mo - 1 && d.getUTCDate() === da;
495
+ }
496
+
497
+ if (value.includes('T')) {
498
+ const dt = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(\.\d+)?(Z|[+-]\d{2}:\d{2})$/.exec(value);
499
+ if (!dt) return false;
500
+ if (isNaN(new Date(value).getTime())) return false;
501
+ const mo = Number(dt[2]), da = Number(dt[3]), hh = Number(dt[4]), mi = Number(dt[5]), ss = Number(dt[6]);
502
+ return mo >= 1 && mo <= 12 && da >= 1 && da <= 31 && hh <= 23 && mi <= 59 && ss <= 60;
503
+ }
504
+ return false;
505
+ }
506
+
507
+ /**
508
+ * Require a value (when present) to be a valid FHIR date; otherwise throw an
509
+ * error naming the field and value. Empty/absent values are allowed and
510
+ * returned unchanged.
511
+ * @private
512
+ */
513
+ _requireValidDate(value, path) {
514
+ if (value === null || value === undefined || value === '') return value;
515
+ if (!this._isValidFhirDate(value)) {
516
+ this._invalid(path, value, 'invalid date');
517
+ }
518
+ return value;
519
+ }
520
+
521
+ /**
522
+ * Require a value to be a non-empty string code; otherwise throw an error
523
+ * naming the field and value.
524
+ * @private
525
+ */
526
+ _requireValidCode(value, path) {
527
+ if (typeof value !== 'string' || value.trim() === '') {
528
+ this._invalid(path, value, 'invalid code');
529
+ }
530
+ return value;
531
+ }
532
+
533
+ /**
534
+ * Require a value (when present) to be a member of an allowed set; otherwise
535
+ * throw an error naming the field and value.
536
+ * @private
537
+ */
538
+ _requireAllowed(value, allowed, path, what) {
539
+ if (value === null || value === undefined) return value;
540
+ if (!allowed.has(value)) {
541
+ this._invalid(path, value, what);
542
+ }
543
+ return value;
544
+ }
545
+
546
+ /**
547
+ * Format a FHIR date / dateTime / instant value into a human-readable string
548
+ * using the user's locale. The output precision follows the input precision:
549
+ * a year stays a year, a year-month becomes "Month Year", a full date becomes
550
+ * a localised date, and a dateTime/instant becomes a localised date and time.
551
+ *
552
+ * Unparseable values are returned unchanged so the renderer never throws on
553
+ * unexpected input.
554
+ *
555
+ * @param {string} value - a FHIR date, dateTime, or instant (e.g. "2024",
556
+ * "2024-03", "2024-03-15", "2024-03-15T10:30:00Z")
557
+ * @returns {string} the localised representation, or '' for empty input
558
+ */
559
+ displayDate(value) {
560
+ if (value === null || value === undefined || value === '') {
561
+ return '';
562
+ }
563
+ if (typeof value !== 'string') {
564
+ value = String(value);
565
+ }
566
+
567
+ const locale = this._formatLocale();
568
+
569
+ // Year only — nothing to localise.
570
+ if (/^\d{4}$/.test(value)) {
571
+ return value;
572
+ }
573
+
574
+ // Year-month → "Month Year".
575
+ let m = /^(\d{4})-(\d{2})$/.exec(value);
576
+ if (m) {
577
+ const year = Number(m[1]), month = Number(m[2]);
578
+ const d = new Date(Date.UTC(year, month - 1, 1));
579
+ // Reject values that Date silently rolled over (e.g. month 13).
580
+ if (isNaN(d.getTime()) || d.getUTCFullYear() !== year || d.getUTCMonth() !== month - 1) {
581
+ return value;
582
+ }
583
+ return new Intl.DateTimeFormat(locale, {
584
+ year: 'numeric', month: 'long', timeZone: 'UTC'
585
+ }).format(d);
586
+ }
587
+
588
+ // Full date → localised date.
589
+ m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
590
+ if (m) {
591
+ const year = Number(m[1]), month = Number(m[2]), day = Number(m[3]);
592
+ const d = new Date(Date.UTC(year, month - 1, day));
593
+ // Reject values that Date silently rolled over (e.g. 2024-02-30).
594
+ if (isNaN(d.getTime()) || d.getUTCFullYear() !== year ||
595
+ d.getUTCMonth() !== month - 1 || d.getUTCDate() !== day) {
596
+ return value;
597
+ }
598
+ return new Intl.DateTimeFormat(locale, {
599
+ year: 'numeric', month: 'long', day: 'numeric', timeZone: 'UTC'
600
+ }).format(d);
601
+ }
602
+
603
+ // dateTime / instant → localised date and time.
604
+ if (value.includes('T')) {
605
+ const d = new Date(value);
606
+ if (isNaN(d.getTime())) return value;
607
+ const hasTimezone = /(Z|[+-]\d{2}:\d{2})$/.test(value);
608
+ const opts = {
609
+ year: 'numeric', month: 'long', day: 'numeric',
610
+ hour: '2-digit', minute: '2-digit', second: '2-digit'
611
+ };
612
+ if (hasTimezone) {
613
+ // We don't know the user's timezone, so render the absolute instant in
614
+ // UTC and label it, rather than silently using the server's zone.
615
+ opts.timeZone = 'UTC';
616
+ opts.timeZoneName = 'short';
617
+ }
618
+ return new Intl.DateTimeFormat(locale, opts).format(d);
619
+ }
620
+
621
+ // Unrecognised format — return unchanged.
622
+ return value;
623
+ }
624
+
625
+ async renderValueSet(vs) {
626
+ vs = this._resolveResource(vs, 'ValueSet', 'renderValueSet');
627
+ this._requireAllowed(vs.status, VALID_PUBLICATION_STATUS, 'ValueSet.status', 'invalid status');
354
628
 
355
629
  let div_ = div();
356
630
  div_.h2().tx("Properties");
@@ -369,9 +643,9 @@ class Renderer {
369
643
  }
370
644
 
371
645
  async renderCodeSystem(cs, sourcePackage) {
372
- if (cs.json) {
373
- cs = cs.json;
374
- }
646
+ cs = this._resolveResource(cs, 'CodeSystem', 'renderCodeSystem');
647
+ this._requireAllowed(cs.status, VALID_PUBLICATION_STATUS, 'CodeSystem.status', 'invalid status');
648
+ this._requireAllowed(cs.content, VALID_CODESYSTEM_CONTENT, 'CodeSystem.content', 'invalid CodeSystem content');
375
649
 
376
650
  let div_ = div();
377
651
 
@@ -399,7 +673,7 @@ class Renderer {
399
673
  p.tx(" ");
400
674
  p.startCommaList("and");
401
675
  for (let ext of supplements) {
402
- this.renderLinkComma(p, ext);
676
+ this.renderLinkComma(p, getValuePrimitive(ext));
403
677
  }
404
678
  p.stopCommaList();
405
679
  p.tx(".");
@@ -447,6 +721,7 @@ class Renderer {
447
721
  li.tx(":");
448
722
  const ul = li.ul();
449
723
  for (let c of inc.concept) {
724
+ this._requireValidCode(c.code, 'ValueSet.compose.include.concept.code');
450
725
  const li = ul.li();
451
726
  const link = this.linkResolver ? await this.linkResolver.resolveCode(this.opContext, inc.system, inc.version, c.code) : null;
452
727
  if (link) {
@@ -467,6 +742,7 @@ class Renderer {
467
742
  li.startCommaList("and");
468
743
  for (let f of inc.filter) {
469
744
  let op = this.readFilterOp(f);
745
+ this._requireAllowed(op, VALID_FILTER_OPS, 'ValueSet.compose.include.filter.op', 'invalid filter operator');
470
746
  if (op == 'exists') {
471
747
  if (f.value == "true") {
472
748
  li.commaItem(f.property+" "+ this.translate('VALUE_SET_EXISTS'));
@@ -485,13 +761,15 @@ class Renderer {
485
761
  }
486
762
  li.stopCommaList();
487
763
  }
488
- } else {
764
+ } else if (inc.valueSet && inc.valueSet.length > 0) {
489
765
  li.tx(this.translatePlural(inc.valueSet.length, 'VALUE_SET_RULES_INC'));
490
766
  li.startCommaList("and");
491
767
  for (let vs of inc.valueSet) {
492
768
  this.renderLinkComma(li, vs);
493
769
  }
494
770
  li.stopCommaList();
771
+ } else {
772
+ this._invalid('ValueSet.compose.include', inc, 'invalid ValueSet include (must specify a system or at least one valueSet)');
495
773
  }
496
774
  }
497
775
 
@@ -713,6 +991,7 @@ class Renderer {
713
991
  }
714
992
 
715
993
  async addConceptRow(tbl, concept, level, cs, columnInfo) {
994
+ this._requireValidCode(concept.code, 'CodeSystem.concept.code');
716
995
  const tr = tbl.tr();
717
996
 
718
997
  // Apply styling for deprecated concepts
@@ -1304,9 +1583,8 @@ class Renderer {
1304
1583
  }
1305
1584
 
1306
1585
  async renderCapabilityStatement(cs) {
1307
- if (cs.json) {
1308
- cs = cs.json;
1309
- }
1586
+ cs = this._resolveResource(cs, 'CapabilityStatement', 'renderCapabilityStatement');
1587
+ this._requireAllowed(cs.status, VALID_PUBLICATION_STATUS, 'CapabilityStatement.status', 'invalid status');
1310
1588
 
1311
1589
  let div_ = div();
1312
1590
 
@@ -1575,9 +1853,8 @@ class Renderer {
1575
1853
  }
1576
1854
 
1577
1855
  async renderTerminologyCapabilities(tc) {
1578
- if (tc.json) {
1579
- tc = tc.json;
1580
- }
1856
+ tc = this._resolveResource(tc, 'TerminologyCapabilities', 'renderTerminologyCapabilities');
1857
+ this._requireAllowed(tc.status, VALID_PUBLICATION_STATUS, 'TerminologyCapabilities.status', 'invalid status');
1581
1858
 
1582
1859
  let div_ = div();
1583
1860
 
@@ -1675,9 +1952,8 @@ class Renderer {
1675
1952
  * metadata table (reusing renderMetadataTable), then group-by-group rendering.
1676
1953
  */
1677
1954
  async renderConceptMap(cm) {
1678
- if (cm.json) {
1679
- cm = cm.json;
1680
- }
1955
+ cm = this._resolveResource(cm, 'ConceptMap', 'renderConceptMap');
1956
+ this._requireAllowed(cm.status, VALID_PUBLICATION_STATUS, 'ConceptMap.status', 'invalid status');
1681
1957
 
1682
1958
  let div_ = div();
1683
1959
 
@@ -2080,8 +2356,12 @@ class Renderer {
2080
2356
  */
2081
2357
  renderConceptMapRelationship(tr, tgt) {
2082
2358
  if (tgt.relationship) {
2359
+ this._requireAllowed(tgt.relationship, VALID_CONCEPTMAP_RELATIONSHIPS,
2360
+ 'ConceptMap.group.element.target.relationship', 'invalid ConceptMap relationship');
2083
2361
  tr.td().tx(this.presentRelationshipCode(tgt.relationship));
2084
2362
  } else if (tgt.equivalence) {
2363
+ this._requireAllowed(tgt.equivalence, VALID_CONCEPTMAP_EQUIVALENCES,
2364
+ 'ConceptMap.group.element.target.equivalence', 'invalid ConceptMap equivalence');
2085
2365
  tr.td().tx(this.presentEquivalenceCode(tgt.equivalence));
2086
2366
  } else {
2087
2367
  tr.td().tx("(" + "equivalent" + ")");
@@ -2252,6 +2532,7 @@ class Renderer {
2252
2532
  return f.op;
2253
2533
  }
2254
2534
  }
2535
+
2255
2536
  }
2256
2537
 
2257
2538
  module.exports = { Renderer };
@@ -939,7 +939,10 @@ class Registry {
939
939
  }
940
940
 
941
941
  register(handler) {
942
- this.handlers.set(handler.code, handler);
942
+ // Key on getCode() — the interface every handler implements. Some handlers
943
+ // (CelsiusHandler/FahrenheitHandler) override getCode() without setting a
944
+ // `.code` field, so keying on `.code` would register them under `undefined`.
945
+ this.handlers.set(handler.getCode(), handler);
943
946
  }
944
947
  }
945
948
 
@@ -25,6 +25,52 @@ class ValueSet extends CanonicalResource {
25
25
  this.jsonObj = valueSetToR5(jsonObj, fhirVersion);
26
26
  this.validate();
27
27
  this.buildMaps();
28
+ // Precalculated at construction so callers (e.g. the resource cache) have a
29
+ // cheap O(1) sense of how large this resource is.
30
+ this._conceptCount = ValueSet._countConcepts(this.jsonObj);
31
+ }
32
+
33
+ /**
34
+ * Number of concepts this ValueSet carries: the enumerated concepts in its
35
+ * compose (include + exclude `concept` lists) plus any concepts in an inline
36
+ * expansion (`expansion.contains`, counted recursively). Note this counts the
37
+ * concepts *present in the resource*, not the (possibly unbounded) size of the
38
+ * value set's full expansion. Precalculated at construction time.
39
+ * @returns {number}
40
+ */
41
+ conceptCount() {
42
+ return this._conceptCount;
43
+ }
44
+
45
+ static _countConcepts(jsonObj) {
46
+ let n = 0;
47
+ const compose = jsonObj && jsonObj.compose;
48
+ if (compose) {
49
+ for (const inc of (compose.include || [])) {
50
+ n += Array.isArray(inc.concept) ? inc.concept.length : 0;
51
+ }
52
+ for (const exc of (compose.exclude || [])) {
53
+ n += Array.isArray(exc.concept) ? exc.concept.length : 0;
54
+ }
55
+ }
56
+ if (jsonObj && jsonObj.expansion) {
57
+ n += ValueSet._countContains(jsonObj.expansion.contains);
58
+ }
59
+ return n;
60
+ }
61
+
62
+ static _countContains(items) {
63
+ if (!Array.isArray(items)) {
64
+ return 0;
65
+ }
66
+ let n = 0;
67
+ for (const it of items) {
68
+ n++;
69
+ if (it && it.contains) {
70
+ n += ValueSet._countContains(it.contains);
71
+ }
72
+ }
73
+ return n;
28
74
  }
29
75
 
30
76
  /**