relaton-bib 2.1.7 → 2.1.8

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 15d36c3ed1e4ae0042f96a06a6320dccbe089a87a691b77b8ad14e276217bd22
4
- data.tar.gz: d6db31f185b72668da0c289bc462a62d1cfd1b9dda542f37b9dee7257d5282b4
3
+ metadata.gz: 395f67780defebb9f033822f67f28dec65f7a507b687631d718e7a143926bf95
4
+ data.tar.gz: 15b9c502271a17e795f21886d6721cff6533a3a9c9e67aaec8ef9b332ddb87de
5
5
  SHA512:
6
- metadata.gz: 304fcb5b02e2c019d532164228acede4ed81c210eafededbbdd9446ac94fcd32d142be5be7689db09bc17b7aa3e6cfb333b892220ba36e64fcccc2bdda2df29c
7
- data.tar.gz: 390cf8b808ab85b099d573b4bea833d265a2326d81c00936e5cebe6ae79c9243991843d44a46d3d24e403137f1ec27b433acc532c51f99a0d5e91d0ed66d6744
6
+ metadata.gz: 71e9495276f45dab74db3170a882d8db0a62aff68c29f5daa00e1c59abac9ad6d4069b887da6aee1293b6df1b3e2e57878c8abd9158014eac9d945f92a478f4b
7
+ data.tar.gz: 6045720a0265a3fb0c54ae76b7870a8b4288ef310832f9519bb488bf18b3f5cce94c00e08bb5b6ae42c832dcb3124a99de6a8e80599aa78ab90b779963990c42
data/CLAUDE.md CHANGED
@@ -77,7 +77,17 @@ Each bibliographic attribute has its own class in `lib/relaton/bib/model/`:
77
77
  ### Rendering
78
78
 
79
79
  - **`Renderer::BibtexBuilder`** - Converts `ItemData` to BibTeX format
80
- - **`Converter::BibXml`** - Converts `ItemData` to RFC XML format via `Relaton::Bib::Converter::BibXml.to_xml`
80
+ - **`Converter::BibXml`** - Converts `ItemData` to an `Rfcxml::V3` model object via
81
+ `Relaton::Bib::Converter::BibXml.from_item`; call `to_xml` on the result (this is what
82
+ `ItemData#to_rfcxml` does). Two output shapes share one class hierarchy:
83
+ - `ToRfcxml` / `ToRfcxmlReferencegroup` (default) emit **BibXML**, the shape the Relaton
84
+ data repositories publish. It keeps the RFC 7991-deprecated `<format>` element, so it
85
+ must stay byte-compatible — every relaton data fetcher's `"bibxml"` format is
86
+ `entry.to_rfcxml`. Do not change its output.
87
+ - `ToRfcxmlV3` / `ToRfcxmlReferencegroupV3` (`v3: true`) subclass those and emit strict
88
+ **RFC 7991**: no `<format>`, plus `<stream>`, `ascii*` attributes and `<refcontent>`.
89
+ New v3-only behaviour belongs in the subclass, so the default path stays untouched by
90
+ construction and `spec/relaton/bib/converter/bibxml_spec.rb` keeps passing unedited.
81
91
  - **lutaml-model native** - `to_xml`, `to_yaml`, `to_json` via the serialization classes
82
92
 
83
93
  ### Usage Pattern
@@ -98,11 +108,13 @@ item.to_xml # as <bibitem>
98
108
  item.to_xml(bibdata: true) # as <bibdata>
99
109
  item.to_yaml
100
110
  item.to_bibtex
101
- item.to_rfcxml
111
+ item.to_rfcxml # BibXML (what the data repos publish)
112
+ item.to_rfcxml(v3: true) # strict RFC 7991 / xml2rfc v3
113
+ item.to_rfcxml(anchor: "ISO712", include_keywords: false)
102
114
  ```
103
115
 
104
116
  ## Code Style
105
117
 
106
118
  - Follows Ribose OSS Ruby style guide (inherited via `.rubocop.yml`)
107
- - Target Ruby version: 3.1+
119
+ - Target Ruby version: 3.2+ (`required_ruby_version` in the gemspec, `TargetRubyVersion` in `.rubocop.yml`)
108
120
  - Uses YARD documentation comments
data/README.adoc CHANGED
@@ -334,6 +334,70 @@ item.to_rfcxml
334
334
  </reference>
335
335
  ----
336
336
 
337
+ === Export bibliographic item to strict RFC XML v3
338
+
339
+ `to_rfcxml` emits the BibXML shape published by the Relaton data repositories,
340
+ which still carries the `<format>` element that RFC 7991 deprecates. Pass
341
+ `v3: true` for output that follows the RFC 7991 (xml2rfc v3) vocabulary
342
+ instead: no `<format>`, a `<stream>` element, `ascii*` attributes, and
343
+ `<refcontent>` for identifiers that cannot be expressed as `<seriesInfo>`.
344
+
345
+ [source,ruby]
346
+ ----
347
+ item.to_rfcxml v3: true
348
+ <reference anchor="ISO20483">
349
+ <front>
350
+ <title>Cereals and pulses</title>
351
+ <author initials="Ö." asciiInitials="O." surname="Nürk" asciiSurname="Nurk"/>
352
+ <author>
353
+ <organization abbrev="ISO" ascii="International Organization for Standardization">International Organization for Standardization</organization>
354
+ </author>
355
+ <date year="2013"/>
356
+ </front>
357
+ <refcontent>ISO 20483:2013-2014</refcontent>
358
+ </reference>
359
+ ----
360
+
361
+ Documents published by the IETF (identified by a contributing organization
362
+ named `IETF`, `Internet Engineering Task Force` or `RFC Publisher`) express
363
+ their identifiers as `<seriesInfo>` rather than `<refcontent>`:
364
+
365
+ [source,ruby]
366
+ ----
367
+ <reference anchor="RFC2119" target="https://www.rfc-editor.org/info/rfc2119">
368
+ <stream>IETF</stream>
369
+ <front>
370
+ <title>Key words for use in RFCs to Indicate Requirement Levels</title>
371
+ <seriesInfo name="BCP" value="14"/>
372
+ <seriesInfo name="RFC" value="2119"/>
373
+ ...
374
+ </front>
375
+ </reference>
376
+ ----
377
+
378
+ The v3 mode also applies the citation rules the IETF toolchain expects, which
379
+ the BibXML mode leaves alone:
380
+
381
+ * Contributors with role `author` or `editor` become `<author>`; the wider set
382
+ (`publisher`, `authorizer`, `translator`, …) is used only when the item names
383
+ no author or editor, so an RFC's publisher is not listed as its author.
384
+ * `<title>` takes the composite `title[@type="main"]`, else joins
385
+ title-intro / title-main / title-part, so multipart standards keep their full
386
+ title. Inline markup is flattened, since v3 `<title>` is text-only.
387
+ * Identifiers that restate the reference label, `URN` identifiers and the
388
+ `I-D.` anchor form are left out of `<refcontent>`; Internet-Draft
389
+ `<seriesInfo>` carries the full `draft-…` name, which is what xml2rfc
390
+ resolves against.
391
+ * Where folding a name leaves no letters (a non-Latin script), the `ascii*`
392
+ attribute is omitted rather than emitted blank.
393
+
394
+ Two further options are accepted by both modes:
395
+
396
+ `anchor:`:: overrides the anchor otherwise derived from `docnumber`, for
397
+ callers that carry their own identifier (for example a Metanorma Semantic XML
398
+ `bibitem/@id`).
399
+ `include_keywords:`:: set to `false` to suppress `<keyword>` elements.
400
+
337
401
  === Logging
338
402
 
339
403
  RelatonBib uses the relaton-logger gem for logging. By default, it logs to STDOUT. To change the log levels and add other loggers, read the https://github.com/relaton/relaton-logger#usage[relaton-logger] documentation.
@@ -326,7 +326,18 @@ module Relaton
326
326
 
327
327
  t = Title.new(content: si.name, language: "en", script: "Latn")
328
328
  Series.new(title: [t], number: si.value, type: "main")
329
- end.compact
329
+ end.compact + stream_series
330
+ end
331
+
332
+ # RFC XML v3 carries the publication stream in a <stream> element.
333
+ # ItemData has no dedicated slot for it, so it is kept the way
334
+ # relaton-ietf does: as a series marked `type="stream"`.
335
+ def stream_series
336
+ return [] unless @reference.stream
337
+
338
+ t = Title.new(content: @reference.stream, language: "en",
339
+ script: "Latn")
340
+ [Series.new(title: [t], type: "stream")]
330
341
  end
331
342
 
332
343
  # --- Keyword ---
@@ -5,14 +5,15 @@ module Relaton
5
5
  module Converter
6
6
  module BibXml
7
7
  class ToRfcxml
8
- def initialize(item, include_keywords: true)
8
+ def initialize(item, include_keywords: true, anchor: nil)
9
9
  @item = item
10
10
  @include_keywords = include_keywords
11
+ @anchor = anchor
11
12
  end
12
13
 
13
14
  def transform
14
15
  model = ::Rfcxml::V3::Reference.new
15
- model.anchor = @item.docnumber || derive_anchor
16
+ model.anchor = @anchor || @item.docnumber || derive_anchor
16
17
  model.target = create_target
17
18
  model.front = create_front
18
19
  model.format = create_format
@@ -26,14 +27,22 @@ module Relaton
26
27
  di&.content&.to_s&.gsub(" ", ".")
27
28
  end
28
29
 
30
+ # A <uri> may legitimately carry no type -- biblio.rng makes it
31
+ # optional, and hand-authored bibitems often omit it -- so never call
32
+ # String methods on it unguarded.
33
+ def target_types = %w[src doi]
34
+
29
35
  def create_target
30
- target = @item.source.detect { |l| l.type.casecmp("src").zero? } ||
31
- @item.source.detect { |l| l.type.casecmp("doi").zero? }
36
+ target = target_types.filter_map { |t| source_of_type(t) }.first
32
37
  return unless target
33
38
 
34
39
  target.content.to_s
35
40
  end
36
41
 
42
+ def source_of_type(type)
43
+ @item.source.detect { |l| l.type&.casecmp(type)&.zero? }
44
+ end
45
+
37
46
  def create_front # rubocop:disable Metrics/AbcSize,Metrics/MethodLength
38
47
  front = ::Rfcxml::V3::Front.new
39
48
  front.title = Rfcxml::V3::Title.new(content: @item.title[0].content) if @item.title.any?
@@ -59,9 +68,16 @@ module Relaton
59
68
  end
60
69
  end
61
70
 
71
+ # A `type="stream"` series is the publication stream, not a series:
72
+ # it has no number, so it would yield a valueless <seriesInfo>.
73
+ def seriesinfo_series?(ser)
74
+ ser.type != "stream" &&
75
+ ser.title.reject { |t| t.content == "DOI" }.any?
76
+ end
77
+
62
78
  def series_to_seriesinfo # rubocop:disable Metrics/AbcSize,Metrics/CyclomaticComplexity
63
79
  @item.series.select do |s|
64
- s.title.reject { |t| t.content == "DOI" }.any?
80
+ seriesinfo_series?(s)
65
81
  end.uniq do |s|
66
82
  s.title.find { |t| t.content != "DOI" }.content
67
83
  end.each_with_object([]) do |s, si|
@@ -71,8 +87,13 @@ module Relaton
71
87
  end
72
88
  end
73
89
 
90
+ # Which contributors become <author>; overridden by the v3 emitter.
91
+ def contributors_for_authors
92
+ @item.contributor.reject { |c| committee_contributor?(c) }
93
+ end
94
+
74
95
  def create_authors # rubocop:disable Metrics/AbcSize,Metrics/MethodLength
75
- @item.contributor.reject { |c| committee_contributor?(c) }.map do |contrib|
96
+ contributors_for_authors.map do |contrib|
76
97
  role = "editor" if contrib.role.detect { |r| r.type == "editor" }
77
98
  Rfcxml::V3::Author.new(
78
99
  role: role,
@@ -284,7 +305,10 @@ module Relaton
284
305
  return unless @item.abstract.any?
285
306
 
286
307
  content = @item.abstract[0].content
287
- paragraphs = content.scan(%r{<p>(.*?)</p>}m).flatten
308
+ # Semantic XML paragraphs carry attributes (`<p id="_349ea…">`);
309
+ # match those too, or the whole abstract falls through to the
310
+ # fallback below and the markup is escaped into the <t>.
311
+ paragraphs = content.scan(%r{<p\b[^>]*>(.*?)</p>}m).flatten
288
312
  paragraphs = [content] if paragraphs.empty?
289
313
  ts = paragraphs.map { |p| Rfcxml::V3::Text.new(content: CGI.unescapeHTML(p)) }
290
314
  Rfcxml::V3::Abstract.new(t: ts)
@@ -294,7 +318,7 @@ module Relaton
294
318
 
295
319
  def create_format # rubocop:disable Metrics/AbcSize
296
320
  @item.source.each_with_object([]) do |l, a|
297
- next unless FORMAT_TYPES.any? { |ft| l.type.casecmp(ft).zero? }
321
+ next unless FORMAT_TYPES.any? { |ft| l.type&.casecmp(ft)&.zero? }
298
322
 
299
323
  a << Rfcxml::V3::Format.new(
300
324
  type: l.type, target: l.content,
@@ -3,9 +3,10 @@ module Relaton
3
3
  module Converter
4
4
  module BibXml
5
5
  class ToRfcxmlReferencegroup
6
- def initialize(item, include_keywords: true)
6
+ def initialize(item, include_keywords: true, anchor: nil)
7
7
  @item = item
8
8
  @include_keywords = include_keywords
9
+ @anchor = anchor
9
10
  end
10
11
 
11
12
  def transform
@@ -30,6 +31,8 @@ module Relaton
30
31
  end
31
32
 
32
33
  def create_anchor
34
+ return @anchor if @anchor
35
+
33
36
  docid = @item.docidentifier.detect(&:primary) ||
34
37
  @item.docidentifier[0]
35
38
  return unless docid
@@ -38,9 +41,13 @@ module Relaton
38
41
  id.sub(/^(RFC|BCP|FYI|STD) /, '\1').sub(/^\w+\./, "")
39
42
  end
40
43
 
44
+ # An untyped <uri> is legal, so guard before comparing.
45
+ def target_types = %w[src doi]
46
+
41
47
  def create_target
42
- target = @item.source.detect { |l| l.type.casecmp("src").zero? } ||
43
- @item.source.detect { |l| l.type.casecmp("doi").zero? }
48
+ target = target_types.filter_map do |type|
49
+ @item.source.detect { |l| l.type&.casecmp(type)&.zero? }
50
+ end.first
44
51
  return unless target
45
52
 
46
53
  target.content.to_s
@@ -0,0 +1,43 @@
1
+ module Relaton
2
+ module Bib
3
+ module Converter
4
+ module BibXml
5
+ #
6
+ # Strict RFC 7991 flavour of {ToRfcxmlReferencegroup}: members are
7
+ # rendered by {ToRfcxmlV3}, and every one of them gets an anchor,
8
+ # which the v3 grammar makes mandatory on `<reference>`.
9
+ #
10
+ class ToRfcxmlReferencegroupV3 < ToRfcxmlReferencegroup
11
+ private
12
+
13
+ def target_types = %w[src HTML doi]
14
+
15
+ # <referencegroup> needs an anchor just as much as its members do,
16
+ # so fall back past the docidentifier the parent relies on.
17
+ def create_anchor
18
+ anchor = super || @item.docnumber || @item.id
19
+ anchor && anchor.to_s.strip.gsub(/\s+/, ".")
20
+ end
21
+
22
+ def build_references
23
+ included_items.each_with_index.map do |item, idx|
24
+ ref = ToRfcxmlV3.new(item, include_keywords: @include_keywords)
25
+ .transform
26
+ ref.anchor = member_anchor(idx) if ref.anchor.to_s.empty?
27
+ ref
28
+ end
29
+ end
30
+
31
+ def included_items
32
+ @item.relation.select { |rel| rel.type == "includes" }
33
+ .map(&:bibitem)
34
+ end
35
+
36
+ def member_anchor(idx)
37
+ "#{create_anchor || 'reference'}_#{idx + 1}"
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,416 @@
1
+ module Relaton
2
+ module Bib
3
+ module Converter
4
+ module BibXml
5
+ #
6
+ # Strict RFC 7991 (xml2rfc v3) flavour of {ToRfcxml}.
7
+ #
8
+ # It differs from the BibXML output its parent produces in four ways:
9
+ #
10
+ # * the deprecated `<format>` element is never emitted;
11
+ # * identifiers of documents published outside the IETF go into
12
+ # `<refcontent>` rather than being forced into `<seriesInfo>`
13
+ # (RFC 7991 2.39), as do series that carry no number;
14
+ # * `<stream>` and the `ascii*` attributes are populated;
15
+ # * `<front>` is filled out enough to satisfy the v3 grammar, which
16
+ # requires a title and at least one author.
17
+ #
18
+ class ToRfcxmlV3 < ToRfcxml
19
+ # Organizations whose documents are "home" standards: their
20
+ # identifiers are expressible as <seriesInfo>.
21
+ HOME_ORGS = [
22
+ "IETF", "Internet Engineering Task Force", "RFC Publisher"
23
+ ].freeze
24
+
25
+ # Identifier types that are already emitted as <seriesInfo> by the
26
+ # parent, or that never belong in one.
27
+ NON_AUTHORITATIVE_TYPES = %w[DOI URI].freeze
28
+
29
+ # Identifiers metanorma-ietf drops as internal bookkeeping.
30
+ IGNORED_ID_RE = /\A(rfc-anchor|Internet-Draft)/
31
+
32
+ # Series that name the document itself; handled as identifiers.
33
+ SUBSERIES = %w[BCP STD].freeze
34
+
35
+ HOME_ID_RE = /\A(?<name>RFC|BCP|STD|FYI)\s*(?<value>\d+)\z/i
36
+ # xml2rfc only resolves a draft reference when seriesInfo carries the
37
+ # full draft name, so keep `draft-…` intact. The `I-D.` anchor form
38
+ # is a citation label, not the identifier (metanorma-ietf#283).
39
+ DRAFT_ID_RE = /\A(?<value>draft-\S+)\z/i
40
+ DRAFT_ANCHOR_RE = /\AI-D[.\s]\s*\S+\z/i
41
+
42
+ # Identifier types that duplicate a human-readable identifier or are
43
+ # bookkeeping rather than citation content (metanorma-ietf#301).
44
+ EXCLUDED_ID_TYPES = %w[URN].freeze
45
+
46
+ # Title parts joined when relaton supplies no composite title.
47
+ TITLE_PARTS = %w[title-intro title-main title-part].freeze
48
+
49
+ # Roles that may stand in for an author when the item names none,
50
+ # mirroring the released renderer's `creatornames_roles_allowed`.
51
+ FALLBACK_ROLES = %w[performer adapter translator publisher
52
+ distributor authorizer].freeze
53
+
54
+ # The values RFC XML v3 allows on <stream>; "editorial" was added by
55
+ # RFC 9280. Keyed by the spellings the RFC Editor and relaton-ietf
56
+ # use, so "Independent Submission" is not silently thrown away.
57
+ STREAMS = {
58
+ "ietf" => "IETF", "iab" => "IAB", "irtf" => "IRTF",
59
+ "independent" => "independent",
60
+ "independent submission" => "independent",
61
+ "independent submission stream" => "independent",
62
+ "editorial" => "editorial", "rfc editor" => "editorial",
63
+ "rfc-editor" => "editorial"
64
+ }.freeze
65
+
66
+ # Latin letters with no NFKD decomposition, which would otherwise be
67
+ # dropped outright ("Sørensen" -> "Srensen").
68
+ TRANSLITERATIONS = {
69
+ "æ" => "ae", "Æ" => "AE", "œ" => "oe", "Œ" => "OE",
70
+ "ø" => "o", "Ø" => "O", "ß" => "ss", "ẞ" => "SS",
71
+ "đ" => "d", "Đ" => "D", "ð" => "d", "Ð" => "D",
72
+ "þ" => "th", "Þ" => "Th", "ł" => "l", "Ł" => "L",
73
+ "ħ" => "h", "Ħ" => "H", "ŋ" => "ng", "Ŋ" => "NG",
74
+ "ŧ" => "t", "Ŧ" => "T", "ı" => "i", "ĸ" => "k"
75
+ }.freeze
76
+
77
+ def transform
78
+ model = ::Rfcxml::V3::Reference.new
79
+ model.anchor = create_anchor
80
+ model.target = create_target
81
+ model.stream = create_stream
82
+ model.front = create_front
83
+ model.refcontent = create_refcontent
84
+ model
85
+ end
86
+
87
+ private
88
+
89
+ # The released IETF renderer takes an HTML-typed uri as the reference
90
+ # target when there is no src (isodoc/ietf/references.rb), and the
91
+ # transformer does the same; match it here.
92
+ def target_types = %w[src HTML doi]
93
+
94
+ # anchor is mandatory in v3 and is an XML identifier, so fall back
95
+ # through every source the item has and never leave whitespace in it.
96
+ def create_anchor
97
+ sanitize_anchor(@anchor || @item.docnumber || derive_anchor ||
98
+ @item.id)
99
+ end
100
+
101
+ def sanitize_anchor(anchor)
102
+ return nil if anchor.nil?
103
+
104
+ anchor.to_s.strip.gsub(/\s+/, ".")
105
+ end
106
+
107
+ # <front> needs a title and at least one author to be valid v3.
108
+ def create_front
109
+ front = super
110
+ front.title = compound_title || formattedref_title
111
+ front.author = [unknown_author] if Array(front.author).empty?
112
+ front
113
+ end
114
+
115
+ # The base emitter takes title[0], which on a multipart standard is
116
+ # the intro alone ("IT Security techniques"). A citation needs the
117
+ # whole compound, so prefer the composite title relaton builds, then
118
+ # the intro-main-part join, then whatever is first.
119
+ def compound_title
120
+ content = composite_title || joined_title ||
121
+ @item.title.first&.content
122
+ return nil if content.nil?
123
+
124
+ ::Rfcxml::V3::Title.new(content: plain_text(content))
125
+ end
126
+
127
+ def composite_title
128
+ @item.title.find { |t| t.type == "main" }&.content
129
+ end
130
+
131
+ def joined_title
132
+ parts = TITLE_PARTS.filter_map do |type|
133
+ @item.title.find { |t| t.type == type }&.content
134
+ end
135
+ parts.empty? ? nil : parts.join(" - ")
136
+ end
137
+
138
+ def formattedref_title
139
+ content = @item.formattedref&.content or return nil
140
+
141
+ ::Rfcxml::V3::Title.new(content: plain_text(content))
142
+ end
143
+
144
+ # v3 <title> is text-only, so inline markup a formattedref or title
145
+ # carries has to be flattened rather than escaped into the output.
146
+ def plain_text(content)
147
+ text = content.to_s
148
+ return text unless text.include?("<")
149
+
150
+ Nokogiri::XML.fragment(text).text.squeeze(" ").strip
151
+ end
152
+
153
+ def unknown_author
154
+ ::Rfcxml::V3::Author.new(surname: "Unknown")
155
+ end
156
+
157
+ # One <seriesInfo> per (name, value): the same identifier can arrive
158
+ # from a docidentifier and a series, or from two docidentifiers that
159
+ # spell it differently ("RFC 2119" and "RFC2119").
160
+ def create_seriesinfo
161
+ (docidentifier_to_seriesinfo + series_to_seriesinfo +
162
+ identifier_to_seriesinfo).uniq { |si| [si.name, si.value] }
163
+ end
164
+
165
+ # Only series that carry a number can become <seriesInfo>; the rest
166
+ # are rendered as <refcontent>.
167
+ def series_to_seriesinfo
168
+ numbered_series.map do |ser|
169
+ ::Rfcxml::V3::SeriesInfo.new(name: series_title(ser).content,
170
+ value: ser.number)
171
+ end
172
+ end
173
+
174
+ def numbered_series
175
+ renderable_series.select(&:number)
176
+ end
177
+
178
+ # Series that are neither the stream marker nor a restatement of
179
+ # something already rendered from the document's identifiers. Only
180
+ # names actually emitted elsewhere are dropped: a series such as
181
+ # Internet-Draft still carries its number when no docidentifier
182
+ # supplies one.
183
+ def renderable_series
184
+ @item.series.reject { |ser| skip_series?(ser) }
185
+ .uniq { |ser| series_title(ser).content }
186
+ end
187
+
188
+ def skip_series?(ser)
189
+ return true if ser.type == "stream"
190
+
191
+ title = series_title(ser)
192
+ title.nil? || title.content.to_s == "DOI" ||
193
+ identifier_series_names.include?(title.content.to_s)
194
+ end
195
+
196
+ # Series names already covered by <seriesInfo> or <refcontent> built
197
+ # from the item's identifiers.
198
+ def identifier_series_names
199
+ @identifier_series_names ||=
200
+ identifier_to_seriesinfo.map(&:name) +
201
+ subseries_identifiers.map { |id| id.split.first }
202
+ end
203
+
204
+ def series_title(ser)
205
+ ser.title.find { |title| title.content.to_s != "DOI" }
206
+ end
207
+
208
+ # --- Identifiers ---
209
+
210
+ def identifier_to_seriesinfo
211
+ return [] unless home_standard?
212
+
213
+ authoritative_identifiers.filter_map { |id| id_to_seriesinfo(id) }
214
+ end
215
+
216
+ def id_to_seriesinfo(id)
217
+ name, value = split_identifier(id)
218
+ return nil unless name
219
+
220
+ # No `stream` attribute here: in v3 the stream is carried by the
221
+ # <stream> element, and duplicating it only adds noise.
222
+ ::Rfcxml::V3::SeriesInfo.new(name: name, value: value,
223
+ status: seriesinfo_status)
224
+ end
225
+
226
+ def split_identifier(id)
227
+ if (match = DRAFT_ID_RE.match(id))
228
+ ["Internet-Draft", match[:value]]
229
+ elsif (match = HOME_ID_RE.match(id))
230
+ [match[:name].upcase, match[:value]]
231
+ end
232
+ end
233
+
234
+ # An untyped docidentifier that merely restates the reference's own
235
+ # label ("ZELLER", "Grail") is a citation label, not a citation: keep
236
+ # it only when the reference would otherwise show nothing at all.
237
+ def label_echo?(docid)
238
+ return false unless docid.type.nil?
239
+
240
+ docid.content.to_s.casecmp(label_candidates.to_s).zero? &&
241
+ reference_has_visible_text?
242
+ end
243
+
244
+ def label_candidates
245
+ @item.docnumber || @item.id
246
+ end
247
+
248
+ def reference_has_visible_text?
249
+ @item.title.any? || @item.formattedref ||
250
+ @item.contributor.any? || @item.date.any?
251
+ end
252
+
253
+ def seriesinfo_status
254
+ @item.status&.stage&.content
255
+ end
256
+
257
+ def authoritative_identifiers
258
+ ids = subseries_identifiers + docidentifier_identifiers
259
+ ids.reject { |id| id.empty? || IGNORED_ID_RE.match?(id) }.uniq
260
+ end
261
+
262
+ def docidentifier_identifiers
263
+ @item.docidentifier.reject { |di| non_authoritative?(di) }
264
+ .map { |di| di.content.to_s.strip }
265
+ end
266
+
267
+ def non_authoritative?(docid)
268
+ NON_AUTHORITATIVE_TYPES.include?(docid.type) ||
269
+ EXCLUDED_ID_TYPES.include?(docid.type) ||
270
+ docid.scope == "trademark" ||
271
+ DRAFT_ANCHOR_RE.match?(docid.content.to_s) ||
272
+ label_echo?(docid)
273
+ end
274
+
275
+ def subseries_identifiers
276
+ @item.series.filter_map do |ser|
277
+ title = ser.title.find { |t| SUBSERIES.include?(t.content.to_s) }
278
+ "#{title.content} #{ser.number}" if title && ser.number
279
+ end
280
+ end
281
+
282
+ def home_standard?
283
+ @item.contributor.any? do |contrib|
284
+ org = contrib.organization or next false
285
+
286
+ ([org.abbreviation&.content] + org.name.map(&:content))
287
+ .compact.any? { |name| HOME_ORGS.include?(name.to_s) }
288
+ end
289
+ end
290
+
291
+ # --- refcontent ---
292
+
293
+ def create_refcontent
294
+ (identifier_refcontent + series_refcontent)
295
+ .map { |text| ::Rfcxml::V3::Refcontent.new(content: text) }
296
+ end
297
+
298
+ # Identifiers that cannot be expressed as <seriesInfo> — either
299
+ # because the document is not an IETF one, or because the identifier
300
+ # does not split into a series name and number.
301
+ # Whatever <seriesInfo> already states must not be repeated here,
302
+ # whichever route put it there.
303
+ def identifier_refcontent
304
+ ids = authoritative_identifiers.reject { |id| in_seriesinfo?(id) }
305
+ ids.empty? ? [] : [ids.join(", ")]
306
+ end
307
+
308
+ def in_seriesinfo?(id)
309
+ name, value = split_identifier(id)
310
+ return false if name.nil?
311
+
312
+ create_seriesinfo.any? { |si| si.name == name && si.value == value }
313
+ end
314
+
315
+ def series_refcontent
316
+ (renderable_series - numbered_series).filter_map do |ser|
317
+ text = ser.formattedref&.content&.to_s || series_description(ser)
318
+ text unless text.strip.empty?
319
+ end
320
+ end
321
+
322
+ def series_description(ser)
323
+ [series_title(ser)&.content, ser.run, ser.organization,
324
+ ser.place&.city, series_dates(ser)]
325
+ .map(&:to_s).reject(&:empty?).join(", ")
326
+ end
327
+
328
+ def series_dates(ser)
329
+ [ser.from, ser.to].compact.map(&:to_s).reject(&:empty?).join("-")
330
+ end
331
+
332
+ # --- stream ---
333
+
334
+ # Values outside the grammar (relaton-ietf also has "Legacy") have no
335
+ # valid representation, so they are dropped with a warning rather
336
+ # than emitted as invalid XML.
337
+ def create_stream
338
+ value = ext_stream || series_stream
339
+ return nil if value.nil?
340
+
341
+ stream = STREAMS[value.to_s.strip.downcase]
342
+ unless stream
343
+ Util.warn "Dropping stream `#{value}`: not an RFC XML v3 stream"
344
+ end
345
+ stream
346
+ end
347
+
348
+ def ext_stream
349
+ @item.ext.respond_to?(:stream) ? @item.ext.stream : nil
350
+ end
351
+
352
+ def series_stream
353
+ ser = @item.series.find { |s| s.type == "stream" }
354
+ ser && ser.title.first&.content
355
+ end
356
+
357
+ # --- Contributors ---
358
+
359
+ # Every relaton-ietf RFC record carries publisher and authorizer
360
+ # contributors, and published RFC XML never lists those as authors.
361
+ # So: authors and editors if there are any, and only otherwise fall
362
+ # back to the wider set, which keeps a translator-only monograph its
363
+ # translator (metanorma-ietf#301).
364
+ def contributors_for_authors
365
+ contribs = super
366
+ primary = contribs.select { |c| role?(c, %w[author editor]) }
367
+ primary.any? ? primary : contribs.select { |c| fallback_author?(c) }
368
+ end
369
+
370
+ def role?(contrib, types)
371
+ contrib.role.any? { |r| types.include?(r.type) }
372
+ end
373
+
374
+ # A contributor with no role at all is an author by default; that is
375
+ # how most non-IETF bibitems are written.
376
+ def fallback_author?(contrib)
377
+ contrib.role.empty? || role?(contrib, FALLBACK_ROLES)
378
+ end
379
+
380
+ # --- ascii folding ---
381
+
382
+ def create_authors
383
+ super.each do |author|
384
+ author.ascii_fullname = ascii(author.fullname)
385
+ author.ascii_surname = ascii(author.surname)
386
+ author.ascii_initials = ascii(author.initials)
387
+ end
388
+ end
389
+
390
+ def create_organization(contrib)
391
+ org = super or return nil
392
+
393
+ org.ascii = ascii(Array(org.content).join)
394
+ org
395
+ end
396
+
397
+ # Transliterate to bare ASCII: expand the letters above, then strip
398
+ # the combining marks NFKD leaves behind. "Nürk" -> "Nurk",
399
+ # "Ö." -> "O.", "Sørensen" -> "Sorensen".
400
+ def ascii(str)
401
+ return nil if str.nil?
402
+
403
+ folded = str.to_s.gsub(/[#{TRANSLITERATIONS.keys.join}]/,
404
+ TRANSLITERATIONS)
405
+ .unicode_normalize(:nfkd)
406
+ .encode("ASCII", invalid: :replace, undef: :replace, replace: "")
407
+ # Folding a non-Latin script leaves only the punctuation and
408
+ # spacing behind ("Νίκος Παπαδόπουλος" -> " "), which is worse than
409
+ # no attribute at all. Real transliteration is the caller's policy.
410
+ folded.match?(/[[:alnum:]]/) ? folded : nil
411
+ end
412
+ end
413
+ end
414
+ end
415
+ end
416
+ end
@@ -1,5 +1,7 @@
1
1
  require_relative "bibxml/to_rfcxml"
2
2
  require_relative "bibxml/to_rfcxml_referencegroup"
3
+ require_relative "bibxml/to_rfcxml_v3"
4
+ require_relative "bibxml/to_rfcxml_referencegroup_v3"
3
5
  require_relative "bibxml/from_rfcxml"
4
6
  require_relative "bibxml/from_rfcxml_referencegroup"
5
7
 
@@ -16,17 +18,27 @@ module Relaton
16
18
  RFCPREFIXES = %w[RFC BCP FYI STD].freeze
17
19
 
18
20
  # Forward: ItemData -> Rfcxml model
19
- def self.from_item(item, include_keywords: true) # rubocop:disable Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity
20
- if bcp?(item)
21
- ToRfcxmlReferencegroup.new(
22
- item, include_keywords: include_keywords
23
- ).transform
24
- else
25
- ToRfcxml.new(
26
- item, include_keywords: include_keywords
27
- ).transform
21
+ #
22
+ # @param v3 [Boolean] emit strict RFC 7991 (xml2rfc v3) instead of the
23
+ # BibXML shape the relaton data fetchers publish
24
+ # @param anchor [String, nil] anchor to use instead of the one derived
25
+ # from the item's docnumber/docidentifier
26
+ def self.from_item(item, include_keywords: true, v3: false, anchor: nil) # rubocop:disable Naming/MethodParameterName
27
+ klass = if v3 then v3_converter(item)
28
+ elsif bcp?(item) then ToRfcxmlReferencegroup
29
+ else ToRfcxml
30
+ end
31
+ klass.new(item, include_keywords: include_keywords,
32
+ anchor: anchor).transform
33
+ end
34
+
35
+ def self.v3_converter(item)
36
+ if item.relation.any? { |rel| rel.type == "includes" }
37
+ ToRfcxmlReferencegroupV3
38
+ else ToRfcxmlV3
28
39
  end
29
40
  end
41
+ private_class_method :v3_converter
30
42
 
31
43
  def self.bcp?(item) # rubocop:disable Metrics/CyclomaticComplexity
32
44
  item.docnumber&.match(/^BCP/) ||
@@ -19,8 +19,18 @@ module Relaton
19
19
  COLLECTION_WRITE_ONLY_ATTRIBUTES = %i[title abstract source relation].freeze
20
20
 
21
21
  ATTRIBUTES.each { |attr| attr_accessor attr }
22
- COLLECTION_ATTRBUTES.each { |attr| attr_accessor attr }
23
- COLLECTION_WRITE_ONLY_ATTRIBUTES.each { |attr| attr_writer attr }
22
+ COLLECTION_ATTRBUTES.each { |attr| attr_reader attr }
23
+
24
+ # Collections are always arrays. lutaml-model assigns every mapped
25
+ # attribute after building the instance, so an element absent from the
26
+ # XML would otherwise overwrite the [] set in #initialize with nil --
27
+ # `Item.from_xml` items then blew up in any consumer that iterated them,
28
+ # while `from_yaml` ones did not.
29
+ (COLLECTION_ATTRBUTES + COLLECTION_WRITE_ONLY_ATTRIBUTES).each do |attr|
30
+ define_method("#{attr}=") do |value|
31
+ instance_variable_set("@#{attr}", array(value))
32
+ end
33
+ end
24
34
 
25
35
  attr_reader :id, :docidentifier
26
36
 
@@ -28,10 +38,10 @@ module Relaton
28
38
  ATTRIBUTES.each { |attr| instance_variable_set("@#{attr}", args[attr]) }
29
39
 
30
40
  (COLLECTION_ATTRBUTES + COLLECTION_WRITE_ONLY_ATTRIBUTES).each do |attr|
31
- instance_variable_set("@#{attr}", args[attr] || [])
41
+ instance_variable_set("@#{attr}", array(args[attr]))
32
42
  end
33
43
 
34
- @docidentifier = args[:docidentifier] || []
44
+ @docidentifier = array(args[:docidentifier])
35
45
  self.schema_version = schema
36
46
  @id = args[:id]
37
47
  create_id
@@ -42,7 +52,7 @@ module Relaton
42
52
  end
43
53
 
44
54
  def docidentifier=(value)
45
- @docidentifier = value || []
55
+ @docidentifier = array(value)
46
56
  create_id
47
57
  end
48
58
 
@@ -156,8 +166,10 @@ module Relaton
156
166
  Converter::Asciibib.from_item(self)
157
167
  end
158
168
 
159
- def to_rfcxml
160
- Converter::BibXml.from_item(self).to_xml
169
+ # @param opts [Hash] passed to {Converter::BibXml.from_item}; notably
170
+ # `v3: true` for strict RFC 7991 output instead of BibXML.
171
+ def to_rfcxml(**)
172
+ Converter::BibXml.from_item(self, **).to_xml
161
173
  end
162
174
 
163
175
  def deep_clone
@@ -1,7 +1,9 @@
1
1
  module Relaton
2
2
  module Bib
3
3
  class Series < Lutaml::Model::Serializable
4
- attribute :type, :string, values: %w[main alt]
4
+ # "stream" carries an RFC publication stream; relaton-ietf already builds
5
+ # such series, and biblio.rng's SeriesType admits any string.
6
+ attribute :type, :string, values: %w[main alt stream]
5
7
  attribute :formattedref, Formattedref
6
8
  attribute :title, Title, collection: (1..)
7
9
  attribute :place, Place
@@ -1,5 +1,5 @@
1
1
  module Relaton
2
2
  module Bib
3
- VERSION = "2.1.7".freeze
3
+ VERSION = "2.1.8".freeze
4
4
  end
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: relaton-bib
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.1.7
4
+ version: 2.1.8
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-07-31 00:00:00.000000000 Z
11
+ date: 2026-09-06 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: bibtex-ruby
@@ -171,6 +171,8 @@ files:
171
171
  - lib/relaton/bib/converter/bibxml/from_rfcxml_referencegroup.rb
172
172
  - lib/relaton/bib/converter/bibxml/to_rfcxml.rb
173
173
  - lib/relaton/bib/converter/bibxml/to_rfcxml_referencegroup.rb
174
+ - lib/relaton/bib/converter/bibxml/to_rfcxml_referencegroup_v3.rb
175
+ - lib/relaton/bib/converter/bibxml/to_rfcxml_v3.rb
174
176
  - lib/relaton/bib/hash_parser_v1.rb
175
177
  - lib/relaton/bib/item_data.rb
176
178
  - lib/relaton/bib/model/abstract.rb