@pubid/pubid 0.1.6 → 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.
Binary file
@@ -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");
@@ -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,
@@ -0,0 +1,85 @@
1
+ type PgArtifactJs = any;
2
+ /** A field requirement from the binding-requirements schema. */
3
+ export interface FieldRequirement {
4
+ type: string;
5
+ card: string;
6
+ preprocess?: string;
7
+ }
8
+ /** The binding-requirements schema record for one entry. */
9
+ export interface EntrySchema {
10
+ root: string;
11
+ fields: Record<string, FieldRequirement>;
12
+ examples: {
13
+ input: string;
14
+ captures: Record<string, unknown>;
15
+ }[];
16
+ }
17
+ export type Schema = Record<string, EntrySchema>;
18
+ /**
19
+ * A loaded, checksum-verified PG artifact with its schema.
20
+ * Construction throws when the checksum does not verify.
21
+ */
22
+ export declare class PgRuntime {
23
+ readonly artifact: PgArtifactJs;
24
+ readonly schema: Schema;
25
+ readonly renderSpec: Record<string, RenderSegment[]>;
26
+ readonly entry: string;
27
+ readonly defaultEntry: string | null;
28
+ readonly envelope: Record<string, unknown>;
29
+ constructor(artifactJson: string, entry?: string);
30
+ /** Load an artifact JSON file and verify its checksum. */
31
+ static fromFile(path: string, entry?: string): PgRuntime;
32
+ /** The default artifacts directory (the pubid-grammar checkout). */
33
+ static artifactsDir(): string;
34
+ static load(grammar: string, entry?: string): PgRuntime;
35
+ /** Parse with the native wasm engine; returns the parsanol-shape tree. */
36
+ parseShape(input: string): unknown;
37
+ /** Apply the entry's bindings to a parsanol-shape tree. */
38
+ applyBindings(shape: unknown): Record<string, unknown>;
39
+ /** Parse and bind in one step. */
40
+ parseAndBind(input: string): Record<string, unknown> | null;
41
+ /**
42
+ * Schema-driven materialization: the schema (from the artifact, not
43
+ * hand-written model code) declares the fields a data model must
44
+ * implement; the bound captures populate a structurally-typed object.
45
+ * Card `1*`/`*` fields become arrays; `0..1` fields become nullable.
46
+ */
47
+ materialize(input: string): Materialized | null;
48
+ /** Render the identifier string from a bound map (F6). */
49
+ render(bound: Record<string, unknown>, variant?: string): string;
50
+ /** Parse, bind, and render in one step. */
51
+ renderString(input: string, variant?: string): string | null;
52
+ /** Evaluate a named derive spec against a bound map (F6). */
53
+ derive(name: string, bound: Record<string, unknown>): string;
54
+ /** Parse, bind, and evaluate a derive spec in one step. */
55
+ deriveString(input: string, name: string): string | null;
56
+ /** Run the artifact's embedded tests; empty list means green. */
57
+ runTests(): string[];
58
+ }
59
+ /** F6 v1 derive evaluator: {field} interpolates from the bound map. */
60
+ export declare function deriveTemplate(derive: Record<string, string>, name: string, bound: Record<string, unknown>): string;
61
+ export type RenderSegment = {
62
+ type: "field";
63
+ field: string;
64
+ } | {
65
+ type: "literal";
66
+ text: string;
67
+ } | {
68
+ type: "cond";
69
+ field: string;
70
+ then: RenderSegment[];
71
+ };
72
+ /** F6 v1 generic renderer: field / literal / cond-presence segments. */
73
+ export declare function renderSegments(segments: RenderSegment[] | null, bound: Record<string, unknown>): string;
74
+ /** A schema-materialized identifier: typed fields plus provenance. */
75
+ export interface Materialized {
76
+ entry: string;
77
+ attributes: Record<string, unknown>;
78
+ }
79
+ /**
80
+ * Schema-driven materialization independent of any wasm handle —
81
+ * the same function the model layer will build on (T2 codegen emits the
82
+ * static view of exactly these fields).
83
+ */
84
+ export declare function materializeFromSchema(schema: EntrySchema, bound: Record<string, unknown>): Materialized;
85
+ export {};
@@ -0,0 +1,170 @@
1
+ //! PG artifact runtime for TypeScript (TODO 12 / T1): checksum-verified
2
+ //! artifact load, native wasm parse, bindings application, and
3
+ //! schema-driven materialization. The engine is the vendored
4
+ //! parsanol wasm package (PgArtifactJs, C9) — no grammar code lives in
5
+ //! this repo; the artifact is the contract.
6
+ import { createRequire } from "node:module";
7
+ import { existsSync, readFileSync } from "node:fs";
8
+ import { fileURLToPath } from "node:url";
9
+ import { dirname, join } from "node:path";
10
+ /** Walk up from this module to the package root (which holds package.json). */
11
+ function packageRoot() {
12
+ let dir = dirname(fileURLToPath(import.meta.url));
13
+ for (; !existsSync(join(dir, "package.json")); dir = dirname(dir)) {
14
+ if (dir === dirname(dir))
15
+ throw new Error("package.json not found above pg runtime");
16
+ }
17
+ return dir;
18
+ }
19
+ // The vendored wasm glue is CommonJS and lives outside the compiled tree,
20
+ // so resolve it against the package root at runtime.
21
+ const require = createRequire(import.meta.url);
22
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
23
+ const wasm = require(join(packageRoot(), "vendor", "parsanol-wasm", "parsanol.js"));
24
+ /**
25
+ * A loaded, checksum-verified PG artifact with its schema.
26
+ * Construction throws when the checksum does not verify.
27
+ */
28
+ export class PgRuntime {
29
+ artifact;
30
+ schema;
31
+ renderSpec;
32
+ entry;
33
+ defaultEntry;
34
+ envelope;
35
+ constructor(artifactJson, entry) {
36
+ this.envelope = JSON.parse(artifactJson);
37
+ this.artifact = new wasm.PgArtifactJs(artifactJson);
38
+ this.schema = JSON.parse(this.artifact.schema());
39
+ this.renderSpec =
40
+ this.envelope["render"] ?? {};
41
+ // The compiler bakes default_entry: JSON key order is not preserved
42
+ // across engines, so the sorted entry list cannot rederive it.
43
+ this.defaultEntry =
44
+ JSON.parse(artifactJson)["default_entry"] ?? null;
45
+ this.entry = entry ?? this.defaultEntry ?? this.artifact.entryNames()[0];
46
+ }
47
+ /** Load an artifact JSON file and verify its checksum. */
48
+ static fromFile(path, entry) {
49
+ return new PgRuntime(readFileSync(path, "utf8"), entry);
50
+ }
51
+ /** The default artifacts directory (the pubid-grammar checkout). */
52
+ static artifactsDir() {
53
+ const env = process.env.PG_ARTIFACT_DIR;
54
+ if (env)
55
+ return env;
56
+ // pubid-grammar is a sibling checkout in the pubid workspace.
57
+ return join(packageRoot(), "..", "pubid-grammar", "artifacts");
58
+ }
59
+ static load(grammar, entry) {
60
+ return PgRuntime.fromFile(join(PgRuntime.artifactsDir(), `${grammar}.json`), entry);
61
+ }
62
+ /** Parse with the native wasm engine; returns the parsanol-shape tree. */
63
+ parseShape(input) {
64
+ return JSON.parse(this.artifact.parseShape(this.entry, input));
65
+ }
66
+ /** Apply the entry's bindings to a parsanol-shape tree. */
67
+ applyBindings(shape) {
68
+ return JSON.parse(this.artifact.applyBindings(this.entry, JSON.stringify(shape)));
69
+ }
70
+ /** Parse and bind in one step. */
71
+ parseAndBind(input) {
72
+ try {
73
+ return JSON.parse(this.artifact.parseAndBind(this.entry, input));
74
+ }
75
+ catch {
76
+ return null;
77
+ }
78
+ }
79
+ /**
80
+ * Schema-driven materialization: the schema (from the artifact, not
81
+ * hand-written model code) declares the fields a data model must
82
+ * implement; the bound captures populate a structurally-typed object.
83
+ * Card `1*`/`*` fields become arrays; `0..1` fields become nullable.
84
+ */
85
+ materialize(input) {
86
+ const bound = this.parseAndBind(input);
87
+ if (bound === null)
88
+ return null;
89
+ const schema = this.schema[this.entry];
90
+ if (!schema)
91
+ throw new Error(`artifact has no schema for entry ${this.entry}`);
92
+ return materializeFromSchema(schema, bound);
93
+ }
94
+ /** Render the identifier string from a bound map (F6). */
95
+ render(bound, variant = "default") {
96
+ return renderSegments((this.renderSpec[variant] ?? null), bound);
97
+ }
98
+ /** Parse, bind, and render in one step. */
99
+ renderString(input, variant = "default") {
100
+ const bound = this.parseAndBind(input);
101
+ return bound === null ? null : this.render(bound, variant);
102
+ }
103
+ /** Evaluate a named derive spec against a bound map (F6). */
104
+ derive(name, bound) {
105
+ return deriveTemplate((this.envelope["derive"] ?? {}), name, bound);
106
+ }
107
+ /** Parse, bind, and evaluate a derive spec in one step. */
108
+ deriveString(input, name) {
109
+ const bound = this.parseAndBind(input);
110
+ return bound === null ? null : this.derive(name, bound);
111
+ }
112
+ /** Run the artifact's embedded tests; empty list means green. */
113
+ runTests() {
114
+ return JSON.parse(this.artifact.runTests());
115
+ }
116
+ }
117
+ /** F6 v1 derive evaluator: {field} interpolates from the bound map. */
118
+ export function deriveTemplate(derive, name, bound) {
119
+ const template = derive[name];
120
+ if (template === undefined)
121
+ throw new Error(`derive spec ${name} not declared`);
122
+ return template.replace(/\{(\w+)\}/g, (_, field) => {
123
+ const value = bound[field];
124
+ return value === undefined || value === null ? "" : String(value);
125
+ });
126
+ }
127
+ /** F6 v1 generic renderer: field / literal / cond-presence segments. */
128
+ export function renderSegments(segments, bound) {
129
+ let out = "";
130
+ for (const segment of segments ?? []) {
131
+ if (segment.type === "field") {
132
+ const value = bound[segment.field];
133
+ out += value === undefined || value === null ? "" : String(value);
134
+ }
135
+ else if (segment.type === "literal") {
136
+ out += segment.text;
137
+ }
138
+ else if (segment.type === "cond") {
139
+ const value = bound[segment.field];
140
+ if (value !== undefined && value !== null)
141
+ out += renderSegments(segment.then, bound);
142
+ }
143
+ }
144
+ return out;
145
+ }
146
+ /**
147
+ * Schema-driven materialization independent of any wasm handle —
148
+ * the same function the model layer will build on (T2 codegen emits the
149
+ * static view of exactly these fields).
150
+ */
151
+ export function materializeFromSchema(schema, bound) {
152
+ const attributes = {};
153
+ for (const [path, requirements] of Object.entries(schema.fields)) {
154
+ const leaf = path.includes("[]")
155
+ ? (path.split("].").pop() ?? path)
156
+ : path;
157
+ const value = bound[leaf];
158
+ if (requirements.card.endsWith("*")) {
159
+ const list = Array.isArray(value) ? value : value === undefined ? [] : [value];
160
+ attributes[leaf] = list;
161
+ }
162
+ else if (requirements.card === "0..1") {
163
+ attributes[leaf] = value === undefined ? null : value;
164
+ }
165
+ else {
166
+ attributes[leaf] = value;
167
+ }
168
+ }
169
+ return { entry: schema.root, attributes };
170
+ }
@@ -0,0 +1,20 @@
1
+ import { PgRuntime } from "./runtime.js";
2
+ export type TestKind = "accept" | "reject" | "example";
3
+ export interface SuiteTest {
4
+ kind: TestKind;
5
+ input: string;
6
+ expect?: Record<string, string>;
7
+ }
8
+ export interface Suite {
9
+ name: string;
10
+ entry?: string;
11
+ tests: SuiteTest[];
12
+ }
13
+ /** Parse one *.pgtest file (same grammar as Parsanol::PG::Suite#read_file). */
14
+ export declare function readSuite(text: string, name: string): Suite;
15
+ /** Load every *.pgtest file in a directory. */
16
+ export declare function loadSuites(dir: string): Suite[];
17
+ /** Run a suite against a runtime; returns failure descriptions. */
18
+ export declare function runSuite(runtime: PgRuntime, suite: Suite): string[];
19
+ /** The default suites directory (the pubid-grammar checkout). */
20
+ export declare function suitesDir(): string;
@@ -0,0 +1,90 @@
1
+ //! T3: external *.pgtest suites run through the wasm runtime. The suite
2
+ //! syntax matches the Ruby loader (Parsanol::PG::Suite): a
3
+ //! `suite <name> [for <entry>] { ... }` header with accept / reject /
4
+ //! example lines. Green means Ruby and TypeScript agree on the contract.
5
+ import { readFileSync, readdirSync } from "node:fs";
6
+ import { join } from "node:path";
7
+ import { PgRuntime } from "./runtime.js";
8
+ const LINE = /^\s*(accept|reject|example)\s+"((?:[^"\\]|\\.)*)"(?:\s*\{([^}]*)\})?\s*$/;
9
+ const HEADER = /suite\s+(\w+)(?:\s+for\s+entry\s+(\w+))?\s*\{/;
10
+ /** Parse one *.pgtest file (same grammar as Parsanol::PG::Suite#read_file). */
11
+ export function readSuite(text, name) {
12
+ const body = text.replace(/^#[^\n]*\n/gm, "");
13
+ const header = HEADER.exec(body);
14
+ if (!header) {
15
+ throw new Error(`${name}: expected 'suite <name> [for <entry>] { ... }'`);
16
+ }
17
+ const tests = [];
18
+ for (const line of body.slice((header.index ?? 0) + header[0].length).split("\n")) {
19
+ if (line.trim() === "}")
20
+ break;
21
+ const match = LINE.exec(line);
22
+ if (!match)
23
+ continue;
24
+ const kind = match[1];
25
+ const rawInput = match[2] ?? "";
26
+ const input = JSON.parse(`"${rawInput}"`);
27
+ const expect = {};
28
+ if (match[3]) {
29
+ for (const pair of match[3].split(",")) {
30
+ const kv = /(\w+)\s*:\s*"((?:[^"\\]|\\.)*)"/.exec(pair);
31
+ const key = kv?.[1];
32
+ const raw = kv?.[2];
33
+ if (key && raw !== undefined)
34
+ expect[key] = JSON.parse(`"${raw}"`);
35
+ }
36
+ }
37
+ tests.push(kind === "example" ? { kind, input, expect } : { kind, input });
38
+ }
39
+ const entry = header[2];
40
+ return entry === undefined
41
+ ? { name: header[1] ?? "suite", tests }
42
+ : { name: header[1] ?? "suite", entry, tests };
43
+ }
44
+ /** Load every *.pgtest file in a directory. */
45
+ export function loadSuites(dir) {
46
+ return readdirSync(dir)
47
+ .filter((file) => file.endsWith(".pgtest"))
48
+ .map((file) => readSuite(readFileSync(join(dir, file), "utf8"), file.replace(/\.pgtest$/, "")));
49
+ }
50
+ /** Run a suite against a runtime; returns failure descriptions. */
51
+ export function runSuite(runtime, suite) {
52
+ const failures = [];
53
+ const entry = suite.entry ?? runtime.entry;
54
+ for (const test of suite.tests) {
55
+ // The wasm runtime throws on parse failure; that is the reject signal.
56
+ let bound = null;
57
+ try {
58
+ bound = runtime.artifact.parseAndBind(entry, test.input);
59
+ }
60
+ catch {
61
+ bound = null;
62
+ }
63
+ if (test.kind === "reject") {
64
+ if (bound !== null) {
65
+ failures.push(`test ${JSON.stringify(test.input)}: expected the input to be rejected`);
66
+ }
67
+ continue;
68
+ }
69
+ if (bound === null) {
70
+ failures.push(`test ${JSON.stringify(test.input)}: expected the input to parse`);
71
+ continue;
72
+ }
73
+ if (test.kind === "example") {
74
+ const parsed = JSON.parse(bound);
75
+ for (const [key, value] of Object.entries(test.expect ?? {})) {
76
+ if (parsed[key] !== value) {
77
+ failures.push(`test ${JSON.stringify(test.input)}: expected captures ${key}: ${JSON.stringify(value)}, got ${JSON.stringify(parsed[key])}`);
78
+ }
79
+ }
80
+ }
81
+ }
82
+ return failures;
83
+ }
84
+ /** The default suites directory (the pubid-grammar checkout). */
85
+ export function suitesDir() {
86
+ const env = process.env.PG_SUITES_DIR;
87
+ if (env)
88
+ return env;
89
+ return join(PgRuntime.artifactsDir(), "..", "suites");
90
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pubid/pubid",
3
- "version": "0.1.6",
3
+ "version": "0.1.7",
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",