@pubid/pubid 0.1.36 → 0.1.38

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.
@@ -43,7 +43,28 @@ function buildRules() {
43
43
  rule("part_edition_rule", () => space.then(digits.then(dot, digits, dot, digits).as("part_edition")));
44
44
  rule("part_year_rule", () => space.then(str("(").then(year4.as("part_year"), str(")"))));
45
45
  rule("part_dated", () => rules["part_edition_rule"].then(rules["part_year_rule"].maybe()));
46
- rule("part", () => space.then(str("Part")).then(space, rules["part_value"], rules["part_dated"].maybe()));
46
+ // --- S-4 loose-leaf sections and specifications (issue #691) ---
47
+ // The spec number's vocabulary: the S-4 sections run 100-600 (Part C
48
+ // 100-500), so the leaf number is 3 digits with hundreds digit 1-6 —
49
+ // the same shape the bare citation branch leans on to keep the B
50
+ // Bibliographic series (B-1..B-13 style) distinct from B-4xx specs.
51
+ const specShape = match("[1-6]").then(match("[0-9]").repeat(2, 2));
52
+ // The citation letter is the spec's part (A/B/C); the builder binds
53
+ // it into `part` and rejects a conflicting Part-rule capture.
54
+ rule("spec_citation_body", () => str("A").or(str("B"), str("C")).as("spec_part")
55
+ .then(dash, specShape.as("spec"))
56
+ .then(dot.then(digits.as("spec_sub")).maybe())
57
+ .then(str("(").then(match("[A-Z]").repeat(1, Infinity).as("spec_variant"), str(")")).maybe()));
58
+ // The compact anchor form: the citation follows the code after one
59
+ // space ("IHO S-4 B-401").
60
+ rule("spec_citation", () => space.then(rules["spec_citation_body"]));
61
+ // The spelled section long form: "Part B, Section 400" — the spelled
62
+ // number becomes the spec (the section-level citation), and a
63
+ // trailing citation (", Section 400, B-401") makes it a cross-check
64
+ // only.
65
+ rule("part_section_tail", () => str(",").then(space, str("Section"), space, specShape.as("spec_section"))
66
+ .then(str(",").then(space, rules["spec_citation_body"]).maybe()));
67
+ rule("part", () => space.then(str("Part")).then(space, rules["part_value"], rules["part_dated"].maybe(), rules["part_section_tail"].maybe()));
47
68
  rule("supplement_edition_rule", () => space.then(digits.then(dot, digits, dot, digits).as("supplement_edition")));
48
69
  rule("supplement_year_rule", () => space.then(str("(").then(year4.as("supplement_year"), str(")"))));
49
70
  rule("supplement_dated", () => rules["supplement_edition_rule"].then(rules["supplement_year_rule"].maybe()));
@@ -79,8 +100,16 @@ function buildRules() {
79
100
  rule("identifier", () => str("IHO")
80
101
  .then(space)
81
102
  .maybe()
82
- .then(rules["series"], dash, rules["number"], rules["compact_part"].maybe(), rules["glued"].maybe(), rules["volume_short"].maybe(), rules["dated"].maybe(), rules["supplement"].maybe(), rules["part"].maybe(), rules["annex"].maybe(), rules["appendix"].maybe(), rules["serial"].maybe(), rules["year_qualifier"].maybe()));
83
- rule("root", () => rules["identifier"]);
103
+ .then(rules["series"], dash, rules["number"], rules["compact_part"].maybe(), rules["glued"].maybe(), rules["volume_short"].maybe(), rules["dated"].maybe(), rules["supplement"].maybe(), rules["part"].maybe(), rules["spec_citation"].maybe(), rules["annex"].maybe(), rules["appendix"].maybe(), rules["serial"].maybe(), rules["year_qualifier"].maybe()));
104
+ // The bare citation branch (the entry's FIRST alternative): "B-401" /
105
+ // "IHO B-401" — implicit S-4 anchor (the builder synthesizes series S
106
+ // number 4). B only: bare C-x stays the Circular Letter series, and
107
+ // the 3-digit [1-6]xx shape keeps the B Bibliographic series (B-12)
108
+ // on the main branch.
109
+ rule("bare_spec_citation", () => str("IHO").then(space).maybe()
110
+ .then(str("B").as("spec_part"), dash, specShape.as("spec"), dot.then(digits.as("spec_sub")).maybe(), str("(").then(match("[A-Z]").repeat(1, Infinity).as("spec_variant"), str(")")).maybe()));
111
+ rule("identifier_entry", () => rules["bare_spec_citation"].or(rules["identifier"]));
112
+ rule("root", () => rules["identifier_entry"]);
84
113
  return rules;
85
114
  }
86
115
  export const ihoGrammar = { rules: buildRules(), root: "root" };
@@ -115,8 +144,15 @@ function ihoClass(kind) {
115
144
  volume_edition: { type: "string" },
116
145
  volume_year: { type: "string" },
117
146
  issue: { type: "string" },
147
+ // The S-4 loose-leaf spec citation (issue #691): spec is the leaf
148
+ // number, the dotted sub and the parenthesized variant its
149
+ // children. The citation letter rides `part`; the spelled section
150
+ // is resolve-only (never an atom).
151
+ spec: { type: "string" },
152
+ spec_sub: { type: "string" },
153
+ spec_variant: { type: "string" },
118
154
  });
119
- static mappings = keyValue({ wire: "number", to: "number" }, { wire: "part", to: "part" }, { wire: "part_edition", to: "part_edition" }, { wire: "part_year", to: "part_year" }, { wire: "annex", to: "annex" }, { wire: "annex_edition", to: "annex_edition" }, { wire: "annex_year", to: "annex_year" }, { wire: "appendix", to: "appendix" }, { wire: "appendix_subnumber", to: "appendix_subnumber" }, { wire: "appendix_edition", to: "appendix_edition" }, { wire: "appendix_year", to: "appendix_year" }, { wire: "supplement", to: "supplement" }, { wire: "supplement_edition", to: "supplement_edition" }, { wire: "supplement_year", to: "supplement_year" }, { wire: "edition", to: "edition" }, { wire: "year", to: "year" }, { wire: "volume", to: "volume" }, { wire: "volume_edition", to: "volume_edition" }, { wire: "volume_year", to: "volume_year" }, { wire: "issue", to: "issue" });
155
+ static mappings = keyValue({ wire: "number", to: "number" }, { wire: "part", to: "part" }, { wire: "part_edition", to: "part_edition" }, { wire: "part_year", to: "part_year" }, { wire: "annex", to: "annex" }, { wire: "annex_edition", to: "annex_edition" }, { wire: "annex_year", to: "annex_year" }, { wire: "appendix", to: "appendix" }, { wire: "appendix_subnumber", to: "appendix_subnumber" }, { wire: "appendix_edition", to: "appendix_edition" }, { wire: "appendix_year", to: "appendix_year" }, { wire: "supplement", to: "supplement" }, { wire: "supplement_edition", to: "supplement_edition" }, { wire: "supplement_year", to: "supplement_year" }, { wire: "edition", to: "edition" }, { wire: "year", to: "year" }, { wire: "volume", to: "volume" }, { wire: "volume_edition", to: "volume_edition" }, { wire: "volume_year", to: "volume_year" }, { wire: "issue", to: "issue" }, { wire: "spec", to: "spec" }, { wire: "spec_sub", to: "spec_sub" }, { wire: "spec_variant", to: "spec_variant" });
120
156
  render() {
121
157
  let result = `IHO ${letter}-${this.number}`;
122
158
  if (this.edition !== undefined)
@@ -128,7 +164,17 @@ function ihoClass(kind) {
128
164
  if (this.supplement_year !== undefined)
129
165
  result += ` (${this.supplement_year})`;
130
166
  }
131
- if (this.part !== undefined) {
167
+ if (this.spec !== undefined) {
168
+ // The S-4 citation face replaces the Part face (same
169
+ // containment slot: the citation letter IS the part):
170
+ // "B-401", "B-122.1", "B-521(I)".
171
+ result += ` ${this.part}-${this.spec}`;
172
+ if (this.spec_sub !== undefined)
173
+ result += `.${this.spec_sub}`;
174
+ if (this.spec_variant !== undefined)
175
+ result += `(${this.spec_variant})`;
176
+ }
177
+ else if (this.part !== undefined) {
132
178
  result += ` Part ${this.part}`;
133
179
  if (this.part_edition !== undefined)
134
180
  result += ` ${this.part_edition}`;
@@ -192,6 +238,17 @@ function ihoClass(kind) {
192
238
  if (id.part_year !== undefined)
193
239
  parts.push(`year-${id.part_year}`);
194
240
  }
241
+ // The S-4 spec citation: a child of its part (the citation letter
242
+ // rides the part- atom), the dotted sub and the parenthesized
243
+ // variant its own children. The spelled SECTION is NOT emitted —
244
+ // derivable from the spec's hundreds digit.
245
+ if (id.spec !== undefined) {
246
+ parts.push(`spec-${id.spec}`);
247
+ if (id.spec_sub !== undefined)
248
+ parts.push(`specsub-${id.spec_sub}`);
249
+ if (id.spec_variant !== undefined)
250
+ parts.push(`specvar-${id.spec_variant}`);
251
+ }
195
252
  if (id.annex !== undefined) {
196
253
  parts.push(`annex-${id.annex}`);
197
254
  if (id.annex_edition !== undefined)
@@ -235,6 +292,18 @@ const KIND_CLASSES = {
235
292
  };
236
293
  const LETTER_TO_CLASS = new Map(Object.keys(KIND_LETTERS).map((kind) => [KIND_LETTERS[kind], KIND_CLASSES[kind]]));
237
294
  class IhoBuilder extends BaseBuilder {
295
+ // The S-4 anchor: the loose-leaf publication every spec citation
296
+ // resolves through (bare "B-401" carries it implicitly).
297
+ static S4_TYPE = "S";
298
+ static S4_NUMBER = "4";
299
+ // A citation letter is one of S-4's lettered Parts; the section
300
+ // vocabulary (100-600, Part C 100-500) is theirs.
301
+ static SPEC_LETTERS = ["A", "B", "C"];
302
+ // Components an S-4 spec citation cannot carry: the citation is a
303
+ // leaf of the Part, not a wrappable publication.
304
+ static SPEC_FOREIGN_COMPONENTS = [
305
+ "supplement", "annex", "appendix", "appendix_subnumber", "volume", "issue",
306
+ ];
238
307
  selectClass(data) {
239
308
  const kind = LETTER_TO_CLASS.get(String(data["type"]));
240
309
  if (kind === undefined) {
@@ -264,6 +333,58 @@ class IhoBuilder extends BaseBuilder {
264
333
  delete flat[yearKey];
265
334
  }
266
335
  }
336
+ // The S-4 spec citation resolution (ruby resolve_spec_citation!):
337
+ // the citation letter folds into `part` (a Part-rule capture must
338
+ // agree), and the long form's spelled section becomes the spec
339
+ // alone — beside a citation it only cross-checks the citation's
340
+ // derived section ("Part B, Section 400, B-401" stores spec 401;
341
+ // "Part B, Section 500, B-401" is rejected).
342
+ const letter = flat["spec_part"];
343
+ const spelled = flat["spec_section"];
344
+ delete flat["spec_part"];
345
+ delete flat["spec_section"];
346
+ if (letter !== undefined) {
347
+ if (flat["part"] === undefined || flat["part"] === letter) {
348
+ flat["part"] = letter;
349
+ }
350
+ else {
351
+ throw new ParseFailed(`IHO: the citation letter ${String(letter)} conflicts with part ${String(flat["part"])}`, 0);
352
+ }
353
+ }
354
+ if (spelled !== undefined) {
355
+ if (flat["spec"] === undefined) {
356
+ flat["spec"] = spelled;
357
+ }
358
+ else {
359
+ const section = `${String(flat["spec"])[0]}00`;
360
+ if (spelled !== section) {
361
+ throw new ParseFailed(`IHO: the spelled section ${String(spelled)} disagrees with the citation ${String(flat["part"])}-${String(flat["spec"])}`, 0);
362
+ }
363
+ }
364
+ }
365
+ if (flat["spec"] !== undefined) {
366
+ if (!IhoBuilder.SPEC_LETTERS.includes(String(flat["part"]))) {
367
+ throw new ParseFailed(`IHO: an S-4 spec citation needs a lettered Part (A/B/C), not ${JSON.stringify(flat["part"])}`, 0);
368
+ }
369
+ if (flat["part_edition"] !== undefined || flat["part_year"] !== undefined) {
370
+ throw new ParseFailed("IHO: a spec citation rides the S-4 edition, not a part edition", 0);
371
+ }
372
+ const foreign = IhoBuilder.SPEC_FOREIGN_COMPONENTS.filter((k) => flat[k] !== undefined);
373
+ if (foreign.length > 0) {
374
+ throw new ParseFailed(`IHO: an S-4 spec citation is a leaf of its Part and cannot carry ${foreign.join(", ")}`, 0);
375
+ }
376
+ // The series letter: a bare citation branch parse has no series
377
+ // capture, and its anchor is implicitly the S-4 publication (the
378
+ // number binds with it). An explicit series must BE the anchor.
379
+ if (flat["type"] === undefined) {
380
+ flat["type"] = IhoBuilder.S4_TYPE;
381
+ flat["number"] = IhoBuilder.S4_NUMBER;
382
+ }
383
+ else if (flat["type"] !== IhoBuilder.S4_TYPE ||
384
+ flat["number"] !== IhoBuilder.S4_NUMBER) {
385
+ throw new ParseFailed(`IHO: an S-4 spec citation requires the S-4 anchor: ${String(flat["type"])}-${String(flat["number"])} cannot carry spec ${String(flat["spec"])}`, 0);
386
+ }
387
+ }
267
388
  return super.build(flat);
268
389
  }
269
390
  cast(key, value) {
@@ -1,2 +1,7 @@
1
1
  import { Grammar } from "../../grammar/engine.js";
2
+ /** The DAM-family ballot stage codes (grammar table
3
+ * typed_stages_ballots: PDAM, DAM, DAmd, FDAM, FDAmd, FPDAM — exactly
4
+ * these entries' abbrs); the TYPE-first ballot branch stays scoped to
5
+ * the ballot family. */
6
+ export declare const BALLOT_STAGE_CODES: string[];
2
7
  export declare const isoGrammar: Grammar;
@@ -1,5 +1,6 @@
1
1
  import { match, ref, str } from "../../grammar/engine.js";
2
2
  import { stageTokens } from "./model.js";
3
+ import { ISO_TYPED_STAGES } from "./stages.generated.js";
3
4
  /**
4
5
  * Port of lib/pubid/iso/parser.rb. The identifier alternation order is
5
6
  * load-bearing (PEG ordered choice): nsb_iso | tc_document |
@@ -9,6 +10,11 @@ import { stageTokens } from "./model.js";
9
10
  * supplement tables exactly like the Ruby TYPED_STAGES constants.
10
11
  */
11
12
  const SUPPLEMENT_TYPE_KEYS = ["amd", "cor", "suppl", "ext", "add"];
13
+ /** The DAM-family ballot stage codes (grammar table
14
+ * typed_stages_ballots: PDAM, DAM, DAmd, FDAM, FDAmd, FPDAM — exactly
15
+ * these entries' abbrs); the TYPE-first ballot branch stays scoped to
16
+ * the ballot family. */
17
+ export const BALLOT_STAGE_CODES = ["pdam", "damd", "fdamd", "fpdam"];
12
18
  const BASE_STAGE_TOKENS = stageTokens().filter((t) => {
13
19
  // Supplement tokens are excluded from the base table by type key, but
14
20
  // stageTokens() is type-agnostic here; the split is done via the
@@ -208,7 +214,68 @@ function buildRules() {
208
214
  .as("base")
209
215
  .then(space.maybe(), str("|"), space.maybe(), rules["idf_identifier"])
210
216
  .as("joint_identifier"));
211
- rule("identifier", () => rules["bare_tc_document"].or(rules["nsb_iso_identifier"], rules["tc_document"], rules["directives_identifiers"], rules["jcgm_identifier"], rules["iso_r_supplement_identifier"], rules["iso_r_identifier"], rules["supplement_supplement_supplement_identifier"], rules["supplement_supplement_identifier"], rules["supplement_identifier"], rules["joint_identifier"], rules["identifier_copublishers"]));
217
+ // Historic TYPE-first ballot faces (decode-to-modern, owner ruling
218
+ // 2026-11-12): the DAM-family token in TYPE position — "ISO/IEC FDAM 1
219
+ // 14496-12:2004", "ISO/PDAM 19973-2" — and the space-attached ballot
220
+ // face "ISO 15877-5:2009 FDAM 1". Both branches sit LAST in the
221
+ // identifier alternation, so they are purely additive: every face an
222
+ // earlier branch parses keeps its exact parse. The ballot token
223
+ // captures as ballot_type_with_stage; a number on the TYPE token is
224
+ // space-bounded (ballot_number) so "PDAM 19973-2" keeps 19973 in the
225
+ // base; an on-base stamp's number/date capture as ballot_stamp_* (the
226
+ // stamp token itself matches uncaptured — the ballot's stage comes
227
+ // from the TYPE token and the decode keeps no spelling). The builder
228
+ // folds the captures into ONE amendment layer over the whole base, the
229
+ // number taken from the TYPE token, the stamp, or the implied 1.
230
+ const ballotStageTokens = ISO_TYPED_STAGES
231
+ .filter((st) => BALLOT_STAGE_CODES.includes(st.code))
232
+ .flatMap((st) => st.abbr);
233
+ const ballotTypeAlt = literalAlternation(ballotStageTokens).as("ballot_type_with_stage");
234
+ rule("ballot_type_with_stage", () => ballotTypeAlt);
235
+ // An on-base stamp riding a ballot face ("/Amd 9:2001", "/Amd.1",
236
+ // "/Am1:2006"): it carries the amendment's number and ballot date
237
+ // while its own token letters are decode-away (matched uncaptured).
238
+ // The stamp's date captures under its own ballot_stamp_* names —
239
+ // reusing the base date capture would collide with second_part's at
240
+ // the sequence merge and the stamp's year would eat the base's.
241
+ rule("ballot_stamp", () => str("/")
242
+ .then(literalAlternation(supplementTypeTokens))
243
+ .then(space
244
+ .maybe()
245
+ .then(digit.repeat(1, 5).as("ballot_stamp_number"))
246
+ .then(str(":")
247
+ .or(dash)
248
+ .then(yearDigits
249
+ .then(dash.then(monthDigits).maybe(), dash.then(dayDigits).maybe())
250
+ .as("ballot_stamp_date"))
251
+ .or(str(":")
252
+ .or(dash)
253
+ .then(str("--").or(str("—")).as("ballot_stamp_undated_marker")))
254
+ .maybe())
255
+ .or(str(".").then(digits.as("ballot_stamp_dot_number")))));
256
+ // "ISO/IEC FDAM 1 14496-12:2004", "ISO/PDAM 19973-2": the ballot
257
+ // token first, then the base kept whole. The TYPE-token number is
258
+ // space-bounded on both sides — mandatory trailing space — so
259
+ // "PDAM 19973-2" cannot read 19973 as the ballot number: with no
260
+ // second space the group backtracks and 19973 parses in second_part.
261
+ rule("ballot_supplement_identifier", () => rules["prefix_with_copublishers"]
262
+ .then(space.maybe(), str("/").maybe(), ballotTypeAlt, space.then(digit.repeat(1, Infinity).as("ballot_number")).then(space).maybe(), space.maybe(), rules["type_with_stage"].maybe(), space.maybe(), rules["second_part"], rules["ballot_stamp"].maybe()));
263
+ // The space-attached ballot face: "ISO 15877-5:2009 FDAM 1" — the
264
+ // base designation first, then the ballot token attached by a space.
265
+ // The base front is inlined (not identifier_copublishers_no_third):
266
+ // that rule ends in third_part_edition, whose own optional space
267
+ // would consume the separator the ballot token needs. No base
268
+ // edition is served here — none of the stored or owner-cited ballot
269
+ // faces carries one.
270
+ rule("ballot_space_supplement_identifier", () => rules["guide_prefix"]
271
+ .as("type_with_stage_fr")
272
+ .then(space)
273
+ .maybe()
274
+ .then(rules["prefix_with_copublishers"].maybe(), space.maybe(), str("/").maybe(), rules["type_with_stage"].maybe(), space.maybe(), rules["second_part"], space, ballotTypeAlt, space.then(digit.repeat(1, Infinity).as("ballot_number")).maybe()));
275
+ rule("identifier", () => rules["bare_tc_document"].or(rules["nsb_iso_identifier"], rules["tc_document"], rules["directives_identifiers"], rules["jcgm_identifier"], rules["iso_r_supplement_identifier"], rules["iso_r_identifier"], rules["supplement_supplement_supplement_identifier"], rules["supplement_supplement_identifier"], rules["supplement_identifier"], rules["joint_identifier"], rules["identifier_copublishers"],
276
+ // The historic ballot faces go last — purely additive branches
277
+ // (an input an earlier member parses keeps that parse).
278
+ rules["ballot_supplement_identifier"], rules["ballot_space_supplement_identifier"]));
212
279
  rule("root", () => rules["identifier"]);
213
280
  return rules;
214
281
  }
@@ -1,6 +1,6 @@
1
1
  import { ParseFailed, parseGrammar } from "../../grammar/engine.js";
2
2
  import { PubidDate, Language, Iteration } from "../../model/component.js";
3
- import { isoGrammar } from "./grammar.js";
3
+ import { isoGrammar, BALLOT_STAGE_CODES } from "./grammar.js";
4
4
  import { IsoBundledIdentifier, IsoDirectives, IsoDirectivesSupplement, IsoEdition, IsoPublisher, IsoSupplementIdentifier, IsoTcDocument, isoClassForType, locateStage, } from "./model.js";
5
5
  import { ISO_TYPED_STAGES } from "./stages.generated.js";
6
6
  /**
@@ -232,6 +232,15 @@ class IsoBuilder {
232
232
  }
233
233
  buildStandard(treeIn) {
234
234
  const tree = { ...treeIn };
235
+ // Historic TYPE-first ballot face ("ISO/IEC FDAM 1 14496-12:2004",
236
+ // "ISO/PDAM 19973-2", "ISO 15877-5:2009 FDAM 1"): the ballot_* keys
237
+ // are one amendment layer over the whole base, never a second
238
+ // supplement layer (the stored twins keep one level), so the layer
239
+ // is assembled here, before the supplement branch could read the
240
+ // ballot token as an outer wrapper.
241
+ if (tree["ballot_type_with_stage"] !== undefined) {
242
+ return this.buildBallotSupplement(tree);
243
+ }
235
244
  // Legacy ISO/R: publisher ISO + type "R".
236
245
  if (tree["iso_r_prefix"] !== undefined) {
237
246
  tree["publisher"] = "ISO";
@@ -280,8 +289,63 @@ class IsoBuilder {
280
289
  attrs["base"] = this.build(tree["base"]);
281
290
  }
282
291
  const identifier = new klass(attrs);
292
+ // The owner's no-number rule, applied on every construction path
293
+ // that routes through this builder (parse, URN, bundle members): a
294
+ // DAM-family ballot-stage amendment token with no number anywhere
295
+ // names Amendment 1 ("ISO 19973-2/PDAM", "ISO/IEC PDAM 12207"). The
296
+ // number is identity-bearing, so the default lands at build time and
297
+ // the wire hash serializes it — parse and fromHash(toHash) agree
298
+ // because both carry the filled number. The ballot stages are
299
+ // matched by the typed stage's code (the per-typed-stage key) —
300
+ // PDAM's stageCode is "cd", the harmonized range it lives in.
301
+ if (typeKey === "amd" && attrs["number"] === undefined &&
302
+ BALLOT_STAGE_CODES.includes(attrs["typedStageState"]?.entry.code ?? "")) {
303
+ identifier["number"] = "1";
304
+ }
283
305
  return identifier;
284
306
  }
307
+ /**
308
+ * Historic TYPE-first ballot face (owner ruling 2026-11-12, Option B
309
+ * decode-to-modern): "[P|F]?DAM [N] BASE [stamp]" decodes to ONE
310
+ * amendment over the WHOLE base, rendered as the canonical on-base
311
+ * slash face. The stage comes from the ballot token; the number from
312
+ * the TYPE token, then the on-base stamp, then the owner's no-number
313
+ * rule (a ballot face with no number anywhere names Amendment 1); the
314
+ * stamp's date is the ballot supplement's date. The base keeps every
315
+ * component it carried: a legacy slash-part stays a part of the base
316
+ * number ("ISO/IEC FPDAM 9075/1" → base "9075-1"), and the base's own
317
+ * type token stays the base's ("ISO/IEC PDAM TR 9573-11" → base
318
+ * "TR 9573-11") — nothing migrates into the supplement slot. The
319
+ * stamp's token letters (Amd/Am/…) are decode-away: the ballot's
320
+ * stage is the TYPE token's.
321
+ */
322
+ buildBallotSupplement(tree) {
323
+ const fields = {
324
+ type_with_stage: s(tree["ballot_type_with_stage"]),
325
+ };
326
+ const number = s(tree["ballot_number"]) ??
327
+ s(tree["ballot_stamp_number"]) ??
328
+ s(tree["ballot_stamp_dot_number"]) ??
329
+ "1";
330
+ fields["number_with_part"] = number;
331
+ // The on-base stamp: its date (or undated marker) is the ballot
332
+ // supplement's; its number feeds the precedence above; its token
333
+ // letters were matched uncaptured and decode away.
334
+ const stampDate = s(tree["ballot_stamp_date"]);
335
+ if (stampDate !== undefined) {
336
+ const [year, month, day] = stampDate.split("-");
337
+ fields["date"] = new PubidDate({ year, month, day });
338
+ }
339
+ else if (tree["ballot_stamp_undated_marker"] !== undefined) {
340
+ fields["date"] = new PubidDate({ undated: true });
341
+ }
342
+ const rest = {};
343
+ for (const [key, value] of Object.entries(tree)) {
344
+ if (!key.startsWith("ballot_"))
345
+ rest[key] = value;
346
+ }
347
+ return this.build({ ...fields, base: rest });
348
+ }
285
349
  buildDirectives(tree, state) {
286
350
  const attrs = this.baseAttrs(tree);
287
351
  const publisher = this.publisherOf(tree);
@@ -102,8 +102,14 @@ export class IsoIdentifier extends BaseIdentifier {
102
102
  const ts = this.typedStageState;
103
103
  if (ts === undefined)
104
104
  return false;
105
- if (ts.entry.longAbbr !== undefined && ts.originalAbbr?.startsWith(ts.entry.longAbbr))
105
+ if (ts.entry.longAbbr !== undefined && ts.originalAbbr !== undefined &&
106
+ (ts.originalAbbr.startsWith(ts.entry.longAbbr) ||
107
+ // The shorter long-family spellings ("Am", the alias of "Amd"
108
+ // the owner ruled renders canonical) are prefixes of the long
109
+ // abbr; the short family ("AMD") matches neither direction.
110
+ ts.entry.longAbbr.startsWith(ts.originalAbbr))) {
106
111
  return true;
112
+ }
107
113
  if (ts.entry.longAbbr !== undefined && ts.originalAbbr?.includes("Directives"))
108
114
  return true;
109
115
  return false;
@@ -32,7 +32,7 @@ export const ISO_TYPED_STAGES = [
32
32
  code: "prf_amd", stageCode: "prf", typeCode: "amd", abbr: ["PRF Amd"], harmonized: ["50.00"]
33
33
  }),
34
34
  TS("amd", {
35
- code: "published", stageCode: "published", typeCode: "amd", abbr: ["Amd", "AMD", "Amd."], shortAbbr: "AMD", longAbbr: "Amd", harmonized: ["60.00", "60.60"]
35
+ code: "published", stageCode: "published", typeCode: "amd", abbr: ["Amd", "AMD", "Amd.", "Am"], shortAbbr: "AMD", longAbbr: "Amd", harmonized: ["60.00", "60.60"]
36
36
  }),
37
37
  TS("cor", {
38
38
  code: "pwi_cor", stageCode: "proposal", typeCode: "cor", abbr: ["PWI Cor"], harmonized: ["00.00", "00.20", "00.60", "00.92", "00.93", "00.98", "00.99"]
@@ -709,8 +709,24 @@ function nistClass(spec) {
709
709
  return `${this.publisher ?? "NIST"} ${this.date_year ?? ""}-${this.date_month ?? ""}-${this.date_day ?? ""} ${this.dated_seq ?? ""}`;
710
710
  }
711
711
  if (spec.style === "fips") {
712
- let result = this.publisher !== undefined ? `${this.publisher} ` : "";
713
- result += this.seriesCode() ?? "";
712
+ // The official published face is "FIPS PUB <number>": FIPS is
713
+ // government-published and "PUB" (Federal Information Processing
714
+ // Standards PUBlication) is part of the official designation
715
+ // (owner ruling 2026-11-12). A spelled NIST publisher renders the
716
+ // official face; the historical NBS-prefixed rows keep
717
+ // "NBS FIPS <number>", and the bare publisher-less spelling keeps
718
+ // its short "FIPS <number>" face. The lead reads
719
+ // shortRenderPublisher() (the printed-publisher view), so a
720
+ // series-default publisher filled without being spelled (#704's
721
+ // ruby model) can never flip the bare face; the tail rides the
722
+ // lead.
723
+ const pub = this.shortRenderPublisher();
724
+ const series = this.seriesCode() ?? "";
725
+ let result = pub === "NIST"
726
+ ? "FIPS PUB"
727
+ : pub !== undefined && pub !== ""
728
+ ? `${pub} ${series}`
729
+ : series;
714
730
  let numberPart = this.number ?? "";
715
731
  const fy = /^(\d{1,3})-(\d{4})$/.exec(numberPart);
716
732
  if (fy !== null) {
@@ -137,6 +137,17 @@ export function preprocessNist(input) {
137
137
  cleaned = cleaned.replace(/^(NBS|NIST)(IR|FIPS|GCR|HB|MONO|MP|NCSTAR|NSRDS)/i, "$1 $2");
138
138
  cleaned = cleaned.replace(/\b(ir|sp|tn|hb|fips|ams|vts)\b/i, (m) => m.toUpperCase());
139
139
  cleaned = cleaned.replace(/\bLC\b(?!IRC)/g, "LCIRC");
140
+ // normalize_fips_pub_alias: the FIPS series' official printed face is
141
+ // "FIPS PUB <number>" — FIPS is government-published and "PUB" (Federal
142
+ // Information Processing Standards PUBlication) is part of the official
143
+ // designation — and the RENDERER emits that face directly (owner ruling
144
+ // 2026-11-12, mirroring the gem). The grammar reads the
145
+ // publisher-bearing spelling only through this fold (pubid#697): the
146
+ // printed spellings — "FIPS PUB 205" and the publisher-prefixed
147
+ // "NIST FIPS PUB 205" (pubid#702) — fold onto the one model "NIST FIPS
148
+ // <number>" the grammar already builds (same hash, same URN, same MR
149
+ // string). Anchored at the head; bare "FIPS" is untouched.
150
+ cleaned = cleaned.replace(/^(?:NIST )?FIPS PUB\b/i, "NIST FIPS");
140
151
  // normalize_lcirc_supplement_contexts
141
152
  cleaned = cleaned.replace(/\bNBS LCIRC\b(?=.*\b(?:supp?|sup\+|r\d+\/)\d)/, "NBS.LCIRC");
142
153
  cleaned = cleaned.replace(/\bNBS\.LCIRC\.(\d+r\d+\/\d{4})/, "NBS LCIRC $1");
@@ -20,11 +20,17 @@ function buildRules() {
20
20
  rule("edition_part", () => str("第").then(digits.then(str("."), digits).as("edition"), str("版")));
21
21
  rule("edition_latin", () => digits.then(str("."), digits).as("edition"));
22
22
  rule("edition", () => space.then(rules["edition_part"].or(rules["edition_latin"])));
23
+ // MLIT's volume label after the edition: 本編 (main volume) or
24
+ // 本編+付録 (main volume with appendix). ASCII or full-width (U+3000)
25
+ // space; ASCII "+" or full-width "+" (U+FF0B), which the builder
26
+ // normalizes to "+".
27
+ rule("volume_sep", () => str(" ").or(str(" ")));
28
+ rule("volume", () => rules["volume_sep"].then(str("本編").then(str("+").or(str("+")).then(str("付録")).maybe()).as("volume")));
23
29
  rule("annex_supplement", () => space.then(str("Annex")).then(space, match("[A-Z]").as("annex_letter")));
24
30
  rule("handbook", () => str("PLATEAU")
25
31
  .then(space, rules["doc_type"], space, rules["number"])
26
32
  .then(rules["annex"].maybe())
27
- .then(rules["edition"].maybe()));
33
+ .then(rules["edition"].then(rules["volume"].maybe()).maybe()));
28
34
  rule("identifier", () => rules["handbook"]
29
35
  .then(rules["annex_supplement"])
30
36
  .as("base")
@@ -42,14 +48,21 @@ function plateauClass(kind) {
42
48
  number: { type: "integer" },
43
49
  annex: { type: "integer" },
44
50
  edition: { type: "string" },
51
+ // The volume label MLIT prints after the edition: "本編" (the main
52
+ // volume alone) or "本編+付録" (main volume and appendix, one
53
+ // PDF). A nil volume means the publication has no volume label:
54
+ // "#00 第5.0版" does not subsume "#00 第5.0版 本編+付録".
55
+ volume: { type: "string" },
45
56
  });
46
- static mappings = keyValue({ wire: "number", to: "number" }, { wire: "edition", to: "edition" }, { wire: "annex", to: "annex" });
57
+ static mappings = keyValue({ wire: "number", to: "number" }, { wire: "edition", to: "edition" }, { wire: "annex", to: "annex" }, { wire: "volume", to: "volume" });
47
58
  render() {
48
59
  let result = `PLATEAU ${isHandbook ? "Handbook" : "Technical Report"} #${pad2(this.number)}`;
49
60
  if (this.annex !== undefined)
50
61
  result += `-${this.annex}`;
51
62
  if (isHandbook && this.edition !== undefined)
52
63
  result += ` 第${this.edition}版`;
64
+ if (this.volume !== undefined)
65
+ result += ` ${this.volume}`;
53
66
  return result;
54
67
  }
55
68
  }
@@ -91,6 +104,12 @@ export function plateauGrammarImplementation() {
91
104
  if (data["edition"] !== undefined && data["edition"] !== null && kind === "handbook") {
92
105
  attrs["edition"] = String(data["edition"]);
93
106
  }
107
+ // The grammar reads a volume label only after an edition, on the
108
+ // Handbook; the builder normalizes the ASCII "+" to the
109
+ // full-width "+" (MLIT prints full-width).
110
+ if (data["volume"] !== undefined && data["volume"] !== null && kind === "handbook") {
111
+ attrs["volume"] = String(data["volume"]).replaceAll("+", "+");
112
+ }
94
113
  return new KIND_CLASSES[kind](attrs);
95
114
  },
96
115
  };
@@ -15,6 +15,9 @@ export const ihoSchema = define("iho", (s) => {
15
15
  s.atom("part", "part");
16
16
  s.atom("part_edition", "parted");
17
17
  s.atom("part_year", "party");
18
+ s.atom("spec", "spec");
19
+ s.atom("spec_sub", "specsub");
20
+ s.atom("spec_variant", "specvar");
18
21
  s.atom("annex", "annex");
19
22
  s.atom("annex_edition", "anned");
20
23
  s.atom("annex_year", "annyear");
@@ -10,6 +10,7 @@ export const plateauSchema = define("plateau", (s) => {
10
10
  s.atom("publisher", "pub");
11
11
  s.atom("edition", "ed");
12
12
  s.atom("annex", "annex", "int");
13
+ s.atom("volume", "vol");
13
14
  s.type("pubid:plateau:annex", "annex", {
14
15
  core: [],
15
16
  atoms: [["letter", "letter"]],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pubid/pubid",
3
- "version": "0.1.36",
3
+ "version": "0.1.38",
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",
@@ -19,7 +19,7 @@
19
19
  "doc:api": "node scripts/api-reference.mjs"
20
20
  },
21
21
  "dependencies": {
22
- "parsanol": "0.10.45",
22
+ "parsanol": "0.10.46",
23
23
  "yaml": "^2.5.0"
24
24
  },
25
25
  "devDependencies": {