@acta-dev/web 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/LICENSE +21 -0
  2. package/astro.config.mjs +31 -0
  3. package/package.json +62 -0
  4. package/public/favicon.png +0 -0
  5. package/src/components/DocumentSearchList.astro +150 -0
  6. package/src/components/DocumentView.astro +198 -0
  7. package/src/components/LanguageSwitcher.tsx +115 -0
  8. package/src/components/SidebarToggle.tsx +42 -0
  9. package/src/components/ThemeToggle.tsx +129 -0
  10. package/src/components/graph/DocumentGraphIsland.tsx +226 -0
  11. package/src/components/graph/GraphContext.ts +15 -0
  12. package/src/components/graph/graph.css +330 -0
  13. package/src/components/graph/layout.ts +48 -0
  14. package/src/components/graph/nodes.tsx +80 -0
  15. package/src/components/ui/Button.astro +105 -0
  16. package/src/components/ui/Chip.astro +147 -0
  17. package/src/components/ui/Field.astro +29 -0
  18. package/src/components/ui/Input.astro +34 -0
  19. package/src/components/ui/Pill.astro +71 -0
  20. package/src/components/ui/SegmentedControl.astro +105 -0
  21. package/src/components/ui/Select.astro +41 -0
  22. package/src/components/ui/Tooltip.astro +94 -0
  23. package/src/layouts/BaseLayout.astro +147 -0
  24. package/src/lib/documents.test.ts +175 -0
  25. package/src/lib/documents.ts +156 -0
  26. package/src/lib/i18n-client.ts +32 -0
  27. package/src/lib/i18n.ts +92 -0
  28. package/src/lib/project.test.ts +24 -0
  29. package/src/lib/project.ts +120 -0
  30. package/src/lib/search-client.ts +192 -0
  31. package/src/lib/search.test.ts +94 -0
  32. package/src/lib/search.ts +153 -0
  33. package/src/locales/en/common.json +6 -0
  34. package/src/locales/en/dashboard.json +8 -0
  35. package/src/locales/en/documents.json +63 -0
  36. package/src/locales/en/graph.json +19 -0
  37. package/src/locales/en/search.json +7 -0
  38. package/src/locales/en/sidebar.json +25 -0
  39. package/src/locales/en/validation.json +13 -0
  40. package/src/locales/ru/common.json +6 -0
  41. package/src/locales/ru/dashboard.json +8 -0
  42. package/src/locales/ru/documents.json +63 -0
  43. package/src/locales/ru/graph.json +19 -0
  44. package/src/locales/ru/search.json +7 -0
  45. package/src/locales/ru/sidebar.json +25 -0
  46. package/src/locales/ru/validation.json +13 -0
  47. package/src/pages/documents/[id]/index.astro +39 -0
  48. package/src/pages/graph.astro +54 -0
  49. package/src/pages/index.astro +41 -0
  50. package/src/pages/ru/documents/[id]/index.astro +39 -0
  51. package/src/pages/ru/graph.astro +54 -0
  52. package/src/pages/ru/index.astro +41 -0
  53. package/src/pages/ru/search-index-full.json.ts +1 -0
  54. package/src/pages/ru/search.astro +27 -0
  55. package/src/pages/ru/validation.astro +63 -0
  56. package/src/pages/search-index-full.json.ts +12 -0
  57. package/src/pages/search-index.json.ts +12 -0
  58. package/src/pages/search.astro +27 -0
  59. package/src/pages/validation.astro +63 -0
  60. package/src/styles/global.css +1391 -0
  61. package/src/styles/themes/dark.css +61 -0
  62. package/src/styles/themes/light.css +63 -0
  63. package/src/styles/tokens/primitives.css +32 -0
  64. package/src/styles/tokens/semantic.css +34 -0
  65. package/src/styles/tokens/typography.css +28 -0
  66. package/tsconfig.json +11 -0
@@ -0,0 +1,94 @@
1
+ ---
2
+ interface Props {
3
+ label: string;
4
+ text: string;
5
+ id?: string;
6
+ class?: string;
7
+ }
8
+
9
+ const { label, text, id, class: className } = Astro.props;
10
+ const tipId =
11
+ id ?? `tip-${Math.random().toString(36).slice(2, 9)}-${Date.now().toString(36).slice(-4)}`;
12
+ const classes = ["ui-tooltip", className].filter(Boolean).join(" ");
13
+ ---
14
+
15
+ <span class={classes}>
16
+ <button
17
+ type="button"
18
+ class="ui-tooltip__trigger"
19
+ aria-label={label}
20
+ aria-describedby={tipId}
21
+ >
22
+ ?
23
+ </button>
24
+ <span role="tooltip" id={tipId} class="ui-tooltip__bubble">{text}</span>
25
+ </span>
26
+
27
+ <style>
28
+ .ui-tooltip {
29
+ position: relative;
30
+ display: inline-flex;
31
+ align-items: center;
32
+ }
33
+
34
+ .ui-tooltip__trigger {
35
+ display: inline-grid;
36
+ place-items: center;
37
+ width: 16px;
38
+ height: 16px;
39
+ padding: 0;
40
+ border: 1px solid var(--color-border);
41
+ border-radius: var(--radius-pill);
42
+ background: var(--color-panel);
43
+ color: var(--color-text-muted);
44
+ font: inherit;
45
+ font-size: 10px;
46
+ font-weight: var(--weight-bold);
47
+ line-height: 1;
48
+ cursor: help;
49
+ transition:
50
+ color var(--duration-hover) var(--ease-out),
51
+ border-color var(--duration-hover) var(--ease-out);
52
+ }
53
+
54
+ .ui-tooltip__trigger:hover,
55
+ .ui-tooltip__trigger:focus-visible {
56
+ color: var(--color-accent);
57
+ border-color: var(--color-accent);
58
+ outline: none;
59
+ }
60
+
61
+ .ui-tooltip__bubble {
62
+ position: absolute;
63
+ bottom: calc(100% + var(--space-1-5));
64
+ left: 50%;
65
+ transform: translateX(-50%);
66
+ z-index: 50;
67
+ width: max-content;
68
+ max-width: 240px;
69
+ padding: var(--space-2) var(--space-3);
70
+ border: 1px solid var(--color-border);
71
+ border-radius: var(--radius-sm);
72
+ background: var(--color-panel);
73
+ color: var(--color-text);
74
+ box-shadow: var(--shadow-md);
75
+ font-size: var(--text-sm);
76
+ font-weight: var(--weight-regular);
77
+ line-height: var(--leading-snug);
78
+ text-transform: none;
79
+ letter-spacing: 0;
80
+ pointer-events: none;
81
+ opacity: 0;
82
+ transform: translateX(-50%) scale(0.97);
83
+ transform-origin: bottom center;
84
+ transition:
85
+ opacity var(--duration-popover) var(--ease-out),
86
+ transform var(--duration-popover) var(--ease-out);
87
+ }
88
+
89
+ .ui-tooltip__trigger:hover + .ui-tooltip__bubble,
90
+ .ui-tooltip__trigger:focus-visible + .ui-tooltip__bubble {
91
+ opacity: 1;
92
+ transform: translateX(-50%) scale(1);
93
+ }
94
+ </style>
@@ -0,0 +1,147 @@
1
+ ---
2
+ import "../styles/global.css";
3
+ import ThemeToggle from "../components/ThemeToggle.tsx";
4
+ import SidebarToggle from "../components/SidebarToggle.tsx";
5
+ import LanguageSwitcher from "../components/LanguageSwitcher.tsx";
6
+ import { defaultLocale, getT, isLocale, localizedHref, type Locale } from "@lib/i18n.js";
7
+
8
+ interface Props {
9
+ title: string;
10
+ description?: string;
11
+ }
12
+
13
+ const { title, description } = Astro.props;
14
+
15
+ const rawLocale = Astro.currentLocale ?? defaultLocale;
16
+ const locale: Locale = isLocale(rawLocale) ? rawLocale : defaultLocale;
17
+ const t = await getT(locale);
18
+ const tCommon = (key: string, params?: Record<string, unknown>) => t(`common:${key}`, params);
19
+ const tSidebar = (key: string, params?: Record<string, unknown>) => t(`sidebar:${key}`, params);
20
+
21
+ const finalDescription = description ?? tCommon("metaDefault") ?? "Acta document viewer";
22
+
23
+ const base = import.meta.env.BASE_URL; // "/" in dev, "/acta/" in prod
24
+ const basePrefix = base === "/" ? "" : base.replace(/\/$/, ""); // "" or "/acta"
25
+ const rawPathname = Astro.url.pathname;
26
+ const pathname = basePrefix && rawPathname.startsWith(basePrefix)
27
+ ? rawPathname.slice(basePrefix.length) || "/"
28
+ : rawPathname;
29
+ const segments = pathname.split("/").filter(Boolean);
30
+ const bareSegments = segments[0] && isLocale(segments[0]) ? segments.slice(1) : segments;
31
+ const barePath = `/${bareSegments.join("/")}${bareSegments.length > 0 ? "/" : ""}` || "/";
32
+
33
+ const isActive = (href: string) => {
34
+ if (href === "/") return barePath === "/" || barePath.startsWith("/documents");
35
+ return barePath === href || barePath.startsWith(href);
36
+ };
37
+
38
+ const navItems = [
39
+ { href: "/", label: tSidebar("nav.documents"), num: "01" },
40
+ { href: "/graph/", label: tSidebar("nav.graph"), num: "02" },
41
+ { href: "/validation/", label: tSidebar("nav.validation"), num: "03" },
42
+ ];
43
+
44
+ const languageLabels = { en: tSidebar("language.en"), ru: tSidebar("language.ru") };
45
+ ---
46
+
47
+ <!doctype html>
48
+ <html lang={locale}>
49
+ <head>
50
+ <meta charset="utf-8" />
51
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
52
+ <meta name="description" content={finalDescription} />
53
+ <title>{title}</title>
54
+ <link rel="icon" href={`${basePrefix}/favicon.png`} />
55
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
56
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
57
+ <link
58
+ rel="stylesheet"
59
+ href="https://fonts.googleapis.com/css2?family=Newsreader:ital,opsz,wght@0,6..72,400;0,6..72,500;0,6..72,600;1,6..72,400;1,6..72,500&family=Inter+Tight:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap"
60
+ />
61
+ <script is:inline>
62
+ (() => {
63
+ try {
64
+ const t = localStorage.getItem("acta-theme") ?? "system";
65
+ const r =
66
+ t === "system"
67
+ ? matchMedia("(prefers-color-scheme: dark)").matches
68
+ ? "dark"
69
+ : "light"
70
+ : t;
71
+ document.documentElement.dataset.theme = r;
72
+ document.documentElement.dataset.themePref = t;
73
+ const s = localStorage.getItem("acta-sidebar");
74
+ document.documentElement.dataset.sidebar = s === "collapsed" ? "collapsed" : "expanded";
75
+ } catch {
76
+ document.documentElement.dataset.theme = "light";
77
+ document.documentElement.dataset.themePref = "system";
78
+ document.documentElement.dataset.sidebar = "expanded";
79
+ }
80
+ })();
81
+ </script>
82
+ </head>
83
+ <body>
84
+ <div class="app-shell">
85
+ <aside class="sidebar">
86
+ <div class="sidebar-head">
87
+ <a class="brand" href={localizedHref(locale, "/", base)}>
88
+ <span class="brand-mark"><img src={`${basePrefix}/favicon.png`} alt="A" /></span>
89
+ <span class="brand-label">
90
+ <strong class="brand-wordmark">Acta</strong>
91
+ <small class="brand-kicker">{tSidebar("brand.subtitle")}</small>
92
+ </span>
93
+ </a>
94
+ <SidebarToggle
95
+ client:load
96
+ labels={{
97
+ collapse: tSidebar("sidebar.collapse"),
98
+ expand: tSidebar("sidebar.expand"),
99
+ }}
100
+ />
101
+ </div>
102
+ <nav class="nav">
103
+ {
104
+ navItems.map((item) => {
105
+ const active = isActive(item.href);
106
+ return (
107
+ <a
108
+ href={localizedHref(locale, item.href, base)}
109
+ title={item.label}
110
+ aria-label={item.label}
111
+ class:list={[{ "is-active": active }]}
112
+ aria-current={active ? "page" : undefined}
113
+ >
114
+ <span class="nav-num" aria-hidden="true">{item.num}</span>
115
+ <span class="nav-letter" aria-hidden="true">{item.label.charAt(0)}</span>
116
+ <span class="nav-label">{item.label}</span>
117
+ <span class="nav-bar" aria-hidden="true"></span>
118
+ </a>
119
+ );
120
+ })
121
+ }
122
+ </nav>
123
+ <slot name="sidebar" />
124
+ <div class="sidebar-foot">
125
+ <LanguageSwitcher
126
+ client:load
127
+ labels={languageLabels}
128
+ legend={tSidebar("language.legend")}
129
+ base={base}
130
+ />
131
+ <ThemeToggle
132
+ client:load
133
+ labels={{
134
+ legend: tSidebar("theme.legend"),
135
+ system: tSidebar("theme.system"),
136
+ light: tSidebar("theme.light"),
137
+ dark: tSidebar("theme.dark"),
138
+ }}
139
+ />
140
+ </div>
141
+ </aside>
142
+ <main class="content">
143
+ <slot />
144
+ </main>
145
+ </div>
146
+ </body>
147
+ </html>
@@ -0,0 +1,175 @@
1
+ import type { ActaDocument } from "@acta-dev/core";
2
+ import { describe, expect, it } from "vitest";
3
+ import {
4
+ buildDocumentHref,
5
+ buildDocumentOrderIndex,
6
+ collectFilterOptions,
7
+ filterDocuments,
8
+ formatDisplayDate,
9
+ getDocumentSearchText,
10
+ getNextDocumentLimit,
11
+ shouldShowMoreDocuments,
12
+ sortDocumentsByNewest,
13
+ } from "./documents.js";
14
+
15
+ describe("web document utilities", () => {
16
+ it("builds stable document URLs from document IDs", () => {
17
+ expect(buildDocumentHref("ADR-0001")).toBe("/documents/ADR-0001/");
18
+ expect(buildDocumentHref("SPEC 0002")).toBe("/documents/SPEC%200002/");
19
+ });
20
+
21
+ it("collects sorted tags, components, statuses, and kinds for filters", () => {
22
+ const options = collectFilterOptions([
23
+ documentFixture({
24
+ id: "ADR-0001",
25
+ kind: "adr",
26
+ status: "accepted",
27
+ tags: ["architecture", "core"],
28
+ component: ["acta-core"],
29
+ }),
30
+ documentFixture({
31
+ id: "SPEC-0001",
32
+ kind: "spec",
33
+ status: "active",
34
+ tags: ["core"],
35
+ component: ["acta-web"],
36
+ }),
37
+ ]);
38
+
39
+ expect(options.kinds).toEqual(["adr", "spec"]);
40
+ expect(options.statuses).toEqual(["accepted", "active"]);
41
+ expect(options.tags).toEqual(["architecture", "core"]);
42
+ expect(options.components).toEqual(["acta-core", "acta-web"]);
43
+ });
44
+
45
+ it("filters by free text, kind, status, tag, and component", () => {
46
+ const documents = [
47
+ documentFixture({
48
+ id: "ADR-0001",
49
+ title: "Use Markdown as source of truth",
50
+ kind: "adr",
51
+ status: "accepted",
52
+ tags: ["source"],
53
+ component: ["acta-core"],
54
+ body: "Markdown files stay in Git.",
55
+ }),
56
+ documentFixture({
57
+ id: "SPEC-0004",
58
+ title: "Acta web viewer",
59
+ kind: "spec",
60
+ status: "active",
61
+ tags: ["web"],
62
+ component: ["acta-web"],
63
+ body: "Static Astro interface for documents.",
64
+ }),
65
+ ];
66
+
67
+ expect(
68
+ filterDocuments(documents, {
69
+ query: "astro",
70
+ kind: "spec",
71
+ status: "active",
72
+ tag: "web",
73
+ component: "acta-web",
74
+ }).map((document) => document.id),
75
+ ).toEqual(["SPEC-0004"]);
76
+ });
77
+
78
+ it("sorts newest documents first and uses descending IDs for matching dates", () => {
79
+ const documents = [
80
+ documentFixture({ id: "SPEC-0001", date: "2026-04-26" }),
81
+ documentFixture({ id: "SPEC-0002", date: "2026-04-26" }),
82
+ documentFixture({ id: "ADR-0004", date: "2026-05-01" }),
83
+ documentFixture({ id: "SPEC-0004", date: "2026-05-01" }),
84
+ ];
85
+
86
+ expect(sortDocumentsByNewest(documents).map((document) => document.id)).toEqual([
87
+ "SPEC-0004",
88
+ "ADR-0004",
89
+ "SPEC-0002",
90
+ "SPEC-0001",
91
+ ]);
92
+ });
93
+
94
+ it("builds document order indexes for client-side sorting", () => {
95
+ const documents = [
96
+ documentFixture({ id: "SPEC-0001", date: "2026-04-26" }),
97
+ documentFixture({ id: "ADR-0004", date: "2026-05-01" }),
98
+ documentFixture({ id: "SPEC-0004", date: "2026-05-01" }),
99
+ ];
100
+
101
+ expect(buildDocumentOrderIndex(sortDocumentsByNewest(documents))).toEqual({
102
+ "SPEC-0004": 0,
103
+ "ADR-0004": 1,
104
+ "SPEC-0001": 2,
105
+ });
106
+ });
107
+
108
+ it("increments the document list limit without exceeding the matching count", () => {
109
+ expect(getNextDocumentLimit(20, 45)).toBe(40);
110
+ expect(getNextDocumentLimit(40, 45)).toBe(45);
111
+ expect(shouldShowMoreDocuments(45, 40)).toBe(true);
112
+ expect(shouldShowMoreDocuments(45, 45)).toBe(false);
113
+ });
114
+
115
+ it("formats display date by dropping the time portion of ISO datetime", () => {
116
+ expect(formatDisplayDate("2026-04-26T08:15:00.000Z")).toBe("2026-04-26");
117
+ expect(formatDisplayDate("2026-04-26")).toBe("2026-04-26");
118
+ expect(formatDisplayDate("")).toBe("");
119
+ expect(formatDisplayDate("not-a-date")).toBe("not-a-date");
120
+ });
121
+
122
+ it("includes metadata and section content in search text", () => {
123
+ const document = documentFixture({
124
+ id: "SPEC-0004",
125
+ summary: "Static document viewer",
126
+ owners: ["Boris"],
127
+ sections: [{ level: 1, title: "Requirements", content: "Client-side filtering" }],
128
+ });
129
+
130
+ expect(getDocumentSearchText(document)).toContain("spec-0004");
131
+ expect(getDocumentSearchText(document)).toContain("static document viewer");
132
+ expect(getDocumentSearchText(document)).toContain("boris");
133
+ expect(getDocumentSearchText(document)).toContain("client-side filtering");
134
+ });
135
+ });
136
+
137
+ function documentFixture(overrides: Partial<ActaDocument> = {}): ActaDocument {
138
+ return {
139
+ id: "ADR-0001",
140
+ kind: "adr",
141
+ title: "Use Markdown",
142
+ status: "accepted",
143
+ date: "2026-05-01",
144
+ tags: [],
145
+ component: [],
146
+ owners: [],
147
+ links: {
148
+ related: [],
149
+ supersedes: [],
150
+ replacedBy: [],
151
+ decidedBy: [],
152
+ dependsOn: [],
153
+ validates: [],
154
+ references: [],
155
+ },
156
+ backlinks: {
157
+ related: [],
158
+ supersedes: [],
159
+ replacedBy: [],
160
+ decidedBy: [],
161
+ dependsOn: [],
162
+ validates: [],
163
+ references: [],
164
+ },
165
+ sections: [],
166
+ body: "",
167
+ file: {
168
+ path: "/repo/docs/decisions/ADR-0001-use-markdown.md",
169
+ relativePath: "docs/decisions/ADR-0001-use-markdown.md",
170
+ slug: "ADR-0001-use-markdown",
171
+ contentHash: "hash",
172
+ },
173
+ ...overrides,
174
+ } as ActaDocument;
175
+ }
@@ -0,0 +1,156 @@
1
+ import type { ActaDocument, InternalLinkKey, ValidationIssue } from "@acta-dev/core";
2
+
3
+ export interface DocumentFilters {
4
+ query?: string;
5
+ kind?: string;
6
+ status?: string;
7
+ tag?: string;
8
+ component?: string;
9
+ }
10
+
11
+ export interface FilterOptions {
12
+ kinds: string[];
13
+ statuses: string[];
14
+ tags: string[];
15
+ components: string[];
16
+ }
17
+
18
+ export const documentPageSize = 20;
19
+
20
+ export const internalLinkKeys: InternalLinkKey[] = [
21
+ "related",
22
+ "supersedes",
23
+ "replacedBy",
24
+ "decidedBy",
25
+ "dependsOn",
26
+ "validates",
27
+ ];
28
+
29
+ export function buildDocumentHref(id: string): string {
30
+ return `/documents/${encodeURIComponent(id)}/`;
31
+ }
32
+
33
+ export function collectFilterOptions(documents: ActaDocument[]): FilterOptions {
34
+ return {
35
+ kinds: uniqueSorted(documents.map((document) => document.kind)),
36
+ statuses: uniqueSorted(documents.map((document) => document.status)),
37
+ tags: uniqueSorted(documents.flatMap((document) => document.tags)),
38
+ components: uniqueSorted(documents.flatMap((document) => document.component)),
39
+ };
40
+ }
41
+
42
+ export function filterDocuments(
43
+ documents: ActaDocument[],
44
+ filters: DocumentFilters,
45
+ ): ActaDocument[] {
46
+ const query = normalize(filters.query ?? "");
47
+
48
+ return documents.filter((document) => {
49
+ if (filters.kind && document.kind !== filters.kind) {
50
+ return false;
51
+ }
52
+
53
+ if (filters.status && document.status !== filters.status) {
54
+ return false;
55
+ }
56
+
57
+ if (filters.tag && !document.tags.includes(filters.tag)) {
58
+ return false;
59
+ }
60
+
61
+ if (filters.component && !document.component.includes(filters.component)) {
62
+ return false;
63
+ }
64
+
65
+ return query.length === 0 || getDocumentSearchText(document).includes(query);
66
+ });
67
+ }
68
+
69
+ export function sortDocumentsByNewest(documents: ActaDocument[]): ActaDocument[] {
70
+ return documents.slice().sort((left, right) => {
71
+ const dateOrder = right.date.localeCompare(left.date);
72
+ return dateOrder === 0 ? right.id.localeCompare(left.id) : dateOrder;
73
+ });
74
+ }
75
+
76
+ /**
77
+ * Format an ISO 8601 datetime for human display as `YYYY-MM-DD`. Time portion is dropped.
78
+ * Falls back to the original value if parsing fails so historical day-only values keep working.
79
+ */
80
+ export function formatDisplayDate(value: string): string {
81
+ if (!value) {
82
+ return "";
83
+ }
84
+ const parsed = new Date(value);
85
+ if (Number.isNaN(parsed.getTime())) {
86
+ return value;
87
+ }
88
+ const year = parsed.getUTCFullYear();
89
+ const month = String(parsed.getUTCMonth() + 1).padStart(2, "0");
90
+ const day = String(parsed.getUTCDate()).padStart(2, "0");
91
+ return `${year}-${month}-${day}`;
92
+ }
93
+
94
+ export function buildDocumentOrderIndex(documents: ActaDocument[]): Record<string, number> {
95
+ return Object.fromEntries(documents.map((document, index) => [document.id, index]));
96
+ }
97
+
98
+ export function getNextDocumentLimit(
99
+ currentLimit: number,
100
+ matchingCount: number,
101
+ pageSize = documentPageSize,
102
+ ): number {
103
+ return Math.min(currentLimit + pageSize, matchingCount);
104
+ }
105
+
106
+ export function shouldShowMoreDocuments(matchingCount: number, visibleLimit: number): boolean {
107
+ return matchingCount > visibleLimit;
108
+ }
109
+
110
+ export function getDocumentSearchText(document: ActaDocument): string {
111
+ return normalize(
112
+ [
113
+ document.id,
114
+ document.title,
115
+ document.summary ?? "",
116
+ document.status,
117
+ document.kind,
118
+ ...document.tags,
119
+ ...document.component,
120
+ ...document.owners,
121
+ ...document.sections.flatMap((section) => [section.title, section.content]),
122
+ document.body,
123
+ ].join(" "),
124
+ );
125
+ }
126
+
127
+ export function getIssuesForDocument(
128
+ issues: ValidationIssue[],
129
+ document: ActaDocument,
130
+ ): ValidationIssue[] {
131
+ return issues.filter(
132
+ (issue) => issue.documentId === document.id || issue.path === document.file.path,
133
+ );
134
+ }
135
+
136
+ export function getDocumentById(documents: ActaDocument[], id: string): ActaDocument | undefined {
137
+ return documents.find((document) => document.id === id);
138
+ }
139
+
140
+ export function hasInternalLinks(document: ActaDocument): boolean {
141
+ return internalLinkKeys.some((key) => document.links[key].length > 0);
142
+ }
143
+
144
+ export function hasBacklinks(document: ActaDocument): boolean {
145
+ return internalLinkKeys.some((key) => document.backlinks[key].length > 0);
146
+ }
147
+
148
+ function normalize(value: string): string {
149
+ return value.toLowerCase().trim();
150
+ }
151
+
152
+ function uniqueSorted(values: string[]): string[] {
153
+ return [...new Set(values.filter((value) => value.length > 0))].sort((left, right) =>
154
+ left.localeCompare(right),
155
+ );
156
+ }
@@ -0,0 +1,32 @@
1
+ import i18next from "i18next";
2
+ import { initReactI18next } from "react-i18next";
3
+ import { defaultLocale, isLocale, type Locale, resources } from "./i18n.js";
4
+
5
+ let started = false;
6
+
7
+ export function ensureClientI18n(): void {
8
+ if (started) return;
9
+ started = true;
10
+ const lang = readClientLocale();
11
+ i18next.use(initReactI18next).init({
12
+ lng: lang,
13
+ fallbackLng: defaultLocale,
14
+ ns: Object.keys(resources[defaultLocale] ?? {}),
15
+ defaultNS: "common",
16
+ resources: Object.fromEntries(
17
+ Object.entries(resources).map(([code, ns]) => [
18
+ code,
19
+ Object.fromEntries(Object.entries(ns).map(([k, v]) => [k, v as object])),
20
+ ]),
21
+ ),
22
+ interpolation: { escapeValue: false },
23
+ returnNull: false,
24
+ });
25
+ }
26
+
27
+ export function readClientLocale(): Locale {
28
+ if (typeof document === "undefined") return defaultLocale;
29
+ const fromHtml = document.documentElement.lang;
30
+ if (isLocale(fromHtml)) return fromHtml;
31
+ return defaultLocale;
32
+ }
@@ -0,0 +1,92 @@
1
+ import i18next, { type i18n as I18nInstance } from "i18next";
2
+
3
+ const localeModules = import.meta.glob("../locales/**/*.json", { eager: true }) as Record<
4
+ string,
5
+ { default: Record<string, unknown> }
6
+ >;
7
+
8
+ export type Locale = "en" | "ru";
9
+ export const locales: Locale[] = ["en", "ru"];
10
+ export const defaultLocale: Locale = "en";
11
+
12
+ type Resources = Record<string, Record<string, Record<string, unknown>>>;
13
+
14
+ function buildResources(): Resources {
15
+ const out: Resources = {};
16
+ for (const [path, mod] of Object.entries(localeModules)) {
17
+ const match = path.match(/\/locales\/([^/]+)\/([^/]+)\.json$/);
18
+ if (!match) continue;
19
+ const [, lang, ns] = match;
20
+ out[lang] ??= {};
21
+ out[lang][ns] = mod.default;
22
+ }
23
+ return out;
24
+ }
25
+
26
+ export const resources = buildResources();
27
+
28
+ const namespaces = Object.keys(resources[defaultLocale] ?? {});
29
+
30
+ let initPromise: Promise<I18nInstance> | null = null;
31
+
32
+ export function getI18n(locale: Locale = defaultLocale): Promise<I18nInstance> {
33
+ if (!initPromise) {
34
+ initPromise = i18next
35
+ .init({
36
+ lng: locale,
37
+ fallbackLng: defaultLocale,
38
+ ns: namespaces,
39
+ defaultNS: "common",
40
+ resources: Object.fromEntries(
41
+ Object.entries(resources).map(([lang, ns]) => [
42
+ lang,
43
+ Object.fromEntries(Object.entries(ns).map(([k, v]) => [k, v as object])),
44
+ ]),
45
+ ),
46
+ interpolation: { escapeValue: false },
47
+ returnNull: false,
48
+ })
49
+ .then(() => i18next);
50
+ }
51
+ return initPromise.then(async (instance) => {
52
+ if (instance.language !== locale) {
53
+ await instance.changeLanguage(locale);
54
+ }
55
+ return instance;
56
+ });
57
+ }
58
+
59
+ export type TFunc = (key: string, params?: Record<string, unknown>) => string;
60
+
61
+ export async function getT(locale: Locale = defaultLocale): Promise<TFunc> {
62
+ const instance = await getI18n(locale);
63
+ const fixedT = instance.getFixedT(locale);
64
+ return (key, params) => fixedT(key, params) as string;
65
+ }
66
+
67
+ export function isLocale(value: string | undefined): value is Locale {
68
+ return value === "en" || value === "ru";
69
+ }
70
+
71
+ export function getLocaleFromUrl(url: URL): Locale {
72
+ const segments = url.pathname.split("/").filter(Boolean);
73
+ if (segments[0] && isLocale(segments[0])) return segments[0];
74
+ return defaultLocale;
75
+ }
76
+
77
+ export function localizedHref(
78
+ locale: Locale,
79
+ path: string,
80
+ base = import.meta.env.BASE_URL,
81
+ ): string {
82
+ const normalized = path.startsWith("/") ? path : `/${path}`;
83
+ const localizedPath =
84
+ locale === defaultLocale
85
+ ? normalized
86
+ : normalized === "/"
87
+ ? `/${locale}/`
88
+ : `/${locale}${normalized}`;
89
+ // BASE_URL is "/" in dev or "/acta/" in prod — strip trailing slash for concat
90
+ const basePrefix = base === "/" ? "" : base.replace(/\/$/, "");
91
+ return `${basePrefix}${localizedPath}`;
92
+ }