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.
- checksums.yaml +4 -4
- data/bin/relaton +20 -0
- data/lib/relaton/bib/model/docidentifier.rb +17 -2
- data/lib/relaton/bibcollection.rb +146 -0
- data/lib/relaton/bibdata.rb +92 -0
- data/lib/relaton/bipm/data_outcomes_parser.rb +6 -1
- data/lib/relaton/cli/base_convertor.rb +134 -0
- data/lib/relaton/cli/command.rb +390 -0
- data/lib/relaton/cli/data_fetcher.rb +20 -0
- data/lib/relaton/cli/frontend/dist/404.html +18 -0
- data/lib/relaton/cli/frontend/dist/app.iife.js +1 -0
- data/lib/relaton/cli/frontend/dist/style.css +3 -0
- data/lib/relaton/cli/frontend/index.html +14 -0
- data/lib/relaton/cli/frontend/package-lock.json +2945 -0
- data/lib/relaton/cli/frontend/package.json +29 -0
- data/lib/relaton/cli/frontend/public/404.html +18 -0
- data/lib/relaton/cli/frontend/src/App.test.ts +501 -0
- data/lib/relaton/cli/frontend/src/App.vue +506 -0
- data/lib/relaton/cli/frontend/src/app.ts +90 -0
- data/lib/relaton/cli/frontend/src/components/DocumentDetail.test.ts +142 -0
- data/lib/relaton/cli/frontend/src/components/DocumentDetail.vue +228 -0
- data/lib/relaton/cli/frontend/src/components/DocumentRow.vue +88 -0
- data/lib/relaton/cli/frontend/src/components/Icon.test.ts +44 -0
- data/lib/relaton/cli/frontend/src/components/Icon.vue +55 -0
- data/lib/relaton/cli/frontend/src/components/Pagination.vue +26 -0
- data/lib/relaton/cli/frontend/src/dev/main.ts +100 -0
- data/lib/relaton/cli/frontend/src/dev/sample-data.json +71 -0
- data/lib/relaton/cli/frontend/src/lib/filter.test.ts +88 -0
- data/lib/relaton/cli/frontend/src/lib/filter.ts +95 -0
- data/lib/relaton/cli/frontend/src/lib/hydrate.test.ts +102 -0
- data/lib/relaton/cli/frontend/src/lib/hydrate.ts +55 -0
- data/lib/relaton/cli/frontend/src/lib/shards.test.ts +319 -0
- data/lib/relaton/cli/frontend/src/lib/shards.ts +183 -0
- data/lib/relaton/cli/frontend/src/lib/types.ts +148 -0
- data/lib/relaton/cli/frontend/src/lib/url.test.ts +151 -0
- data/lib/relaton/cli/frontend/src/lib/url.ts +111 -0
- data/lib/relaton/cli/frontend/src/shims-vue.d.ts +5 -0
- data/lib/relaton/cli/frontend/src/styles/index.css +27 -0
- data/lib/relaton/cli/frontend/src/test/fetch.ts +58 -0
- data/lib/relaton/cli/frontend/tsconfig.json +20 -0
- data/lib/relaton/cli/frontend/vite.config.ts +42 -0
- data/lib/relaton/cli/frontend/vitest.config.ts +11 -0
- data/lib/relaton/cli/frontend_assets.rb +58 -0
- data/lib/relaton/cli/full_text_search.rb +98 -0
- data/lib/relaton/cli/i18n.yaml +1 -0
- data/lib/relaton/cli/index_item_normalizer.rb +380 -0
- data/lib/relaton/cli/index_site_generator.rb +1015 -0
- data/lib/relaton/cli/relaton_file.rb +290 -0
- data/lib/relaton/cli/subcommand_collection.rb +263 -0
- data/lib/relaton/cli/subcommand_db.rb +81 -0
- data/lib/relaton/cli/templates/_document.liquid +111 -0
- data/lib/relaton/cli/templates/_index.liquid +61 -0
- data/lib/relaton/cli/templates/index/page.liquid +20 -0
- data/lib/relaton/cli/templates/index-style.css +1017 -0
- data/lib/relaton/cli/util.rb +8 -0
- data/lib/relaton/cli/version.rb +11 -0
- data/lib/relaton/cli/xml_convertor.rb +47 -0
- data/lib/relaton/cli/xml_to_html_renderer.rb +117 -0
- data/lib/relaton/cli/yaml_convertor.rb +102 -0
- data/lib/relaton/cli.rb +113 -0
- data/lib/relaton/doi/CLAUDE.md +1 -1
- data/lib/relaton/element_finder.rb +29 -0
- data/lib/relaton/gb/CLAUDE.md +1 -1
- data/lib/relaton/ietf/CLAUDE.md +1 -1
- data/lib/relaton/iho/CLAUDE.md +3 -3
- data/lib/relaton/iho/bibliography.rb +41 -27
- data/lib/relaton/iho/processor.rb +3 -2
- data/lib/relaton/nist/CLAUDE.md +11 -1
- data/lib/relaton/nist/README.adoc +7 -1
- data/lib/relaton/nist/hit_collection.rb +25 -1
- data/lib/relaton/nist/mods_parser.rb +32 -8
- data/lib/relaton/nist/pubs_export.rb +69 -11
- data/lib/relaton/ogc/CLAUDE.md +1 -1
- data/lib/relaton/omg/CLAUDE.md +1 -1
- data/lib/relaton/plateau/CLAUDE.md +1 -1
- data/lib/relaton/render/general.rb +319 -0
- data/lib/relaton/render/iso690/csl_import.rb +129 -0
- data/lib/relaton/render/iso690/element.rb +153 -0
- data/lib/relaton/render/iso690/elements/access.rb +44 -0
- data/lib/relaton/render/iso690/elements/authorizer.rb +45 -0
- data/lib/relaton/render/iso690/elements/citeid.rb +39 -0
- data/lib/relaton/render/iso690/elements/component_part.rb +97 -0
- data/lib/relaton/render/iso690/elements/creator.rb +267 -0
- data/lib/relaton/render/iso690/elements/edition.rb +97 -0
- data/lib/relaton/render/iso690/elements/extent.rb +71 -0
- data/lib/relaton/render/iso690/elements/identifier.rb +46 -0
- data/lib/relaton/render/iso690/elements/location.rb +73 -0
- data/lib/relaton/render/iso690/elements/medium.rb +32 -0
- data/lib/relaton/render/iso690/elements/numeration.rb +26 -0
- data/lib/relaton/render/iso690/elements/production.rb +111 -0
- data/lib/relaton/render/iso690/elements/pub_date.rb +63 -0
- data/lib/relaton/render/iso690/elements/series.rb +66 -0
- data/lib/relaton/render/iso690/elements/size.rb +45 -0
- data/lib/relaton/render/iso690/elements/status.rb +30 -0
- data/lib/relaton/render/iso690/elements/stddoc.rb +40 -0
- data/lib/relaton/render/iso690/elements/title.rb +56 -0
- data/lib/relaton/render/iso690/elements/updated.rb +38 -0
- data/lib/relaton/render/iso690/elements.rb +67 -0
- data/lib/relaton/render/iso690/fields.rb +142 -0
- data/lib/relaton/render/iso690/i18n/en.yml +48 -0
- data/lib/relaton/render/iso690/i18n/fr.yml +14 -0
- data/lib/relaton/render/iso690/i18n/ja.yml +38 -0
- data/lib/relaton/render/iso690/i18n.rb +83 -0
- data/lib/relaton/render/iso690/identifier_kinds.rb +29 -0
- data/lib/relaton/render/iso690/index_renderer.rb +57 -0
- data/lib/relaton/render/iso690/kinds.rb +53 -0
- data/lib/relaton/render/iso690/renderer.rb +150 -0
- data/lib/relaton/render/iso690/rules/bipm.rb +186 -0
- data/lib/relaton/render/iso690/rules/jis.rb +103 -0
- data/lib/relaton/render/iso690/rules/nist.rb +384 -0
- data/lib/relaton/render/iso690/rules.rb +227 -0
- data/lib/relaton/render/iso690/style/locale.rb +59 -0
- data/lib/relaton/render/iso690/style/name_form.rb +87 -0
- data/lib/relaton/render/iso690/style/scheme.rb +33 -0
- data/lib/relaton/render/iso690/style/sort_rule.rb +25 -0
- data/lib/relaton/render/iso690/style/template_map.rb +40 -0
- data/lib/relaton/render/iso690/style/type_template.rb +45 -0
- data/lib/relaton/render/iso690/style.rb +227 -0
- data/lib/relaton/render/iso690/styles/apa.yml +45 -0
- data/lib/relaton/render/iso690/styles/author-date.yml +31 -0
- data/lib/relaton/render/iso690/styles/chicago.yml +49 -0
- data/lib/relaton/render/iso690/styles/ieee-sa.yml +45 -0
- data/lib/relaton/render/iso690/styles/jis-en.yml +68 -0
- data/lib/relaton/render/iso690/styles/jis-ja.yml +67 -0
- data/lib/relaton/render/iso690/styles/lncs.yml +54 -0
- data/lib/relaton/render/iso690/styles/mla.yml +56 -0
- data/lib/relaton/render/iso690/template.rb +248 -0
- data/lib/relaton/render/iso690.rb +28 -0
- data/lib/relaton/render/version.rb +5 -0
- data/lib/relaton/un/CLAUDE.md +1 -1
- data/lib/relaton/version.rb +1 -1
- data/lib/relaton-cli.rb +17 -0
- data/lib/relaton-render.rb +13 -0
- 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,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
|