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
@@ -1,1008 +0,0 @@
1
- require "json"
2
- require "yaml"
3
- require "zlib"
4
- require "digest"
5
- require "pathname"
6
- require "fileutils"
7
- require "liquid"
8
- require "relaton/cli/frontend_assets"
9
- require "relaton/index"
10
- require "zip"
11
- require "pubid"
12
- require "relaton/cli/index_item_normalizer"
13
-
14
- module Relaton
15
- module Cli
16
- # Builds a browsable HTML index site from a folder of Relaton bibliographic
17
- # YAML documents (default ./data, arbitrarily nested).
18
- #
19
- # The page shell carries no document data at all — only branding, the
20
- # inlined Vue+Tailwind bundle, and the scalars describing the shard layout.
21
- # Documents are written as numbered JSON shards the frontend fetches:
22
- #
23
- # search-NNNN.json summary records {r,c,t,s,d,u,l}, loaded progressively
24
- # detail-NNNN.json the rich fields, fetched when a detail panel opens
25
- #
26
- # So page weight is a function of what the user looks at, not of corpus size,
27
- # and the generator streams rather than materializing the whole corpus.
28
- class IndexSiteGenerator
29
- TEMPLATE_DIR = File.expand_path("../../../templates/index", __dir__).freeze
30
- # index YAMLs that are machine indexes, not documents.
31
- SKIP_BASENAMES = /\Aindex(-v\d+)?\.ya?ml\z/i
32
- # Sibling of the data folder holding manually-curated bib docs (ISO/IEC
33
- # Directives, JCGM/GUM guides, NIST research-library metadata, …) that the
34
- # crawler can't fetch. Part of the corpus (referenced by index-vN.yaml), so
35
- # it belongs in the browsable site too.
36
- STATIC_DIRNAME = "static".freeze
37
- SHARD_PATTERN = "search-%04d.json".freeze
38
- DETAIL_PATTERN = "detail-%04d.json".freeze
39
- # Output written by a previous build that must not survive into this one:
40
- # a shrunken corpus would otherwise leave orphan shards nothing points at.
41
- STALE_GLOBS = ["search.json", "search-*.json", "detail-*.json", "index/*.json", "index-v*.yaml", "index-v*.zip"].freeze
42
- # Normalized key -> compact key. Order defines the key order in a shard
43
- # record; a key NOT listed here is a detail field (see #detail_record).
44
- COMPACT_KEYS = {
45
- "id" => "r", "title" => "c", "doctype" => "t", "stage" => "s",
46
- "date" => "d", "yaml" => "u", "link" => "l"
47
- }.freeze
48
- # flavor token -> [pubid namespace, relaton namespace], for the tokens
49
- # that do not follow the plain capitalize rule. 3GPP is the only one:
50
- # `Pubid::Tgpp` against `Relaton::ThreeGpp`.
51
- FLAVOR_NAMESPACES = {
52
- "3gpp" => %w[Tgpp ThreeGpp], "tgpp" => %w[Tgpp ThreeGpp]
53
- }.freeze
54
- # A monolith base name: one path segment, so `--index-name` can neither
55
- # write outside the output directory nor collide with a hidden file.
56
- INDEX_NAME = /\A[A-Za-z0-9][A-Za-z0-9._-]*\z/
57
- # <link rel="icon"> type hints, keyed by the favicon href's extension. An
58
- # unlisted extension emits no type at all and lets the browser sniff.
59
- FAVICON_TYPES = {
60
- ".svg" => "image/svg+xml", ".png" => "image/png", ".ico" => "image/x-icon",
61
- ".gif" => "image/gif", ".jpg" => "image/jpeg", ".jpeg" => "image/jpeg"
62
- }.freeze
63
-
64
- # Buffers records and flushes them as `dir/<pattern % n>` shards of at most
65
- # `size` records, so peak memory is O(size), not O(corpus). Writing is
66
- # delegated so the caller keeps control of the overwrite policy.
67
- class ShardWriter
68
- attr_reader :count
69
-
70
- def initialize(dir, pattern, size, &write)
71
- @dir = dir
72
- @pattern = pattern
73
- @size = size
74
- @write = write
75
- @buffer = []
76
- @count = 0
77
- end
78
-
79
- def <<(record)
80
- @buffer << record
81
- flush if @buffer.size >= @size
82
- self
83
- end
84
-
85
- # No-op on an empty buffer, so an exact multiple of `size` doesn't emit a
86
- # trailing empty shard and an empty corpus emits none at all.
87
- def flush
88
- return if @buffer.empty?
89
-
90
- @write.call(File.join(@dir, format(@pattern, @count)), JSON.generate(@buffer))
91
- @count += 1
92
- @buffer.clear
93
- end
94
- end
95
-
96
- # The machine-consumable index: docid -> file rows a data repo
97
- # publishes as index-vN.yaml, emitted on the Pages site as JSON
98
- # shards plus a monolith, per the contract documented in
99
- # relaton/relaton#113 (contract v2) and specified in
100
- # docs/data-repository-format.adoc.
101
- #
102
- # Every row is structured: each data repo publishes a pubid index, so
103
- # the generator always has a parser and never writes a plain-string id.
104
- # A row the parser rejects takes its id from the repo's committed index
105
- # (`committed:`), and is dropped only when that has no row for it either
106
- # — `Relaton::Index::FileIO#deserialize_id` rejects the *whole* index on
107
- # the first row it cannot deserialize, so one string row would poison
108
- # the file.
109
- #
110
- # Shard key: `crc32(pubid.root.number.to_s) % N` — the same expression
111
- # `Relaton::Index` bsearches on (`Type#candidates_by_number`,
112
- # `FileIO#deserialize_pubid`). A document family (base, parts,
113
- # amendments) shares one root and lands in one shard. The key is NOT
114
- # given a rendered-id fallback: that would break the identity, so a
115
- # client computing the key from its parsed query would look in a bucket
116
- # the row is not in and read the miss as not-found. An identifier with
117
- # no root number keys on "" and lands in shard 0.
118
- class MachineIndex
119
- TARGET_ROWS = 15
120
- MIN_ROWS = 2000
121
- MIN_SHARDS = 16
122
- # Id components a record's printed docid omits by design, so only the
123
- # committed row carries them (see #refine). `year`/`issue` are the IHO
124
- # series components (relaton#407): the data repo carries the
125
- # year/volume/issue of a series record (P-5's year, the IHR's
126
- # volume/issue) in the committed row even where the printed docid a
127
- # crawler reads spells only `P-5`.
128
- REFINABLE_KEYS = %w[version edition volume year issue].freeze
129
- MAX_SHARDS = 65_536
130
-
131
- # Deliberately does NOT retain the pubid object: `add` extracts the only
132
- # two things the write path needs (`key`, `id_hash`) while the document
133
- # streams past, then lets the identifier go. Retaining it costs 3.14 KB
134
- # per row against 0.44 KB for the derived values alone — 543 MB vs 76 MB
135
- # on a 177k-row corpus. Anything else derived from pubid must therefore
136
- # be computed in `add` too; by write time the object is gone.
137
- #
138
- # `derived` marks an alias row (this document's other spellings, or a
139
- # committed fetcher alias) as opposed to the document's own primary
140
- # id — `indexed_rows` prunes a derived row whose render a document
141
- # owns as its primary, so an alias can never repoint a spelling at
142
- # the wrong file (`Type#add_or_update` keys on the rendered string
143
- # and overwrites).
144
- Row = Struct.new(:rendered, :file, :key, :id_hash, :derived)
145
-
146
- attr_reader :rows
147
-
148
- # @param pubid_class [Class] the flavor's pubid Identifier
149
- # @param index_name [String] the monolith's base name, from the
150
- # flavor's `INDEXFILE`
151
- # @param committed [Hash, nil] `{ file => id hash }` read from the
152
- # repo's own committed index, consulted only when a rendered docid
153
- # does not parse
154
- def initialize(pubid_class:, index_name:, committed: nil)
155
- @pubid_class = pubid_class
156
- @index_name = index_name
157
- @committed = committed || {}
158
- @rows = []
159
- end
160
-
161
- # Committed rows an id-sharing file already covers: the fetcher's
162
- # aliases (a record-less registry's id pointing at the record-bearing
163
- # document's file -- relaton-iana#8, #9). Carried as rows of their
164
- # own so either spelling resolves to the one document.
165
- def add_alias_rows(rows)
166
- rows.each do |id_hash, file|
167
- pubid = from_hash(id_hash)
168
- next unless pubid
169
-
170
- row = Row.new(pubid.to_s, file)
171
- row.id_hash = id_hash
172
- row.key = key_string(pubid)
173
- row.derived = true
174
- @rows << row
175
- end
176
- end
177
-
178
- def add(rendered, file, alias_ids = nil)
179
- row = Row.new(rendered, file)
180
- # Precompute the expensive derived values (root.number walk,
181
- # lutaml to_hash) once during the streaming pass — computing
182
- # them at monolith-write time is pathological on 177k rows
183
- # because each involves object-graph traversal.
184
- pubid = parse(rendered)
185
- # A rendered docid the parser rejects falls back to the repo's own
186
- # committed row: the crawler resolved that id from source metadata.
187
- # The hash is kept verbatim, so the site's row is byte-identical to
188
- # the repo's. A row with neither keeps a nil id_hash, which
189
- # `indexed_rows` drops and `skipped_count` reports.
190
- hash = pubid ? pubid.to_hash : @committed[file]
191
- pubid ||= from_hash(hash)
192
- hash = refine(hash, file)
193
- if pubid
194
- row.id_hash = hash
195
- row.key = key_string(pubid)
196
- end
197
- @rows << row
198
- add_rendered_alias_rows(pubid, hash, rendered, file, alias_ids)
199
- end
200
-
201
- # The document's other spellings as rows of their own, so every id
202
- # the dataset prints resolves: the parse's canonical render of the
203
- # primary ("ISO/IEC DIR 1" from the file's own
204
- # "ISO/IEC Directives Part 1") and each further docidentifier's
205
- # render. All point at the same file. A docid that does not parse
206
- # is skipped, and one that renders as an already-added spelling of
207
- # this document adds no second row. Cross-document collisions are
208
- # not decided here — `indexed_rows` prunes a derived row whose
209
- # spelling a document owns as its primary.
210
- def add_rendered_alias_rows(pubid, primary_hash, rendered, file, alias_ids)
211
- seen = { rendered => true }
212
- canon = pubid&.to_s
213
- if canon && !canon.empty? && !seen.key?(canon)
214
- seen[canon] = true
215
- push_derived_row(canon, file, primary_hash, key_string(pubid))
216
- end
217
- Array(alias_ids).each do |content|
218
- alias_pubid = parse(content.to_s)
219
- next unless alias_pubid
220
-
221
- render = alias_pubid.to_s
222
- next if render.empty? || seen.key?(render)
223
-
224
- seen[render] = true
225
- push_derived_row(render, file, alias_pubid.to_hash, key_string(alias_pubid))
226
- end
227
- end
228
-
229
- # The committed id hash for `file`, when it adds only components in
230
- # REFINABLE_KEYS to the parse; else `hash`. Those are the components a
231
- # record's printed docid omits by design: an IHO record prints `B-12`
232
- # and keeps its edition in `edition:` (relaton-data-iho#20), and an
233
- # ECMA one prints `ECMA-262` with no ` ed<N>`. Every other key must be
234
- # equal, so the root number (the shard key) cannot change, and a part,
235
- # a co-publisher or a sub-registry the parse omits is never taken.
236
- # The parse still gives the rendered spellings, so a docid alias
237
- # `B-12` stays deduplicated against the canonical `IHO B-12`.
238
- def refine(hash, file)
239
- committed = @committed[file]
240
- return hash unless hash && committed.is_a?(Hash)
241
-
242
- extra = committed.keys - hash.keys
243
- return hash if extra.empty? || !(extra - REFINABLE_KEYS).empty?
244
-
245
- committed.slice(*hash.keys) == hash ? committed : hash
246
- end
247
-
248
- def push_derived_row(rendered, file, id_hash, key)
249
- row = Row.new(rendered, file)
250
- row.id_hash = id_hash
251
- row.key = key
252
- row.derived = true
253
- @rows << row
254
- end
255
-
256
- def count
257
- @rows.size
258
- end
259
-
260
- def key_strategy
261
- "root-number"
262
- end
263
-
264
- # Sized from the rows actually written, the same figure the manifest
265
- # reports as `count` — not from every row scanned.
266
- def shard_count
267
- written = indexed_rows.size
268
- return 0 if written < MIN_ROWS
269
-
270
- next_pow2((written.to_f / TARGET_ROWS).ceil).clamp(MIN_SHARDS, MAX_SHARDS)
271
- end
272
-
273
- def key_of(row)
274
- row.key
275
- end
276
-
277
- def manifest(generated:)
278
- {
279
- "version" => 2,
280
- # The monolith's base name, so a client can fetch
281
- # "#{index}.zip" without knowing the flavor's INDEXFILE.
282
- "index" => @index_name,
283
- # What the index actually contains, not what was scanned: rows
284
- # whose id could not be resolved at all are dropped.
285
- "count" => indexed_rows.size,
286
- "shards" => shard_count,
287
- "key" => key_strategy,
288
- "algorithm" => "crc32",
289
- "generated" => generated,
290
- }
291
- end
292
-
293
- # { "r" => rendered, "file" => path, "id" => pubid hash }. The same
294
- # "id" the monolith carries for that row — the two artifacts must not
295
- # describe one document differently.
296
- def row_record(row)
297
- { "r" => row.rendered, "file" => row.file, "id" => row.id_hash }
298
- end
299
-
300
- # The rows actually written, in deterministic (key, rendered) order.
301
- #
302
- # Carries only rows whose id resolved: the consumer
303
- # (`Relaton::Index::FileIO#deserialize_id`) calls `from_hash` on every
304
- # row and raises `InvalidIndexError` on the first one it cannot
305
- # deserialize, which rejects the *whole* index — so a single unresolved
306
- # row written as a plain string would poison the file. Five shipping
307
- # corpora carry a handful of ids that do not parse back from the
308
- # rendered string (ieee 69, itu-r 47, iec 42, itu 3, nist 3), so this is
309
- # reachable, not theoretical; `add` rescues those from the committed
310
- # index, and only what that misses is dropped.
311
- # `Relaton::Ieee::DataFetcher#build_index` already skips the same rows
312
- # for the same reason and logs the loss; this matches it.
313
- #
314
- # A derived (alias) row is dropped when a document owns the spelling
315
- # as its own primary id, or when an earlier alias row claimed it:
316
- # `Type#add_or_update` keys id_lookup on the rendered string, so
317
- # letting both publish would silently repoint the spelling at
318
- # whichever file sorted last. The document's primary id — and, when
319
- # two derived rows survive, the earlier document — wins.
320
- #
321
- # Memoized, and the single place the sort happens — `each_shard` and
322
- # `write_monolith` previously sorted the corpus independently, so both
323
- # ran on every build.
324
- def indexed_rows
325
- @indexed_rows ||= begin
326
- owned = {}
327
- @rows.each { |row| owned[row.rendered] = true unless row.derived }
328
- claimed = {}
329
- @rows.reject { |row| row.id_hash.nil? }
330
- .reject do |row|
331
- next false unless row.derived
332
-
333
- next true if owned.key?(row.rendered)
334
-
335
- if claimed.key?(row.rendered)
336
- true
337
- else
338
- claimed[row.rendered] = true
339
- false
340
- end
341
- end
342
- .sort_by { |row| [row.key, row.rendered] }
343
- end
344
- end
345
-
346
- # Rows dropped by `indexed_rows` — reported so the loss is never silent.
347
- def skipped_count
348
- count - indexed_rows.size
349
- end
350
-
351
- # Rows bucketed by shard, in deterministic (key, rendered) order;
352
- # empty buckets are absent — a client treats a 404 as not-found.
353
- def each_shard
354
- n = shard_count
355
- return enum_for(:each_shard) unless block_given? && n.positive?
356
-
357
- buckets = Array.new(n) { [] }
358
- indexed_rows.each { |row| buckets[Zlib.crc32(row.key) % n] << row_record(row) }
359
- buckets.each_with_index { |rows, i| yield(format("%05d", i), rows) unless rows.empty? }
360
- end
361
-
362
- def monolith_filename
363
- "#{@index_name}.yaml"
364
- end
365
-
366
- # Same shape Relaton::Index::FileIO#save emits: an Array of
367
- # {id:, file:} hashes, id always a pubid to_hash. Streamed one row at a
368
- # time — building the full array and calling to_yaml is
369
- # pathological on six-figure corpora (Psych re-allocates on
370
- # every nested hash, and the whole array sits in memory).
371
- # Hand-rolls the YAML instead of calling to_yaml per row:
372
- # Psych's per-invocation overhead on 177k rows takes ~40 min.
373
- # The shapes are flat (string id) or one-level-nested (pubid
374
- # to_hash), so hand-rendering is straightforward and the output
375
- # is indistinguishable from what Psych produces.
376
- def write_monolith(path)
377
- File.open(path, "w:utf-8") do |f|
378
- f << "---\n"
379
- indexed_rows.each do |row|
380
- f << "- :id:\n"
381
- yaml_nested(f, row.id_hash, " ")
382
- f << " :file: #{yaml_scalar(row.file)}\n"
383
- end
384
- end
385
- end
386
-
387
- # An Array is written as a JSON flow sequence, which is valid YAML and
388
- # escapes every element unambiguously. It must not reach `yaml_scalar`:
389
- # there `["IEC"].to_s` starts with "[", so it was quoted into the String
390
- # "[\"IEC\"]" — every ISO/IEC copublished id (`copublishers`) — and
391
- # `from_hash` cannot cast that, so `FileIO` rejects the whole index.
392
- def yaml_nested(f, hash, indent)
393
- hash.each do |k, v|
394
- case v
395
- when Hash
396
- f << "#{indent}#{k}:\n"
397
- yaml_nested(f, v, indent + " ")
398
- when Array
399
- f << "#{indent}#{k}: #{JSON.generate(v)}\n"
400
- else
401
- f << "#{indent}#{k}: #{yaml_scalar(v)}\n"
402
- end
403
- end
404
- end
405
-
406
- # YAML 1.1 plain scalars Psych resolves to true/false/nil. Emitted bare,
407
- # an id or path with one of these literal values changes *type* on the
408
- # round trip ("yes" -> true, "null" -> nil).
409
- YAML11_PLAIN = /\A(?:y|Y|yes|Yes|YES|n|N|no|No|NO|true|True|TRUE|
410
- false|False|FALSE|on|On|ON|off|Off|OFF|
411
- null|Null|NULL|~)\z/x
412
-
413
- def yaml_scalar(value)
414
- # Booleans and numbers are emitted bare, so they keep their type.
415
- # Quoting them is what the String branch below exists to prevent,
416
- # inverted: a pubid `to_hash` carrying a real `true` (CIE's
417
- # `d_prefix`, 31 of its 1139 rows) came back as the String "true", and
418
- # the published row no longer matched the repo's own index.
419
- return "" if value.nil?
420
- return value.to_s if value == true || value == false || value.is_a?(Integer)
421
-
422
- s = value.to_s
423
- # Quote if the value could be ambiguous: leading special char, an
424
- # embedded ": " or " #", numeric-looking, *trailing* whitespace (the
425
- # guard here used to read /\z\s/, which can never match — nothing
426
- # follows end-of-string), a YAML 1.1 plain word, or a C0 control
427
- # character. The control case is the severe one: emitted raw it makes
428
- # the whole file unparseable, and one bad row rejects the entire index.
429
- if s.empty? ||
430
- s.match?(/\A[-?:,\[\]{}#&*!|>'"%@`\s]|:\s|\s#|\A\d|\s\z/) ||
431
- s.match?(YAML11_PLAIN) || s.match?(/[[:cntrl:]]/)
432
- s.inspect
433
- else
434
- s
435
- end
436
- end
437
-
438
-
439
-
440
- private
441
-
442
- def next_pow2(value)
443
- bit = 0
444
- bit += 1 while (1 << bit) < value
445
- 1 << bit
446
- end
447
-
448
- def parse(rendered)
449
- @pubid_class.parse(rendered)
450
- rescue StandardError
451
- nil
452
- end
453
-
454
- # Only a structured hash: a legacy index under the same name carries
455
- # plain strings, and one written into a row would break the monolith
456
- # (`yaml_nested` walks a Hash) and the consumer alike.
457
- def from_hash(hash)
458
- hash.is_a?(Hash) && @pubid_class.from_hash(hash) || nil
459
- rescue StandardError
460
- nil
461
- end
462
-
463
- # The narrowing key `Relaton::Index` sorts and bsearches on. An
464
- # identifier with no root number gives "", so those rows cluster in
465
- # shard 0 — the same degeneracy the gem's bsearch already has, and the
466
- # only shape a client can reproduce without knowing our fallbacks.
467
- def key_string(pubid)
468
- pubid.root.number.to_s
469
- end
470
- end
471
-
472
- # @param data_dir [String]
473
- # @param options [Hash] :output :title :description :favicon :base_url
474
- # :overwrite :lang :generated :static :shard_size :detail_shard_size
475
- # :detail
476
- def self.generate(data_dir, options = {})
477
- new(data_dir, options).generate
478
- end
479
-
480
- def initialize(data_dir, options = {})
481
- @data_dir = data_dir
482
- @options = options
483
- @output = options[:output] || "_site"
484
- @lang = options[:lang] || "en"
485
- @overwrite = options.fetch(:overwrite, true)
486
- @base_url = options[:base_url]
487
- @title = presence(options[:title]) || "Relaton Index"
488
- @description = presence(options[:description])
489
- @favicon = presence(options[:favicon])
490
- @generated = options.fetch(:generated) { Time.now.utc.strftime("%Y-%m-%d") }
491
- @include_static = options.fetch(:static, true)
492
- @shard_size = options.fetch(:shard_size, 5000)
493
- @detail_shard_size = options.fetch(:detail_shard_size, 500)
494
- @emit_detail = options.fetch(:detail, true)
495
- @emit_index = options.fetch(:machine_index, true)
496
- @publish_data = options.fetch(:publish_data, false)
497
- @pubid_class = pubid_class_for(options[:flavor])
498
- @index_name = index_name_for(options[:flavor])
499
- validate!
500
- end
501
-
502
- # @return [String] path to the written index.html
503
- def generate
504
- # Read the bundle first: a missing frontend build must fail before any
505
- # output is written, not halfway through a corpus.
506
- assets = { "css" => FrontendAssets.stylesheet, "iife" => FrontendAssets.iife }
507
-
508
- FileUtils.mkdir_p(output)
509
- purge_stale!
510
- counts = write_shards
511
- counts[:index_shards] = write_machine_index_manifest(counts[:total]) if emit_index?
512
- publish_data! if @publish_data
513
-
514
- index_path = File.join(output, "index.html")
515
- write_file(index_path, render(assets, counts))
516
- copy_404_fallback
517
- Util.info "Indexed #{counts[:total]} document(s) from #{sources_description} " \
518
- "into #{counts[:shards]} search shard(s), " \
519
- "#{counts[:detail_shards]} detail shard(s) and " \
520
- "#{counts[:index_shards]} machine-index shard(s)"
521
- index_path
522
- end
523
-
524
- private
525
-
526
- attr_reader :data_dir, :options, :output, :lang, :overwrite, :base_url,
527
- :title, :description, :favicon, :generated, :include_static,
528
- :shard_size, :detail_shard_size
529
-
530
- def emit_detail?
531
- @emit_detail
532
- end
533
-
534
- # `flavor` names the flavor ("iso", "iho", ...) whose pubid Identifier
535
- # parses docids and whose `INDEXFILE` names the published monolith.
536
- # Resolved from the two gems' own namespaces — the alias table carries
537
- # only the names that do not follow the capitalize rule.
538
- def pubid_class_for(flavor)
539
- return nil unless flavor_key(flavor)
540
-
541
- name = namespace_names(flavor).first
542
- ::Pubid.const_get(name).const_get(:Identifier)
543
- rescue NameError => e
544
- raise unless probed_constant?(e, name, :Identifier)
545
-
546
- raise ArgumentError, "unknown pubid flavor: #{flavor}"
547
- end
548
-
549
- # The monolith's base name. Derived from the flavor's own `INDEXFILE`
550
- # so the site publishes the file that flavor's consumer already fetches
551
- # — the version there encodes the index *structure*, per flavor, and is
552
- # not a global generation counter. `index_name:` overrides it for a
553
- # corpus that is not a relaton flavor.
554
- def index_name_for(flavor)
555
- override = presence(options[:index_name])
556
- return override if override
557
- return nil unless flavor_key(flavor)
558
-
559
- name = namespace_names(flavor).last
560
- ::Relaton.const_get(name).const_get(:INDEXFILE)
561
- rescue NameError => e
562
- raise unless probed_constant?(e, name, :INDEXFILE)
563
-
564
- raise ArgumentError,
565
- "no relaton flavor for `#{flavor}`; pass index_name to name the index"
566
- end
567
-
568
- # True only when the NameError is about the constant we looked up. Looking
569
- # it up runs the flavor's autoload, and a genuine NameError from inside
570
- # that file must surface as itself — relabelled "unknown flavor", it
571
- # would send whoever reads a red deploy to --index-name instead of the bug.
572
- def probed_constant?(error, *names)
573
- names.map(&:to_s).include?(error.name.to_s)
574
- end
575
-
576
- def flavor_key(flavor)
577
- key = flavor.to_s.strip.downcase
578
- key unless key.empty?
579
- end
580
-
581
- # [pubid namespace, relaton namespace] for a flavor token. They agree for
582
- # every flavor but 3GPP, whose pubid namespace is Tgpp.
583
- def namespace_names(flavor)
584
- key = flavor_key(flavor)
585
- FLAVOR_NAMESPACES.fetch(key) do
586
- name = key.split(/[_-]/).map(&:capitalize).join
587
- [name, name]
588
- end
589
- end
590
-
591
- def emit_index?
592
- @emit_index
593
- end
594
-
595
- # The data folder's parent — the repo root the index rows' `file` paths
596
- # and the committed index are relative to.
597
- def repo_root
598
- @repo_root ||= File.dirname(File.expand_path(data_dir))
599
- end
600
-
601
- # `{ file => id hash }` from the repo's own committed index, the
602
- # authority for a docid the parser cannot read back from its rendered
603
- # form. Empty when the repo publishes no index (a fresh repo, or one
604
- # whose index this build is the first to produce).
605
- def committed_index
606
- return @committed_index if defined?(@committed_index)
607
-
608
- path = File.join(repo_root, "#{@index_name}.yaml")
609
- @committed_index = File.exist?(path) ? read_committed_index(path) : {}
610
- rescue StandardError => e
611
- Util.warn "Ignoring #{path}: #{e.message}"
612
- @committed_index = {}
613
- end
614
-
615
- # Keeps only rows whose `:id` is a structured hash. A legacy index under
616
- # the same name carries plain strings, and one of those written into a
617
- # row would break both the monolith (`yaml_nested` walks a Hash) and the
618
- # consumer (`FileIO#deserialize_id` calls `from_hash`).
619
- #
620
- # The first id per file becomes the map; ids beyond the first are the
621
- # fetcher's aliases onto that file (see .committed_alias_rows).
622
- def read_committed_index(path)
623
- rows = YAML.safe_load(File.read(path), permitted_classes: [Symbol])
624
- @committed_rows = Array(rows).select do |row|
625
- row.is_a?(Hash) && row[:id].is_a?(Hash) && row[:file]
626
- end
627
- @committed_rows.each_with_object({}) do |row, acc|
628
- acc[row[:file]] ||= row[:id]
629
- end
630
- end
631
-
632
- # `{ id hash => file }` for committed rows beyond each file's first --
633
- # the fetcher's alias ids, which no data file of their own carries.
634
- # Without this pass the machine index drops them (the file-keyed
635
- # fallback resolves one id per file), and a spelling the corpus
636
- # aliased stops resolving.
637
- def committed_alias_rows
638
- committed_index # force the load and @committed_rows
639
- seen = {}
640
- (@committed_rows || []).filter_map do |row|
641
- if seen[row[:file]]
642
- [row[:id], row[:file]]
643
- else
644
- seen[row[:file]] = true
645
- nil
646
- end
647
- end
648
- end
649
-
650
- # nil for a nil/blank option value. A caller workflow that forwards an
651
- # unset input renders it as an empty string (`--favicon ""`), which must
652
- # mean "not set" — an empty href in <link rel="icon"> resolves to the page
653
- # itself, and an empty <meta name="description"> is worse than none.
654
- def presence(value)
655
- str = value.to_s.strip
656
- str unless str.empty?
657
- end
658
-
659
- def validate!
660
- if options.key?(:mode)
661
- raise ArgumentError,
662
- "The `mode` option was removed: `relaton index` now always emits " \
663
- "sharded JSON that the page fetches. Drop --mode from the call."
664
- end
665
- unless File.directory?(data_dir)
666
- raise ArgumentError, "Data directory not found: #{data_dir}"
667
- end
668
- { shard_size: shard_size, detail_shard_size: detail_shard_size }
669
- .each do |name, value|
670
- next if value.is_a?(Integer) && value.positive?
671
-
672
- raise ArgumentError, "#{name} must be a positive integer (got #{value.inspect})"
673
- end
674
-
675
- # Every data repo publishes a pubid index, so a machine index without
676
- # a parser could only carry plain-string ids — a shape no consumer
677
- # narrows on and this generator no longer writes.
678
- return unless emit_index?
679
-
680
- if @pubid_class.nil?
681
- raise ArgumentError,
682
- "--pubid-flavor is required to build a machine index; " \
683
- "pass --no-machine-index to build the human site only"
684
- end
685
- return if @index_name.match?(INDEX_NAME)
686
-
687
- raise ArgumentError,
688
- "index name must be a single file name such as index-v2 (got #{@index_name.inspect})"
689
- end
690
-
691
- # Human-readable description of the folders scanned, for the info log.
692
- # Keeps data_dir as given (no absolute-path noise) and only notes when a
693
- # sibling static/ was folded in.
694
- def sources_description
695
- repo_root = self.repo_root
696
- static_source_dir(repo_root) ? "#{data_dir} (+ #{STATIC_DIRNAME}/)" : data_dir
697
- end
698
-
699
- # ---- streaming ----------------------------------------------------------
700
-
701
- # The machine-index input: rendered primary docid + repo-relative
702
- # path (clients prefix their own baseurl onto file).
703
- def machine_record(item)
704
- [item["id"], item["yaml_path"],
705
- Array(item["docids"]).filter_map { |d| d["id"] if d.is_a?(Hash) }]
706
- end
707
-
708
- def write_machine_index_manifest(total)
709
- machine = @machine_index or return 0
710
-
711
- FileUtils.mkdir_p(File.join(output, "index"))
712
- machine.each_shard do |number, rows|
713
- write_file(File.join(output, "index", "shard-#{number}.json"), JSON.generate(rows))
714
- end
715
-
716
- monolith = File.join(output, machine.monolith_filename)
717
- machine.write_monolith(monolith)
718
- zip_path = zip_file(monolith)
719
-
720
- # Content address of the published whole-index artifact: a consumer
721
- # pins `sha256` from this manifest and can verify the zip it fetched.
722
- # `bytes` is the same artifact's size (progress and cache accounting).
723
- artifact = {
724
- "sha256" => Digest::SHA256.file(zip_path).hexdigest,
725
- "bytes" => File.size(zip_path),
726
- }
727
-
728
- write_file(
729
- File.join(output, "index", "manifest.json"),
730
- JSON.pretty_generate(
731
- machine.manifest(generated: @generated).merge(artifact),
732
- ),
733
- )
734
- if machine.skipped_count.positive?
735
- Util.warn "Machine index: skipped #{machine.skipped_count} of " \
736
- "#{machine.count} document(s) whose id could not be parsed " \
737
- "(a structured index cannot carry them)."
738
- end
739
- machine.shard_count
740
- end
741
-
742
- # Zip sibling of a monolith — the form `Relaton::Index url:`
743
- # fetches today. The zip stores the yaml at its bare basename,
744
- # matching the layout data repos publish in git. Returns the zip path.
745
- def zip_file(yaml_path)
746
- zip_path = yaml_path.sub(/\.yaml\z/, ".zip")
747
- File.delete(zip_path) if File.exist?(zip_path)
748
- Zip::File.open(zip_path, create: true) do |archive|
749
- archive.add(File.basename(yaml_path), yaml_path)
750
- end
751
- zip_path
752
- end
753
-
754
- # Copy the scanned corpus onto the site so clients can fetch
755
- # documents (the index rows' `file` paths) from the same origin.
756
- # Opt-in: for most repos raw.githubusercontent already serves the
757
- # committed data, and duplicating a large corpus would double the
758
- # published-site size against the 1 GB cap.
759
- def publish_data!
760
- repo_root = self.repo_root
761
- source_dirs(repo_root).each do |dir|
762
- Dir.glob(File.join(dir, "**", "*.{yaml,yml}")).sort.each do |src|
763
- # Relative to the data dir itself, so the copy lives at
764
- # output/data/<name>.yaml matching the index rows' file paths.
765
- rel = Pathname.new(File.expand_path(src))
766
- .relative_path_from(Pathname.new(dir)).to_s
767
- dest = File.join(output, "data", rel)
768
- FileUtils.mkdir_p(File.dirname(dest))
769
- FileUtils.cp(src, dest)
770
- end
771
- end
772
- end
773
-
774
- # One pass over the corpus, fanning each document out to both shard
775
- # families. The search/detail families accumulate nothing — peak memory is
776
- # one shard of each. The machine index is the exception: shard assignment
777
- # needs the corpus size, which is not knowable until the pass ends, so
778
- # `MachineIndex` retains one `Row` per document (measured ~0.44 KB/row — 76 MB at 177k
779
- # rows; see the note on `Row`, which is why it does not hold the pubid).
780
- def write_shards
781
- writer = method(:write_file)
782
- summary = ShardWriter.new(output, SHARD_PATTERN, shard_size, &writer)
783
- detail = ShardWriter.new(output, DETAIL_PATTERN, detail_shard_size, &writer)
784
- if emit_index?
785
- machine = MachineIndex.new(pubid_class: @pubid_class,
786
- index_name: @index_name,
787
- committed: committed_index)
788
- end
789
- total = 0
790
-
791
- each_document do |doc|
792
- total += 1
793
- summary << compact_record(doc)
794
- detail << detail_record(doc) if emit_detail?
795
- machine&.add(*machine_record(doc))
796
- end
797
- machine&.add_alias_rows(committed_alias_rows)
798
- summary.flush
799
- detail.flush
800
- @machine_index = machine
801
-
802
- { total: total, shards: summary.count, detail_shards: detail.count }
803
- end
804
-
805
- # Yield each index item in corpus order. De-dup is **cross-dir only**: an id
806
- # already indexed from an *earlier* dir (i.e. static/ duplicating a data/
807
- # doc) is skipped, so data/ wins — but duplicates *within* a single dir are
808
- # left as-is, preserving the pre-static behavior of the data scan.
809
- def each_document
810
- return to_enum(:each_document) unless block_given?
811
-
812
- repo_root = self.repo_root
813
- seen = {}
814
- source_dirs(repo_root).each do |dir|
815
- dir_ids = {}
816
- Dir.glob(File.join(dir, "**", "*.{yaml,yml}")).sort.each do |file|
817
- item = index_file(file, repo_root)
818
- next unless item
819
-
820
- id = dedup_key(item)
821
- if id && seen.key?(id)
822
- Util.warn "Skipping #{item['yaml']} (duplicate id #{id}); " \
823
- "already indexed from #{seen[id]}"
824
- next
825
- end
826
- dir_ids[id] ||= item["yaml"] if id
827
- # The machine index needs the repo-relative path: clients
828
- # prefix their own baseurl onto row[:file], so an absolutized
829
- # ref (yaml_ref with --base-url) would double-prefix.
830
- item["yaml_path"] = relative_path(file, repo_root)
831
- yield item
832
- end
833
- seen.merge!(dir_ids)
834
- end
835
- end
836
-
837
- # The compact summary record the search shards carry.
838
- def compact_record(item)
839
- COMPACT_KEYS.each_with_object({}) { |(key, short), acc| acc[short] = item[key] }
840
- end
841
-
842
- # The complement: everything the summary record doesn't carry, tagged with
843
- # the id so the frontend can verify a positional lookup landed on the right
844
- # record. nil when the document has no detail fields at all — the slot is
845
- # still written (as null) so position stays aligned with the summary shards.
846
- def detail_record(item)
847
- # "yaml_path" is transport for the machine index, not a detail field.
848
- rest = item.reject { |key, _| COMPACT_KEYS.key?(key) || key == "yaml_path" }
849
- return nil if rest.empty?
850
-
851
- { "r" => item["id"] }.merge(rest)
852
- end
853
-
854
- # The data folder, plus an auto-detected sibling static/ folder (its bib
855
- # docs are part of the corpus). Data is scanned first so it wins on a
856
- # cross-dir duplicate id. Enabled by default; --no-static opts out.
857
- def source_dirs(repo_root)
858
- [data_dir, static_source_dir(repo_root)].compact
859
- end
860
-
861
- # The sibling static/ dir to fold in, or nil when disabled, absent, or the
862
- # same folder as data_dir (guards `relaton index static` double-scanning).
863
- def static_source_dir(repo_root)
864
- return nil unless include_static
865
-
866
- static = File.join(repo_root, STATIC_DIRNAME)
867
- return nil unless File.directory?(static)
868
- return nil if File.expand_path(static) == File.expand_path(data_dir)
869
-
870
- static
871
- end
872
-
873
- # The id we de-dup on, or nil when the doc has no usable id (a blank/empty
874
- # id must NOT collapse distinct docid-less documents together).
875
- def dedup_key(item)
876
- id = item["id"]
877
- id unless id.nil? || id.empty?
878
- end
879
-
880
- # Normalize one YAML file to an index item, or nil if it's a machine index,
881
- # not a document, or unparseable.
882
- def index_file(file, repo_root)
883
- return if File.basename(file).match?(SKIP_BASENAMES)
884
-
885
- doc = load_yaml(file)
886
- return unless document?(doc)
887
-
888
- rel = relative_path(file, repo_root)
889
- IndexItemNormalizer.normalize(doc, lang: lang, yaml_ref: yaml_ref(rel))
890
- rescue Psych::SyntaxError => e
891
- Util.warn "Skipping #{file}: #{e.message}"
892
- nil
893
- end
894
-
895
- def load_yaml(file)
896
- content = File.read(file, encoding: "utf-8")
897
- begin
898
- YAML.safe_load(content, permitted_classes: [Date, Time], aliases: true)
899
- rescue ArgumentError
900
- # older Psych positional signature
901
- YAML.safe_load(content, [Date, Time], [], true)
902
- end
903
- end
904
-
905
- # A bib document (not a collection/index) — has an id/docid/title, no "root".
906
- def document?(doc)
907
- return false unless doc.is_a?(Hash)
908
- return false if doc.key?("root")
909
-
910
- doc.key?("id") || doc.key?("docidentifier") || doc.key?("title")
911
- end
912
-
913
- def relative_path(file, repo_root)
914
- Pathname.new(File.expand_path(file))
915
- .relative_path_from(Pathname.new(repo_root)).to_s
916
- end
917
-
918
- def yaml_ref(rel)
919
- return rel if base_url.nil? || base_url.empty?
920
-
921
- "#{base_url.chomp('/')}/#{rel}"
922
- end
923
-
924
- # ---- rendering ----------------------------------------------------------
925
-
926
- def render(assets, counts)
927
- env = Liquid::Environment.build(
928
- file_system: Liquid::LocalFileSystem.new(TEMPLATE_DIR),
929
- )
930
- template = Liquid::Template.parse(
931
- File.read(File.join(TEMPLATE_DIR, "page.liquid"), encoding: "utf-8"),
932
- environment: env,
933
- )
934
- template.render!(
935
- "title" => title,
936
- "description" => description,
937
- "favicon" => favicon,
938
- "favicon_type" => favicon_type,
939
- "css" => assets["css"],
940
- "iife" => assets["iife"],
941
- "generated" => generated,
942
- "total" => counts[:total],
943
- "shards" => counts[:shards],
944
- "shard_size" => shard_size,
945
- "detail_shards" => counts[:detail_shards],
946
- "detail_shard_size" => detail_shard_size,
947
- )
948
- end
949
-
950
- # The <link rel="icon"> type for the configured favicon, or nil when there
951
- # is none or its extension isn't a known image type. The href is passed
952
- # through verbatim (absolute URL or output-relative path alike), so any
953
- # ?query/#fragment is stripped before looking at the extension.
954
- def favicon_type
955
- return nil unless favicon
956
-
957
- FAVICON_TYPES[File.extname(favicon.split(/[?#]/, 2).first.to_s).downcase]
958
- end
959
-
960
- # Drop shards from a previous build. Without a manifest, an orphan shard
961
- # left by a larger corpus is invisible — nothing points at it — so it would
962
- # sit in the deployed site indefinitely.
963
- # Path-based document URLs (/doc/<id>) are served by this fallback:
964
- # GitHub Pages has no rewrites, so unknown paths resolve to 404.html,
965
- # whose script forwards the id into the app as ?doc=<id>.
966
- def copy_404_fallback
967
- fallback = File.join(FrontendAssets.dist_dir, "404.html")
968
- return unless File.exist?(fallback)
969
-
970
- write_file(File.join(output, "404.html"), File.read(fallback))
971
- end
972
-
973
- def purge_stale!
974
- return unless overwrite
975
-
976
- # Read before the glob deletes it. `--index-name` accepts a name
977
- # `index-v*` does not match, so the previous build's own manifest is
978
- # the only record of which monolith it wrote.
979
- previous = previous_index_name
980
- # `base:` rather than interpolating `output` into the pattern: an output
981
- # path containing glob metacharacters (`[`, `{`, `*`, `?`, …) would
982
- # otherwise match nothing, and the stale shards would survive silently.
983
- paths = Dir.glob(STALE_GLOBS, base: output).map { |name| File.join(output, name) }
984
- paths += %w[yaml zip].map { |ext| File.join(output, "#{previous}.#{ext}") } if previous
985
- paths.uniq.each { |path| File.delete(path) if File.file?(path) }
986
- end
987
-
988
- # The monolith name a previous build recorded, if it is a safe basename.
989
- def previous_index_name
990
- path = File.join(output, "index", "manifest.json")
991
- return unless File.file?(path)
992
-
993
- name = JSON.parse(File.read(path))["index"]
994
- name if name.is_a?(String) && name.match?(INDEX_NAME)
995
- rescue JSON::ParserError
996
- nil
997
- end
998
-
999
- def write_file(path, content)
1000
- if File.exist?(path) && !overwrite
1001
- Util.warn "Skipping existing #{path} (use --overwrite)"
1002
- return
1003
- end
1004
- File.write(path, content, encoding: "utf-8")
1005
- end
1006
- end
1007
- end
1008
- end