relaton-render 3.0.0.pre.alpha.10 → 3.0.0.pre.alpha.12

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: 1766ae3fd56fd0795621ff3d1edb933e5bac8e2f30bca21acefee9bb04897634
4
- data.tar.gz: 2466a4fad57993f7200b082382fa65971fb683cc9cbda96e778ddd735694a229
3
+ metadata.gz: 6940d659b738ee1c15dc46b7ee7be2bfb3f3bf322fd6cb8ae5fe70e19a2ab7ce
4
+ data.tar.gz: 060007b9cd392e9bf4b84cac3961aa8f0881849e74195c4ca6eb3f5f6dcf64f6
5
5
  SHA512:
6
- metadata.gz: db257cae7bd79d5d7cfa657770f50272c6fce01858295a5d62279060b6a13c4bec40e7dc36d62951689e68319f983c09b428431a1e71912eabcc486040d5d6fa
7
- data.tar.gz: 35d018a607b463d0f4de6b52553f927ae605370b56aeea96217022df9203d5027f83775c057069ef9d186aada6d06098439ac0ceed10155fea4d8a410f669253
6
+ metadata.gz: 4a989f5e702e6e27afbad1fab8dc59c7bd092355eab5dbfb1cedf0b99c2373e71c99c3ed418e033f6638f00addcf68d983006213206a4200b7ef6e45c9907682
7
+ data.tar.gz: 56ac7f18bd13de8324a8ac118d68c7c9d98b6b3eb49b455a32cbca194802097b2e5123e725427ff84d16fd8fe1bd4fe952de7e90c610bf27e39cd94d105a76c2
@@ -63,7 +63,7 @@ module Relaton
63
63
  end
64
64
  m[item.id] = {
65
65
  id: item.id, ord: i,
66
- formattedref: terminate_reference(ref),
66
+ formattedref: terminate_reference(ref, item),
67
67
  citation: citation_renderings(item, ref),
68
68
  }
69
69
  end
@@ -82,7 +82,9 @@ module Relaton
82
82
  # render_all feeds the bibliography list: a reference not ending
83
83
  # in the biblio terminator takes one (the 1.x render1 behaviour;
84
84
  # single-item render stays verbatim)
85
- def terminate_reference(ref)
85
+ # The bibliography terminator; flavors override to suppress it
86
+ # (1.x use_terminator?), receiving the item it terminates
87
+ def terminate_reference(ref, _item = nil)
86
88
  return ref if ref.empty? || ref.rstrip.end_with?(".")
87
89
 
88
90
  "#{ref}."
@@ -147,6 +149,12 @@ module Relaton
147
149
  EXCLUDED_ID_TYPES = %w(METANORMA METANORMA-ORDINAL AUTHOR-DATE TITLE
148
150
  URN ISO-REFERENCE ISSN ISBN DOI).freeze
149
151
 
152
+ # Flavors extend the authoritative-identifier exclusions by
153
+ # overriding this reader on their facade subclass
154
+ def excluded_id_types
155
+ EXCLUDED_ID_TYPES
156
+ end
157
+
150
158
  def facade_docids(doc)
151
159
  node = doc
152
160
  unless node.respond_to?(:xpath)
@@ -165,7 +173,7 @@ module Relaton
165
173
  ->(_x) { true },
166
174
  ].each do |p|
167
175
  out = ids.select do |x|
168
- p.call(x) && !EXCLUDED_ID_TYPES.include?(facade_id_type(x))
176
+ p.call(x) && !excluded_id_types.include?(facade_id_type(x))
169
177
  end
170
178
  out.empty? or break
171
179
  end
@@ -62,7 +62,7 @@ module Relaton
62
62
  end
63
63
 
64
64
  def item_kind
65
- @kind || Kinds.kind_for(@model.type)
65
+ @kind || @style.kind_for(@model.type)
66
66
  end
67
67
 
68
68
  def person_completename(person)
@@ -77,7 +77,7 @@ module Relaton
77
77
  parts = Array(person.name&.forename).map do |f|
78
78
  content = localized(f)
79
79
  next content unless content.empty?
80
- next "#{f.initial}." if f.initial.to_s.length == 1
80
+ next initial(f.initial) if f.initial.to_s.length == 1
81
81
 
82
82
  ""
83
83
  end.reject(&:empty?)
@@ -93,14 +93,23 @@ module Relaton
93
93
  end
94
94
  end
95
95
 
96
- # The declared creator fallback (e.g. the publisher's
97
- # abbreviation for standards with no personal creators)
96
+ # The declared creator fallback for items with no personal
97
+ # creators: the publisher's abbreviation or full name
98
98
  def fallback_name
99
- return "" unless name_form.creator_fallback == "publisher_abbrev"
100
-
101
- contributors("publisher").map do |c|
102
- localized(c.organization&.abbreviation)
103
- end.reject(&:empty?).first.to_s
99
+ orgs = contributors("publisher")
100
+ name = case name_form.creator_fallback
101
+ when "publisher_abbrev"
102
+ orgs.map do |c|
103
+ localized(c.organization&.abbreviation)
104
+ end
105
+ when "publisher_name"
106
+ orgs.map do |c|
107
+ localized(Array(c.organization&.name).first)
108
+ end
109
+ else
110
+ []
111
+ end
112
+ name.reject(&:empty?).first.to_s
104
113
  end
105
114
 
106
115
  def names
@@ -15,9 +15,18 @@ module Relaton
15
15
 
16
16
 
17
17
  def render
18
- d = identifiers.first or return ""
18
+ return "" if identifiers.empty?
19
19
 
20
- IdentifierKinds.render(d)
20
+ ids = @style.templates.identifier_mode == "first" ?
21
+ [identifiers.first] : identifiers
22
+ ids.map { |d| render_id(d) }
23
+ .join(@i18n.punct_fetch("identifier_join", ". "))
24
+ end
25
+
26
+ # A flavor's identifier kinds subclass this element and
27
+ # override here
28
+ def render_id(docidentifier)
29
+ IdentifierKinds.render(docidentifier)
21
30
  end
22
31
 
23
32
  private
@@ -12,13 +12,20 @@ module Relaton
12
12
  end
13
13
 
14
14
  def render
15
+ text = uri
15
16
  form = uri_form
17
+ # an access location cites its own label ("At: Library"); a
18
+ # uri carries the availability label
19
+ if @from_accesslocation
20
+ return "#{@i18n.label('at_url')} #{text}".strip
21
+ end
22
+
16
23
  text = if form && !form.empty?
17
24
  ::Relaton::Render::Iso690::Template.new(form).evaluate(
18
- "uri" => Template::Field[!uri.empty?, uri],
25
+ "uri" => Template::Field[!text.empty?, text],
19
26
  )
20
27
  else
21
- uri
28
+ text
22
29
  end
23
30
 
24
31
  label = @short ? "" : "#{@i18n.label('available_from')} "
@@ -44,6 +51,7 @@ module Relaton
44
51
  from_accesslocation =
45
52
  Array(@model.accesslocation).map(&:to_s).reject(&:empty?)
46
53
  unless from_accesslocation.empty?
54
+ @from_accesslocation = true
47
55
  return from_accesslocation.first.to_s
48
56
  end
49
57
 
@@ -14,12 +14,7 @@ module Relaton
14
14
  end
15
15
 
16
16
  def render
17
- text = range || year&.to_s
18
- return text if (form = @style.templates.date_form).empty?
19
-
20
- ::Relaton::Render::Iso690::Template.new(form).evaluate(
21
- "date" => Template::Field[!text.to_s.empty?, text.to_s],
22
- )
17
+ range || year&.to_s
23
18
  end
24
19
 
25
20
  private
@@ -27,7 +22,9 @@ module Relaton
27
22
  def publication_date
28
23
  Array(@model.date).find(&:published) ||
29
24
  Array(@model.date).find { |d| d.type.nil? } ||
30
- Array(@model.date).find { |d| d.type == "created" }
25
+ Array(@model.date).find { |d| d.type == "created" } ||
26
+ Array(@model.date).find { |d| d.type == "issued" } ||
27
+ Array(@model.date).find { |d| d.type == "circulated" }
31
28
  end
32
29
 
33
30
  def publication_date_value
@@ -39,7 +36,9 @@ module Relaton
39
36
  d = publication_date or return nil
40
37
  dash = @i18n.label("date_range")
41
38
  if d.from && d.to
42
- "#{d.from.to_date.year}#{dash}#{d.to.to_date.year}"
39
+ fy = d.from.to_date.year
40
+ ty = d.to.to_date.year
41
+ fy == ty ? fy.to_s : "#{fy}#{dash}#{ty}"
43
42
  elsif d.from
44
43
  "#{d.from.to_date.year}#{dash}"
45
44
  end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Relaton
4
+ module Render
5
+ module Iso690
6
+ module Elements
7
+ # The item's updated date, nested in the creator date group
8
+ # ("(2018 (updated November 2018))")
9
+ class Updated < Element
10
+ def present?
11
+ !render.empty?
12
+ end
13
+
14
+ def render
15
+ d = Array(@model.date).find { |x| x.type == "updated" } or
16
+ return ""
17
+ value = d.at || d.from || d.to or return ""
18
+
19
+ date = value.to_date
20
+ " (#{label('updated')} #{month(date.month)} #{date.year})"
21
+ rescue ArgumentError, TypeError
22
+ ""
23
+ end
24
+
25
+ private
26
+
27
+ def label(key)
28
+ @i18n.label(key)
29
+ end
30
+
31
+ def month(num)
32
+ @i18n.label("month_#{num}")
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -22,6 +22,7 @@ module Relaton
22
22
  autoload :Stddoc, "relaton/render/iso690/elements/stddoc"
23
23
  autoload :Status, "relaton/render/iso690/elements/status"
24
24
  autoload :Citeid, "relaton/render/iso690/elements/citeid"
25
+ autoload :Updated, "relaton/render/iso690/elements/updated"
25
26
 
26
27
  CLASSES = {
27
28
  creator: Creator,
@@ -41,11 +42,22 @@ module Relaton
41
42
  stddoc: Stddoc,
42
43
  status: Status,
43
44
  citeid: Citeid,
45
+ updated: Updated,
44
46
  }.freeze
45
47
 
46
- def self.build(name, model, style:, i18n:, short: false)
47
- CLASSES.fetch(name).new(model, style: style, i18n: i18n,
48
- short: short)
48
+ class << self
49
+ # Flavors extend the vocabulary through the renderer's
50
+ # element map: a flavor slot resolves before the built-ins,
51
+ # scoped to that renderer alone (never process-wide)
52
+ def resolve(name, elements = {})
53
+ elements[name.to_sym] || CLASSES.fetch(name, nil)
54
+ end
55
+ end
56
+
57
+ def self.build(name, model, style:, i18n:, short: false,
58
+ elements: {})
59
+ klass = resolve(name, elements) or return nil
60
+ klass.new(model, style: style, i18n: i18n, short: short)
49
61
  end
50
62
  end
51
63
  end
@@ -11,49 +11,99 @@ module Relaton
11
11
  ELEMENT_SLOTS = %i[
12
12
  creator title edition medium series production
13
13
  date numeration component_part identifier location size
14
- extent access stddoc status citeid
14
+ extent access stddoc status citeid updated
15
15
  ].freeze
16
16
 
17
17
  def initialize(model, style:, i18n:, disambiguator: nil,
18
- short: false)
18
+ short: false, elements: {})
19
19
  @model = model
20
20
  @style = style
21
21
  @i18n = i18n
22
22
  @disambiguator = disambiguator.to_s
23
23
  @short = short
24
+ @elements = elements
24
25
  end
25
26
 
26
27
  def to_h
27
28
  table = {}
28
- ELEMENT_SLOTS.each do |slot|
29
+ slots.each do |slot|
29
30
  key = slot.to_s.delete("_").downcase
30
31
  table[key] = element_field(slot)
31
32
  end
33
+ table["dategroup"] = dategroup_field
34
+ unless table["dategroup"].present?
35
+ table["dategroup"] = fallback_field(:dategroup)
36
+ end
32
37
  table.merge(name_fields)
33
38
  end
34
39
 
40
+ def fallback_field(slot)
41
+ fallback = @style.fallback_for(item_kind, slot)
42
+ fallback ? Template::Field[true, fallback] :
43
+ Template::Field[false, ""]
44
+ end
45
+
35
46
  private
36
47
 
48
+ # The date with the updated date nested ("(2018 (updated
49
+ # November 2018))"); the bare date slot stays untouched
50
+ def dategroup_field
51
+ date = raw_field(:date)
52
+ updated = raw_field(:updated)
53
+ return Template::Field[false, ""] if !date.present? &&
54
+ !updated.present?
55
+
56
+ inner = updated.present? ? updated.text : date.text
57
+ inner = "#{date.text}#{updated.text}" if date.present? &&
58
+ updated.present?
59
+ Template::Field[true, "(#{inner})"]
60
+ end
61
+
62
+ private
63
+
64
+ def slots
65
+ ELEMENT_SLOTS | @elements.keys
66
+ end
67
+
68
+ # The element's own render, before any absent-slot fallback:
69
+ # the creator-date group cites real dates only
70
+ def raw_field(slot)
71
+ element_field = build_field(slot)
72
+ return element_field if element_field.present?
73
+
74
+ Template::Field[false, ""]
75
+ end
76
+
37
77
  def element_field(slot)
78
+ field = build_field(slot)
79
+ return field if field.present?
80
+
81
+ fallback = @style.fallback_for(item_kind, slot)
82
+ return Template::Field[true, fallback] if fallback
83
+
84
+ Template::Field[false, ""]
85
+ end
86
+
87
+ def build_field(slot)
38
88
  element = Elements.build(slot, @model, style: @style,
39
- i18n: @i18n, short: @short)
89
+ i18n: @i18n, short: @short,
90
+ elements: @elements) or
91
+ return Template::Field[false, ""]
40
92
  text = element.render.to_s
41
93
  if element.present? && !text.empty?
42
94
  return Template::Field[true, text]
43
95
  end
44
96
 
45
- fallback = @style.fallback_for(item_kind, slot)
46
- return Template::Field[true, fallback] if fallback
47
-
48
97
  Template::Field[false, ""]
49
98
  end
50
99
 
51
100
  def item_kind
52
- Kinds.kind_for(@model.type)
101
+ @style.kind_for(@model.type)
53
102
  end
54
103
 
55
104
  def name_fields
56
- creator = Elements.build(:creator, @model, style: @style, i18n: @i18n)
105
+ creator = Elements.build(:creator, @model, style: @style,
106
+ i18n: @i18n, elements: @elements)
57
107
  {
58
108
  "surname" => Template::Field[creator.present?, creator.in_text],
59
109
  "givennames" => Template::Field[!creator.principal_given.empty?,
@@ -26,6 +26,7 @@ labels:
26
26
  series_no: "no."
27
27
  report_no: "Report no."
28
28
  available_from: "Available from:"
29
+ at_url: "At:"
29
30
  in: "In:"
30
31
  and: "and"
31
32
  oxford_comma: ","
@@ -16,19 +16,10 @@ module Relaton
16
16
  "MRN" => ->(content) { content },
17
17
  }.freeze
18
18
 
19
- # Flavor-registered kinds, consulted before the built-ins; the
20
- # registry is process-wide by design
21
- OVERRIDES = {}
22
-
23
19
  class << self
24
- def register(type, kind)
25
- OVERRIDES[type] = kind
26
- kind
27
- end
28
-
29
20
  def render(docidentifier)
30
21
  content = docidentifier.content.to_s
31
- kind = OVERRIDES[docidentifier.type] || KINDS[docidentifier.type]
22
+ kind = KINDS[docidentifier.type]
32
23
  kind ? kind.call(content) : content
33
24
  end
34
25
  end
@@ -38,6 +38,12 @@ module Relaton
38
38
 
39
39
  DEFAULT_KIND = "report"
40
40
 
41
+ class << self
42
+ def mapped?(type)
43
+ TO_KIND.key?(type.to_s)
44
+ end
45
+ end
46
+
41
47
  def self.kind_for(type)
42
48
  TO_KIND.fetch(type.to_s, DEFAULT_KIND)
43
49
  end
@@ -23,7 +23,8 @@ module Relaton
23
23
  end
24
24
 
25
25
  def initialize(style: DEFAULT_STYLE, lang: "en", script: "Latn",
26
- labels: {})
26
+ labels: {}, elements: {})
27
+ @elements = elements
27
28
  @style = Style.load(style)
28
29
  @i18n = I18n.load(lang)
29
30
  @i18n.overlay_hash!(labels) unless labels.empty?
@@ -34,10 +35,11 @@ module Relaton
34
35
  end
35
36
 
36
37
  def render(model)
37
- kind = Kinds.kind_for(model.type)
38
+ kind = @style.kind_for(model.type)
38
39
  template = @style.template_for(kind, home: home_docid?(model))
39
40
  out = Template.new(template).evaluate(
40
- Fields.new(model, style: @style, i18n: @i18n).to_h,
41
+ Fields.new(model, style: @style, i18n: @i18n,
42
+ elements: @elements).to_h,
41
43
  )
42
44
  raise ::Relaton::Render::Unrenderable,
43
45
  "no renderable elements" if out.strip.empty?
@@ -55,16 +57,17 @@ module Relaton
55
57
  # A creator list alone (flavors' document-history name forms)
56
58
  def render_creators(model)
57
59
  Elements.build(:creator, model, style: @style, i18n: @i18n,
58
- short: true).render
60
+ short: true, elements: @elements).render
59
61
  end
60
62
 
61
63
  # The short cite: the reference with the first-biblio marker
62
64
  def render_short(model, delim)
63
- kind = Kinds.kind_for(model.type)
65
+ kind = @style.kind_for(model.type)
64
66
  template = @style.type_template_for(kind)&.short ||
65
67
  @style.template_for(kind, home: home_docid?(model))
66
68
  Template.new(template).evaluate_short(
67
- Fields.new(model, style: @style, i18n: @i18n, short: true).to_h,
69
+ Fields.new(model, style: @style, i18n: @i18n, short: true,
70
+ elements: @elements).to_h,
68
71
  delim,
69
72
  )
70
73
  rescue ::Relaton::Render::Unrenderable
@@ -75,7 +78,8 @@ module Relaton
75
78
  def citation(model, disambiguator: nil)
76
79
  Template.new(@style.templates.citation).evaluate(
77
80
  Fields.new(model, style: @style, i18n: @i18n,
78
- disambiguator: disambiguator).to_h,
81
+ disambiguator: disambiguator,
82
+ elements: @elements).to_h,
79
83
  )
80
84
  end
81
85
  end
@@ -11,12 +11,14 @@ module Relaton
11
11
  attribute :name_form, NameForm, default: -> { NameForm.new }
12
12
  attribute :locale, Locale, default: -> { Locale.new }
13
13
  attribute :home_docid_type, :string, collection: true
14
+ attribute :default_kind, :string, default: ""
14
15
 
15
16
  key_value do
16
17
  map "system", to: :system
17
18
  map "nameForm", to: :name_form
18
19
  map "locale", to: :locale
19
20
  map "homeDocidType", to: :home_docid_type
21
+ map "defaultKind", to: :default_kind
20
22
  end
21
23
  end
22
24
  end
@@ -17,7 +17,9 @@ module Relaton
17
17
  attribute :uri, :string, default: ""
18
18
  attribute :creators, :string, default: ""
19
19
  attribute :uri_short, :string, default: ""
20
- attribute :date_form, :string, default: ""
20
+ # "first": cite only the first identifier (1.x
21
+ # authoritative_identifier|first styles)
22
+ attribute :identifier_mode, :string, default: "all"
21
23
 
22
24
  key_value do
23
25
  map "titleOpen", to: :title_open
@@ -29,7 +31,7 @@ module Relaton
29
31
  map "uri", to: :uri
30
32
  map "creators", to: :creators
31
33
  map "uriShort", to: :uri_short
32
- map "dateForm", to: :date_form
34
+ map "identifierMode", to: :identifier_mode
33
35
  end
34
36
  end
35
37
  end
@@ -40,6 +40,16 @@ module Relaton
40
40
  [SortRule.new(attribute: "creator"), SortRule.new(attribute: "date")]
41
41
  end
42
42
 
43
+ # The ISO 690 kind of an item: the vocabulary's mapping, or
44
+ # the style's declared kind for types the vocabulary does not
45
+ # map (a flavor whose untyped items are not reports)
46
+ def kind_for(type)
47
+ return Kinds.kind_for(type) if Kinds.mapped?(type)
48
+
49
+ declared = scheme.default_kind.to_s
50
+ declared.empty? ? Kinds.kind_for(type) : declared
51
+ end
52
+
43
53
  # Per-type template selection is a data lookup; unmatched types
44
54
  # fall back to the general reference template. When a kind has
45
55
  # home-flagged variants, the split is resolved by whether the item
@@ -17,7 +17,7 @@ module Relaton
17
17
  Field = Struct.new(:present?, :text)
18
18
 
19
19
  def initialize(source)
20
- @slots = []
20
+ @chunks = []
21
21
  @terminator = ""
22
22
  scan(source.to_s)
23
23
  end
@@ -27,41 +27,56 @@ module Relaton
27
27
  # references (eref2linkshort) split there (the 1.x
28
28
  # fmt-first-biblio-delim)
29
29
  def evaluate_short(fields, delim)
30
- rendered = @slots.filter_map do |(name, head, tail)|
31
- field = fields[normalise(name)] or next
32
- next unless field.present?
30
+ present = present_indices(fields)
31
+ return "" if present.empty?
33
32
 
34
- [head, field.text, tail]
35
- end
36
- return "" if rendered.empty?
37
-
38
- parts = rendered.each_with_index.map do |(head, text, tail), i|
39
- if i.zero?
40
- # the split marker precedes the component's trailing
41
- # punctuation (1.x ret[0] += delim, then join), with the
42
- # join's separating space
43
- "#{head}#{text}#{delim} #{tail}"
44
- elsif i == rendered.size - 1
45
- "#{head}#{text}"
46
- else
47
- "#{head}#{text}#{tail}"
48
- end
33
+ out = +""
34
+ first_seen = false
35
+ @chunks.each_with_index do |(kind, value), i|
36
+ text =
37
+ case kind
38
+ when :slot
39
+ field = fields[normalise(value)]
40
+ next unless field&.present?
41
+
42
+ first_seen ? field.text : "#{field.text}#{delim} "
43
+ else
44
+ # the split marker precedes the component's trailing
45
+ # punctuation (1.x ret[0] += delim, then join), with
46
+ # the join's separating space
47
+ next unless bridged?(i, present)
48
+
49
+ value
50
+ end
51
+ first_seen = true if @chunks[i].first == :slot
52
+ out << text unless text.nil?
49
53
  end
50
- terminate(parts.join).rstrip
54
+ terminate(out).rstrip
51
55
  end
52
56
 
57
+ # 1.x segment-join semantics: present elements joined with the
58
+ # template's separators — a literal renders only when it
59
+ # separates (a run of absent elements between) two present
60
+ # elements; leading and trailing literals drop with the
61
+ # elements they would have attached to
53
62
  def evaluate(fields)
54
- rendered = @slots.filter_map do |(name, head, tail)|
55
- field = fields[normalise(name)] or next
56
- next unless field.present?
63
+ present = present_indices(fields)
64
+ return "" if present.empty?
57
65
 
58
- [head, field.text, tail]
66
+ out = +"".dup
67
+ @chunks.each_with_index do |(kind, value), i|
68
+ if kind == :slot
69
+ field = fields[normalise(value)]
70
+ out << field.text if field&.present?
71
+ elsif bridged?(i, present) ||
72
+ (value.include?("<") && present.any? { |j| j < i })
73
+ # markup attaches to the element it closes
74
+ out << value
75
+ elsif close_pending_paren?(out, value)
76
+ out << value
77
+ end
59
78
  end
60
- return "" if rendered.empty?
61
-
62
- terminate(rendered.each_with_index.map do |(head, text, tail), i|
63
- i == rendered.size - 1 ? "#{head}#{text}" : "#{head}#{text}#{tail}"
64
- end.join).rstrip
79
+ terminate(out).rstrip
65
80
  end
66
81
 
67
82
  private
@@ -69,31 +84,74 @@ module Relaton
69
84
  def scan(source)
70
85
  rest = source
71
86
  while (slot = SLOT.match(rest))
72
- head = @slots.empty? ? rest[0...slot.begin(0)] : ""
73
- @slots << [slot[1], head, ""]
87
+ @chunks << [:text, rest[0...slot.begin(0)]] if
88
+ slot.begin(0).positive?
89
+ @chunks << [:slot, slot[1]]
74
90
  rest = rest[slot.end(0)..]
75
- following = SLOT.match(rest)
76
- boundary = following ? following.begin(0) : rest.length
77
- literal = rest[0...boundary]
78
- if following
79
- @slots.last[2] = literal
80
- else
81
- @terminator = literal
82
- end
83
- rest = rest[boundary..]
91
+ end
92
+ @terminator = rest
93
+ end
94
+
95
+ def present_indices(fields)
96
+ @chunks.each_index.select do |i|
97
+ kind, value = @chunks[i]
98
+ next false unless kind == :slot
99
+
100
+ field = fields[normalise(value)]
101
+ !field.nil? && field.present?
84
102
  end
85
103
  end
86
104
 
105
+ # A literal bridges two present elements when at least one
106
+ # present element stands on each side of it; a leading literal
107
+ # (no slot precedes it) attaches to the first slot, emitting
108
+ # only when that slot is present
109
+ def bridged?(index, present)
110
+ first_slot = @chunks.index { |(kind, _)| kind == :slot }
111
+ return present.include?(first_slot) if index < first_slot
112
+
113
+ present.any? { |i| i < index } && present.any? { |i| i > index }
114
+ end
115
+
116
+ # A template's paired literal ("({{production}})") closes even
117
+ # when nothing follows the present element it wraps
118
+ def close_pending_paren?(out, value)
119
+ value.start_with?(")") && out.count("(") > out.count(")")
120
+ end
121
+
87
122
  def terminate(body)
88
- # 1.x punctuation cleanup: an element ending in a colon
89
- # carries its own separator (the following sentence period
90
- # collapses), and spaces collapse before a comma
123
+ # 1.x punctuation cleanup: a sentence period before a spaced
124
+ # comma yields to the comma (", 2013" over ". , 2013"; an
125
+ # initials period before a comma ("P.,") stands, being
126
+ # unspaced), and stacked separators around absent elements
127
+ # collapse into one (". : ." into ". "); an empty pair left
128
+ # by an absent element drops
129
+ body = body.gsub(/:\s*\.\s*/, ": ")
130
+ body = body.gsub(/\.\s+,/, ",")
131
+ body = body.gsub(/(?:[.,:;]\s+)+[.,:;]/, ". ")
132
+ body = body.gsub(/\.\s*\./, ". ")
91
133
  body = body.gsub(/:\s*\.\s*/, ": ")
134
+ body = body.gsub(/\(\s*\)/, "")
92
135
  body = body.gsub(/ +/, " ").gsub(" ,", ",")
93
- return body if @terminator.strip.empty?
94
- return body if body.rstrip.end_with?(@terminator.strip)
136
+ terminator = unbalanced_close(body, @terminator)
137
+ return body if terminator.strip.empty?
138
+ if terminator !~ /[<\w]/ && body.rstrip.end_with?(terminator.strip)
139
+ return body
140
+ end
141
+
142
+ "#{body}#{terminator}"
143
+ end
144
+
145
+ # The terminator's closing parens stand only when the body
146
+ # opened them (their "(" died with an absent element)
147
+ def unbalanced_close(body, terminator)
148
+ return terminator unless terminator.start_with?(")")
149
+
150
+ open = body.count("(") - body.count(")")
151
+ return terminator.sub(/\A\)+/) { |closes| closes[0, open] } if
152
+ open.positive?
95
153
 
96
- "#{body}#{@terminator}"
154
+ terminator.sub(/\A\)+/, "")
97
155
  end
98
156
 
99
157
  def normalise(slot)
@@ -1,5 +1,5 @@
1
1
  module Relaton
2
2
  module Render
3
- VERSION = "3.0.0.pre.alpha.10".freeze
3
+ VERSION = "3.0.0.pre.alpha.12".freeze
4
4
  end
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: relaton-render
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.0.0.pre.alpha.10
4
+ version: 3.0.0.pre.alpha.12
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-10-05 00:00:00.000000000 Z
11
+ date: 2026-10-06 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: htmlentities
@@ -137,6 +137,7 @@ files:
137
137
  - lib/relaton/render/iso690/elements/status.rb
138
138
  - lib/relaton/render/iso690/elements/stddoc.rb
139
139
  - lib/relaton/render/iso690/elements/title.rb
140
+ - lib/relaton/render/iso690/elements/updated.rb
140
141
  - lib/relaton/render/iso690/fields.rb
141
142
  - lib/relaton/render/iso690/i18n.rb
142
143
  - lib/relaton/render/iso690/i18n/en.yml