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,319 @@
1
+ // @vitest-environment happy-dom
2
+ import { afterEach, describe, expect, it, vi } from "vitest";
3
+ import {
4
+ createDetailLoader,
5
+ loadSearchShards,
6
+ shardPath,
7
+ } from "./shards";
8
+ import type { CompactRecord, DetailRecord, ShardInfo } from "./types";
9
+ import { deferred, stubFetch } from "../test/fetch";
10
+
11
+ afterEach(() => {
12
+ vi.unstubAllGlobals();
13
+ });
14
+
15
+ function info(over: Partial<ShardInfo> = {}): ShardInfo {
16
+ return {
17
+ total: 0,
18
+ shards: 0,
19
+ shardSize: 3,
20
+ detailShards: 0,
21
+ detailShardSize: 2,
22
+ ...over,
23
+ };
24
+ }
25
+
26
+ function rows(...ids: string[]): CompactRecord[] {
27
+ return ids.map((id) => ({ r: id, c: `Title ${id}` }));
28
+ }
29
+
30
+ describe("shardPath", () => {
31
+ it("zero-pads to four digits, matching the generator", () => {
32
+ expect(shardPath("search", 0)).toBe("search-0000.json");
33
+ expect(shardPath("search", 7)).toBe("search-0007.json");
34
+ expect(shardPath("detail", 1234)).toBe("detail-1234.json");
35
+ });
36
+ });
37
+
38
+ describe("loadSearchShards", () => {
39
+ it("does nothing when there are no shards", async () => {
40
+ const onBatch = vi.fn();
41
+ await loadSearchShards(info({ shards: 0 }), onBatch);
42
+ expect(onBatch).not.toHaveBeenCalled();
43
+ });
44
+
45
+ it("maps compact records onto documents", async () => {
46
+ stubFetch({
47
+ "search-0000.json": [
48
+ { r: "A", c: "Title A", t: "standard", s: "published", d: "2020-01-01", u: "a.yaml", l: "https://x/a" },
49
+ ],
50
+ });
51
+ const batches: string[][] = [];
52
+ await loadSearchShards(info({ shards: 1 }), (docs) =>
53
+ batches.push(docs.map((d) => d.id)),
54
+ );
55
+ expect(batches[batches.length - 1]).toEqual(["A"]);
56
+ });
57
+
58
+ // The detail shards are addressed by corpus position, so a shard that arrives
59
+ // early must not jump the queue.
60
+ it("appends in shard order even when responses resolve out of order", async () => {
61
+ const first = deferred<CompactRecord[]>();
62
+ stubFetch({
63
+ "search-0000.json": () => first.promise,
64
+ "search-0001.json": rows("B1", "B2"),
65
+ "search-0002.json": rows("C1"),
66
+ });
67
+
68
+ const seen: string[][] = [];
69
+ const done = loadSearchShards(
70
+ info({ shards: 3 }),
71
+ (docs) => seen.push(docs.map((d) => d.id)),
72
+ { flushMs: 0 },
73
+ );
74
+
75
+ // Let shards 1 and 2 settle while 0 is still outstanding.
76
+ await Promise.resolve();
77
+ await Promise.resolve();
78
+ first.resolve(rows("A1", "A2"));
79
+ await done;
80
+
81
+ expect(seen[seen.length - 1]).toEqual(["A1", "A2", "B1", "B2", "C1"]);
82
+ // Nothing was emitted before shard 0 landed.
83
+ expect(seen.every((batch) => batch.length === 0 || batch[0] === "A1")).toBe(true);
84
+ });
85
+
86
+ it("coalesces emissions instead of firing once per shard", async () => {
87
+ stubFetch({
88
+ "search-0000.json": rows("A"),
89
+ "search-0001.json": rows("B"),
90
+ "search-0002.json": rows("C"),
91
+ "search-0003.json": rows("D"),
92
+ });
93
+
94
+ const onBatch = vi.fn();
95
+ // A clock that never advances: the first batch still paints immediately, the
96
+ // two in the middle are suppressed, and the final forced emit carries them.
97
+ await loadSearchShards(info({ shards: 4 }), onBatch, {
98
+ flushMs: 10_000,
99
+ now: () => 1_000,
100
+ concurrency: 1,
101
+ });
102
+
103
+ expect(onBatch).toHaveBeenCalledTimes(2);
104
+ const ids = (call: number) =>
105
+ onBatch.mock.calls[call][0].map((d: { id: string }) => d.id);
106
+ expect(ids(0)).toEqual(["A"]);
107
+ expect(ids(1)).toEqual(["A", "B", "C", "D"]);
108
+ });
109
+
110
+ it("paints the first batch immediately regardless of the clock's epoch", async () => {
111
+ stubFetch({
112
+ "search-0000.json": rows("A"),
113
+ "search-0001.json": rows("B"),
114
+ });
115
+
116
+ const onBatch = vi.fn();
117
+ // A monotonic-style clock starting near zero would, with a naive lastEmit of
118
+ // 0, suppress the very first paint.
119
+ await loadSearchShards(info({ shards: 2 }), onBatch, {
120
+ flushMs: 750,
121
+ now: () => 5,
122
+ concurrency: 1,
123
+ });
124
+
125
+ expect(onBatch.mock.calls[0][0].map((d: { id: string }) => d.id)).toEqual(["A"]);
126
+ });
127
+
128
+ it("stamps each document with its corpus position", async () => {
129
+ stubFetch({
130
+ "search-0000.json": rows("A1", "A2", "A3"),
131
+ "search-0001.json": rows("B1", "B2"),
132
+ });
133
+
134
+ let docs: { id: string; pos?: number }[] = [];
135
+ await loadSearchShards(info({ shards: 2, shardSize: 3 }), (d) => {
136
+ docs = d;
137
+ });
138
+ // Shard 1 starts at 1 * shardSize, not at "however many arrived so far".
139
+ expect(docs.map((d) => d.pos)).toEqual([0, 1, 2, 3, 4]);
140
+ });
141
+
142
+ it("skips a failing shard without losing the rest", async () => {
143
+ stubFetch({
144
+ "search-0000.json": rows("A"),
145
+ "search-0001.json": 404,
146
+ "search-0002.json": rows("C"),
147
+ });
148
+
149
+ const batches: string[][] = [];
150
+ await loadSearchShards(
151
+ info({ shards: 3 }),
152
+ (docs) => batches.push(docs.map((d) => d.id)),
153
+ { flushMs: 0 },
154
+ );
155
+ expect(batches[batches.length - 1]).toEqual(["A", "C"]);
156
+ });
157
+
158
+ // The documents in a lost shard are gone, but their absence must not shift
159
+ // everyone else: positions are what address the detail shards, so a hole that
160
+ // renumbered the survivors would silently point every later document at the
161
+ // wrong detail shard — and the id check there can only scan the shard it was
162
+ // sent to, so it could never recover.
163
+ it("keeps corpus positions correct across a hole left by a failed shard", async () => {
164
+ stubFetch({
165
+ "search-0000.json": rows("A1", "A2"),
166
+ "search-0001.json": 404,
167
+ "search-0002.json": rows("C1", "C2"),
168
+ });
169
+
170
+ let docs: { id: string; pos?: number }[] = [];
171
+ await loadSearchShards(info({ shards: 3, shardSize: 2 }), (d) => {
172
+ docs = d;
173
+ });
174
+
175
+ expect(docs.map((d) => d.id)).toEqual(["A1", "A2", "C1", "C2"]);
176
+ // C1 is at array index 2 but corpus position 4.
177
+ expect(docs.map((d) => d.pos)).toEqual([0, 1, 4, 5]);
178
+ });
179
+
180
+ it("retries a shard once before giving up", async () => {
181
+ let attempts = 0;
182
+ stubFetch({
183
+ "search-0000.json": () => {
184
+ attempts++;
185
+ return attempts === 1 ? 503 : rows("A1", "A2");
186
+ },
187
+ });
188
+
189
+ let docs: { id: string }[] = [];
190
+ await loadSearchShards(info({ shards: 1, shardSize: 2 }), (d) => {
191
+ docs = d;
192
+ });
193
+
194
+ expect(attempts).toBe(2);
195
+ expect(docs.map((d) => d.id)).toEqual(["A1", "A2"]);
196
+ });
197
+
198
+ // A 200 whose body is an object rather than an array used to throw inside the
199
+ // worker, rejecting the whole load and skipping the final forced emit — so
200
+ // every throttled batch since the last emit was lost, plus an unhandled
201
+ // rejection.
202
+ it("survives a shard whose body is valid JSON but not an array", async () => {
203
+ stubFetch({
204
+ "search-0000.json": rows("A"),
205
+ "search-0001.json": { not: "an array" },
206
+ "search-0002.json": rows("C"),
207
+ });
208
+
209
+ const batches: string[][] = [];
210
+ await expect(
211
+ loadSearchShards(
212
+ info({ shards: 3 }),
213
+ (docs) => batches.push(docs.map((d) => d.id)),
214
+ { flushMs: 10_000, now: () => 1_000 },
215
+ ),
216
+ ).resolves.toBeUndefined();
217
+
218
+ expect(batches[batches.length - 1]).toEqual(["A", "C"]);
219
+ });
220
+
221
+ // A 404 on GitHub Pages returns an HTML error page; without the res.ok check
222
+ // that would throw inside res.json() and look like a network failure.
223
+ it("treats a non-OK response as an empty shard", async () => {
224
+ stubFetch({ "search-0000.json": 500 });
225
+ const batches: string[][] = [];
226
+ await loadSearchShards(info({ shards: 1 }), (docs) =>
227
+ batches.push(docs.map((d) => d.id)),
228
+ );
229
+ expect(batches[batches.length - 1]).toEqual([]);
230
+ });
231
+
232
+ it("requests each shard exactly once", async () => {
233
+ const stub = stubFetch({
234
+ "search-0000.json": rows("A"),
235
+ "search-0001.json": rows("B"),
236
+ });
237
+ await loadSearchShards(info({ shards: 2 }), () => {});
238
+ expect(stub.calls.sort()).toEqual(["search-0000.json", "search-0001.json"]);
239
+ });
240
+ });
241
+
242
+ describe("createDetailLoader", () => {
243
+ const detail = (id: string): DetailRecord => ({
244
+ r: id,
245
+ abstract: `About ${id}`,
246
+ });
247
+
248
+ const shardedInfo = info({ detailShards: 3, detailShardSize: 2 });
249
+
250
+ it("resolves a document by its corpus position", async () => {
251
+ stubFetch({ "detail-0001.json": [detail("C"), detail("D")] });
252
+ const load = createDetailLoader(shardedInfo);
253
+ // index 3 -> shard 1, slot 1
254
+ await expect(load(3, "D")).resolves.toEqual(detail("D"));
255
+ });
256
+
257
+ it("returns null when detail shards were not built", async () => {
258
+ const load = createDetailLoader(info({ detailShards: 0 }));
259
+ await expect(load(0, "A")).resolves.toBeNull();
260
+ });
261
+
262
+ it("returns null for a position beyond the last shard", async () => {
263
+ const load = createDetailLoader(shardedInfo);
264
+ await expect(load(999, "Z")).resolves.toBeNull();
265
+ });
266
+
267
+ it("returns null for a null slot (a document with no detail fields)", async () => {
268
+ stubFetch({ "detail-0000.json": [null, null] });
269
+ const load = createDetailLoader(shardedInfo);
270
+ await expect(load(0, "A")).resolves.toBeNull();
271
+ });
272
+
273
+ // Position is derived, not stored, so it has to be checked. Showing another
274
+ // document's abstract would be worse than showing none.
275
+ it("falls back to an id scan when the slot holds a different document", async () => {
276
+ stubFetch({ "detail-0000.json": [detail("WRONG"), detail("A")] });
277
+ const load = createDetailLoader(shardedInfo);
278
+ await expect(load(0, "A")).resolves.toEqual(detail("A"));
279
+ });
280
+
281
+ it("returns null when the id is nowhere in the shard", async () => {
282
+ stubFetch({ "detail-0000.json": [detail("X"), detail("Y")] });
283
+ const load = createDetailLoader(shardedInfo);
284
+ await expect(load(0, "A")).resolves.toBeNull();
285
+ });
286
+
287
+ it("fetches a shard once and serves later hits from cache", async () => {
288
+ const stub = stubFetch({ "detail-0000.json": [detail("A"), detail("B")] });
289
+ const load = createDetailLoader(shardedInfo);
290
+
291
+ await load(0, "A");
292
+ await load(1, "B");
293
+
294
+ expect(stub.calls).toEqual(["detail-0000.json"]);
295
+ });
296
+
297
+ it("shares one request between concurrent opens in the same shard", async () => {
298
+ const stub = stubFetch({ "detail-0000.json": [detail("A"), detail("B")] });
299
+ const load = createDetailLoader(shardedInfo);
300
+
301
+ const [a, b] = await Promise.all([load(0, "A"), load(1, "B")]);
302
+
303
+ expect(stub.calls).toEqual(["detail-0000.json"]);
304
+ expect(a).toEqual(detail("A"));
305
+ expect(b).toEqual(detail("B"));
306
+ });
307
+
308
+ it("returns null when the shard request fails", async () => {
309
+ stubFetch({ "detail-0000.json": 404 });
310
+ const load = createDetailLoader(shardedInfo);
311
+ await expect(load(0, "A")).resolves.toBeNull();
312
+ });
313
+
314
+ it("returns null when the shard body is not an array", async () => {
315
+ stubFetch({ "detail-0000.json": { oops: true } });
316
+ const load = createDetailLoader(shardedInfo);
317
+ await expect(load(0, "A")).resolves.toBeNull();
318
+ });
319
+ });
@@ -0,0 +1,183 @@
1
+ import type {
2
+ CompactRecord,
3
+ DetailRecord,
4
+ IndexDocument,
5
+ ShardInfo,
6
+ } from "./types";
7
+
8
+ // Fetching the numbered JSON shards the generator writes beside index.html.
9
+ //
10
+ // Summary shards are loaded progressively so the page is usable long before the
11
+ // whole corpus has arrived; detail shards are fetched one at a time, only when a
12
+ // document panel opens.
13
+
14
+ /** `search-0007.json` — zero-padded to four digits, matching the generator. */
15
+ export function shardPath(prefix: string, index: number): string {
16
+ return `${prefix}-${String(index).padStart(4, "0")}.json`;
17
+ }
18
+
19
+ // A shard that 404s or contains garbage resolves to null rather than throwing,
20
+ // so one bad file can't take down the whole load. Three things are checked:
21
+ // `res.ok`, because a 404 on GitHub Pages returns an HTML error page that would
22
+ // otherwise blow up in res.json(); the parse itself; and that the result is
23
+ // actually an array, because a 200 whose body is `{}` or `5` would otherwise
24
+ // throw at the call site (in a worker, rejecting the whole load).
25
+ async function fetchArray<T>(path: string): Promise<T[] | null> {
26
+ try {
27
+ const res = await fetch(path);
28
+ if (!res.ok) return null;
29
+ const body = await res.json();
30
+ return Array.isArray(body) ? (body as T[]) : null;
31
+ } catch {
32
+ return null;
33
+ }
34
+ }
35
+
36
+ // One retry, because the common failure here is a transient CDN blip mid-deploy
37
+ // and the cost of giving up is a permanent hole in the corpus.
38
+ async function fetchArrayWithRetry<T>(path: string): Promise<T[] | null> {
39
+ const first = await fetchArray<T>(path);
40
+ if (first) return first;
41
+ return fetchArray<T>(path);
42
+ }
43
+
44
+ export function fromCompact(r: CompactRecord, pos?: number): IndexDocument {
45
+ return {
46
+ id: r.r ?? "",
47
+ title: r.c ?? "",
48
+ doctype: r.t || null,
49
+ stage: r.s || null,
50
+ date: r.d || null,
51
+ link: r.l || null,
52
+ yaml: r.u || null,
53
+ ...(pos === undefined ? {} : { pos }),
54
+ };
55
+ }
56
+
57
+ export interface LoadOptions {
58
+ /** Shards fetched at once. */
59
+ concurrency?: number;
60
+ /** Minimum gap between onBatch calls, in ms. */
61
+ flushMs?: number;
62
+ /** Injectable clock, for tests. */
63
+ now?: () => number;
64
+ }
65
+
66
+ // Load every summary shard, calling `onBatch` with the documents so far.
67
+ //
68
+ // Two properties matter to the caller:
69
+ //
70
+ // * Order. Records are appended in shard order regardless of the order
71
+ // responses arrive in, because the corpus order is what the detail shards'
72
+ // positional lookup is keyed to. A shard that resolves early waits for its
73
+ // predecessors before being appended.
74
+ // * Throttling. App.vue re-filters and re-sorts the entire array on every
75
+ // change, so emitting once per shard would re-sort a growing 166k-element
76
+ // array dozens of times. Batches are coalesced into one emit per window.
77
+ export async function loadSearchShards(
78
+ info: ShardInfo,
79
+ onBatch: (docs: IndexDocument[]) => void,
80
+ opts: LoadOptions = {},
81
+ ): Promise<void> {
82
+ const total = info.shards;
83
+ if (total <= 0) return;
84
+
85
+ const flushMs = opts.flushMs ?? 750;
86
+ const now = opts.now ?? (() => Date.now());
87
+ const slots: (IndexDocument[] | null)[] = new Array(total).fill(null);
88
+
89
+ let out: IndexDocument[] = [];
90
+ let appendedUpTo = 0; // index of the first slot not yet appended to `out`
91
+ let next = 0;
92
+ // -Infinity, not 0: the first batch must paint immediately whatever epoch the
93
+ // clock uses. With 0 it would only do so because Date.now() happens to be huge.
94
+ let lastEmit = -Infinity;
95
+
96
+ const drain = (force: boolean) => {
97
+ let grew = false;
98
+ while (appendedUpTo < total && slots[appendedUpTo]) {
99
+ out = out.concat(slots[appendedUpTo]!);
100
+ slots[appendedUpTo] = null; // release the intermediate array
101
+ appendedUpTo++;
102
+ grew = true;
103
+ }
104
+ if (!grew && !force) return;
105
+
106
+ const at = now();
107
+ if (!force && at - lastEmit < flushMs) return;
108
+ lastEmit = at;
109
+ onBatch(out); // new array identity each time, so the caller can reassign
110
+ };
111
+
112
+ const worker = async (): Promise<void> => {
113
+ for (let i = next++; i < total; i = next++) {
114
+ const rows = await fetchArrayWithRetry<CompactRecord>(
115
+ shardPath("search", i),
116
+ );
117
+ if (!rows) {
118
+ // The documents are lost, but their absence must not shift anyone
119
+ // else's corpus position — hence the explicit `pos` below.
120
+ console.warn(`relaton-index: shard ${shardPath("search", i)} failed to load`);
121
+ slots[i] = [];
122
+ drain(false);
123
+ continue;
124
+ }
125
+ // Corpus position is assigned here, from the shard number, so it survives
126
+ // a hole left by a failed shard and any later reordering of the array.
127
+ const base = i * info.shardSize;
128
+ slots[i] = rows.map((row, j) => fromCompact(row, base + j));
129
+ drain(false);
130
+ }
131
+ };
132
+
133
+ const workers = Math.max(1, Math.min(opts.concurrency ?? 4, total));
134
+ await Promise.all(Array.from({ length: workers }, worker));
135
+ drain(true);
136
+ }
137
+
138
+ export type DetailLoader = (
139
+ index: number,
140
+ id: string,
141
+ ) => Promise<DetailRecord | null>;
142
+
143
+ // Fetch-on-demand loader for the detail shards, with a per-shard cache so
144
+ // paging through neighbouring documents costs one request, not one per
145
+ // document. Concurrent opens within the same shard share a single in-flight
146
+ // request.
147
+ //
148
+ // `index` is the document's position in the full corpus, which is also its
149
+ // position across the detail shards. The entry's `r` is checked against the
150
+ // document id before its fields are used: if the two families ever fell out of
151
+ // step, showing another document's abstract would be worse than showing none,
152
+ // so a mismatch falls back to scanning the shard for the right id.
153
+ export function createDetailLoader(info: ShardInfo): DetailLoader {
154
+ type Slots = (DetailRecord | null)[];
155
+ const cache = new Map<number, Slots>();
156
+ const inFlight = new Map<number, Promise<Slots>>();
157
+
158
+ return async function loadDetail(index, id) {
159
+ const { detailShards, detailShardSize } = info;
160
+ if (detailShards <= 0 || detailShardSize <= 0 || index < 0) return null;
161
+
162
+ const shard = Math.floor(index / detailShardSize);
163
+ if (shard >= detailShards) return null;
164
+
165
+ let entries = cache.get(shard);
166
+ if (!entries) {
167
+ let pending = inFlight.get(shard);
168
+ if (!pending) {
169
+ pending = fetchArray<DetailRecord | null>(
170
+ shardPath("detail", shard),
171
+ ).then((rows) => rows ?? []);
172
+ inFlight.set(shard, pending);
173
+ }
174
+ entries = await pending;
175
+ cache.set(shard, entries);
176
+ inFlight.delete(shard);
177
+ }
178
+
179
+ const slot = entries[index % detailShardSize];
180
+ if (slot && slot.r === id) return slot;
181
+ return entries.find((entry) => entry != null && entry.r === id) ?? null;
182
+ };
183
+ }
@@ -0,0 +1,148 @@
1
+ /** One contributor (author, publisher, editor, …) on the detail page. */
2
+ export interface Contributor {
3
+ name: string;
4
+ role?: string | null;
5
+ }
6
+
7
+ /** One of a document's identifiers (may repeat per language / scheme). */
8
+ export interface DocIdentifier {
9
+ id: string;
10
+ type?: string | null;
11
+ language?: string | null;
12
+ }
13
+
14
+ // One related document, e.g. { type: "obsoletedBy", id: "ISO 29862:2024" }.
15
+ // Only the target's DocID is carried — no title or date — because the id is both
16
+ // the label and the key the detail page matches against the rest of the corpus
17
+ // to decide whether it can be rendered as an intra-dataset link.
18
+ export interface Relation {
19
+ type?: string | null;
20
+ id: string;
21
+ }
22
+
23
+ /** One dated event, e.g. { type: "published", value: "2009-06-19" }. */
24
+ export interface DocDate {
25
+ type?: string | null;
26
+ value: string;
27
+ }
28
+
29
+ // One bibliographic document as rendered in the index. This shape is the
30
+ // contract shared with the Ruby side (Relaton::Cli::IndexItemNormalizer):
31
+ // whatever the generator emits as JSON must map here.
32
+ //
33
+ // The first block is the summary contract, carried by the `search-NNNN.json`
34
+ // shards and loaded progressively. The second block holds the richer detail
35
+ // fields, which live in the separate `detail-NNNN.json` shards and are fetched
36
+ // only when a detail panel opens — so they are absent until then, and stay
37
+ // absent for a document that has none.
38
+ export interface IndexDocument {
39
+ /** Primary rendered Relaton DocID, e.g. "BIPM CCRI 21 (2009)". */
40
+ id: string;
41
+ /** Main title (English preferred). */
42
+ title: string;
43
+ /** Document type, e.g. "meeting-report" (from ext.doctype). */
44
+ doctype: string | null;
45
+ /** Workflow stage, e.g. "published". */
46
+ stage: string | null;
47
+ /** Publication date, ISO "YYYY-MM-DD". */
48
+ date: string | null;
49
+ /** Human-facing landing/link URL, if any. */
50
+ link: string | null;
51
+ /** URL (or relative path) to the raw Relaton YAML source. */
52
+ yaml: string | null;
53
+
54
+ // Assigned by the shard loader, NOT emitted by the generator: this document's
55
+ // position in the full corpus, which is what addresses its detail shard. It is
56
+ // carried explicitly rather than derived from the array index because a shard
57
+ // that fails to load leaves a hole — every later document would then sit at a
58
+ // lower index than its true position and resolve to the wrong detail shard.
59
+ pos?: number;
60
+
61
+ /** Abstract / summary text (HTML stripped). */
62
+ abstract?: string | null;
63
+ /** Edition, e.g. "2". */
64
+ edition?: string | null;
65
+ /** Language codes, e.g. ["en", "fr"]. */
66
+ languages?: string[];
67
+ /** Subject keywords. */
68
+ keywords?: string[];
69
+ /** Publishing organization name. */
70
+ publisher?: string | null;
71
+ /** All contributors with their roles. */
72
+ contributors?: Contributor[];
73
+ /** All identifiers (multilingual/scheme variants of `id`). */
74
+ docids?: DocIdentifier[];
75
+ /** All dated events. */
76
+ dates?: DocDate[];
77
+ /** Related documents (obsoletes, updatedBy, partOf, …). */
78
+ relations?: Relation[];
79
+ }
80
+
81
+ export interface IndexData {
82
+ title: string;
83
+ /** Site blurb shown as the header subtitle (optional; also the meta description). */
84
+ description?: string | null;
85
+ documents: IndexDocument[];
86
+ /** ISO timestamp the site was generated (optional, for the footer). */
87
+ generated?: string | null;
88
+ }
89
+
90
+ /** Compact record shape carried by the `search-NNNN.json` summary shards. */
91
+ export interface CompactRecord {
92
+ r: string; // reference / id
93
+ c: string; // content / title
94
+ t?: string | null; // doctype
95
+ s?: string | null; // stage
96
+ d?: string | null; // date
97
+ u?: string | null; // url (yaml)
98
+ l?: string | null; // link
99
+ }
100
+
101
+ // One entry in a `detail-NNNN.json` shard: the fields the summary record does
102
+ // NOT carry, tagged with `r` (the document id) so a positional lookup can be
103
+ // verified before its fields are shown. A shard slot is `null` when the
104
+ // document at that position has no detail fields at all — the slot is still
105
+ // written, so position stays aligned with the summary shards.
106
+ export type DetailRecord = Pick<
107
+ IndexDocument,
108
+ | "abstract"
109
+ | "edition"
110
+ | "languages"
111
+ | "keywords"
112
+ | "publisher"
113
+ | "contributors"
114
+ | "docids"
115
+ | "dates"
116
+ | "relations"
117
+ > & { r: string };
118
+
119
+ // The shard layout, read from the mount node's data-* attributes. There is
120
+ // deliberately no manifest file: these five scalars are everything the frontend
121
+ // needs, and carrying them inline saves a round-trip before the first shard can
122
+ // be requested (and a failure mode where the manifest 404s but the shards are
123
+ // fine). Shard file names are derived by convention — `search-0000.json`,
124
+ // `detail-0000.json` — and resolve relative to the page, which sits beside them.
125
+ export interface ShardInfo {
126
+ /** Documents across the whole corpus. */
127
+ total: number;
128
+ /** Number of `search-NNNN.json` files. */
129
+ shards: number;
130
+ /** Summary records per shard (the last one may hold fewer). */
131
+ shardSize: number;
132
+ /** Number of `detail-NNNN.json` files; 0 when built with --no-detail. */
133
+ detailShards: number;
134
+ /** Detail slots per shard — the divisor for the positional lookup. */
135
+ detailShardSize: number;
136
+ }
137
+
138
+ // Progressive-load state shared with App. A plain reactive object rather than a
139
+ // prop value, because `createApp(App, props)` props are static: reactivity has
140
+ // to come from the object itself.
141
+ export interface Hydration {
142
+ /** True until every summary shard has landed. */
143
+ loading: boolean;
144
+ /** Corpus total, known up front so counts are truthful during loading. */
145
+ total: number | null;
146
+ /** Start (or hurry along) the shard load — e.g. when the user searches. */
147
+ requestAll: () => void;
148
+ }