metanorma 2.4.3 → 2.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 157d526ec5945e1eb8faae4f4cc75466fbbf59ec367695264edd10d22d10bd37
4
- data.tar.gz: 84eb4ecb451f7614da76abba559a4fe5b750425516dc78357480b68c5d50fe16
3
+ metadata.gz: cc6ddbece893102c78d8b27a94f175a85c45cfb911e8d7bc908440bd19a58e85
4
+ data.tar.gz: 226289825a52fe8da92a744a00a2239385e46f0964468be522f6b5bb7ba8066d
5
5
  SHA512:
6
- metadata.gz: 04f311680d84a53dca58be88d51f85df772f5bc75ab928393a55f37253b342ba68d0931da23142ddd45f3a3ec11bb2f778ef165878f02d790f46fca64935a022
7
- data.tar.gz: b73810634afb80f8c8dc5b4613421759479b7550262645efa42357b3f53d37ddbfc5f9d2520681ecbade585703baa840b489f975a0d9c28917a095bc93a8663c
6
+ metadata.gz: 0d9c775a338fc4b30a27aa53e217037583228a702c61977070f7e76895718b7a1d8e138b26f3053eed4ae9bdfb361a9b4545b62ad4c6e98df5f187eef8209399
7
+ data.tar.gz: f5c41df1386891d848d76576e783335697c80adb75eefda918a990c414ec112844d53e9f94717049873171f9978acaa28e7eed98e04ab11eedcbb11d87119576
data/.rubocop.yml CHANGED
@@ -3,8 +3,20 @@
3
3
  inherit_from:
4
4
  - https://raw.githubusercontent.com/riboseinc/oss-guides/main/ci/rubocop.yml
5
5
 
6
+ # Rubocop plugins enabled centrally so every metanorma-org gem picks them up
7
+ # on cimas sync — best practice belongs at the shared-template layer, not
8
+ # per-repo. Per ronaldtse feedback on metanorma/ci#332.
9
+ plugins:
10
+ - rubocop-rspec
11
+ - rubocop-performance
12
+ - rubocop-rake
13
+
6
14
  # local repo-specific modifications
7
15
  # ...
8
16
 
9
17
  AllCops:
10
- TargetRubyVersion: 3.4
18
+ # 3.3 matches the org-wide minimum being pushed via #274 (Raise minimum
19
+ # Ruby version to 3.3 due to EOL of 3.2 on 2026-03-31). Was 3.4 before
20
+ # this commit — 3.4 was above the org's stated minimum and would have
21
+ # applied Rubocop rules that fail-close on gems still targeting 3.3.
22
+ TargetRubyVersion: 3.3
@@ -0,0 +1,142 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "digest"
5
+
6
+ module Metanorma
7
+ class Collection
8
+ # Durable, content-addressed store for staged collection-build artefacts.
9
+ #
10
+ # Enables incremental (batched) builds and resumption across runs: an
11
+ # artefact is named +<docid-slug>.<content-hash>.<stage>.<format>+, so a
12
+ # document already built with identical inputs is detected by filename alone
13
+ # and reused, and an interrupted run resumes by recomputing which artefacts
14
+ # are already present.
15
+ #
16
+ # Reuse is keyed on the caller-supplied content hash of the document's
17
+ # *input closure* (source + resolved includes + templates + referenced
18
+ # schemas + tool versions + options) -- never on file presence alone. See
19
+ # metanorma/iso-10303#455 for why presence-keyed reuse of compiled XML is
20
+ # unsound; the store deliberately requires a content hash it does not infer.
21
+ #
22
+ # Disabled by default: the collection renderer uses NullArtifactStore unless
23
+ # an incremental build is explicitly requested (opt-in, never default).
24
+ class ArtifactStore
25
+ DEFAULT_DIRNAME = ".metanorma-collection-cache"
26
+
27
+ # The artefact-stage vocabulary, and the one non-redundant property each
28
+ # stage carries: the format it is serialised as (also its file extension).
29
+ # THIS IS ITS HOME: every producer and consumer names its stage from this
30
+ # table by symbol, never a bare string literal elsewhere; an unknown stage
31
+ # raises (KeyError via +fetch+), so the vocabulary cannot drift outside
32
+ # this file. The stage's *name* is the filename token, so it is not stored
33
+ # here -- that would just restate the key. The stages trace the pipeline
34
+ # (isolated compile -> index -> reinflate); each role is documented inline:
35
+ STAGE_FORMATS = {
36
+ # Compiled Semantic XML, unresolved cross-document repo:() references
37
+ # PRESERVED as deterministic stubs (neither stripped nor resolved). The
38
+ # unit of an isolated per-document compile; a pure function of the
39
+ # document's input closure, hence content-addressable and reusable.
40
+ semantic: "xml",
41
+ # This document's contribution to the global anchor index: its anchors
42
+ # and ids (type => {label/UUID => anchor-id}). Aggregated across all
43
+ # documents to build the anchor -> owning-document index used at
44
+ # reinflation.
45
+ anchors: "json",
46
+ # Reinflated Presentation XML: the preserved stubs resolved against the
47
+ # global index into real cross-document links. Shared source for the PDF
48
+ # and Word collection outputs, and per-document for HTML.
49
+ presentation: "xml",
50
+ }.freeze
51
+
52
+ attr_reader :dir
53
+
54
+ def initialize(dir)
55
+ @dir = dir
56
+ FileUtils.mkdir_p(@dir)
57
+ end
58
+
59
+ # <docid-slug>.<content-hash>.<stage>.<format>
60
+ # +stage+ must be a key of STAGE_FORMATS; an unknown stage raises KeyError.
61
+ def path(docid, content_hash, stage)
62
+ format = STAGE_FORMATS.fetch(stage)
63
+ File.join(@dir, "#{slug(docid)}.#{content_hash}.#{stage}.#{format}")
64
+ end
65
+
66
+ # Is this exact (document, input-closure, stage) artefact already present?
67
+ # This is the resumption / skip predicate: a hit is byte-identical to a
68
+ # fresh build because the hash covers every input that determines output.
69
+ def key?(docid, content_hash, stage)
70
+ File.exist?(path(docid, content_hash, stage))
71
+ end
72
+
73
+ def read(docid, content_hash, stage)
74
+ f = path(docid, content_hash, stage)
75
+ File.exist?(f) ? File.read(f, encoding: "UTF-8") : nil
76
+ end
77
+
78
+ # Idempotent write: the same (docid, content_hash, stage) always maps to
79
+ # the same path and the content is a pure function of the hash, so a re-run
80
+ # overwrites with identical bytes -- no side effects on repeat.
81
+ def write(docid, content_hash, stage, content)
82
+ f = path(docid, content_hash, stage)
83
+ File.write(f, content, encoding: "UTF-8")
84
+ f
85
+ end
86
+
87
+ # Remove every stored version of this document whose content hash is not
88
+ # +keep_hash+ -- the superseded artefacts left behind when the document's
89
+ # content changed. Keeps exactly one (current) version per document per
90
+ # stage, bounding disk to current state without losing the cache the next
91
+ # build resumes from.
92
+ def prune_superseded(docid, keep_hash)
93
+ STAGE_FORMATS.each do |stage, ext|
94
+ keep = path(docid, keep_hash, stage)
95
+ Dir[File.join(@dir, "#{slug(docid)}.*.#{stage}.#{ext}")].each do |f|
96
+ f == keep or File.delete(f)
97
+ end
98
+ end
99
+ end
100
+
101
+ # Wipe the entire store. The escape hatch when inputs the content hash
102
+ # cannot see -- imported EXPRESS schemas, templates, included files -- have
103
+ # changed, so the cache can no longer be trusted; the next build recompiles
104
+ # everything. Tracking such shared-input changes is the collection
105
+ # maintainer's responsibility, not the build's.
106
+ def clear
107
+ FileUtils.rm_rf(@dir)
108
+ FileUtils.mkdir_p(@dir)
109
+ end
110
+
111
+ # SHA256 over the input closure, truncated to 16 hex (64 bits -- ample for
112
+ # a per-collection store, and short enough to stay well inside filename
113
+ # length limits). Each input is hashed to a fixed-width digest and the
114
+ # digests concatenated before the final hash, so no two distinct closures
115
+ # collide on the pre-truncation digest. The caller assembles the closure;
116
+ # the store does not infer it.
117
+ def self.content_hash(*inputs)
118
+ digests = inputs.map { |i| Digest::SHA256.hexdigest(i.to_s) }
119
+ Digest::SHA256.hexdigest(digests.join)[0, 16]
120
+ end
121
+
122
+ private
123
+
124
+ def slug(docid)
125
+ docid.to_s.gsub(/[^A-Za-z0-9._-]+/, "-")
126
+ .gsub(/-{2,}/, "-").gsub(/\A-|-\z/, "")
127
+ end
128
+ end
129
+
130
+ # Null object: the store disabled (the default). Never reports a hit, never
131
+ # writes. Lets the renderer treat "no incremental build" as the no-store
132
+ # path without a conditional at every call site (open/closed).
133
+ class NullArtifactStore
134
+ def path(*_args) = nil
135
+ def key?(*_args) = false
136
+ def read(*_args) = nil
137
+ def write(*_args) = nil
138
+ def prune_superseded(*_args) = nil
139
+ def clear(*_args) = nil
140
+ end
141
+ end
142
+ end
@@ -11,6 +11,9 @@ module Metanorma
11
11
 
12
12
  class AdocFileNotFoundException < StandardError; end
13
13
 
14
+ # One or more collection members failed to render (#586)
15
+ class RenderFailureException < StandardError; end
16
+
14
17
  # Metanorma collection of documents
15
18
  class Collection
16
19
  attr_reader :file, :prefatory, :final
@@ -132,8 +135,24 @@ module Metanorma
132
135
  opts[:log] = @log
133
136
  opts[:flavor] = @flavor
134
137
  output_folder(opts)
135
- ::Metanorma::Collection::Renderer.render self, opts
138
+ cr = ::Metanorma::Collection::Renderer.render self, opts
136
139
  clean_exit
140
+ render_failures_abort(cr)
141
+ cr
142
+ end
143
+
144
+ # A member whose rendering fails no longer lets the collection
145
+ # report success with the document missing from the output (#586).
146
+ # Every member is rendered before aborting, so one run reports all
147
+ # failures; the raise makes callers (CLI, suma) exit non-zero. Runs
148
+ # after clean_exit so the failures are in the written log.
149
+ def render_failures_abort(renderer)
150
+ f = renderer.fatal_errors
151
+ f.empty? and return
152
+ raise RenderFailureException.new(
153
+ "Collection render failed for #{f.size} document(s):\n" +
154
+ f.join("\n"),
155
+ )
137
156
  end
138
157
 
139
158
  def output_folder(opts)
@@ -3,7 +3,6 @@ require "htmlentities"
3
3
  require "metanorma-utils"
4
4
  require "marcel"
5
5
  require_relative "filelookup_sectionsplit"
6
- require_relative "base"
7
6
  require_relative "utils"
8
7
 
9
8
  module Metanorma
@@ -57,17 +56,18 @@ module Metanorma
57
56
  entry = file_entry(manifest, k, idx) or return
58
57
  bibdata_process(entry, i)
59
58
  bibitem_process(entry)
60
- @files[key(i)] = entry
59
+ @files[entry_key(i)] = entry
61
60
  end
62
61
 
63
62
  def read_file_idents(manifest)
64
63
  id = manifest.identifier
65
- sanitised_id = key(@isodoc.docid_prefix("", manifest.identifier.dup))
64
+ sanitised_id =
65
+ entry_key(@isodoc.docid_prefix("", manifest.identifier.dup))
66
66
  # if manifest.bibdata and # NO, DO NOT FISH FOR THE GENUINE IDENTIFIER IN BIBDATA
67
67
  # d = manifest.bibdata.docidentifier.detect { |x| x.primary } ||
68
68
  # manifest.bibdata.docidentifier.first
69
69
  # k = d.id
70
- # i = key(@isodoc.docid_prefix(d.type, d.id.dup))
70
+ # i = entry_key(@isodoc.docid_prefix(d.type, d.id.dup))
71
71
  # end
72
72
  [id, sanitised_id]
73
73
  end
@@ -158,24 +158,10 @@ module Metanorma
158
158
  end
159
159
  end
160
160
 
161
- # Substitute special strings in filename patterns
162
- # @param pattern [String] filename pattern with placeholders
163
- # @param options [Hash] substitution values
164
- # @option options [Integer] :document_num document index
165
- # @option options [String] :basename filename without extension
166
- # @option options [String] :basename_legacy full filename with extension
167
- # @option options [Integer] :sectionsplit_num sectionsplit index
161
+ # Substitute special strings in filename patterns.
162
+ # See Util.substitute_filename_pattern.
168
163
  def substitute_filename_pattern(pattern, options = {})
169
- pattern or return pattern
170
- result = pattern.dup
171
- options[:document_num] and
172
- result.gsub!(/\{document-num\}/, options[:document_num].to_s)
173
- result.gsub!(/\{basename\}/, options[:basename]) if options[:basename]
174
- options[:basename_legacy] and
175
- result.gsub!(/\{basename_legacy\}/, options[:basename_legacy])
176
- options[:sectionsplit_num] and
177
- result.gsub!(/\{sectionsplit-num\}/, options[:sectionsplit_num].to_s)
178
- result
164
+ Util.substitute_filename_pattern(pattern, options)
179
165
  end
180
166
 
181
167
  # TODO make the output file location reflect source location universally,
@@ -18,7 +18,6 @@ module Metanorma
18
18
  # Save the original out_path before it gets modified
19
19
  original_out_path = @files[key][:out_path]
20
20
  s, sectionsplit_manifest = sectionsplit(key)
21
- # section_split_instance_threads(s, manifest, key)
22
21
  s.each_with_index do |f1, i|
23
22
  add_section_split_instance(f1, manifest, key, i)
24
23
  end
@@ -29,18 +28,6 @@ module Metanorma
29
28
  original_out_path
30
29
  end
31
30
 
32
- def section_split_instance_threads(s, manifest, key)
33
- @mutex = Mutex.new
34
- pool = Concurrent::FixedThreadPool.new(4)
35
- s.each_with_index do |f1, i|
36
- pool.post do
37
- add_section_split_instance(f1, manifest, key, i)
38
- end
39
- end
40
- pool.shutdown
41
- pool.wait_for_termination
42
- end
43
-
44
31
  def cleanup_section_split_instance(key, manifest, original_out_path)
45
32
  # Delete the sectionsplit index.html from source directory after it's copied to output
46
33
  @files_to_delete << manifest["#{key}:index.html"][:ref]
@@ -54,6 +41,14 @@ module Metanorma
54
41
  end
55
42
  # @files[key].delete(:ids).delete(:anchors)
56
43
  @files[key][:indirect_key] = @sectionsplit.key
44
+ # sectionsplit is HTML-only; hand the parent its whole Presentation XML
45
+ # so the collection PDF/presentation concatenation inlines the document
46
+ # intact (raw_file) instead of degrading to a bibdata-only, namespace-
47
+ # less stub. file_compile early-returns for sectionsplit, so this
48
+ # :outputs entry is what concatenate1 consumes.
49
+ # https://github.com/metanorma/iso-10303/issues/208
50
+ @files[key][:outputs] =
51
+ { presentation: @sectionsplit.whole_presentation_file }
57
52
  end
58
53
 
59
54
  def add_section_split_cover(manifest, sectionsplit_manifest, ident)
@@ -134,7 +129,7 @@ module Metanorma
134
129
  # file[:url] contains full path with directory for HTML output, but XML is basename only
135
130
  xml_basename = File.basename(file[:url])
136
131
  presfile = File.join(File.dirname(@files[key][:ref]), xml_basename)
137
- newkey = key("#{key.strip} #{file[:title]}")
132
+ newkey = entry_key("#{key.strip} #{file[:title]}")
138
133
  xml = Nokogiri::XML(File.read(presfile), &:huge)
139
134
  [presfile, newkey, xml]
140
135
  end
@@ -1,56 +1,12 @@
1
1
  module Metanorma
2
2
  class Collection
3
3
  class FileLookup
4
- # Also parse all ids in doc (including ones which won't be xref targets)
5
- def read_ids(xml)
6
- ret = {}
7
- xml.traverse do |x|
8
- x.text? and next
9
- x["id"] and ret[x["id"]] = true
10
- end
11
- ret
12
- end
13
-
14
- # map locality type and label (e.g. "clause" "1") to id = anchor for
15
- # a document
16
- # Note: will only key clauses, which have unambiguous reference label in
17
- # locality. Notes, examples etc with containers are just plunked against
18
- # UUIDs, so that their IDs can at least be registered to be tracked
19
- # as existing.
20
- def read_anchors(xml)
21
- xrefs = @isodoc.xref_init(@lang, @script, @isodoc, @isodoc.i18n,
22
- { locale: @locale })
23
- xrefs.parse xml
24
- xrefs.get.each_with_object({}) do |(k, v), ret|
25
- read_anchors1(k, v, ret)
26
- end
27
- end
28
-
29
- def read_anchors1(key, val, ret)
30
- val[:type] ||= "clause"
31
- ret[val[:type]] ||= {}
32
- index = if val[:container] || val[:label].nil? || val[:label].empty?
33
- UUIDTools::UUID.random_create.to_s
34
- else val[:label].gsub(%r{<[^<>]+>}, "")
35
- end
36
- ret[val[:type]][index] = key
37
- v = val[:value] and ret[val[:type]][v.gsub(%r{<[^<>]+>}, "")] = key
38
- end
39
-
40
4
  def anchors_lookup(anchors)
41
5
  anchors.values.each_with_object({}) do |v, m|
42
6
  v.each_value { |v1| m[v1] = true }
43
7
  end
44
8
  end
45
9
 
46
- # return citation url for file
47
- # @param doc [Boolean] I am a Metanorma document,
48
- # so my URL should end with html or pdf or whatever
49
- def url(ident, options)
50
- data = get(ident)
51
- data[:url] || targetfile(data, options)[1]
52
- end
53
-
54
10
  # are references to the file to be linked to a file in the collection,
55
11
  # or externally? Determines whether file suffix anchors are to be used
56
12
  def url?(ident)
@@ -58,7 +14,10 @@ module Metanorma
58
14
  data[:url]
59
15
  end
60
16
 
61
- def key(ident)
17
+ # Normalise an identifier to its @files hash key: decode entities, squeeze
18
+ # whitespace, and strip a leading "metanorma-collection " prefix. Distinct
19
+ # from Util::key, which does NOT strip that prefix.
20
+ def entry_key(ident)
62
21
  @c.decode(ident).gsub(/(\p{Zs})+/, " ")
63
22
  .sub(/^metanorma-collection /, "")
64
23
  end
@@ -68,13 +27,13 @@ module Metanorma
68
27
  end
69
28
 
70
29
  def get(ident, attr = nil)
71
- if attr then @files[key(ident)][attr]
72
- else @files[key(ident)]
30
+ if attr then @files[entry_key(ident)][attr]
31
+ else @files[entry_key(ident)]
73
32
  end
74
33
  end
75
34
 
76
35
  def set(ident, attr, value)
77
- @files[key(ident)][attr] = value
36
+ @files[entry_key(ident)][attr] = value
78
37
  end
79
38
 
80
39
  def each
@@ -11,6 +11,13 @@ module Metanorma
11
11
  "METANORMA_3": { category: "Cross-References",
12
12
  error: "<strong>** Unresolved reference to document %s from eref</strong>",
13
13
  severity: 2 },
14
+ "METANORMA_4": { category: "Rendering",
15
+ error: "Collection member %s: expected output " \
16
+ "file not generated: %s",
17
+ severity: 0 },
18
+ "METANORMA_5": { category: "Rendering",
19
+ error: "Collection member %s: rendering error: %s",
20
+ severity: 0 },
14
21
  }.freeze
15
22
  # rubocop:enable Naming/VariableNumber
16
23
  end
@@ -20,6 +20,12 @@ module Metanorma
20
20
  @config = manifest_postprocess(config)
21
21
  end
22
22
 
23
+ # Ordered pipeline of in-place tree-walks over the manifest; the order is
24
+ # load-bearing. expand_yaml must run early because it grafts nested
25
+ # `fileref` manifests into this tree, so every later pass sees the full
26
+ # entry set; bibdata sets @lang/@script used downstream; compile_adoc
27
+ # turns .adoc sources into .xml before filexist verifies them. Each pass
28
+ # recurses the manifest independently (see the architecture review plan).
23
29
  def manifest_postprocess(config)
24
30
  manifest_bibdata(config)
25
31
  manifest_expand_yaml(config, @dir)
@@ -26,12 +26,16 @@ module Metanorma
26
26
  end
27
27
  end
28
28
 
29
- # Move file to new output filename if specified
29
+ # Move file to new output filename if specified. A member that
30
+ # failed to render has nothing to move: keep the original name
31
+ # registered so output verification reports it as missing, rather
32
+ # than crashing the whole collection on the mv (#586).
30
33
  def handle_new_output_filename(output_fname, new_output_fname)
31
34
  return output_fname unless new_output_fname
32
35
 
33
- FileUtils.mv(File.join(@outdir, output_fname),
34
- File.join(@outdir, new_output_fname))
36
+ src = File.join(@outdir, output_fname)
37
+ File.exist?(src) or return output_fname
38
+ FileUtils.mv(src, File.join(@outdir, new_output_fname))
35
39
  new_output_fname
36
40
  end
37
41
 
@@ -14,24 +14,46 @@ module Metanorma
14
14
  # @param internal_refs [Hash{String=>Hash{String=>String}] schema name to
15
15
  # anchor to filename
16
16
  # @return [String] XML content
17
+ # Each pass below mutates the SAME Nokogiri tree in place; the inline
18
+ # comments state the post-condition each pass guarantees on exit. The
19
+ # contract between passes is "the tree is now in state X" -- there is no
20
+ # intermediate typed representation, so the ordering is load-bearing.
21
+ # `sso` truthy (sectionsplit_output) => `xml` is Presentation XML and the
22
+ # semantic-only passes are skipped; otherwise `xml` is Semantic XML.
23
+ # See the pipeline map in the collection architecture review plan.
17
24
  def update_xrefs(file, docid, internal_refs)
18
25
  xml, sso = update_xrefs_prep(file, docid)
19
- @nested || sso or
26
+ # post: repository docidentifiers supplied on bibitems naming a
27
+ # collection file; `sso` resolved (Presentation vs Semantic).
28
+ @nested || sso || @reinflate or
20
29
  Metanorma::Collection::XrefProcess::xref_process(xml, xml, nil, docid,
21
30
  @isodoc, sso)
31
+ # post (semantic only): intra-doc xref/eref turned into internal erefs;
32
+ # repo bibitems copied; indirect bibliography inserted.
22
33
  @ncnames = {}
23
34
  @nested or update_indirect_refs_to_docs(xml, docid, internal_refs, sso)
24
- @files.add_document_suffix(docid, xml)
35
+ # post: bibitem[@type='internal'] anchors resolved to the containing
36
+ # collection document.
37
+ @reinflate or @files.add_document_suffix(docid, xml)
38
+ # post: every @id/@anchor namespaced with this doc's NCName suffix, so
39
+ # concatenated documents do not collide.
25
40
  @nested or update_sectionsplit_refs_to_docs(xml, docid, internal_refs,
26
41
  sso)
42
+ # post: erefs targeting a sectionsplit document point at the specific
43
+ # split section file that holds the anchor.
27
44
  update_direct_refs_to_docs(xml, docid, sso)
45
+ # post: repo(current-metanorma-collection/x) bibitems replaced in situ
46
+ # with resolved bibdata + citation URI; their erefs become hyperlinks.
28
47
  ::Metanorma::Collection::Util::hide_refs(xml)
48
+ # post: references containers with only hidden bibitems flagged hidden.
29
49
  sso and eref2link(xml, sso)
30
- @nested or svgmap_resolve(xml, docid, sso)
50
+ @nested || @reinflate or svgmap_resolve(xml, docid, sso)
31
51
  xml.to_xml
32
52
  end
33
53
 
34
- ## sso files are Presentation XML; otherwise, Semantic XML
54
+ ## sso (sectionsplit_output) truthy => Presentation XML, else Semantic.
55
+ ## This is the single discriminator that decides which grammar `xml`
56
+ ## carries and therefore which passes in update_xrefs run.
35
57
  def update_xrefs_prep(file, docid)
36
58
  docxml = file.is_a?(String) ? Nokogiri::XML(file, &:huge) : file
37
59
  supply_repo_ids(docxml)
@@ -121,8 +143,13 @@ presxml)
121
143
  # Do not do this if this is a sectionsplit collection or a nested manifest.
122
144
  # Return false if bibitem is not to be further processed
123
145
  def strip_unresolved_repo_erefs(_document_id, bib_docid, erefs, bibitem)
124
- %r{^current-metanorma-collection/(?!Missing:)}.match?(bib_docid.text) and
146
+ if %r{^current-metanorma-collection/(?!Missing:)}.match?(bib_docid.text)
147
+ # Isolated build: keep the cross-document ref as a stub (its bibitemid
148
+ # and repository docidentifier intact) for a later reinflation pass to
149
+ # relink, rather than resolving it now against an absent target.
150
+ @preserve_unresolved and return false
125
151
  return true
152
+ end
126
153
  @nested and return false
127
154
  erefs[bibitem["id"]]&.each { |x| x.parent and strip_eref(x) }
128
155
  false