relaton 3.0.7 → 3.0.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.
Files changed (134) hide show
  1. checksums.yaml +4 -4
  2. data/bin/relaton +20 -0
  3. data/lib/relaton/bib/model/docidentifier.rb +17 -2
  4. data/lib/relaton/bibcollection.rb +146 -0
  5. data/lib/relaton/bibdata.rb +92 -0
  6. data/lib/relaton/bipm/data_outcomes_parser.rb +6 -1
  7. data/lib/relaton/cli/base_convertor.rb +134 -0
  8. data/lib/relaton/cli/command.rb +390 -0
  9. data/lib/relaton/cli/data_fetcher.rb +20 -0
  10. data/lib/relaton/cli/frontend/dist/404.html +18 -0
  11. data/lib/relaton/cli/frontend/dist/app.iife.js +1 -0
  12. data/lib/relaton/cli/frontend/dist/style.css +3 -0
  13. data/lib/relaton/cli/frontend/index.html +14 -0
  14. data/lib/relaton/cli/frontend/package-lock.json +2945 -0
  15. data/lib/relaton/cli/frontend/package.json +29 -0
  16. data/lib/relaton/cli/frontend/public/404.html +18 -0
  17. data/lib/relaton/cli/frontend/src/App.test.ts +501 -0
  18. data/lib/relaton/cli/frontend/src/App.vue +506 -0
  19. data/lib/relaton/cli/frontend/src/app.ts +90 -0
  20. data/lib/relaton/cli/frontend/src/components/DocumentDetail.test.ts +142 -0
  21. data/lib/relaton/cli/frontend/src/components/DocumentDetail.vue +228 -0
  22. data/lib/relaton/cli/frontend/src/components/DocumentRow.vue +88 -0
  23. data/lib/relaton/cli/frontend/src/components/Icon.test.ts +44 -0
  24. data/lib/relaton/cli/frontend/src/components/Icon.vue +55 -0
  25. data/lib/relaton/cli/frontend/src/components/Pagination.vue +26 -0
  26. data/lib/relaton/cli/frontend/src/dev/main.ts +100 -0
  27. data/lib/relaton/cli/frontend/src/dev/sample-data.json +71 -0
  28. data/lib/relaton/cli/frontend/src/lib/filter.test.ts +88 -0
  29. data/lib/relaton/cli/frontend/src/lib/filter.ts +95 -0
  30. data/lib/relaton/cli/frontend/src/lib/hydrate.test.ts +102 -0
  31. data/lib/relaton/cli/frontend/src/lib/hydrate.ts +55 -0
  32. data/lib/relaton/cli/frontend/src/lib/shards.test.ts +319 -0
  33. data/lib/relaton/cli/frontend/src/lib/shards.ts +183 -0
  34. data/lib/relaton/cli/frontend/src/lib/types.ts +148 -0
  35. data/lib/relaton/cli/frontend/src/lib/url.test.ts +151 -0
  36. data/lib/relaton/cli/frontend/src/lib/url.ts +111 -0
  37. data/lib/relaton/cli/frontend/src/shims-vue.d.ts +5 -0
  38. data/lib/relaton/cli/frontend/src/styles/index.css +27 -0
  39. data/lib/relaton/cli/frontend/src/test/fetch.ts +58 -0
  40. data/lib/relaton/cli/frontend/tsconfig.json +20 -0
  41. data/lib/relaton/cli/frontend/vite.config.ts +42 -0
  42. data/lib/relaton/cli/frontend/vitest.config.ts +11 -0
  43. data/lib/relaton/cli/frontend_assets.rb +58 -0
  44. data/lib/relaton/cli/full_text_search.rb +98 -0
  45. data/lib/relaton/cli/i18n.yaml +1 -0
  46. data/lib/relaton/cli/index_item_normalizer.rb +380 -0
  47. data/lib/relaton/cli/index_site_generator.rb +1015 -0
  48. data/lib/relaton/cli/relaton_file.rb +290 -0
  49. data/lib/relaton/cli/subcommand_collection.rb +263 -0
  50. data/lib/relaton/cli/subcommand_db.rb +81 -0
  51. data/lib/relaton/cli/templates/_document.liquid +111 -0
  52. data/lib/relaton/cli/templates/_index.liquid +61 -0
  53. data/lib/relaton/cli/templates/index/page.liquid +20 -0
  54. data/lib/relaton/cli/templates/index-style.css +1017 -0
  55. data/lib/relaton/cli/util.rb +8 -0
  56. data/lib/relaton/cli/version.rb +11 -0
  57. data/lib/relaton/cli/xml_convertor.rb +47 -0
  58. data/lib/relaton/cli/xml_to_html_renderer.rb +117 -0
  59. data/lib/relaton/cli/yaml_convertor.rb +102 -0
  60. data/lib/relaton/cli.rb +113 -0
  61. data/lib/relaton/doi/CLAUDE.md +1 -1
  62. data/lib/relaton/element_finder.rb +29 -0
  63. data/lib/relaton/gb/CLAUDE.md +1 -1
  64. data/lib/relaton/ietf/CLAUDE.md +1 -1
  65. data/lib/relaton/iho/CLAUDE.md +3 -3
  66. data/lib/relaton/iho/bibliography.rb +41 -27
  67. data/lib/relaton/iho/processor.rb +3 -2
  68. data/lib/relaton/nist/CLAUDE.md +11 -1
  69. data/lib/relaton/nist/README.adoc +7 -1
  70. data/lib/relaton/nist/hit_collection.rb +25 -1
  71. data/lib/relaton/nist/mods_parser.rb +32 -8
  72. data/lib/relaton/nist/pubs_export.rb +69 -11
  73. data/lib/relaton/ogc/CLAUDE.md +1 -1
  74. data/lib/relaton/omg/CLAUDE.md +1 -1
  75. data/lib/relaton/plateau/CLAUDE.md +1 -1
  76. data/lib/relaton/render/general.rb +319 -0
  77. data/lib/relaton/render/iso690/csl_import.rb +129 -0
  78. data/lib/relaton/render/iso690/element.rb +153 -0
  79. data/lib/relaton/render/iso690/elements/access.rb +44 -0
  80. data/lib/relaton/render/iso690/elements/authorizer.rb +45 -0
  81. data/lib/relaton/render/iso690/elements/citeid.rb +39 -0
  82. data/lib/relaton/render/iso690/elements/component_part.rb +97 -0
  83. data/lib/relaton/render/iso690/elements/creator.rb +267 -0
  84. data/lib/relaton/render/iso690/elements/edition.rb +97 -0
  85. data/lib/relaton/render/iso690/elements/extent.rb +71 -0
  86. data/lib/relaton/render/iso690/elements/identifier.rb +46 -0
  87. data/lib/relaton/render/iso690/elements/location.rb +73 -0
  88. data/lib/relaton/render/iso690/elements/medium.rb +32 -0
  89. data/lib/relaton/render/iso690/elements/numeration.rb +26 -0
  90. data/lib/relaton/render/iso690/elements/production.rb +111 -0
  91. data/lib/relaton/render/iso690/elements/pub_date.rb +63 -0
  92. data/lib/relaton/render/iso690/elements/series.rb +66 -0
  93. data/lib/relaton/render/iso690/elements/size.rb +45 -0
  94. data/lib/relaton/render/iso690/elements/status.rb +30 -0
  95. data/lib/relaton/render/iso690/elements/stddoc.rb +40 -0
  96. data/lib/relaton/render/iso690/elements/title.rb +56 -0
  97. data/lib/relaton/render/iso690/elements/updated.rb +38 -0
  98. data/lib/relaton/render/iso690/elements.rb +67 -0
  99. data/lib/relaton/render/iso690/fields.rb +142 -0
  100. data/lib/relaton/render/iso690/i18n/en.yml +48 -0
  101. data/lib/relaton/render/iso690/i18n/fr.yml +14 -0
  102. data/lib/relaton/render/iso690/i18n/ja.yml +38 -0
  103. data/lib/relaton/render/iso690/i18n.rb +83 -0
  104. data/lib/relaton/render/iso690/identifier_kinds.rb +29 -0
  105. data/lib/relaton/render/iso690/index_renderer.rb +57 -0
  106. data/lib/relaton/render/iso690/kinds.rb +53 -0
  107. data/lib/relaton/render/iso690/renderer.rb +150 -0
  108. data/lib/relaton/render/iso690/rules/bipm.rb +186 -0
  109. data/lib/relaton/render/iso690/rules/jis.rb +103 -0
  110. data/lib/relaton/render/iso690/rules/nist.rb +384 -0
  111. data/lib/relaton/render/iso690/rules.rb +227 -0
  112. data/lib/relaton/render/iso690/style/locale.rb +59 -0
  113. data/lib/relaton/render/iso690/style/name_form.rb +87 -0
  114. data/lib/relaton/render/iso690/style/scheme.rb +33 -0
  115. data/lib/relaton/render/iso690/style/sort_rule.rb +25 -0
  116. data/lib/relaton/render/iso690/style/template_map.rb +40 -0
  117. data/lib/relaton/render/iso690/style/type_template.rb +45 -0
  118. data/lib/relaton/render/iso690/style.rb +227 -0
  119. data/lib/relaton/render/iso690/styles/apa.yml +45 -0
  120. data/lib/relaton/render/iso690/styles/author-date.yml +31 -0
  121. data/lib/relaton/render/iso690/styles/chicago.yml +49 -0
  122. data/lib/relaton/render/iso690/styles/ieee-sa.yml +45 -0
  123. data/lib/relaton/render/iso690/styles/jis-en.yml +68 -0
  124. data/lib/relaton/render/iso690/styles/jis-ja.yml +67 -0
  125. data/lib/relaton/render/iso690/styles/lncs.yml +54 -0
  126. data/lib/relaton/render/iso690/styles/mla.yml +56 -0
  127. data/lib/relaton/render/iso690/template.rb +248 -0
  128. data/lib/relaton/render/iso690.rb +28 -0
  129. data/lib/relaton/render/version.rb +5 -0
  130. data/lib/relaton/un/CLAUDE.md +1 -1
  131. data/lib/relaton/version.rb +1 -1
  132. data/lib/relaton-cli.rb +17 -0
  133. data/lib/relaton-render.rb +13 -0
  134. metadata +192 -21
@@ -12,6 +12,12 @@ module Relaton
12
12
 
13
13
  GHNISTDATA = "https://raw.githubusercontent.com/relaton/relaton-data-nist/v2/"
14
14
 
15
+ # Errors of the local CSRC cache (read-only or full disk, a bad zip).
16
+ LOCAL_DATA_ERRORS = [
17
+ Errno::EROFS, Errno::EACCES, Errno::EPERM, Errno::ENOSPC,
18
+ Errno::ENOENT, Zip::Error
19
+ ].freeze
20
+
15
21
  attr_reader :reference
16
22
  attr_accessor :array
17
23
 
@@ -47,7 +53,7 @@ module Relaton
47
53
  # @return [Relaton::Nist::HitCollection] hits collection
48
54
  #
49
55
  def search
50
- @array = from_json
56
+ @array = from_json_or_empty
51
57
  @array = from_ga unless @array.any?
52
58
  sort_hits!
53
59
  end
@@ -400,6 +406,24 @@ module Relaton
400
406
  select_data.map { |h| Hit.new(hit_data(h), self) }
401
407
  end
402
408
 
409
+ #
410
+ # The CSRC hits, or none when the local CSRC data cannot be used: a
411
+ # file-system error must not stop the search, because the
412
+ # relaton-data-nist index can still answer (relaton-nist#57). Network
413
+ # errors are not rescued: they still reach Bibliography.search, which
414
+ # raises Relaton::RequestError, so a CSRC outage is not cached as
415
+ # "not found".
416
+ #
417
+ # @return [Array<Relaton::Nist::Hit>] hits
418
+ #
419
+ def from_json_or_empty
420
+ from_json
421
+ rescue *LOCAL_DATA_ERRORS => e
422
+ Util.warn "CSRC pubs-export data is not available (#{e.message}); " \
423
+ "searching the Relaton repository only", key: @reference
424
+ []
425
+ end
426
+
403
427
  def pubs_export_id(json)
404
428
  self.class.pubs_export_id json
405
429
  end
@@ -13,7 +13,7 @@ module Relaton
13
13
  }.freeze
14
14
 
15
15
  ATTRS = %i[type docidentifier title source abstract date contributor
16
- relation place series].freeze
16
+ relation place series edition].freeze
17
17
 
18
18
  def initialize(doc, series, errors = {})
19
19
  @doc = doc
@@ -51,17 +51,41 @@ module Relaton
51
51
  def get_id_from_str(str)
52
52
  return if str.nil? || str.empty?
53
53
 
54
- # DOIs arrive dotted ("NIST.HB.135e2022-upd1"); that MR-style form
55
- # parses through a path that skips UpdateCodes date lookup and drops
56
- # letter suffixes. Space the leading "PUB.SERIES." prefix so it parses
57
- # like the canonical short form (only the separator dots are touched,
58
- # not any inside the number). Then force :human rendering.
59
- spaced = str.sub(/\A(NIST|NBS)\.([A-Z][A-Za-z]*)\./, '\1 \2 ')
60
- ::Pubid::Nist::Identifier.parse(spaced).to_s(format: :human)
54
+ parse_pubid(str).to_s(format: :human)
61
55
  rescue ::Pubid::Errors::ParseError
62
56
  str.gsub(".", " ").sub(/^[\D]+/, &:upcase)
63
57
  end
64
58
 
59
+ # DOIs arrive dotted ("NIST.HB.135e2022-upd1"); that MR-style form
60
+ # parses through a path that skips UpdateCodes date lookup and drops
61
+ # letter suffixes. Space the leading "PUB.SERIES." prefix so it parses
62
+ # like the canonical short form (only the separator dots are touched,
63
+ # not any inside the number).
64
+ def parse_pubid(str)
65
+ spaced = str.sub(/\A(NIST|NBS)\.([A-Z][A-Za-z]*)\./, '\1 \2 ')
66
+ ::Pubid::Nist::Identifier.parse(spaced)
67
+ end
68
+
69
+ # The edition number a DOI states ("NBS.CIRC.111e2" -> 2), relaton-nist#56.
70
+ # The MODS carries no edition number of its own (its <edition> is prose:
71
+ # "New version", "Erratum"), so the DOI is the only source. Only a
72
+ # numbered "e<N>" edition counts: an "e<year>" edition is a date, and an
73
+ # "r<N>" revision is not an edition. An edition 0 counts as 1 (the issue's
74
+ # second rule; no live DOI has one).
75
+ # @return [Bib::Edition, nil]
76
+ def parse_edition
77
+ doi = parse_doi
78
+ return if doi.nil? || doi.empty?
79
+
80
+ edition = parse_pubid(doi).edition
81
+ return unless edition&.type == "e" && edition.id.to_s.match?(/\A\d{1,3}\z/)
82
+
83
+ number = edition.id.to_i.zero? ? "1" : edition.id
84
+ Bib::Edition.new(number: number, content: number)
85
+ rescue ::Pubid::Errors::Error, ArgumentError
86
+ nil
87
+ end
88
+
65
89
  # @return [String]
66
90
  def replace_wrong_doi(id)
67
91
  case id
@@ -8,6 +8,8 @@ module Relaton
8
8
 
9
9
  DOMAIN = "https://csrc.nist.gov".freeze
10
10
  PUBS_EXPORT = URI.join(DOMAIN, "/CSRC/media/feeds/metanorma/pubs-export")
11
+ # The default cache location. The live location is #datafile, which
12
+ # follows Relaton::Index.config.storage_dir (default Dir.home).
11
13
  DATAFILEDIR = File.expand_path ".relaton/nist", Dir.home
12
14
  DATAFILE = File.expand_path "pubs-export.zip", DATAFILEDIR
13
15
 
@@ -23,8 +25,8 @@ module Relaton
23
25
  def data
24
26
  @mutex.synchronize do
25
27
  @data ||= begin
26
- ctime = File.ctime DATAFILE if File.exist? DATAFILE
27
- if !ctime || ctime.to_date < ::Date.today || File.size(DATAFILE).zero?
28
+ ctime = File.ctime datafile if File.exist? datafile
29
+ if !ctime || ctime.to_date < ::Date.today || File.size(datafile).zero?
28
30
  fetch_data(ctime)
29
31
  end
30
32
  unzip
@@ -32,22 +34,64 @@ module Relaton
32
34
  end
33
35
  end
34
36
 
37
+ #
38
+ # The cache file of the pubs-export zip. It lives under the same root as
39
+ # the relaton-index cache, so one setting moves both (for example to a
40
+ # writable directory on a read-only host).
41
+ #
42
+ # @return [String]
43
+ #
44
+ def datafile
45
+ dir = Dir.home
46
+ dir = Relaton::Index.config.storage_dir if defined?(Relaton::Index)
47
+ File.expand_path "pubs-export.zip", File.join(dir, ".relaton", "nist")
48
+ end
49
+
35
50
  private
36
51
 
37
52
  #
38
- # Fetch data form server and save it to file
53
+ # Fetch data form server and save it to file. When the cache file cannot
54
+ # be written (a read-only file system, such as AWS Lambda —
55
+ # relaton-nist#57), keep the downloaded zip in memory for this process.
39
56
  #
40
57
  # @param ctime [Time, nil] file creation time
41
58
  #
42
59
  def fetch_data(ctime)
43
- if !ctime || ctime < last_modified
44
- @data = nil
45
- uri_open = URI.method(:open) || Kernel.method(:open)
46
- FileUtils.mkdir_p DATAFILEDIR
47
- IO.copy_stream(uri_open.call("#{PUBS_EXPORT}.zip"), DATAFILE)
60
+ return unless !ctime || ctime < last_modified
61
+
62
+ @data = nil
63
+ zip = download
64
+ begin
65
+ write_datafile zip
66
+ @buffer = nil
67
+ rescue SystemCallError, IOError => e
68
+ Util.warn "Cannot write `#{datafile}` (#{e.message}); " \
69
+ "the CSRC pubs-export data stays in memory"
70
+ @buffer = zip
48
71
  end
49
72
  end
50
73
 
74
+ #
75
+ # Write the zip to a temp file and rename it into place, so a failed
76
+ # write (a full disk) never leaves a truncated cache that later
77
+ # processes would read as fresh.
78
+ #
79
+ # @param zip [String] zip file content
80
+ #
81
+ def write_datafile(zip)
82
+ FileUtils.mkdir_p File.dirname(datafile)
83
+ tmp = "#{datafile}.#{Process.pid}.tmp"
84
+ File.binwrite tmp, zip
85
+ File.rename tmp, datafile
86
+ ensure
87
+ FileUtils.rm_f tmp if tmp && File.exist?(tmp)
88
+ end
89
+
90
+ def download
91
+ uri_open = URI.method(:open) || Kernel.method(:open)
92
+ uri_open.call("#{PUBS_EXPORT}.zip", &:read)
93
+ end
94
+
51
95
  def last_modified
52
96
  uri = URI("#{PUBS_EXPORT}.meta")
53
97
  Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") do |http|
@@ -56,15 +100,29 @@ module Relaton
56
100
  end
57
101
 
58
102
  #
59
- # upack zip file
103
+ # upack zip file (the in-memory copy when the cache was not writable)
60
104
  #
61
105
  # @return [Array<Hash>] json data
62
106
  #
63
107
  def unzip
64
- Zip::File.open(DATAFILE) do |zf|
65
- Relaton::Json.parse_external zf.first.get_input_stream.read
108
+ if @buffer
109
+ # open_buffer returns the buffer, not the block's value
110
+ rows = nil
111
+ Zip::File.open_buffer(StringIO.new(@buffer)) do |zf|
112
+ rows = read_entry zf
113
+ end
114
+ @buffer = nil
115
+ rows
116
+ else
117
+ Zip::File.open(datafile) { |zf| read_entry zf }
66
118
  end
67
119
  end
120
+
121
+ # The block form closes the entry stream. Without it the cache file
122
+ # stays open, and Windows then refuses to replace or delete it.
123
+ def read_entry(zip_file)
124
+ Relaton::Json.parse_external zip_file.first.get_input_stream(&:read)
125
+ end
68
126
  end
69
127
  end
70
128
  end
@@ -203,7 +203,7 @@ Models declare attributes and XML mappings in the same class. `Item` is the seri
203
203
  - **RSpec** with `--format documentation`
204
204
  - **VCR** + **WebMock** for HTTP interaction recording (`spec/vcr_cassettes/`)
205
205
  - **Jing** for RelaxNG schema validation against `grammars/relaton-ogc-compile.rng`
206
- - **equivalent-xml** for XML comparison in tests
206
+ - **canon** for XML comparison in tests
207
207
  - Fixtures live in `spec/fixtures/` (YAML, XML)
208
208
  - Integration tests in `spec/relaton/ogc/integration_spec.rb` exercise the full search → fetch → serialize pipeline
209
209
 
@@ -124,7 +124,7 @@ Items can be serialized to/from YAML and XML. Tests verify round-trip fidelity f
124
124
  ### Test patterns
125
125
 
126
126
  - RSpec with `expect` syntax (monkey patching disabled)
127
- - `equivalent-xml` for XML comparison
127
+ - `canon` for XML comparison
128
128
  - `ruby-jing` for RELAX NG schema validation against `grammars/relaton-omg-compile.rng`
129
129
  - VCR cassettes in `spec/vcr_cassettes/` for HTTP interaction recording
130
130
  - Fixtures in `spec/fixtures/` (YAML and XML reference files)
@@ -114,7 +114,7 @@ RNG grammar files in `grammars/` define the XML schema. Tests validate fixtures
114
114
  - **Index fixture:** `spec/fixtures/index-v2.zip` (pubid-backed, `_type: pubid:plateau:*` rows) is pre-loaded into the `Relaton::Index` pool in `before(:suite)` via a `Relaton::Index::Type` built with `::Pubid::Plateau::Identifier` (configured in `spec/support/webmock.rb`, mirroring NIST/CIE). Run `rake spec:update_index` to refresh from relaton-data-plateau.
115
115
  - RSpec with `expect` syntax (no monkey-patching)
116
116
  - VCR cassettes record HTTP interactions (in `spec/fixtures/vcr_cassettes/`)
117
- - `equivalent-xml` for XML comparison assertions
117
+ - `canon` for XML comparison assertions
118
118
  - `jing` matcher for RNG schema validation
119
119
  - SimpleCov for code coverage
120
120
 
@@ -0,0 +1,319 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Relaton
4
+ module Render
5
+ # Compatibility entry point (relaton-render#90).
6
+ #
7
+ # The v2 engine replaced the liquid/config/i18n machinery with the
8
+ # template evaluator over CitationStyle instances
9
+ # (Relaton::Render::Iso690). isodoc main's render-isodoc subclasses
10
+ # this constant, so it must exist for all-git-main stacks to boot.
11
+ # This facade delegates rendering to the v2 engine; the retired
12
+ # options (config:, template overrides in liquid syntax) are ignored
13
+ # with one warning — migrate to a CitationStyle instance, which
14
+ # expresses everything the old config.yml did (see the citation
15
+ # module in relaton-models).
16
+ class General
17
+ # The eref `style=` vocabulary metanorma-standoc validates against
18
+ # (cleanup/xref.rb reads citetemplate.template_raw's keys) — the
19
+ # 1.x default config's top-level citation styles.
20
+ CITE_STYLES = %i[
21
+ author_date author_date_br author date reference_tag title
22
+ title_reference_tag short
23
+ ].freeze
24
+
25
+ CiteTemplate = Struct.new(:template_raw)
26
+
27
+ def initialize(options = {})
28
+ options = deep_symbolize(options)
29
+ @lang = options[:language] || options[:lang] || "en"
30
+ renderer_opts = { lang: @lang, script: options[:script] || "Latn",
31
+ labels: options[:i18nhash] || {} }
32
+ # A flavor names its own CitationStyle instance (name or YAML path)
33
+ options[:style] and renderer_opts[:style] = options[:style]
34
+ @renderer = Iso690::Renderer.new(**renderer_opts)
35
+ @renderer_opts = renderer_opts
36
+ @renderers_by_lang = {}
37
+ warn_general_config if options[:config]
38
+ end
39
+
40
+ # A collection's items may each declare their own language; the
41
+ # renderer localizes by language, so a reference renders in its
42
+ # own language within a document of another (the 1.x plateau
43
+ # per-reference behaviour)
44
+ def renderer_for(lang)
45
+ return @renderer if lang.nil? || lang == @lang
46
+
47
+ @renderers_by_lang[lang] ||= Iso690::Renderer.new(
48
+ **@renderer_opts.merge(lang: lang,
49
+ script: %w(ja ko zh).include?(lang) ? nil : "Latn")
50
+ )
51
+ end
52
+
53
+ def render(model, embedded: false, **opts)
54
+ model = to_model(model)
55
+ text =
56
+ if model.respond_to?(:formattedref) && model.formattedref
57
+ model.formattedref.content
58
+ else
59
+ # a collection's items may each declare their own language;
60
+ # the single-item render routes like render_all does
61
+ renderer_for(Array(model.language).first).render(model, **opts)
62
+ end
63
+ embedded ? text : "<formattedref>#{text}</formattedref>"
64
+ end
65
+
66
+ def citation(model, **opts)
67
+ @renderer.citation(to_model(model), **opts)
68
+ end
69
+
70
+ # isodoc's references rendering: renderings[id][:formattedref]
71
+ # feeds the <formattedref> of every bibitem (isodoc
72
+ # presentation_function/refs.rb).
73
+ def render_all(bib, type: "author-date")
74
+ items = facade_bibitems(bib) or return nil
75
+ disambiguators = date_disambiguators(items)
76
+ items.each_with_object({}).with_index do |(item, m), i|
77
+ renderer = renderer_for(Array(item.language).first)
78
+ ref = begin
79
+ renderer.render(item, disambiguator: disambiguators[item.id])
80
+ rescue ::Relaton::Render::Unrenderable
81
+ next
82
+ end
83
+ m[item.id] = {
84
+ author: renderer.in_text_author(item),
85
+ date: renderer.disambiguated_date(item,
86
+ disambiguator:
87
+ disambiguators[item.id]),
88
+ citation: citation_renderings(item, ref, renderer,
89
+ type: type,
90
+ disambiguator:
91
+ disambiguators[item.id]),
92
+ formattedref: terminate_reference(ref, item),
93
+ }
94
+ end
95
+ end
96
+
97
+ # isodoc's styled citations consume these keys: the default is the
98
+ # authoritative identifier; the short cite is the style's citation,
99
+ # falling back to the reference rendering (the 1.x short-cite)
100
+ FIRST_DELIM = '<span class="fmt-first-biblio-delim"/>'
101
+
102
+ # A creator list alone (flavors' document-history name forms)
103
+ def creator_names(item)
104
+ @renderer.render_creators(to_model(item))
105
+ end
106
+
107
+ # render_all feeds the bibliography list: a reference not ending
108
+ # in the biblio terminator takes one (the 1.x render1 behaviour;
109
+ # single-item render stays verbatim)
110
+ # The batch disambiguation suffixes ("2022a"): items sharing a
111
+ # principal-creator surname and a date take alphabetical
112
+ # suffixes in bibliography order (the 1.x
113
+ # disambig_author_date_citations)
114
+ def date_disambiguators(items)
115
+ keys = items.filter_map do |item|
116
+ begin
117
+ [item.id, @renderer.author_key(item),
118
+ @renderer.citation(item)]
119
+ rescue ::Relaton::Render::Unrenderable
120
+ nil
121
+ end
122
+ end
123
+ groups = keys.group_by { |_, author, cite| [author, cite] }
124
+ suffixes = {}
125
+ groups.each_value do |group|
126
+ next if group.size < 2
127
+
128
+ group.each_with_index do |(id, _author, _cite), i|
129
+ suffixes[id] = ("a".ord + i).chr
130
+ end
131
+ end
132
+ suffixes
133
+ end
134
+
135
+ # The bibliography terminator; flavors override to suppress it
136
+ # (1.x use_terminator?), receiving the item it terminates. A
137
+ # reference closing on a footnote closes the sentence before it:
138
+ # the terminator lands ahead of the trailing fn.
139
+ FOOTNOTE_CLOSE = "</fn>".freeze
140
+
141
+ def terminate_reference(ref, _item = nil)
142
+ return ref if ref.empty? || ref.rstrip.end_with?(".")
143
+
144
+ if (fn = ref.rindex("<fn"))
145
+ head = ref[0...fn].rstrip
146
+ return ref if head.empty?
147
+
148
+ sep = head.end_with?(".") ? "" : "."
149
+ return "#{head}#{sep}#{ref[fn..]}"
150
+ end
151
+
152
+ "#{ref}."
153
+ end
154
+
155
+ # The style-keyed capsule ("citation" => { author_date: "..." })
156
+ # is what isodoc's styled erefs consume (their style attribute
157
+ # names the key)
158
+ def citation_renderings(item, ref, renderer, type: "author-date",
159
+ disambiguator: nil)
160
+ # A style declaring shortFromReference takes the reference form
161
+ # with the first-biblio marker (the 1.x citetemplate short)
162
+ short = if renderer.short_from_reference?
163
+ renderer.render_short(item, FIRST_DELIM)
164
+ else
165
+ short = renderer.citation(item)
166
+ short.empty? ? renderer.render_short(item, FIRST_DELIM) :
167
+ short
168
+ end
169
+
170
+ title = renderer.title_citation(item)
171
+
172
+ {
173
+ full: ref,
174
+ default: item.docidentifier.first&.content.to_s,
175
+ short: short,
176
+ author_date: renderer.citation(item,
177
+ disambiguator: disambiguator),
178
+ author_date_br: renderer.bracketed_date_citation(
179
+ item, disambiguator: disambiguator,
180
+ ),
181
+ author: renderer.in_text_author(item),
182
+ date: renderer.disambiguated_date(item),
183
+ # reference_tag: no rendering — isodoc falls back to the
184
+ # biblio-tag anchor (the 1.x nil)
185
+ title: title,
186
+ title_reference_tag: title,
187
+ }
188
+ end
189
+
190
+ # isodoc's pref_ref_code_parse reads
191
+ # data[:authoritative_identifier] to build biblio-tag identifiers
192
+ # (presentation_function/docid.rb). Returns [data, self] as the
193
+ # 1.x signature did.
194
+ def parse(doc)
195
+ [{ authoritative_identifier: facade_docids(doc) }, self]
196
+ end
197
+
198
+ def citetemplate
199
+ CiteTemplate.new(CITE_STYLES.to_h { |s| [s, ""] })
200
+ end
201
+
202
+ private
203
+
204
+ # 1.x accepted a <references> XML string (isodoc strips namespaces
205
+ # before building it) or a ready array of models.
206
+ def facade_bibitems(bib)
207
+ case bib
208
+ when Array then bib
209
+ when String
210
+ require "moxml"
211
+ root = Moxml.parse(bib).root
212
+ return nil unless root&.name == "references"
213
+
214
+ klass = facade_bibitem_class
215
+ root.xpath("./bibitem").filter_map { |b| klass.from_xml(b.to_xml) }
216
+ end
217
+ end
218
+
219
+ def facade_bibitem_class
220
+ Relaton::Bib::Bibitem
221
+ rescue NameError
222
+ Relaton::Bib::Item
223
+ end
224
+
225
+ # 1.x Parse#authoritative_identifier semantics: the
226
+ # primary/language cascade, the excluded types (URN, DOI, ISBN...)
227
+ # dropped, and scoped ids (biblio-tag duplicates, IEEE trademark)
228
+ # demoted within their type group. isodoc hands the live bibitem
229
+ # node with the document's default namespace still attached, so the
230
+ # element queries must be namespace-agnostic.
231
+ EXCLUDED_ID_TYPES = %w(METANORMA METANORMA-ORDINAL AUTHOR-DATE TITLE
232
+ URN ISO-REFERENCE ISSN ISBN DOI).freeze
233
+
234
+ # Flavors extend the authoritative-identifier exclusions by
235
+ # overriding this reader on their facade subclass
236
+ def excluded_id_types
237
+ EXCLUDED_ID_TYPES
238
+ end
239
+
240
+ def facade_docids(doc)
241
+ node = doc
242
+ unless node.respond_to?(:xpath)
243
+ require "moxml"
244
+ node = Moxml.parse(doc.to_s).root
245
+ end
246
+ return [] unless node
247
+
248
+ ids = node.xpath("./*[local-name() = 'docidentifier']")
249
+ ids = facade_scope_filter(ids)
250
+ out = nil
251
+ [
252
+ ->(x) { x["language"] == @lang && x["primary"] },
253
+ ->(x) { x["primary"] },
254
+ ->(x) { x["language"] == @lang },
255
+ ->(_x) { true },
256
+ ].each do |p|
257
+ out = ids.select do |x|
258
+ p.call(x) && !excluded_id_types.include?(facade_id_type(x))
259
+ end
260
+ out.empty? or break
261
+ end
262
+ # esc-wrapped: isodoc's l10n skips the tags, leaving a standard
263
+ # identifier's punctuation verbatim (the 1.x Parse behaviour)
264
+ out.map { |x| "<esc>#{x.text.strip}</esc>" }
265
+ end
266
+
267
+ def facade_id_type(id)
268
+ t = id["type"] or return nil
269
+ m = /\A(ISBN|ISSN)\..*/i.match(t) or return t.upcase
270
+ m[1].upcase
271
+ end
272
+
273
+ def facade_scope_filter(ids)
274
+ ids.detect { |i| i["scope"] } or return ids
275
+ ids.group_by { |i| i["type"] }.flat_map do |type, group|
276
+ grouped = group.group_by { |i| i["scope"] }
277
+ if type == "IEEE" then grouped["trademark"] || grouped[nil] || []
278
+ else grouped[nil] || []
279
+ end
280
+ end
281
+ end
282
+
283
+ # 1.x accepted string- and symbol-keyed options alike (the old
284
+ # engine deep-symbolized via metanorma-utils); isodoc's bibrenderer
285
+ # and every flavor still pass string keys.
286
+ def deep_symbolize(value)
287
+ case value
288
+ # isodoc i18n hashes carry nil keys (an unlabeled punct slot)
289
+ when Hash then value.to_h { |k, v| [k&.to_sym, deep_symbolize(v)] }
290
+ when Array then value.map { |v| deep_symbolize(v) }
291
+ else value
292
+ end
293
+ end
294
+
295
+ # 1.x render/citation accepted a bibitem XML string; the Iso690
296
+ # engine consumes model instances.
297
+ def to_model(model)
298
+ return model unless model.is_a?(String)
299
+
300
+ klass = facade_bibitem_class
301
+ klass.from_xml(model)
302
+ end
303
+
304
+ def warn_general_config
305
+ return if General.config_warned
306
+
307
+ General.config_warned = true
308
+ warn "relaton-render: Relaton::Render::General is a compatibility " \
309
+ "facade over the v2 engine; the liquid config option is " \
310
+ "retired and ignored. Author a CitationStyle instance " \
311
+ "(relaton-models citation module) for custom rendering."
312
+ end
313
+
314
+ class << self
315
+ attr_accessor :config_warned
316
+ end
317
+ end
318
+ end
319
+ end