relaton-cli 3.0.7 → 3.1.0

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 (42) hide show
  1. checksums.yaml +4 -4
  2. data/lib/relaton-cli.rb +4 -16
  3. metadata +11 -123
  4. data/.gitignore +0 -22
  5. data/.hound.yml +0 -3
  6. data/.rspec +0 -1
  7. data/CLAUDE.md +0 -524
  8. data/Gemfile +0 -42
  9. data/Rakefile +0 -21
  10. data/bin/console +0 -11
  11. data/bin/rspec +0 -29
  12. data/bin/setup +0 -8
  13. data/docs/README.adoc +0 -589
  14. data/exe/relaton +0 -20
  15. data/frontend/dist/404.html +0 -18
  16. data/frontend/dist/app.iife.js +0 -1
  17. data/frontend/dist/style.css +0 -3
  18. data/i18n.yaml +0 -1
  19. data/lib/relaton/bibcollection.rb +0 -146
  20. data/lib/relaton/bibdata.rb +0 -92
  21. data/lib/relaton/cli/base_convertor.rb +0 -134
  22. data/lib/relaton/cli/command.rb +0 -390
  23. data/lib/relaton/cli/data_fetcher.rb +0 -20
  24. data/lib/relaton/cli/frontend_assets.rb +0 -58
  25. data/lib/relaton/cli/full_text_search.rb +0 -98
  26. data/lib/relaton/cli/index_item_normalizer.rb +0 -380
  27. data/lib/relaton/cli/index_site_generator.rb +0 -1008
  28. data/lib/relaton/cli/relaton_file.rb +0 -290
  29. data/lib/relaton/cli/subcommand_collection.rb +0 -263
  30. data/lib/relaton/cli/subcommand_db.rb +0 -81
  31. data/lib/relaton/cli/util.rb +0 -8
  32. data/lib/relaton/cli/version.rb +0 -11
  33. data/lib/relaton/cli/xml_convertor.rb +0 -47
  34. data/lib/relaton/cli/xml_to_html_renderer.rb +0 -117
  35. data/lib/relaton/cli/yaml_convertor.rb +0 -102
  36. data/lib/relaton/cli.rb +0 -113
  37. data/lib/relaton/element_finder.rb +0 -29
  38. data/relaton-cli.gemspec +0 -49
  39. data/templates/_document.liquid +0 -111
  40. data/templates/_index.liquid +0 -61
  41. data/templates/index/page.liquid +0 -20
  42. data/templates/index-style.css +0 -1017
data/CLAUDE.md DELETED
@@ -1,524 +0,0 @@
1
- # CLAUDE.md
2
-
3
- This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4
-
5
- ## Project Overview
6
-
7
- relaton-cli is a Ruby CLI tool for managing bibliographic references to standards (ISO, IEC, IETF, NIST, etc.). It provides commands to fetch, convert, and organize standards metadata in XML, YAML, BibTeX, and HTML formats. Part of the broader Relaton/Metanorma ecosystem.
8
-
9
- ## Common Commands
10
-
11
- ```bash
12
- # Install dependencies
13
- bundle install
14
-
15
- # Run all tests
16
- bundle exec rspec
17
-
18
- # Run a single test file
19
- bundle exec rspec spec/relaton/cli/command_spec.rb
20
-
21
- # Run a specific test by line number
22
- bundle exec rspec spec/relaton/cli/command_spec.rb:42
23
-
24
- # Build the gem
25
- bundle exec rake build
26
-
27
- # Lint (RuboCop, inherits from Ribose OSS guide)
28
- bundle exec rubocop
29
- bundle exec rubocop -a # auto-fix
30
- ```
31
-
32
- ## Architecture
33
-
34
- ### Entry Point & CLI Framework
35
-
36
- The executable `exe/relaton` calls `Relaton::Cli.start(ARGV)` which routes to `Relaton::Cli::Command`, a Thor-based command class. Thor handles argument parsing, option definitions, and subcommand routing.
37
-
38
- ### Command Structure
39
-
40
- - `lib/relaton/cli/command.rb` — Main Thor command class with top-level commands (fetch, extract, concatenate, split, yaml2xml, xml2yaml, xml2html, yaml2html, convert, fetch-data)
41
- - `lib/relaton/cli/subcommand_collection.rb` — `relaton collection` subcommands (create, info, list, get, find, fetch, import, export)
42
- - `lib/relaton/cli/subcommand_db.rb` — `relaton db` subcommands (create, mv, clear, fetch, fetch_all, doctype)
43
-
44
- ### Option Forwarding Pattern
45
-
46
- `Command#fetch` and `SubcommandDb#fetch` use the shared `fetch_document` helper (in `Relaton::Cli` private methods at the bottom of `command.rb`). This helper transforms Thor's kebab-case option keys to snake_case symbols via `gsub("-", "_").to_sym` and splats them as `**dup_opts` to `Relaton.db.fetch` / `Relaton.db.fetch_std`. Adding a new Thor option to these commands automatically forwards it to the underlying library with no method changes needed.
47
-
48
- `SubcommandCollection#fetch` calls `Relaton.db.fetch` directly (not through `fetch_document`), so new options must be explicitly forwarded there.
49
-
50
- Current fetch options that use this pattern: `--no-cache`, `--all-parts`, `--keep-year`, `--publication-date-before`, `--publication-date-after`.
51
-
52
- ### Malformed-identifier handling (CLI is the friendly layer)
53
-
54
- The `relaton` library **raises** `Pubid::Errors::ParseError` when a reference
55
- can't be parsed — it does not swallow it, so API consumers of `Relaton::Db`
56
- handle it themselves. Every flavor raises that class, including ITU, whose own
57
- Parslet grammar (`Relaton::Itu::Pubid`) re-raises its failure as one. The
58
- **CLI** is the single place that turns it into a user-facing message:
59
- `fetch_document` rescues the marker module `Pubid::Errors::Error` — which also
60
- covers `Pubid::Errors::InvalidInputError` (input too long) — (alongside
61
- `Relaton::RequestError`) and returns `"<code>" is not a recognized
62
- standards identifier`, and `SubcommandCollection#fetch` — which bypasses
63
- `fetch_document` — rescues it too, emitting the same message via `Util.error`.
64
- The paths that actually reach pubid's parser are `relaton fetch` (→
65
- `Db#fetch`/`#fetch_std` → `processor.get`) and `relaton collection fetch` (→
66
- `Db#fetch`). `relaton db fetch` is a **cache-only** lookup (`fetch_db: true`
67
- short-circuits `check_bibliocache` to a cache-key read via `std_id`, which is
68
- plain string manipulation, never `processor.get`), so it never parses and can't
69
- raise a parse error — a malformed id there simply misses the cache and
70
- prints "No matching bibliographic entry found". `command.rb` and
71
- `subcommand_collection.rb` therefore `require "pubid"` at load time so the
72
- constant resolves even when an unrelated exception (e.g. an `ArgumentError` from
73
- `parse_date_option`) is the one propagating through the rescue chain:
74
- `require "relaton"` loads pubid lazily, so it may not be loaded yet. `Pubid::Errors`
75
- first shipped in pubid `2.0.0.pre.alpha.11`, which is why `relaton.gemspec`
76
- pins `~> 2.0.0.pre.alpha.11`.
77
-
78
- A reference that **no flavor recognizes** is a different error:
79
- `Relaton::Db` raises `Relaton::UnknownReferenceError` (a `Relaton::Error`, not
80
- a `Pubid::Errors::Error`) when routing finds no flavor (relaton#205).
81
- `fetch_document` and `SubcommandCollection#fetch` rescue it, log its message
82
- (`` `<code>` is not a recognized standards identifier``) with `Util.warn`, and
83
- return no document, so `relaton fetch` prints the supported types.
84
-
85
- ### Core Data Classes
86
-
87
- - `lib/relaton/bibdata.rb` — `Relaton::Bibdata` wraps `RelatonBib::BibliographicItem`, adding URL type handling and serialization to XML/YAML/Hash. Uses `method_missing` to delegate to the underlying bibitem.
88
- - `lib/relaton/bibcollection.rb` — `Relaton::Bibcollection` represents a collection of bibliographic items with title/author/doctype metadata. Handles XML/YAML round-tripping.
89
- - `lib/relaton/element_finder.rb` — Mixin providing XPath utilities with namespace handling.
90
-
91
- ### Converters (Template Method Pattern)
92
-
93
- - `lib/relaton/cli/base_convertor.rb` — Abstract base defining the conversion flow (convert_and_write, write_to_file_collection)
94
- - `lib/relaton/cli/xml_convertor.rb` — XML → YAML conversion
95
- - `lib/relaton/cli/yaml_convertor.rb` — YAML → XML conversion (includes processor detection via doctype)
96
- - `lib/relaton/cli/xml_to_html_renderer.rb` — Renders XML/YAML to HTML using Liquid templates from `templates/`
97
-
98
- ### Data index site generator (`relaton index`)
99
-
100
- `relaton index [DATA-DIR]` builds the modern, browsable GitHub Pages index for
101
- `relaton-data-<flavor>` repos (e.g. https://relaton.github.io/relaton-data-bipm/),
102
- replacing the shared Jekyll theme (`relaton/jekyll-theme-relaton-data-index`) +
103
- `relaton/support` `data-deploy.yml` build. Pieces:
104
-
105
- **One delivery mode, sharded.** The shell carries **no document data** — only
106
- branding, the inlined bundle, and five `data-*` scalars describing the shard
107
- layout — so page weight is a function of what the reader opens, not of corpus
108
- size. Everything else is fetched:
109
-
110
- ```
111
- _site/index.html ~110 KB regardless of corpus size
112
- _site/search-0000.json … summary records {r,c,t,s,d,u,l}, `--shard-size` each
113
- _site/detail-0000.json … the rich fields, `--detail-shard-size` each
114
- ```
115
-
116
- Two scope decisions are baked in here; both were deliberate, and re-litigating
117
- either without reading this will lead you back to a 150 MB page:
118
-
119
- 1. **Crawler-indexability was dropped as a requirement.** The old `embedded`/`dom`
120
- modes rendered one `.document` row per document precisely so crawlers could
121
- read the corpus; that is what made the page O(corpus). relaton/relaton#48
122
- listed it as an acceptance criterion, and it was given up knowingly — these
123
- are machine-data repos whose consumers are relaton fetchers, and the Pages
124
- site is a "what data is available" browser reached from the repo. Restoring it
125
- means paginated `page/N/index.html` shells, not un-sharding the data.
126
- 2. **Therefore the three `--mode`s collapsed to one.** `dom` existed *only* to be
127
- crawlable, and as a data channel `data-*` attributes are strictly worse than
128
- JSON (bigger, and lossy — 7 fields, no detail). `embedded`'s remaining
129
- advantage was `file://` self-containment, which a fetched sidecar had already
130
- given up. `static-json` was just "sharded, with one shard". `--mode` is
131
- **removed**, not deprecated; see the note at the end of this section.
132
-
133
- - `lib/relaton/cli/index_site_generator.rb` — scans `DATA-DIR/**/*.{yaml,yml}`
134
- (default `./data`, skips `index-v*.yaml`) **plus an auto-detected sibling
135
- `static/` folder** (`STATIC_DIRNAME`, a sibling of `DATA-DIR` = `repo_root/static`;
136
- `--no-static`/`static: false` opts out), normalizes each doc, and writes the
137
- shell plus the shards into `--output` (default `_site`).
138
- Both source dirs go through the same `SKIP_BASENAMES`/`document?` filters. De-dup
139
- is **cross-folder only**: `data/` is scanned first, so a `static/` doc whose
140
- normalized id already appeared in `data/` is skipped with a warning (data wins);
141
- duplicates *within* a single folder are left as-is (preserving the pre-static
142
- data-scan behavior), and a blank/empty normalized id never de-dups (so docid-less
143
- title-only docs aren't collapsed). Static docs get correct `static/<path>`
144
- raw-YAML links for free because `relative_path` is taken from `repo_root` (the
145
- parent of both `data/` and `static/`). `static/` holds manually-curated bib
146
- records the crawler can't fetch (ISO/IEC Directives, JCGM/GUM guides, NIST
147
- research-library metadata) that are already part of the corpus (in `index-vN.yaml`).
148
- **`each_document` is the streaming spine**: it yields normalized items instead
149
- of accumulating them, and `ShardWriter` buffers at most one shard of each
150
- family, so for the **search/detail** shard families peak RSS is O(shard size) —
151
- measured at ~38 MB above baseline for a 20,000-document, 79 MB corpus. Nothing
152
- may reintroduce a `collect_documents` that materializes the corpus for those.
153
- The spec that guards this observes the property rather than asserting a method
154
- is never called (that passes just as happily once the method is renamed): it
155
- pins that a completed shard is on disk while the corpus is still being read.
156
- **`MachineIndex` is the deliberate exception to the O(shard size) bound.**
157
- Shard assignment needs the corpus size, so it is not knowable until the pass
158
- ends — the class therefore retains one
159
- `Row` per document. That is O(corpus), and fine: ~0.44 KB/row, i.e. ~76 MB at
160
- 177k rows. What it must NOT do is retain the pubid object, which costs
161
- 3.14 KB/row — 543 MB on the same corpus, past the ~250 MB where the process
162
- was observed to hang. `Row` holds only `rendered`/`file`/`key`/`id_hash`, all
163
- derived inside `add`; anything else needed from pubid must likewise be
164
- computed there, because by write time the identifier is gone.
165
-
166
- **The machine-index wire contract.** `relaton index` also publishes a
167
- machine-consumable index — `index-vN.yaml`/`.zip`, `index/manifest.json` and
168
- `index/shard-NNNNN.json`. It is specified in `docs/data-repository-format.adoc`
169
- in the `relaton` repo; that file is the contract, this is why it is shaped that
170
- way. Four decisions, all settled deliberately — don't re-derive them:
171
-
172
- - **The name comes from the flavor's own `INDEXFILE`, never from the data.**
173
- `index-vN` is *per-flavor structure versioning*, not a global generation
174
- counter: 25 flavors read `index-v2`, IHO reads `index-v3`, JCGM reads
175
- `index-v1` — and JCGM's v1 is structured, so the number is a name, not a
176
- claim about the rows. An earlier revision derived it from a parse ratio
177
- (`structured? ? "v3" : "v1"`), which both re-labelled v2 as v3 and let a crawl
178
- that shifted the ratio by a few percent *rename the published file*, breaking
179
- every pinned consumer with nothing but a 404. `--index-name` overrides, for a
180
- corpus that is not a relaton flavor. Resolution is `Relaton::<Flavor>::INDEXFILE`
181
- via `FLAVOR_NAMESPACES`, whose only entry is 3GPP (`Pubid::Tgpp` against
182
- `Relaton::ThreeGpp`); every other flavor capitalizes identically in both gems.
183
- - **The shard key is `crc32(pubid.root.number.to_s) % N`, with no fallback.**
184
- That expression is the one `Relaton::Index` sorts and bsearches on (`Type#
185
- candidates_by_number`, `FileIO#deserialize_pubid`), and the identity is the
186
- whole point: a client computing the key from its parsed query must land in the
187
- bucket the rows are in. A rendered-id fallback for an empty root number
188
- (added once to avoid a hot shard 0) breaks that — the client looks in shard 0,
189
- the row is elsewhere, and the miss is indistinguishable from not-found because
190
- empty shards are omitted. An identifier with no root number keys on `""` and
191
- goes in shard 0, the same degeneracy the gem's bsearch already has.
192
- - **Every published row is structured, and the shards and the monolith agree.**
193
- `row_record` and `write_monolith` both carry the pubid hash for every row.
194
- They cannot differ: `FileIO#deserialize_id` calls `from_hash` on every row and
195
- rejects the *whole* index on the first one it cannot deserialize, so one
196
- plain-string row in the monolith would poison the file. That is also why
197
- `--pubid-flavor` is **required** unless `--no-machine-index` — a machine index
198
- with no parser could only be the string shape.
199
- - **An id the parser rejects is taken from the repo's committed index.** The
200
- generator derives ids by parsing each document's *rendered* docid, while the
201
- committed `index-vN.yaml` was built by the flavor's `DataFetcher` from source
202
- metadata; five shipping corpora disagree on a handful of rows (ieee 69,
203
- itu-r 47, iec 42, itu 3, nist 3). `committed_index` reads that file once and
204
- `add` uses its hash **verbatim**, so the site's row is byte-identical to the
205
- repo's. Only a row neither path resolves is dropped, counted in
206
- `skipped_count`, and warned about.
207
- **A committed row also wins when it only adds an edition**
208
- (`MachineIndex#refine`): when its hash equals the parse's plus keys in
209
- `REFINABLE_KEYS` (`version`, `edition`, `volume`, `year`, `issue`) — the
210
- components a printed
211
- docid omits by design. An IHO record prints `B-12` and keeps its edition in
212
- `edition:`; an ECMA one prints `ECMA-262` with no ` ed<N>`. Without this the
213
- published Pages indexes (2026-10-29) carried no IHO `version` (0 of 325 rows)
214
- and no ECMA `edition` (0 of 804; the committed index has 740), so every
215
- edition-qualified reference answered not-found (relaton-data-iho#20). Only
216
- the hash is taken; the parse still gives the rendered spellings, so a docid
217
- alias `B-12` stays deduplicated against the canonical `IHO B-12`. **Don't
218
- widen this to pubid `===`**: it reads an omitted part, co-publisher or
219
- sub-registry as a wildcard (`ISO 1000 === ISO/IEC 1000`, `IANA foo ===
220
- IANA foo/bar`), `committed_index` keeps only a file's *first* row, and the
221
- extra `from_hash` per row doubled the build time.
222
-
223
- **The monolith's hand-rolled YAML writer must preserve type, not just text.**
224
- `write_monolith` avoids Psych (40 min on 177k rows), so `yaml_nested`/`yaml_scalar`
225
- own the whole round trip. Two shapes broke it, both invisible to specs that only
226
- build `{_type, number}` hashes: an **Array** fell through to `yaml_scalar`, whose
227
- `["IEC"].to_s` starts with `[` and was quoted into the String `"[\"IEC\"]"` —
228
- every ISO/IEC copublished id carries `copublishers`, and `from_hash` cannot cast
229
- it, so `FileIO` rejects the whole index; and a real **Boolean** was stringified
230
- then quoted (`d_prefix: "true"`, 31 of CIE's 1139 rows), so the published row no
231
- longer matched the repo's. Arrays are now written as a JSON flow sequence (valid
232
- YAML, unambiguous escaping); booleans and integers go out bare. Verify a writer
233
- change against a real corpus, comparing the site's monolith row-by-row to the
234
- repo's committed index — that is how both were found.
235
-
236
- **`purge_stale!` reads the previous manifest.** `STALE_GLOBS` only knows
237
- `index-v*`, but `--index-name` accepts any single file name (`INDEX_NAME`; a path
238
- segment or a leading dot is refused), so the previous build's `index/manifest.json`
239
- is the only record of which monolith to delete.
240
-
241
- **`Util.warn` is not the relaton logger — it is `Kernel#warn`.**
242
- `Relaton::Cli::Util` gets `info`/`error` from `Relaton::Bib::Util#method_missing`,
243
- but `warn` already exists as a private `Kernel` instance method, so the explicit
244
- receiver makes Ruby route it to `method_missing` *after* a private-method
245
- NoMethodError. The message does reach the logger, but `expect(Relaton::Cli::Util)
246
- .to receive(:warn)` never intercepts it. Mock `Relaton.logger_pool` instead:
247
- `expect(Relaton.logger_pool).to receive(:warn).with(/…/, "relaton-cli")`.
248
- **`purge_stale!` is load-bearing, not hygiene**: with no manifest, a
249
- `search-0034.json` left by a previous larger corpus is invisible (the shard
250
- count on the mount node says 34), so it would sit in the deployed site forever.
251
- **Per-flavor branding** beyond `--title` is
252
- `--description` and `--favicon` (both `nil` by default — the per-flavor values the
253
- deleted Jekyll `_config.yml`s carried, now supplied by the caller workflow). The
254
- description becomes `<meta name="description">` and `data-description` on the
255
- mount node; the favicon becomes `<link rel="icon">`
256
- with its href passed through **verbatim** and a `type` sniffed from the extension
257
- via `FAVICON_TYPES` (unknown extension → no `type`, the browser sniffs). The
258
- generator copies **no assets** (only the shell + the shards), so a relative
259
- favicon href is the caller's job to place in the deployed site — normally it's an
260
- absolute URL. Both tags are emitted by `{%- if … %}` branches in `page.liquid`, so
261
- a build with neither option is byte-identical to one that never had them —
262
- `spec/index_fixtures/golden/index.html` pins the whole shell, so regenerate it
263
- deliberately and read the diff when you touch that template.
264
- `title`/`description`/`favicon` all go
265
- through `presence`, which maps a blank string to `nil`: the deploy workflow
266
- forwards unset inputs as `--favicon ""`, and an empty `href` would make the
267
- browser re-fetch the page as its own icon.
268
- - `lib/relaton/cli/index_item_normalizer.rb` — doc-hash → `{id,title,doctype,
269
- stage,date,link,yaml}` (the always-present summary core) **plus optional
270
- detail-page fields** (`abstract, edition, languages, keywords, publisher,
271
- contributors, docids, dates, relations`) added by `details`. Reads the doc's own rendered
272
- fields (primary `docidentifier`, main/en `title`, `date[].at|on` preferring
273
- `published`, `ext.doctype`), so **no pubid reconstruction** is needed. Detail
274
- fields are dropped when empty (via `reject`), so a summary-only doc keeps the
275
- compact 7-key shape. The generator then splits that one record in two:
276
- `compact_record` projects the seven summary keys (renamed `r,c,t,s,d,u,l`) into
277
- the search shards, and `detail_record` takes **everything else**, tagged with
278
- `r`. The split is defined by one constant, `COMPACT_KEYS` — a key that isn't in
279
- it is a detail field by definition, so a new normalizer field lands in the
280
- detail shards automatically and never silently bloats the summary. The frontend
281
- `types.ts` `IndexDocument` / `DetailRecord` interfaces mirror both halves.
282
-
283
- **`relations` carries only `{type, id}`, and the id must render exactly like a
284
- document's own.** The whole point of the field is that the detail page can match
285
- the target against the rest of the corpus and turn it into a `?doc=` link, so
286
- `relation_id` shares the primary-DocID logic that computes each document's `id`
287
- (a relation's nested `bibitem` carries the same `docidentifier`/`primary`
288
- shape). Don't "improve" it into a separate id renderer — the two would drift and
289
- every link would silently degrade to plain text. It deliberately shares
290
- `primary_docidentifier` and **not** `docidentifier` itself: the latter bottoms
291
- out at the item's `id`, which on a relation bibitem is an internal XML anchor
292
- (`CENISO/TS21003-7-2008/A1-2010` — CEN records carry exactly this). An anchor can
293
- never match a corpus document, so the fallback order is docidentifier →
294
- `formattedref` → `docnumber` → anchor; putting the anchor any earlier strands a
295
- target that *is* present as plain text under a mangled label. No title/date is
296
- carried: measured on
297
- `relaton-data-iso` (79,402 docs) that is 83,074 relations, of which **99.9%
298
- resolve in-dataset**; the remaining 48 are genuinely-absent `ISO/DIS …` drafts.
299
- Two guards are load-bearing on real data — **self-references are dropped**
300
- (BIPM's Metrologia articles relate to themselves once per carrier) and
301
- identical `{type, id}` pairs are **de-duplicated**.
302
-
303
- **Detail lookup is positional, verified by id.** A detail shard is a *dense*
304
- array: slot `i` is either `null` (that document has no detail fields) or
305
- `{"r": "<id>", …}`. A `null` slot is still written, because the frontend finds a
306
- document's detail by arithmetic — `shard = ⌊i / detailShardSize⌋`,
307
- `slot = i % detailShardSize`, where `i` is its position in the corpus — and
308
- dropping empties would shift everything after it. Nothing stores that mapping,
309
- which is why `loadDetail` checks the entry's `r` against the document id and
310
- falls back to scanning the shard on mismatch: showing another document's
311
- abstract would be worse than showing none. Keep the two families written from
312
- the same single pass over `each_document`, or the arithmetic stops holding.
313
-
314
- **Corpus position is carried, never derived from the array index.** The shard
315
- loader stamps `pos` onto each document from its shard number
316
- (`i * shardSize + j`), and `App.vue` uses `doc.pos` — falling back to `indexOf`
317
- only for a document list supplied whole. This is not redundancy. A summary
318
- shard that fails to load leaves a *hole*: every later document then sits at an
319
- array index `shardSize` lower than its true position, which at the defaults
320
- (5000 / 500) addresses a detail shard **ten files early**. The `r` check would
321
- reject the wrong record, but its fallback scans only the shard it was sent to,
322
- so it can never recover a cross-shard drift — the failure would be silent
323
- summary-only detail panels for the entire rest of the corpus. Don't "simplify"
324
- `doc.pos` back to `indexOf`.
325
- - `lib/relaton/cli/frontend_assets.rb` — reads the compiled bundle from
326
- `frontend/dist/` (one relative path works for both gem and git checkout);
327
- raises `BuildMissingError` with a "run `rake build_frontend`" message if absent.
328
- `dist_dir` is overridable so specs point at a fixture dist (no Node build).
329
- - `templates/index/page.liquid` — the whole output HTML: head/branding, the
330
- inlined IIFE + CSS, and the mount node carrying the five shard scalars
331
- (`data-total`, `data-shards`, `data-shard-size`, `data-detail-shards`,
332
- `data-detail-shard-size`). Those attributes are the **only** description of the
333
- index's shape — there is deliberately no manifest file, which saves a
334
- round-trip before the first shard can be requested and removes a failure mode
335
- where the manifest 404s but the shards are fine. Shard file names are derived by
336
- convention on both sides (`search-%04d.json` / `detail-%04d.json`) and resolve
337
- relative to the page, which sits beside them. Rendered via a Liquid
338
- `Environment` (not the deprecated global `Template.file_system=`).
339
- (`_document.liquid` is gone with the crawler DOM.)
340
- - `--flavor <f>` / `--repo <org/name>` shallow-clone the data repo to a tempdir
341
- and read its `data/`; the checked-out branch is auto-detected (bipm's is `v2`)
342
- and used for the raw-YAML `--base-url`. Default (no flavor/repo) reads the local
343
- folder — the in-Action path where the repo is already checked out.
344
-
345
- **Frontend (`frontend/`).** A Vite 8 **library-mode** project
346
- (`@vitejs/plugin-vue` + `@tailwindcss/vite` + Vue 3) that builds ONE
347
- `frontend/dist/app.iife.js` + `style.css` (the lutaml-xsd embedding pattern,
348
- modernized). `src/` is committed; `dist/` + `node_modules/` are gitignored and
349
- built at package time. The island (`src/App.vue`) does search / doctype+stage
350
- facets / sort / list-grid / dark-mode / copy-DocID / client pagination.
351
-
352
- **Hydration is one synchronous path.** `src/lib/hydrate.ts` `resolveIndex(el)`
353
- just reads the mount node — branding plus `readShardInfo` — and returns an empty
354
- document list; there is nothing to await, so the app paints immediately and the
355
- first shard request starts at boot. `src/lib/shards.ts` then owns all fetching:
356
-
357
- - `loadSearchShards(info, onBatch)` — a bounded worker pool (4) over the summary
358
- shards. Two properties are load-bearing. **Order**: records are appended in
359
- shard order no matter what order responses arrive in, because corpus position is
360
- what the detail lookup is keyed to — a shard that resolves early waits for its
361
- predecessors. **Throttling**: `App.vue` re-filters and re-sorts the entire array
362
- on every change, so emitting per shard would re-sort a growing 166k-element
363
- array dozens of times; emissions are coalesced into one per `flushMs` window,
364
- with a forced final emit. A shard that 404s becomes `[]` rather than aborting
365
- the load, and `res.ok` is checked because a 404 on Pages returns an HTML error
366
- page that would otherwise throw inside `res.json()`.
367
- - `createDetailLoader(info)` — fetch-on-open with a per-shard cache, so paging
368
- through neighbouring documents costs one request rather than one per document;
369
- concurrent opens in the same shard share a single in-flight promise.
370
-
371
- `src/app.ts` holds `reactive()` `IndexData` and `Hydration` objects and mutates
372
- them as shards land — `createApp(App, props)` props are **static**, so reassigning
373
- a prop would never reach the component. The shard load is kicked off via
374
- `requestIdleCallback` so it stays off the first paint.
375
- The site `description` comes from `data-description` on the mount node; `App.vue`
376
- renders it as the header subtitle, falling back to the default "Please use the
377
- provided Relaton DocID…" sentence.
378
-
379
- **The loading gates are the subtle part.** While shards are arriving the corpus is
380
- knowingly incomplete, so anything that reads "absent" as "does not exist" has to
381
- wait for `hydration.loading` to clear:
382
- - an unresolved `?doc=` must **not** bounce to the list (that watcher previously
383
- treated not-found as removed-document; on a large index every deep link would
384
- break) — instead it calls `requestAll()` to hurry the load, and only drops the
385
- param once loading finishes;
386
- - `?page=N` must not be clamped against a `pageCount` computed from a partial
387
- corpus — `clampPage()` is deferred to a `watch(loading)`, and `onPopState`
388
- skips clamping while loading;
389
- - the count line reads `hydration.total` (known up front from `data-total`) so it
390
- says "12 of 166658", not a denominator that grows.
391
- The `hydration` and `loadDetail` props are **optional**: mounted without them the
392
- component behaves exactly as it did before, which is what keeps the pre-existing
393
- tests meaningful.
394
- Pagination is **viewport-adaptive**: `computePageSize()`
395
- sizes a page from the available height and grid/list row density (recomputed on
396
- resize — debounced — and on view-mode change, clamped `10..100`) rather than a
397
- fixed 100 rows, and the Prev/Next/page controls live in a shared
398
- `src/components/Pagination.vue` rendered **both above and below** the list. The
399
- URL-synced state is **two params**, both in `src/lib/url.ts` (History API,
400
- preserving each other + any other params, synced back by the `popstate`
401
- listener): the current page `?page=N` (page 1 omits it —
402
- `readPageFromUrl`/`writePageToUrl`) and the open **detail document** `?doc=<id>`
403
- (`readDocFromUrl`/`writeDocToUrl`) so a reload/share/bookmark restores it. User
404
- paging and opening/closing a detail use `history.pushState` (Back/Forward walk
405
- pages and in/out of the detail), while restore-clamp and filter-reset use
406
- `replaceState`. Search/facets/sort stay in-memory (view/theme still use the
407
- `localStorage` `relaton-index-*` keys).
408
-
409
- **Detail page.** Clicking a `DocumentRow` (its id/title/chevron — real
410
- `?doc=<id>` anchors, click-intercepted to SPA-navigate) opens
411
- `src/components/DocumentDetail.vue` in place of the list (`App.vue` swaps on the
412
- `selectedDoc` computed; an unknown `?doc=` falls back to the list). It renders the
413
- enriched fields (abstract, metadata grid, keywords, contributors, all identifiers,
414
- **relations**, all dates) and the landing + raw-YAML links, each block shown only
415
- when present so a summary-only doc degrades gracefully.
416
-
417
- **Relations link within the dataset.** A relation whose target DocID is a document
418
- in this corpus renders as a real `?doc=<id>` anchor (same `docHref` helper and
419
- click-intercept as `DocumentRow` — `lib/url.ts` owns that one builder, don't
420
- re-inline `URLSearchParams`); a target that isn't stays visible as plain text
421
- rather than linking to a page that would bounce back to the list. Resolution is
422
- **frontend-side against the loaded corpus**, not baked in at build time — the
423
- generator is a single streaming pass (`each_document`) that never holds the corpus,
424
- so it cannot know the full id set when it writes a detail shard. The consequence is
425
- the usual loading gate: while summary shards are still arriving a real target can
426
- look absent, so `DocumentDetail` takes a **`hasDoc(id)` predicate** rather than a
427
- built `Set` (a `computed` Set over 166k ids stays lazy behind the function, and only
428
- materializes for a document that actually has relations), and `App.vue` calls
429
- `hydration.requestAll()` when an opened document's detail carries relations. Links
430
- then upgrade from plain text to anchors as shards land. Pure
431
- filter/sort logic is `src/lib/filter.ts`. UI icons
432
- are inline Heroicons-outline SVGs via the shared `src/components/Icon.vue`
433
- (`<Icon name="…" />`, a name→path map — `fill=none stroke=currentColor` so they
434
- inherit text colour + dark mode), matching the CalConnect standards site — **not**
435
- Unicode emoji; add a new glyph to `Icon.vue`'s `PATHS` rather than inlining an
436
- emoji. Tests: `npm run typecheck` + `vitest`
437
- (filter/hydrate/shards/url/App/Icon/DocumentDetail via happy-dom).
438
- Anything touching the shard loaders needs a `fetch` stub — `src/test/fetch.ts`
439
- (`stubFetch` + `deferred`) is the shared one; `deferred` is how the
440
- out-of-order-response ordering test forces shard 1 to resolve before shard 0.
441
-
442
- **`npm run dev`.** `src/dev/main.ts` shards `sample-data.json` in memory, stubs
443
- `fetch` to serve it, and sets the mount-node scalars — i.e. the dev harness goes
444
- through the *real* hydration path rather than a shortcut that only exists there.
445
- Its shard sizes are deliberately tiny (3 / 2) so progressive loading is visible.
446
-
447
- **Build wiring.** `rake build_frontend` (`npm ci`/`install` + `npm run build`) is
448
- a prerequisite of `build`/`release`; the root `rake build_all` runs it before
449
- `gem build`. The gemspec ships the gitignored `frontend/dist/*` explicitly
450
- (`git ls-files` can't see it) and excludes the tracked `frontend/` sources.
451
- **Release note:** the release pipeline must have Node available or the gem ships
452
- without the bundle. The reusable Pages-deploy workflow (install relaton-cli →
453
- `relaton index` → deploy-pages) belongs in `relaton/support` — data repos call it
454
- via `uses: relaton/support/.github/workflows/data-deploy.yml@main` — not in this
455
- repo (a reusable workflow only runs from the calling repo's `.github/workflows/`,
456
- and an in-tree copy would also get packaged into the gem).
457
-
458
- **`--mode` removal and the caller contract.** `--mode` is **gone**, not
459
- deprecated. `relaton/support`'s `data-deploy.yml` passed it at two call sites,
460
- and every `relaton-data-*` caller runs `source: git` — building relaton-cli from
461
- `relaton/relaton` **main**, not a released gem — so there is no release gate:
462
- a stale caller breaks on its next deploy run, not at some later `gem push`.
463
- `/work/HANDOFFS/relaton__support__data-deploy-drop-mode-input.md` asks support to
464
- drop the input, and **must land first**.
465
-
466
- That is also why `Command.exit_on_failure?` now returns `true`. Thor's legacy
467
- default is to print a usage error and exit **0**, so a caller passing the removed
468
- flag would write no site at all and still go green — publishing an empty index
469
- across ~29 unattended Pages deploys with nothing red. An acceptance spec pins the
470
- loud failure. Don't remove `exit_on_failure?` to quiet a test.
471
-
472
- **Don't pre-compress the shards, and don't add a single archive.** GitHub Pages
473
- already gzips them in transit (measured: a 32.9 MB `search.json` transfers in
474
- 4.8 MB, and the old 151 MB 3gpp page in 6.4 MB — the hand-off's headline byte
475
- counts were `curl` *without* `--compressed`, i.e. uncompressed sizes). Storing
476
- `.json.gz` would be served as `application/gzip` with no `Content-Encoding`, so
477
- the browser would not inflate it transparently: you would add a decompressor to
478
- the bundle to reach bytes Fastly already gives you, and forfeit brotli. A single
479
- zip (the `index-vN.zip` pattern the data repos use for their *machine* index) is
480
- worse still here — nothing renders until all of it arrives, which is exactly the
481
- O(corpus) first paint this design removes. The real remaining cost was never
482
- transfer; it is parse/DOM/heap, which compression does nothing for.
483
-
484
- **Known ceiling.** Sharding fixes *transfer and parse*, not *compute*. 166k
485
- documents is still ~40–60 MB of JS heap, and `applyFilters` runs a full
486
- `Intl.Collator` sort over the whole array on every keystroke. The real fix is a
487
- prebuilt inverted index or server-side search; that is a separate design, not
488
- something to bolt onto the shard loader.
489
-
490
- ### File Operations
491
-
492
- `lib/relaton/cli/relaton_file.rb` — Static methods for extract (pull bibdata from Metanorma XML), concatenate (combine files into a collection), and split (break a collection into individual files).
493
-
494
- ### Database (Singleton)
495
-
496
- `Relaton::Cli::RelatonDb` (in `lib/relaton/cli.rb`) is a Singleton managing a `Relaton::Db` instance. DB path is persisted in `~/.relaton/dbpath`. The `relaton` gem's registry auto-discovers 30+ standard-body processors.
497
-
498
- ### Processor Detection
499
-
500
- `Relaton::Cli.processor(doc)` and `.parse_xml(doc)` detect the correct processor (ISO, IEC, IETF, etc.) from a document's `docidentifier` element type attribute, falling back to prefix matching.
501
-
502
- ## Test Structure
503
-
504
- - `spec/acceptance/` — End-to-end CLI integration tests using `rspec-command`
505
- - `spec/relaton/cli/` — Unit tests for command, converters, subcommands, DB
506
- - `spec/relaton/` — Unit tests for Bibcollection and Bibdata
507
- - `spec/support/` — Test setup: SimpleCov, WebMock, VCR, equivalent-xml matchers
508
- - `spec/fixtures/` and `spec/vcr_cassettes/` — Test data and recorded HTTP responses
509
-
510
- Tests use VCR cassettes to replay HTTP interactions with standards registries. WebMock blocks real HTTP requests in tests.
511
-
512
- ## Key Dependencies
513
-
514
- - `relaton` — Core library providing DB, registry, and all standard-body
515
- processors. relaton-cli releases in lockstep with it: both the gemspec pin
516
- (`= <version>`) and `Relaton::Cli::VERSION` derive from the root
517
- `lib/relaton/version.rb` (`Relaton::VERSION`), so they never drift apart.
518
- - `thor` / `thor-hollaback` — CLI framework
519
- - `liquid ~> 5` — HTML template rendering
520
- - `nokogiri` (transitive via relaton) — XML parsing
521
-
522
- ## Ruby Version
523
-
524
- Requires Ruby >= 3.0.0 (set in gemspec and `.rubocop.yml`).
data/Gemfile DELETED
@@ -1,42 +0,0 @@
1
- source "https://rubygems.org"
2
-
3
- # Specify your gem's dependencies in gemspec
4
- gemspec
5
-
6
- # The single combined `relaton` gem lives at the repo root.
7
- gem "relaton", path: "../.."
8
-
9
- # parsanol/pubid ride the gemspec floors (parsanol ~> 1.3.124 with pubid
10
- # ~> 2.0.4): pubid 2.0.4 re-bakes its artifacts on the 1.3.124 line. No
11
- # local pin — the joint-identifier regression on that line is
12
- # parsanol-ruby#409.
13
- gem "pubid", "~> 2.0.4"
14
-
15
- # https://github.com/rails/rails/issues/55886
16
- # add openssl as an explicit gem to fix SSL verification issues in GHA.
17
- # gem "openssl"
18
-
19
- # `canon` comes in through `lutaml-model`. Keep it below 0.3.52, as the root
20
- # Gemfile does: from that version it installs `yeptris`, which replaces
21
- # `Object#to_yaml` and raises `NameError` on Windows. See the root Gemfile.
22
- gem "canon", "< 0.3.52"
23
-
24
- gem "byebug", "~> 11.0"
25
- gem "debug"
26
- gem "equivalent-xml", "~> 0.6"
27
- gem "fiddle"
28
- gem "pry"
29
- gem "rake"
30
- gem "rspec", "~> 3.0"
31
- gem "rspec-command", "~> 1.0.3"
32
- gem "rspec-core", "~> 3.4"
33
- gem "rspec-html", "~> 0.3.0"
34
- gem "moxml", ">= 0.5.113" # lutaml/moxml#336: the formattedref add_child TypeError fixed in 0.5.113 (relaton#254)
35
- gem "simplecov"
36
- gem "vcr"
37
- gem "webmock"
38
- # leptris 1.9.386.0: the floor moxml's adapter names (older bindings load
39
- # unusable and every XML path silently falls back to nokogiri), pinned to
40
- # match the relaton root bundle — the battery re-run accompanies this bump.
41
- gem "leptris", "1.9.391.0"
42
-
data/Rakefile DELETED
@@ -1,21 +0,0 @@
1
- require "bundler/gem_tasks"
2
- require "rspec/core/rake_task"
3
-
4
- RSpec::Core::RakeTask.new(:spec)
5
-
6
- FRONTEND_DIR = File.expand_path("frontend", __dir__)
7
-
8
- desc "Build the JS/CSS frontend (Vite → frontend/dist/app.iife.js + style.css)"
9
- task :build_frontend do
10
- Dir.chdir(FRONTEND_DIR) do
11
- # `npm ci` when a lockfile is present (reproducible), else `npm install`.
12
- sh(File.exist?("package-lock.json") ? "npm ci" : "npm install")
13
- sh "npm run build"
14
- end
15
- end
16
-
17
- # The gem must ship the compiled frontend, so build it before packaging/release.
18
- task build: :build_frontend
19
- task release: :build_frontend
20
-
21
- task default: :spec
data/bin/console DELETED
@@ -1,11 +0,0 @@
1
- #!/usr/bin/env ruby
2
-
3
- require "bundler/setup"
4
- require "relaton-cli"
5
-
6
- # You can add fixtures and/or initialization code here to make experimenting
7
- # with your gem easier. You can also use a different console, if you like.
8
-
9
- # (If you use this, don't forget to add pry to your Gemfile!)
10
- require "pry"
11
- Pry.start
data/bin/rspec DELETED
@@ -1,29 +0,0 @@
1
- #!/usr/bin/env ruby
2
- # frozen_string_literal: true
3
-
4
- #
5
- # This file was generated by Bundler.
6
- #
7
- # The application 'rspec' is installed as part of a gem, and
8
- # this file is here to facilitate running it.
9
- #
10
-
11
- require "pathname"
12
- ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../Gemfile",
13
- Pathname.new(__FILE__).realpath)
14
-
15
- bundle_binstub = File.expand_path("../bundle", __FILE__)
16
-
17
- if File.file?(bundle_binstub)
18
- if File.read(bundle_binstub, 150) =~ /This file was generated by Bundler/
19
- load(bundle_binstub)
20
- else
21
- abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run.
22
- Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.")
23
- end
24
- end
25
-
26
- require "rubygems"
27
- require "bundler/setup"
28
-
29
- load Gem.bin_path("rspec-core", "rspec")
data/bin/setup DELETED
@@ -1,8 +0,0 @@
1
- #!/usr/bin/env bash
2
- set -euo pipefail
3
- IFS=$'\n\t'
4
- set -vx
5
-
6
- bundle install
7
-
8
- # Do any other automated setup that you need to do here