relaton 3.0.7 → 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,151 @@
1
+ // @vitest-environment happy-dom
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3
+ import {
4
+ docHref,
5
+ readDocFromUrl,
6
+ readPageFromUrl,
7
+ writeDocToUrl,
8
+ writePageToUrl,
9
+ } from "./url";
10
+
11
+ beforeEach(() => {
12
+ // Start every test from a clean, param-free URL.
13
+ window.history.replaceState(null, "", "/");
14
+ });
15
+
16
+ describe("readPageFromUrl", () => {
17
+ it("parses a valid page param", () => {
18
+ expect(readPageFromUrl("?page=3")).toBe(3);
19
+ });
20
+
21
+ it("falls back to 1 when the param is missing", () => {
22
+ expect(readPageFromUrl("")).toBe(1);
23
+ expect(readPageFromUrl("?foo=1")).toBe(1);
24
+ });
25
+
26
+ it("falls back to 1 for zero, negative, fractional, or non-numeric values", () => {
27
+ expect(readPageFromUrl("?page=0")).toBe(1);
28
+ expect(readPageFromUrl("?page=-2")).toBe(1);
29
+ expect(readPageFromUrl("?page=2.5")).toBe(1);
30
+ expect(readPageFromUrl("?page=abc")).toBe(1);
31
+ });
32
+
33
+ it("reads the page param alongside other params", () => {
34
+ expect(readPageFromUrl("?foo=1&page=4")).toBe(4);
35
+ });
36
+
37
+ it("defaults to the live location when no search string is given", () => {
38
+ window.history.replaceState(null, "", "/?page=7");
39
+ expect(readPageFromUrl()).toBe(7);
40
+ });
41
+ });
42
+
43
+ describe("writePageToUrl", () => {
44
+ afterEach(() => {
45
+ vi.restoreAllMocks();
46
+ });
47
+
48
+ it("sets ?page=N while preserving other params", () => {
49
+ window.history.replaceState(null, "", "/?foo=bar");
50
+ writePageToUrl(2);
51
+ const params = new URLSearchParams(window.location.search);
52
+ expect(params.get("page")).toBe("2");
53
+ expect(params.get("foo")).toBe("bar");
54
+ });
55
+
56
+ it("deletes the page param for page 1 (clean canonical URL)", () => {
57
+ window.history.replaceState(null, "", "/?page=5");
58
+ writePageToUrl(1);
59
+ expect(new URLSearchParams(window.location.search).has("page")).toBe(false);
60
+ });
61
+
62
+ it("uses replaceState by default", () => {
63
+ const replace = vi.spyOn(window.history, "replaceState");
64
+ const push = vi.spyOn(window.history, "pushState");
65
+ writePageToUrl(3);
66
+ expect(replace).toHaveBeenCalledTimes(1);
67
+ expect(push).not.toHaveBeenCalled();
68
+ });
69
+
70
+ it("uses pushState when mode is 'push'", () => {
71
+ const replace = vi.spyOn(window.history, "replaceState");
72
+ const push = vi.spyOn(window.history, "pushState");
73
+ writePageToUrl(3, "push");
74
+ expect(push).toHaveBeenCalledTimes(1);
75
+ expect(replace).not.toHaveBeenCalled();
76
+ });
77
+ });
78
+
79
+ describe("readDocFromUrl", () => {
80
+ it("returns the decoded doc id when present", () => {
81
+ expect(readDocFromUrl("?doc=CCRI%2021%20(2009)")).toBe("CCRI 21 (2009)");
82
+ });
83
+
84
+ it("returns null when the param is missing or blank", () => {
85
+ expect(readDocFromUrl("")).toBeNull();
86
+ expect(readDocFromUrl("?page=2")).toBeNull();
87
+ expect(readDocFromUrl("?doc=")).toBeNull();
88
+ });
89
+
90
+ it("reads the doc param alongside other params", () => {
91
+ expect(readDocFromUrl("?page=3&doc=ISO%201234")).toBe("ISO 1234");
92
+ });
93
+
94
+ it("defaults to the live location when no search string is given", () => {
95
+ window.history.replaceState(null, "", "/?doc=X%201");
96
+ expect(readDocFromUrl()).toBe("X 1");
97
+ });
98
+ });
99
+
100
+ describe("writeDocToUrl", () => {
101
+ afterEach(() => {
102
+ vi.restoreAllMocks();
103
+ });
104
+
105
+ it("sets a path-based /doc/ID URL (encoded) while preserving page", () => {
106
+ window.history.replaceState(null, "", "/?page=2");
107
+ writeDocToUrl("CCRI 21 (2009)");
108
+ expect(window.location.pathname).toBe("/doc/CCRI%2021%20(2009)");
109
+ expect(new URLSearchParams(window.location.search).get("page")).toBe("2");
110
+ });
111
+
112
+ it("round-trips an id with spaces and parens", () => {
113
+ writeDocToUrl("BIPM SI Brochure (2019)");
114
+ expect(readDocFromUrl()).toBe("BIPM SI Brochure (2019)");
115
+ });
116
+
117
+ it("reads a path-based id written by the 404 fallback or a shared link", () => {
118
+ window.history.replaceState(null, "", "/doc/BCP%203?page=1");
119
+ expect(readDocFromUrl()).toBe("BCP 3");
120
+ });
121
+
122
+ it("returns to the list path when passed null", () => {
123
+ window.history.replaceState(null, "", "/doc/X?page=2");
124
+ writeDocToUrl(null);
125
+ expect(window.location.pathname).toBe("/");
126
+ expect(new URLSearchParams(window.location.search).get("page")).toBe("2");
127
+ });
128
+
129
+ it("uses replaceState by default and pushState when asked", () => {
130
+ const replace = vi.spyOn(window.history, "replaceState");
131
+ const push = vi.spyOn(window.history, "pushState");
132
+ writeDocToUrl("A");
133
+ expect(replace).toHaveBeenCalledTimes(1);
134
+ expect(push).not.toHaveBeenCalled();
135
+ writeDocToUrl("B", "push");
136
+ expect(push).toHaveBeenCalledTimes(1);
137
+ });
138
+ });
139
+
140
+ describe("docHref", () => {
141
+ it("builds a relative path-based /doc/ link", () => {
142
+ window.history.replaceState(null, "", "/");
143
+ expect(docHref("ISO 1234:2020")).toBe("/doc/ISO%201234%3A2020");
144
+ });
145
+
146
+ it("encodes ids so they round-trip through readDocFromUrl", () => {
147
+ const id = "CCRI 21st Meeting (2009)";
148
+ window.history.replaceState(null, "", docHref(id));
149
+ expect(readDocFromUrl()).toBe(id);
150
+ });
151
+ });
@@ -0,0 +1,111 @@
1
+ // Reflects two pieces of view state in the URL so a reload / shared / bookmarked
2
+ // link restores it: the pagination page (`?page=N`) and the open detail document
3
+ // (`?doc=<id>`). Both go through the History API, preserve any other params, and
4
+ // are synced back on Back/Forward via a `popstate` listener in App.vue.
5
+ // (search/facets/sort stay in-memory; view/theme use localStorage.) Page 1 and a
6
+ // closed detail omit their param to keep the canonical URL clean.
7
+
8
+ const PARAM = "page";
9
+ const DOC_PARAM = "doc";
10
+
11
+ /**
12
+ * Parse the `page` query param. Returns an integer >= 1, or 1 for anything
13
+ * invalid (missing, zero, negative, fractional, non-numeric).
14
+ */
15
+ export function readPageFromUrl(
16
+ search: string = typeof window !== "undefined" ? window.location.search : "",
17
+ ): number {
18
+ const raw = new URLSearchParams(search).get(PARAM);
19
+ if (raw === null) return 1;
20
+ const n = Number(raw);
21
+ return Number.isInteger(n) && n >= 1 ? n : 1;
22
+ }
23
+
24
+ /**
25
+ * Reflect `page` into the URL, preserving any other query params. Page 1 drops
26
+ * the param. `mode: "push"` adds a history entry (user navigation, so
27
+ * Back/Forward walk pages); `"replace"` (default) updates in place (restore,
28
+ * clamp, filter-reset).
29
+ */
30
+ export function writePageToUrl(
31
+ page: number,
32
+ mode: "push" | "replace" = "replace",
33
+ ): void {
34
+ if (typeof window === "undefined" || !window.history) return;
35
+ const url = new URL(window.location.href);
36
+ if (page <= 1) url.searchParams.delete(PARAM);
37
+ else url.searchParams.set(PARAM, String(page));
38
+ // Relative target avoids origin surprises across dev/gem/host contexts.
39
+ const target = `${url.pathname}${url.search}${url.hash}`;
40
+ if (mode === "push") window.history.pushState(window.history.state, "", target);
41
+ else window.history.replaceState(window.history.state, "", target);
42
+ }
43
+
44
+ /**
45
+ * A relative, shareable link to a document's detail page. Used for the real
46
+ * anchors that SPA-navigation intercepts — the list rows and the intra-dataset
47
+ * relation links — so middle-click / open-in-new-tab still work. Built with the
48
+ * same `URLSearchParams` encoding as `writeDocToUrl`, so the two agree exactly.
49
+ */
50
+ export function docHref(id: string): string {
51
+ return `${docPathBase()}/doc/${encodeURIComponent(id)}`;
52
+ }
53
+
54
+ // The app root (site base path) for path-based doc URLs: whatever precedes a
55
+ // trailing `/doc/<id>` segment on the current pathname, or the full pathname
56
+ // when none (the list view at `/` or `/index.html`).
57
+ function docPathBase(): string {
58
+ const p = window.location.pathname;
59
+ const cut = p.lastIndexOf("/doc/");
60
+ const base = cut >= 0 ? p.slice(0, cut) : p.replace(/\/index\.html$/, "");
61
+ return base === "/" ? "" : base.replace(/\/+$/, "");
62
+ }
63
+
64
+ /**
65
+ * Parse the `doc` query param (the open detail document's id). Returns the
66
+ * decoded id, or null when absent/blank. `URLSearchParams` handles decoding, so
67
+ * ids with spaces/parens round-trip with `writeDocToUrl`.
68
+ */
69
+ export function readDocFromUrl(
70
+ search: string = typeof window !== "undefined" ? window.location.search : "",
71
+ ): string | null {
72
+ if (search === (typeof window !== "undefined" ? window.location.search : "")) {
73
+ // Path-based URLs first: `/doc/<id>` served via the 404 fallback (GitHub
74
+ // Pages has no rewrites) or written by writeDocToUrl below.
75
+ const m = window.location.pathname.match(/\/doc\/([^/?#]+)$/);
76
+ if (m) {
77
+ const fromPath = decodeURIComponent(m[1]);
78
+ if (fromPath.trim() !== "") return fromPath;
79
+ }
80
+ }
81
+ const raw = new URLSearchParams(search).get(DOC_PARAM);
82
+ return raw && raw.trim() !== "" ? raw : null;
83
+ }
84
+
85
+ /**
86
+ * Reflect the open detail document into the URL, preserving other params (e.g.
87
+ * `page`, so closing the detail returns to the same list page). A null id drops
88
+ * the param. `mode: "push"` adds a history entry (opening/closing a detail, so
89
+ * Back/Forward walk in and out of it); `"replace"` (default) updates in place.
90
+ */
91
+ export function writeDocToUrl(
92
+ id: string | null,
93
+ mode: "push" | "replace" = "replace",
94
+ ): void {
95
+ if (typeof window === "undefined" || !window.history) return;
96
+ const url = new URL(window.location.href);
97
+ if (id) {
98
+ // Canonical per-document URLs are path-based (`<root>/doc/<id>`) so each
99
+ // document has a shareable address; `?doc=` remains readable for links
100
+ // written before paths existed and by the 404 fallback redirect.
101
+ const base = docPathBase();
102
+ const target = `${base}/doc/${encodeURIComponent(id)}${url.search && url.searchParams.has(PARAM) ? `?${url.searchParams}` : ""}${url.hash}`;
103
+ if (mode === "push") window.history.pushState(window.history.state, "", target);
104
+ else window.history.replaceState(window.history.state, "", target);
105
+ return;
106
+ }
107
+ url.searchParams.delete(DOC_PARAM);
108
+ const target = `${docPathBase()}/${url.search}${url.hash}`;
109
+ if (mode === "push") window.history.pushState(window.history.state, "", target);
110
+ else window.history.replaceState(window.history.state, "", target);
111
+ }
@@ -0,0 +1,5 @@
1
+ declare module "*.vue" {
2
+ import type { DefineComponent } from "vue";
3
+ const component: DefineComponent<Record<string, unknown>, Record<string, unknown>, unknown>;
4
+ export default component;
5
+ }
@@ -0,0 +1,27 @@
1
+ @import "tailwindcss";
2
+
3
+ /* Drive the `dark:` variant from a `.dark` class on any ancestor (we toggle it
4
+ on <html>), rather than the OS media query alone, so the in-page theme toggle
5
+ works. Falls back to the OS preference on first load (see app.ts). */
6
+ @custom-variant dark (&:where(.dark, .dark *));
7
+
8
+ /* Design tokens. Kept small; components use Tailwind utilities on top. */
9
+ @theme {
10
+ --color-brand: #1e6bb8;
11
+ --color-brand-dark: #4a9fe0;
12
+ --font-sans: "Inter", ui-sans-serif, system-ui, -apple-system, "Segoe UI",
13
+ Roboto, Helvetica, Arial, sans-serif;
14
+ }
15
+
16
+ /* Scope a couple of base rules to the app so we don't fight a host page's CSS. */
17
+ #relaton-index-app {
18
+ font-family: var(--font-sans);
19
+ line-height: 1.5;
20
+ -webkit-font-smoothing: antialiased;
21
+ }
22
+
23
+ /* Hide the server-rendered crawler DOM once Vue has taken over (Vue mounts into
24
+ the same node and replaces its children, but guard against a flash). */
25
+ #relaton-index-app > .documents[hidden] {
26
+ display: none;
27
+ }
@@ -0,0 +1,58 @@
1
+ import { vi } from "vitest";
2
+
3
+ // Minimal fetch stub for the shard loaders. Routes are keyed by the file name
4
+ // (the last path segment), which is how the loaders address shards.
5
+ //
6
+ // A route value may be:
7
+ // * a value — served as JSON with status 200
8
+ // * a number — served as that HTTP status with no body (for 404 tests)
9
+ // * a Promise — resolved when it resolves (for out-of-order tests)
10
+ // * a function — called per request, returning any of the above
11
+ // An unrouted path 404s, so a test only declares the shards it cares about.
12
+
13
+ export type Route = unknown | Promise<unknown> | (() => unknown);
14
+
15
+ export interface FetchStub {
16
+ /** Paths requested, in call order. */
17
+ calls: string[];
18
+ restore: () => void;
19
+ }
20
+
21
+ function respond(body: unknown): Response {
22
+ if (typeof body === "number") {
23
+ return new Response(null, { status: body });
24
+ }
25
+ return new Response(JSON.stringify(body), {
26
+ status: 200,
27
+ headers: { "content-type": "application/json" },
28
+ });
29
+ }
30
+
31
+ export function stubFetch(routes: Record<string, Route>): FetchStub {
32
+ const calls: string[] = [];
33
+
34
+ const impl = async (input: RequestInfo | URL): Promise<Response> => {
35
+ const url =
36
+ typeof input === "string" ? input : String((input as Request).url ?? input);
37
+ calls.push(url);
38
+
39
+ const name = url.split("/").pop() ?? "";
40
+ if (!(name in routes)) return new Response(null, { status: 404 });
41
+
42
+ let value = routes[name];
43
+ if (typeof value === "function") value = (value as () => unknown)();
44
+ return respond(await value);
45
+ };
46
+
47
+ vi.stubGlobal("fetch", vi.fn(impl));
48
+ return { calls, restore: () => vi.unstubAllGlobals() };
49
+ }
50
+
51
+ /** A promise plus the handle to settle it, for controlling resolution order. */
52
+ export function deferred<T>(): { promise: Promise<T>; resolve: (v: T) => void } {
53
+ let resolve!: (v: T) => void;
54
+ const promise = new Promise<T>((r) => {
55
+ resolve = r;
56
+ });
57
+ return { promise, resolve };
58
+ }
@@ -0,0 +1,20 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2020",
4
+ "useDefineForClassFields": true,
5
+ "module": "ESNext",
6
+ "moduleResolution": "bundler",
7
+ "strict": true,
8
+ "jsx": "preserve",
9
+ "resolveJsonModule": true,
10
+ "isolatedModules": true,
11
+ "esModuleInterop": true,
12
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
13
+ "skipLibCheck": true,
14
+ "noEmit": true,
15
+ "baseUrl": ".",
16
+ "paths": { "@/*": ["src/*"] }
17
+ },
18
+ "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.vue"],
19
+ "exclude": ["dist", "node_modules"]
20
+ }
@@ -0,0 +1,42 @@
1
+ import { defineConfig } from "vite";
2
+ import vue from "@vitejs/plugin-vue";
3
+ import tailwindcss from "@tailwindcss/vite";
4
+ import { resolve } from "node:path";
5
+
6
+ const root = import.meta.dirname;
7
+
8
+ // Library-mode build that emits exactly ONE self-executing bundle
9
+ // (`dist/app.iife.js`) plus one stylesheet (`dist/style.css`). Vue and
10
+ // Tailwind are bundled in, so the output is fully self-contained and can be
11
+ // inlined verbatim into a generated HTML page by the relaton-cli generator.
12
+ //
13
+ // `formats: ["iife"]` + `inlineDynamicImports: true` guarantees a single JS
14
+ // chunk (no code-splitting), matching the lutaml-xsd embedding pattern.
15
+ export default defineConfig({
16
+ plugins: [vue(), tailwindcss()],
17
+ resolve: {
18
+ alias: { "@": resolve(root, "src") },
19
+ },
20
+ build: {
21
+ target: "es2020",
22
+ cssCodeSplit: false,
23
+ minify: "terser",
24
+ terserOptions: { compress: { drop_console: true } },
25
+ // Library + iife format already emits a single self-executing chunk with no
26
+ // code-splitting, so the bundle is one JS file the generator can inline.
27
+ lib: {
28
+ entry: resolve(root, "src/app.ts"),
29
+ name: "RelatonIndex",
30
+ fileName: () => "app.iife.js",
31
+ formats: ["iife"],
32
+ },
33
+ rollupOptions: {
34
+ output: {
35
+ assetFileNames: "style.css",
36
+ },
37
+ },
38
+ },
39
+ define: {
40
+ "process.env.NODE_ENV": JSON.stringify("production"),
41
+ },
42
+ });
@@ -0,0 +1,11 @@
1
+ import { defineConfig } from "vitest/config";
2
+ import vue from "@vitejs/plugin-vue";
3
+
4
+ export default defineConfig({
5
+ plugins: [vue()],
6
+ test: {
7
+ // Per-file override via `// @vitest-environment happy-dom` where DOM is
8
+ // needed; pure logic tests stay in the default node environment.
9
+ environment: "node",
10
+ },
11
+ });
@@ -0,0 +1,58 @@
1
+ module Relaton
2
+ module Cli
3
+ # Resolves the compiled frontend bundle (`app.iife.js` + `style.css`) that is
4
+ # built from `gems/relaton-cli/frontend/` at gem-build time and shipped inside
5
+ # the gem under `frontend/dist/`. Both the installed gem and a git checkout
6
+ # place `frontend/dist` at the gem root, so one relative path covers both.
7
+ #
8
+ # If the bundle is missing (a fresh checkout that hasn't run the frontend
9
+ # build), raise a clear, actionable error rather than emitting a broken page.
10
+ module FrontendAssets
11
+ class BuildMissingError < StandardError; end
12
+
13
+ # gem root = up three from lib/relaton/cli
14
+ DEFAULT_DIST = File.expand_path("frontend/dist", __dir__).freeze
15
+
16
+ class << self
17
+ # Overridable so specs can point at a fixture dist without a Node build.
18
+ attr_writer :dist_dir
19
+
20
+ def dist_dir
21
+ @dist_dir || DEFAULT_DIST
22
+ end
23
+
24
+ # Run a block with a temporary dist dir, restoring the previous one.
25
+ def with_dist_dir(dir)
26
+ previous = @dist_dir
27
+ @dist_dir = dir
28
+ yield
29
+ ensure
30
+ @dist_dir = previous
31
+ end
32
+
33
+ def iife
34
+ read("app.iife.js")
35
+ end
36
+
37
+ def stylesheet
38
+ read("style.css")
39
+ end
40
+
41
+ def built?
42
+ File.exist?(File.join(dist_dir, "app.iife.js"))
43
+ end
44
+
45
+ def read(name)
46
+ path = File.join(dist_dir, name)
47
+ unless File.exist?(path)
48
+ raise BuildMissingError,
49
+ "Frontend build missing (#{name} not found in #{dist_dir}). " \
50
+ "Run `bundle exec rake build_frontend` in gems/relaton-cli " \
51
+ "(or `cd frontend && npm install && npm run build`) first."
52
+ end
53
+ File.read(path, encoding: "utf-8")
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,98 @@
1
+ require "set"
2
+
3
+ module Relaton
4
+ class FullTextSeatch
5
+ # Instance variables added by lutaml-model that form back-references
6
+ # (cycles) when a model is serialized. Skipping them keeps recursive
7
+ # traversal of a bibitem's object graph finite.
8
+ INTERNAL_IVARS = %i[
9
+ @using_default @lutaml_register @lutaml_parent @lutaml_root @register_records
10
+ ].freeze
11
+
12
+ # @return Regexp
13
+ attr_reader :regex
14
+
15
+ # @param collection [Relaton::Bibcollection]
16
+ def initialize(collection)
17
+ @collection = collection
18
+ end
19
+
20
+ # @param text [String]
21
+ # @return [Array<Hash>]
22
+ def search(text)
23
+ @regex = %{(.*?)(.{0,20})(#{text})(.{0,20})(.*)}
24
+ @matches = @collection.items.reduce({}) do |m, item|
25
+ res = result item, Set.new
26
+ m[item.id] = res if res.any?
27
+ m
28
+ end
29
+ end
30
+
31
+ def print_results
32
+ @matches.each do |docid, attrs|
33
+ puts " Document ID: #{docid}"
34
+ print_attrs attrs, 4
35
+ end
36
+ end
37
+
38
+ # @return [Boolean]
39
+ def any?
40
+ @matches.any?
41
+ end
42
+
43
+ private
44
+
45
+ # rubocop:disable Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/MethodLength,Metrics/PerceivedComplexity
46
+ def print_attrs(attrs, indent)
47
+ ind = " " * indent
48
+ if attrs.is_a? String then puts ind + attrs
49
+ elsif attrs.is_a? Hash
50
+ attrs.each do |key, val|
51
+ pref = "#{ind}#{key}:"
52
+ if val.is_a? String then puts pref + " " + val
53
+ else
54
+ puts pref
55
+ print_attrs val, indent + 2
56
+ end
57
+ end
58
+ elsif attrs.is_a? Array then attrs.each { |v| print_attrs v, indent + 2 }
59
+ end
60
+ end
61
+
62
+ # @param item [Relaton::Bibdata]
63
+ # @param seen [Set<Integer>] object_ids already visited on this path
64
+ # @return [Hash]
65
+ def result(item, seen)
66
+ if item.is_a? String
67
+ message $~ if item.match regex
68
+ elsif item.respond_to? :reduce
69
+ item.reduce([]) do |m, i|
70
+ res = result i, seen
71
+ m << res if res && !res.empty?
72
+ m
73
+ end
74
+ else
75
+ return {} unless seen.add?(item.object_id)
76
+
77
+ item.instance_variables.reduce({}) do |m, var|
78
+ next m if INTERNAL_IVARS.include?(var)
79
+
80
+ res = result item.instance_variable_get(var), seen
81
+ m[var.to_s.tr(":@", "")] = res if res && !res.empty?
82
+ m
83
+ end
84
+ end
85
+ end
86
+ # rubocop:enable Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/MethodLength,Metrics/PerceivedComplexity
87
+
88
+ # @param match [MatchData]
89
+ # @return [String]
90
+ def message(match)
91
+ msg = ""
92
+ msg += "..." unless match[1].empty?
93
+ msg += "#{match[2]}\e[4m#{match[3]}\e[24m#{match[4]}"
94
+ msg += "..." unless match[5].empty?
95
+ msg
96
+ end
97
+ end
98
+ end
@@ -0,0 +1 @@
1
+ label: label