metanorma 2.4.3 → 2.5.1

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.
@@ -0,0 +1,516 @@
1
+ # Collection rendering — architecture review
2
+
3
+ Tracking issue: metanorma/metanorma#573
4
+
5
+ Internal discussion document for the maintainer (opoudjis). Analysis only; no code
6
+ changes proposed for immediate execution. The overriding constraint throughout is
7
+ **performance must not regress** — this code has had real bottlenecks beaten out of
8
+ it, and several apparently-ugly constructs are load-bearing optimisations. Every
9
+ recommendation below carries an explicit **perf-risk** tag.
10
+
11
+ Scope: `lib/metanorma/collection/` — renderer, filelookup, sectionsplit,
12
+ xrefprocess, manifest, util. File:line references are to the tree as of commit
13
+ `5c05fbd` (the attachment-link sectionsplit fix).
14
+
15
+ ---
16
+
17
+ ## 1. Pipeline map — end-to-end collection render
18
+
19
+ ### 1.1 Entry and setup
20
+
21
+ `Collection#render` (`collection.rb:129`) → `Renderer.render` (`renderer.rb:144`).
22
+ `Renderer.render` is the spine:
23
+
24
+ ```
25
+ cr = new(col, dir, options) # renderer.rb:146 — build FileLookup, isodoc, dirs
26
+ cr.files # renderer.rb:147 — compile every doc (the heavy loop)
27
+ cr.rxl(options) # renderer.rb:148 — write collection.rxl
28
+ cr.concatenate(col, options) # renderer.rb:149 — build collection.xml / .presentation.xml / pdf / doc
29
+ cr.coverpage # renderer.rb:150 — Liquid coverpage (html only)
30
+ cr.flush_files # renderer.rb:151 — delete temp + sectionsplit leftovers
31
+ ```
32
+
33
+ `Renderer#initialize` (`renderer.rb:36-85`) is where the *file table* is built:
34
+
35
+ - `@xml = Nokogiri::XML collection.to_xml` (`renderer.rb:38`) — the collection
36
+ manifest, parsed once and kept.
37
+ - `@files = FileLookup.new(folder, self)` (`renderer.rb:81`) — reads **every**
38
+ source XML, extracts bibdata/anchors/ids per file (see §1.5).
39
+ - `@files.add_section_split` (`renderer.rb:82`) — **expands sectionsplit documents
40
+ in place** before the main render loop ever runs (see §2). This is the single most
41
+ surprising piece of control flow: by the time `cr.files` runs, a sectionsplit
42
+ document has already been split into N sub-entries in `@files`, each compiled to
43
+ presentation XML on disk.
44
+
45
+ ### 1.2 The per-file loop (`fileprocess.rb:59` `#files`)
46
+
47
+ For each identifier in `@files.keys`:
48
+
49
+ - **attachment** → `copy_file_to_dest` (`filelocation.rb:149`), no compile.
50
+ - **document** → read source (Semantic XML), `update_xrefs` (`fileparse.rb:17`),
51
+ write the resolved XML to a tmp file, then `file_compile` (`fileprocess.rb:13`).
52
+
53
+ `internal_refs = locate_internal_refs` (`fileprocess.rb:61`) is computed **once**
54
+ before the loop and threaded through every `update_xrefs` call — a deliberate
55
+ hoist (see §1.5).
56
+
57
+ ### 1.3 Semantic vs Presentation XML at each stage
58
+
59
+ This is the axis that makes the code hard to read, because the same variable names
60
+ (`xml`, `file`, `docxml`) carry *different* document grammars at different points:
61
+
62
+ | Stage | Grammar | Where |
63
+ |---|---|---|
64
+ | Source files read by FileLookup | **Semantic** | `filelookup.rb:81`, `:ref` files |
65
+ | `update_xrefs` input for a normal doc | **Semantic** | `fileprocess.rb:69`, `fileparse.rb:17` |
66
+ | `update_xrefs` input for a sectionsplit-output sub-file | **Presentation** (`:sectionsplit_output` truthy) | `fileparse.rb:34-39` |
67
+ | Sectionsplit `sectionsplit_prep` output | **Presentation** | `sectionsplit.rb:98-109` (compiles to `.presentation.xml`) |
68
+ | `file_compile` → flavour `Compile#compile` | Semantic → all formats | `fileprocess.rb:19` |
69
+ | `concatenate1` collecting per-doc outputs | Presentation/whatever ext | `renderer.rb:256-267` |
70
+
71
+ The pivot is `sso = @files.get(docid, :sectionsplit_output)` (`fileparse.rb:38`).
72
+ **`sso` truthy ⇒ the file is already Presentation XML**, so `update_xrefs` skips the
73
+ semantic-only passes (`xref_process`, indirect/sectionsplit doc resolution,
74
+ svgmap) and only does direct-ref + hide_refs + eref2link. This is correct but
75
+ **entirely implicit** — the only documentation is the one-line comment at
76
+ `fileparse.rb:34`.
77
+
78
+ ### 1.4 The three reference-resolution passes (`fileparse.rb:17-32` `#update_xrefs`)
79
+
80
+ The header comment (`fileparse.rb:4-10`) is the best existing summary. In order:
81
+
82
+ 1. **`xref_process`** (`fileparse.rb:18-21`) — only when `!@nested && !sso`.
83
+ Delegates to `XrefProcess.xref_process` (`xrefprocess.rb:26`): turns intra-doc
84
+ `xref`/`eref` into internal erefs, copies repo bibitems, inserts indirect biblio.
85
+ 2. **`update_indirect_refs_to_docs`** (`fileparse.rb:23`, body `fileparse.rb:133`)
86
+ — resolves `bibitem[@type='internal']` repository refs (anchor in an unknown
87
+ collection file) to a concrete containing document.
88
+ 3. **`add_document_suffix`** (`fileparse.rb:24` → `filelookup.rb:237`) — namespaces
89
+ every `@id`/`@anchor` with a per-doc NCName suffix so concatenated docs don't
90
+ collide. Mutates the tree.
91
+ 4. **`update_sectionsplit_refs_to_docs`** (`fileparse.rb:25`, body `fileparse.rb:42`)
92
+ — rewrites erefs that target a *sectionsplit* document to point at the specific
93
+ split section file that contains the anchor.
94
+ 5. **`update_direct_refs_to_docs`** (`fileparse.rb:27`, body `fileparse.rb:88`) —
95
+ `repo(current-metanorma-collection/X)` → hyperlink + bibdata in situ; calls the
96
+ `update_anchors` **bottleneck** (§3.6).
97
+ 6. **`hide_refs`** (`fileparse.rb:28` → `util.rb:71`) — flags now-empty hidden
98
+ references containers.
99
+ 7. `eref2link` / `svgmap_resolve` post-passes.
100
+
101
+ All of 1-7 mutate the *same* Nokogiri tree in place. There is no intermediate
102
+ typed representation; the contract between passes is "the tree is now in state X",
103
+ documented only by reading the passes in order.
104
+
105
+ ### 1.5 The lookup tables (built once, read hot)
106
+
107
+ `locate_internal_refs` (`fileprocess.rb:132`) builds a `schema → anchor → filename`
108
+ map by:
109
+
110
+ - `gather_internal_refs` (`fileprocess.rb:87`) — re-parses every non-attachment,
111
+ non-sectionsplit source file (`Nokogiri::XML(file, &:huge)` at `:97`) to collect
112
+ indirect-ref targets;
113
+ - `populate_internal_refs` (`fileprocess.rb:121`) — re-parses **every file again**
114
+ (`locate_internal_refs1` → `locate_internal_refs1_prep`, `fileprocess.rb:156`) to
115
+ build an `id/anchor → element` index per file and match the wanted ids.
116
+
117
+ So the reference graph costs **two full re-parses of every source document** before
118
+ the main loop. This is a known cost centre (see §3.1) but the result is hoisted out
119
+ of the per-file loop (`fileprocess.rb:61`), so it is paid once, not O(files²).
120
+
121
+ ### 1.6 Concatenate / coverage
122
+
123
+ `concatenate` (`renderer.rb:164`) builds `collection.xml` and, if pdf/doc/bilingual
124
+ is requested, `collection.presentation.xml`, then runs mn2pdf / doc / bilingual
125
+ HTML. `concatenate1` (`renderer.rb:256`) pulls each doc's already-compiled output
126
+ back off disk via `@files.get(id, :outputs)[ext]`. Coverpage is a Liquid template
127
+ fill (`renderer.rb:277`).
128
+
129
+ ---
130
+
131
+ ## 2. Sectionsplit deep-dive
132
+
133
+ Sectionsplit is hard because it is **a collection render nested inside a collection
134
+ render**, wired together through the file table and several temp directories. Two
135
+ distinct entry paths exist.
136
+
137
+ ### 2.1 Path A — sectionsplit inside an existing collection (the FileLookup path)
138
+
139
+ Triggered from `FileLookup#add_section_split` (`filelookup_sectionsplit.rb:6`),
140
+ called at `renderer.rb:82` during renderer construction, **before** the main loop.
141
+
142
+ ```
143
+ add_section_split filelookup_sectionsplit.rb:6
144
+ for each @files entry with :sectionsplit and not :attachment
145
+ process_section_split_instance filelookup_sectionsplit.rb:17
146
+ original_out_path = @files[key][:out_path] # saved for cleanup
147
+ s, manifest = sectionsplit(key) filelookup_sectionsplit.rb:142
148
+ Sectionsplit.new(...).sectionsplit sectionsplit.rb:41
149
+ Sectionsplit#collection_manifest sectionsplit/collection.rb:34
150
+ for each split section file f1:
151
+ add_section_split_instance filelookup_sectionsplit.rb:98
152
+ -> inserts a NEW @files entry per section (presentation XML,
153
+ :sectionsplit_output=true, :parentid=key, :bare for idx>0)
154
+ add_section_split_attachments filelookup_sectionsplit.rb:89
155
+ add_section_split_cover filelookup_sectionsplit.rb:59
156
+ cleanup_section_split_instance filelookup_sectionsplit.rb:44
157
+ schedule original parent html/xml/presentation.xml for deletion
158
+ @files[key][:indirect_key] = @sectionsplit.key
159
+ ```
160
+
161
+ The parent document's `@files` entry survives but is turned into a cover/index
162
+ attachment-like entry (`add_section_split_cover`, `filelookup_sectionsplit.rb:59`
163
+ sets `:out_path = cover`), and N new entries — one per section — are inserted.
164
+ **The `@files` hash is mutated and re-built** (`add_section_split` rebuilds it into
165
+ `ret` at `:7-14`).
166
+
167
+ ### 2.2 The split itself (`sectionsplit.rb`)
168
+
169
+ `Sectionsplit#sectionsplit` (`sectionsplit.rb:41`):
170
+
171
+ 1. `sectionsplit_prep` (`:98`) — reads the **Semantic** source, runs
172
+ `sectionsplit_update_xrefs` (`:121`, which re-enters the *parent's*
173
+ `update_xrefs` with `@nested=true` so unresolved erefs survive), writes a temp
174
+ semantic file, then **compiles it to Presentation XML** via a fresh
175
+ `Compile.new.compile` (`:104`) and reloads the `.presentation.xml`. So from here
176
+ on `xml` is Presentation XML.
177
+ 2. `xref_preprocess` (`:43` → `xrefprocess.rb:9`) — stamps a random 8-char `key` on
178
+ the root and suffixes all anchor attrs. The `key` becomes the document's
179
+ `:indirect_key` and ties the split files back together.
180
+ 3. `empty_doc` (`:140`) — clones the doc and strips all section content to make the
181
+ **template** every section file is built from. `empty_attachments` (`:151`) is
182
+ just `xml.dup` (a second template used for idx>0; the only difference is the
183
+ first file keeps the section-free `empty`).
184
+ 4. `sectionsplit1` (`:53`) walks `SPLITSECTIONS` (`:34-38`: preface, sections,
185
+ annex, bibliography, indexsect, colophon), conflates floating titles
186
+ (`conflate_floatingtitles`, `:88`), and for each chunk posts a job to a
187
+ **thread pool of size 1** (`:48`) that calls `sectionfile` (`:155`).
188
+ 5. `sectionfile` → `create_sectionfile` (`:161`): inserts the chunk into a clone of
189
+ the template, filters footnotes/annotations down to those referenced
190
+ (`sectionfile_fn_filter` `:185`, `sectionfile_annotation_filter` `:237`), runs
191
+ `XrefProcess.xref_process(out, xml, @key, ...)` to resolve cross-refs within the
192
+ section, and writes the section file.
193
+
194
+ ### 2.3 The flat-XML-vs-directory-HTML output split
195
+
196
+ This is the subtle invariant that the recent bug (`5c05fbd`) lives next to:
197
+
198
+ - **XML section files are always written flat** to `@splitdir` (the `_files`
199
+ directory), basename only — `create_sectionfile` (`sectionsplit.rb:166-174`) is
200
+ explicit about this in comments.
201
+ - **HTML output may carry a directory** from `sectionsplit_filename` (e.g.
202
+ `split/{basename}.html`). The directory is reattached only at HTML compile time
203
+ via `preserve_directory_structure?` (`filelookup.rb:359`) and the
204
+ `file_compile_format` machinery (`filelocation.rb:65-79`).
205
+
206
+ The manifest YAML (`collectionyaml`, `sectionsplit/collection.rb:41-73`) encodes
207
+ this: `fileref` is always the basename (`:60`), but
208
+ `sectionsplit-filename`/`sectionsplit-output` are emitted only when there is a
209
+ directory (`:64-67`) so the *inner* renderer knows to re-expand it.
210
+
211
+ ### 2.4 Path B — single-file sectionsplit building its own collection
212
+
213
+ `Sectionsplit#build_collection` (`sectionsplit/collection.rb:4`): used when a single
214
+ document is sectionsplit on its own. It runs `sectionsplit`, writes a generated
215
+ `*.html.yaml` manifest (`collectionyaml`), and calls `Metanorma::Collection.parse`
216
+ + `.render` recursively (`:9-12`) — a **fully nested collection render** — then moves
217
+ attachments (`section_split_attachments`, `:79`).
218
+
219
+ ### 2.5 Attachment handling and the early/late asymmetry (the recent bug)
220
+
221
+ `section_split_attachments` (`sectionsplit/collection.rb:79`) moves the
222
+ `_<basename>_attachments` directory from the temp split location to the collection
223
+ output. The attachment *links* inside section files are resolved during
224
+ `update_bibitem` (`fileparse.rb:170`).
225
+
226
+ The bug fixed in `5c05fbd`: attachment URLs were relativised against the
227
+ referencing document's **unsplit `:out_path`**, but a sectionsplit document's
228
+ content is actually emitted at the split output location, so `../../` overshot. The
229
+ fix introduced `referencing_html_location` (`fileparse.rb:208`) + helpers
230
+ `sectionsplit_ref_html` / `document_ref_html` (`:216`, `:223`) to choose the right
231
+ base. The regression spec (`spec/collection/attachment_link_path_spec.rb`) pins all
232
+ four cases. This fix is correct, but it is a *symptom* of pain point §3.2.
233
+
234
+ ---
235
+
236
+ ## 3. Maintainability pain points (evidence-based)
237
+
238
+ ### 3.1 The reference graph is built from repeated full re-parses
239
+ `gather_internal_refs1` (`fileprocess.rb:97`), `locate_internal_refs1_prep`
240
+ (`fileprocess.rb:156`), and FileLookup's own `bibdata_process` (`filelookup.rb:81`)
241
+ each `Nokogiri::XML(file, &:huge)` the *same* source files. A given source document
242
+ is parsed at least 3× before its content is even compiled. **This is load-bearing
243
+ optimisation territory, not just waste** — see perf note in §4.
244
+
245
+ ### 3.2 Early-vs-late reference resolution asymmetry
246
+ Some links are resolved *before* the split, against the unsplit `out_path`
247
+ (`update_bibitem` path); others are resolved *during* the inner render of the split
248
+ files. The two views of "where does this document's content live" diverge under
249
+ sectionsplit, which is exactly what produced the `5c05fbd` bug. The new
250
+ `referencing_html_location` (`fileparse.rb:208`) papers over one instance, but the
251
+ underlying asymmetry — *out_path is not where the content ends up for a sectionsplit
252
+ doc* — is undocumented as an invariant and will bite again (e.g. svgmap, images,
253
+ PDF cross-links).
254
+
255
+ ### 3.3 Path computation is scattered across ≥4 files
256
+ Relative/output path logic lives in: `filelocation.rb`
257
+ (`preserve_output_dir_structure`, `move_file_to_subdirectory`,
258
+ `apply_custom_filename_pattern`, `:39-147`), `fileparse.rb`
259
+ (`referencing_html_location`, `sectionsplit_ref_html`, `document_ref_html`,
260
+ `out_path_to_html`, `:208-232`), `utils.rb` (`make_relative_path`, `:91`),
261
+ `filelookup.rb` (`output_file_path`, `file_entry_paths`,
262
+ `substitute_filename_pattern`, `ref_file_xml2html`, `:145-312`), and
263
+ `filelookup_sectionsplit.rb` (`add_section_split_instance` path assembly,
264
+ `:98-130`). Each does its own `File.dirname`/`File.basename`/`relative_path_from`
265
+ dance with slightly different rules (when to strip `.xml`, when to keep dirs, when
266
+ to disambiguate). The placeholder substitution (`{basename}`, `{basename_legacy}`,
267
+ `{document-num}`, `{sectionsplit-num}`) is implemented **twice** — once in
268
+ `FileLookup#substitute_filename_pattern` (`filelookup.rb:168`) and once inline in
269
+ `Sectionsplit#sectionsplit2` (`sectionsplit.rb:69-73`).
270
+
271
+ ### 3.4 The `@files` entry is an untyped grab-bag
272
+ A FileLookup entry is a bare `Hash` carrying ~26 distinct keys (counted across the
273
+ codebase): `:format :outputs :out_path :url :type :attachment :ref :bibdata
274
+ :bibitem :sectionsplit :sectionsplit_output :sectionsplit_filename :rel_path
275
+ :indirect_key :index :parentid :idx :ids :document_suffix :presentationxml :pdffile
276
+ :bare :anchors :anchors_lookup :output_filename :extract_opts`. Access is via
277
+ `@files.get(id, :key)` (`base.rb:20`) with no schema, no validation, and several
278
+ near-synonyms whose distinction is only learnable by reading every writer:
279
+ - `:ref` (absolute source) vs `:rel_path` (relative to YAML) vs `:out_path`
280
+ (destination) vs `:url` vs `:outputs[:html]` (post-compile actual path) — five
281
+ notions of "where is this file", documented only in a comment block at
282
+ `filelookup.rb:102-107` and `:128-134`.
283
+ - `:idx` vs `:index`; `:sectionsplit` vs `:sectionsplit_output`;
284
+ `:sectionsplit_filename` vs `:output_filename`.
285
+ - `:type` holds the string `"fileref"`/`"id"` (`filelookup.rb:116,122`) — unrelated
286
+ to document flavour `type`.
287
+
288
+ There is no single place that says "these are the fields and what they mean."
289
+
290
+ ### 3.5 Duplicated / divergent method definitions in FileLookup
291
+ `filelookup/utils.rb` and `filelookup/base.rb` and `filelookup/filelookup.rb`
292
+ **redefine the same methods**. `filelookup.rb` requires both `base` (`:6`) and
293
+ `utils` (`:7`), with `utils` last. Duplicated: `read_ids`, `read_anchors`,
294
+ `read_anchors1`, `anchors_lookup`, `url`, `url?`, `key`, `keys`, `get`, `set`,
295
+ `each`, `each_with_index`, `ns`. Because `utils.rb` is required last, **its
296
+ definitions win** — and `read_anchors1` in `utils.rb` (`utils.rb:29-38`) uses the
297
+ regex `%r{<[^<>]+>}` whereas the copy in `filelookup.rb` (`:335-344`) uses
298
+ `%r{<[^>]+>}`. Whichever is actually live is decided by require order, not by
299
+ intent. This is a latent correctness hazard, not merely cosmetic. (`utils.rb` looks
300
+ like an extraction-in-progress that was never completed or wired exclusively.)
301
+
302
+ ### 3.6 The `update_anchors` bottleneck (`fileparse.rb:195`, marked `# bottleneck`)
303
+ For each repository bibitem, `update_anchors` iterates `erefs_no_anchor` and
304
+ `erefs_anchors` and calls `update_anchors1` (`:208`) which consults
305
+ `@files.get(docid).dig(:anchors_lookup, ...)`. The `:anchors_lookup` table
306
+ (`anchors_lookup`, `utils.rb:40` / `filelookup.rb` via `bibdata_extract`) is a
307
+ flattened `{anchor => true}` set precomputed per file — this is the optimisation
308
+ that keeps the lookup O(1) instead of re-xpathing the target doc per eref. **Do not
309
+ remove `:anchors_lookup`** (see §5).
310
+
311
+ ### 3.7 `@nested` flag overloading
312
+ `@nested` (`renderer.rb:70`) means "this Renderer will run again, don't do
313
+ finalising ref work." It gates five different behaviours in `update_xrefs`
314
+ (`fileparse.rb:19,23,25,30`) and the strip-unresolved logic
315
+ (`fileparse.rb:126`). Sectionsplit toggles it on the *parent* renderer temporarily
316
+ (`sectionsplit.rb:122-126`: save, set true, call, restore). One boolean encoding
317
+ "am I the root render?" + "should I preserve unresolved erefs?" + "skip svgmap" is
318
+ hard to reason about; the save/restore-around-call idiom is a smell that it is
319
+ really a per-call parameter, not object state.
320
+
321
+ ### 3.8 Nested-manifest in-place expansion
322
+ `Manifest#manifest_expand_yaml` (`manifest.rb:118`) mutates entries in place,
323
+ setting `entry.entry = ...from_yaml(...)` and rewriting filepaths via
324
+ `update_filepaths` (`:141`). Combined with the several `manifest_*` passes that each
325
+ recurse the tree independently (`manifest_postprocess`, `:23-32` runs 7 separate
326
+ recursive walks), the manifest is normalised by a pipeline of mutating tree-walks
327
+ with order dependencies that are not stated.
328
+
329
+ ### 3.9 Identifier/key normalisation duplicated
330
+ `key` is defined in `Util::key` (`util.rb:79`), `FileLookup#key` (`base.rb:11`,
331
+ `utils.rb:61`), and the decode+squeeze pattern recurs in `docid_prefix`
332
+ (`utils.rb` renderer, `:105`). `FileLookup#key` additionally strips a
333
+ `metanorma-collection ` prefix (`base.rb:13`) that `Util::key` does not — so they
334
+ are *not* interchangeable, yet both are called "key". Mixing them is a footgun.
335
+
336
+ ### 3.10 Mutation-in-place through many passes
337
+ Every resolution pass mutates the shared Nokogiri tree (§1.4). There is no
338
+ checkpoint, no copy, no assertion of post-conditions. Debugging "which pass put the
339
+ tree in this state" means instrumenting each pass. This is partly inherent to
340
+ Nokogiri performance (copying trees is expensive), but the *absence of documented
341
+ invariants between passes* is the maintainability cost, not the mutation itself.
342
+
343
+ ### 3.11 Dead / commented-out code and `warn` debug noise
344
+ Numerous `# KILL`, commented thread-pool variants, and `warn` timing/debug lines
345
+ remain: `renderer.rb:93-106` (two methods named `directives_normalise_coverpage_pdf_portfolio`,
346
+ the **second silently shadows the first**), `renderer.rb:132,145,165,179,200` warn
347
+ spam, `filelookup.rb:212` `warn ret`, `filelookup_sectionsplit.rb:32-42`
348
+ (`section_split_instance_threads` defined but unused), `fileprocess.rb:48-55`
349
+ commented `allowed_extension_keys`. The duplicate `directives_normalise_coverpage_pdf_portfolio`
350
+ (`renderer.rb:94` and `:108`) is an actual bug-shaped artifact: Ruby keeps the
351
+ second definition, so the first (more elaborate) one is dead.
352
+
353
+ ---
354
+
355
+ ## 4. Prioritised recommendations
356
+
357
+ Each carries a **perf-risk** tag: `NEUTRAL` (no runtime change), `COULD-HELP`,
358
+ `RISK` (could reintroduce a bottleneck — flagged loudly), `BENIGN-CLEANUP`.
359
+
360
+ ### HIGH
361
+
362
+ **H1. Document the file-grammar and out_path invariants as code comments + a
363
+ one-page note.** State explicitly: (a) when `xml`/`file` is Semantic vs Presentation
364
+ and that `:sectionsplit_output` is the discriminator; (b) the load-bearing invariant
365
+ "for a sectionsplit document, content is emitted at the sectionsplit output
366
+ location, NOT at `:out_path`" (the §3.2 root cause). Put it at the top of
367
+ `fileparse.rb` and `sectionsplit.rb`.
368
+ *Gain:* directly prevents recurrences of the `5c05fbd` class of bug.
369
+ **perf-risk: NEUTRAL** (comments only).
370
+
371
+ **H2. Resolve the FileLookup method duplication (§3.5).** Pick one home for
372
+ `read_ids`/`read_anchors`/`read_anchors1`/`anchors_lookup`/`url`/`key`/etc., delete
373
+ the others, and reconcile the divergent `read_anchors1` regex (`<[^<>]+>` vs
374
+ `<[^>]+>`) deliberately. Right now correctness depends on require order.
375
+ *Gain:* removes a latent correctness hazard and ~80 lines of confusing duplication.
376
+ **perf-risk: NEUTRAL** (same code runs; you are just deleting the shadowed copy).
377
+ *Caveat:* verify which regex is currently live before deleting, so behaviour is
378
+ preserved exactly unless you intend to change it.
379
+
380
+ **H3. Introduce a typed `FileEntry` value object wrapping the `@files` hash
381
+ (§3.4).** Keep the hash as the backing store for perf, but give it named readers and
382
+ a documented field list, plus predicate methods (`#sectionsplit?`,
383
+ `#sectionsplit_output?`, `#attachment?`). Do this incrementally behind the existing
384
+ `get/set` API so call sites can migrate gradually.
385
+ *Gain:* the single biggest readability win; makes the 26-key grab-bag legible.
386
+ **perf-risk: COULD-HELP if done as a thin wrapper, RISK if naively done.** A
387
+ `Struct`/`Data` rebuilt per access, or replacing the hash with per-call object
388
+ allocation in the hot `update_anchors`/`gather_*` loops, **would** add allocation
389
+ pressure. Mark clearly: the wrapper must be allocated **once per entry**, not per
390
+ access, and the hot paths (`fileparse.rb:196-237`, `util.rb:17-45`) should keep
391
+ using direct hash reads or memoised readers. Stage it: wrapper first for the
392
+ cold-path call sites (manifest, setup), leave the bottleneck loops on raw hash
393
+ access until measured.
394
+
395
+ ### MEDIUM
396
+
397
+ **M1. Consolidate path computation into one module (§3.3).** Gather
398
+ `make_relative_path`, `out_path_to_html`, `ref_file_xml2html`, `output_file_path`,
399
+ `preserve_output_dir_structure`, `substitute_filename_pattern`, and the inline
400
+ substitution in `sectionsplit.rb:69-73` into a single `PathResolver` with
401
+ well-named, individually-tested methods. De-duplicate the two placeholder
402
+ substituters first (they should be one function).
403
+ *Gain:* the next path bug (and §3.2 predicts more) gets fixed in one place with a
404
+ test, instead of hunting four files.
405
+ **perf-risk: NEUTRAL.** Pure string/Pathname math, not in an XML-parsing hot loop;
406
+ moving it does not change call frequency. Keep the functions pure (no I/O) so they
407
+ stay cheap.
408
+
409
+ **M2. Turn `@nested` into an explicit parameter or a small mode object (§3.7).**
410
+ The save/set-true/call/restore dance in `sectionsplit_update_xrefs`
411
+ (`sectionsplit.rb:122-126`) is the tell. Pass a `render_mode:` (`:root` /
412
+ `:nested`) argument through `update_xrefs`, or split `update_xrefs` into
413
+ `update_xrefs_root` / `update_xrefs_nested` that share helpers.
414
+ *Gain:* removes hidden global-ish state; makes the sectionsplit re-entry legible.
415
+ **perf-risk: NEUTRAL** (same branches, just parameterised).
416
+
417
+ **M3. Name the sectionsplit control flow seams (§2).** Extract well-named methods
418
+ for the three conceptual phases that are currently interleaved in
419
+ `add_section_split` / `process_section_split_instance`: *split-into-section-files*,
420
+ *register-section-entries-in-@files*, *register-cover-and-attachments*. The logic
421
+ need not move; just give the phases names and a comment each.
422
+ *Gain:* the hardest-to-follow file becomes a readable three-step.
423
+ **perf-risk: NEUTRAL** (method extraction only).
424
+
425
+ **M4. Delete dead code and shadowed definitions (§3.11).** Remove the shadowed
426
+ `directives_normalise_coverpage_pdf_portfolio` (`renderer.rb:94` — confirm which is
427
+ intended), unused `section_split_instance_threads`, commented `allowed_extension_keys`,
428
+ `# KILL` blocks. Gate the `warn` timing lines behind a debug flag rather than
429
+ unconditional stderr spam.
430
+ *Gain:* less noise, removes a real shadowing bug.
431
+ **perf-risk: BENIGN-CLEANUP** (removing `warn` calls is a micro-improvement; the
432
+ shadowed-method removal must preserve whichever definition is actually wanted).
433
+
434
+ ### LOW
435
+
436
+ **L1. Unify identifier normalisation (§3.9).** Document the difference between
437
+ `Util::key` and `FileLookup#key` (the `metanorma-collection ` strip) and rename one
438
+ so they are not both `key`. Consider a single `Identifier` helper.
439
+ **perf-risk: NEUTRAL.**
440
+
441
+ **L2. Document the manifest normalisation pipeline order (§3.8).** Add a comment to
442
+ `manifest_postprocess` (`manifest.rb:23`) stating why the 7 passes run in that order
443
+ and which depend on which.
444
+ **perf-risk: NEUTRAL.**
445
+
446
+ **L3. Add post-condition comments (not assertions) to each `update_xrefs` pass
447
+ (§3.10)** describing the tree state each pass guarantees on exit.
448
+ **perf-risk: NEUTRAL.**
449
+
450
+ ---
451
+
452
+ ## 5. Do NOT do this — refactors that would reintroduce bottlenecks
453
+
454
+ - **Do NOT replace the precomputed `:anchors` / `:anchors_lookup` / `:ids` tables
455
+ with on-demand xpath.** These are built once in `bibdata_extract`
456
+ (`filelookup.rb:87-93`) / `read_anchors` and consumed in the `update_anchors`
457
+ **bottleneck** (`fileparse.rb:196-213`) and `update_anchor_create_loc`
458
+ (`fileparse.rb:229-237`). Re-xpathing the target document per eref would turn an
459
+ O(1) lookup into O(anchors × erefs) re-traversal. The flattened `:anchors_lookup`
460
+ set exists specifically to avoid that.
461
+
462
+ - **Do NOT remove the hoist of `locate_internal_refs` out of the per-file loop**
463
+ (`fileprocess.rb:61`). Computing the internal-ref graph once and threading it in
464
+ is what keeps the loop O(files) and not O(files²). Folding it back inside
465
+ `update_xrefs` would re-derive the whole graph per file.
466
+
467
+ - **Do NOT "tidy up" by re-parsing XML where a parsed tree is already in hand, or
468
+ by parsing more than necessary.** The code already pays for ~3 re-parses per
469
+ source file (§3.1); that is the *floor* the maintainer has tuned to, not an
470
+ invitation to add more. Conversely, if consolidating §3.1 ever looks tempting,
471
+ treat caching parsed trees as a **measured** change — naively memoising every
472
+ parsed Nokogiri document for the whole render would blow memory on large
473
+ collections (these docs are huge; note the `&:huge` parse flag everywhere). Any
474
+ parse-caching must be scoped and benchmarked. **perf-risk on this whole area:
475
+ HIGH either direction.**
476
+
477
+ - **Do NOT bump the sectionsplit thread pool back to 4 without measuring**
478
+ (`sectionsplit.rb:48` is deliberately `FixedThreadPool.new(1)`; the size-4 variant
479
+ and `section_split_instance_threads` are commented out at
480
+ `filelookup_sectionsplit.rb:32`). Nokogiri tree mutation across threads is not
481
+ obviously safe here and the pools were dialled to 1 for a reason — likely
482
+ correctness or contention. Treat re-parallelising as a separate, carefully-tested
483
+ investigation, not a cleanup.
484
+
485
+ - **Do NOT convert the `@files` hash to per-access object allocation** (see H3
486
+ caveat). The wrapper, if introduced, must be one allocation per entry, and the
487
+ bottleneck loops should stay on raw reads until profiled.
488
+
489
+ - **Do NOT deep-clone Nokogiri trees between passes to "isolate" them.** The
490
+ in-place mutation (§3.10) is ugly but cheap; cloning the tree per pass to get
491
+ immutability would multiply parse/serialise cost across every document.
492
+
493
+ ---
494
+
495
+ ## Appendix — key file:line index
496
+
497
+ | Concern | Location |
498
+ |---|---|
499
+ | Render spine | `renderer.rb:144-153` |
500
+ | File table built + sectionsplit expanded | `renderer.rb:81-82` |
501
+ | Per-file compile loop | `fileprocess.rb:59-84` |
502
+ | The three ref passes | `fileparse.rb:17-32` |
503
+ | Semantic/Presentation pivot (`sso`) | `fileparse.rb:34-39` |
504
+ | Internal-ref graph (double re-parse) | `fileprocess.rb:87-168` |
505
+ | `update_anchors` bottleneck | `fileparse.rb:195-213` |
506
+ | Attachment-link fix (`referencing_html_location`) | `fileparse.rb:208-232` |
507
+ | Sectionsplit core | `sectionsplit.rb:41-175` |
508
+ | Flat-XML / dir-HTML invariant | `sectionsplit.rb:166-174`, `filelookup.rb:359-369` |
509
+ | Sectionsplit @files expansion | `filelookup_sectionsplit.rb:6-130` |
510
+ | `@files` entry key comments | `filelookup.rb:102-107,128-134` |
511
+ | Duplicated FileLookup methods | `filelookup/utils.rb` vs `base.rb` vs `filelookup.rb` |
512
+ | Path math scattered | `filelocation.rb`, `utils.rb:91`, `filelookup.rb:145-312`, `fileparse.rb:208-232` |
513
+ | Manifest normalisation pipeline | `manifest.rb:23-32` |
514
+ | Shadowed method (dead) | `renderer.rb:94` vs `:108` |
515
+
516
+ 🤖
metadata CHANGED
@@ -1,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: metanorma
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.4.3
4
+ version: 2.5.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
8
- autorequire:
9
8
  bindir: bin
10
9
  cert_chain: []
11
- date: 2026-06-22 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: asciidoctor
@@ -212,9 +211,9 @@ email:
212
211
  executables: []
213
212
  extensions: []
214
213
  extra_rdoc_files:
215
- - README.adoc
216
214
  - CHANGELOG.adoc
217
215
  - LICENSE.txt
216
+ - README.adoc
218
217
  files:
219
218
  - ".envrc"
220
219
  - ".gitignore"
@@ -228,6 +227,7 @@ files:
228
227
  - README.adoc
229
228
  - lib/metanorma.rb
230
229
  - lib/metanorma/array_monkeypatch.rb
230
+ - lib/metanorma/collection/artifact_store.rb
231
231
  - lib/metanorma/collection/collection.rb
232
232
  - lib/metanorma/collection/config/bibdata.rb
233
233
  - lib/metanorma/collection/config/compile_options.rb
@@ -238,7 +238,6 @@ files:
238
238
  - lib/metanorma/collection/config/manifest.rb
239
239
  - lib/metanorma/collection/config/namespaces.rb
240
240
  - lib/metanorma/collection/document/document.rb
241
- - lib/metanorma/collection/filelookup/base.rb
242
241
  - lib/metanorma/collection/filelookup/filelookup.rb
243
242
  - lib/metanorma/collection/filelookup/filelookup_sectionsplit.rb
244
243
  - lib/metanorma/collection/filelookup/utils.rb
@@ -274,11 +273,11 @@ files:
274
273
  - lib/metanorma/util/worker_pool.rb
275
274
  - lib/metanorma/version.rb
276
275
  - metanorma.gemspec
276
+ - plans/collection-rendering-architecture-review.md
277
277
  homepage: https://github.com/metanorma/metanorma
278
278
  licenses:
279
279
  - BSD-2-Clause
280
280
  metadata: {}
281
- post_install_message:
282
281
  rdoc_options: []
283
282
  require_paths:
284
283
  - lib
@@ -286,15 +285,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
286
285
  requirements:
287
286
  - - ">="
288
287
  - !ruby/object:Gem::Version
289
- version: 3.1.0
288
+ version: 3.3.0
290
289
  required_rubygems_version: !ruby/object:Gem::Requirement
291
290
  requirements:
292
291
  - - ">="
293
292
  - !ruby/object:Gem::Version
294
293
  version: '0'
295
294
  requirements: []
296
- rubygems_version: 3.5.22
297
- signing_key:
295
+ rubygems_version: 4.0.10
298
296
  specification_version: 4
299
297
  summary: Metanorma is the standard of standards; the metanorma gem allows you to create
300
298
  any standard document type supported by Metanorma.
@@ -1,43 +0,0 @@
1
- module Metanorma
2
- class Collection
3
- class FileLookup
4
- # are references to the file to be linked to a file in the collection,
5
- # or externally? Determines whether file suffix anchors are to be used
6
- def url?(ident)
7
- data = get(ident) or return false
8
- data[:url]
9
- end
10
-
11
- def key(ident)
12
- @c.decode(ident).gsub(/(\p{Zs})+/, " ")
13
- .sub(/^metanorma-collection /, "")
14
- end
15
-
16
- def keys
17
- @files.keys
18
- end
19
-
20
- def get(ident, attr = nil)
21
- if attr then @files[key(ident)][attr]
22
- else @files[key(ident)]
23
- end
24
- end
25
-
26
- def set(ident, attr, value)
27
- @files[key(ident)][attr] = value
28
- end
29
-
30
- def each
31
- @files.each
32
- end
33
-
34
- def each_with_index
35
- @files.each_with_index
36
- end
37
-
38
- def ns(xpath)
39
- @isodoc.ns(xpath)
40
- end
41
- end
42
- end
43
- end