relaton-bib 2.1.8 → 2.1.9

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: 395f67780defebb9f033822f67f28dec65f7a507b687631d718e7a143926bf95
4
- data.tar.gz: 15b9c502271a17e795f21886d6721cff6533a3a9c9e67aaec8ef9b332ddb87de
3
+ metadata.gz: 74bf6a3c3abe13cbadc903c2d2b811b125f654033e092ed33305834663404854
4
+ data.tar.gz: 396420681c9fe3567117b7e6a992c56a0cbfd3b219d36d650d8deb072a36435b
5
5
  SHA512:
6
- metadata.gz: 71e9495276f45dab74db3170a882d8db0a62aff68c29f5daa00e1c59abac9ad6d4069b887da6aee1293b6df1b3e2e57878c8abd9158014eac9d945f92a478f4b
7
- data.tar.gz: 6045720a0265a3fb0c54ae76b7870a8b4288ef310832f9519bb488bf18b3f5cce94c00e08bb5b6ae42c832dcb3124a99de6a8e80599aa78ab90b779963990c42
6
+ metadata.gz: 9fdb28c540d26dd47ac56af80c292cae33f6cafa96672161d732f85e457b4a7336c2c5f11d5f5b852284433f73b08cfefe7d2af0c1e2d99e1557c05f985f46da
7
+ data.tar.gz: 1585a8df0f2a2ad420eb26ed1d11a6f36483f90f10510f3cb63f72145d0d401b4499016100475b37195f31033d4c600bd281d8d4f97b7f648732212f7a5378b2
data/CLAUDE.md CHANGED
@@ -77,17 +77,7 @@ 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 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.
80
+ - **`Converter::BibXml`** - Converts `ItemData` to RFC XML format via `Relaton::Bib::Converter::BibXml.to_xml`
91
81
  - **lutaml-model native** - `to_xml`, `to_yaml`, `to_json` via the serialization classes
92
82
 
93
83
  ### Usage Pattern
@@ -108,13 +98,11 @@ item.to_xml # as <bibitem>
108
98
  item.to_xml(bibdata: true) # as <bibdata>
109
99
  item.to_yaml
110
100
  item.to_bibtex
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)
101
+ item.to_rfcxml
114
102
  ```
115
103
 
116
104
  ## Code Style
117
105
 
118
106
  - Follows Ribose OSS Ruby style guide (inherited via `.rubocop.yml`)
119
- - Target Ruby version: 3.2+ (`required_ruby_version` in the gemspec, `TargetRubyVersion` in `.rubocop.yml`)
107
+ - Target Ruby version: 3.1+
120
108
  - Uses YARD documentation comments
data/README.adoc CHANGED
@@ -334,70 +334,6 @@ 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
-
401
337
  === Logging
402
338
 
403
339
  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,18 +326,7 @@ 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 + 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")]
329
+ end.compact
341
330
  end
342
331
 
343
332
  # --- Keyword ---
@@ -5,15 +5,14 @@ module Relaton
5
5
  module Converter
6
6
  module BibXml
7
7
  class ToRfcxml
8
- def initialize(item, include_keywords: true, anchor: nil)
8
+ def initialize(item, include_keywords: true)
9
9
  @item = item
10
10
  @include_keywords = include_keywords
11
- @anchor = anchor
12
11
  end
13
12
 
14
13
  def transform
15
14
  model = ::Rfcxml::V3::Reference.new
16
- model.anchor = @anchor || @item.docnumber || derive_anchor
15
+ model.anchor = @item.docnumber || derive_anchor
17
16
  model.target = create_target
18
17
  model.front = create_front
19
18
  model.format = create_format
@@ -27,22 +26,14 @@ module Relaton
27
26
  di&.content&.to_s&.gsub(" ", ".")
28
27
  end
29
28
 
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
-
35
29
  def create_target
36
- target = target_types.filter_map { |t| source_of_type(t) }.first
30
+ target = @item.source.detect { |l| l.type.casecmp("src").zero? } ||
31
+ @item.source.detect { |l| l.type.casecmp("doi").zero? }
37
32
  return unless target
38
33
 
39
34
  target.content.to_s
40
35
  end
41
36
 
42
- def source_of_type(type)
43
- @item.source.detect { |l| l.type&.casecmp(type)&.zero? }
44
- end
45
-
46
37
  def create_front # rubocop:disable Metrics/AbcSize,Metrics/MethodLength
47
38
  front = ::Rfcxml::V3::Front.new
48
39
  front.title = Rfcxml::V3::Title.new(content: @item.title[0].content) if @item.title.any?
@@ -68,16 +59,9 @@ module Relaton
68
59
  end
69
60
  end
70
61
 
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
-
78
62
  def series_to_seriesinfo # rubocop:disable Metrics/AbcSize,Metrics/CyclomaticComplexity
79
63
  @item.series.select do |s|
80
- seriesinfo_series?(s)
64
+ s.title.reject { |t| t.content == "DOI" }.any?
81
65
  end.uniq do |s|
82
66
  s.title.find { |t| t.content != "DOI" }.content
83
67
  end.each_with_object([]) do |s, si|
@@ -87,13 +71,8 @@ module Relaton
87
71
  end
88
72
  end
89
73
 
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
-
95
74
  def create_authors # rubocop:disable Metrics/AbcSize,Metrics/MethodLength
96
- contributors_for_authors.map do |contrib|
75
+ @item.contributor.reject { |c| committee_contributor?(c) }.map do |contrib|
97
76
  role = "editor" if contrib.role.detect { |r| r.type == "editor" }
98
77
  Rfcxml::V3::Author.new(
99
78
  role: role,
@@ -305,10 +284,7 @@ module Relaton
305
284
  return unless @item.abstract.any?
306
285
 
307
286
  content = @item.abstract[0].content
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
287
+ paragraphs = content.scan(%r{<p>(.*?)</p>}m).flatten
312
288
  paragraphs = [content] if paragraphs.empty?
313
289
  ts = paragraphs.map { |p| Rfcxml::V3::Text.new(content: CGI.unescapeHTML(p)) }
314
290
  Rfcxml::V3::Abstract.new(t: ts)
@@ -318,7 +294,7 @@ module Relaton
318
294
 
319
295
  def create_format # rubocop:disable Metrics/AbcSize
320
296
  @item.source.each_with_object([]) do |l, a|
321
- next unless FORMAT_TYPES.any? { |ft| l.type&.casecmp(ft)&.zero? }
297
+ next unless FORMAT_TYPES.any? { |ft| l.type.casecmp(ft).zero? }
322
298
 
323
299
  a << Rfcxml::V3::Format.new(
324
300
  type: l.type, target: l.content,
@@ -3,10 +3,9 @@ module Relaton
3
3
  module Converter
4
4
  module BibXml
5
5
  class ToRfcxmlReferencegroup
6
- def initialize(item, include_keywords: true, anchor: nil)
6
+ def initialize(item, include_keywords: true)
7
7
  @item = item
8
8
  @include_keywords = include_keywords
9
- @anchor = anchor
10
9
  end
11
10
 
12
11
  def transform
@@ -31,8 +30,6 @@ module Relaton
31
30
  end
32
31
 
33
32
  def create_anchor
34
- return @anchor if @anchor
35
-
36
33
  docid = @item.docidentifier.detect(&:primary) ||
37
34
  @item.docidentifier[0]
38
35
  return unless docid
@@ -41,13 +38,9 @@ module Relaton
41
38
  id.sub(/^(RFC|BCP|FYI|STD) /, '\1').sub(/^\w+\./, "")
42
39
  end
43
40
 
44
- # An untyped <uri> is legal, so guard before comparing.
45
- def target_types = %w[src doi]
46
-
47
41
  def create_target
48
- target = target_types.filter_map do |type|
49
- @item.source.detect { |l| l.type&.casecmp(type)&.zero? }
50
- end.first
42
+ target = @item.source.detect { |l| l.type.casecmp("src").zero? } ||
43
+ @item.source.detect { |l| l.type.casecmp("doi").zero? }
51
44
  return unless target
52
45
 
53
46
  target.content.to_s
@@ -1,7 +1,5 @@
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"
5
3
  require_relative "bibxml/from_rfcxml"
6
4
  require_relative "bibxml/from_rfcxml_referencegroup"
7
5
 
@@ -18,27 +16,17 @@ module Relaton
18
16
  RFCPREFIXES = %w[RFC BCP FYI STD].freeze
19
17
 
20
18
  # Forward: ItemData -> Rfcxml model
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
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
39
28
  end
40
29
  end
41
- private_class_method :v3_converter
42
30
 
43
31
  def self.bcp?(item) # rubocop:disable Metrics/CyclomaticComplexity
44
32
  item.docnumber&.match(/^BCP/) ||
@@ -19,18 +19,8 @@ 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_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
22
+ COLLECTION_ATTRBUTES.each { |attr| attr_accessor attr }
23
+ COLLECTION_WRITE_ONLY_ATTRIBUTES.each { |attr| attr_writer attr }
34
24
 
35
25
  attr_reader :id, :docidentifier
36
26
 
@@ -38,10 +28,10 @@ module Relaton
38
28
  ATTRIBUTES.each { |attr| instance_variable_set("@#{attr}", args[attr]) }
39
29
 
40
30
  (COLLECTION_ATTRBUTES + COLLECTION_WRITE_ONLY_ATTRIBUTES).each do |attr|
41
- instance_variable_set("@#{attr}", array(args[attr]))
31
+ instance_variable_set("@#{attr}", args[attr] || [])
42
32
  end
43
33
 
44
- @docidentifier = array(args[:docidentifier])
34
+ @docidentifier = args[:docidentifier] || []
45
35
  self.schema_version = schema
46
36
  @id = args[:id]
47
37
  create_id
@@ -52,7 +42,7 @@ module Relaton
52
42
  end
53
43
 
54
44
  def docidentifier=(value)
55
- @docidentifier = array(value)
45
+ @docidentifier = value || []
56
46
  create_id
57
47
  end
58
48
 
@@ -166,10 +156,8 @@ module Relaton
166
156
  Converter::Asciibib.from_item(self)
167
157
  end
168
158
 
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
159
+ def to_rfcxml
160
+ Converter::BibXml.from_item(self).to_xml
173
161
  end
174
162
 
175
163
  def deep_clone
@@ -1,9 +1,7 @@
1
1
  module Relaton
2
2
  module Bib
3
3
  class Series < Lutaml::Model::Serializable
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]
4
+ attribute :type, :string, values: %w[main alt]
7
5
  attribute :formattedref, Formattedref
8
6
  attribute :title, Title, collection: (1..)
9
7
  attribute :place, Place
@@ -42,15 +42,151 @@ module Relaton
42
42
 
43
43
  TAG_RX = %r{<[a-zA-Z/!?]}
44
44
 
45
+ # Captures a namespace prefix, on a tag or on an attribute: the
46
+ # "jats" of <jats:p> and </jats:italic>, and the "xlink" of
47
+ # xlink:href.
48
+ NS_PREFIX_RX = %r{(?:</?|\s)([A-Za-z_][\w.-]*):(?=[A-Za-z_])}
49
+
50
+ # Namespace that declares a prefix which the content leaves
51
+ # undeclared. The sanitiser removes it again before it serialises.
52
+ NS_PLACEHOLDER = "urn:x-relaton-undeclared:%s".freeze
53
+
54
+ # Element that carries the placeholder declarations. Its children
55
+ # are the sanitised content, so the element itself never reaches
56
+ # the output.
57
+ NS_WRAPPER = "relaton-sanitizer-root".freeze
58
+
59
+ # Reserved prefixes. XML declares both, so the content must not.
60
+ NS_RESERVED = %w[xml xmlns].freeze
61
+
62
+ # Serialise without the FORMAT option, so the sanitiser keeps the
63
+ # shape of element-only content instead of adding newlines and
64
+ # indent.
65
+ SAVE_OPTS = Nokogiri::XML::Node::SaveOptions::AS_XML
66
+
45
67
  def self.sanitize(content)
46
68
  return content unless sanitizable?(content)
47
69
 
70
+ node = parse(content)
71
+ return content if node.nil?
72
+
73
+ sanitize_children(node)
74
+ node.children.map do |c|
75
+ c.to_xml(encoding: "UTF-8", save_with: SAVE_OPTS)
76
+ end.join
77
+ end
78
+
79
+ #
80
+ # Parse the content into a node whose children are the content.
81
+ #
82
+ # @param [String] content The raw marked-up content.
83
+ #
84
+ # @return [Nokogiri::XML::Node, nil] The node, or nil when the
85
+ # content does not parse.
86
+ #
87
+ def self.parse(content)
48
88
  fragment = Nokogiri::XML::DocumentFragment.parse(content)
49
- return content if fragment.errors.any?
89
+ return fragment if fragment.errors.empty?
90
+
91
+ parse_with_prefixes(content)
92
+ end
93
+ private_class_method :parse
50
94
 
51
- sanitize_children(fragment)
52
- fragment.children.map { |c| c.to_xml(encoding: "UTF-8") }.join
95
+ #
96
+ # Parse content that uses undeclared namespace prefixes.
97
+ #
98
+ # An undeclared prefix is always a parse error, so without this the
99
+ # sanitiser gives up on exactly the third-party markup that needs
100
+ # sanitising most. Declare every prefix that the content uses on a
101
+ # wrapper element, parse, then remove the placeholder namespaces
102
+ # from the elements and from the attributes. See metanorma-pdfa#99.
103
+ #
104
+ # An undeclared prefix inside an OPAQUE <stem> goes as well. The
105
+ # sanitiser cannot keep it: an undeclared prefix in the output is
106
+ # the exact failure that this method removes. Only a namespace that
107
+ # the content declares itself survives verbatim.
108
+ #
109
+ # @param [String] content The raw marked-up content.
110
+ #
111
+ # @return [Nokogiri::XML::Element, nil] The wrapper element, or nil
112
+ # when the content uses no prefix or does not parse.
113
+ #
114
+ def self.parse_with_prefixes(content)
115
+ decl = placeholder_declarations(content) or return
116
+ name = wrapper_name(content)
117
+ doc = Nokogiri::XML "<#{name} #{decl}>#{content}</#{name}>"
118
+ return unless doc.errors.empty?
119
+
120
+ drop_placeholder_namespaces doc.root
121
+ end
122
+ private_class_method :parse_with_prefixes
123
+
124
+ #
125
+ # Declare every namespace prefix that the content uses.
126
+ #
127
+ # @param [String] content The raw marked-up content.
128
+ #
129
+ # @return [String, nil] The declarations, or nil when the content
130
+ # uses no prefix.
131
+ #
132
+ def self.placeholder_declarations(content)
133
+ prefixes = content.scan(NS_PREFIX_RX).flatten.uniq - NS_RESERVED
134
+ return if prefixes.empty?
135
+
136
+ prefixes.map do |pfx|
137
+ %(xmlns:#{pfx}="#{format NS_PLACEHOLDER, pfx}")
138
+ end.join(" ")
139
+ end
140
+ private_class_method :placeholder_declarations
141
+
142
+ #
143
+ # Name a wrapper element that the content does not close itself.
144
+ #
145
+ # Content that holds the literal end tag of the wrapper would close
146
+ # it early. The document then has more than one root, the parse
147
+ # fails, and the sanitiser gives up on content that it can handle.
148
+ #
149
+ # @param [String] content The raw marked-up content.
150
+ #
151
+ # @return [String] A name that the content does not contain.
152
+ #
153
+ def self.wrapper_name(content)
154
+ name = NS_WRAPPER
155
+ name += "-x" while content.include?(name)
156
+ name
157
+ end
158
+ private_class_method :wrapper_name
159
+
160
+ #
161
+ # Remove the placeholder namespaces, and only those.
162
+ #
163
+ # Nokogiri's remove_namespaces! would also strip a namespace that
164
+ # the content declares itself, such as the MathML xmlns inside an
165
+ # OPAQUE <stem>, which must survive verbatim. Match the wrapper's
166
+ # own declarations, so a namespace of the content never matches,
167
+ # whatever its URI.
168
+ #
169
+ # The declarations stay on the wrapper element. Only its children
170
+ # reach the output, so the declarations never leak. Do not
171
+ # serialise the root itself.
172
+ #
173
+ # @param [Nokogiri::XML::Element] root The wrapper element.
174
+ #
175
+ # @return [Nokogiri::XML::Element] The same element.
176
+ #
177
+ def self.drop_placeholder_namespaces(root)
178
+ placeholders = root.namespace_definitions
179
+ root.traverse do |node|
180
+ node.namespace = nil if placeholders.include?(node.namespace)
181
+ next unless node.element?
182
+
183
+ node.attribute_nodes.each do |attr|
184
+ attr.namespace = nil if placeholders.include?(attr.namespace)
185
+ end
186
+ end
187
+ root
53
188
  end
189
+ private_class_method :drop_placeholder_namespaces
54
190
 
55
191
  def self.sanitizable?(content)
56
192
  content.is_a?(::String) && !content.empty? && content.match?(TAG_RX)
@@ -1,5 +1,5 @@
1
1
  module Relaton
2
2
  module Bib
3
- VERSION = "2.1.8".freeze
3
+ VERSION = "2.1.9".freeze
4
4
  end
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: relaton-bib
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.1.8
4
+ version: 2.1.9
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
@@ -171,8 +171,6 @@ 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
176
174
  - lib/relaton/bib/hash_parser_v1.rb
177
175
  - lib/relaton/bib/item_data.rb
178
176
  - lib/relaton/bib/model/abstract.rb
@@ -1,43 +0,0 @@
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
@@ -1,416 +0,0 @@
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