@pubid/pubid 0.1.37 → 0.1.39

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
@@ -0,0 +1,35 @@
1
+ import type { FlavorImplementation } from "../../conformance/implementation.js";
2
+ import { BaseIdentifier } from "../../model/identifier.js";
3
+ /**
4
+ * The PFS-compiled doi flavor (pubid/pubid#706 §2.2) — one definition
5
+ * file, both engines: the grammar runtime is the SAME baked artifact the
6
+ * Ruby flavor runs (pubid-grammar artifacts/doi_pfs.json, compiled from
7
+ * definitions/doi.pfs), loaded through the artifact runtime, so grammar
8
+ * changes land once and this side cannot drift. Nothing is hand-rolled:
9
+ * the model is declared attributes (serialization derives from
10
+ * BaseIdentifier), the builder is the shared one, and the render body is
11
+ * the definition's canonical template. The parity gate
12
+ * (test/doi-pfs-parity.test.ts) pins byte-identity with the coded
13
+ * {@linkcode import("../doi").DoiIdentifier} over the shared conformance
14
+ * set.
15
+ */
16
+ export declare class DoiPfsIdentifier extends BaseIdentifier {
17
+ /**
18
+ * The SCHEME-canonical wire tag (declared in the definition): to_hash
19
+ * is byte-identical with the coded flavor. Deliberately NOT registered
20
+ * in the global type map — one scheme, one `_type`, one global
21
+ * resolution (the coded class); {@linkcode fromHash} routes the tag
22
+ * back here flavor-locally, mirroring Ruby's DOI_TYPE_MAP.
23
+ */
24
+ static polymorphicName: string;
25
+ static attributes: import("../../model/attribute.js").AttributeTable;
26
+ static mappings: import("../../model/attribute.js").FieldMapping[];
27
+ render(): string;
28
+ readonly prefix: string;
29
+ readonly suffix: string;
30
+ /** The alias's own from_hash: the scheme tag routes HERE, while the
31
+ * global resolution keeps answering the coded class (coexistence, not
32
+ * cutover). */
33
+ static fromHash(hash: Record<string, unknown>): BaseIdentifier;
34
+ }
35
+ export declare function doiPfsGrammarImplementation(): FlavorImplementation;
@@ -0,0 +1,61 @@
1
+ import { ParseFailed } from "../../grammar/engine.js";
2
+ import { PubidRuntime } from "../../parg/runtime.js";
3
+ import { BaseIdentifier } from "../../model/identifier.js";
4
+ import { extendAttributes, keyValue } from "../../model/attribute.js";
5
+ /**
6
+ * The PFS-compiled doi flavor (pubid/pubid#706 §2.2) — one definition
7
+ * file, both engines: the grammar runtime is the SAME baked artifact the
8
+ * Ruby flavor runs (pubid-grammar artifacts/doi_pfs.json, compiled from
9
+ * definitions/doi.pfs), loaded through the artifact runtime, so grammar
10
+ * changes land once and this side cannot drift. Nothing is hand-rolled:
11
+ * the model is declared attributes (serialization derives from
12
+ * BaseIdentifier), the builder is the shared one, and the render body is
13
+ * the definition's canonical template. The parity gate
14
+ * (test/doi-pfs-parity.test.ts) pins byte-identity with the coded
15
+ * {@linkcode import("../doi").DoiIdentifier} over the shared conformance
16
+ * set.
17
+ */
18
+ export class DoiPfsIdentifier extends BaseIdentifier {
19
+ /**
20
+ * The SCHEME-canonical wire tag (declared in the definition): to_hash
21
+ * is byte-identical with the coded flavor. Deliberately NOT registered
22
+ * in the global type map — one scheme, one `_type`, one global
23
+ * resolution (the coded class); {@linkcode fromHash} routes the tag
24
+ * back here flavor-locally, mirroring Ruby's DOI_TYPE_MAP.
25
+ */
26
+ static polymorphicName = "pubid:doi:resource";
27
+ static attributes = extendAttributes(BaseIdentifier, {
28
+ prefix: { type: "string" },
29
+ suffix: { type: "string" },
30
+ });
31
+ static mappings = keyValue({ wire: "prefix", to: "prefix" }, { wire: "suffix", to: "suffix" });
32
+ render() {
33
+ return `doi:${this.prefix}/${this.suffix}`;
34
+ }
35
+ /** The alias's own from_hash: the scheme tag routes HERE, while the
36
+ * global resolution keeps answering the coded class (coexistence, not
37
+ * cutover). */
38
+ static fromHash(hash) {
39
+ return new DoiPfsIdentifier(this.applyMappings(this.inflateScalarComponents(hash)));
40
+ }
41
+ }
42
+ /**
43
+ * The artifact's declared bindings (prefix -> prefix, suffix -> suffix)
44
+ * carry the whole printed prefix face ("10.1000") — the doi.pfs pattern
45
+ * captures the "10." face the coded grammar re-assembles — so the bound
46
+ * map lands on the model fields directly, no builder cast.
47
+ */
48
+ let cachedRuntime;
49
+ export function doiPfsGrammarImplementation() {
50
+ return {
51
+ parse(input) {
52
+ cachedRuntime ??= PubidRuntime.load("doi_pfs");
53
+ const bound = cachedRuntime.parseAndBind(input);
54
+ if (bound === null || typeof bound !== "object") {
55
+ throw new ParseFailed("doi_pfs: the artifact rejected the input", 0);
56
+ }
57
+ const identifier = new DoiPfsIdentifier(bound);
58
+ return identifier;
59
+ },
60
+ };
61
+ }
@@ -106,11 +106,45 @@ class EcmaBuilder extends BaseBuilder {
106
106
  return value;
107
107
  }
108
108
  }
109
+ /**
110
+ * The human edition spellings that alias the glued " ed<N>" token
111
+ * (pubid#698; ruby Ecma::Parser.normalize_input), in rewrite order.
112
+ * Each rule rewrites INTO the exact canonical face, so the parse (and
113
+ * therefore the render, the URN and the index key) is the one " ed<N>"
114
+ * already produces, and a canonical face passes through
115
+ * byte-identically.
116
+ *
117
+ * ECMA-262 Edition 5.1 the document cover's own form
118
+ * ECMA-262 ed. 5.1 dotted, spaced
119
+ * ECMA-262 ed 5.1 spaced, undotted
120
+ * ECMA-262 ed.5.1 dotted, unspaced
121
+ * ECMA 262 5.1 bare trailing edition
122
+ *
123
+ * " ed5.1" (no separator) is the grammar's own face and is left alone;
124
+ * a " vol<N>" number is never rewritten — the bare-trailing rule fires
125
+ * only behind a document number's own digit ("ECMA 262 5.1"), so the
126
+ * space-separator form itself ("ECMA 6") keeps its number and the
127
+ * grammar's ed-then-vol order is untouched.
128
+ */
129
+ const EDITION_ALIASES = [
130
+ // "Edition <N>" starts mid-word, so the replacement carries no space
131
+ // of its own — the space before it is kept as-is.
132
+ [/\bedition[.\s]*(\d+(?:\.\d+)?)\b/gi, "ed$1"],
133
+ // "ed. <N>" / "ed <N>" / "ed.<N>": same mid-word shape. The glued
134
+ // "ed5.1" has no separator after "ed" and cannot match [.\s]+.
135
+ [/\bed[.\s]+(\d+(?:\.\d+)?)\b/g, "ed$1"],
136
+ // Bare trailing edition: fires only when a document number is already
137
+ // present (a digit directly before the space — "ECMA 262 5.1"). A
138
+ // glued "vol2" has no space, and a spaced "vol 2" has no digit before
139
+ // the space, so a volume number is never rewritten.
140
+ [/(\d) (\d+(?:\.\d+)?)$/, "$1 ed$2"],
141
+ ];
109
142
  export function ecmaGrammarImplementation() {
110
143
  const builder = new EcmaBuilder();
111
144
  return {
112
145
  parse(input) {
113
- const tree = parseGrammar(ecmaGrammar, input);
146
+ const normalized = EDITION_ALIASES.reduce((acc, [pattern, replacement]) => acc.replace(pattern, replacement), input);
147
+ const tree = parseGrammar(ecmaGrammar, normalized);
114
148
  if (typeof tree !== "object" || tree === null || Array.isArray(tree)) {
115
149
  throw new ParseFailed("ECMA: unexpected parse tree", 0);
116
150
  }
@@ -86,8 +86,9 @@ function buildRules() {
86
86
  // --- supplements ---
87
87
  const SUPP_ABBRS = [
88
88
  "PRF Amd", "PWI Amd", "NP Amd", "ANW Amd", "WD Amd",
89
- "PRF Cor", "PWI Cor", "NP Cor", "ANW Cor", "WD Cor", "CDCor", "WDCor",
90
- "CDAM", "DAM", "FDAM", "DCOR", "FDCOR", "AMD", "Amd", "COR", "Cor", "ISH", "Ish",
89
+ "PRF Cor", "PWI Cor", "NP Cor", "ANW Cor", "WD Cor", "CDCor", "CDCOR", "WDCor",
90
+ "CDAM", "DAM", "FDAM", "PDAM", "FPDAM", "DCOR", "FDCOR", "AMD", "Amd", "COR", "Cor",
91
+ "CDISH", "DISH", "ISH", "Ish",
91
92
  ];
92
93
  rule("supplement_type_with_stage", () => SUPP_ABBRS.reduce((acc, a) => acc.or(str(a)), str(SUPP_ABBRS[0]))
93
94
  .or(str("FRAG"))
@@ -155,6 +156,15 @@ function buildRules() {
155
156
  .then(str("/"), rules["supplement_type_with_stage"])
156
157
  .then(spaceQ, rules["second_part"], rules["third_part"]));
157
158
  rule("supplement_identifier_no_third", () => rules["identifier_copublishers_no_third"].as("base")
159
+ // The [language] slot between the base designation and the
160
+ // supplement token: the renderer prints the BASE document's
161
+ // languages right after its date
162
+ // ("ISO/IEC 19772:2009(en)/DAM1:2019" — the rendered twin of the
163
+ // stored "ISO/IEC 19772:2009/DAM 1:2019(E)" input), and without
164
+ // the slot the canonical face could not re-parse. The capture
165
+ // lands on the base (the builder's supplement branch routes it
166
+ // there, like the supplement's own third_part language).
167
+ .then(rules["language"].maybe())
158
168
  .then(str("/"), rules["supplement_type_with_stage"])
159
169
  .then(spaceQ, rules["second_part"]));
160
170
  rule("supplement_identifier", () => rules["supplement_identifier_no_third"].then(rules["third_part"]));
@@ -164,9 +174,36 @@ function buildRules() {
164
174
  rule("standalone_supplement", () => rules["prefix_with_copublishers"]
165
175
  .then(str("/"), rules["supplement_type_with_stage"])
166
176
  .then(space, rules["second_part"], rules["third_part"]));
177
+ // --- the legacy DAM-family prefix face (the JTC1 ballot spelling;
178
+ // decode-to-modern, owner ruling 2026-11-12) ---
179
+ // The stage token precedes the base designation — "IEC/FDAM 60038-1",
180
+ // "ISO/IEC PDAM 12207", "ISO/IEC PDAM 2 10646:2012",
181
+ // "ISO/IEC FPDAM 9075/1", "ISO/IEC FDAM 1 14496-12:2004". A number
182
+ // between the token and the designation is the SUPPLEMENT number; the
183
+ // designation is the base's and is kept whole (unnumbered
184
+ // DAM/PDAM/FDAM/FPDAM means Amendment 1, and the old slash-part
185
+ // spelling "9075/1" carries the base's part). The date rides with the
186
+ // designation — old notation prints the base's year, and the draft
187
+ // supplement carries none of its own. Longest tokens first: FPDAM
188
+ // before PDAM, FDAM before DAM. The slash-part alternative precedes
189
+ // the dash-part one: the designation body also matches a bare number,
190
+ // so a slash-part input must be claimed first or the "/1" would
191
+ // dangle unclaimed.
192
+ rule("legacy_dam_token", () => str("FPDAM").or(str("PDAM"), str("FDAM"), str("DAM")).as("legacy_stage"));
193
+ // The copublisher captures as its own top-level scalar (the wasm
194
+ // binder scatters array bindings over the sibling list when a bare
195
+ // copublisher node rides a flat shape — the parg uses the same
196
+ // legacy_copublisher_item rule for that).
197
+ rule("legacy_copublisher_item", () => str("/").then(spaceQ, ORGS.reduce((acc, o) => acc.or(str(o)), str(ORGS[0])).as("legacy_copublisher")));
198
+ rule("legacy_base_designation", () => match("[0-9]").repeat(1, 6).as("legacy_base_number")
199
+ .then(str("/"), match("[0-9]").repeat(1, Infinity).as("legacy_base_part"))
200
+ .or(rules["number_with_part"].as("legacy_base_number_with_part")));
201
+ rule("legacy_supplement", () => rules["prefix_sole_publisher"]
202
+ .then(spaceQ, rules["legacy_copublisher_item"].maybe(), spaceQ, str("/").maybe(), rules["legacy_dam_token"], space, match("[0-9]").repeat(1, Infinity).as("legacy_supplement_number").then(space).maybe(), rules["legacy_base_designation"], (spaceQ.then(rules["date"])).maybe(), rules["third_part"]));
167
203
  rule("identifier", () => rules["working_programme"]
168
204
  .or(rules["technical_group"])
169
205
  .or(rules["working_document"])
206
+ .or(rules["legacy_supplement"])
170
207
  .or(rules["sheet_supplement_identifier"])
171
208
  .or(rules["sheet_identifier"])
172
209
  .or(rules["supplement_supplement_identifier"])
@@ -62,6 +62,20 @@ class IecBuilder {
62
62
  delete tree["vap_suffix"];
63
63
  tree["base"]["vap_suffix"] = vapSuffix;
64
64
  }
65
+ // The third_part language rides the BASE document on every
66
+ // attached-supplement layer: the ruby grammar binds the layers'
67
+ // language unprefixed, the supplement field set keeps none
68
+ // (SUPP_KEYS), and the wire carries base.languages — the ruby
69
+ // render prints it inside the base portion
70
+ // ("ISO/IEC 19772:2009(en)/DAM1:2019"). Push it down before
71
+ // dispatching; the recursion re-applies it until it reaches the
72
+ // base. A standalone supplement (no base tree) keeps its own
73
+ // languages, like ruby's standalone slice.
74
+ const languages = tree["languages"];
75
+ if (languages !== undefined && isObj(tree["base"])) {
76
+ delete tree["languages"];
77
+ tree["base"]["languages"] = languages;
78
+ }
65
79
  // --- working programme / working document ---
66
80
  if (tree["wp_stage"] !== undefined)
67
81
  return this.buildWorkingProgramme(tree);
@@ -71,6 +85,10 @@ class IecBuilder {
71
85
  if (tree["technical_committee"] !== undefined) {
72
86
  throw new ParseFailed("IEC: technical groups are not corpus-covered", 0);
73
87
  }
88
+ // --- the legacy DAM-family prefix face (decode-to-modern) ---
89
+ if (tree["legacy_stage"] !== undefined) {
90
+ return this.buildLegacySupplement(tree);
91
+ }
74
92
  // --- fragment of an amendment/corrigendum (third_part notation) ---
75
93
  if (tree["fragment_type"] !== undefined && tree["base"] !== undefined) {
76
94
  return this.buildFragment(tree);
@@ -153,6 +171,41 @@ class IecBuilder {
153
171
  kind: kind,
154
172
  attrs,
155
173
  };
174
+ // The ensure_base decode (ruby SupplementIdentifier#ensure_base;
175
+ // the 2026-11-12 owner ruling reversing #693): a standalone draft
176
+ // face whose designation is designation-shaped ("IEC/DCOR 60038-1",
177
+ // "IEC CDISH 60050-191") moves the designation WHOLE onto a
178
+ // concrete InternationalStandard base — number, part, subpart and
179
+ // the date (old notation prints the base's year) — while the
180
+ // supplement keeps a number of its own, defaulting to 1 (the
181
+ // no-number rule: an unnumbered DCOR/DISH/CDISH means
182
+ // Corrigendum/Interpretation-Sheet 1). The concrete base is what
183
+ // makes the decode hash-match (and equal) the modern on-base parse
184
+ // of the same document. A supplement with no part (a true
185
+ // standalone like "IEC/AMD 1:2017") keeps its own number below.
186
+ if ((kind === "amd" || kind === "cor" || kind === "ish") &&
187
+ tree["base"] === undefined &&
188
+ attrs["part"] !== undefined) {
189
+ const baseAttrs = {};
190
+ for (const key of ["publisher", "copublishers", "number", "part", "subpart", "date"]) {
191
+ if (attrs[key] !== undefined)
192
+ baseAttrs[key] = attrs[key];
193
+ }
194
+ const base = new IEC_CLASSES.KIND_CLASSES["is"](baseAttrs);
195
+ const suppAttrs = { base, number: "1" };
196
+ // Supplements copy the delegated publisher/copublishers level
197
+ // (ruby reads them through the base; the stored wire copies the
198
+ // non-default level).
199
+ if (attrs["publisher"] !== undefined && attrs["publisher"] !== "IEC") {
200
+ suppAttrs["publisher"] = attrs["publisher"];
201
+ }
202
+ if (Array.isArray(attrs["copublishers"]) && attrs["copublishers"].length > 0) {
203
+ suppAttrs["copublishers"] = attrs["copublishers"];
204
+ }
205
+ const supplement = this.construct({ kind: kind, attrs: suppAttrs, typedStage }, suppAttrs);
206
+ supplement["base"] = base;
207
+ return supplement;
208
+ }
156
209
  // Consolidated supplements (+AMD…+AMD…)
157
210
  const consolidated = tree["consolidated_supplements"];
158
211
  let identifier = this.construct(parsed, attrs);
@@ -219,6 +272,63 @@ class IecBuilder {
219
272
  ...(edition !== undefined ? { edition } : {}),
220
273
  });
221
274
  }
275
+ /**
276
+ * The legacy DAM-family prefix face (IEC/FDAM 60038-1, ISO/IEC PDAM
277
+ * 2 10646:2012, ISO/IEC FPDAM 9075/1): the JTC1 ballot spelling puts
278
+ * the stage token before the base designation. Decode onto the
279
+ * modern identity (owner ruling 2026-11-12): the designation is the
280
+ * BASE's, kept whole — the old slash-part spelling "9075/1" is base
281
+ * 9075 part 1 — and the supplement's number is the captured N,
282
+ * defaulting to 1 (the no-number rule). The date rides with the
283
+ * designation (old notation prints the base's year); a draft
284
+ * supplement carries none of its own. The base builds through the
285
+ * ordinary single-document path, so it is the same
286
+ * InternationalStandard the modern on-base parse holds and
287
+ * parse(legacy) == parse(modern).
288
+ */
289
+ buildLegacySupplement(tree) {
290
+ const baseTree = {};
291
+ if (tree["publisher"] !== undefined)
292
+ baseTree["publisher"] = tree["publisher"];
293
+ if (tree["legacy_copublisher"] !== undefined) {
294
+ baseTree["copublishers"] = [s(tree["legacy_copublisher"])];
295
+ }
296
+ if (tree["legacy_base_number"] !== undefined) {
297
+ // The slash-part designation ("9075/1") captured number and part
298
+ // separately; they go on straight as the plain strings the
299
+ // retyped attributes are.
300
+ baseTree["number_with_part"] = {
301
+ number: s(tree["legacy_base_number"]),
302
+ ...(s(tree["legacy_base_part"]) !== undefined ? { part: s(tree["legacy_base_part"]) } : {}),
303
+ };
304
+ }
305
+ else if (tree["legacy_base_number_with_part"] !== undefined) {
306
+ const designation = tree["legacy_base_number_with_part"];
307
+ baseTree["number_with_part"] = isObj(designation)
308
+ ? flatten(designation)["number_with_part"]
309
+ : designation;
310
+ }
311
+ if (tree["date"] !== undefined)
312
+ baseTree["date"] = tree["date"];
313
+ if (tree["undated_marker"] !== undefined)
314
+ baseTree["undated_marker"] = tree["undated_marker"];
315
+ if (tree["edition"] !== undefined)
316
+ baseTree["edition"] = tree["edition"];
317
+ if (tree["languages"] !== undefined)
318
+ baseTree["languages"] = tree["languages"];
319
+ // The wrap: one amendment/corrigendum layer over the whole base;
320
+ // third_part's fragment notation rides the outermost layer.
321
+ const suppTree = {
322
+ base: baseTree,
323
+ type_with_stage: s(tree["legacy_stage"]),
324
+ number_with_part: { number: s(tree["legacy_supplement_number"]) ?? "1" },
325
+ };
326
+ if (tree["fragment_type"] !== undefined) {
327
+ suppTree["fragment_type"] = tree["fragment_type"];
328
+ suppTree["fragment_number"] = tree["fragment_number"];
329
+ }
330
+ return this.buildInner(suppTree);
331
+ }
222
332
  buildSupplement(tree) {
223
333
  const base = this.buildInner(tree["base"]);
224
334
  const stageAbbr = s(tree["type_with_stage"]) ?? "";
@@ -44,8 +44,20 @@ export const TYPED_STAGES = [
44
44
  TS("ndts", "ndts", "ts", "NDTS"),
45
45
  TS("dpas", "dpas", "pas", "DPAS"),
46
46
  TS("cdpas", "cdpas", "pas", "CDPAS"),
47
- TS("dam", "dam", "amd", "DAM"),
48
- TS("fdam", "fdam", "amd", "FDAM"),
47
+ // The DAM family's ballot stages carry the CIRCULATION slots as the
48
+ // lead harmonized code (30.20/50.20), not ISO's 30.00/50.00 — CDAM/
49
+ // FDAM keep those (type, leading code) URN keys (gem Amendment
50
+ // registry; the codes are the per-typed-stage keys the wire
51
+ // serializes: pdam, damd, fdamd, fpdam).
52
+ TS("pdam", "cd", "amd", "PDAM", "30.20"),
53
+ TS("damd", "damd", "amd", "DAM", "40.00"),
54
+ TS("fdamd", "fdamd", "amd", "FDAM", "50.00", ["FDAM", "PRF Amd"]),
55
+ TS("fpdam", "fdamd", "amd", "FPDAM", "50.20"),
56
+ // "CDCOR" is the all-caps spelling the attached-supplement renderer
57
+ // emits (abbr.first upcase) when a legacy prefix face decodes onto a
58
+ // CDCor draft; appended as a token so the canonical render re-parses
59
+ // (spelling-only: abbr stays "CDCor").
60
+ TS("cdcor", "cd", "cor", "CDCor", "30.00", ["CDCor", "CDCOR"]),
49
61
  TS("dcor", "dcor", "cor", "DCOR"),
50
62
  TS("fdcor", "fdcor", "cor", "FDCOR"),
51
63
  TS("cdish", "cdish", "ish", "CDISH"),
@@ -311,6 +323,10 @@ function iecSupplementClass(kind) {
311
323
  static attributes = extendAttributes(BaseIdentifier, {
312
324
  base: { type: IecIdentifier },
313
325
  number: { type: "string" },
326
+ // The draft stage rides the wire as the typed stage's code
327
+ // ("stage: fdamd") — the single classes declare the same attr
328
+ // (SINGLE_ATTRS.stage); construct sets it on the parse path.
329
+ stage: { type: "string" },
314
330
  synthetic_base: { type: "boolean", default: false },
315
331
  });
316
332
  static mappings = keyValue({ wire: "number", to: "number" }, { wire: "year", to: "date", toWire: (m) => m["date"]?.year, fromWire: yearFromWire }, { wire: "month", to: "date", toWire: (m) => m["date"]?.month, fromWire: () => undefined }, { wire: "day", to: "date", toWire: (m) => m["date"]?.day, fromWire: () => undefined }, { wire: "edition", to: "edition" }, {
@@ -326,7 +342,11 @@ function iecSupplementClass(kind) {
326
342
  return list !== undefined && list.length > 0 ? list : undefined;
327
343
  },
328
344
  fromWire: (h) => (Array.isArray(h["copublishers"]) ? h["copublishers"].map(String) : undefined),
329
- }, { wire: "base", to: "base" });
345
+ },
346
+ // The draft stage rides the wire as the typed stage's code
347
+ // ("stage: fdamd"); the single classes map the same key (ruby
348
+ // serializes typed_stage.code and restores it in from_hash).
349
+ { wire: "stage", to: "stage" }, { wire: "base", to: "base" });
330
350
  render() {
331
351
  const base = this.base;
332
352
  if (base === undefined || this.synthetic_base === true) {
@@ -340,7 +360,12 @@ function iecSupplementClass(kind) {
340
360
  return result;
341
361
  }
342
362
  let result = base.render();
343
- result += `/${abbr}${this.number ?? ""}`;
363
+ // The attached-supplement renderer prints the typed stage's own
364
+ // abbreviation, upcased (ruby renderer.rb: abbr.first.upcase) —
365
+ // a published Amendment renders "/AMD1", a draft stage its own
366
+ // token ("/FDAM1", "/DCOR1", "/CDCOR1" from the "CDCor" entry's
367
+ // spelling-only upcase).
368
+ result += `/${(this.typedStage?.abbr ?? abbr).toUpperCase()}${this.number ?? ""}`;
344
369
  if (this.date !== undefined)
345
370
  result += `:${this.date.render()}`;
346
371
  if (this.edition !== undefined)
@@ -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) {
@@ -2,6 +2,7 @@ import { lookupUrnParser, parseUrnFor } from "../urn/parsers.js";
2
2
  import { oimlGrammarImplementation } from "./oiml/implementation.js";
3
3
  import { xsfGrammarImplementation } from "./xsf/index.js";
4
4
  import { doiGrammarImplementation } from "./doi/index.js";
5
+ import { doiPfsGrammarImplementation } from "./doi_pfs/index.js";
5
6
  import { ianaGrammarImplementation } from "./iana/index.js";
6
7
  import { unGrammarImplementation } from "./un/index.js";
7
8
  import { plateauGrammarImplementation } from "./plateau/index.js";
@@ -81,6 +82,8 @@ function grammarImplementationSwitch(flavor) {
81
82
  return xsfGrammarImplementation();
82
83
  case "doi":
83
84
  return doiGrammarImplementation();
85
+ case "doi_pfs":
86
+ return doiPfsGrammarImplementation();
84
87
  case "iana":
85
88
  return ianaGrammarImplementation();
86
89
  case "un":
@@ -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;