@pubid/pubid 0.1.5 → 0.1.7

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
+ }
@@ -895,12 +895,21 @@ class IeeeBuilder {
895
895
  });
896
896
  }
897
897
  buildIecIeeCopublished(parsed) {
898
- const content = extractValue(parsed["content"]);
898
+ const contentRaw = extractValue(parsed["content"]);
899
899
  let copublishedNumber;
900
900
  let draftInfo;
901
901
  let iecYear;
902
902
  let dateInfo;
903
- if (content !== undefined) {
903
+ let relationships;
904
+ if (contentRaw !== undefined) {
905
+ // A bare (unparenthesised) revision narrative printed after the code
906
+ // is the same relationship prose as the parenthetical form (C3
907
+ // ruling) — rewrite it into the parenthetical shape so both
908
+ // spellings collapse onto one canonical.
909
+ let content = contentRaw;
910
+ const bare = content.match(/^(.+?) (Revis(?:ion|on) (?:of|to) IEEE Std .+)$/);
911
+ if (bare !== null)
912
+ content = `${bare[1]} (${bare[2]})`;
904
913
  if (content.includes("IEC:")) {
905
914
  copublishedNumber = content.split(" IEC:")[0]?.trim();
906
915
  }
@@ -923,10 +932,35 @@ class IeeeBuilder {
923
932
  if (iecPart !== undefined)
924
933
  iecYear = iecPart.split(" ")[0];
925
934
  }
935
+ // The parenthetical tail is classified, not swallowed (C3 ruling):
936
+ // a "(Revision of IEEE Std ...)" narrative is a relationship, not
937
+ // identity — it lands in `relationships` so the URN carries it as
938
+ // the rel. segment and the canonical human drops it; an "(MM/DD)"
939
+ // print date is non-identity and dropped; anything else keeps the
940
+ // historical date_info rendering.
926
941
  if (content.includes(" (")) {
927
942
  const datePart = content.split(" (")[1];
928
943
  if (datePart !== undefined && datePart.includes(")")) {
929
- dateInfo = datePart.split(")")[0];
944
+ const tail = datePart.split(")")[0];
945
+ const revision = tail !== undefined
946
+ ? tail.match(/^Revis(?:ion|on) (?:of|to) IEEE Std (.+)$/)
947
+ : null;
948
+ if (tail !== undefined && revision !== null) {
949
+ let related;
950
+ try {
951
+ related = ieeeParseSingle(revision[1]);
952
+ }
953
+ catch {
954
+ related = { toHuman: () => revision[1] };
955
+ }
956
+ relationships = [new IeeeRelationship("revision_of", [related], [])];
957
+ }
958
+ else if (tail !== undefined && /^\d{2}\/\d{2}$/.test(tail)) {
959
+ // print date — non-identity, dropped
960
+ }
961
+ else if (tail !== undefined) {
962
+ dateInfo = tail;
963
+ }
930
964
  }
931
965
  }
932
966
  }
@@ -937,6 +971,8 @@ class IeeeBuilder {
937
971
  attrs["iec_year"] = iecYear;
938
972
  if (dateInfo !== undefined)
939
973
  attrs["date_info"] = dateInfo;
974
+ if (relationships !== undefined)
975
+ attrs["relationships"] = relationships;
940
976
  Object.assign(attrs, copublishedStructured(copublishedNumber));
941
977
  return new IecIeeeCopublishedClass(attrs);
942
978
  }
@@ -474,10 +474,9 @@ export function renderIeeeBase(id) {
474
474
  result += `Rev${id.revision}`;
475
475
  if (draftObj !== undefined) {
476
476
  let printed = draftObj.render();
477
- if (iecIeeOnly) {
478
- printed = printed.split(", ")[0];
479
- }
480
- else if (id.publisher === "IEEE" && (id.draft_status ?? "") === "") {
477
+ // The draft designator carries its date on every lead
478
+ // (docs/IEEE-DRAFT-STAGES.md §3) — mirrors the ruby render fix.
479
+ if (id.publisher === "IEEE" && (id.draft_status ?? "") === "") {
481
480
  // The long comma form for dated project drafts; the
482
481
  // unapproved-draft keeps its pinned single-comma form.
483
482
  printed = printed.replace(/, ([A-Z][a-z]+) (\d{4})$/, ", $1, $2");
@@ -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();
@@ -61,7 +61,16 @@ function buildRules() {
61
61
  rule("attachment", () => space.then(str("attachment").as("attachment")));
62
62
  // "ITU-T Q.120-Q.139 (11/1988)" — one document covering a range.
63
63
  rule("range_end", () => dash.then(letter.repeat(1, 3).then(dot, digits).as("range_end")));
64
- rule("part", () => dash.then(digits.as("part")));
64
+ rule("date_part", () => space.then(str("("), digits.as("month").then(str("/")).maybe(), digit.repeat(4, 4).as("year"), str(")")));
65
+ // A "-YYYYMM" approval date — the "200307" of "T-REC-T.4-200307-I" and
66
+ // "ITU-T T.4-200307". ITU's own edition suffix is short ("-5"), so six
67
+ // digits that read as a plausible year (19xx/20xx) and month (01-12) are
68
+ // the date, never a part. A six-digit run that fails either test
69
+ // ("-200313", "-180001") is still a part, as it was before.
70
+ rule("yyyymm_year", () => str("19").or(str("20")).then(digit.repeat(2, 2)));
71
+ rule("yyyymm_month", () => str("0").then(match("[1-9]")).or(str("1").then(match("[0-2]"))));
72
+ rule("yyyymm_shape", () => rules["yyyymm_year"].then(rules["yyyymm_month"], digit.absent()));
73
+ rule("part", () => dash.then(rules["yyyymm_shape"].absent(), digits.as("part")));
65
74
  rule("parts", () => rules["part"].repeat(0, Infinity).as("parts"));
66
75
  // Imp code first: the standard code needs digits, which "Imp712" lacks.
67
76
  rule("imp_code", () => str("Imp")
@@ -70,7 +79,21 @@ function buildRules() {
70
79
  rule("standard_code", () => rules["number"]
71
80
  .then(rules["series_suffix"].maybe(), rules["subseries"].maybe(), rules["parts"], rules["qualifier_glued"].maybe()));
72
81
  rule("code", () => rules["imp_code"].or(rules["standard_code"]));
73
- rule("date_part", () => space.then(str("("), digits.as("month").then(str("/")).maybe(), digit.repeat(4, 4).as("year"), str(")")));
82
+ // The status letter that trails the date in a publication id — "I" (in
83
+ // force) or "S" (superseded). It names the state of the edition, not the
84
+ // edition, so it is parsed and dropped. "S" is also the Spanish language
85
+ // suffix, so it is a status ONLY in the full "T-REC-…" id, where ITU
86
+ // always writes one; after an "ITU-T …-YYYYMM" print form only "I" is,
87
+ // and "-S" stays the language ("ITU-T Z.100-199911-S").
88
+ rule("id_status", () => dash.then(match("[IS]"), match("[A-Za-z0-9]").absent()));
89
+ rule("print_id_status", () => dash.then(str("I"), match("[A-Za-z0-9]").absent()));
90
+ rule("yyyymm_date", () => dash.then(rules["yyyymm_year"].as("year"), rules["yyyymm_month"].as("month"), digit.absent()));
91
+ rule("id_date", () => rules["yyyymm_date"].then(rules["print_id_status"].maybe()));
92
+ // Either date spelling of a Recommendation.
93
+ rule("document_date", () => rules["date_part"].or(rules["id_date"]));
94
+ // "ITU-T REC T.4", "ITU-T REC-T.4" — the redundant type word of ITU's
95
+ // own URLs. Not captured: a Recommendation is the default type.
96
+ rule("rec_word", () => str("REC").then(space.or(dash)));
74
97
  // "(V14)" plus the bare spellings "V2", "v10", "v.1" — all
75
98
  // normalise to the parenthesised form on render.
76
99
  rule("version_part", () => space.then(str("(V")
@@ -103,9 +126,9 @@ function buildRules() {
103
126
  .then(digits.then(dot.then(digits).repeat(0, Infinity)).as("supplement_number")));
104
127
  rule("supplement_date", () => space.then(str("("), digits.as("supplement_month").then(str("/")).maybe(), digit.repeat(4, 4).as("supplement_year"), str(")")));
105
128
  rule("base_with_series", () => rules["itu_prefix"]
106
- .then(rules["sector"], space, rules["series"], dot, rules["code"], rules["range_end"].maybe(), rules["code_suffixes"], rules["combined_suffixes"].maybe(), rules["series_word"].maybe(), rules["attachment"].maybe(), rules["version_part"].maybe(), rules["date_part"].maybe()));
129
+ .then(rules["sector"], space, rules["rec_word"].maybe(), rules["series"], dot, rules["code"], rules["range_end"].maybe(), rules["code_suffixes"], rules["combined_suffixes"].maybe(), rules["series_word"].maybe(), rules["attachment"].maybe(), rules["version_part"].maybe(), rules["document_date"].maybe()));
107
130
  rule("base_without_series", () => rules["itu_prefix"]
108
- .then(rules["sector"], space, rules["code"], rules["code_suffixes"], rules["attachment"].maybe(), rules["version_part"].maybe(), rules["date_part"].maybe()));
131
+ .then(rules["sector"], space, rules["code"], rules["code_suffixes"], rules["attachment"].maybe(), rules["version_part"].maybe(), rules["document_date"].maybe()));
109
132
  // Series-code documents ("EMC-5", "SEC-QKD"): 2+ letter mnemonic,
110
133
  // dash-joined number. The OB guard keeps "ITU-T OB-1" a clean parse
111
134
  // failure rather than an escaped ArgumentError.
@@ -165,16 +188,39 @@ function buildRules() {
165
188
  rule("supplement_identifier", () => rules["chained_supplement"]
166
189
  .or(rules["slash_chained_supplement"], rules["supplement_with_base"], rules["supplement_series_only"]));
167
190
  rule("with_series", () => rules["itu_prefix"]
168
- .then(rules["sector"], space, rules["series"], dot, rules["code"], rules["range_end"].maybe(), rules["code_suffixes"], rules["combined_suffixes"].maybe(), rules["series_word"].maybe(), rules["attachment"].maybe(), rules["version_part"].maybe(), rules["date_part"].maybe(), rules["language"].maybe()));
191
+ .then(rules["sector"], space, rules["rec_word"].maybe(), rules["series"], dot, rules["code"], rules["range_end"].maybe(), rules["code_suffixes"], rules["combined_suffixes"].maybe(), rules["series_word"].maybe(), rules["attachment"].maybe(), rules["version_part"].maybe(), rules["document_date"].maybe(), rules["language"].maybe()));
169
192
  rule("without_series", () => rules["itu_prefix"]
170
- .then(rules["sector"], space, rules["code"], rules["code_suffixes"], rules["attachment"].maybe(), rules["version_part"].maybe(), rules["date_part"].maybe(), rules["language"].maybe()));
193
+ .then(rules["sector"], space, rules["code"], rules["code_suffixes"], rules["attachment"].maybe(), rules["version_part"].maybe(), rules["document_date"].maybe(), rules["language"].maybe()));
171
194
  rule("ob_series", () => str("OB").as("series"));
172
195
  rule("ob_dot_body", () => dot.then(rules["number"]));
173
196
  rule("ob_no_body", () => space.then(str("No."), space, rules["number"]));
197
+ // "ITU OB 1000" — metanorma-itu's docidentifier ("Annex to ITU OB %").
198
+ // Accepted as an input spelling only; it renders "ITU OB No. 1000", the
199
+ // form ITU's own bulletin site uses.
200
+ rule("ob_bare_body", () => space.then(rules["number"]));
201
+ // The date as a bulletin prints it — "ITU-T OB.1096 - 15.III.2016": day,
202
+ // Roman month, year. The months are tried longest first, because PEG
203
+ // takes the first alternative that matches and "I" would otherwise win
204
+ // on "III"; "XIII" matches "XII", then fails on the required dot.
205
+ rule("roman_month", () => str("XII")
206
+ .or(str("XI"))
207
+ .or(str("X"))
208
+ .or(str("IX"))
209
+ .or(str("VIII"))
210
+ .or(str("VII"))
211
+ .or(str("VI"))
212
+ .or(str("V"))
213
+ .or(str("IV"))
214
+ .or(str("III"))
215
+ .or(str("II"))
216
+ .or(str("I")));
217
+ rule("ob_roman_date", () => str(" - ")
218
+ .then(digit.repeat(2, 2).as("day"), dot, rules["roman_month"].as("roman_month"), dot, digit.repeat(4, 4).as("year")));
219
+ rule("ob_date", () => rules["date_part"].or(rules["ob_roman_date"]));
174
220
  rule("ob_with_sector", () => rules["itu_prefix"]
175
- .then(rules["sector"].then(space).maybe(), rules["ob_series"], rules["ob_dot_body"].or(rules["ob_no_body"]), rules["date_part"].maybe(), rules["language"].maybe()));
221
+ .then(rules["sector"].then(space).maybe(), rules["ob_series"], rules["ob_dot_body"].or(rules["ob_no_body"]).or(rules["ob_bare_body"]), rules["ob_date"].maybe(), rules["language"].maybe()));
176
222
  rule("ob_long_form", () => rules["itu_prefix"]
177
- .then(rules["sector"].then(space).maybe(), str("Operational Bulletin").as("_op_bull"), space, str("No."), space, rules["number"], rules["date_part"].maybe(), rules["language"].maybe()));
223
+ .then(rules["sector"].then(space).maybe(), str("Operational Bulletin").as("_op_bull"), space, str("No."), space, rules["number"], rules["ob_date"].maybe(), rules["language"].maybe()));
178
224
  rule("special_publication", () => rules["ob_with_sector"].or(rules["ob_long_form"]));
179
225
  rule("annex_to_identifier", () => str("Annex to").then(space, rules["special_publication"].as("annex_to")));
180
226
  rule("handbook", () => rules["itu_prefix"]
@@ -191,8 +237,24 @@ function buildRules() {
191
237
  rule("common_text_twin", () => space
192
238
  .then(str("|"), space, match("[^|]").repeat(1, Infinity))
193
239
  .as("common_text_twin"));
240
+ // ITU's publication id — "T-REC-T.4-200307-I",
241
+ // "R-REC-BO.1130-5-202602-I": <sector>-REC-<number>[-<edition>]-<YYYYMM>
242
+ // [-<status>], the name ITU gives each edition in its URLs and PDF
243
+ // files. It builds the plain Recommendation it names and renders in the
244
+ // print form ("ITU-T T.4 (07/2003)"). The date is required: without it
245
+ // the string names no edition. No other rule starts with a bare sector
246
+ // letter, so the slot is free.
247
+ rule("publication_id", () => rules["sector"]
248
+ .then(dash, str("REC"), dash, rules["series"], dot, rules["code"], rules["yyyymm_date"], rules["id_status"].maybe(), rules["language"].maybe()));
249
+ // The Radio Regulations — "ITU-R RR", "ITU-R RR (2020)", and the URL
250
+ // spelling "ITU-R RR-2020". Always ITU-R. The trailing any.absent? is
251
+ // load-bearing: PEG ordered choice never re-enters the alternation once
252
+ // an alternative succeeds, so a partial match on "ITU-R RR.1" must fail
253
+ // here and fall through to with_series.
254
+ rule("radio_regulations", () => rules["itu_prefix"]
255
+ .then(str("R").as("sector"), space, str("RR").as("radio_regulations"), rules["date_part"].or(dash.then(digit.repeat(4, 4).as("year"))).maybe(), rules["language"].maybe(), match(".").absent()));
194
256
  rule("identifier", () => rules["annex_to_identifier"]
195
- .or(rules["special_publication"], rules["supplement_identifier"], rules["annex_identifier"], rules["appendix_identifier"], rules["report_identifier"], rules["handbook"], rules["numeric_question"], rules["letter_question"], rules["with_series"], rules["contribution"], rules["series_code_identifier"], rules["without_series"]));
257
+ .or(rules["special_publication"], rules["supplement_identifier"], rules["annex_identifier"], rules["appendix_identifier"], rules["report_identifier"], rules["handbook"], rules["numeric_question"], rules["letter_question"], rules["radio_regulations"], rules["with_series"], rules["contribution"], rules["series_code_identifier"], rules["without_series"], rules["publication_id"]));
196
258
  rule("root", () => rules["identifier"]
197
259
  .then(rules["common_text_twin"])
198
260
  .or(rules["identifier"]));
@@ -1,7 +1,7 @@
1
1
  import { ParseFailed, parseGrammar } from "../../grammar/engine.js";
2
2
  import { PubidDate } from "../../model/component.js";
3
3
  import { ituGrammar, normalizeWhitespaceItu } from "./grammar.js";
4
- import { ITU_CLASSES, ITU_LANGUAGES, ItuCode, ItuDesignation, } from "./model.js";
4
+ import { ITU_CLASSES, ITU_LANGUAGES, ItuCode, ItuDesignation, ItuSpecialPublication, } from "./model.js";
5
5
  /**
6
6
  * Port of lib/pubid/itu/builder.rb — the exact dispatch order:
7
7
  * annex_to → annex_number → appendix_number → OB/_op_bull →
@@ -43,6 +43,9 @@ class ItuBuilder {
43
43
  if (tree["appendix_number"] !== undefined) {
44
44
  return this.withTwin(this.buildAppendixOfRecommendation(tree), twin);
45
45
  }
46
+ if (tree["radio_regulations"] !== undefined) {
47
+ return this.withTwin(this.buildRadioRegulations(tree), twin);
48
+ }
46
49
  if ((s(tree["series"]) === "OB" && tree["series_dash"] === undefined) || tree["_op_bull"] !== undefined) {
47
50
  return this.withTwin(this.buildSpecialPublication(tree), twin);
48
51
  }
@@ -98,17 +101,34 @@ class ItuBuilder {
98
101
  });
99
102
  }
100
103
  buildDate(tree) {
101
- return new PubidDate({ year: s(tree["year"]), month: s(tree["month"]) });
104
+ // The Roman month of a bulletin date ("15.III.2016") is stored as the
105
+ // two-digit month every other ITU date uses; the day marks the spelling.
106
+ const roman = s(tree["roman_month"]);
107
+ const month = roman !== undefined
108
+ ? (ItuSpecialPublication.ROMAN_MONTHS.indexOf(roman) + 1).toString().padStart(2, "0")
109
+ : s(tree["month"]);
110
+ return new PubidDate({ year: s(tree["year"]), month, day: s(tree["day"]) });
102
111
  }
103
- /** OB is cross-bureau — the sector of legacy strings is dropped. */
112
+ /** OB keeps the sector of the TSB spelling ("ITU-T OB.1096") — it is a
113
+ * spelling, not identity (ItuSpecialPublication ignores it in ==). */
104
114
  buildSpecialPublication(tree) {
105
115
  return new ITU_CLASSES["special_publication"]({
116
+ sector: s(tree["sector"]),
106
117
  series: "OB",
107
118
  code: tree["number"] !== undefined ? this.buildCode(tree) : undefined,
108
119
  date: tree["year"] !== undefined ? this.buildDate(tree) : undefined,
109
120
  language: normLanguage(tree["language"]),
110
121
  });
111
122
  }
123
+ /** "ITU-R RR (2020)" — the series carries the whole designation. */
124
+ buildRadioRegulations(tree) {
125
+ return new ITU_CLASSES["radio_regulations"]({
126
+ sector: s(tree["sector"]),
127
+ series: "RR",
128
+ date: tree["year"] !== undefined ? this.buildDate(tree) : undefined,
129
+ language: normLanguage(tree["language"]),
130
+ });
131
+ }
112
132
  buildAnnexTo(inner) {
113
133
  const data = flatten(inner);
114
134
  const base = this.buildSpecialPublication(data);
@@ -78,6 +78,13 @@ export declare class ItuHandbook extends ItuIdentifier {
78
78
  renderBase(): string;
79
79
  }
80
80
  export declare class ItuSpecialPublication extends ItuIdentifier {
81
+ static polymorphicName: string;
82
+ static mappings: FieldMapping[];
83
+ static ROMAN_MONTHS: string[];
84
+ renderBase(): string;
85
+ private renderObDate;
86
+ }
87
+ export declare class ItuRadioRegulations extends ItuIdentifier {
81
88
  static polymorphicName: string;
82
89
  static mappings: FieldMapping[];
83
90
  renderBase(): string;
@@ -169,6 +176,7 @@ export declare const ITU_CLASSES: {
169
176
  readonly report: typeof ItuReport;
170
177
  readonly handbook: typeof ItuHandbook;
171
178
  readonly special_publication: typeof ItuSpecialPublication;
179
+ readonly radio_regulations: typeof ItuRadioRegulations;
172
180
  readonly contribution: typeof ItuContribution;
173
181
  readonly question: typeof ItuQuestion;
174
182
  readonly combined: typeof ItuCombinedIdentifier;
@@ -218,6 +218,11 @@ const SUPPLEMENT_MAPPINGS = keyValue({ wire: "sector", to: "sector", toWire: (m)
218
218
  function urnDateSegment(id) {
219
219
  if (!(id.date instanceof PubidDate))
220
220
  return undefined;
221
+ // Only an Operational Bulletin's printed date carries a day
222
+ // ("15.III.2016"); it is in `==`, so it must reach the URN too.
223
+ if (id.date.day !== undefined && id.date.month !== undefined) {
224
+ return `${id.date.day}/${id.date.month}/${id.date.year}`;
225
+ }
221
226
  return id.date.month !== undefined ? `${id.date.month}/${id.date.year}` : id.date.year;
222
227
  }
223
228
  export class ItuUrnGenerator extends BaseUrnGenerator {
@@ -245,7 +250,14 @@ export class ItuUrnGenerator extends BaseUrnGenerator {
245
250
  }
246
251
  generateBaseUrn(id) {
247
252
  const parts = ["urn", "itu"];
248
- parts.push(id.sector !== undefined ? id.sector.toLowerCase() : "itu");
253
+ // An Operational Bulletin is cross-bureau: its sector is a spelling,
254
+ // not identity (see ItuSpecialPublication), so it stays out of the URN.
255
+ if (id.sector !== undefined && !(id instanceof ItuSpecialPublication)) {
256
+ parts.push(id.sector.toLowerCase());
257
+ }
258
+ else {
259
+ parts.push("itu");
260
+ }
249
261
  // Reports number independently of Recommendations — the marker must
250
262
  // reach the URN or the two same-numbered documents collide.
251
263
  if (id instanceof ItuReport)
@@ -338,18 +350,43 @@ attachUrn(ItuHandbook);
338
350
  export class ItuSpecialPublication extends ItuIdentifier {
339
351
  static polymorphicName = "pubid:itu:special-publication";
340
352
  static mappings = STANDARD_MAPPINGS;
353
+ static ROMAN_MONTHS = ["I", "II", "III", "IV", "V", "VI", "VII", "VIII", "IX", "X", "XI", "XII"];
341
354
  renderBase() {
342
- let result = `ITU ${this.series} No. ${this.code?.number ?? ""}`;
343
- if (this.date instanceof PubidDate) {
344
- result += this.date.month !== undefined
345
- ? ` (${this.date.month.padStart(2, "0")}/${this.date.year})`
346
- : ` (${this.date.year})`;
355
+ const number = this.code?.number ?? "";
356
+ const result = this.sector !== undefined
357
+ ? `ITU-${this.sector} ${this.series}.${number}`
358
+ : `ITU ${this.series} No. ${number}`;
359
+ return result + this.renderObDate();
360
+ }
361
+ renderObDate() {
362
+ if (!(this.date instanceof PubidDate))
363
+ return "";
364
+ if (this.date.day !== undefined && this.date.month !== undefined) {
365
+ const roman = ItuSpecialPublication.ROMAN_MONTHS[Number(this.date.month) - 1] ?? "";
366
+ return ` - ${this.date.day.padStart(2, "0")}.${roman}.${this.date.year}`;
347
367
  }
348
- return result;
368
+ if (this.date.month !== undefined) {
369
+ return ` (${this.date.month.padStart(2, "0")}/${this.date.year})`;
370
+ }
371
+ return ` (${this.date.year})`;
349
372
  }
350
373
  }
351
374
  registerType(ItuSpecialPublication);
352
375
  attachUrn(ItuSpecialPublication);
376
+ // The ITU Radio Regulations — the treaty text revised by each World
377
+ // Radiocommunication Conference (hand-off itu-relaton-query-forms).
378
+ // It has no document number: "RR" is the whole designation, stored as
379
+ // the series; "ITU-R RR-2020" (URL spelling) normalizes to
380
+ // "ITU-R RR (2020)".
381
+ export class ItuRadioRegulations extends ItuIdentifier {
382
+ static polymorphicName = "pubid:itu:radio-regulations";
383
+ static mappings = STANDARD_MAPPINGS;
384
+ renderBase() {
385
+ return `ITU-${this.sector} ${this.series}${this.dateSuffix()}`;
386
+ }
387
+ }
388
+ registerType(ItuRadioRegulations);
389
+ attachUrn(ItuRadioRegulations);
353
390
  export class ItuContribution extends ItuIdentifier {
354
391
  static polymorphicName = "pubid:itu:contribution";
355
392
  static mappings = STANDARD_MAPPINGS;
@@ -585,6 +622,7 @@ export const ITU_CLASSES = {
585
622
  report: ItuReport,
586
623
  handbook: ItuHandbook,
587
624
  special_publication: ItuSpecialPublication,
625
+ radio_regulations: ItuRadioRegulations,
588
626
  contribution: ItuContribution,
589
627
  question: ItuQuestion,
590
628
  combined: ItuCombinedIdentifier,