@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,24 @@
|
|
|
1
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { describe, expect, it } from "vitest";
|
|
5
|
+
import { findActaRoot, getActaConfigPath } from "./project.js";
|
|
6
|
+
|
|
7
|
+
describe("web project utilities", () => {
|
|
8
|
+
it("finds the nearest parent directory containing acta.config.ts", async () => {
|
|
9
|
+
const root = join(tmpdir(), `acta-web-${crypto.randomUUID()}`);
|
|
10
|
+
const nested = join(root, "apps", "web", "src");
|
|
11
|
+
await mkdir(nested, { recursive: true });
|
|
12
|
+
await writeFile(join(root, "acta.config.ts"), "export default {};\n", "utf8");
|
|
13
|
+
|
|
14
|
+
await expect(findActaRoot(nested)).resolves.toBe(root);
|
|
15
|
+
await expect(getActaConfigPath(nested)).resolves.toBe(join(root, "acta.config.ts"));
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("throws a clear error when no Acta config exists in parent directories", async () => {
|
|
19
|
+
const root = join(tmpdir(), `acta-web-${crypto.randomUUID()}`);
|
|
20
|
+
await mkdir(root, { recursive: true });
|
|
21
|
+
|
|
22
|
+
await expect(findActaRoot(root)).rejects.toThrow("Could not find acta.config.ts");
|
|
23
|
+
});
|
|
24
|
+
});
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { access, readFile } from "node:fs/promises";
|
|
2
|
+
import { dirname, join, parse, resolve } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import type {
|
|
5
|
+
ActaDocument,
|
|
6
|
+
DocumentGraph,
|
|
7
|
+
DocumentOrdering,
|
|
8
|
+
ValidationResult,
|
|
9
|
+
} from "@acta-dev/core";
|
|
10
|
+
import { sortDocumentsByNewest } from "./documents.js";
|
|
11
|
+
|
|
12
|
+
export interface ActaWebProject {
|
|
13
|
+
documents: ActaDocument[];
|
|
14
|
+
graph: DocumentGraph;
|
|
15
|
+
ordering: DocumentOrdering;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface ActaWebData {
|
|
19
|
+
project: ActaWebProject;
|
|
20
|
+
validation: ValidationResult;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
let cachedData: Promise<ActaWebData> | undefined;
|
|
24
|
+
|
|
25
|
+
export async function loadActaWebData(distDir?: string): Promise<ActaWebData> {
|
|
26
|
+
cachedData ??= loadUncachedActaWebData(distDir);
|
|
27
|
+
return cachedData;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Resolve the directory holding the `acta build` artifacts.
|
|
32
|
+
*
|
|
33
|
+
* Precedence:
|
|
34
|
+
* 1. explicit argument (used by tests),
|
|
35
|
+
* 2. `ACTA_DIST_DIR` env (set by `acta site` when building outside the monorepo),
|
|
36
|
+
* 3. `<ACTA_PROJECT_ROOT|findActaRoot()>/.acta/dist` (monorepo dev fallback).
|
|
37
|
+
*/
|
|
38
|
+
export async function resolveDistDir(distDir?: string): Promise<string> {
|
|
39
|
+
if (distDir) {
|
|
40
|
+
return resolve(distDir);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (process.env.ACTA_DIST_DIR) {
|
|
44
|
+
return resolve(process.env.ACTA_DIST_DIR);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const root = process.env.ACTA_PROJECT_ROOT
|
|
48
|
+
? resolve(process.env.ACTA_PROJECT_ROOT)
|
|
49
|
+
: await findActaRoot();
|
|
50
|
+
|
|
51
|
+
return join(root, ".acta", "dist");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function findActaRoot(startDir = defaultStartDir()): Promise<string> {
|
|
55
|
+
let current = resolve(startDir);
|
|
56
|
+
const root = parse(current).root;
|
|
57
|
+
|
|
58
|
+
while (true) {
|
|
59
|
+
const configPath = join(current, "acta.config.ts");
|
|
60
|
+
if (await exists(configPath)) {
|
|
61
|
+
return current;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (current === root) {
|
|
65
|
+
throw new Error(`Could not find acta.config.ts from ${startDir}`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
current = dirname(current);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export async function getActaConfigPath(startDir = defaultStartDir()): Promise<string> {
|
|
73
|
+
return join(await findActaRoot(startDir), "acta.config.ts");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function loadUncachedActaWebData(distDir?: string): Promise<ActaWebData> {
|
|
77
|
+
const dir = await resolveDistDir(distDir);
|
|
78
|
+
|
|
79
|
+
const [documents, graph, ordering, validation] = await Promise.all([
|
|
80
|
+
readArtifact<ActaDocument[]>(dir, "documents.json"),
|
|
81
|
+
readArtifact<DocumentGraph>(dir, "graph.json"),
|
|
82
|
+
readArtifact<DocumentOrdering>(dir, "ordering.json"),
|
|
83
|
+
readArtifact<ValidationResult>(dir, "validation.json"),
|
|
84
|
+
]);
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
project: {
|
|
88
|
+
documents: sortDocumentsByNewest(documents),
|
|
89
|
+
graph,
|
|
90
|
+
ordering,
|
|
91
|
+
},
|
|
92
|
+
validation,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function readArtifact<T>(dir: string, file: string): Promise<T> {
|
|
97
|
+
const path = join(dir, file);
|
|
98
|
+
|
|
99
|
+
try {
|
|
100
|
+
return JSON.parse(await readFile(path, "utf8")) as T;
|
|
101
|
+
} catch (cause) {
|
|
102
|
+
throw new Error(
|
|
103
|
+
`Could not read Acta artifact ${path}. Run \`acta build\` before building the site.`,
|
|
104
|
+
{ cause },
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function exists(path: string): Promise<boolean> {
|
|
110
|
+
try {
|
|
111
|
+
await access(path);
|
|
112
|
+
return true;
|
|
113
|
+
} catch {
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function defaultStartDir(): string {
|
|
119
|
+
return dirname(fileURLToPath(import.meta.url));
|
|
120
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import {
|
|
2
|
+
buildSearchUrl,
|
|
3
|
+
getQueryFromUrl,
|
|
4
|
+
searchDocuments,
|
|
5
|
+
updateQueryInUrl,
|
|
6
|
+
type WebSearchFilters,
|
|
7
|
+
type WebSearchIndexArtifact,
|
|
8
|
+
} from "./search.js";
|
|
9
|
+
|
|
10
|
+
let searchIndexPromise: Promise<WebSearchIndexArtifact> | undefined;
|
|
11
|
+
let fullSearchIndexPromise: Promise<WebSearchIndexArtifact> | undefined;
|
|
12
|
+
|
|
13
|
+
export function initDocumentSearch(root: HTMLElement): void {
|
|
14
|
+
const searchInput = root.querySelector<HTMLInputElement>("[data-search-query]");
|
|
15
|
+
const kindSelect = root.querySelector<HTMLSelectElement>("[data-filter-kind]");
|
|
16
|
+
const statusSelect = root.querySelector<HTMLSelectElement>("[data-filter-status]");
|
|
17
|
+
const tagSelect = root.querySelector<HTMLSelectElement>("[data-filter-tag]");
|
|
18
|
+
const componentSelect = root.querySelector<HTMLSelectElement>("[data-filter-component]");
|
|
19
|
+
const sortInputs = Array.from(root.querySelectorAll<HTMLInputElement>("[data-sort-order]"));
|
|
20
|
+
const contentSearchInput = root.querySelector<HTMLInputElement>("[data-search-content]");
|
|
21
|
+
const contentLoading = root.querySelector<HTMLElement>("[data-search-content-loading]");
|
|
22
|
+
const documentList = root.querySelector<HTMLElement>("[data-document-list]");
|
|
23
|
+
const showMoreButton = root.querySelector<HTMLButtonElement>("[data-show-more]");
|
|
24
|
+
const rows = Array.from(root.querySelectorAll<HTMLElement>("[data-document-row]"));
|
|
25
|
+
const emptyState = root.querySelector<HTMLElement>("[data-empty-state]");
|
|
26
|
+
const searchLink = root.querySelector<HTMLAnchorElement>("[data-search-link]");
|
|
27
|
+
|
|
28
|
+
if (
|
|
29
|
+
!searchInput ||
|
|
30
|
+
!kindSelect ||
|
|
31
|
+
!statusSelect ||
|
|
32
|
+
!tagSelect ||
|
|
33
|
+
!componentSelect ||
|
|
34
|
+
!contentSearchInput ||
|
|
35
|
+
sortInputs.length === 0 ||
|
|
36
|
+
!documentList ||
|
|
37
|
+
!showMoreButton ||
|
|
38
|
+
!emptyState
|
|
39
|
+
) {
|
|
40
|
+
throw new Error("Document search controls failed to initialize.");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const search = searchInput;
|
|
44
|
+
const kindFilter = kindSelect;
|
|
45
|
+
const statusFilter = statusSelect;
|
|
46
|
+
const tagFilter = tagSelect;
|
|
47
|
+
const componentFilter = componentSelect;
|
|
48
|
+
const contentSearch = contentSearchInput;
|
|
49
|
+
const loading = contentLoading;
|
|
50
|
+
const list = documentList;
|
|
51
|
+
const showMore = showMoreButton;
|
|
52
|
+
const empty = emptyState;
|
|
53
|
+
const rowsById = new Map(rows.map((row) => [row.dataset.documentId ?? "", row]));
|
|
54
|
+
const pageSize = Number.parseInt(list.dataset.pageSize ?? "20", 10);
|
|
55
|
+
const increment = Number.isFinite(pageSize) && pageSize > 0 ? pageSize : 20;
|
|
56
|
+
let visibleLimit = increment;
|
|
57
|
+
let currentMatchingCount = rows.length;
|
|
58
|
+
let updateSequence = 0;
|
|
59
|
+
|
|
60
|
+
search.value = getQueryFromUrl(window.location.href);
|
|
61
|
+
|
|
62
|
+
function filters(): WebSearchFilters {
|
|
63
|
+
return {
|
|
64
|
+
kind: kindFilter.value,
|
|
65
|
+
status: statusFilter.value,
|
|
66
|
+
tag: tagFilter.value,
|
|
67
|
+
component: componentFilter.value,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function selectedSortOrder(): "newest" | "dependency" {
|
|
72
|
+
return sortInputs.find((input) => input.checked)?.value === "dependency"
|
|
73
|
+
? "dependency"
|
|
74
|
+
: "newest";
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function syncQueryUrl() {
|
|
78
|
+
const nextUrl = updateQueryInUrl(window.location.href, search.value);
|
|
79
|
+
history.replaceState(history.state, "", `${nextUrl.pathname}${nextUrl.search}${nextUrl.hash}`);
|
|
80
|
+
if (searchLink) {
|
|
81
|
+
searchLink.href = buildSearchUrl(search.value);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function matchingRows(): Promise<HTMLElement[]> {
|
|
86
|
+
const query = search.value.trim();
|
|
87
|
+
|
|
88
|
+
if (query.length > 0) {
|
|
89
|
+
const includeContent = contentSearch.checked || query.length > 2;
|
|
90
|
+
if (loading) {
|
|
91
|
+
loading.hidden = !includeContent || fullSearchIndexPromise !== undefined;
|
|
92
|
+
}
|
|
93
|
+
const ids = await searchDocuments(await loadSearchIndex(includeContent), query, filters());
|
|
94
|
+
if (loading) {
|
|
95
|
+
loading.hidden = true;
|
|
96
|
+
}
|
|
97
|
+
return ids.map((id) => rowsById.get(id)).filter((row) => row !== undefined);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const orderKey = selectedSortOrder() === "dependency" ? "dependencyOrder" : "newestOrder";
|
|
101
|
+
return rows
|
|
102
|
+
.filter((row) => rowMatchesFilters(row, filters()))
|
|
103
|
+
.sort((left, right) => {
|
|
104
|
+
const leftOrder = Number.parseInt(left.dataset[orderKey] ?? "0", 10);
|
|
105
|
+
const rightOrder = Number.parseInt(right.dataset[orderKey] ?? "0", 10);
|
|
106
|
+
return leftOrder - rightOrder;
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function updateResults() {
|
|
111
|
+
updateSequence += 1;
|
|
112
|
+
const sequence = updateSequence;
|
|
113
|
+
syncQueryUrl();
|
|
114
|
+
const matches = await matchingRows();
|
|
115
|
+
|
|
116
|
+
if (sequence !== updateSequence) {
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
for (const row of rows) {
|
|
121
|
+
row.hidden = true;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
matches.forEach((row, index) => {
|
|
125
|
+
list.append(row);
|
|
126
|
+
row.hidden = index >= visibleLimit;
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
list.append(empty);
|
|
130
|
+
currentMatchingCount = matches.length;
|
|
131
|
+
empty.hidden = matches.length > 0;
|
|
132
|
+
showMore.hidden = matches.length <= visibleLimit;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const resetAndUpdateResults = () => {
|
|
136
|
+
visibleLimit = increment;
|
|
137
|
+
void updateResults();
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
for (const control of [
|
|
141
|
+
search,
|
|
142
|
+
kindFilter,
|
|
143
|
+
statusFilter,
|
|
144
|
+
tagFilter,
|
|
145
|
+
componentFilter,
|
|
146
|
+
contentSearch,
|
|
147
|
+
...sortInputs,
|
|
148
|
+
]) {
|
|
149
|
+
control.addEventListener("input", resetAndUpdateResults);
|
|
150
|
+
control.addEventListener("change", resetAndUpdateResults);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
showMore.addEventListener("click", () => {
|
|
154
|
+
visibleLimit = Math.min(visibleLimit + increment, currentMatchingCount);
|
|
155
|
+
void updateResults();
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
void updateResults();
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function rowMatchesFilters(row: HTMLElement, filters: WebSearchFilters): boolean {
|
|
162
|
+
return (
|
|
163
|
+
(!filters.kind || row.dataset.kind === filters.kind) &&
|
|
164
|
+
(!filters.status || row.dataset.status === filters.status) &&
|
|
165
|
+
(!filters.tag || (row.dataset.tags ?? "").split(" ").includes(filters.tag)) &&
|
|
166
|
+
(!filters.component || (row.dataset.components ?? "").split(" ").includes(filters.component))
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async function loadSearchIndex(includeContent: boolean): Promise<WebSearchIndexArtifact> {
|
|
171
|
+
if (includeContent) {
|
|
172
|
+
fullSearchIndexPromise ??= fetch("/search-index-full.json").then((response) => {
|
|
173
|
+
if (!response.ok) {
|
|
174
|
+
throw new Error(`Failed to load full search index: ${response.status}`);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return response.json() as Promise<WebSearchIndexArtifact>;
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
return fullSearchIndexPromise;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
searchIndexPromise ??= fetch("/search-index.json").then((response) => {
|
|
184
|
+
if (!response.ok) {
|
|
185
|
+
throw new Error(`Failed to load search index: ${response.status}`);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return response.json() as Promise<WebSearchIndexArtifact>;
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
return searchIndexPromise;
|
|
192
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
buildSearchUrl,
|
|
4
|
+
getQueryFromUrl,
|
|
5
|
+
removeQueryFromUrl,
|
|
6
|
+
searchDocuments,
|
|
7
|
+
updateQueryInUrl,
|
|
8
|
+
type WebSearchIndexArtifact,
|
|
9
|
+
} from "./search.js";
|
|
10
|
+
|
|
11
|
+
const index: WebSearchIndexArtifact = {
|
|
12
|
+
schemaVersion: "1.0.0",
|
|
13
|
+
documents: [
|
|
14
|
+
{
|
|
15
|
+
id: "ADR-0001",
|
|
16
|
+
href: "/documents/ADR-0001/",
|
|
17
|
+
kind: "adr",
|
|
18
|
+
status: "accepted",
|
|
19
|
+
date: "2026-05-01",
|
|
20
|
+
title: "Use Markdown as source of truth",
|
|
21
|
+
summary: "Markdown remains canonical.",
|
|
22
|
+
tags: ["source"],
|
|
23
|
+
components: ["acta-core"],
|
|
24
|
+
owners: ["Boris"],
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
id: "SPEC-0004",
|
|
28
|
+
href: "/documents/SPEC-0004/",
|
|
29
|
+
kind: "spec",
|
|
30
|
+
status: "active",
|
|
31
|
+
date: "2026-05-02",
|
|
32
|
+
title: "Acta web viewer",
|
|
33
|
+
summary: "Static document viewer.",
|
|
34
|
+
tags: ["web"],
|
|
35
|
+
components: ["acta-web"],
|
|
36
|
+
owners: ["Boris"],
|
|
37
|
+
},
|
|
38
|
+
],
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const fullIndex: WebSearchIndexArtifact = {
|
|
42
|
+
schemaVersion: "1.0.0",
|
|
43
|
+
documents: [
|
|
44
|
+
{
|
|
45
|
+
...index.documents[0],
|
|
46
|
+
sectionsText: "Context Decision Consequences",
|
|
47
|
+
bodyText: "Markdown files stay in Git.",
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
...index.documents[1],
|
|
51
|
+
sectionsText: "Requirements Client-side search",
|
|
52
|
+
bodyText: "Astro interface with Orama search.",
|
|
53
|
+
},
|
|
54
|
+
],
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
describe("web search utilities", () => {
|
|
58
|
+
it("reads and normalizes q from URLs", () => {
|
|
59
|
+
expect(getQueryFromUrl("https://acta.test/search?q=%20Astro%20")).toBe("Astro");
|
|
60
|
+
expect(getQueryFromUrl("https://acta.test/search?q=%20%20")).toBe("");
|
|
61
|
+
expect(getQueryFromUrl("https://acta.test/search")).toBe("");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("updates q in URLs and removes empty queries", () => {
|
|
65
|
+
expect(updateQueryInUrl("https://acta.test/search?kind=spec", "Astro").toString()).toBe(
|
|
66
|
+
"https://acta.test/search?kind=spec&q=Astro",
|
|
67
|
+
);
|
|
68
|
+
expect(removeQueryFromUrl("https://acta.test/search?q=Astro&kind=spec").toString()).toBe(
|
|
69
|
+
"https://acta.test/search?kind=spec",
|
|
70
|
+
);
|
|
71
|
+
expect(buildSearchUrl("Astro docs")).toBe("/search?q=Astro+docs");
|
|
72
|
+
expect(buildSearchUrl("")).toBe("/search");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("ranks exact ID and title matches before body matches", async () => {
|
|
76
|
+
await expect(searchDocuments(index, "SPEC-0004")).resolves.toEqual(["SPEC-0004"]);
|
|
77
|
+
await expect(searchDocuments(index, "markdown")).resolves.toEqual(["ADR-0001"]);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("searches body content only when full index fields are present", async () => {
|
|
81
|
+
await expect(searchDocuments(index, "orama")).resolves.toEqual([]);
|
|
82
|
+
await expect(searchDocuments(fullIndex, "orama")).resolves.toEqual(["SPEC-0004"]);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("applies metadata filters after Orama ranking", async () => {
|
|
86
|
+
await expect(searchDocuments(index, "viewer", { kind: "adr" })).resolves.toEqual([]);
|
|
87
|
+
await expect(searchDocuments(index, "viewer", { kind: "spec" })).resolves.toEqual([
|
|
88
|
+
"SPEC-0004",
|
|
89
|
+
]);
|
|
90
|
+
await expect(searchDocuments(index, "source", { component: "acta-core" })).resolves.toEqual([
|
|
91
|
+
"ADR-0001",
|
|
92
|
+
]);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { create, insertMultiple, search } from "@orama/orama";
|
|
2
|
+
|
|
3
|
+
export interface WebSearchIndexArtifact {
|
|
4
|
+
schemaVersion: string;
|
|
5
|
+
documents: WebSearchIndexDocument[];
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface WebSearchIndexDocument {
|
|
9
|
+
id: string;
|
|
10
|
+
href: string;
|
|
11
|
+
kind: "adr" | "spec";
|
|
12
|
+
status: string;
|
|
13
|
+
date: string;
|
|
14
|
+
title: string;
|
|
15
|
+
summary: string;
|
|
16
|
+
tags: string[];
|
|
17
|
+
components: string[];
|
|
18
|
+
owners: string[];
|
|
19
|
+
sectionsText?: string;
|
|
20
|
+
bodyText?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface WebSearchFilters {
|
|
24
|
+
kind?: string;
|
|
25
|
+
status?: string;
|
|
26
|
+
tag?: string;
|
|
27
|
+
component?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const searchSchema = {
|
|
31
|
+
id: "string",
|
|
32
|
+
href: "string",
|
|
33
|
+
kind: "string",
|
|
34
|
+
status: "string",
|
|
35
|
+
date: "string",
|
|
36
|
+
title: "string",
|
|
37
|
+
summary: "string",
|
|
38
|
+
tags: "string[]",
|
|
39
|
+
components: "string[]",
|
|
40
|
+
owners: "string[]",
|
|
41
|
+
} as const;
|
|
42
|
+
|
|
43
|
+
const fullSearchSchema = {
|
|
44
|
+
...searchSchema,
|
|
45
|
+
sectionsText: "string",
|
|
46
|
+
bodyText: "string",
|
|
47
|
+
} as const;
|
|
48
|
+
|
|
49
|
+
export function getQueryFromUrl(url: string | URL): string {
|
|
50
|
+
return new URL(url).searchParams.get("q")?.trim() ?? "";
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function updateQueryInUrl(url: string | URL, query: string): URL {
|
|
54
|
+
const nextUrl = new URL(url);
|
|
55
|
+
const normalizedQuery = query.trim();
|
|
56
|
+
|
|
57
|
+
if (normalizedQuery.length > 0) {
|
|
58
|
+
nextUrl.searchParams.set("q", normalizedQuery);
|
|
59
|
+
} else {
|
|
60
|
+
nextUrl.searchParams.delete("q");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return nextUrl;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function removeQueryFromUrl(url: string | URL): URL {
|
|
67
|
+
return updateQueryInUrl(url, "");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function buildSearchUrl(query: string): string {
|
|
71
|
+
const url = updateQueryInUrl("https://acta.local/search", query);
|
|
72
|
+
return `${url.pathname}${url.search}`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export async function searchDocuments(
|
|
76
|
+
index: WebSearchIndexArtifact,
|
|
77
|
+
query: string,
|
|
78
|
+
filters: WebSearchFilters = {},
|
|
79
|
+
): Promise<string[]> {
|
|
80
|
+
const normalizedQuery = query.trim();
|
|
81
|
+
const filteredDocuments = normalizedQuery
|
|
82
|
+
? await rankedDocuments(index, normalizedQuery)
|
|
83
|
+
: index.documents;
|
|
84
|
+
|
|
85
|
+
return filteredDocuments
|
|
86
|
+
.filter((document) => matchesFilters(document, filters))
|
|
87
|
+
.map((document) => document.id);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function matchesFilters(document: WebSearchIndexDocument, filters: WebSearchFilters): boolean {
|
|
91
|
+
return (
|
|
92
|
+
(!filters.kind || document.kind === filters.kind) &&
|
|
93
|
+
(!filters.status || document.status === filters.status) &&
|
|
94
|
+
(!filters.tag || document.tags.includes(filters.tag)) &&
|
|
95
|
+
(!filters.component || document.components.includes(filters.component))
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function rankedDocuments(
|
|
100
|
+
index: WebSearchIndexArtifact,
|
|
101
|
+
query: string,
|
|
102
|
+
): Promise<WebSearchIndexDocument[]> {
|
|
103
|
+
const searchesBody = index.documents.some(
|
|
104
|
+
(document) => document.sectionsText !== undefined || document.bodyText !== undefined,
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
if (searchesBody) {
|
|
108
|
+
const db = create({ schema: fullSearchSchema });
|
|
109
|
+
await insertMultiple(db, index.documents);
|
|
110
|
+
const result = await search<typeof db, WebSearchIndexDocument>(db, {
|
|
111
|
+
term: query,
|
|
112
|
+
properties: [
|
|
113
|
+
"id",
|
|
114
|
+
"title",
|
|
115
|
+
"summary",
|
|
116
|
+
"tags",
|
|
117
|
+
"components",
|
|
118
|
+
"owners",
|
|
119
|
+
"sectionsText",
|
|
120
|
+
"bodyText",
|
|
121
|
+
],
|
|
122
|
+
boost: {
|
|
123
|
+
id: 16,
|
|
124
|
+
title: 10,
|
|
125
|
+
tags: 6,
|
|
126
|
+
components: 6,
|
|
127
|
+
summary: 4,
|
|
128
|
+
sectionsText: 2,
|
|
129
|
+
bodyText: 1,
|
|
130
|
+
},
|
|
131
|
+
limit: index.documents.length,
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
return result.hits.map((hit) => hit.document);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const db = create({ schema: searchSchema });
|
|
138
|
+
await insertMultiple(db, index.documents);
|
|
139
|
+
const result = await search<typeof db, WebSearchIndexDocument>(db, {
|
|
140
|
+
term: query,
|
|
141
|
+
properties: ["id", "title", "summary", "tags", "components", "owners"],
|
|
142
|
+
boost: {
|
|
143
|
+
id: 16,
|
|
144
|
+
title: 10,
|
|
145
|
+
tags: 6,
|
|
146
|
+
components: 6,
|
|
147
|
+
summary: 4,
|
|
148
|
+
},
|
|
149
|
+
limit: index.documents.length,
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
return result.hits.map((hit) => hit.document);
|
|
153
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
{
|
|
2
|
+
"tab": "Acta documents",
|
|
3
|
+
"meta": "Browse Acta ADR and spec documents",
|
|
4
|
+
"eyebrow": "Repository viewer",
|
|
5
|
+
"header": "Documents",
|
|
6
|
+
"counts": "{{count}} documents, {{adrs}} ADRs, {{specs}} specs",
|
|
7
|
+
"issuesPill": "{{errors}} errors · {{warnings}} warnings"
|
|
8
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
{
|
|
2
|
+
"filters": {
|
|
3
|
+
"legend": "Document filters",
|
|
4
|
+
"search": "Search",
|
|
5
|
+
"searchPlaceholder": "Title, tag, owner, section...",
|
|
6
|
+
"kind": "Kind",
|
|
7
|
+
"status": "Status",
|
|
8
|
+
"tag": "Tag",
|
|
9
|
+
"component": "Component",
|
|
10
|
+
"sort": "Sort",
|
|
11
|
+
"sortNewest": "Newest first",
|
|
12
|
+
"sortDependency": "Dependency order",
|
|
13
|
+
"searchContent": "Search in content",
|
|
14
|
+
"searchingContent": "Searching content..."
|
|
15
|
+
},
|
|
16
|
+
"empty": "No matching documents.",
|
|
17
|
+
"showMore": "Show more",
|
|
18
|
+
"detail": {
|
|
19
|
+
"file": "File",
|
|
20
|
+
"owners": "Owners",
|
|
21
|
+
"tags": "Tags",
|
|
22
|
+
"components": "Components",
|
|
23
|
+
"validation": "Validation",
|
|
24
|
+
"links": "Links",
|
|
25
|
+
"backlinks": "Backlinks",
|
|
26
|
+
"externalReferences": "External references",
|
|
27
|
+
"noOutgoing": "No outgoing internal links.",
|
|
28
|
+
"noBacklinks": "No backlinks.",
|
|
29
|
+
"back": "Back to documents"
|
|
30
|
+
},
|
|
31
|
+
"linkTypes": {
|
|
32
|
+
"tooltipOutgoing": "What is \"{{key}}\"?",
|
|
33
|
+
"tooltipIncoming": "What is \"{{key}}\" (incoming)?",
|
|
34
|
+
"related": {
|
|
35
|
+
"outgoing": "Loose semantic connection — context shared, no dependency.",
|
|
36
|
+
"incoming": "Other documents that mark this one as related context."
|
|
37
|
+
},
|
|
38
|
+
"supersedes": {
|
|
39
|
+
"outgoing": "This document replaces the linked documents.",
|
|
40
|
+
"incoming": "Documents that replace this one (this one is superseded)."
|
|
41
|
+
},
|
|
42
|
+
"replacedBy": {
|
|
43
|
+
"outgoing": "The linked documents replace this one.",
|
|
44
|
+
"incoming": "Documents that this one replaces."
|
|
45
|
+
},
|
|
46
|
+
"decidedBy": {
|
|
47
|
+
"outgoing": "Decisions (ADRs) that drive this document.",
|
|
48
|
+
"incoming": "Documents driven by the decision recorded here."
|
|
49
|
+
},
|
|
50
|
+
"dependsOn": {
|
|
51
|
+
"outgoing": "Documents this one depends on.",
|
|
52
|
+
"incoming": "Documents that depend on this one."
|
|
53
|
+
},
|
|
54
|
+
"validates": {
|
|
55
|
+
"outgoing": "Documents this one validates (e.g. tests/specs verify decisions).",
|
|
56
|
+
"incoming": "Documents that validate this one."
|
|
57
|
+
},
|
|
58
|
+
"references": {
|
|
59
|
+
"outgoing": "External URLs cited from this document.",
|
|
60
|
+
"incoming": "External references pointing here."
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"tab": "Acta graph",
|
|
3
|
+
"meta": "Explore Acta document relationships",
|
|
4
|
+
"eyebrow": "Repository viewer",
|
|
5
|
+
"header": "Graph",
|
|
6
|
+
"counts": "{{nodes}} nodes, {{edges}} relationships",
|
|
7
|
+
"filters": {
|
|
8
|
+
"legend": "Graph filters",
|
|
9
|
+
"kind": "Kind",
|
|
10
|
+
"status": "Status"
|
|
11
|
+
},
|
|
12
|
+
"areaLabel": "Document relationship graph",
|
|
13
|
+
"legend": {
|
|
14
|
+
"label": "Graph legend",
|
|
15
|
+
"dependency": "Dependency",
|
|
16
|
+
"related": "Related",
|
|
17
|
+
"supersession": "Supersession"
|
|
18
|
+
}
|
|
19
|
+
}
|