relaton 3.0.6 → 3.0.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (134) hide show
  1. checksums.yaml +4 -4
  2. data/bin/relaton +20 -0
  3. data/lib/relaton/bib/model/docidentifier.rb +17 -2
  4. data/lib/relaton/bibcollection.rb +146 -0
  5. data/lib/relaton/bibdata.rb +92 -0
  6. data/lib/relaton/bipm/data_outcomes_parser.rb +6 -1
  7. data/lib/relaton/cli/base_convertor.rb +134 -0
  8. data/lib/relaton/cli/command.rb +390 -0
  9. data/lib/relaton/cli/data_fetcher.rb +20 -0
  10. data/lib/relaton/cli/frontend/dist/404.html +18 -0
  11. data/lib/relaton/cli/frontend/dist/app.iife.js +1 -0
  12. data/lib/relaton/cli/frontend/dist/style.css +3 -0
  13. data/lib/relaton/cli/frontend/index.html +14 -0
  14. data/lib/relaton/cli/frontend/package-lock.json +2945 -0
  15. data/lib/relaton/cli/frontend/package.json +29 -0
  16. data/lib/relaton/cli/frontend/public/404.html +18 -0
  17. data/lib/relaton/cli/frontend/src/App.test.ts +501 -0
  18. data/lib/relaton/cli/frontend/src/App.vue +506 -0
  19. data/lib/relaton/cli/frontend/src/app.ts +90 -0
  20. data/lib/relaton/cli/frontend/src/components/DocumentDetail.test.ts +142 -0
  21. data/lib/relaton/cli/frontend/src/components/DocumentDetail.vue +228 -0
  22. data/lib/relaton/cli/frontend/src/components/DocumentRow.vue +88 -0
  23. data/lib/relaton/cli/frontend/src/components/Icon.test.ts +44 -0
  24. data/lib/relaton/cli/frontend/src/components/Icon.vue +55 -0
  25. data/lib/relaton/cli/frontend/src/components/Pagination.vue +26 -0
  26. data/lib/relaton/cli/frontend/src/dev/main.ts +100 -0
  27. data/lib/relaton/cli/frontend/src/dev/sample-data.json +71 -0
  28. data/lib/relaton/cli/frontend/src/lib/filter.test.ts +88 -0
  29. data/lib/relaton/cli/frontend/src/lib/filter.ts +95 -0
  30. data/lib/relaton/cli/frontend/src/lib/hydrate.test.ts +102 -0
  31. data/lib/relaton/cli/frontend/src/lib/hydrate.ts +55 -0
  32. data/lib/relaton/cli/frontend/src/lib/shards.test.ts +319 -0
  33. data/lib/relaton/cli/frontend/src/lib/shards.ts +183 -0
  34. data/lib/relaton/cli/frontend/src/lib/types.ts +148 -0
  35. data/lib/relaton/cli/frontend/src/lib/url.test.ts +151 -0
  36. data/lib/relaton/cli/frontend/src/lib/url.ts +111 -0
  37. data/lib/relaton/cli/frontend/src/shims-vue.d.ts +5 -0
  38. data/lib/relaton/cli/frontend/src/styles/index.css +27 -0
  39. data/lib/relaton/cli/frontend/src/test/fetch.ts +58 -0
  40. data/lib/relaton/cli/frontend/tsconfig.json +20 -0
  41. data/lib/relaton/cli/frontend/vite.config.ts +42 -0
  42. data/lib/relaton/cli/frontend/vitest.config.ts +11 -0
  43. data/lib/relaton/cli/frontend_assets.rb +58 -0
  44. data/lib/relaton/cli/full_text_search.rb +98 -0
  45. data/lib/relaton/cli/i18n.yaml +1 -0
  46. data/lib/relaton/cli/index_item_normalizer.rb +380 -0
  47. data/lib/relaton/cli/index_site_generator.rb +1015 -0
  48. data/lib/relaton/cli/relaton_file.rb +290 -0
  49. data/lib/relaton/cli/subcommand_collection.rb +263 -0
  50. data/lib/relaton/cli/subcommand_db.rb +81 -0
  51. data/lib/relaton/cli/templates/_document.liquid +111 -0
  52. data/lib/relaton/cli/templates/_index.liquid +61 -0
  53. data/lib/relaton/cli/templates/index/page.liquid +20 -0
  54. data/lib/relaton/cli/templates/index-style.css +1017 -0
  55. data/lib/relaton/cli/util.rb +8 -0
  56. data/lib/relaton/cli/version.rb +11 -0
  57. data/lib/relaton/cli/xml_convertor.rb +47 -0
  58. data/lib/relaton/cli/xml_to_html_renderer.rb +117 -0
  59. data/lib/relaton/cli/yaml_convertor.rb +102 -0
  60. data/lib/relaton/cli.rb +113 -0
  61. data/lib/relaton/doi/CLAUDE.md +1 -1
  62. data/lib/relaton/element_finder.rb +29 -0
  63. data/lib/relaton/gb/CLAUDE.md +1 -1
  64. data/lib/relaton/ietf/CLAUDE.md +1 -1
  65. data/lib/relaton/iho/CLAUDE.md +3 -3
  66. data/lib/relaton/iho/bibliography.rb +41 -27
  67. data/lib/relaton/iho/processor.rb +3 -2
  68. data/lib/relaton/nist/CLAUDE.md +11 -1
  69. data/lib/relaton/nist/README.adoc +7 -1
  70. data/lib/relaton/nist/hit_collection.rb +25 -1
  71. data/lib/relaton/nist/mods_parser.rb +32 -8
  72. data/lib/relaton/nist/pubs_export.rb +69 -11
  73. data/lib/relaton/ogc/CLAUDE.md +1 -1
  74. data/lib/relaton/omg/CLAUDE.md +1 -1
  75. data/lib/relaton/plateau/CLAUDE.md +1 -1
  76. data/lib/relaton/render/general.rb +319 -0
  77. data/lib/relaton/render/iso690/csl_import.rb +129 -0
  78. data/lib/relaton/render/iso690/element.rb +153 -0
  79. data/lib/relaton/render/iso690/elements/access.rb +44 -0
  80. data/lib/relaton/render/iso690/elements/authorizer.rb +45 -0
  81. data/lib/relaton/render/iso690/elements/citeid.rb +39 -0
  82. data/lib/relaton/render/iso690/elements/component_part.rb +97 -0
  83. data/lib/relaton/render/iso690/elements/creator.rb +267 -0
  84. data/lib/relaton/render/iso690/elements/edition.rb +97 -0
  85. data/lib/relaton/render/iso690/elements/extent.rb +71 -0
  86. data/lib/relaton/render/iso690/elements/identifier.rb +46 -0
  87. data/lib/relaton/render/iso690/elements/location.rb +73 -0
  88. data/lib/relaton/render/iso690/elements/medium.rb +32 -0
  89. data/lib/relaton/render/iso690/elements/numeration.rb +26 -0
  90. data/lib/relaton/render/iso690/elements/production.rb +111 -0
  91. data/lib/relaton/render/iso690/elements/pub_date.rb +63 -0
  92. data/lib/relaton/render/iso690/elements/series.rb +66 -0
  93. data/lib/relaton/render/iso690/elements/size.rb +45 -0
  94. data/lib/relaton/render/iso690/elements/status.rb +30 -0
  95. data/lib/relaton/render/iso690/elements/stddoc.rb +40 -0
  96. data/lib/relaton/render/iso690/elements/title.rb +56 -0
  97. data/lib/relaton/render/iso690/elements/updated.rb +38 -0
  98. data/lib/relaton/render/iso690/elements.rb +67 -0
  99. data/lib/relaton/render/iso690/fields.rb +142 -0
  100. data/lib/relaton/render/iso690/i18n/en.yml +48 -0
  101. data/lib/relaton/render/iso690/i18n/fr.yml +14 -0
  102. data/lib/relaton/render/iso690/i18n/ja.yml +38 -0
  103. data/lib/relaton/render/iso690/i18n.rb +83 -0
  104. data/lib/relaton/render/iso690/identifier_kinds.rb +29 -0
  105. data/lib/relaton/render/iso690/index_renderer.rb +57 -0
  106. data/lib/relaton/render/iso690/kinds.rb +53 -0
  107. data/lib/relaton/render/iso690/renderer.rb +150 -0
  108. data/lib/relaton/render/iso690/rules/bipm.rb +186 -0
  109. data/lib/relaton/render/iso690/rules/jis.rb +103 -0
  110. data/lib/relaton/render/iso690/rules/nist.rb +384 -0
  111. data/lib/relaton/render/iso690/rules.rb +227 -0
  112. data/lib/relaton/render/iso690/style/locale.rb +59 -0
  113. data/lib/relaton/render/iso690/style/name_form.rb +87 -0
  114. data/lib/relaton/render/iso690/style/scheme.rb +33 -0
  115. data/lib/relaton/render/iso690/style/sort_rule.rb +25 -0
  116. data/lib/relaton/render/iso690/style/template_map.rb +40 -0
  117. data/lib/relaton/render/iso690/style/type_template.rb +45 -0
  118. data/lib/relaton/render/iso690/style.rb +227 -0
  119. data/lib/relaton/render/iso690/styles/apa.yml +45 -0
  120. data/lib/relaton/render/iso690/styles/author-date.yml +31 -0
  121. data/lib/relaton/render/iso690/styles/chicago.yml +49 -0
  122. data/lib/relaton/render/iso690/styles/ieee-sa.yml +45 -0
  123. data/lib/relaton/render/iso690/styles/jis-en.yml +68 -0
  124. data/lib/relaton/render/iso690/styles/jis-ja.yml +67 -0
  125. data/lib/relaton/render/iso690/styles/lncs.yml +54 -0
  126. data/lib/relaton/render/iso690/styles/mla.yml +56 -0
  127. data/lib/relaton/render/iso690/template.rb +248 -0
  128. data/lib/relaton/render/iso690.rb +28 -0
  129. data/lib/relaton/render/version.rb +5 -0
  130. data/lib/relaton/un/CLAUDE.md +1 -1
  131. data/lib/relaton/version.rb +1 -1
  132. data/lib/relaton-cli.rb +17 -0
  133. data/lib/relaton-render.rb +13 -0
  134. metadata +192 -21
@@ -0,0 +1,506 @@
1
+ <script setup lang="ts">
2
+ import { computed, nextTick, onMounted, onUnmounted, ref, watch } from "vue";
3
+ import type { Hydration, IndexData, IndexDocument } from "./lib/types";
4
+ import type { DetailLoader } from "./lib/shards";
5
+ import {
6
+ applyFilters,
7
+ distinctValues,
8
+ type SortDir,
9
+ type SortKey,
10
+ } from "./lib/filter";
11
+ import DocumentRow from "./components/DocumentRow.vue";
12
+ import DocumentDetail from "./components/DocumentDetail.vue";
13
+ import Pagination from "./components/Pagination.vue";
14
+ import Icon from "./components/Icon.vue";
15
+ import {
16
+ readDocFromUrl,
17
+ readPageFromUrl,
18
+ writeDocToUrl,
19
+ writePageToUrl,
20
+ } from "./lib/url";
21
+
22
+ const props = defineProps<{
23
+ data: IndexData;
24
+ hydration?: Hydration;
25
+ loadDetail?: DetailLoader;
26
+ }>();
27
+
28
+ // Mounted without progressive loading (every existing test, and any embedder
29
+ // passing a complete document list) behaves exactly as before: nothing is
30
+ // loading, and the total is just what's in hand.
31
+ const STATIC_HYDRATION: Hydration = {
32
+ loading: false,
33
+ total: null,
34
+ requestAll: () => {},
35
+ };
36
+ const hyd = computed<Hydration>(() => props.hydration ?? STATIC_HYDRATION);
37
+ const loading = computed(() => hyd.value.loading);
38
+
39
+ const query = ref("");
40
+ const activeDoctypes = ref<Set<string>>(new Set());
41
+ const activeStages = ref<Set<string>>(new Set());
42
+ const sortKey = ref<SortKey>("id");
43
+ const sortDir = ref<SortDir>("asc");
44
+ const view = ref<"list" | "grid">(
45
+ (localStorage.getItem("relaton-index-view") as "list" | "grid") || "list",
46
+ );
47
+ const isDark = ref(document.documentElement.classList.contains("dark"));
48
+ const searchEl = ref<HTMLInputElement | null>(null);
49
+
50
+ const docs = computed(() => props.data.documents);
51
+ const doctypes = computed(() => distinctValues(docs.value, "doctype"));
52
+ const stages = computed(() => distinctValues(docs.value, "stage"));
53
+ // The corpus size is known from the mount node before any shard has landed, so
54
+ // the count line reads "12 of 166658" rather than a denominator that grows.
55
+ // Never report fewer than we actually hold: a shell with a missing or wrong
56
+ // data-total would otherwise render nonsense like "3 of 0 documents".
57
+ const total = computed(() =>
58
+ Math.max(hyd.value.total ?? 0, docs.value.length),
59
+ );
60
+
61
+ // Detail-page routing: `?doc=<id>` selects a document to show in full instead of
62
+ // the list. A missing/unknown id falls back to the list. This is the second
63
+ // piece of URL-synced state alongside `?page=N` (see lib/url.ts).
64
+ const selectedId = ref<string | null>(readDocFromUrl());
65
+ const selectedDoc = computed(() =>
66
+ selectedId.value == null
67
+ ? null
68
+ : (docs.value.find((d) => d.id === selectedId.value) ?? null),
69
+ );
70
+ // Opening a document — from a list row, or from a relation link on another
71
+ // document's detail page (detail -> detail).
72
+ function openDoc(id: string) {
73
+ selectedId.value = id;
74
+ // New history entry so Back retraces the path that got here.
75
+ writeDocToUrl(id, "push");
76
+ window.scrollTo({ top: 0 });
77
+ }
78
+ // Which DocIDs exist in this dataset — the set that decides whether a relation
79
+ // on the detail page can be rendered as a link. A `computed` is lazy, so this is
80
+ // only ever built when a detail panel with relations actually asks (via the
81
+ // `hasDoc` predicate below), never during ordinary list browsing; it then
82
+ // re-derives as summary shards land, so links appear as the corpus fills in.
83
+ const docIdSet = computed(() => new Set(docs.value.map((d) => d.id)));
84
+ function hasDoc(id: string): boolean {
85
+ return docIdSet.value.has(id);
86
+ }
87
+
88
+ function closeDoc() {
89
+ selectedId.value = null;
90
+ // Replace (not push) so we don't leave a forward "detail" entry that Forward
91
+ // would re-open. Back then steps to whatever preceded this detail — the list
92
+ // for a document opened from a row, or the referring document when a relation
93
+ // link brought us here. That mirrors the trail the reader actually walked.
94
+ writeDocToUrl(null, "replace");
95
+ }
96
+
97
+ const visible = computed(() =>
98
+ applyFilters(docs.value, {
99
+ query: query.value,
100
+ doctypes: activeDoctypes.value,
101
+ stages: activeStages.value,
102
+ sortKey: sortKey.value,
103
+ sortDir: sortDir.value,
104
+ }),
105
+ );
106
+
107
+ // Client-side pagination keeps rendering fast for large indexes (e.g. BIPM's
108
+ // ~8k docs) — only the current page is rendered to the DOM at a time. The page
109
+ // size adapts to the viewport so a page fills roughly one screen instead of a
110
+ // fixed 100 rows. Row heights are estimates (px); grid packs two columns wide.
111
+ const listEl = ref<HTMLElement | null>(null);
112
+ const ROW_H = { list: 76, grid: 128 };
113
+ const RESERVE = 120; // bottom pagination + breathing room below the list
114
+ const MIN_ROWS = 10;
115
+ const MAX_ROWS = 100;
116
+
117
+ function computePageSize(): number {
118
+ const vh = window.innerHeight || 800;
119
+ const top = listEl.value?.getBoundingClientRect().top ?? 300;
120
+ const avail = Math.max(vh - top - RESERVE, ROW_H.list);
121
+ const cols = view.value === "grid" && window.innerWidth >= 640 ? 2 : 1;
122
+ const rows = Math.floor(avail / ROW_H[view.value]) * cols;
123
+ return Math.min(MAX_ROWS, Math.max(MIN_ROWS, rows));
124
+ }
125
+
126
+ const pageSize = ref(computePageSize());
127
+ // The current page is reflected in the URL (?page=N) so reload/share/bookmark
128
+ // preserves it; restore it here on load (clamped to a valid page after the
129
+ // adaptive pageSize settles — see onMounted).
130
+ const page = ref(readPageFromUrl());
131
+ const pageCount = computed(() => Math.max(1, Math.ceil(visible.value.length / pageSize.value)));
132
+ const paged = computed(() => {
133
+ const start = (page.value - 1) * pageSize.value;
134
+ return visible.value.slice(start, start + pageSize.value);
135
+ });
136
+
137
+ function refreshPageSize() {
138
+ const next = computePageSize();
139
+ if (next !== pageSize.value) pageSize.value = next;
140
+ }
141
+ function clampPage() {
142
+ const clamped = Math.min(page.value, pageCount.value);
143
+ if (clamped !== page.value) {
144
+ page.value = clamped;
145
+ writePageToUrl(clamped, "replace");
146
+ }
147
+ }
148
+ let resizeTimer: ReturnType<typeof setTimeout> | undefined;
149
+ function onResize() {
150
+ clearTimeout(resizeTimer);
151
+ resizeTimer = setTimeout(refreshPageSize, 150);
152
+ }
153
+ // Grid/list density differs, so recompute when the view mode changes.
154
+ watch(view, () => nextTick(refreshPageSize));
155
+ // The list is unmounted while a detail page is open (listEl is null, so the page
156
+ // size can't be measured); re-measure once we return to the list. A stale/unknown
157
+ // `?doc=` id (whose doc isn't found) resolves to no detail — drop the param so the
158
+ // URL matches the list we actually show. `immediate` handles an unknown id present
159
+ // at load time (a deep link to a since-removed doc).
160
+ //
161
+ // While shards are still arriving, "not found" does NOT mean "removed": the
162
+ // document's shard may simply not have landed. Bouncing then would break every
163
+ // deep link on a large index, so the drop waits for loading to finish — and we
164
+ // hurry the load along, since someone is waiting on that exact document.
165
+ watch(
166
+ [selectedDoc, loading] as const,
167
+ ([doc, isLoading]) => {
168
+ if (doc) return;
169
+ nextTick(refreshPageSize);
170
+ if (selectedId.value == null) return;
171
+ if (isLoading) {
172
+ hyd.value.requestAll();
173
+ return;
174
+ }
175
+ selectedId.value = null;
176
+ writeDocToUrl(null, "replace");
177
+ },
178
+ { immediate: true },
179
+ );
180
+ // Keep the current page valid after the page size changes (e.g. on resize).
181
+ // Skipped while loading: pageCount over a partial corpus would clamp a URL
182
+ // ?page=N down to a page that only exists because the rest hasn't arrived yet.
183
+ watch(pageSize, () => {
184
+ if (!loading.value) clampPage();
185
+ });
186
+ // …so do it once, when the corpus is finally complete.
187
+ watch(loading, (isLoading) => {
188
+ if (!isLoading) clampPage();
189
+ });
190
+ // Reset to the first page whenever the filtered set changes.
191
+ watch([query, activeDoctypes, activeStages, sortKey, sortDir], () => {
192
+ if (page.value !== 1) {
193
+ page.value = 1;
194
+ writePageToUrl(1, "replace");
195
+ }
196
+ // Searching or faceting a partial corpus gives partial answers, so stop
197
+ // waiting for idle time and pull the rest now.
198
+ hyd.value.requestAll();
199
+ });
200
+
201
+ // Detail fields live in their own shards and are fetched when a panel opens.
202
+ // `doc.pos` — the corpus position stamped on by the shard loader — is what
203
+ // addresses the detail shard. It is used in preference to the array index
204
+ // because a summary shard that failed to load leaves a hole, after which the
205
+ // two stop agreeing. indexOf remains the fallback for a document list supplied
206
+ // whole (no shards), where position and index are the same thing.
207
+ // Each document is attempted at most once; DocumentDetail guards every rich
208
+ // block with v-if, so the sections simply fill in on arrival.
209
+ const detailTried = new Set<string>();
210
+ function docKey(doc: IndexDocument): string {
211
+ return `${doc.id}|${doc.yaml ?? ""}`;
212
+ }
213
+ watch(selectedDoc, (doc) => {
214
+ const load = props.loadDetail;
215
+ if (!doc || !load) return;
216
+
217
+ const key = docKey(doc);
218
+ if (detailTried.has(key)) return;
219
+ detailTried.add(key);
220
+
221
+ const index = doc.pos ?? docs.value.indexOf(doc);
222
+ if (index < 0) return;
223
+
224
+ void load(index, doc.id)
225
+ .then((detail) => {
226
+ if (!detail) return;
227
+ const { r: _id, ...fields } = detail;
228
+ Object.assign(doc, fields);
229
+ // Relation links resolve against the loaded corpus, so a partially loaded
230
+ // one would show real intra-dataset targets as plain text. Same remedy as
231
+ // the unresolved-?doc= watcher: hurry the load rather than let "absent"
232
+ // read as "not in this dataset".
233
+ if (fields.relations?.length) props.hydration?.requestAll();
234
+ })
235
+ .catch(() => {
236
+ /* detail is optional enrichment; the panel already renders without it */
237
+ });
238
+ });
239
+ function goto(p: number) {
240
+ const next = Math.min(pageCount.value, Math.max(1, p));
241
+ if (next !== page.value) {
242
+ page.value = next;
243
+ // User navigation → a new history entry so Back/Forward walk pages.
244
+ writePageToUrl(next, "push");
245
+ }
246
+ document.getElementById("relaton-index-app")?.scrollIntoView({ block: "start" });
247
+ }
248
+ // Back/Forward navigation: sync both the open detail doc and the page from the
249
+ // URL without writing them back (which would otherwise clobber the history
250
+ // entry we just navigated to).
251
+ function onPopState() {
252
+ selectedId.value = readDocFromUrl();
253
+ const requested = Math.max(1, readPageFromUrl());
254
+ // Same reasoning as the pageSize watcher: don't clamp against a pageCount
255
+ // computed from a corpus that is still arriving.
256
+ page.value = loading.value
257
+ ? requested
258
+ : Math.min(pageCount.value, requested);
259
+ }
260
+
261
+ function toggle(set: Set<string>, value: string): Set<string> {
262
+ const next = new Set(set);
263
+ next.has(value) ? next.delete(value) : next.add(value);
264
+ return next;
265
+ }
266
+ function toggleDoctype(v: string) {
267
+ activeDoctypes.value = toggle(activeDoctypes.value, v);
268
+ }
269
+ function toggleStage(v: string) {
270
+ activeStages.value = toggle(activeStages.value, v);
271
+ }
272
+ function clearFilters() {
273
+ query.value = "";
274
+ activeDoctypes.value = new Set();
275
+ activeStages.value = new Set();
276
+ }
277
+
278
+ function setSort(key: SortKey) {
279
+ if (sortKey.value === key) {
280
+ sortDir.value = sortDir.value === "asc" ? "desc" : "asc";
281
+ } else {
282
+ sortKey.value = key;
283
+ sortDir.value = "asc";
284
+ }
285
+ }
286
+
287
+ function setView(v: "list" | "grid") {
288
+ view.value = v;
289
+ localStorage.setItem("relaton-index-view", v);
290
+ }
291
+
292
+ function toggleTheme() {
293
+ isDark.value = !isDark.value;
294
+ document.documentElement.classList.toggle("dark", isDark.value);
295
+ try {
296
+ localStorage.setItem("relaton-index-theme", isDark.value ? "dark" : "light");
297
+ } catch {
298
+ /* ignore */
299
+ }
300
+ }
301
+
302
+ const hasFilters = computed(
303
+ () =>
304
+ query.value.trim() !== "" ||
305
+ activeDoctypes.value.size > 0 ||
306
+ activeStages.value.size > 0,
307
+ );
308
+
309
+ function onKey(e: KeyboardEvent) {
310
+ const target = e.target as HTMLElement | null;
311
+ const typing =
312
+ target &&
313
+ (target.tagName === "INPUT" ||
314
+ target.tagName === "TEXTAREA" ||
315
+ target.isContentEditable);
316
+ if (e.key === "/" && !typing) {
317
+ e.preventDefault();
318
+ searchEl.value?.focus();
319
+ } else if (e.key === "Escape" && target === searchEl.value) {
320
+ query.value = "";
321
+ searchEl.value?.blur();
322
+ }
323
+ }
324
+ onMounted(() => {
325
+ window.addEventListener("keydown", onKey);
326
+ window.addEventListener("resize", onResize);
327
+ window.addEventListener("popstate", onPopState);
328
+ // Measure the real layout once rows have rendered, then size the page. Only
329
+ // then is pageCount final, so clamp a URL-provided page (e.g. ?page=99, or a
330
+ // smaller viewport with fewer pages) and normalize the URL in place.
331
+ nextTick(() => {
332
+ refreshPageSize();
333
+ // Only meaningful once the corpus is complete; the `loading` watcher above
334
+ // clamps when the last shard lands.
335
+ if (!loading.value) clampPage();
336
+ });
337
+ });
338
+ onUnmounted(() => {
339
+ window.removeEventListener("keydown", onKey);
340
+ window.removeEventListener("resize", onResize);
341
+ window.removeEventListener("popstate", onPopState);
342
+ clearTimeout(resizeTimer);
343
+ });
344
+ </script>
345
+
346
+ <template>
347
+ <div class="min-h-screen bg-slate-50 text-slate-900 dark:bg-slate-950 dark:text-slate-100">
348
+ <header class="border-b border-slate-200 dark:border-slate-800">
349
+ <div class="mx-auto flex max-w-5xl items-center justify-between gap-4 px-4 py-5">
350
+ <div>
351
+ <h1 class="text-2xl font-semibold tracking-tight">{{ data.title }}</h1>
352
+ <p class="mt-1 text-sm text-slate-500 dark:text-slate-400">
353
+ {{ data.description || "Please use the provided Relaton DocID to refer to an item." }}
354
+ </p>
355
+ </div>
356
+ <button
357
+ type="button"
358
+ class="rounded-lg border border-slate-300 p-2 text-slate-600 hover:bg-slate-100 dark:border-slate-700 dark:text-slate-300 dark:hover:bg-slate-800"
359
+ :aria-label="isDark ? 'Switch to light theme' : 'Switch to dark theme'"
360
+ :title="isDark ? 'Light theme' : 'Dark theme'"
361
+ @click="toggleTheme"
362
+ >
363
+ <Icon :name="isDark ? 'sun' : 'moon'" class="h-5 w-5" />
364
+ </button>
365
+ </div>
366
+ </header>
367
+
368
+ <main class="mx-auto max-w-5xl px-4 py-6">
369
+ <DocumentDetail
370
+ v-if="selectedDoc"
371
+ :doc="selectedDoc"
372
+ :has-doc="hasDoc"
373
+ @back="closeDoc"
374
+ @open="openDoc"
375
+ />
376
+ <template v-else>
377
+ <!-- Toolbar -->
378
+ <div class="sticky top-0 z-10 -mx-4 mb-4 bg-slate-50/90 px-4 py-3 backdrop-blur dark:bg-slate-950/90">
379
+ <div class="flex flex-wrap items-center gap-3">
380
+ <div class="relative min-w-56 flex-1">
381
+ <span class="pointer-events-none absolute inset-y-0 left-3 flex items-center text-slate-400">
382
+ <Icon name="search" class="h-4 w-4" />
383
+ </span>
384
+ <input
385
+ ref="searchEl"
386
+ v-model="query"
387
+ type="search"
388
+ class="w-full rounded-lg border border-slate-300 bg-white py-2 pl-9 pr-3 text-sm shadow-sm outline-none focus:border-brand focus:ring-2 focus:ring-brand/30 dark:border-slate-700 dark:bg-slate-900"
389
+ aria-label="Filter documents by identifier or title"
390
+ placeholder="Filter by identifier or title… ( / )"
391
+ />
392
+ </div>
393
+
394
+ <div class="flex items-center gap-1 text-sm">
395
+ <span class="text-slate-500 dark:text-slate-400">Sort</span>
396
+ <button
397
+ v-for="k in (['id', 'date', 'title'] as SortKey[])"
398
+ :key="k"
399
+ type="button"
400
+ class="rounded-md px-2 py-1 capitalize"
401
+ :class="sortKey === k
402
+ ? 'bg-brand text-white'
403
+ : 'text-slate-600 hover:bg-slate-200 dark:text-slate-300 dark:hover:bg-slate-800'"
404
+ @click="setSort(k)"
405
+ >
406
+ {{ k }}<Icon
407
+ v-if="sortKey === k"
408
+ :name="sortDir === 'asc' ? 'chevron-up' : 'chevron-down'"
409
+ class="ml-0.5 inline h-3.5 w-3.5"
410
+ />
411
+ </button>
412
+ </div>
413
+
414
+ <div class="flex items-center gap-1 rounded-lg border border-slate-300 p-0.5 dark:border-slate-700">
415
+ <button
416
+ type="button"
417
+ class="rounded-md px-2 py-1 text-sm"
418
+ :class="view === 'list' ? 'bg-brand text-white' : 'text-slate-600 dark:text-slate-300'"
419
+ aria-label="List view"
420
+ @click="setView('list')"
421
+ ><Icon name="list" class="h-4 w-4" /></button>
422
+ <button
423
+ type="button"
424
+ class="rounded-md px-2 py-1 text-sm"
425
+ :class="view === 'grid' ? 'bg-brand text-white' : 'text-slate-600 dark:text-slate-300'"
426
+ aria-label="Grid view"
427
+ @click="setView('grid')"
428
+ ><Icon name="grid" class="h-4 w-4" /></button>
429
+ </div>
430
+ </div>
431
+
432
+ <!-- Facets -->
433
+ <div v-if="doctypes.length || stages.length" class="mt-3 flex flex-wrap items-center gap-2">
434
+ <button
435
+ v-for="t in doctypes"
436
+ :key="'dt-' + t"
437
+ type="button"
438
+ class="rounded-full border px-3 py-1 text-xs"
439
+ :class="activeDoctypes.has(t)
440
+ ? 'border-brand bg-brand text-white'
441
+ : 'border-slate-300 text-slate-600 hover:border-brand dark:border-slate-700 dark:text-slate-300'"
442
+ @click="toggleDoctype(t)"
443
+ >{{ t }}</button>
444
+ <button
445
+ v-for="s in stages"
446
+ :key="'st-' + s"
447
+ type="button"
448
+ class="rounded-full border px-3 py-1 text-xs"
449
+ :class="activeStages.has(s)
450
+ ? 'border-emerald-500 bg-emerald-500 text-white'
451
+ : 'border-slate-300 text-slate-600 hover:border-emerald-500 dark:border-slate-700 dark:text-slate-300'"
452
+ @click="toggleStage(s)"
453
+ >{{ s }}</button>
454
+ </div>
455
+
456
+ <div class="mt-3 flex items-center gap-3 text-xs text-slate-500 dark:text-slate-400">
457
+ <span>{{ visible.length }} of {{ total }} documents</span>
458
+ <span v-if="loading" class="text-slate-400 dark:text-slate-500">loading…</span>
459
+ <button
460
+ v-if="hasFilters"
461
+ type="button"
462
+ class="text-brand hover:underline dark:text-brand-dark"
463
+ @click="clearFilters"
464
+ >Clear filters</button>
465
+ </div>
466
+ </div>
467
+
468
+ <!-- Pagination (top, mirrors the bottom controls) -->
469
+ <Pagination class="mb-4" :page="page" :page-count="pageCount" @go="goto" />
470
+
471
+ <!-- List -->
472
+ <div
473
+ v-if="visible.length"
474
+ ref="listEl"
475
+ :class="view === 'grid'
476
+ ? 'grid grid-cols-1 gap-3 sm:grid-cols-2'
477
+ : 'flex flex-col divide-y divide-slate-200 dark:divide-slate-800'"
478
+ >
479
+ <DocumentRow
480
+ v-for="doc in paged"
481
+ :key="doc.id + '|' + (doc.yaml ?? '')"
482
+ :doc="doc"
483
+ :view="view"
484
+ @open="openDoc"
485
+ />
486
+ </div>
487
+ <p v-else class="py-16 text-center text-slate-500 dark:text-slate-400">
488
+ No documents match your filters.
489
+ </p>
490
+
491
+ <!-- Pagination (bottom) -->
492
+ <Pagination class="mt-6" :page="page" :page-count="pageCount" @go="goto" />
493
+ </template>
494
+ </main>
495
+
496
+ <footer class="border-t border-slate-200 py-8 text-center text-xs text-slate-500 dark:border-slate-800 dark:text-slate-400">
497
+ <p>
498
+ An open source project by
499
+ <a class="text-brand hover:underline dark:text-brand-dark" href="https://www.ribose.com">Ribose</a>.
500
+ Built with
501
+ <a class="text-brand hover:underline dark:text-brand-dark" href="https://www.relaton.org">Relaton</a>.
502
+ </p>
503
+ <p v-if="data.generated" class="mt-1">Generated {{ data.generated }}</p>
504
+ </footer>
505
+ </div>
506
+ </template>
@@ -0,0 +1,90 @@
1
+ import { createApp, reactive } from "vue";
2
+ import App from "./App.vue";
3
+ import "./styles/index.css";
4
+ import { resolveIndex } from "./lib/hydrate";
5
+ import { createDetailLoader, loadSearchShards } from "./lib/shards";
6
+ import type { Hydration, IndexData } from "./lib/types";
7
+
8
+ const MOUNT_ID = "relaton-index-app";
9
+
10
+ // Restore the persisted theme (or fall back to the OS preference) before the
11
+ // app paints, so there's no light→dark flash.
12
+ function initTheme(): void {
13
+ try {
14
+ const stored = localStorage.getItem("relaton-index-theme");
15
+ const dark =
16
+ stored === "dark" ||
17
+ (stored === null &&
18
+ window.matchMedia?.("(prefers-color-scheme: dark)").matches);
19
+ document.documentElement.classList.toggle("dark", !!dark);
20
+ } catch {
21
+ /* localStorage/matchMedia may be unavailable; ignore. */
22
+ }
23
+ }
24
+
25
+ // Off the critical path, but soon: the shell has nothing to show until the
26
+ // first shard lands, so this is only deferred past the initial paint. The
27
+ // `timeout` is not optional — without it an idle callback is not guaranteed to
28
+ // run at all, and a backgrounded or throttled tab would sit on an empty list
29
+ // forever, with every escape hatch (search, facets, a deep link) requiring the
30
+ // user to interact with a page showing nothing.
31
+ const IDLE_TIMEOUT_MS = 2000;
32
+
33
+ function whenIdle(cb: () => void): void {
34
+ if (typeof window.requestIdleCallback === "function") {
35
+ window.requestIdleCallback(() => cb(), { timeout: IDLE_TIMEOUT_MS });
36
+ } else {
37
+ window.setTimeout(cb, 0);
38
+ }
39
+ }
40
+
41
+ function boot(): void {
42
+ const el = document.getElementById(MOUNT_ID);
43
+ if (!el) return;
44
+
45
+ initTheme();
46
+
47
+ const { data, shards } = resolveIndex(el);
48
+ // `createApp(App, props)` props are static, so both of these have to be
49
+ // reactive objects we mutate as shards arrive — reassigning a prop would not
50
+ // reach the component.
51
+ const state = reactive<IndexData>({ ...data });
52
+
53
+ let started = false;
54
+ function start(): void {
55
+ if (started || shards.shards <= 0) return;
56
+ started = true;
57
+ loadSearchShards(shards, (docs) => {
58
+ state.documents = docs;
59
+ })
60
+ .catch((err) => {
61
+ // Individual shard failures are already handled inside the loader; this
62
+ // only catches something unexpected, and must not surface as an
63
+ // unhandled rejection.
64
+ console.warn("relaton-index: shard load failed", err);
65
+ })
66
+ .finally(() => {
67
+ hydration.loading = false;
68
+ });
69
+ }
70
+
71
+ const hydration = reactive<Hydration>({
72
+ loading: shards.shards > 0,
73
+ total: shards.total,
74
+ requestAll: start,
75
+ });
76
+
77
+ if (shards.shards > 0) whenIdle(start);
78
+
79
+ createApp(App, {
80
+ data: state,
81
+ hydration,
82
+ loadDetail: createDetailLoader(shards),
83
+ }).mount(el);
84
+ }
85
+
86
+ if (document.readyState === "loading") {
87
+ document.addEventListener("DOMContentLoaded", boot, { once: true });
88
+ } else {
89
+ boot();
90
+ }