pubid 2.0.0.pre.alpha.1 → 2.0.0.pre.alpha.2

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.
Files changed (79) hide show
  1. checksums.yaml +4 -4
  2. data/data/nist/update_codes.yaml +2 -0
  3. data/lib/pubid/amca/identifier.rb +39 -0
  4. data/lib/pubid/ansi/identifier.rb +42 -0
  5. data/lib/pubid/api/identifier.rb +47 -0
  6. data/lib/pubid/ashrae/identifier.rb +39 -0
  7. data/lib/pubid/asme/identifier.rb +46 -0
  8. data/lib/pubid/astm/identifier.rb +77 -0
  9. data/lib/pubid/bsi/identifier.rb +60 -0
  10. data/lib/pubid/ccsds/identifier.rb +68 -0
  11. data/lib/pubid/ccsds/identifiers/base.rb +11 -0
  12. data/lib/pubid/ccsds/single_identifier.rb +4 -1
  13. data/lib/pubid/cen_cenelec/identifier.rb +37 -0
  14. data/lib/pubid/cie/identifier.rb +53 -0
  15. data/lib/pubid/components/factory.rb +50 -0
  16. data/lib/pubid/components/typed_stage.rb +4 -0
  17. data/lib/pubid/components.rb +1 -0
  18. data/lib/pubid/csa/identifier.rb +56 -0
  19. data/lib/pubid/etsi/identifier.rb +43 -0
  20. data/lib/pubid/identifier.rb +8 -1
  21. data/lib/pubid/idf/identifier.rb +60 -0
  22. data/lib/pubid/iec/builder.rb +2 -1
  23. data/lib/pubid/iec/components/code.rb +2 -1
  24. data/lib/pubid/iec/components/publisher.rb +2 -1
  25. data/lib/pubid/iec/identifier.rb +235 -0
  26. data/lib/pubid/iec/identifiers/base.rb +4 -0
  27. data/lib/pubid/iec/identifiers/consolidated_identifier.rb +0 -4
  28. data/lib/pubid/iec/identifiers/fragment_identifier.rb +0 -4
  29. data/lib/pubid/iec/identifiers/sheet_identifier.rb +0 -4
  30. data/lib/pubid/iec/identifiers/vap_identifier.rb +0 -4
  31. data/lib/pubid/iec/parser.rb +7 -2
  32. data/lib/pubid/iec/urn_generator.rb +57 -171
  33. data/lib/pubid/iec/urn_parser.rb +53 -252
  34. data/lib/pubid/ieee/identifier.rb +41 -0
  35. data/lib/pubid/iho/identifier.rb +42 -0
  36. data/lib/pubid/iho/identifiers/base.rb +1 -1
  37. data/lib/pubid/iho/identifiers/bibliographic.rb +0 -4
  38. data/lib/pubid/iho/identifiers/circular_letter.rb +0 -4
  39. data/lib/pubid/iho/identifiers/miscellaneous.rb +0 -4
  40. data/lib/pubid/iho/identifiers/publication.rb +0 -4
  41. data/lib/pubid/iho/identifiers/standard.rb +0 -4
  42. data/lib/pubid/iho/urn_generator.rb +1 -1
  43. data/lib/pubid/iso/builder.rb +5 -1
  44. data/lib/pubid/iso/identifier.rb +261 -0
  45. data/lib/pubid/iso/parser.rb +4 -2
  46. data/lib/pubid/iso/scheme.rb +6 -0
  47. data/lib/pubid/iso/single_identifier.rb +6 -3
  48. data/lib/pubid/iso/urn_generator.rb +17 -3
  49. data/lib/pubid/iso/urn_parser.rb +16 -2
  50. data/lib/pubid/itu/identifier.rb +87 -22
  51. data/lib/pubid/jcgm/identifier.rb +43 -0
  52. data/lib/pubid/jis/identifier.rb +43 -0
  53. data/lib/pubid/nist/builder.rb +174 -5
  54. data/lib/pubid/nist/components/edition.rb +16 -0
  55. data/lib/pubid/nist/components/supplement.rb +88 -21
  56. data/lib/pubid/nist/identifier.rb +62 -0
  57. data/lib/pubid/nist/identifiers/base.rb +103 -24
  58. data/lib/pubid/nist/identifiers/circular_supplement.rb +1 -1
  59. data/lib/pubid/nist/identifiers/crpl_report.rb +1 -4
  60. data/lib/pubid/nist/identifiers/federal_information_processing_standards.rb +10 -0
  61. data/lib/pubid/nist/identifiers/report.rb +1 -2
  62. data/lib/pubid/nist/parser.rb +36 -3
  63. data/lib/pubid/nist/supplement_identifier.rb +8 -24
  64. data/lib/pubid/nist/urn_generator.rb +14 -8
  65. data/lib/pubid/nist.rb +1 -0
  66. data/lib/pubid/oiml/identifier.rb +50 -0
  67. data/lib/pubid/plateau/identifier.rb +57 -0
  68. data/lib/pubid/plateau.rb +1 -0
  69. data/lib/pubid/renderers/base.rb +34 -0
  70. data/lib/pubid/renderers/directives_renderer.rb +13 -14
  71. data/lib/pubid/renderers/guide_renderer.rb +5 -1
  72. data/lib/pubid/renderers/human_readable.rb +20 -8
  73. data/lib/pubid/renderers/iwa_renderer.rb +5 -1
  74. data/lib/pubid/renderers/supplement_renderer.rb +4 -1
  75. data/lib/pubid/rendering/context.rb +5 -2
  76. data/lib/pubid/sae/identifier.rb +23 -0
  77. data/lib/pubid/scheme.rb +12 -0
  78. data/lib/pubid/version.rb +1 -1
  79. metadata +5 -2
@@ -50,10 +50,13 @@ module Pubid
50
50
  attribute :update_year, :string
51
51
  attribute :addendum, :string
52
52
  attribute :addendum_number, :string
53
- attribute :supplement, :string
54
- attribute :supplement_date_range_start, :string # For date ranges like Jan1924-Jan1926
55
- attribute :supplement_date_range_end, :string
56
- attribute :supplement_has_revision, :boolean, default: -> { false }
53
+ # Single source of truth for the supplement: a structured component with
54
+ # isolated parts (number / year / month / date-range / revision), so a
55
+ # supplement's year is queryable independently of its number. Presence
56
+ # (non-nil) means "is a supplement"; an all-empty component is a bare
57
+ # "sup" marker. Replaces the former flat :supplement string plus the
58
+ # separate date-range/has_revision fields.
59
+ attribute :supplement, Components::Supplement
57
60
  attribute :errata, :string
58
61
  attribute :index, :string
59
62
  attribute :insert, :string
@@ -84,6 +87,90 @@ module Pubid
84
87
  # See lib/pubid/nist/builder.rb lines 368-472 for compound number logic
85
88
  end
86
89
 
90
+ # Attributes that are build artifacts or rendering aliases, not part
91
+ # of an identifier's logical identity. They diverge between equally-
92
+ # valid spellings of the same id (e.g. long "Rev. 1" vs short "r1"):
93
+ # - edition_component: redundant alias of :edition
94
+ # - first_number/second_number: decomposed parts of the canonical
95
+ # :number, retained from the parse for building
96
+ # - parsed_format: records the input format for round-trip rendering
97
+ EQUALITY_IGNORED_ATTRS = %i[
98
+ edition_component first_number second_number parsed_format
99
+ ].freeze
100
+
101
+ # Logical identity comparison: equal when every attribute except the
102
+ # build artifacts/aliases above matches. (Edition#== already ignores
103
+ # its rendering-only original_prefix.)
104
+ def ==(other)
105
+ return false unless other.instance_of?(self.class)
106
+
107
+ self.class.attributes.each_key.all? do |name|
108
+ EQUALITY_IGNORED_ATTRS.include?(name) || send(name) == other.send(name)
109
+ end
110
+ end
111
+
112
+ alias eql? ==
113
+
114
+ def hash
115
+ vals = self.class.attributes.each_key.reject do |name|
116
+ EQUALITY_IGNORED_ATTRS.include?(name)
117
+ end.map { |name| send(name) }
118
+ [self.class, *vals].hash
119
+ end
120
+
121
+ # Wildcard / partial-identifier match. Treats +self+ as a QUERY pattern
122
+ # and +candidate+ as a concrete document: every ID part SET on the query
123
+ # must equal the candidate's, while parts left unset (nil/empty) are
124
+ # wildcards that match any value. So a query carrying no edition and no
125
+ # supplement matches that document across ALL editions, years, and
126
+ # supplements — the basis for "select docs by ID parts".
127
+ #
128
+ # Asymmetric (unlike ==): "NBS CIRC 25".matches?("NBS CIRC 25sup1924")
129
+ # is true, but not the reverse. The candidate must be the same class or
130
+ # a subclass so series-level identity still holds.
131
+ def matches?(candidate)
132
+ return false unless candidate.is_a?(self.class)
133
+
134
+ self.class.attributes.each_key.all? do |name|
135
+ next true if EQUALITY_IGNORED_ATTRS.include?(name)
136
+
137
+ query_val = public_send(name)
138
+ next true if query_val.nil? ||
139
+ (query_val.respond_to?(:empty?) && query_val.empty?)
140
+
141
+ query_val == candidate.public_send(name)
142
+ end
143
+ end
144
+
145
+ # Return a copy with the named attributes nil'd. Overrides
146
+ # Pubid::Identifier#exclude because NIST's initialize is keyword-only
147
+ # (initialize(**attributes)) while the inherited exclude rebuilds via
148
+ # the positional self.class.new(attrs) form — passing a positional
149
+ # hash to a keyword-only initializer raises ArgumentError. Rebuild
150
+ # with the keyword splat instead.
151
+ def exclude(*args)
152
+ excluded_args = args.dup
153
+ excluded_args << :date if excluded_args.delete(:year)
154
+
155
+ attrs = self.class.attributes.each_with_object({}) do |(name, _), h|
156
+ h[name] = excluded_args.include?(name) ? nil : send(name)
157
+ end
158
+ self.class.new(**attrs)
159
+ end
160
+
161
+ # Short-form supplement fragment ("sup", "sup1924", "supJan1924",
162
+ # "suprev", " supJun1925-Jun1926"), rendered from the structured
163
+ # component. A present-but-empty component is the bare "sup" marker; a
164
+ # number-less date range gets the leading space the number would have
165
+ # supplied. Shared by base and the per-series to_short_style overrides.
166
+ def supplement_short
167
+ return "" unless supplement
168
+
169
+ prefix = (supplement.range? && !number) ? " " : ""
170
+ rendered = supplement.to_s(:short)
171
+ prefix + (rendered.empty? ? "sup" : rendered)
172
+ end
173
+
87
174
  # Compute revision from edition component for backward compatibility
88
175
  # @return [String, nil] revision string (e.g., "r5") or nil
89
176
  def revision
@@ -333,30 +420,22 @@ module Pubid
333
420
  end
334
421
  end
335
422
 
336
- # V2: Use version_component if available, else use version string
423
+ # V2: Use version_component if available, else use version string.
424
+ # Attach directly (no leading space) to match edition rendering
425
+ # (e.g. "800-53r5"), so version reads "800-45ver2" not
426
+ # "800-45 ver2".
337
427
  if version_component
338
- result += " #{version_component.to_s(:short)}"
428
+ result += version_component.to_s(:short)
339
429
  elsif version
340
- result += " Ver. #{version}"
430
+ result += "ver#{version}"
341
431
  end
342
432
 
343
- # Add supplement with date range support - FIX: proper spacing
344
- if supplement_date_range_start && supplement_date_range_end
345
- result += "supp#{supplement_date_range_start}-#{supplement_date_range_end}"
346
- elsif supplement_has_revision
347
- result += "supprev"
348
- elsif supplement && !supplement.empty?
349
- # Smart dash logic:
350
- # - If supplement starts with letter (month like "Jan1924"), NO dash
351
- # - If supplement is digits only (year like "1924"), WITH dash
352
- result += if supplement.match?(/^[A-Z]/)
353
- "supp#{supplement}"
354
- else
355
- "supp-#{supplement}"
356
- end
357
- elsif supplement
358
- result += "supp"
359
- end
433
+ # Add supplement. NIST/NBS canonical short form is single-p "sup"
434
+ # with the suffix attached directly, no dash (relaton-data-nist
435
+ # uses "sup2", "sup1940", "supA"); date-range keeps its inner dash.
436
+ # Rendered from the structured component; a present-but-empty
437
+ # component is the bare "sup" marker.
438
+ result += supplement_short
360
439
 
361
440
  # Add other attributes
362
441
  result += errata.to_s if errata
@@ -38,7 +38,7 @@ module Pubid
38
38
  def to_s(format = :short)
39
39
  # Handle date range supplements (no base identifier)
40
40
  if supplement_date_range_start && supplement_date_range_end
41
- return "NBS CIRC supp#{supplement_date_range_start}-#{supplement_date_range_end}"
41
+ return "NBS CIRC sup#{supplement_date_range_start}-#{supplement_date_range_end}"
42
42
  end
43
43
 
44
44
  # Use parent's rendering for base + supplement
@@ -121,10 +121,7 @@ module Pubid
121
121
  result += part.to_s if part
122
122
 
123
123
  # Add supplement with "sup" prefix for CRPL identifiers
124
- if supplement
125
- # Check if supplement already has "sup" prefix (for backward compatibility)
126
- result += (supplement.start_with?("sup") ? supplement : "sup#{supplement}")
127
- end
124
+ result += supplement_short
128
125
 
129
126
  result += range_notation if range_notation
130
127
  result
@@ -86,6 +86,16 @@ module Pubid
86
86
  if edition
87
87
  result += "#{edition.type}#{edition.id}"
88
88
  end
89
+
90
+ # Mirror Base#to_short_style update rendering — FIPS overrides
91
+ # to_short_style entirely, so update (e.g. /Upd2) would otherwise
92
+ # be dropped.
93
+ if update_component
94
+ result += update_component.to_s(:short)
95
+ elsif update
96
+ result += "-upd#{update}"
97
+ end
98
+
89
99
  result
90
100
  end
91
101
  end
@@ -52,8 +52,7 @@ module Pubid
52
52
  def to_short_style
53
53
  result = "#{default_publisher} #{series_code}"
54
54
  result += " #{number}" if number
55
- result += "sup#{supplement}" if supplement && !supplement.empty?
56
- result += "sup" if supplement == ""
55
+ result += supplement_short
57
56
  result
58
57
  end
59
58
 
@@ -204,9 +204,12 @@ module Pubid
204
204
  # FIXED: Pattern must start with "v" or digit to avoid matching "rev 2013" as "v" + " 2013"
205
205
  # CRITICAL: Added word boundary \b to prevent matching "v" within "rev"
206
206
  # CRITICAL FIX: Use \b to ensure match starts at word boundary
207
- cleaned = cleaned.gsub(/(\b(?:v|\d)[v\d]*[-A-Z]*)\s+(\d+)\s+(\d+)/, '\1.\2.\3') # Three parts
208
- # CRITICAL FIX: Use \b to ensure match starts at word boundary
209
- cleaned = cleaned.gsub(/(\b(?:v|\d)[v\d]*)\s+(\d+)/, '\1.\2') # Two parts
207
+ cleaned = cleaned.gsub(/(\b(?:v|\d)[v\d]*[-A-Z]*)\s+(\d+)(?!(?i:pd|wd|prd)\b)\s+(\d+)(?!(?i:pd|wd|prd)\b)/, '\1.\2.\3') # Three parts
208
+ # CRITICAL FIX: Use \b to ensure match starts at word boundary.
209
+ # Negative lookahead: don't swallow the digit of a numeric draft
210
+ # stage ("189 2pd" must stay split, not become "189.2pd"); letter
211
+ # stages ("ipd") already don't match the trailing \d+.
212
+ cleaned = cleaned.gsub(/(\b(?:v|\d)[v\d]*)\s+(\d+)(?!(?i:pd|wd|prd)\b)/, '\1.\2') # Two parts
210
213
 
211
214
  # Fix update patterns: ensure space before -upd or /upd (not just at end)
212
215
  # Enhanced to handle optional digits after upd: -upd, -upd1, /upd, /upd1
@@ -240,6 +243,16 @@ module Pubid
240
243
  # Normalize "sup" to "supp" for LCIRC patterns to match circ_supplement_identifier rule
241
244
  cleaned = cleaned.gsub(/(\d+)sup(\d+\/\d{4})/, '\1supp\2') # 118sup12/1926 → 118supp12/1926
242
245
 
246
+ # Unify dashed/undashed year supplements: "supp-YYYY" → "suppYYYY".
247
+ # A bare dash before a 4-digit year is not semantic — "25supp-1924" and
248
+ # "25supp1924" denote the same publication (the genuine edition marker is
249
+ # explicit "e", e.g. "25suppe1924"). Collapsing the dash here gives both
250
+ # spellings ONE parse tree (the normal first_number path), so they build
251
+ # to an identical Circular with supplement=<year>, with equal ==/URN.
252
+ # Guard: 4 digits NOT followed by another digit or a slash, so the
253
+ # dash-slash form "supp-12/1926" (supplement_dash_slash_year) is untouched.
254
+ cleaned = cleaned.gsub(/(\d)(supp?)-(\d{4})(?![\d\/])/, '\1\2\3') # 25supp-1924 → 25supp1924
255
+
243
256
  # REMOVED: Revision letter patterns that add space before revision with letter
244
257
  # These conflicted with the fix at lines 131-142 which keeps "22r1a" together
245
258
  # for second_number pattern matching. The comprehensive fix now handles:
@@ -361,6 +374,21 @@ module Pubid
361
374
  # Fix verbose "Revision" format: " Revision (r)" → " r"
362
375
  cleaned = cleaned.gsub(/\s+Revision\s+\(r\)/, " r")
363
376
 
377
+ # Fix verbose "Part N" → short "ptN": "800-57 Part 2 Rev. 1" →
378
+ # "800-57pt2 Rev. 1". The grammar already accepts short "ptN" (and
379
+ # "ptN Rev. M"); only the verbose spelling was unsupported. Attaches
380
+ # to the preceding number so the existing part rule applies.
381
+ cleaned = cleaned.gsub(/\s+Part\s+(\d+)/, 'pt\1')
382
+
383
+ # Normalize verbose addendum " Add"/" add" (with or without period)
384
+ # to the canonical " Add." the grammar accepts, and uppercase a
385
+ # doc-number letter that immediately precedes it ("800-38a Add" →
386
+ # "800-38A Add.") — NIST doc-number letters are canonically uppercase
387
+ # and the letter_number grammar rule only splits the uppercase form.
388
+ # Scoped to the addendum context so bare markers like "800-90r"
389
+ # (revision) are left untouched.
390
+ cleaned = cleaned.gsub(/(\d[a-z]?)\s+Add\b\.?/i) { "#{Regexp.last_match(1).upcase} Add." }
391
+
364
392
  # Fix verbose "rev YYYY" format: "126 rev 2013" → "126r2013"
365
393
  # Removes space between number and "rev", and converts to "r" prefix
366
394
  # Handles patterns like "NIST SP 260-126 rev 2013" → "NIST SP 260-126r2013"
@@ -634,6 +662,11 @@ module Pubid
634
662
  # NEW: Exclude "draft" keyword
635
663
  str("draft").absent? >>
636
664
  (
665
+ # Trailing bare supplement marker on a compound second number
666
+ # (e.g. "800-53sup") so it isn't split into "53s" + "up". Builder
667
+ # strips the marker and sets supplement="" (canonical "sup").
668
+ (digits >> (str("supp") | str("sup")) >>
669
+ (digit.absent? >> letter.absent?)) |
637
670
  # NEW: Revision pattern with U+letter suffix (e.g., "22r1Ua", "38Ua")
638
671
  # MUST come BEFORE general letter suffix to avoid matching just "U" from "Ua"
639
672
  (digits >> str("r") >> digits >> str("U") >> lower_letter) |
@@ -28,7 +28,7 @@ module Pubid
28
28
  def to_s(format = :short)
29
29
  # Handle date range supplements (no base identifier)
30
30
  if supplement_date_range_start && supplement_date_range_end
31
- return "NBS CIRC supp#{supplement_date_range_start}-#{supplement_date_range_end}"
31
+ return "NBS CIRC sup#{supplement_date_range_start}-#{supplement_date_range_end}"
32
32
  end
33
33
 
34
34
  return super unless base_identifier
@@ -37,34 +37,18 @@ module Pubid
37
37
 
38
38
  # NEW: Handle update attribute (e.g., "Upd12-1926" for supplement patterns)
39
39
  if update
40
- # Check if this is an implicit supplement (no explicit "sup/supp" marker, just update)
41
- # For implicit supplements like "145r11/1925", don't add "sup" before the update
40
+ # Implicit supplements (e.g. "145r11/1925") have no explicit marker;
41
+ # everything else uses the canonical single-p "sup" marker
42
+ # (relaton-data-nist uses "sup" across all series).
42
43
  is_implicit = self.class.attributes.key?(:implicit_supplement) && implicit_supplement == true
43
-
44
- if is_implicit
45
- # Implicit supplement: "{base}/{update}" (e.g., "145/Upd1-192511")
46
- else
47
- # Explicit supplement: "{base}sup/{update}" (e.g., "118sup/Upd12-1926")
48
- is_circ_supplement = ["LCIRC", "CIRC"].include?(series.to_s)
49
- result += is_circ_supplement ? "sup" : "supp"
50
- end
44
+ result += "sup" unless is_implicit
51
45
  result += "/#{update}"
52
46
  return result
53
47
  end
54
48
 
55
- # Original supplement rendering
56
- # For LCIRC/CIRC supplements with update, use "sup"
57
- # For LCIRC/CIRC supplements without update, normalize "sup" to "supp" for consistency
58
- is_circ_supplement = ["LCIRC", "CIRC"].include?(series.to_s)
59
- # When update is present, use "sup" (e.g., "118sup/Upd1-192612")
60
- # When update is not present, use "supp" (e.g., "378Gsupp" - normalized from "sup")
61
- result += if is_circ_supplement && !update
62
- "supp"
63
- elsif is_circ_supplement
64
- "sup"
65
- else
66
- "supp"
67
- end
49
+ # Canonical supplement marker is single-p "sup" across all NIST/NBS
50
+ # series (relaton-data-nist: SP/CIRC/HB/RPT/LC/IR/MONO/BMS all use "sup").
51
+ result += "sup"
68
52
 
69
53
  # Add edition information if present (just ID, not type prefix)
70
54
  if edition&.id
@@ -61,18 +61,24 @@ module Pubid
61
61
  identifier_parts << identifier.stage.to_s
62
62
  end
63
63
 
64
+ # Supplement is now a structured component. Preserve the existing URN
65
+ # branching exactly: range → "supp{start}-{end}", revision → "supprev",
66
+ # a valued supplement → "suppX"/"supp-X" (dash unless it starts with an
67
+ # uppercase letter), and — quirk retained — a nil (absent) supplement
68
+ # still emits "supp", while a present-but-empty one emits nothing.
64
69
  supp = identifier.supplement
65
- if identifier.supplement_date_range_start && identifier.supplement_date_range_end
66
- identifier_parts << "supp#{identifier.supplement_date_range_start}-#{identifier.supplement_date_range_end}"
67
- elsif identifier.supplement_has_revision
70
+ if supp&.range?
71
+ identifier_parts << "supp#{supp.month}#{supp.year}-#{supp.month_end}#{supp.year_end}"
72
+ elsif supp&.has_revision
68
73
  identifier_parts << "supprev"
69
- elsif supp && !supp.empty?
70
- identifier_parts << if supp.match?(/^[A-Z]/)
71
- "supp#{supp}"
74
+ elsif supp && !supp.value_string.empty?
75
+ value = supp.value_string
76
+ identifier_parts << if value.match?(/^[A-Z]/)
77
+ "supp#{value}"
72
78
  else
73
- "supp-#{supp}"
79
+ "supp-#{value}"
74
80
  end
75
- elsif !supp
81
+ elsif supp.nil?
76
82
  identifier_parts << "supp"
77
83
  end
78
84
 
data/lib/pubid/nist.rb CHANGED
@@ -5,6 +5,7 @@ module Pubid
5
5
  autoload :Builder, "#{__dir__}/nist/builder"
6
6
  autoload :Components, "#{__dir__}/nist/components"
7
7
  autoload :Configuration, "#{__dir__}/nist/configuration"
8
+ autoload :Identifier, "#{__dir__}/nist/identifier"
8
9
  autoload :Identifiers, "#{__dir__}/nist/identifiers"
9
10
  autoload :Parser, "#{__dir__}/nist/parser"
10
11
  autoload :Scheme, "#{__dir__}/nist/scheme"
@@ -6,6 +6,56 @@ module Pubid
6
6
  def to_urn
7
7
  UrnGenerator.new(self).generate
8
8
  end
9
+
10
+ # Factory mirroring pubid 1.x's `Pubid::Oiml::Identifier.create` API.
11
+ # Default subclass is {Identifiers::Recommendation}.
12
+ TYPE_KEY_TO_KLASS = {
13
+ recommendation: "Recommendation",
14
+ document: "Document",
15
+ guide: "Guide",
16
+ vocabulary: "Vocabulary",
17
+ basic_publication: "BasicPublication",
18
+ expert_report: "ExpertReport",
19
+ seminar_report: "SeminarReport",
20
+ annex: "Annex",
21
+ }.freeze
22
+
23
+ def self.create(type: nil, **opts)
24
+ klass = resolve_create_class(type)
25
+ klass.new(**coerce_create_attrs(opts))
26
+ end
27
+
28
+ def self.resolve_create_class(type)
29
+ return Identifiers::Recommendation if type.nil?
30
+
31
+ klass_name = TYPE_KEY_TO_KLASS[type.to_sym]
32
+ raise ArgumentError, "Unknown OIML type: #{type.inspect}" unless klass_name
33
+
34
+ Identifiers.const_get(klass_name)
35
+ end
36
+
37
+ def self.coerce_create_attrs(opts)
38
+ attrs = { publisher: (opts[:publisher] || "OIML").to_s }
39
+
40
+ if opts[:code].is_a?(Pubid::Oiml::Components::Code)
41
+ attrs[:code] = opts[:code]
42
+ elsif opts[:code] || opts[:number]
43
+ attrs[:code] = Pubid::Oiml::Components::Code.new(
44
+ number: (opts[:number] || opts[:code])&.to_s,
45
+ part: opts[:part]&.to_s,
46
+ subpart: opts[:subpart]&.to_s,
47
+ )
48
+ end
49
+
50
+ if (v = opts[:year])
51
+ attrs[:date] = Pubid::Components::Date.new(year: v.to_s)
52
+ end
53
+ %i[stage iteration language edition].each do |k|
54
+ attrs[k] = opts[k].to_s unless opts[k].nil?
55
+ end
56
+ attrs
57
+ end
58
+ private_class_method :resolve_create_class, :coerce_create_attrs
9
59
  end
10
60
  end
11
61
  end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pubid
4
+ module Plateau
5
+ # Plateau factory entry point. `.parse` lives on `Pubid::Plateau`
6
+ # itself for historical reasons; this module hosts `.create` for API
7
+ # consistency with the other pubid flavors.
8
+ module Identifier
9
+ # Delegate to the flavor module so callers can use
10
+ # `Pubid::Plateau::Identifier.parse` consistently with other flavors.
11
+ def self.parse(identifier)
12
+ Pubid::Plateau.parse(identifier)
13
+ end
14
+
15
+ # Factory that builds a PLATEAU identifier from a hash of primitives.
16
+ #
17
+ # Dispatch on `:type`:
18
+ # * `:handbook` (default) → Identifiers::Handbook
19
+ # * `:technical_report` / `:tr` → Identifiers::TechnicalReport
20
+ # * `:annex` → Identifiers::Annex
21
+ #
22
+ # Attributes are plain integers/strings — no Component wrapping.
23
+ # `:publisher` is silently ignored (PLATEAU is hardcoded).
24
+ def self.create(type: nil, **opts)
25
+ klass = resolve_create_class(type)
26
+ klass.new(**coerce_create_attrs(opts, klass: klass))
27
+ end
28
+
29
+ def self.resolve_create_class(type)
30
+ case type&.to_sym
31
+ when nil, :handbook
32
+ Identifiers::Handbook
33
+ when :technical_report, :tr
34
+ Identifiers::TechnicalReport
35
+ when :annex
36
+ Identifiers::Annex
37
+ else
38
+ raise ArgumentError, "Unknown PLATEAU type: #{type.inspect}"
39
+ end
40
+ end
41
+
42
+ def self.coerce_create_attrs(opts, klass:)
43
+ attrs = {}
44
+ attrs[:number] = opts[:number].to_i if opts[:number]
45
+ attrs[:annex] = opts[:annex].to_i if opts[:annex]
46
+ # :edition exists only on Handbook (and possibly TechnicalReport);
47
+ # silently drop on Annex.
48
+ if opts[:edition] && klass.attributes.key?(:edition)
49
+ attrs[:edition] = opts[:edition].to_s
50
+ end
51
+ # TODO(create-shim): :publisher silently ignored (PLATEAU hardcoded).
52
+ attrs
53
+ end
54
+ private_class_method :resolve_create_class, :coerce_create_attrs
55
+ end
56
+ end
57
+ end
data/lib/pubid/plateau.rb CHANGED
@@ -5,6 +5,7 @@ require "parslet"
5
5
  module Pubid
6
6
  module Plateau
7
7
  autoload :Builder, "#{__dir__}/plateau/builder"
8
+ autoload :Identifier, "#{__dir__}/plateau/identifier"
8
9
  autoload :Identifiers, "#{__dir__}/plateau/identifiers"
9
10
  autoload :Parser, "#{__dir__}/plateau/parser"
10
11
  autoload :Scheme, "#{__dir__}/plateau/scheme"
@@ -14,6 +14,40 @@ module Pubid
14
14
  def self.render(identifier)
15
15
  new(identifier).render
16
16
  end
17
+
18
+ # Partitions a value into (leading separators, core, trailing separators)
19
+ # so "- : / space , ." stay OUTSIDE the annotation span (v1 parity).
20
+ SEMANTIC_SPLIT = %r{\A([-:/ ,.]*)(.*?)([-:/ ,.]*)\z}m
21
+
22
+ # type_code (a string) → semantic CSS class for the typed-stage token.
23
+ # Anything not listed (and not the default "is") renders as "doctype".
24
+ TYPED_STAGE_CSS = {
25
+ "amd" => "amendment",
26
+ "cor" => "corrigendum",
27
+ "add" => "addendum",
28
+ }.freeze
29
+
30
+ private
31
+
32
+ # Wrap a rendered value in a <span class="css_class"> when annotation is
33
+ # enabled, keeping leading/trailing separator chars outside the span.
34
+ def annotate(value, css_class, annotated:)
35
+ str = value.to_s
36
+ return value unless annotated && css_class && !str.empty?
37
+
38
+ lead, core, trail = str.match(SEMANTIC_SPLIT).captures
39
+ return value if core.empty?
40
+
41
+ %(#{lead}<span class="#{css_class}">#{core}</span>#{trail})
42
+ end
43
+
44
+ # Choose between "stage" and a type/supplement class for a typed stage.
45
+ def typed_stage_css(typed_stage)
46
+ code = typed_stage&.type_code.to_s
47
+ return "stage" if code.empty? || code == "is"
48
+
49
+ TYPED_STAGE_CSS[code] || "doctype"
50
+ end
17
51
  end
18
52
  end
19
53
  end
@@ -27,18 +27,13 @@ module Pubid
27
27
  private
28
28
 
29
29
  def render_publisher_portion(context)
30
- pub_str = @id.publisher.render(context:) if @id.publisher
30
+ ann = context.annotated
31
+ pub_str = annotate(@id.publisher.render(context:), "publisher",
32
+ annotated: ann) if @id.publisher
31
33
  abbr = @id.typed_stage ? @id.typed_stage.abbreviation(format_long: false) : ""
34
+ abbr = annotate(abbr, typed_stage_css(@id.typed_stage), annotated: ann) unless abbr.empty?
32
35
  subgroup_str = @id.subgroup.render(context:) if @id.subgroup
33
36
 
34
- unless @id.publisher&.copublished?
35
- return [
36
- pub_str,
37
- (subgroup_str ? " #{subgroup_str}" : ""),
38
- (abbr.empty? ? "" : " #{abbr}"),
39
- ].join
40
- end
41
-
42
37
  [
43
38
  pub_str,
44
39
  (subgroup_str ? " #{subgroup_str}" : ""),
@@ -47,13 +42,17 @@ module Pubid
47
42
  end
48
43
 
49
44
  def render_number_portion(context)
45
+ ann = context.annotated
50
46
  parts = []
51
- parts << @id.number.render(context:) if @id.number
52
- parts << " #{@id.part.render(context:)}" if @id.part
53
- parts << "-#{@id.subpart.render(context:)}" if @id.subpart
54
- parts << ".#{@id.stage_iteration.render(context:)}" if @id.stage_iteration
47
+ parts << annotate(@id.number.render(context:), "docnumber",
48
+ annotated: ann) if @id.number
49
+ parts << " #{annotate(@id.part.render(context:), 'part', annotated: ann)}" if @id.part
50
+ parts << "-#{annotate(@id.subpart.render(context:), 'part', annotated: ann)}" if @id.subpart
51
+ if @id.stage_iteration
52
+ parts << ".#{annotate(@id.stage_iteration.render(context:), 'iteration', annotated: ann)}"
53
+ end
55
54
  date_str = @id.date.render(context:) if @id.date && context.with_date
56
- parts << ":#{date_str}" if date_str
55
+ parts << ":#{annotate(date_str, 'year', annotated: ann)}" if date_str
57
56
  result = parts.join.strip
58
57
  result.empty? ? nil : result
59
58
  end
@@ -6,10 +6,14 @@ module Pubid
6
6
  private
7
7
 
8
8
  def render_publisher_and_stage(context)
9
- pub_str = @id.publisher.render(context:) if @id.publisher
9
+ ann = context.annotated
10
+ pub_str = annotate(@id.publisher.render(context:), "publisher",
11
+ annotated: ann) if @id.publisher
10
12
  stage_str = @id.typed_stage.render(context:) if @id.typed_stage
11
13
 
12
14
  if stage_str && !stage_str.empty?
15
+ stage_str = annotate(stage_str, typed_stage_css(@id.typed_stage),
16
+ annotated: ann)
13
17
  "#{pub_str} #{stage_str}"
14
18
  else
15
19
  pub_str
@@ -17,12 +17,16 @@ module Pubid
17
17
  private
18
18
 
19
19
  def render_publisher_and_stage(context)
20
- pub_str = @id.publisher.render(context:) if @id.publisher
20
+ ann = context.annotated
21
+ pub_str = annotate(@id.publisher.render(context:), "publisher",
22
+ annotated: ann) if @id.publisher
21
23
  stage_str = @id.typed_stage.render(context:) if @id.typed_stage
22
24
 
23
25
  if stage_str && !stage_str.empty?
24
26
  has_copub = @id.publisher&.copublished?
25
27
  sep = has_copub ? " " : "/"
28
+ stage_str = annotate(stage_str, typed_stage_css(@id.typed_stage),
29
+ annotated: ann)
26
30
  "#{pub_str}#{sep}#{stage_str}"
27
31
  else
28
32
  pub_str
@@ -30,18 +34,25 @@ module Pubid
30
34
  end
31
35
 
32
36
  def render_number_portion(context)
37
+ ann = context.annotated
33
38
  parts = []
34
- parts << @id.number.render(context:) if @id.number
35
- parts << "-#{@id.part.render(context:)}" if @id.part
36
- parts << "-#{@id.subpart.render(context:)}" if @id.subpart
37
- parts << ".#{@id.stage_iteration.render(context:)}" if @id.stage_iteration
39
+ parts << annotate(@id.number.render(context:), "docnumber",
40
+ annotated: ann) if @id.number
41
+ parts << "-#{annotate(@id.part.render(context:), 'part', annotated: ann)}" if @id.part
42
+ parts << "-#{annotate(@id.subpart.render(context:), 'part', annotated: ann)}" if @id.subpart
43
+ if @id.stage_iteration
44
+ parts << ".#{annotate(@id.stage_iteration.render(context:), 'iteration', annotated: ann)}"
45
+ end
38
46
  date_str = @id.date.render(context:) if @id.date && context.with_date
39
- parts << ":#{date_str}" if date_str
47
+ parts << ":#{annotate(date_str, 'year', annotated: ann)}" if date_str
40
48
  parts.join
41
49
  end
42
50
 
43
51
  def render_edition_portion(context)
44
- @id.edition.render(context:) if @id.edition&.number
52
+ return unless @id.edition&.number
53
+
54
+ annotate(@id.edition.render(context:), "edition",
55
+ annotated: context.annotated)
45
56
  end
46
57
 
47
58
  def render_language_portion(context, with_edition: false)
@@ -49,7 +60,8 @@ module Pubid
49
60
 
50
61
  use_single = with_edition ? false : context.with_language_code == :single
51
62
  rendered = @id.languages.map do |l|
52
- l.render(context:, lang_single: use_single)
63
+ annotate(l.render(context:, lang_single: use_single), "language",
64
+ annotated: context.annotated)
53
65
  end
54
66
  "(#{rendered.join(use_single ? '/' : ',')})"
55
67
  end