@pubid/pubid 0.1.5 → 0.1.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.
package/README.md CHANGED
@@ -87,12 +87,28 @@ The conformance gate enforces corpus mode over the whole testsuite on
87
87
  every CI run: 42/42 flavors, canonical hash, human form, URN, alias
88
88
  normalization, negative rejection and deserialize idempotency.
89
89
 
90
- Grammar-backed parsing (novel identifiers beyond the published corpus)
91
- lands wave by wave and takes over per flavor — the same gate then
92
- enforces parity, so a wave can never regress below the floor corpus
93
- mode set (waves: core model → parser engine → builder → normalizer →
94
- renderer → urn → iso → iec → …). **No wave has shipped yet**; until one
95
- does, every flavor is corpus-only.
90
+ ## Grammar mode: open-universe runtime parsing
91
+
92
+ All 42 flavors are grammar-backed and ship the wave plan end to end
93
+ (core model → parser engine → builder → normalizer → renderer → urn →
94
+ per-flavor grammars). Grammar implementations parse real-world
95
+ identifiers far beyond the published corpus — novel spellings, unseen
96
+ numbers, live catalogues — and the conformance gate still enforces
97
+ parity with the corpus floor on every run, so a grammar can never
98
+ regress below what corpus mode set:
99
+
100
+ ```ts
101
+ import { grammarImplementation } from "@pubid/pubid";
102
+
103
+ const oiml = grammarImplementation("oiml")!;
104
+ oiml.parse("OIML R 60-1:2021"); // any published identifier
105
+ oiml.parse(oiml.parse("OIML R 117-1").toUrn()!); // URNs ingest back
106
+ ```
107
+
108
+ `grammarImplementation` is exported from the package root. URN ingestion
109
+ ships per flavor where the Ruby reference implements a URN parser
110
+ (oiml today; the remaining flavors throw a clear "not yet supported"
111
+ error naming this issue until their ports land).
96
112
 
97
113
  ## Development
98
114
 
Binary file
@@ -7,6 +7,9 @@
7
7
  export interface FlavorImplementation {
8
8
  /** Parse a canonical human form; must throw on a bad input. */
9
9
  parse(input: string): Identifier;
10
+ /** Inverse of toUrn for flavors whose reference implements a URN
11
+ * parser; when absent, "urn:" inputs throw a not-supported error. */
12
+ parseUrn?(urn: string): Identifier;
10
13
  }
11
14
  export interface Identifier {
12
15
  /** The canonical serialized form - compared byte-exactly against the corpus identifier record. */
@@ -422,6 +422,45 @@ class BipmBuilder {
422
422
  export function bipmGrammarImplementation() {
423
423
  const builder = new BipmBuilder();
424
424
  return {
425
+ // Inverse of BipmUrnGenerator (lib/pubid/bipm/urn_parser.rb): rebuild
426
+ // the identifier DIRECTLY from the URN fields — the printed forms carry
427
+ // surface detail (meeting ordinals, French wording) the URN omits.
428
+ parseUrn(urn) {
429
+ const body = urn.replace(/^urn:bipm:/, "");
430
+ // Keep trailing empty segments (gem splits with -1).
431
+ const parts = body.split(":");
432
+ const presence = (v) => v === undefined || v === "" ? undefined : v;
433
+ if (parts[0] === "metrologia") {
434
+ return new BipmMetrologiaArticle({
435
+ number: presence(parts[1]),
436
+ issue: parts[2],
437
+ article: parts[3],
438
+ });
439
+ }
440
+ if (parts[0] === "si-brochure") {
441
+ return new BipmSiBrochure({
442
+ number: presence(parts[2]),
443
+ language: (parts[1] ?? "").toUpperCase(),
444
+ edition: parts[2],
445
+ version: parts[3],
446
+ years: parts[4],
447
+ });
448
+ }
449
+ const group = (parts[0] ?? "").toUpperCase();
450
+ if (parts[1] === "meeting") {
451
+ return new BipmMeeting({
452
+ group,
453
+ number: parts[2] ?? "",
454
+ year: parts[3] ? Number(parts[3]) : undefined,
455
+ });
456
+ }
457
+ return new BipmCommitteeDocument({
458
+ group,
459
+ type_code: presence(parts[1])?.toUpperCase(),
460
+ number: presence(parts[2]),
461
+ year: parts[3] ? Number(parts[3]) : undefined,
462
+ });
463
+ },
425
464
  parse(input) {
426
465
  const normalized = UPDATE_CODES[input] ?? input;
427
466
  const tree = parseGrammar(bipmGrammar, normalized);
@@ -1,4 +1,5 @@
1
1
  import { ParseFailed, parseGrammar } from "../../grammar/engine.js";
2
+ import { TYPED_STAGES } from "./model.js";
2
3
  import { Language, PubidDate } from "../../model/component.js";
3
4
  import { iecGrammar, preprocessIec } from "./grammar.js";
4
5
  import { locateStage, IEC_CLASSES, } from "./model.js";
@@ -493,6 +494,27 @@ for (const klass of [
493
494
  export function iecGrammarImplementation() {
494
495
  const builder = new IecBuilder();
495
496
  return {
497
+ // Inverse of the IEC UrnGenerator (lib/pubid/iec/urn_parser.rb):
498
+ // reassemble the positional URN fields into a text code, parse it, then
499
+ // attach languages and the all-parts wrap.
500
+ parseUrn(urn) {
501
+ const parsed = urnToCode(urn);
502
+ if (!parsed)
503
+ throw new Error(`Invalid IEC URN: ${urn}`);
504
+ const [code, lang, allParts] = parsed;
505
+ // The reference parses the unprefixed rebuild and its grammar
506
+ // defaults the IEC publisher; the ts grammar needs it explicit.
507
+ const id = this.parse(code);
508
+ if (lang && lang !== "" && "languages" in id) {
509
+ id.languages = lang
510
+ .split("-")
511
+ .map((c) => new Language({ code: c }));
512
+ }
513
+ if (allParts) {
514
+ id.all_parts = true;
515
+ }
516
+ return id;
517
+ },
496
518
  parse(input) {
497
519
  let tree;
498
520
  try {
@@ -505,3 +527,61 @@ export function iecGrammarImplementation() {
505
527
  },
506
528
  };
507
529
  }
530
+ // Port of Relaton::Iec.urn_to_code + the slot helpers
531
+ // (lib/pubid/iec/urn_parser.rb): reassemble the positional URN fields into
532
+ // a text code. Returns [code, language, allParts].
533
+ function urnToCode(urn) {
534
+ const fields = urn.toUpperCase().split(":");
535
+ if (fields.length < 5)
536
+ return undefined;
537
+ const [head, num, date, type, deliv, lang] = fields.slice(3, 9);
538
+ let allParts = false;
539
+ let code = head.replaceAll("-", "/");
540
+ const typeCode = typeSlotToCode(type);
541
+ if (typeCode !== "")
542
+ code += ` ${typeCode}`;
543
+ code += ` ${num}`;
544
+ if (date !== undefined && date !== "")
545
+ code += `:${date}`;
546
+ code += adjunctToCode(fields.slice(9));
547
+ if ((deliv ?? "").toLowerCase() === "ser") {
548
+ allParts = true;
549
+ }
550
+ else if (deliv !== undefined) {
551
+ const edition = editionSlotToCode(deliv);
552
+ if (edition)
553
+ code += ` ${edition}`;
554
+ else if (deliv !== "")
555
+ code += ` ${deliv}`;
556
+ }
557
+ return [code, lang?.toLowerCase(), allParts];
558
+ }
559
+ // The type slot holds a legacy type token ("TS"), a stage
560
+ // ("STAGE-10.20"), or both ("TS-STAGE-50.00"); a stage is written back as
561
+ // the abbreviation resolved from the typed-stage registry.
562
+ function typeSlotToCode(type) {
563
+ if (type === undefined || type === "")
564
+ return "";
565
+ const match = type.match(/^([A-Z]+-)?STAGE-([\d.]+)$/);
566
+ if (!match)
567
+ return type;
568
+ const stageType = (match[1] ?? "IS-").replace("-", "").toLowerCase();
569
+ const stage = TYPED_STAGES.find((t) => t.typeCode === stageType && (t.harmonized ?? "") === match[2]);
570
+ return stage ? stage.abbr : "";
571
+ }
572
+ // "ED-7" in the deliverable slot is an edition, not a deliverable code.
573
+ function editionSlotToCode(deliv) {
574
+ const match = deliv?.match(/^ED-(.+)$/);
575
+ return match ? `ED${match[1]}` : undefined;
576
+ }
577
+ // Adjuncts are (relation, type, number, date) quartets; a "PLUS" relation
578
+ // yields "+", otherwise "/".
579
+ function adjunctToCode(fields) {
580
+ if (!fields.length)
581
+ return "";
582
+ const [rel = "", type = "", num = "", date = ""] = fields.slice(0, 4);
583
+ let code = (rel === "" ? "/" : "+") + type + num;
584
+ if (date !== "")
585
+ code += `:${date}`;
586
+ return code + adjunctToCode(fields.slice(4));
587
+ }
@@ -1,3 +1,4 @@
1
+ import { lookupUrnParser, parseUrnFor } from "../urn/parsers.js";
1
2
  import { oimlGrammarImplementation } from "./oiml/implementation.js";
2
3
  import { xsfGrammarImplementation } from "./xsf/index.js";
3
4
  import { doiGrammarImplementation } from "./doi/index.js";
@@ -47,6 +48,32 @@ import { ialaGrammarImplementation } from "./iala/index.js";
47
48
  * flavor NOT listed falls back to corpus mode.
48
49
  */
49
50
  export function grammarImplementation(flavor) {
51
+ const impl = grammarImplementationSwitch(flavor);
52
+ if (impl === undefined)
53
+ return undefined;
54
+ // "urn:" inputs route to the flavor's URN parser where the reference
55
+ // implements one (impl.parseUrn or the registry in src/urn/parsers.ts
56
+ // mirroring lib/pubid/<flavor>/urn_parser.rb); flavors without one whose
57
+ // own grammar accepts URN spellings keep parsing them natively
58
+ // (pubid/pubid-ts#1).
59
+ const urnDef = lookupUrnParser(flavor);
60
+ if (impl.parseUrn === undefined && urnDef === undefined)
61
+ return impl;
62
+ return {
63
+ parse(input) {
64
+ if (/^urn:/i.test(input)) {
65
+ if (impl.parseUrn)
66
+ return impl.parseUrn(input);
67
+ return parseUrnFor(urnDef, input, impl.parse);
68
+ }
69
+ return impl.parse(input);
70
+ },
71
+ ...(impl.parseUrn
72
+ ? { parseUrn: impl.parseUrn }
73
+ : { parseUrn: (urn) => parseUrnFor(urnDef, urn, impl.parse) }),
74
+ };
75
+ }
76
+ function grammarImplementationSwitch(flavor) {
50
77
  switch (flavor) {
51
78
  case "oiml":
52
79
  return oimlGrammarImplementation();
@@ -288,6 +288,27 @@ class JcgmBuilder extends BaseBuilder {
288
288
  export function jcgmGrammarImplementation() {
289
289
  const builder = new JcgmBuilder();
290
290
  return {
291
+ // Inverse of JcgmUrnGenerator (lib/pubid/jcgm/urn_parser.rb): rebuild
292
+ // identifiers DIRECTLY from the URN segments — re-parsing through the
293
+ // grammar flattened segments and lost languages/supplements.
294
+ parseUrn(urn) {
295
+ const parts = urn.replace(/^urn:jcgm:/, "").split(":");
296
+ if (parts[0] === "meeting") {
297
+ const number = toMeetingNumber(parts[1]);
298
+ const date = urnDate(parts[2]);
299
+ return new (KIND_CLASSES.meeting)({ number, date });
300
+ }
301
+ const markerAt = parts.findIndex((seg) => seg === "corrigendum" || seg === "amendment");
302
+ if (markerAt === -1) {
303
+ return buildUrnDocument(parts);
304
+ }
305
+ const kind = (parts[markerAt] === "amendment" ? "amendment" : "corrigendum");
306
+ const base = buildUrnDocument(parts.slice(0, markerAt));
307
+ const tail = parts.slice(markerAt + 1);
308
+ const number = tail[0] === undefined || tail[0] === "" ? undefined : tail[0];
309
+ const date = urnDate(tail[1]);
310
+ return new (KIND_CLASSES[kind])({ base, number, date });
311
+ },
291
312
  parse(input) {
292
313
  const tree = parseGrammar(jcgmGrammar, input);
293
314
  if (typeof tree !== "object" || tree === null || Array.isArray(tree)) {
@@ -297,4 +318,44 @@ export function jcgmGrammarImplementation() {
297
318
  },
298
319
  };
299
320
  }
321
+ // lib/pubid/jcgm/urn_parser.rb build_document: Guide vs GumGuide by the
322
+ // "gum." number prefix; the year and language segments are matched by
323
+ // pattern, not position.
324
+ function buildUrnDocument(parts) {
325
+ const number = parts[0];
326
+ if (number === undefined || number === "") {
327
+ throw new Error("JCGM URN has no document number");
328
+ }
329
+ const gum = number.startsWith("gum.");
330
+ const rest = parts.slice(1);
331
+ const year = rest.find((seg) => /^(19|20)\d{2}$/.test(seg));
332
+ const langSegment = rest.find((seg) => /^[a-z]{2}(,[a-z]{2})*$/.test(seg));
333
+ const langLetters = {
334
+ ru: "R", fr: "F", en: "E", ar: "A", es: "S", de: "D",
335
+ };
336
+ const languages = langSegment
337
+ ? langSegment.split(",").map((code) => new Language({ code, originalCode: langLetters[code] ?? code }))
338
+ : [];
339
+ const cls = gum ? KIND_CLASSES["gum-guide"] : KIND_CLASSES.guide;
340
+ const cleanNumber = gum ? number.slice("gum.".length) : number;
341
+ return new cls({
342
+ number: cleanNumber,
343
+ date: year ? new PubidDate({ year }) : undefined,
344
+ languages,
345
+ });
346
+ }
347
+ // nil for an absent year; a component for a well-formed 19xx/20xx year;
348
+ // anything else rejects (no re-parse validates it any more).
349
+ function urnDate(segment) {
350
+ if (segment === undefined || segment === "")
351
+ return undefined;
352
+ if (!/^(19|20)\d{2}$/.test(segment)) {
353
+ throw new Error(`Invalid year in JCGM URN: ${JSON.stringify(segment)}`);
354
+ }
355
+ return new PubidDate({ year: segment });
356
+ }
357
+ // "011" reads back as "11" (Identifiers::Meeting.ordinal normalization).
358
+ function toMeetingNumber(segment) {
359
+ return String(Number(segment));
360
+ }
300
361
  export { isObj };
@@ -271,12 +271,19 @@ function buildRules() {
271
271
  .as("series")
272
272
  .then(space.or(dot))
273
273
  .as("circ_series")
274
- .then(str("supp")
274
+ .then(
275
+ // Bare supplement marker to the whole series, no base number
276
+ // ("NBS.CIRC.sup" — testsuite#5 C4).
277
+ ((str("supp").or(str("sup"))).then(monthAbbrev().then(digits).as("supplement_month_year").or(digits.as("supp_number").then(slash, digits.as("supp_year")).as("supplement_slash_year"), str("").as("supplement_empty")).maybe())).or(
278
+ // Date-range supplement (no base document).
279
+ str("supp")
275
280
  .then(monthAbbrev().as("supp_month_start"), digits.as("supp_year_start"), dash, monthAbbrev().as("supp_month_end"), digits.as("supp_year_end"))
276
281
  .as("supplement_date_range")
277
282
  .or((str("supp").or(str("sup")))
278
283
  .then(match("[0-9]").repeat(4, 4).as("supp_year_start"), dash, match("[0-9]").repeat(4, 4).as("supp_year_end"))
279
- .as("supplement_date_range"), (digits.as("base_number").then(str("e"), digits.as("edition_number")).or(digits
284
+ .as("supplement_date_range"))).or(
285
+ // Base identifier + supplement.
286
+ (digits.as("base_number").then(str("e"), digits.as("edition_number")).or(digits
280
287
  .as("base_number")
281
288
  .then(lowerLetter.as("revision_letter"), digits.as("revision_number")), digits.as("base_number").then(upperLetter.as("letter_suffix")), digits.as("simple_number")))
282
289
  .as("base_portion")
@@ -803,6 +803,7 @@ class NistBuilder {
803
803
  h["supplement_month_year"] !== undefined ||
804
804
  h["supplement_year"] !== undefined ||
805
805
  h["supplement"] !== undefined ||
806
+ h["supplement_empty"] !== undefined ||
806
807
  h["base_portion"] !== undefined) {
807
808
  return this.buildCircularSupplement(h);
808
809
  }
@@ -1094,9 +1095,12 @@ class NistBuilder {
1094
1095
  return this.buildCircularSupplementWrapper(h);
1095
1096
  }
1096
1097
  const seriesValue = isObj(h["circ_series"]) ? s(h["circ_series"]["series"]) : s(h["series"]);
1098
+ // The dotted series literal ("NBS.CIRC") names its publisher in the
1099
+ // first token; the builder default would render "NIST" instead.
1100
+ const seriesPublisher = seriesValue?.startsWith("NBS") ? "NBS" : undefined;
1097
1101
  if (isObj(h["supplement_date_range"])) {
1098
1102
  const range = h["supplement_date_range"];
1099
- const identifier = this.build({ series: seriesValue }, "short");
1103
+ const identifier = this.build({ series: seriesValue, publisher: seriesPublisher }, "short");
1100
1104
  const ms = s(range["supp_month_start"]);
1101
1105
  const ys = s(range["supp_year_start"]);
1102
1106
  const me = s(range["supp_month_end"]);
@@ -1109,7 +1113,7 @@ class NistBuilder {
1109
1113
  });
1110
1114
  return identifier;
1111
1115
  }
1112
- const base = this.buildCircularSupplementBase(h, seriesValue);
1116
+ const base = this.buildCircularSupplementBase(h, seriesValue, seriesPublisher);
1113
1117
  const self = base;
1114
1118
  const raw = h["supplement_month_year"] !== undefined
1115
1119
  ? s(h["supplement_month_year"])
@@ -1119,11 +1123,11 @@ class NistBuilder {
1119
1123
  self["supplement"] = NistSupplement.fromRaw(raw);
1120
1124
  return base;
1121
1125
  }
1122
- buildCircularSupplementBase(h, seriesValue) {
1126
+ buildCircularSupplementBase(h, seriesValue, seriesPublisher) {
1123
1127
  const basePortion = h["base_portion"];
1124
1128
  if (!isObj(basePortion)) {
1125
1129
  return this.build({
1126
- publisher: s(h["publisher"]),
1130
+ publisher: s(h["publisher"]) ?? seriesPublisher,
1127
1131
  series: seriesValue ?? s(h["series"]),
1128
1132
  first_number: s(h["first_number"]),
1129
1133
  }, s(h["parsed_format"]) ?? "short");
@@ -516,11 +516,18 @@ export class NistIdentifier extends BaseIdentifier {
516
516
  else if (this.volume !== undefined && this.issue_number !== undefined) {
517
517
  result += `${this.volume.render()}n${this.issue_number.number}`;
518
518
  }
519
- if (this.edition !== undefined)
520
- result += this.edition.render();
519
+ // With a number the edition glues per the NIST spec ("800-53r5");
520
+ // series-only editions take a dot separator ("NBS.CIRC.e2" — the
521
+ // attested raw spelling; testsuite#5 C4/C5).
522
+ if (this.edition !== undefined) {
523
+ result += this.number !== undefined ? this.edition.render() : `.${this.edition.render()}`;
524
+ }
521
525
  if (this.version_component !== undefined)
522
526
  result += this.version_component.render();
523
- result += this.supplementShort();
527
+ // A series-only supplement takes the dot itself ("NBS.CIRC.sup").
528
+ if (this.supplement !== undefined) {
529
+ result += this.number !== undefined ? this.supplementShort() : `.${this.supplementShort()}`;
530
+ }
524
531
  if (this.update_component !== undefined)
525
532
  result += this.update_component.render("mr");
526
533
  if (this.stage !== undefined) {
@@ -14,6 +14,22 @@ function flavorOf(input) {
14
14
  */
15
15
  export function oimlGrammarImplementation() {
16
16
  return {
17
+ // Inverse of OimlUrnGenerator (lib/pubid/oiml/urn_parser.rb): strip the
18
+ // "urn:oiml:" namespace, rebuild the human head "OIML <TYPE> <locator>",
19
+ // and re-parse. Mirrors the reference exactly — the year and language
20
+ // URN segments are not carried back (the reference parser reads only
21
+ // the type and the number-part locator).
22
+ parseUrn(urn) {
23
+ const body = urn.replace(/^urn:oiml:/i, "");
24
+ const parts = body.split(":");
25
+ const typeToken = parts[0] ?? "";
26
+ const number = parts[1];
27
+ const displayType = typeToken.toLowerCase() === "bulletin" ? "Bulletin" : typeToken.toUpperCase();
28
+ let text = `OIML ${displayType}`;
29
+ if (number !== undefined && number !== "")
30
+ text += ` ${number}`;
31
+ return this.parse(text);
32
+ },
17
33
  parse(input) {
18
34
  // The co-published form carries both identifiers joined by "|":
19
35
  // "ISO 4064-1:2024|OIML R 49-1:2024". The first side is another
@@ -0,0 +1,5 @@
1
+ import type { Identifier } from "../conformance/implementation.js";
2
+ import { type UrnParserDef } from "./types.js";
3
+ export declare const URN_PARSERS: Record<string, UrnParserDef>;
4
+ export declare function lookupUrnParser(flavor: string): UrnParserDef | undefined;
5
+ export declare function parseUrnFor(def: UrnParserDef, urn: string, parse: (text: string) => Identifier): Identifier;
@@ -0,0 +1,566 @@
1
+ import { splitParts, withYear } from "./types.js";
2
+ import { grammarImplementation } from "../flavors/index.js";
3
+ import { NationalAdoption } from "../flavors/evs/model.js";
4
+ // Each parser mirrors lib/pubid/<flavor>/urn_parser.rb#parse_urn exactly —
5
+ // including its lossiness (dropped segments stay dropped) and its error
6
+ // cases. The parity fixture (test/fixtures/urn-parity.json) pins the
7
+ // reference behavior for ~93k corpus URNs.
8
+ export const URN_PARSERS = {
9
+ // lib/pubid/ansi/urn_parser.rb
10
+ ansi: {
11
+ prefix: "urn:ansi:",
12
+ reconstruct(body, parse) {
13
+ const [code, year] = splitParts(body);
14
+ return parse(withYear(`ANSI ${code}`, year));
15
+ },
16
+ },
17
+ // lib/pubid/ccsds/urn_parser.rb — the body is the full suffix.
18
+ ccsds: {
19
+ prefix: "urn:ccsds:",
20
+ reconstruct(body, parse) {
21
+ // The reference rejects cor-supplemented forms at its text parser;
22
+ // mirrored (fixture-pinned gap).
23
+ if (/:cor\./.test(body)) {
24
+ throw new Error(`CCSDS URN rejected by the reference: ${JSON.stringify(body)}`);
25
+ }
26
+ return parse(`CCSDS ${body}`);
27
+ },
28
+ },
29
+ // lib/pubid/cen_cenelec/urn_parser.rb — namespace "cen"; type token
30
+ // uppercased before the number.
31
+ cen_cenelec: {
32
+ prefix: "urn:cen:",
33
+ reconstruct(body, parse) {
34
+ const [typeToken = "", number, year] = splitParts(body);
35
+ return parse(withYear(`${typeToken.toUpperCase()} ${number}`, year));
36
+ },
37
+ },
38
+ // lib/pubid/cie/urn_parser.rb — leading lowercase publisher + trailing
39
+ // "sep.<x>" separator tokens are dropped.
40
+ cie: {
41
+ prefix: "urn:cie:",
42
+ reconstruct(body, parse) {
43
+ const parts = splitParts(body).filter((p) => !p.startsWith("sep."));
44
+ const [, code, year] = parts;
45
+ return parse(withYear(`CIE ${code}`, year));
46
+ },
47
+ },
48
+ // lib/pubid/ashrae/urn_parser.rb — "ASHRAE Standard <number>-<year>".
49
+ ashrae: {
50
+ prefix: "urn:ashrae:",
51
+ reconstruct(body, parse) {
52
+ const [number, year] = splitParts(body);
53
+ let text = `ASHRAE Standard ${number}`;
54
+ if (year)
55
+ text += `-${year}`;
56
+ return parse(text);
57
+ },
58
+ },
59
+ // lib/pubid/asme/urn_parser.rb — "ASME <code>-<year>".
60
+ asme: {
61
+ prefix: "urn:asme:",
62
+ reconstruct(body, parse) {
63
+ const [, code, year] = splitParts(body);
64
+ let text = `ASME ${code}`;
65
+ if (year)
66
+ text += `-${year}`;
67
+ return parse(text);
68
+ },
69
+ },
70
+ // lib/pubid/astm/urn_parser.rb — year rendered to its last two digits.
71
+ astm: {
72
+ prefix: "urn:astm:",
73
+ reconstruct(body, parse) {
74
+ const [, code, year] = splitParts(body);
75
+ let text = `ASTM ${code}`;
76
+ if (year && year.length >= 2)
77
+ text += `-${year.slice(-2)}`;
78
+ return parse(text);
79
+ },
80
+ },
81
+ // lib/pubid/iana/urn_parser.rb — "<registry>[/<sub-registry>]".
82
+ iana: {
83
+ prefix: "urn:iana:",
84
+ reconstruct(body, parse) {
85
+ const [registry = "", subRegistry] = splitParts(body);
86
+ return parse(subRegistry ? `${registry}/${subRegistry}` : registry);
87
+ },
88
+ },
89
+ // lib/pubid/idf/urn_parser.rb — empty segments dropped.
90
+ idf: {
91
+ prefix: "urn:idf:",
92
+ reconstruct(body, parse) {
93
+ const parts = splitParts(body).filter((p) => p !== "");
94
+ const [number = "", year] = parts;
95
+ return parse(withYear(`IDF ${number}`, year));
96
+ },
97
+ },
98
+ // lib/pubid/plateau/urn_parser.rb — "PLATEAU <Type> #<number>[-<annex>]".
99
+ plateau: {
100
+ prefix: "urn:plateau:",
101
+ reconstruct(body, parse) {
102
+ const parts = splitParts(body);
103
+ const typeToken = parts[0] ?? "";
104
+ const number = parts[1] ?? "";
105
+ const annex = parts[2];
106
+ const typeMap = {
107
+ handbook: "Handbook",
108
+ tr: "Technical Report",
109
+ an: "Annex",
110
+ };
111
+ let text = `PLATEAU ${typeMap[typeToken] ?? (typeToken.charAt(0).toUpperCase() + typeToken.slice(1))} #${number}`;
112
+ if (annex)
113
+ text += `-${annex}`;
114
+ return parse(text);
115
+ },
116
+ },
117
+ // lib/pubid/tgpp/urn_parser.rb — empty release/version segments dropped.
118
+ tgpp: {
119
+ prefix: "urn:3gpp:",
120
+ reconstruct(body, parse) {
121
+ const [type = "", code = "", release, version] = splitParts(body);
122
+ let result = `${type.toUpperCase()} ${code}`;
123
+ if (release !== undefined && release !== "")
124
+ result += `:${release}`;
125
+ if (version !== undefined && version !== "")
126
+ result += `/${version}`;
127
+ return parse(result);
128
+ },
129
+ },
130
+ // lib/pubid/w3c/urn_parser.rb — a known type token hyphenates the rest.
131
+ w3c: {
132
+ prefix: "urn:w3c:",
133
+ reconstruct(body, parse) {
134
+ const known = ["note", "dnote", "wd", "cr", "crd", "rec", "pr", "per", "spsd", "obsl"];
135
+ const parts = splitParts(body);
136
+ let text;
137
+ if (parts.length > 1 && known.includes(parts[0] ?? "")) {
138
+ text = `W3C ${parts.shift().toUpperCase()}-${parts.shift() ?? ""}`;
139
+ }
140
+ else {
141
+ text = `W3C ${parts.shift()}`;
142
+ }
143
+ if (parts.length)
144
+ text += `-${parts.shift()}`;
145
+ return parse(text);
146
+ },
147
+ },
148
+ // lib/pubid/xsf/urn_parser.rb — "XEP <number>".
149
+ xsf: {
150
+ prefix: "urn:xsf:",
151
+ reconstruct(body, parse) {
152
+ const [, number] = splitParts(body);
153
+ return parse(`XEP ${number}`);
154
+ },
155
+ },
156
+ // lib/pubid/calconnect/urn_parser.rb — "CC[/series] <number>:<date>".
157
+ calconnect: {
158
+ prefix: "urn:calconnect:",
159
+ reconstruct(body, parse) {
160
+ const parts = splitParts(body);
161
+ const date = parts.pop();
162
+ const number = parts.pop();
163
+ const series = parts.pop();
164
+ let text = "CC";
165
+ if (series !== undefined)
166
+ text += `/${series}`;
167
+ text += ` ${number}:${date}`;
168
+ return parse(text);
169
+ },
170
+ },
171
+ // lib/pubid/api/urn_parser.rb — "API STD <number>[-<part>]:<year>".
172
+ api: {
173
+ prefix: "urn:api:",
174
+ reconstruct(body, parse) {
175
+ const parts = splitParts(body);
176
+ const number = parts[1] ?? "";
177
+ let idx = 2;
178
+ let part = "";
179
+ if (parts[idx]?.startsWith("-")) {
180
+ part = parts[idx] ?? "";
181
+ idx += 1;
182
+ }
183
+ const year = parts[idx];
184
+ let text = `API STD ${number}${part}`;
185
+ if (year)
186
+ text += `:${year}`;
187
+ return parse(text);
188
+ },
189
+ },
190
+ // lib/pubid/jis/urn_parser.rb — "JIS <code>[:<year>]".
191
+ jis: {
192
+ prefix: "urn:jis:",
193
+ reconstruct(body, parse) {
194
+ const [code = "", year] = splitParts(body);
195
+ return parse(withYear(`JIS ${code}`, year));
196
+ },
197
+ },
198
+ // lib/pubid/ietf/urn_parser.rb — rfc/bcp/std/fyi/id; unknown types raise.
199
+ ietf: {
200
+ prefix: "urn:ietf:",
201
+ reconstruct(body, parse) {
202
+ const parts = splitParts(body);
203
+ const type = parts.shift() ?? "";
204
+ let text;
205
+ if (type === "rfc") {
206
+ text = `RFC ${parts[0] ?? ""}`;
207
+ }
208
+ else if (type === "bcp" || type === "std" || type === "fyi") {
209
+ text = `${type.toUpperCase()} ${parts[0] ?? ""}`;
210
+ }
211
+ else if (type === "id") {
212
+ const [name, version] = parts;
213
+ text = version ? `${name}-${version}` : (name ?? "");
214
+ }
215
+ else {
216
+ throw new Error(`Invalid IETF URN type: ${JSON.stringify(type)}`);
217
+ }
218
+ return parse(text);
219
+ },
220
+ },
221
+ // lib/pubid/ecma/urn_parser.rb — optional tr/mem tag, labelled
222
+ // part-/ed-/vol- segments.
223
+ ecma: {
224
+ prefix: "urn:ecma:",
225
+ reconstruct(body, parse) {
226
+ const parts = splitParts(body);
227
+ const tag = parts[0] === "tr" || parts[0] === "mem" ? parts.shift() : undefined;
228
+ const number = parts.shift() ?? "";
229
+ const shiftLabelled = (label) => parts[0]?.startsWith(label) ? parts.shift().slice(label.length) : undefined;
230
+ const part = shiftLabelled("part-");
231
+ const edition = shiftLabelled("ed-");
232
+ const volume = shiftLabelled("vol-");
233
+ let base;
234
+ if (tag === "tr")
235
+ base = `ECMA TR/${number}`;
236
+ else if (tag === "mem")
237
+ base = `ECMA MEM/${number}`;
238
+ else
239
+ base = part ? `ECMA-${number}-${part}` : `ECMA-${number}`;
240
+ if (edition)
241
+ base += ` ed${edition}`;
242
+ if (volume)
243
+ base += ` vol${volume}`;
244
+ return parse(base);
245
+ },
246
+ },
247
+ // lib/pubid/gost/urn_parser.rb — "r" prefix selects the national
248
+ // standard; both classes render "GOST [R ]<number>-<year>".
249
+ gost: {
250
+ prefix: "urn:gost:std:",
251
+ reconstruct(body, parse) {
252
+ const parts = splitParts(body);
253
+ if (parts[0] === "r") {
254
+ const number = parts[1] ?? "";
255
+ const year = parts[2];
256
+ return parse(year ? `GOST R ${number}-${year}` : `GOST R ${number}`);
257
+ }
258
+ const number = parts[0] ?? "";
259
+ const year = parts[1];
260
+ return parse(year ? `GOST ${number}-${year}` : `GOST ${number}`);
261
+ },
262
+ },
263
+ // lib/pubid/iho/urn_parser.rb — "IHO <TYPE>-<number> [<Part label>
264
+ // <value>] [<version>]".
265
+ iho: {
266
+ prefix: "urn:iho:",
267
+ reconstruct(body, parse) {
268
+ const partLabels = {
269
+ part: "Part",
270
+ ap: "Ap.",
271
+ annex: "Annex",
272
+ suppl: "Suppl",
273
+ };
274
+ const parts = splitParts(body);
275
+ const typeToken = (parts[0] ?? "").toUpperCase();
276
+ const number = parts[1] ?? "";
277
+ let idx = 2;
278
+ let partLabel;
279
+ const kindToken = parts[idx];
280
+ if (kindToken !== undefined && /^[a-z]+\./i.test(kindToken)) {
281
+ const [kind = "", value = ""] = kindToken.split(".", 2);
282
+ const label = partLabels[kind.toLowerCase()] ?? (kind.charAt(0).toUpperCase() + kind.slice(1));
283
+ partLabel = `${label} ${value}`;
284
+ idx += 1;
285
+ }
286
+ const version = parts[idx];
287
+ let text = `IHO ${typeToken}-${number}`;
288
+ if (partLabel)
289
+ text += ` ${partLabel}`;
290
+ if (version)
291
+ text += ` ${version}`;
292
+ return parse(text);
293
+ },
294
+ },
295
+ // lib/pubid/oasis/urn_parser.rb — %20/%5D decoded after the namespace.
296
+ oasis: {
297
+ prefix: "urn:oasis:",
298
+ reconstruct(body, parse) {
299
+ const slug = body.replace(/%20|%5D/g, (m) => (m === "%20" ? " " : "]"));
300
+ return parse(`OASIS ${slug}`);
301
+ },
302
+ },
303
+ // lib/pubid/ogc/urn_parser.rb — "<year>-<number>[<revision>]".
304
+ ogc: {
305
+ prefix: "urn:ogc:",
306
+ reconstruct(body, parse) {
307
+ const [year = "", number = "", revision] = splitParts(body);
308
+ let text = `${year}-${number}`;
309
+ if (revision !== undefined)
310
+ text += revision;
311
+ return parse(text);
312
+ },
313
+ },
314
+ // lib/pubid/amca/urn_parser.rb — keyed tokens (copub./rev./reaff./interp.),
315
+ // a bare numeric year, and a trailing type token.
316
+ amca: {
317
+ prefix: "urn:amca:",
318
+ reconstruct(body, parse) {
319
+ const parts = splitParts(body);
320
+ const number = parts.shift() ?? "";
321
+ const first = parts[0];
322
+ const year = first !== undefined && /^\d+$/.test(first) ? parts.shift() : undefined;
323
+ const keyed = {};
324
+ const keyedTokens = [];
325
+ for (const token of parts) {
326
+ if (token.includes(".")) {
327
+ const [k, ...v] = token.split(".", 2);
328
+ keyed[k] = v.join(".");
329
+ keyedTokens.push(token);
330
+ }
331
+ }
332
+ const type = parts.filter((t) => !keyedTokens.includes(t)).pop();
333
+ const publisher = (keyed["copub"] ?? "amca").toUpperCase();
334
+ let text;
335
+ if (type === "interpretation") {
336
+ const code = keyed["interp"]?.toUpperCase();
337
+ text = [publisher, number, code, "Interp"].filter(Boolean).join(" ");
338
+ }
339
+ else {
340
+ const titles = {
341
+ standard: "Standard",
342
+ publication: "Publication",
343
+ };
344
+ text = [publisher, titles[type ?? ""], number].filter(Boolean).join(" ");
345
+ if (year)
346
+ text += `-${year}`;
347
+ if (keyed["rev"])
348
+ text += ` (Rev. ${keyed["rev"]})`;
349
+ if (keyed["reaff"])
350
+ text += ` (R${keyed["reaff"]})`;
351
+ }
352
+ return parse(text);
353
+ },
354
+ },
355
+ // lib/pubid/itu/urn_parser.rb — "report" segment between sector and code
356
+ // marks an ITU-R Report; otherwise "ITU-<SECTOR> <code>".
357
+ itu: {
358
+ prefix: "urn:itu:",
359
+ reconstruct(body, parse) {
360
+ const parts = splitParts(body);
361
+ const sector = parts[0] ?? "";
362
+ if (parts[1] === "report") {
363
+ return parse(`Report ITU-${sector.toUpperCase()} ${parts[2] ?? ""}`);
364
+ }
365
+ return parse(`ITU-${sector.toUpperCase()} ${parts[1] ?? ""}`);
366
+ },
367
+ },
368
+ // lib/pubid/iala/urn_parser.rb — annex(-letter) / ed.<x> / single-letter
369
+ // language segments after the code.
370
+ iala: {
371
+ prefix: "urn:mrn:iala:pub:",
372
+ reconstruct(body, parse) {
373
+ const parts = splitParts(body);
374
+ const code = (parts.shift() ?? "").toUpperCase();
375
+ let annexForm;
376
+ let annexLetter;
377
+ let edition;
378
+ let language;
379
+ for (const seg of parts) {
380
+ const annexMatch = seg.match(/^annex(-([a-z]))?$/i);
381
+ if (annexMatch) {
382
+ annexLetter = annexMatch[2]?.toUpperCase();
383
+ annexForm = annexLetter ? "ANNEX" : "Annex";
384
+ }
385
+ else if (/^ed\.?/i.test(seg)) {
386
+ edition = seg.replace(/^ed\.?/i, "");
387
+ }
388
+ else if (/^[a-z]$/i.test(seg)) {
389
+ language = seg.toUpperCase();
390
+ }
391
+ }
392
+ let text = `IALA ${code}`;
393
+ if (annexForm)
394
+ text += ` ${annexForm}`;
395
+ if (annexLetter)
396
+ text += ` ${annexLetter}`;
397
+ if (edition)
398
+ text += ` Ed ${edition}`;
399
+ if (language)
400
+ text += ` (${language})`;
401
+ return parse(text);
402
+ },
403
+ },
404
+ // lib/pubid/bsi/urn_parser.rb — publisher token + number + year +
405
+ // 3-segment supplement slices (amd/cor/add).
406
+ bsi: {
407
+ prefix: "urn:bsi:",
408
+ reconstruct(body, parse) {
409
+ const abbr = { amd: "Amd", cor: "Cor", add: "Add" };
410
+ const parts = splitParts(body);
411
+ const [publisherToken = "", number = "", year, ...supplementParts] = parts;
412
+ let text = `${publisherToken.toUpperCase()} ${number}`;
413
+ if (year)
414
+ text += `:${year}`;
415
+ for (let i = 0; i < supplementParts.length; i += 3) {
416
+ const type = supplementParts[i] ?? "";
417
+ const num = supplementParts[i + 1];
418
+ const suppYear = supplementParts[i + 2];
419
+ const label = abbr[type.toLowerCase()] ?? (type.charAt(0).toUpperCase() + type.slice(1));
420
+ let suffix = num ? ` ${num}` : "";
421
+ if (suppYear)
422
+ suffix += `:${suppYear}`;
423
+ text += `/${label}${suffix}`;
424
+ }
425
+ return parse(text);
426
+ },
427
+ },
428
+ // lib/pubid/csa/urn_parser.rb — format.<sep> token picks the year
429
+ // separator; the token itself is dropped.
430
+ csa: {
431
+ prefix: "urn:csa:",
432
+ reconstruct(body, parse) {
433
+ const parts = splitParts(body);
434
+ const separators = { dash: "-", colon: ":" };
435
+ const formatToken = parts.find((t) => t.startsWith("format."));
436
+ const separator = separators[(formatToken?.split(".")[1]) ?? ""] ?? "-";
437
+ const payload = parts.filter((t) => !t.startsWith("format."));
438
+ const [, code, year] = payload;
439
+ let text = `CSA ${code}`;
440
+ if (year)
441
+ text += `${separator}${year}`;
442
+ return parse(text);
443
+ },
444
+ },
445
+ // lib/pubid/etsi/urn_parser.rb — "ETSI <TYPE> <code> [VERSION] [(date)]".
446
+ etsi: {
447
+ prefix: "urn:etsi:",
448
+ reconstruct(body, parse) {
449
+ const parts = splitParts(body);
450
+ const typeToken = (parts[0] ?? "").toUpperCase();
451
+ const code = parts[1] ?? "";
452
+ const version = parts[2];
453
+ const date = parts[3];
454
+ let text = `ETSI ${typeToken} ${code}`;
455
+ if (version !== undefined)
456
+ text += ` ${version.toUpperCase()}`;
457
+ if (date !== undefined)
458
+ text += ` (${date})`;
459
+ return parse(text);
460
+ },
461
+ },
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.
464
+ evs: {
465
+ prefix: "urn:evs:",
466
+ reconstruct(body, parse) {
467
+ 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
+ }
472
+ const orgs = {
473
+ iso: "ISO",
474
+ "iso-iec": "ISO/IEC",
475
+ iec: "IEC",
476
+ cispr: "CISPR",
477
+ };
478
+ const orgParts = [];
479
+ while (parts[0] !== undefined && /^[a-z][a-z-]*$/.test(parts[0])) {
480
+ orgParts.push(parts.shift());
481
+ }
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}`;
488
+ }
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)}`);
502
+ }
503
+ if (iteration === undefined || parts.length) {
504
+ throw new Error(`malformed supplement in ${JSON.stringify(body)}`);
505
+ }
506
+ text += kind === "amd" ? `/A${iteration}` : `/AC${iteration}`;
507
+ if (yearTail)
508
+ text += `:${yearTail}`;
509
+ }
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 });
516
+ },
517
+ },
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.
520
+ nist: {
521
+ prefix: "urn:nist:",
522
+ reconstruct(body, parse) {
523
+ const typeUpcase = {
524
+ sp: "SP", fips: "FIPS", ir: "IR", nistcir: "NISTCIR", nbs: "NBS",
525
+ nistir: "NISTIR", csf: "CSF", gcr: "GCR", itl: "ITL", jres: "JRES",
526
+ lcirc: "LCIRC", mon: "MONO", ms: "MS", nsrds: "NSRDS", tn: "TN",
527
+ wp: "WP",
528
+ };
529
+ const parts = splitParts(body);
530
+ const typeToken = parts[0] ?? "";
531
+ const payload = parts[1] ?? "";
532
+ 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;
537
+ let text = `NIST ${label} ${code}`;
538
+ if (revision)
539
+ text += revision;
540
+ return parse(text);
541
+ },
542
+ },
543
+ // lib/pubid/ieee/urn_parser.rb — "IEEE Std <code>[-<year>]". The fixture
544
+ // pins the reference's own parse failures for narrative/artifact rows.
545
+ ieee: {
546
+ prefix: "urn:ieee:",
547
+ reconstruct(body, parse) {
548
+ const parts = splitParts(body);
549
+ const [, code, year] = parts;
550
+ let text = `IEEE Std ${code}`;
551
+ if (year)
552
+ text += `-${year}`;
553
+ return parse(text);
554
+ },
555
+ },
556
+ };
557
+ export function lookupUrnParser(flavor) {
558
+ return URN_PARSERS[flavor];
559
+ }
560
+ export function parseUrnFor(def, urn, parse) {
561
+ const prefix = def.prefix;
562
+ if (!urn.toLowerCase().startsWith(prefix)) {
563
+ throw new Error(`Invalid ${def.prefix.toUpperCase()} URN: ${JSON.stringify(urn)}`);
564
+ }
565
+ return def.reconstruct(urn.slice(prefix.length), parse);
566
+ }
@@ -0,0 +1,12 @@
1
+ import type { Identifier } from "../conformance/implementation.js";
2
+ export type UrnParserDef = {
3
+ /** Full URN prefix including the trailing colon
4
+ * (gem UrnParser PREFIX / "urn:" + flavor_name + ":"). */
5
+ prefix: string;
6
+ /** Rebuild the human text (or parse directly) from the URN body. */
7
+ reconstruct: (body: string, parse: (text: string) => Identifier) => Identifier;
8
+ };
9
+ /** Split the body on ":" (gem Base#split_parts). */
10
+ export declare function splitParts(body: string): string[];
11
+ /** Reconstruct "<TEXT>:<year>" (the common trailing-year shape). */
12
+ export declare function withYear(text: string, year: string | undefined): string;
@@ -0,0 +1,8 @@
1
+ /** Split the body on ":" (gem Base#split_parts). */
2
+ export function splitParts(body) {
3
+ return body.split(":");
4
+ }
5
+ /** Reconstruct "<TEXT>:<year>" (the common trailing-year shape). */
6
+ export function withYear(text, year) {
7
+ return year ? `${text}:${year}` : text;
8
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pubid/pubid",
3
- "version": "0.1.5",
3
+ "version": "0.1.6",
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",