@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.
- package/LICENSE +21 -0
- package/astro.config.mjs +31 -0
- package/package.json +62 -0
- package/public/favicon.png +0 -0
- package/src/components/DocumentSearchList.astro +150 -0
- package/src/components/DocumentView.astro +198 -0
- package/src/components/LanguageSwitcher.tsx +115 -0
- package/src/components/SidebarToggle.tsx +42 -0
- package/src/components/ThemeToggle.tsx +129 -0
- package/src/components/graph/DocumentGraphIsland.tsx +226 -0
- package/src/components/graph/GraphContext.ts +15 -0
- package/src/components/graph/graph.css +330 -0
- package/src/components/graph/layout.ts +48 -0
- package/src/components/graph/nodes.tsx +80 -0
- package/src/components/ui/Button.astro +105 -0
- package/src/components/ui/Chip.astro +147 -0
- package/src/components/ui/Field.astro +29 -0
- package/src/components/ui/Input.astro +34 -0
- package/src/components/ui/Pill.astro +71 -0
- package/src/components/ui/SegmentedControl.astro +105 -0
- package/src/components/ui/Select.astro +41 -0
- package/src/components/ui/Tooltip.astro +94 -0
- package/src/layouts/BaseLayout.astro +147 -0
- package/src/lib/documents.test.ts +175 -0
- package/src/lib/documents.ts +156 -0
- package/src/lib/i18n-client.ts +32 -0
- package/src/lib/i18n.ts +92 -0
- package/src/lib/project.test.ts +24 -0
- package/src/lib/project.ts +120 -0
- package/src/lib/search-client.ts +192 -0
- package/src/lib/search.test.ts +94 -0
- package/src/lib/search.ts +153 -0
- package/src/locales/en/common.json +6 -0
- package/src/locales/en/dashboard.json +8 -0
- package/src/locales/en/documents.json +63 -0
- package/src/locales/en/graph.json +19 -0
- package/src/locales/en/search.json +7 -0
- package/src/locales/en/sidebar.json +25 -0
- package/src/locales/en/validation.json +13 -0
- package/src/locales/ru/common.json +6 -0
- package/src/locales/ru/dashboard.json +8 -0
- package/src/locales/ru/documents.json +63 -0
- package/src/locales/ru/graph.json +19 -0
- package/src/locales/ru/search.json +7 -0
- package/src/locales/ru/sidebar.json +25 -0
- package/src/locales/ru/validation.json +13 -0
- package/src/pages/documents/[id]/index.astro +39 -0
- package/src/pages/graph.astro +54 -0
- package/src/pages/index.astro +41 -0
- package/src/pages/ru/documents/[id]/index.astro +39 -0
- package/src/pages/ru/graph.astro +54 -0
- package/src/pages/ru/index.astro +41 -0
- package/src/pages/ru/search-index-full.json.ts +1 -0
- package/src/pages/ru/search.astro +27 -0
- package/src/pages/ru/validation.astro +63 -0
- package/src/pages/search-index-full.json.ts +12 -0
- package/src/pages/search-index.json.ts +12 -0
- package/src/pages/search.astro +27 -0
- package/src/pages/validation.astro +63 -0
- package/src/styles/global.css +1391 -0
- package/src/styles/themes/dark.css +61 -0
- package/src/styles/themes/light.css +63 -0
- package/src/styles/tokens/primitives.css +32 -0
- package/src/styles/tokens/semantic.css +34 -0
- package/src/styles/tokens/typography.css +28 -0
- 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
|
+
}
|
package/src/lib/i18n.ts
ADDED
|
@@ -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
|
+
}
|