@pubid/pubid 0.1.20 → 0.1.22

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.
@@ -2,6 +2,7 @@ import { ParseFailed, parseGrammar } from "../../grammar/engine.js";
2
2
  import { PubidDate, Language, Iteration } from "../../model/component.js";
3
3
  import { isoGrammar } from "./grammar.js";
4
4
  import { IsoBundledIdentifier, IsoDirectives, IsoDirectivesSupplement, IsoEdition, IsoPublisher, IsoSupplementIdentifier, IsoTcDocument, isoClassForType, locateStage, } from "./model.js";
5
+ import { ISO_TYPED_STAGES } from "./stages.generated.js";
5
6
  /**
6
7
  * Port of lib/pubid/iso/{builder,normalizer}.rb. Class dispatch follows
7
8
  * the Ruby Builder: joint → Combined (unreachable by the corpus),
@@ -246,7 +247,14 @@ class IsoBuilder {
246
247
  }
247
248
  }
248
249
  const state = typeStateFromToken(s(tree["type_with_stage"]), "is");
249
- const typeKey = state?.entry.typeCode ?? "is";
250
+ // The explicit URN type token pins the class: a stage's abbreviation
251
+ // can overwrite type_with_stage with an IS-only stage ("WDA" for
252
+ // 90.93), and the document must stay a TR/TS (the Ruby builder reads
253
+ // the same document_type hint).
254
+ const documentType = s(tree["document_type"]);
255
+ const typeKey = documentType !== undefined
256
+ ? documentType.toLowerCase()
257
+ : state?.entry.typeCode ?? "is";
250
258
  if (typeKey === "dir-sup")
251
259
  return this.buildDirectivesSupplement(tree, state);
252
260
  if (typeKey === "dir")
@@ -334,8 +342,264 @@ function parseHuman(input) {
334
342
  }
335
343
  return new IsoBuilder().build(tree);
336
344
  }
345
+ // --- URN ingestion (lib/pubid/iso/urn_parser.rb) ----------------------------
346
+ // RFC 5141-bis typed-stage abbreviations → stage codes. "DIS"/"ADIS" are
347
+ // ISO ladder stages the reference maps; everything else mirrors the gem's
348
+ // TYPED_STAGE_REVERSE_MAP verbatim.
349
+ const URN_STAGE_ABBRS = {
350
+ WD: "wd", WDS: "wds", CD: "cd", CDV: "cdv", DIS: "dis", FDIS: "fdis",
351
+ PDAM: "pdam", DAM: "dam", FDAM: "fdamd", DCOR: "dcor", FDCOR: "fdcor",
352
+ CDTS: "cdts", DTS: "dts", FDTS: "fdts",
353
+ };
354
+ const URN_TYPE_TOKENS = new Set([
355
+ "tr", "ts", "pas", "guide", "dir", "dir-sup", "iwa-sup", "isp", "iwa", "r", "data",
356
+ ]);
357
+ const URN_SUPP_TYPES = new Set(["amd", "cor", "sup", "add"]);
358
+ // The URN type tokens are the gem's type keys; the ts leaf for ISO/R is
359
+ // keyed "rec".
360
+ const URN_TYPE_KEYS = { r: "rec" };
361
+ function urnStageByHarmonized(code) {
362
+ const candidates = ISO_TYPED_STAGES.filter((s) => s.harmonized?.includes(code));
363
+ // The gem prefers the IS family when a code is shared across families
364
+ // (pubid#570 — the registry order is load-order dependent there).
365
+ return candidates.find((s) => s.typeCode === "is") ?? candidates[0];
366
+ }
367
+ // When the URN gives a harmonized code and no type token, the document is
368
+ // an IS: prefer the non-published IS stages (PRF over the published IS for
369
+ // 60.00), then the stage covering the most codes (WD over WDS).
370
+ function urnIsStageByHarmonized(code) {
371
+ const candidates = ISO_TYPED_STAGES.filter((s) => s.typeCode === "is" && s.harmonized?.includes(code));
372
+ if (candidates.length === 0)
373
+ return undefined;
374
+ const nonPublished = candidates.filter((s) => s.stageCode !== "published");
375
+ const pool = nonPublished.length > 0 ? nonPublished : candidates;
376
+ let best = pool[0];
377
+ for (const candidate of pool) {
378
+ if ((candidate.harmonized?.length ?? 0) > (best.harmonized?.length ?? 0))
379
+ best = candidate;
380
+ }
381
+ return best;
382
+ }
383
+ // The URN carries parts as "10164:-5-1-1" or "9001-1"; split into
384
+ // number / part / subpart (a subpart keeps its dashes).
385
+ function parseUrnNumberPart(raw) {
386
+ if (raw === undefined)
387
+ return [undefined, undefined, undefined];
388
+ if (/^\w+:-/.test(raw)) {
389
+ const segments = raw.split(":-");
390
+ const tail = segments.slice(1).join("-").split("-");
391
+ return [
392
+ segments[0],
393
+ tail[0],
394
+ tail.length > 1 ? tail.slice(1).join("-") : undefined,
395
+ ];
396
+ }
397
+ if (raw.includes("-")) {
398
+ const segments = raw.split("-");
399
+ return [
400
+ segments[0],
401
+ segments[1],
402
+ segments.length > 2 ? segments.slice(2).join("-") : undefined,
403
+ ];
404
+ }
405
+ return [raw, undefined, undefined];
406
+ }
407
+ function parseIsoUrn(urn) {
408
+ if (!urn.startsWith("urn:iso:std:"))
409
+ throw new Error(`Invalid ISO URN: ${urn}`);
410
+ let parts = urn.slice("urn:iso:std:".length).split(":");
411
+ // A trailing "ser" marks an all-parts identifier.
412
+ let allParts = false;
413
+ if (parts[parts.length - 1] === "ser") {
414
+ parts = parts.slice(0, -1);
415
+ allParts = true;
416
+ }
417
+ const publishers = (parts.shift() ?? "").split("-").map((p) => p.toUpperCase());
418
+ let typeCode;
419
+ if (parts[0] !== undefined && URN_TYPE_TOKENS.has(parts[0].toLowerCase())) {
420
+ typeCode = parts.shift().toLowerCase();
421
+ }
422
+ let [number, part, subpart] = parseUrnNumberPart(parts.shift());
423
+ let year;
424
+ if (/^\d{4}$/.test(parts[0] ?? ""))
425
+ year = parts.shift();
426
+ // URN-style part notation can trail the year (":10164:2019:-22").
427
+ if ((parts[0] ?? "").startsWith("-")) {
428
+ const partStr = parts.shift();
429
+ [number, part, subpart] = parseUrnNumberPart(`${number}${partStr}`);
430
+ }
431
+ if (year === undefined && /^\d{4}$/.test(parts[0] ?? ""))
432
+ year = parts.shift();
433
+ let edition;
434
+ if ((parts[0] ?? "").startsWith("ed-"))
435
+ edition = parts.shift().slice(3);
436
+ let languages;
437
+ if (parts[0] !== undefined && !/^\d+$/.test(parts[0]) &&
438
+ !/^(amd|cor|sup|add|v\d+|stage-|ed-)/i.test(parts[0]) &&
439
+ !(parts[0].toUpperCase() in URN_STAGE_ABBRS) &&
440
+ !/^[A-Za-z]+\.\d+$/.test(parts[0])) {
441
+ languages = parts.shift().split(",").map((l) => l.toUpperCase()).join("/");
442
+ }
443
+ // Stage: either "stage-XX.XX[.vN]" (harmonized) or a typed abbreviation
444
+ // ("WD", "CD.2").
445
+ let stageCode;
446
+ let stageFromAbbr;
447
+ let stageIteration;
448
+ if ((parts[0] ?? "").startsWith("stage-")) {
449
+ const stageStr = parts.shift();
450
+ stageCode = stageStr.slice(6).replace(/\.v\d+$/i, "");
451
+ const iteration = /\.v(\d+)$/i.exec(stageStr);
452
+ if (iteration !== null)
453
+ stageIteration = iteration[1];
454
+ }
455
+ else if ((parts[0] !== undefined && parts[0].toUpperCase() in URN_STAGE_ABBRS) ||
456
+ (parts[0] !== undefined && /^[A-Za-z]+\.\d+$/.test(parts[0]) &&
457
+ (parts[0].toUpperCase().split(".")[0] ?? "") in URN_STAGE_ABBRS)) {
458
+ const stageAbbr = parts[0].toUpperCase();
459
+ // A supplement-family stage directly before its own supplement type
460
+ // token ("...:FDAM:amd:1:v1") names the SUPPLEMENT's stage: consuming
461
+ // it as the base's stage builds the base as a base-less supplement,
462
+ // which cannot render (the gem guards the same way).
463
+ const family = locateStage(stageAbbr.split(".")[0] ?? "")?.typeCode;
464
+ const supplementFollows = (family !== undefined &&
465
+ ["amd", "cor", "suppl", "add"].includes(family)) &&
466
+ parts.slice(1).some((token) => URN_SUPP_TYPES.has(token.toLowerCase()) &&
467
+ (URN_TYPE_KEYS[token.toLowerCase()] ?? token.toLowerCase()) === family);
468
+ if (!supplementFollows) {
469
+ parts.shift();
470
+ if (stageAbbr.includes(".")) {
471
+ const abbrPart = stageAbbr.split(".")[0];
472
+ const iterationPart = stageAbbr.split(".")[1];
473
+ stageCode = URN_STAGE_ABBRS[abbrPart];
474
+ stageFromAbbr = abbrPart;
475
+ stageIteration = iterationPart;
476
+ }
477
+ else {
478
+ stageCode = URN_STAGE_ABBRS[stageAbbr];
479
+ stageFromAbbr = stageAbbr;
480
+ }
481
+ }
482
+ }
483
+ // Supplements (amd, cor, sup, add), left-to-right: each wraps the previous.
484
+ const supplements = [];
485
+ while (parts.length > 0) {
486
+ const supp = {};
487
+ if ((parts[0] ?? "").startsWith("stage-")) {
488
+ supp.stage = parts.shift().slice(6).replace(/\.v\d+$/i, "");
489
+ }
490
+ else if (parts[0] !== undefined && parts[0].toUpperCase() in URN_STAGE_ABBRS) {
491
+ supp.stage = URN_STAGE_ABBRS[parts.shift().toUpperCase()];
492
+ }
493
+ if (parts[0] !== undefined && URN_SUPP_TYPES.has(parts[0].toLowerCase())) {
494
+ supp.type = parts.shift().toLowerCase();
495
+ }
496
+ if (/^\d+$/.test(parts[0] ?? "")) {
497
+ if (/^\d{4}$/.test(parts[0]))
498
+ supp.year = parts.shift();
499
+ else
500
+ supp.number = parts.shift();
501
+ }
502
+ if ((parts[0] ?? "").startsWith("v")) {
503
+ const version = parts.shift().slice(1);
504
+ supp.number = version.split(".")[0];
505
+ }
506
+ if (supp.year === undefined && /^\d{4}$/.test(parts[0] ?? ""))
507
+ supp.year = parts.shift();
508
+ if (parts[0] !== undefined && !/^\d+$/.test(parts[0]) &&
509
+ !/^(amd|cor|sup|add|v\d+|stage-)/i.test(parts[0])) {
510
+ supp.languages = parts.shift().split(",").map((l) => l.toUpperCase()).join("/");
511
+ }
512
+ supplements.push(supp);
513
+ }
514
+ // Builder tree, mirroring the gem's build_identifier.
515
+ const base = {};
516
+ if (publishers[0] !== undefined && publishers[0] !== "") {
517
+ base["publisher"] = publishers[0];
518
+ if (publishers.length > 1) {
519
+ base["copublishers"] = publishers.slice(1).map((c) => ({ copublisher: c }));
520
+ }
521
+ }
522
+ if (part !== undefined || subpart !== undefined) {
523
+ base["number_with_part"] = [number, part, subpart].filter((p) => p !== undefined).join("-");
524
+ }
525
+ else if (number !== undefined) {
526
+ base["number"] = number;
527
+ }
528
+ if (year !== undefined)
529
+ base["date"] = year;
530
+ if (edition !== undefined)
531
+ base["edition"] = edition;
532
+ if (typeCode !== undefined && typeCode !== "is") {
533
+ base["type_with_stage"] = typeCode.toUpperCase();
534
+ // Pin the document class against the stage block below overwriting
535
+ // type_with_stage (the Ruby builder reads the same hint).
536
+ base["document_type"] = (URN_TYPE_KEYS[typeCode] ?? typeCode).toUpperCase();
537
+ }
538
+ if (stageCode !== undefined) {
539
+ let stage;
540
+ if (stageFromAbbr !== undefined) {
541
+ stage = locateStage(stageFromAbbr) ??
542
+ ISO_TYPED_STAGES.find((s) => s.stageCode === stageCode);
543
+ }
544
+ else if (typeCode === undefined || typeCode === "is") {
545
+ stage = urnIsStageByHarmonized(stageCode);
546
+ // FCD is a legacy spelling of the DIS stage in URN form.
547
+ if (stageCode === "40.00" && stage?.stageCode === "fcd") {
548
+ stage = ISO_TYPED_STAGES.find((s) => s.stageCode === "dis" && s.harmonized?.includes("40.00")) ?? stage;
549
+ }
550
+ }
551
+ stage ??= urnStageByHarmonized(stageCode);
552
+ if (stage !== undefined)
553
+ base["type_with_stage"] = stage.abbr[0] ?? "";
554
+ else
555
+ base["stage"] = stageCode;
556
+ if (stageIteration !== undefined)
557
+ base["stage_iteration"] = stageIteration;
558
+ }
559
+ if (languages !== undefined)
560
+ base["languages"] = languages;
561
+ let tree = base;
562
+ for (const supp of supplements) {
563
+ // A bare language token with no supplement identity names the BASE
564
+ // document (the gem merges it the same way — a wrapper would drop it).
565
+ if (supp.type === undefined && supp.stage === undefined &&
566
+ supp.number === undefined && supp.year === undefined &&
567
+ supp.languages !== undefined) {
568
+ base["languages"] = supp.languages;
569
+ continue;
570
+ }
571
+ const suppTree = {};
572
+ if (supp.stage !== undefined) {
573
+ const stage = ISO_TYPED_STAGES.find((s) => s.stageCode === supp.stage);
574
+ suppTree["type_with_stage"] = stage !== undefined
575
+ ? stage.abbr[0] ?? ""
576
+ : supp.stage.toUpperCase();
577
+ }
578
+ if (supp.type !== undefined && suppTree["type_with_stage"] === undefined) {
579
+ suppTree["type_with_stage"] = supp.type.toUpperCase();
580
+ }
581
+ if (supp.number !== undefined)
582
+ suppTree["number"] = supp.number;
583
+ if (supp.year !== undefined)
584
+ suppTree["date"] = supp.year;
585
+ if (supp.languages !== undefined)
586
+ suppTree["languages"] = supp.languages;
587
+ tree = { base: tree, ...suppTree };
588
+ }
589
+ const identifier = new IsoBuilder().build(tree);
590
+ if (allParts) {
591
+ identifier.all_parts = true;
592
+ }
593
+ return identifier;
594
+ }
337
595
  export function isoGrammarImplementation() {
338
596
  return {
597
+ // Inverse of the ISO UrnGenerator (lib/pubid/iso/urn_parser.rb): the
598
+ // reference rebuilds a builder hash straight from the positional URN
599
+ // fields, so the port does the same against the shared IsoBuilder.
600
+ parseUrn(urn) {
601
+ return parseIsoUrn(urn);
602
+ },
339
603
  parse(input) {
340
604
  return parseHuman(input);
341
605
  },
@@ -61,7 +61,7 @@ export declare abstract class IsoIdentifier extends BaseIdentifier {
61
61
  protected stageString(): string;
62
62
  /** Render with an EXPLICIT long-format flag (the supplement
63
63
  * renderer propagates its own context to the base). */
64
- renderWithFormat(formatLong: boolean): string;
64
+ renderWithFormat(formatLong: boolean, withLanguages?: boolean): string;
65
65
  /** Ruby SupplementRenderer renders the base with the wrapper's
66
66
  * detected context (stage_format_long propagates down the chain;
67
67
  * with_date being non-nil pins it against re-detection — a nested
@@ -119,7 +119,7 @@ export class IsoIdentifier extends BaseIdentifier {
119
119
  }
120
120
  /** Render with an EXPLICIT long-format flag (the supplement
121
121
  * renderer propagates its own context to the base). */
122
- renderWithFormat(formatLong) {
122
+ renderWithFormat(formatLong, withLanguages = true) {
123
123
  let result = "";
124
124
  if (this.typeKey() === "iwa") {
125
125
  const stage = this.typedStageState === undefined ? "" : stageAbbr(this.typedStageState.entry, formatLong);
@@ -141,7 +141,8 @@ export class IsoIdentifier extends BaseIdentifier {
141
141
  const num = this.numberPortion(true);
142
142
  if (num !== "")
143
143
  result += result === "" ? num : ` ${num}`;
144
- result += this.languagePortion(this.languageSingle());
144
+ if (withLanguages)
145
+ result += this.languagePortion(this.languageSingle());
145
146
  if (this.all_parts === true)
146
147
  result += " (all parts)";
147
148
  return result;
@@ -152,7 +153,11 @@ export class IsoIdentifier extends BaseIdentifier {
152
153
  * amendment renders "AMD" under a "CD Cor" top but "Amd" under a
153
154
  * "Cor." top). */
154
155
  renderAsSupplementBase(long) {
155
- return this.renderWithFormat(long);
156
+ // The reference re-renders a nested base from the four propagated
157
+ // options alone, and that rebuilt context defaults to
158
+ // with_language_code :none — only the outermost identifier renders
159
+ // its own language portion.
160
+ return this.renderWithFormat(long, false);
156
161
  }
157
162
  stageStringFor(formatLong) {
158
163
  const ts = this.typedStageState;
@@ -618,12 +623,15 @@ export class IsoSupplementIdentifier extends IsoSingleIdentifier {
618
623
  });
619
624
  static mappings = SUPPLEMENT_MAPPINGS;
620
625
  render() {
621
- return this.renderSupplement(this.stageFormatLong());
626
+ return this.renderSupplement(this.stageFormatLong(), false);
622
627
  }
623
628
  renderAsSupplementBase(long) {
624
- return this.renderSupplement(long);
629
+ return this.renderSupplement(long, true);
625
630
  }
626
- renderSupplement(long) {
631
+ // The reference renders a nested base from a rebuilt context whose
632
+ // with_language_code defaults to :none, so only the outermost
633
+ // identifier of a chain renders its language portion.
634
+ renderSupplement(long, asBase) {
627
635
  const base = this.base;
628
636
  const baseStr = base === undefined ? "" : base.renderAsSupplementBase(long);
629
637
  const ts = this.typedStageState;
@@ -635,7 +643,8 @@ export class IsoSupplementIdentifier extends IsoSingleIdentifier {
635
643
  result += " ";
636
644
  result += num;
637
645
  }
638
- result += this.languagePortion(this.languageSingle());
646
+ if (!asBase)
647
+ result += this.languagePortion(this.languageSingle());
639
648
  return result;
640
649
  }
641
650
  }
@@ -688,7 +688,10 @@ function nistClass(spec) {
688
688
  }
689
689
  renderMr() {
690
690
  if (spec.style === "fips") {
691
- let result = this.seriesCode() ?? "";
691
+ // The MR form keeps a parsed publisher ("NIST.FIPS.197-upd1" is the
692
+ // DOI suffix NIST publishes); a bare FIPS invents none.
693
+ const series = this.seriesCode() ?? "";
694
+ let result = this.publisher !== undefined ? `${this.publisher}.${series}` : series;
692
695
  if (this.number !== undefined)
693
696
  result += `.${this.number}`;
694
697
  if (this.part instanceof NistPart)
@@ -1,10 +1,36 @@
1
1
  import { splitParts, withYear } from "./types.js";
2
- import { grammarImplementation } from "../flavors/index.js";
3
- import { NationalAdoption } from "../flavors/evs/model.js";
4
2
  // Each parser mirrors lib/pubid/<flavor>/urn_parser.rb#parse_urn exactly —
5
3
  // including its lossiness (dropped segments stay dropped) and its error
6
4
  // cases. The parity fixture (test/fixtures/urn-parity.json) pins the
7
5
  // reference behavior for ~93k corpus URNs.
6
+ // The adopted CEN spine consumes its own supplements; their urn segments
7
+ // carry the consolidation marker ("plus" = "+A1", its absence = "/A1") —
8
+ // cen_cenelec/urn_generator.rb's supplement_segments.
9
+ function adoptedSupplement(parts) {
10
+ let faces = "";
11
+ while (["plus", "slash", "amd", "cor", "frag"].includes(parts[0] ?? "")) {
12
+ let sep = "/";
13
+ let kind;
14
+ if (parts[0] === "plus" || parts[0] === "slash") {
15
+ sep = parts.shift() === "plus" ? "+" : "/";
16
+ kind = parts.shift();
17
+ }
18
+ else {
19
+ kind = parts.shift();
20
+ }
21
+ const num = /^\d{1,3}$/.test(parts[0] ?? "") ? parts.shift() : undefined;
22
+ const yr = /^\d{4}$/.test(parts[0] ?? "") ? parts.shift() : undefined;
23
+ const iter = num ?? "";
24
+ const year = yr !== undefined ? `:${yr}` : "";
25
+ if (kind === "amd")
26
+ faces += `${sep}A${iter}${year}`;
27
+ else if (kind === "cor")
28
+ faces += `${sep}AC${iter}${year}`;
29
+ else
30
+ faces += ` FRAG${iter}`;
31
+ }
32
+ return faces;
33
+ }
8
34
  export const URN_PARSERS = {
9
35
  // lib/pubid/ansi/urn_parser.rb
10
36
  ansi: {
@@ -459,64 +485,111 @@ export const URN_PARSERS = {
459
485
  return parse(text);
460
486
  },
461
487
  },
462
- // lib/pubid/evs/urn_parser.rb — EN [org] number[:year] with an optional
463
- // amd/cor iteration tail; the rebuilt text is parsed as a CEN adoption.
488
+ // lib/pubid/evs/urn_parser.rb — reassemble the identifier's human text
489
+ // (draft prefix, adopted org, national annex, language, supplements)
490
+ // and delegate to the EVS grammar, exactly like the reference.
464
491
  evs: {
465
492
  prefix: "urn:evs:",
466
493
  reconstruct(body, parse) {
467
494
  const parts = splitParts(body);
468
- const type = parts.shift() ?? "";
469
- if (type.toLowerCase() !== "en") {
470
- throw new Error(`unsupported type in ${JSON.stringify(body)}`);
471
- }
495
+ const draft = parts[0] === "pr";
496
+ if (draft)
497
+ parts.shift();
472
498
  const orgs = {
473
499
  iso: "ISO",
474
500
  "iso-iec": "ISO/IEC",
475
501
  iec: "IEC",
476
502
  cispr: "CISPR",
477
503
  };
478
- const orgParts = [];
479
- while (parts[0] !== undefined && /^[a-z][a-z-]*$/.test(parts[0])) {
480
- orgParts.push(parts.shift());
504
+ const types = { ts: "TS", tr: "TR" };
505
+ // The trailing language and the EVS-level national annex peel
506
+ // before the body dispatch — both are EVS-level, never CEN
507
+ // segments.
508
+ let language;
509
+ const last = parts[parts.length - 1];
510
+ if (parts.length > 1 && last !== undefined && /^(et-en|et|en)$/.test(last)) {
511
+ language = parts.pop();
481
512
  }
482
- let text = "EN";
483
- if (orgParts.length) {
484
- const org = orgs[orgParts.join("-")];
485
- if (org === undefined)
486
- throw new Error(`unknown adopted org in ${JSON.stringify(body)}`);
487
- text += ` ${org}`;
513
+ let naPresent = false;
514
+ let naYear;
515
+ const naIndex = parts.indexOf("na");
516
+ if (naIndex >= 0) {
517
+ parts.splice(naIndex, 1);
518
+ naPresent = true;
519
+ if (/^\d{4}$/.test(parts[naIndex] ?? ""))
520
+ naYear = parts.splice(naIndex, 1)[0];
488
521
  }
489
- const number = parts.shift();
490
- if (number === undefined)
491
- throw new Error(`missing number in ${JSON.stringify(body)}`);
492
- text += ` ${number}`;
493
- const year = parts.shift();
494
- if (year)
495
- text += `:${year}`;
496
- if (parts.length) {
497
- const yearTail = parts[parts.length - 1]?.match(/^\d{4}$/) ? parts.pop() : undefined;
498
- const kind = parts.shift();
499
- const iteration = parts.shift();
500
- if (kind !== "amd" && kind !== "cor") {
501
- throw new Error(`malformed supplement in ${JSON.stringify(body)}`);
522
+ const head = parts.shift() ?? "";
523
+ let text = draft ? "prEVS" : "EVS";
524
+ if (/^en$/i.test(head)) {
525
+ text += "-EN";
526
+ const orgParts = [];
527
+ while (parts[0] !== undefined && /^[a-z][a-z-]*$/.test(parts[0])) {
528
+ orgParts.push(parts.shift());
502
529
  }
503
- if (iteration === undefined || parts.length) {
504
- throw new Error(`malformed supplement in ${JSON.stringify(body)}`);
530
+ if (orgParts.length) {
531
+ const org = orgs[orgParts.join("-")];
532
+ if (org === undefined)
533
+ throw new Error(`unknown adopted org in ${JSON.stringify(body)}`);
534
+ text += ` ${org}`;
505
535
  }
506
- text += kind === "amd" ? `/A${iteration}` : `/AC${iteration}`;
507
- if (yearTail)
508
- text += `:${yearTail}`;
536
+ const number = parts.shift();
537
+ if (number === undefined)
538
+ throw new Error(`missing number in ${JSON.stringify(body)}`);
539
+ text += ` ${number}`;
540
+ const year = parts.shift();
541
+ if (year !== undefined)
542
+ text += `:${year}`;
543
+ text += adoptedSupplement(parts);
544
+ }
545
+ else if (head === "ts" || head === "tr") {
546
+ const number = parts.shift();
547
+ const year = parts.shift();
548
+ text += `-${types[head]} ${number}`;
549
+ if (year !== undefined)
550
+ text += `:${year}`;
551
+ }
552
+ else if (head === "iso" || head === "iec" || head === "cispr") {
553
+ const org = orgs[head];
554
+ const number = parts.shift();
555
+ let part;
556
+ if (/^\d+$/.test(parts[0] ?? "") && !/^\d{4}$/.test(parts[0]) && parts.length > 1) {
557
+ part = parts.shift();
558
+ }
559
+ const year = parts.shift();
560
+ text += `-${org} ${number}`;
561
+ if (part !== undefined)
562
+ text += `-${part}`;
563
+ if (year !== undefined)
564
+ text += `:${year}`;
565
+ }
566
+ else if (/^\d/.test(head)) {
567
+ const number = head;
568
+ const year = parts.shift();
569
+ text += ` ${number}`;
570
+ if (year !== undefined)
571
+ text += `:${year}`;
572
+ }
573
+ else {
574
+ throw new Error(`unsupported shape in ${JSON.stringify(body)}`);
509
575
  }
510
- // The reference constructs NationalAdoption around a parsed CEN
511
- // base (lib/pubid/evs/urn_parser.rb) rather than re-parsing the
512
- // rebuilt text as EVS.
513
- const cen = grammarImplementation("cen_cenelec");
514
- const base = cen.parse(text);
515
- return new NationalAdoption({ base });
576
+ if (parts.length)
577
+ throw new Error(`trailing segments in ${JSON.stringify(body)}`);
578
+ if (naPresent) {
579
+ text += "+NA";
580
+ if (naYear !== undefined)
581
+ text += `:${naYear}`;
582
+ }
583
+ if (language !== undefined)
584
+ text += ` ${language}`;
585
+ return parse(text);
516
586
  },
517
587
  },
518
- // lib/pubid/nist/urn_parser.rb — type token upcased via the reference
519
- // map; ".supp" stripped; ".r<N>" revision suffix split off the payload.
588
+ // lib/pubid/nist/urn_parser.rb — the generator dot-joins the identity's
589
+ // fields after the number; reassemble the human spelling the flavor
590
+ // parser reads (edition/volume glue onto the number, a revision glues
591
+ // after them, ".supp" marks supplementability, ".supp-<v>" is "sup<v>",
592
+ // insert/errata glue).
520
593
  nist: {
521
594
  prefix: "urn:nist:",
522
595
  reconstruct(body, parse) {
@@ -530,17 +603,75 @@ export const URN_PARSERS = {
530
603
  const typeToken = parts[0] ?? "";
531
604
  const payload = parts[1] ?? "";
532
605
  const label = typeUpcase[typeToken.toLowerCase()] ?? typeToken.toUpperCase();
533
- const stripped = payload.replace(/\.supp$/, "");
534
- const revMatch = stripped.match(/^(.*)\.(r\d+)$/i);
535
- const code = revMatch ? revMatch[1] : stripped;
536
- const revision = revMatch ? revMatch[2] : undefined;
606
+ const tokens = payload.split(".");
607
+ // Ruby's String#split drops trailing empty fields; JS keeps them, and a
608
+ // trailing "." would become a stray marker that the flavor rejects.
609
+ while (tokens.length > 0 && tokens[tokens.length - 1] === "")
610
+ tokens.pop();
611
+ const code = tokens.shift() ?? "";
612
+ let marker = "";
613
+ let part;
614
+ let edition;
615
+ let volume;
616
+ let revision;
617
+ let supplement;
618
+ for (const token of tokens) {
619
+ const supp = /^supp-(.+)$/is.exec(token);
620
+ if (/^r\d+$/i.test(token))
621
+ revision = token;
622
+ else if (/^e\w+$/i.test(token))
623
+ edition = token;
624
+ else if (/^v\d+$/i.test(token))
625
+ volume = token;
626
+ else if (supp !== null)
627
+ supplement = supp[1];
628
+ else if (token === "supp")
629
+ continue; // supplementability marker
630
+ else if (token === "insert" || token === "errata")
631
+ marker += token;
632
+ else if (/^[A-Z]$/.test(token))
633
+ part = token; // part letter, glued to the code
634
+ else if (/^[A-Z][a-z]+\d{4}$/.test(token)) {
635
+ // the edition's additional text ("13e2.June1908")
636
+ if (edition !== undefined)
637
+ edition = `${edition}.${token}`;
638
+ }
639
+ else
640
+ marker += `.${token}`;
641
+ }
537
642
  // The NBS-era series carry the NBS imprint in their canonical form
538
643
  // ("NBS CSM 1") — the rebuild names the publisher the document
539
644
  // carries, or the flavor parse rejects its own URN's rebuild.
540
645
  const publisher = typeToken.toLowerCase() === "csm" ? "NBS" : "NIST";
541
- let text = `${publisher} ${label} ${code}`;
542
- if (revision)
543
- text += revision;
646
+ let text = `${publisher} ${label} ${code}${part ?? ""}${marker}`;
647
+ text += `${edition ?? ""}${volume ?? ""}${revision ?? ""}`;
648
+ if (supplement !== undefined)
649
+ text += `sup${supplement}`;
650
+ return parse(text);
651
+ },
652
+ },
653
+ // lib/pubid/easc/urn_parser.rb — "urn:easc:<series>[:v]:<number>[:<year>]";
654
+ // the human face renders the Cyrillic series label
655
+ // ("ПМГ 02-2008", "ПМГ В 31-2001", "РМГ 151-2025").
656
+ easc: {
657
+ prefix: "urn:easc:",
658
+ reconstruct(body, parse) {
659
+ const parts = body.split(":");
660
+ const series = parts.shift() ?? "";
661
+ if (series === "") {
662
+ throw new Error(`EASC URN missing series: ${JSON.stringify(body)}`);
663
+ }
664
+ const label = series.toUpperCase() === "PMG" ? "ПМГ" : "РМГ";
665
+ let rest = parts;
666
+ let separator = " ";
667
+ if (/^v$/i.test(rest[0] ?? "")) {
668
+ separator = " В ";
669
+ rest = rest.slice(1);
670
+ }
671
+ const number = rest[0] ?? "";
672
+ let text = `${label}${separator}${number}`;
673
+ if (rest[1] !== undefined)
674
+ text += `-${rest[1]}`;
544
675
  return parse(text);
545
676
  },
546
677
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pubid/pubid",
3
- "version": "0.1.20",
3
+ "version": "0.1.22",
4
4
  "description": "TypeScript PubID identifiers: resolution, normalization and canonical hashes for every identifier in the pubid-testsuite corpus (41 standards bodies), with a conformance runner mirroring the Ruby reference",
5
5
  "type": "module",
6
6
  "license": "MIT",