relaton 3.0.6 → 3.0.8

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