@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
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Boris Khakhin
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/astro.config.mjs
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { defineConfig } from "astro/config";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
import react from "@astrojs/react";
|
|
4
|
+
|
|
5
|
+
const isPagesBuild = process.env.GITHUB_PAGES === "true";
|
|
6
|
+
|
|
7
|
+
// `acta site` drives the build from outside the monorepo and injects these.
|
|
8
|
+
const siteUrl =
|
|
9
|
+
process.env.ACTA_SITE ?? (isPagesBuild ? "https://jentix.github.io" : "http://localhost:4321");
|
|
10
|
+
const base = process.env.ACTA_BASE ?? (isPagesBuild ? "/acta" : undefined);
|
|
11
|
+
const outDir = process.env.ACTA_SITE_OUT || undefined;
|
|
12
|
+
|
|
13
|
+
export default defineConfig({
|
|
14
|
+
site: siteUrl,
|
|
15
|
+
base,
|
|
16
|
+
...(outDir ? { outDir } : {}),
|
|
17
|
+
output: "static",
|
|
18
|
+
i18n: {
|
|
19
|
+
defaultLocale: "en",
|
|
20
|
+
locales: ["en", "ru"],
|
|
21
|
+
routing: { prefixDefaultLocale: false },
|
|
22
|
+
},
|
|
23
|
+
integrations: [react()],
|
|
24
|
+
vite: {
|
|
25
|
+
resolve: {
|
|
26
|
+
alias: {
|
|
27
|
+
"@lib": fileURLToPath(new URL("./src/lib", import.meta.url)),
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@acta-dev/web",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Prebuilt Astro viewer for Acta docs-as-code repositories, built on demand by `acta site`",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/jentix/acta.git",
|
|
10
|
+
"directory": "apps/web"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://github.com/jentix/acta#readme",
|
|
13
|
+
"keywords": [
|
|
14
|
+
"acta",
|
|
15
|
+
"adr",
|
|
16
|
+
"docs-as-code",
|
|
17
|
+
"astro",
|
|
18
|
+
"viewer"
|
|
19
|
+
],
|
|
20
|
+
"publishConfig": {
|
|
21
|
+
"access": "public"
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"src",
|
|
25
|
+
"public",
|
|
26
|
+
"astro.config.mjs",
|
|
27
|
+
"tsconfig.json"
|
|
28
|
+
],
|
|
29
|
+
"exports": {
|
|
30
|
+
"./package.json": "./package.json"
|
|
31
|
+
},
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"@astrojs/react": "^5.0.5",
|
|
34
|
+
"@dagrejs/dagre": "^3.0.0",
|
|
35
|
+
"@orama/orama": "^3.1.18",
|
|
36
|
+
"@xyflow/react": "^12.10.2",
|
|
37
|
+
"astro": "^6.3.7",
|
|
38
|
+
"i18next": "^26.2.0",
|
|
39
|
+
"i18next-resources-to-backend": "^1.2.1",
|
|
40
|
+
"react": "^19.2.5",
|
|
41
|
+
"react-dom": "^19.2.5",
|
|
42
|
+
"react-i18next": "^17.0.8",
|
|
43
|
+
"@acta-dev/core": "1.1.0",
|
|
44
|
+
"@acta-dev/renderer": "1.0.0"
|
|
45
|
+
},
|
|
46
|
+
"devDependencies": {
|
|
47
|
+
"@astrojs/check": "^0.9.9",
|
|
48
|
+
"@types/dagre": "^0.7.54",
|
|
49
|
+
"@types/react": "^19.2.14",
|
|
50
|
+
"@types/react-dom": "^19.2.3",
|
|
51
|
+
"typescript": "^5.9.3",
|
|
52
|
+
"vitest": "^4.1.7"
|
|
53
|
+
},
|
|
54
|
+
"scripts": {
|
|
55
|
+
"artifacts": "node scripts/build-artifacts.mjs",
|
|
56
|
+
"dev": "node scripts/build-artifacts.mjs && ASTRO_TELEMETRY_DISABLED=1 astro dev --host 127.0.0.1",
|
|
57
|
+
"build": "node scripts/build-artifacts.mjs && ASTRO_TELEMETRY_DISABLED=1 astro build",
|
|
58
|
+
"preview": "ASTRO_TELEMETRY_DISABLED=1 astro preview",
|
|
59
|
+
"test": "vitest run",
|
|
60
|
+
"typecheck": "ASTRO_TELEMETRY_DISABLED=1 astro check"
|
|
61
|
+
}
|
|
62
|
+
}
|
|
Binary file
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
---
|
|
2
|
+
import type { ActaDocument } from "@acta-dev/core";
|
|
3
|
+
import {
|
|
4
|
+
buildDocumentHref,
|
|
5
|
+
buildDocumentOrderIndex,
|
|
6
|
+
collectFilterOptions,
|
|
7
|
+
documentPageSize,
|
|
8
|
+
formatDisplayDate,
|
|
9
|
+
} from "../lib/documents.js";
|
|
10
|
+
import { defaultLocale, getT, isLocale, localizedHref, type Locale } from "../lib/i18n.js";
|
|
11
|
+
import Field from "./ui/Field.astro";
|
|
12
|
+
import Input from "./ui/Input.astro";
|
|
13
|
+
import Select from "./ui/Select.astro";
|
|
14
|
+
import SegmentedControl from "./ui/SegmentedControl.astro";
|
|
15
|
+
import Chip from "./ui/Chip.astro";
|
|
16
|
+
import Button from "./ui/Button.astro";
|
|
17
|
+
|
|
18
|
+
interface Props {
|
|
19
|
+
documents: ActaDocument[];
|
|
20
|
+
dependencyDocumentIds: string[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const { documents, dependencyDocumentIds } = Astro.props;
|
|
24
|
+
const filterOptions = collectFilterOptions(documents);
|
|
25
|
+
const newestOrder = buildDocumentOrderIndex(documents);
|
|
26
|
+
const documentsById = new Map(documents.map((document) => [document.id, document]));
|
|
27
|
+
const dependencyOrderDocuments = dependencyDocumentIds
|
|
28
|
+
.map((id) => documentsById.get(id))
|
|
29
|
+
.filter((document) => document !== undefined);
|
|
30
|
+
const dependencyOrder = buildDocumentOrderIndex(dependencyOrderDocuments);
|
|
31
|
+
|
|
32
|
+
const rawLocale = Astro.currentLocale ?? defaultLocale;
|
|
33
|
+
const locale: Locale = isLocale(rawLocale) ? rawLocale : defaultLocale;
|
|
34
|
+
const t = await getT(locale);
|
|
35
|
+
const td = (key: string) => t(`documents:${key}`);
|
|
36
|
+
const tc = (key: string) => t(`common:${key}`);
|
|
37
|
+
---
|
|
38
|
+
|
|
39
|
+
<div data-document-search-root>
|
|
40
|
+
<section class="toolbar" aria-label={td("filters.legend")}>
|
|
41
|
+
<Field label={td("filters.search")}>
|
|
42
|
+
<Input type="search" placeholder={td("filters.searchPlaceholder")} data-search-query />
|
|
43
|
+
</Field>
|
|
44
|
+
<Field label={td("filters.kind")}>
|
|
45
|
+
<Select data-filter-kind>
|
|
46
|
+
<option value="">{tc("all")}</option>
|
|
47
|
+
{filterOptions.kinds.map((kind) => <option value={kind}>{kind.toUpperCase()}</option>)}
|
|
48
|
+
</Select>
|
|
49
|
+
</Field>
|
|
50
|
+
<Field label={td("filters.status")}>
|
|
51
|
+
<Select data-filter-status>
|
|
52
|
+
<option value="">{tc("all")}</option>
|
|
53
|
+
{filterOptions.statuses.map((status) => <option value={status}>{status}</option>)}
|
|
54
|
+
</Select>
|
|
55
|
+
</Field>
|
|
56
|
+
<Field label={td("filters.tag")}>
|
|
57
|
+
<Select data-filter-tag>
|
|
58
|
+
<option value="">{tc("all")}</option>
|
|
59
|
+
{filterOptions.tags.map((tag) => <option value={tag}>{tag}</option>)}
|
|
60
|
+
</Select>
|
|
61
|
+
</Field>
|
|
62
|
+
<Field label={td("filters.component")}>
|
|
63
|
+
<Select data-filter-component>
|
|
64
|
+
<option value="">{tc("all")}</option>
|
|
65
|
+
{
|
|
66
|
+
filterOptions.components.map((component) => (
|
|
67
|
+
<option value={component}>{component}</option>
|
|
68
|
+
))
|
|
69
|
+
}
|
|
70
|
+
</Select>
|
|
71
|
+
</Field>
|
|
72
|
+
<SegmentedControl
|
|
73
|
+
name="sort-order"
|
|
74
|
+
legend={td("filters.sort")}
|
|
75
|
+
value="newest"
|
|
76
|
+
dataAttr="data-sort-order"
|
|
77
|
+
options={[
|
|
78
|
+
{ value: "newest", label: td("filters.sortNewest") },
|
|
79
|
+
{ value: "dependency", label: td("filters.sortDependency") },
|
|
80
|
+
]}
|
|
81
|
+
/>
|
|
82
|
+
</section>
|
|
83
|
+
<div class="toolbar-sub">
|
|
84
|
+
<label class="content-search">
|
|
85
|
+
<input type="checkbox" data-search-content />
|
|
86
|
+
<span>{td("filters.searchContent")}</span>
|
|
87
|
+
</label>
|
|
88
|
+
<span class="content-loading muted" data-search-content-loading hidden>
|
|
89
|
+
{td("filters.searchingContent")}
|
|
90
|
+
</span>
|
|
91
|
+
</div>
|
|
92
|
+
|
|
93
|
+
<section
|
|
94
|
+
class="document-list"
|
|
95
|
+
aria-live="polite"
|
|
96
|
+
data-document-list
|
|
97
|
+
data-page-size={documentPageSize}
|
|
98
|
+
>
|
|
99
|
+
{
|
|
100
|
+
documents.map((document) => (
|
|
101
|
+
<article
|
|
102
|
+
class="document-row"
|
|
103
|
+
data-document-row
|
|
104
|
+
data-document-id={document.id}
|
|
105
|
+
data-kind={document.kind}
|
|
106
|
+
data-status={document.status}
|
|
107
|
+
data-tags={document.tags.join(" ")}
|
|
108
|
+
data-components={document.component.join(" ")}
|
|
109
|
+
data-newest-order={newestOrder[document.id]}
|
|
110
|
+
data-dependency-order={dependencyOrder[document.id] ?? newestOrder[document.id]}
|
|
111
|
+
>
|
|
112
|
+
<div class="row-id">
|
|
113
|
+
<Chip kind={document.kind}>{document.kind}</Chip>
|
|
114
|
+
<span>{document.id.replace(/^(?:adr|spec)-/i, "")}</span>
|
|
115
|
+
</div>
|
|
116
|
+
<div class="row-main">
|
|
117
|
+
<a class="document-title" href={localizedHref(locale, buildDocumentHref(document.id))}>
|
|
118
|
+
{document.title}
|
|
119
|
+
</a>
|
|
120
|
+
{document.summary && <p class="row-summary">{document.summary}</p>}
|
|
121
|
+
</div>
|
|
122
|
+
<div class="row-status">
|
|
123
|
+
<Chip status={document.status}>{document.status}</Chip>
|
|
124
|
+
</div>
|
|
125
|
+
<span class="row-date">{formatDisplayDate(document.date)}</span>
|
|
126
|
+
</article>
|
|
127
|
+
))
|
|
128
|
+
}
|
|
129
|
+
<p class="empty-state" data-empty-state hidden>{td("empty")}</p>
|
|
130
|
+
</section>
|
|
131
|
+
<div class="list-actions">
|
|
132
|
+
<Button data-show-more hidden>{td("showMore")}</Button>
|
|
133
|
+
</div>
|
|
134
|
+
</div>
|
|
135
|
+
|
|
136
|
+
<script>
|
|
137
|
+
import { initDocumentSearch } from "../lib/search-client.js";
|
|
138
|
+
|
|
139
|
+
for (const root of document.querySelectorAll<HTMLElement>("[data-document-search-root]")) {
|
|
140
|
+
initDocumentSearch(root);
|
|
141
|
+
}
|
|
142
|
+
</script>
|
|
143
|
+
|
|
144
|
+
<style>
|
|
145
|
+
.toolbar-sub {
|
|
146
|
+
display: flex;
|
|
147
|
+
align-items: center;
|
|
148
|
+
gap: var(--space-4);
|
|
149
|
+
}
|
|
150
|
+
</style>
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
---
|
|
2
|
+
import type { ActaDocument, ValidationIssue } from "@acta-dev/core";
|
|
3
|
+
import Chip from "./ui/Chip.astro";
|
|
4
|
+
import Tooltip from "./ui/Tooltip.astro";
|
|
5
|
+
import {
|
|
6
|
+
buildDocumentHref,
|
|
7
|
+
formatDisplayDate,
|
|
8
|
+
getDocumentById,
|
|
9
|
+
hasBacklinks,
|
|
10
|
+
internalLinkKeys,
|
|
11
|
+
} from "../lib/documents.js";
|
|
12
|
+
import { getT, localizedHref, type Locale } from "../lib/i18n.js";
|
|
13
|
+
|
|
14
|
+
interface Props {
|
|
15
|
+
document: ActaDocument;
|
|
16
|
+
documents: ActaDocument[];
|
|
17
|
+
issues: ValidationIssue[];
|
|
18
|
+
rendered: { html: string };
|
|
19
|
+
locale: Locale;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const { document, documents, issues, rendered, locale } = Astro.props;
|
|
23
|
+
|
|
24
|
+
const t = await getT(locale);
|
|
25
|
+
const td = (key: string, params?: Record<string, unknown>) => t(`documents:${key}`, params);
|
|
26
|
+
const tc = (key: string) => t(`common:${key}`);
|
|
27
|
+
const linkDesc = (key: string, dir: "outgoing" | "incoming") =>
|
|
28
|
+
t(`documents:linkTypes.${key}.${dir}`);
|
|
29
|
+
const severityLabel = (sev: string) => t(`validation:severity.${sev}`);
|
|
30
|
+
|
|
31
|
+
const prettyKey = (key: string) =>
|
|
32
|
+
key.replace(/([A-Z])/g, " $1").replace(/^./, (c) => c.toUpperCase());
|
|
33
|
+
|
|
34
|
+
// Outgoing internal links, grouped by type (Related, Depends on, …)
|
|
35
|
+
const linkGroups = internalLinkKeys
|
|
36
|
+
.filter((key) => document.links[key].length > 0)
|
|
37
|
+
.map((key) => ({ key, ids: document.links[key] }));
|
|
38
|
+
|
|
39
|
+
// Backlinks flattened into a single list (deduped) for the meta column
|
|
40
|
+
const backlinkIds = hasBacklinks(document)
|
|
41
|
+
? [...new Set(internalLinkKeys.flatMap((key) => document.backlinks[key]))]
|
|
42
|
+
: [];
|
|
43
|
+
|
|
44
|
+
const references = document.links.references;
|
|
45
|
+
---
|
|
46
|
+
|
|
47
|
+
<div class="crumbs">
|
|
48
|
+
<a href={localizedHref(locale, "/")}>← {td("detail.back")}</a>
|
|
49
|
+
<span>{document.id}</span>
|
|
50
|
+
<span class="grow"></span>
|
|
51
|
+
{
|
|
52
|
+
references.length > 0 && (
|
|
53
|
+
<a href={references[0]} target="_blank" rel="noreferrer">
|
|
54
|
+
↗ {td("detail.externalReferences")}
|
|
55
|
+
</a>
|
|
56
|
+
)
|
|
57
|
+
}
|
|
58
|
+
</div>
|
|
59
|
+
|
|
60
|
+
<header class="page-header">
|
|
61
|
+
<div class="doc-kicker">
|
|
62
|
+
<Chip kind={document.kind}>{document.kind}</Chip>
|
|
63
|
+
<Chip status={document.status} class="doc-status">{document.status}</Chip>
|
|
64
|
+
<span>{formatDisplayDate(document.date)}</span>
|
|
65
|
+
</div>
|
|
66
|
+
<h1>{document.title}</h1>
|
|
67
|
+
<div class="doc-byline">
|
|
68
|
+
<span><b>{document.id}</b></span>
|
|
69
|
+
{
|
|
70
|
+
document.owners.length > 0 && (
|
|
71
|
+
<span>{td("detail.owners")} <b>{document.owners.join(", ")}</b></span>
|
|
72
|
+
)
|
|
73
|
+
}
|
|
74
|
+
<span><b>{formatDisplayDate(document.date)}</b></span>
|
|
75
|
+
<span><code>{document.file.relativePath}</code></span>
|
|
76
|
+
</div>
|
|
77
|
+
{document.summary && <p class="doc-standfirst">{document.summary}</p>}
|
|
78
|
+
</header>
|
|
79
|
+
|
|
80
|
+
<div class="document-grid">
|
|
81
|
+
<aside class="metadata-panel">
|
|
82
|
+
<dl>
|
|
83
|
+
<div class="meta-block">
|
|
84
|
+
<dt>{td("detail.owners")}</dt>
|
|
85
|
+
<dd>{document.owners.length > 0 ? document.owners.join(", ") : tc("none")}</dd>
|
|
86
|
+
</div>
|
|
87
|
+
<div class="meta-block">
|
|
88
|
+
<dt>{td("detail.tags")}</dt>
|
|
89
|
+
<dd>{document.tags.length > 0 ? document.tags.join(", ") : tc("none")}</dd>
|
|
90
|
+
</div>
|
|
91
|
+
<div class="meta-block">
|
|
92
|
+
<dt>{td("detail.components")}</dt>
|
|
93
|
+
<dd>
|
|
94
|
+
{
|
|
95
|
+
document.component.length > 0
|
|
96
|
+
? document.component.map((component, index) => (
|
|
97
|
+
<>
|
|
98
|
+
{index > 0 ? ", " : ""}
|
|
99
|
+
<code>{component}</code>
|
|
100
|
+
</>
|
|
101
|
+
))
|
|
102
|
+
: tc("none")
|
|
103
|
+
}
|
|
104
|
+
</dd>
|
|
105
|
+
</div>
|
|
106
|
+
<div class="meta-block">
|
|
107
|
+
<dt>{td("detail.file")}</dt>
|
|
108
|
+
<dd><code>{document.file.relativePath}</code></dd>
|
|
109
|
+
</div>
|
|
110
|
+
|
|
111
|
+
{
|
|
112
|
+
linkGroups.map(({ key, ids }) => (
|
|
113
|
+
<div class="meta-block">
|
|
114
|
+
<span class="meta-lbl">
|
|
115
|
+
{prettyKey(key)} <span class="ct">· {ids.length}</span>
|
|
116
|
+
<Tooltip
|
|
117
|
+
label={td("linkTypes.tooltipOutgoing", { key })}
|
|
118
|
+
text={linkDesc(key, "outgoing")}
|
|
119
|
+
/>
|
|
120
|
+
</span>
|
|
121
|
+
<div class="meta-links">
|
|
122
|
+
{ids.map((id) => {
|
|
123
|
+
const target = getDocumentById(documents, id);
|
|
124
|
+
return (
|
|
125
|
+
<a class="metalink" href={localizedHref(locale, buildDocumentHref(id))}>
|
|
126
|
+
<span class="id">{id}</span>
|
|
127
|
+
{target && <span class="t">{target.title}</span>}
|
|
128
|
+
</a>
|
|
129
|
+
);
|
|
130
|
+
})}
|
|
131
|
+
</div>
|
|
132
|
+
</div>
|
|
133
|
+
))
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
<div class="meta-block">
|
|
137
|
+
<span class="meta-lbl">
|
|
138
|
+
{td("detail.backlinks")} <span class="ct">· {backlinkIds.length}</span>
|
|
139
|
+
</span>
|
|
140
|
+
<div class="meta-links">
|
|
141
|
+
{
|
|
142
|
+
backlinkIds.length > 0 ? (
|
|
143
|
+
backlinkIds.map((id) => {
|
|
144
|
+
const source = getDocumentById(documents, id);
|
|
145
|
+
return (
|
|
146
|
+
<a class="metalink" href={localizedHref(locale, buildDocumentHref(id))}>
|
|
147
|
+
<span class="id">{id}</span>
|
|
148
|
+
{source && <span class="t">{source.title}</span>}
|
|
149
|
+
</a>
|
|
150
|
+
);
|
|
151
|
+
})
|
|
152
|
+
) : (
|
|
153
|
+
<span class="metalink empty">
|
|
154
|
+
<span class="t">{td("detail.noBacklinks")}</span>
|
|
155
|
+
</span>
|
|
156
|
+
)
|
|
157
|
+
}
|
|
158
|
+
</div>
|
|
159
|
+
</div>
|
|
160
|
+
</dl>
|
|
161
|
+
|
|
162
|
+
{
|
|
163
|
+
issues.length > 0 && (
|
|
164
|
+
<section class="issue-list compact">
|
|
165
|
+
<h2>{td("detail.validation")}</h2>
|
|
166
|
+
{issues.map((issue) => (
|
|
167
|
+
<p class={`issue severity-${issue.severity}`}>
|
|
168
|
+
<strong>{severityLabel(issue.severity)}</strong> {issue.message}
|
|
169
|
+
</p>
|
|
170
|
+
))}
|
|
171
|
+
</section>
|
|
172
|
+
)
|
|
173
|
+
}
|
|
174
|
+
</aside>
|
|
175
|
+
|
|
176
|
+
<article class="markdown" set:html={rendered.html} />
|
|
177
|
+
</div>
|
|
178
|
+
|
|
179
|
+
{
|
|
180
|
+
references.length > 0 && (
|
|
181
|
+
<section class="relations">
|
|
182
|
+
<div class="relation-group-header">
|
|
183
|
+
<h2>{td("detail.externalReferences")}</h2>
|
|
184
|
+
<Tooltip
|
|
185
|
+
label={td("linkTypes.tooltipOutgoing", { key: "references" })}
|
|
186
|
+
text={linkDesc("references", "outgoing")}
|
|
187
|
+
/>
|
|
188
|
+
</div>
|
|
189
|
+
<div class="chip-list">
|
|
190
|
+
{references.map((reference) => (
|
|
191
|
+
<Chip as="a" href={reference} interactive>
|
|
192
|
+
{reference}
|
|
193
|
+
</Chip>
|
|
194
|
+
))}
|
|
195
|
+
</div>
|
|
196
|
+
</section>
|
|
197
|
+
)
|
|
198
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from "react";
|
|
2
|
+
import { defaultLocale, isLocale, type Locale, locales } from "../lib/i18n.js";
|
|
3
|
+
import { ensureClientI18n } from "../lib/i18n-client.js";
|
|
4
|
+
|
|
5
|
+
const STORAGE_KEY = "acta-locale";
|
|
6
|
+
const COOKIE_KEY = "acta-locale";
|
|
7
|
+
|
|
8
|
+
function readLocale(): Locale {
|
|
9
|
+
if (typeof document === "undefined") return defaultLocale;
|
|
10
|
+
const stored = window.localStorage.getItem(STORAGE_KEY);
|
|
11
|
+
if (isLocale(stored ?? "")) return stored as Locale;
|
|
12
|
+
const lang = document.documentElement.lang;
|
|
13
|
+
return isLocale(lang) ? lang : defaultLocale;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function stripLocaleFromPath(path: string, base: string): string {
|
|
17
|
+
// Strip base prefix first ("/acta/" → "/acta", then remove from path start)
|
|
18
|
+
const basePrefix = base === "/" ? "" : base.replace(/\/$/, "");
|
|
19
|
+
const withoutBase =
|
|
20
|
+
basePrefix && path.startsWith(basePrefix) ? path.slice(basePrefix.length) || "/" : path;
|
|
21
|
+
const segments = withoutBase.split("/").filter(Boolean);
|
|
22
|
+
if (segments[0] && isLocale(segments[0])) {
|
|
23
|
+
return `/${segments.slice(1).join("/")}` || "/";
|
|
24
|
+
}
|
|
25
|
+
return withoutBase || "/";
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function buildTargetUrl(locale: Locale, base: string): string {
|
|
29
|
+
const bare = stripLocaleFromPath(window.location.pathname, base);
|
|
30
|
+
const basePrefix = base === "/" ? "" : base.replace(/\/$/, "");
|
|
31
|
+
const localizedPath =
|
|
32
|
+
locale === defaultLocale
|
|
33
|
+
? bare
|
|
34
|
+
: bare === "/"
|
|
35
|
+
? `/${locale}/`
|
|
36
|
+
: `/${locale}${bare.startsWith("/") ? bare : `/${bare}`}`;
|
|
37
|
+
return `${basePrefix}${localizedPath}${window.location.search}${window.location.hash}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
type Props = { labels: Record<Locale, string>; legend: string; base?: string };
|
|
41
|
+
|
|
42
|
+
export default function LanguageSwitcher({
|
|
43
|
+
labels,
|
|
44
|
+
legend,
|
|
45
|
+
base = import.meta.env.BASE_URL,
|
|
46
|
+
}: Props) {
|
|
47
|
+
ensureClientI18n();
|
|
48
|
+
const [current, setCurrent] = useState<Locale>(defaultLocale);
|
|
49
|
+
const [open, setOpen] = useState(false);
|
|
50
|
+
const rootRef = useRef<HTMLDivElement>(null);
|
|
51
|
+
|
|
52
|
+
useEffect(() => {
|
|
53
|
+
setCurrent(readLocale());
|
|
54
|
+
}, []);
|
|
55
|
+
|
|
56
|
+
useEffect(() => {
|
|
57
|
+
if (!open) return;
|
|
58
|
+
const onClick = (event: MouseEvent) => {
|
|
59
|
+
if (!rootRef.current?.contains(event.target as Node)) setOpen(false);
|
|
60
|
+
};
|
|
61
|
+
const onKey = (event: KeyboardEvent) => {
|
|
62
|
+
if (event.key === "Escape") setOpen(false);
|
|
63
|
+
};
|
|
64
|
+
document.addEventListener("mousedown", onClick);
|
|
65
|
+
document.addEventListener("keydown", onKey);
|
|
66
|
+
return () => {
|
|
67
|
+
document.removeEventListener("mousedown", onClick);
|
|
68
|
+
document.removeEventListener("keydown", onKey);
|
|
69
|
+
};
|
|
70
|
+
}, [open]);
|
|
71
|
+
|
|
72
|
+
const update = (next: Locale) => {
|
|
73
|
+
setOpen(false);
|
|
74
|
+
if (next === current) return;
|
|
75
|
+
window.localStorage.setItem(STORAGE_KEY, next);
|
|
76
|
+
document.cookie = `${COOKIE_KEY}=${next}; path=/; max-age=31536000; samesite=lax`;
|
|
77
|
+
window.location.assign(buildTargetUrl(next, base));
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
return (
|
|
81
|
+
<div className="lang-switcher" ref={rootRef}>
|
|
82
|
+
<button
|
|
83
|
+
type="button"
|
|
84
|
+
className="lang-trigger"
|
|
85
|
+
aria-label={legend}
|
|
86
|
+
aria-haspopup="listbox"
|
|
87
|
+
aria-expanded={open}
|
|
88
|
+
onClick={() => setOpen((value) => !value)}
|
|
89
|
+
>
|
|
90
|
+
<span className="code">{current.toUpperCase()}</span>
|
|
91
|
+
<span className="name">{labels[current]}</span>
|
|
92
|
+
<span className="caret" aria-hidden="true">
|
|
93
|
+
{open ? "▴" : "▾"}
|
|
94
|
+
</span>
|
|
95
|
+
</button>
|
|
96
|
+
{open && (
|
|
97
|
+
<div className="lang-menu" role="listbox" aria-label={legend}>
|
|
98
|
+
{locales.map((code) => (
|
|
99
|
+
<button
|
|
100
|
+
key={code}
|
|
101
|
+
type="button"
|
|
102
|
+
role="option"
|
|
103
|
+
aria-selected={code === current}
|
|
104
|
+
className={code === current ? "is-active" : ""}
|
|
105
|
+
onClick={() => update(code)}
|
|
106
|
+
>
|
|
107
|
+
<span className="code">{code.toUpperCase()}</span>
|
|
108
|
+
<span className="name">{labels[code]}</span>
|
|
109
|
+
</button>
|
|
110
|
+
))}
|
|
111
|
+
</div>
|
|
112
|
+
)}
|
|
113
|
+
</div>
|
|
114
|
+
);
|
|
115
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
|
|
3
|
+
const STORAGE_KEY = "acta-sidebar";
|
|
4
|
+
|
|
5
|
+
function readCollapsed(): boolean {
|
|
6
|
+
if (typeof window === "undefined") return false;
|
|
7
|
+
return window.localStorage.getItem(STORAGE_KEY) === "collapsed";
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function applyCollapsed(collapsed: boolean): void {
|
|
11
|
+
document.documentElement.dataset.sidebar = collapsed ? "collapsed" : "expanded";
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
type Props = { labels: { collapse: string; expand: string } };
|
|
15
|
+
|
|
16
|
+
export default function SidebarToggle({ labels }: Props) {
|
|
17
|
+
const [collapsed, setCollapsed] = useState(false);
|
|
18
|
+
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
const initial = readCollapsed();
|
|
21
|
+
setCollapsed(initial);
|
|
22
|
+
}, []);
|
|
23
|
+
|
|
24
|
+
const toggle = () => {
|
|
25
|
+
const next = !collapsed;
|
|
26
|
+
setCollapsed(next);
|
|
27
|
+
window.localStorage.setItem(STORAGE_KEY, next ? "collapsed" : "expanded");
|
|
28
|
+
applyCollapsed(next);
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
<button
|
|
33
|
+
type="button"
|
|
34
|
+
className="ui-sidebar-toggle"
|
|
35
|
+
aria-label={collapsed ? labels.expand : labels.collapse}
|
|
36
|
+
aria-pressed={collapsed}
|
|
37
|
+
onClick={toggle}
|
|
38
|
+
>
|
|
39
|
+
<span aria-hidden="true">{collapsed ? "›" : "‹"}</span>
|
|
40
|
+
</button>
|
|
41
|
+
);
|
|
42
|
+
}
|