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,100 @@
1
+ // Dev-only entry: shard the sample dataset in memory, serve it over a stubbed
2
+ // fetch, and boot the app exactly as a generated page would.
3
+ // Excluded from the production library build (which uses src/app.ts).
4
+ //
5
+ // This mirrors what Relaton::Cli::IndexSiteGenerator writes — the mount-node
6
+ // scalars plus `search-NNNN.json` / `detail-NNNN.json` — so `npm run dev`
7
+ // exercises the real hydration path instead of a shortcut that only exists here.
8
+ // The shard sizes are deliberately tiny so progressive loading is visible.
9
+ import sample from "./sample-data.json";
10
+ import type {
11
+ CompactRecord,
12
+ DetailRecord,
13
+ IndexData,
14
+ IndexDocument,
15
+ } from "../lib/types";
16
+
17
+ const SHARD_SIZE = 3;
18
+ const DETAIL_SHARD_SIZE = 2;
19
+
20
+ const data = sample as IndexData;
21
+ const documents = data.documents ?? [];
22
+
23
+ const SUMMARY_KEYS: readonly string[] = [
24
+ "id",
25
+ "title",
26
+ "doctype",
27
+ "stage",
28
+ "date",
29
+ "link",
30
+ "yaml",
31
+ ];
32
+
33
+ function toCompact(doc: IndexDocument): CompactRecord {
34
+ return {
35
+ r: doc.id,
36
+ c: doc.title,
37
+ t: doc.doctype,
38
+ s: doc.stage,
39
+ d: doc.date,
40
+ u: doc.yaml,
41
+ l: doc.link,
42
+ };
43
+ }
44
+
45
+ function toDetail(doc: IndexDocument): DetailRecord | null {
46
+ const rest = Object.fromEntries(
47
+ Object.entries(doc).filter(([key]) => !SUMMARY_KEYS.includes(key)),
48
+ );
49
+ if (Object.keys(rest).length === 0) return null;
50
+ return { r: doc.id, ...rest } as DetailRecord;
51
+ }
52
+
53
+ function chunk<T>(items: T[], size: number): T[][] {
54
+ const out: T[][] = [];
55
+ for (let i = 0; i < items.length; i += size) {
56
+ out.push(items.slice(i, i + size));
57
+ }
58
+ return out;
59
+ }
60
+
61
+ const summaryShards = chunk(documents.map(toCompact), SHARD_SIZE);
62
+ const detailShards = chunk(documents.map(toDetail), DETAIL_SHARD_SIZE);
63
+
64
+ const files = new Map<string, unknown>();
65
+ summaryShards.forEach((rows, i) => {
66
+ files.set(`search-${String(i).padStart(4, "0")}.json`, rows);
67
+ });
68
+ detailShards.forEach((rows, i) => {
69
+ files.set(`detail-${String(i).padStart(4, "0")}.json`, rows);
70
+ });
71
+
72
+ const realFetch = window.fetch.bind(window);
73
+ window.fetch = ((input: RequestInfo | URL, init?: RequestInit) => {
74
+ const url = typeof input === "string" ? input : String((input as Request).url ?? input);
75
+ const name = url.split("/").pop() ?? "";
76
+ const body = files.get(name);
77
+ if (body !== undefined) {
78
+ return Promise.resolve(
79
+ new Response(JSON.stringify(body), {
80
+ status: 200,
81
+ headers: { "content-type": "application/json" },
82
+ }),
83
+ );
84
+ }
85
+ return realFetch(input as RequestInfo, init);
86
+ }) as typeof window.fetch;
87
+
88
+ const el = document.getElementById("relaton-index-app");
89
+ if (el) {
90
+ el.dataset.title = data.title ?? "Relaton Index";
91
+ if (data.description) el.dataset.description = data.description;
92
+ if (data.generated) el.dataset.generated = data.generated;
93
+ el.dataset.total = String(documents.length);
94
+ el.dataset.shards = String(summaryShards.length);
95
+ el.dataset.shardSize = String(SHARD_SIZE);
96
+ el.dataset.detailShards = String(detailShards.length);
97
+ el.dataset.detailShardSize = String(DETAIL_SHARD_SIZE);
98
+ }
99
+
100
+ import("../app");
@@ -0,0 +1,71 @@
1
+ {
2
+ "title": "BIPM Index",
3
+ "generated": "2026-08-05",
4
+ "documents": [
5
+ {
6
+ "id": "BIPM CCRI 21 (2009)",
7
+ "title": "21st meeting of the CCRI",
8
+ "doctype": "meeting-report",
9
+ "stage": "published",
10
+ "date": "2009-06-19",
11
+ "link": "https://www.bipm.org/en/committees/cc/ccri/21-2009",
12
+ "yaml": "https://raw.githubusercontent.com/relaton/relaton-data-bipm/v2/data/ccri/meeting/21.yaml",
13
+ "abstract": "Report of the 21st meeting of the Consultative Committee for Ionizing Radiation (CCRI), held at the BIPM.",
14
+ "languages": ["en", "fr"],
15
+ "publisher": "International Bureau of Weights and Measures",
16
+ "contributors": [
17
+ { "name": "International Bureau of Weights and Measures", "role": "publisher" }
18
+ ],
19
+ "docids": [
20
+ { "id": "CCRI 21st Meeting (2009)", "type": "BIPM", "language": "en" },
21
+ { "id": "CCRI 21e réunion (2009)", "type": "BIPM", "language": "fr" }
22
+ ],
23
+ "dates": [{ "type": "published", "value": "2009-06-19" }],
24
+ "relations": [
25
+ { "type": "obsoletedBy", "id": "BIPM CCRI 22 (2013)" },
26
+ { "type": "partOf", "id": "BIPM CGPM 26 (2018)" },
27
+ { "type": "updates", "id": "ISO/IEC/IEEE 60559:2011" }
28
+ ]
29
+ },
30
+ {
31
+ "id": "BIPM CCRI 22 (2013)",
32
+ "title": "22nd meeting of the CCRI",
33
+ "doctype": "meeting-report",
34
+ "stage": "published",
35
+ "date": "2013-05-15",
36
+ "link": null,
37
+ "yaml": "https://raw.githubusercontent.com/relaton/relaton-data-bipm/v2/data/ccri/meeting/22.yaml"
38
+ },
39
+ {
40
+ "id": "BIPM CGPM 26 (2018)",
41
+ "title": "26th meeting of the CGPM",
42
+ "doctype": "meeting-report",
43
+ "stage": "published",
44
+ "date": "2018-11-13",
45
+ "link": null,
46
+ "yaml": "https://raw.githubusercontent.com/relaton/relaton-data-bipm/v2/data/cgpm/meeting/26.yaml"
47
+ },
48
+ {
49
+ "id": "BIPM SI Brochure (2019)",
50
+ "title": "The International System of Units (SI), 9th edition",
51
+ "doctype": "brochure",
52
+ "stage": "published",
53
+ "date": "2019-05-20",
54
+ "link": null,
55
+ "yaml": "https://raw.githubusercontent.com/relaton/relaton-data-bipm/v2/data/si-brochure.yaml",
56
+ "abstract": "The SI Brochure is the defining document for the International System of Units, published by the BIPM.",
57
+ "edition": "9",
58
+ "languages": ["en", "fr"],
59
+ "keywords": ["metrology", "SI units", "base units"],
60
+ "publisher": "International Bureau of Weights and Measures",
61
+ "contributors": [
62
+ { "name": "International Bureau of Weights and Measures", "role": "publisher" }
63
+ ],
64
+ "docids": [{ "id": "SI Brochure (2019)", "type": "BIPM", "language": "en" }],
65
+ "dates": [
66
+ { "type": "published", "value": "2019-05-20" },
67
+ { "type": "created", "value": "2018" }
68
+ ]
69
+ }
70
+ ]
71
+ }
@@ -0,0 +1,88 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { applyFilters, compareDocuments, distinctValues, matchesQuery } from "./filter";
3
+ import type { SortDir } from "./filter";
4
+ import type { IndexDocument } from "./types";
5
+
6
+ const doc = (over: Partial<IndexDocument>): IndexDocument => ({
7
+ id: "X 1",
8
+ title: "Title",
9
+ doctype: null,
10
+ stage: null,
11
+ date: null,
12
+ link: null,
13
+ yaml: null,
14
+ ...over,
15
+ });
16
+
17
+ const docs: IndexDocument[] = [
18
+ doc({ id: "CCRI 2", title: "Second meeting", doctype: "meeting-report", stage: "published", date: "2009-06-19" }),
19
+ doc({ id: "CCRI 10", title: "Tenth meeting", doctype: "meeting-report", stage: "draft", date: "2019-01-01" }),
20
+ doc({ id: "SI Brochure", title: "The SI", doctype: "brochure", stage: "published", date: "2019-05-20" }),
21
+ ];
22
+
23
+ describe("matchesQuery", () => {
24
+ it("matches all terms case-insensitively", () => {
25
+ expect(matchesQuery(docs[0], "second CCRI")).toBe(true);
26
+ expect(matchesQuery(docs[0], "brochure")).toBe(false);
27
+ expect(matchesQuery(docs[0], "")).toBe(true);
28
+ });
29
+ });
30
+
31
+ describe("distinctValues", () => {
32
+ it("returns sorted distinct non-empty values", () => {
33
+ expect(distinctValues(docs, "doctype")).toEqual(["brochure", "meeting-report"]);
34
+ expect(distinctValues(docs, "stage")).toEqual(["draft", "published"]);
35
+ });
36
+ });
37
+
38
+ describe("compareDocuments", () => {
39
+ it("sorts ids naturally (2 before 10)", () => {
40
+ expect(compareDocuments(docs[0], docs[1], "id", "asc")).toBeLessThan(0);
41
+ });
42
+ it("reverses on desc", () => {
43
+ expect(compareDocuments(docs[0], docs[1], "id", "desc")).toBeGreaterThan(0);
44
+ });
45
+ });
46
+
47
+ describe("applyFilters", () => {
48
+ it("filters by facet and sorts", () => {
49
+ const out = applyFilters(docs, {
50
+ query: "",
51
+ doctypes: new Set(["meeting-report"]),
52
+ stages: new Set(),
53
+ sortKey: "id",
54
+ sortDir: "asc",
55
+ });
56
+ expect(out.map((d) => d.id)).toEqual(["CCRI 2", "CCRI 10"]);
57
+ });
58
+
59
+ it("combines search + stage facet", () => {
60
+ const out = applyFilters(docs, {
61
+ query: "meeting",
62
+ doctypes: new Set(),
63
+ stages: new Set(["published"]),
64
+ sortKey: "date",
65
+ sortDir: "asc",
66
+ });
67
+ expect(out.map((d) => d.id)).toEqual(["CCRI 2"]);
68
+ });
69
+ });
70
+
71
+ describe("date sort with missing dates", () => {
72
+ it("sorts undated documents last in both directions", () => {
73
+ const docs = [
74
+ doc({ id: "undated-a", title: "x" }),
75
+ doc({ id: "dated-2020", title: "x", date: "2020-01-01" }),
76
+ doc({ id: "undated-b", title: "x" }),
77
+ doc({ id: "dated-1999", title: "x", date: "1999-01-01" }),
78
+ ];
79
+ const opts = (sortDir: SortDir) => ({
80
+ query: "", doctypes: new Set<string>(), stages: new Set<string>(),
81
+ sortKey: "date" as const, sortDir,
82
+ });
83
+ expect(applyFilters(docs, opts("asc")).map((d) => d.id))
84
+ .toEqual(["dated-1999", "dated-2020", "undated-a", "undated-b"]);
85
+ expect(applyFilters(docs, opts("desc")).map((d) => d.id))
86
+ .toEqual(["dated-2020", "dated-1999", "undated-a", "undated-b"]);
87
+ });
88
+ });
@@ -0,0 +1,95 @@
1
+ import type { IndexDocument } from "./types";
2
+
3
+ export type SortKey = "id" | "date" | "title";
4
+ export type SortDir = "asc" | "desc";
5
+
6
+ /** Lowercased haystack for a document (id + title + doctype). */
7
+ export function searchText(doc: IndexDocument): string {
8
+ return `${doc.id} ${doc.title} ${doc.doctype ?? ""}`.toLowerCase();
9
+ }
10
+
11
+ /** Case-insensitive AND-of-terms match over the search haystack. */
12
+ export function matchesQuery(doc: IndexDocument, query: string): boolean {
13
+ const q = query.trim().toLowerCase();
14
+ if (!q) return true;
15
+ const hay = searchText(doc);
16
+ return q.split(/\s+/).every((term) => hay.includes(term));
17
+ }
18
+
19
+ /** Distinct, sorted non-empty values of a field across documents. */
20
+ export function distinctValues(
21
+ docs: IndexDocument[],
22
+ key: "doctype" | "stage",
23
+ ): string[] {
24
+ const set = new Set<string>();
25
+ for (const d of docs) {
26
+ const v = d[key];
27
+ if (v) set.add(v);
28
+ }
29
+ return Array.from(set).sort((a, b) => a.localeCompare(b));
30
+ }
31
+
32
+ // Natural (human) comparison so "CCRI 2" sorts before "CCRI 10".
33
+ const collator = new Intl.Collator(undefined, {
34
+ numeric: true,
35
+ sensitivity: "base",
36
+ });
37
+
38
+ export function compareDocuments(
39
+ a: IndexDocument,
40
+ b: IndexDocument,
41
+ key: SortKey,
42
+ dir: SortDir,
43
+ ): number {
44
+ let cmp = 0;
45
+ switch (key) {
46
+ case "date": {
47
+ // Missing dates sort last regardless of direction. An empty string
48
+ // compared with localeCompare sorts FIRST ascending — with tens of
49
+ // thousands of undated records that made date-asc look like no-op.
50
+ const ad = a.date;
51
+ const bd = b.date;
52
+ if (!ad && !bd) return 0;
53
+ if (!ad) return 1;
54
+ if (!bd) return -1;
55
+ cmp = ad.localeCompare(bd);
56
+ break;
57
+ }
58
+ case "title":
59
+ cmp = collator.compare(a.title, b.title);
60
+ break;
61
+ case "id":
62
+ default:
63
+ cmp = collator.compare(a.id, b.id);
64
+ break;
65
+ }
66
+ return dir === "asc" ? cmp : -cmp;
67
+ }
68
+
69
+ export interface FilterOptions {
70
+ query: string;
71
+ doctypes: Set<string>;
72
+ stages: Set<string>;
73
+ sortKey: SortKey;
74
+ sortDir: SortDir;
75
+ }
76
+
77
+ /** Apply search + facet filters, then sort. Pure — safe to unit test. */
78
+ export function applyFilters(
79
+ docs: IndexDocument[],
80
+ opts: FilterOptions,
81
+ ): IndexDocument[] {
82
+ const filtered = docs.filter((d) => {
83
+ if (!matchesQuery(d, opts.query)) return false;
84
+ if (opts.doctypes.size && !(d.doctype && opts.doctypes.has(d.doctype))) {
85
+ return false;
86
+ }
87
+ if (opts.stages.size && !(d.stage && opts.stages.has(d.stage))) {
88
+ return false;
89
+ }
90
+ return true;
91
+ });
92
+ return filtered.sort((a, b) =>
93
+ compareDocuments(a, b, opts.sortKey, opts.sortDir),
94
+ );
95
+ }
@@ -0,0 +1,102 @@
1
+ // @vitest-environment happy-dom
2
+ import { afterEach, describe, expect, it } from "vitest";
3
+ import { readShardInfo, resolveIndex } from "./hydrate";
4
+
5
+ function mount(attrs: Record<string, string> = {}): HTMLElement {
6
+ const el = document.createElement("div");
7
+ el.id = "relaton-index-app";
8
+ Object.entries(attrs).forEach(([k, v]) => el.setAttribute(`data-${k}`, v));
9
+ document.body.appendChild(el);
10
+ return el;
11
+ }
12
+
13
+ afterEach(() => {
14
+ document.body.innerHTML = "";
15
+ document.title = "";
16
+ });
17
+
18
+ describe("resolveIndex", () => {
19
+ it("reads the branding off the mount node", () => {
20
+ const el = mount({
21
+ title: "BIPM Index",
22
+ description: "The BIPM standards index.",
23
+ generated: "2026-01-01",
24
+ });
25
+ const { data } = resolveIndex(el);
26
+ expect(data.title).toBe("BIPM Index");
27
+ expect(data.description).toBe("The BIPM standards index.");
28
+ expect(data.generated).toBe("2026-01-01");
29
+ });
30
+
31
+ // Documents arrive from the shards; the shell carries none, so the app paints
32
+ // immediately with an empty list rather than awaiting anything.
33
+ it("starts with no documents", () => {
34
+ const { data } = resolveIndex(mount({ total: "42", shards: "3" }));
35
+ expect(data.documents).toEqual([]);
36
+ });
37
+
38
+ it("exposes the shard layout alongside the branding", () => {
39
+ const { shards } = resolveIndex(mount({ total: "42", shards: "3" }));
40
+ expect(shards.total).toBe(42);
41
+ expect(shards.shards).toBe(3);
42
+ });
43
+
44
+ it("falls back to the page h1, then document.title, then a default", () => {
45
+ document.title = "From document title";
46
+ expect(resolveIndex(mount()).data.title).toBe("From document title");
47
+
48
+ document.body.innerHTML = "";
49
+ const h1 = document.createElement("h1");
50
+ h1.textContent = "From heading";
51
+ document.body.appendChild(h1);
52
+ expect(resolveIndex(mount()).data.title).toBe("From heading");
53
+
54
+ document.body.innerHTML = "";
55
+ document.title = "";
56
+ expect(resolveIndex(mount()).data.title).toBe("Relaton Index");
57
+ });
58
+
59
+ it("reports no description when the attribute is absent or blank", () => {
60
+ expect(resolveIndex(mount()).data.description).toBeNull();
61
+ expect(resolveIndex(mount({ description: "" })).data.description).toBeNull();
62
+ });
63
+ });
64
+
65
+ describe("readShardInfo", () => {
66
+ it("reads the five scalars the generator writes", () => {
67
+ const el = mount({
68
+ total: "166658",
69
+ shards: "34",
70
+ "shard-size": "5000",
71
+ "detail-shards": "334",
72
+ "detail-shard-size": "500",
73
+ });
74
+ expect(readShardInfo(el)).toEqual({
75
+ total: 166658,
76
+ shards: 34,
77
+ shardSize: 5000,
78
+ detailShards: 334,
79
+ detailShardSize: 500,
80
+ });
81
+ });
82
+
83
+ // An index with zero shards renders as an empty but correctly branded page —
84
+ // better than throwing on a malformed shell.
85
+ it("treats missing, blank or non-numeric attributes as zero", () => {
86
+ expect(readShardInfo(mount())).toEqual({
87
+ total: 0,
88
+ shards: 0,
89
+ shardSize: 0,
90
+ detailShards: 0,
91
+ detailShardSize: 0,
92
+ });
93
+ expect(readShardInfo(mount({ shards: "" })).shards).toBe(0);
94
+ expect(readShardInfo(mount({ shards: "lots" })).shards).toBe(0);
95
+ expect(readShardInfo(mount({ shards: "-3" })).shards).toBe(0);
96
+ });
97
+
98
+ it("reports zero detail shards for a --no-detail build", () => {
99
+ const el = mount({ total: "2", shards: "1", "detail-shards": "0" });
100
+ expect(readShardInfo(el).detailShards).toBe(0);
101
+ });
102
+ });
@@ -0,0 +1,55 @@
1
+ import type { IndexData, ShardInfo } from "./types";
2
+
3
+ // Read the page's index metadata off the mount node. There is exactly one
4
+ // delivery mode: the shell carries branding plus the shard layout, and the
5
+ // documents themselves arrive from the `search-NNNN.json` shards (see
6
+ // ./shards.ts). Nothing is fetched here, so this is synchronous and the app can
7
+ // paint immediately instead of waiting on a manifest round-trip.
8
+ export interface HydratedIndex {
9
+ /** Branding + an empty document list for the app to fill as shards land. */
10
+ data: IndexData;
11
+ shards: ShardInfo;
12
+ }
13
+
14
+ export function resolveIndex(el: HTMLElement): HydratedIndex {
15
+ return {
16
+ data: {
17
+ title: pageTitle(el),
18
+ description: pageDescription(el),
19
+ documents: [],
20
+ generated: el.dataset.generated || null,
21
+ },
22
+ shards: readShardInfo(el),
23
+ };
24
+ }
25
+
26
+ // A missing or unparseable attribute reads as 0 — an index with no shards,
27
+ // which renders as an empty (but correctly branded) page rather than throwing.
28
+ function count(value: string | undefined): number {
29
+ const n = Number.parseInt(value ?? "", 10);
30
+ return Number.isFinite(n) && n > 0 ? n : 0;
31
+ }
32
+
33
+ export function readShardInfo(el: HTMLElement): ShardInfo {
34
+ return {
35
+ total: count(el.dataset.total),
36
+ shards: count(el.dataset.shards),
37
+ shardSize: count(el.dataset.shardSize),
38
+ detailShards: count(el.dataset.detailShards),
39
+ detailShardSize: count(el.dataset.detailShardSize),
40
+ };
41
+ }
42
+
43
+ function pageTitle(el: HTMLElement): string {
44
+ return (
45
+ el.dataset.title ||
46
+ document.querySelector("h1")?.textContent?.trim() ||
47
+ document.title ||
48
+ "Relaton Index"
49
+ );
50
+ }
51
+
52
+ /** The site blurb, carried on the mount node (also the meta description). */
53
+ function pageDescription(el: HTMLElement): string | null {
54
+ return el.dataset.description || null;
55
+ }