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