Release govoplan-docs v0.1.23: unify help discovery and batch semantic reads
Module Package Release / publish-packages (push) Successful in 12s

This commit is contained in:
2026-09-08 01:32:36 +02:00
parent 1ae23b4e9d
commit cbe434de02
18 changed files with 842 additions and 81 deletions
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@govoplan/docs-webui",
"version": "0.1.22",
"version": "0.1.23",
"private": true,
"type": "module",
"main": "src/index.ts",
@@ -16,7 +16,7 @@
"typecheck": "tsc --noEmit"
},
"peerDependencies": {
"@govoplan/core-webui": "^0.1.18",
"@govoplan/core-webui": "^0.1.45",
"lucide-react": "^1.23.0",
"react": ">=19.2.7 <20",
"react-dom": ">=19.2.7 <20",
+1
View File
@@ -178,6 +178,7 @@ export type DocsDocumentationTopic = {
conditions: DocsDocumentationCondition[];
links: DocsDocumentationLink[];
related_modules: string[];
area_module_ids?: string[];
unlocks: string[];
configuration_keys: string[];
configuration_states: Array<{
+65 -40
View File
@@ -8,9 +8,15 @@ import {
Dialog,
DismissibleAlert,
ExplorerTree,
FilterBar,
FormField,
MultiSelectFilter,
PageActionBar,
PageLayout,
SegmentedControl,
SelectionList,
SelectionListItem,
SelectionListItemContent,
StatusBadge,
WorkspaceLayout,
adminErrorMessage,
@@ -30,6 +36,7 @@ import {
type DocsSource,
type DocsSourceDetail
} from "../../api/docs";
import { ancestorOccurrenceIds, documentationTagOptions, documentationTags, matchesDocumentationTopic, qualifyTreeOccurrences, selectedTreeOccurrence, topicAreaIds } from "./docsDiscovery";
type DocumentationType = "admin" | "user";
@@ -68,13 +75,15 @@ type OutlineItem = {
export default function DocsPage({ settings }: { settings: ApiSettings }) {
const location = useLocation();
const navigate = useGuardedNavigate();
const { language } = usePlatformLanguage();
const { language, translateText } = usePlatformLanguage();
const [context, setContext] = useState<DocsContext | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const loadSequence = useRef(0);
const [documentationType, setDocumentationType] = useState<DocumentationType>(() => documentationTypeFromSearch(location.search));
const [expandedNodes, setExpandedNodes] = useState<Set<string>>(() => new Set());
const [searchQuery, setSearchQuery] = useState("");
const [selectedTags, setSelectedTags] = useState<string[] | null>(null);
const locale = localeFromSearch(location.search) ?? language;
const selectedVersion = versionFromSearch(location.search);
const adminDocs = documentationType === "admin";
@@ -109,6 +118,12 @@ export default function DocsPage({ settings }: { settings: ApiSettings }) {
const treeNodes = useMemo(() => docsTreeNodes(context, adminDocs), [context, adminDocs]);
const pages = useMemo(() => flattenTreePages(treeNodes), [treeNodes]);
const selectedPage = selectedPageFromSearch(location.search, pages);
const selectedNode = selectedTreeOccurrence(treeNodes, selectedPage?.id ?? "", new URLSearchParams(location.search).get("occurrence"));
const allTopics = useMemo(() => allDocumentationTopics(context), [context]);
const tagOptions = useMemo(() => documentationTagOptions(allTopics, context), [allTopics, context]);
const matchingTopics = useMemo(() => allTopics.filter((topic) => matchesDocumentationTopic(topic, context, searchQuery, selectedTags)), [allTopics, context, searchQuery, selectedTags]);
const filtering = Boolean(searchQuery.trim()) || selectedTags !== null;
const visibleTreeNodes = useMemo(() => filtering ? filterTopicTree(treeNodes, new Set(matchingTopics.map((topic) => topic.id))) : treeNodes, [filtering, treeNodes, matchingTopics]);
const topicById = useMemo(() => topicIndex(context), [context]);
const visibleRoutes = context?.layers.configured.routes ?? [];
const availableRoutes = context?.layers.available.routes ?? [];
@@ -122,7 +137,7 @@ export default function DocsPage({ settings }: { settings: ApiSettings }) {
useEffect(() => {
if (!selectedPage) return;
const ancestorIds = ancestorNodeIdsForPage(treeNodes, selectedPage.id);
const ancestorIds = ancestorOccurrenceIds(treeNodes, selectedNode?.id ?? "");
if (!ancestorIds.length) return;
setExpandedNodes((current) => {
const next = new Set(current);
@@ -131,7 +146,7 @@ export default function DocsPage({ settings }: { settings: ApiSettings }) {
}
return next;
});
}, [treeNodes, selectedPage?.id]);
}, [treeNodes, selectedNode?.id]);
return (
<WorkspaceLayout
@@ -165,14 +180,24 @@ export default function DocsPage({ settings }: { settings: ApiSettings }) {
))}
</select>
</label>
<FilterBar layout="stack">
<FormField label="i18n:govoplan-docs.search_topics">
<input type="search" value={searchQuery} onChange={(event) => setSearchQuery(event.target.value)} placeholder={translateText("i18n:govoplan-docs.search_topics_hint")} />
</FormField>
<MultiSelectFilter label="i18n:govoplan-docs.topic_tags" options={tagOptions} value={selectedTags} onChange={setSelectedTags} />
{filtering && <>
<span role="status" className="muted">{translateText("i18n:govoplan-docs.matching_topics").replace("{count}", String(matchingTopics.length))}</span>
<Button onClick={() => { setSearchQuery(""); setSelectedTags(null); }}>{translateText("i18n:govoplan-docs.clear_topic_filters")}</Button>
</>}
</FilterBar>
</div>
<nav className="docs-tree" aria-label="i18n:govoplan-docs.documentation_outline.6f836b99">
<ExplorerTree
nodes={treeNodes}
nodes={visibleTreeNodes}
getNodeId={(node) => node.id}
getNodeLabel={(node) => node.title}
getNodeChildren={(node) => node.children}
activeId={activeNodeIdForPage(treeNodes, selectedPage?.id ?? "")}
activeId={selectedNode?.id ?? ""}
expandedIds={expandedNodes}
depth={0}
childrenBaseClassName="docs-tree-children"
@@ -181,11 +206,11 @@ export default function DocsPage({ settings }: { settings: ApiSettings }) {
toggleBaseClassName="docs-tree-toggle"
nodeButtonBaseClassName="docs-tree-page"
getNodeWrapStyle={(_node, context) => ({ paddingLeft: `${context.depth * 14}px` })}
getNodeButtonClassName={(node) => selectedPage?.id === node.page.id ? "is-active" : ""}
getNodeButtonClassName={(node) => selectedNode?.id === node.id ? "is-active" : ""}
renderToggleIcon={(_node, context) => context.hasChildren ? context.expanded ? <ChevronDown size={15} /> : <ChevronRight size={15} /> : <span className="docs-tree-toggle-placeholder" />}
renderNodeContent={(node) => node.title}
onToggle={(node) => toggleNode(node.id)}
onOpen={(node) => selectPage(node.page)}
onOpen={(node) => { selectPage(node.page, node.id); setSearchQuery(""); setSelectedTags(null); }}
/>
</nav>
</aside>
@@ -205,7 +230,16 @@ export default function DocsPage({ settings }: { settings: ApiSettings }) {
>
<div className="docs-content">
<main className="docs-page-main">
<SelectedPageContent
{filtering && <section aria-label={translateText("i18n:govoplan-docs.search_results")}>
<h2>{translateText("i18n:govoplan-docs.search_results")}</h2>
{matchingTopics.length ? <SelectionList label="i18n:govoplan-docs.search_results">{matchingTopics.map((topic) => <SelectionListItem
key={topic.id} aria-label={topic.title} selected={selectedPage?.id === topic.id}
onClick={() => { selectPage(topicPage(topic)); setSearchQuery(""); setSelectedTags(null); }}
>
<SelectionListItemContent title={topic.title} description={[topic.summary, documentationTags(topic, context).map((tag) => tag.label).join(" · ")].filter(Boolean).join(" — ")} />
</SelectionListItem>)}</SelectionList> : <p className="muted">{translateText("i18n:govoplan-docs.no_matching_topics")}</p>}
</section>}
{!filtering && <SelectedPageContent
page={selectedPage}
adminDocs={adminDocs}
documentationType={documentationType}
@@ -218,9 +252,9 @@ export default function DocsPage({ settings }: { settings: ApiSettings }) {
evidenceSources={context?.layers.evidence.sources ?? []}
settings={settings}
locale={locale}
/>
/>}
</main>
<PageOutline items={outlineItems} />
{!filtering && <PageOutline items={outlineItems} />}
</div>
</PageLayout>
</WorkspaceLayout>
@@ -239,9 +273,11 @@ export default function DocsPage({ settings }: { settings: ApiSettings }) {
navigate(`${location.pathname}?${params.toString()}`, { replace: true });
}
function selectPage(page: DocsPageNode) {
function selectPage(page: DocsPageNode, occurrenceId?: string) {
const params = new URLSearchParams(location.search);
params.set("topic", page.id);
if (occurrenceId) params.set("occurrence", occurrenceId);
else params.delete("occurrence");
navigate(`${location.pathname}?${params.toString()}`, { replace: true });
}
@@ -629,8 +665,6 @@ function docsTreeNodes(context: DocsContext | null, adminDocs: boolean): DocsTre
const workflowTopics = context?.topic_groups.workflow ?? [];
const referenceTopics = context?.topic_groups.reference ?? [];
const patternTopics = context?.topic_groups.pattern ?? [];
const systemTopicIds = new Set(systemTopics.map((topic) => topic.id));
const moduleTopics = allTopics.filter((topic) => !systemTopicIds.has(topic.id));
const workingTopics = uniqueTopics([...patternTopics, ...workflowTopics]);
const troubleshootingTopics = allTopics.filter((topic) => !topic.active || topic.layer === "evidence");
const basicsNode = categoryTreeNode(
@@ -672,15 +706,15 @@ function docsTreeNodes(context: DocsContext | null, adminDocs: boolean): DocsTre
),
{
id: "tree:modules",
title: "i18n:govoplan-docs.modules.04e9462c",
title: "i18n:govoplan-docs.topic_areas",
page: {
id: "modules:overview",
title: "i18n:govoplan-docs.modules.04e9462c",
title: "i18n:govoplan-docs.topic_areas",
kind: "topic-list",
topics: moduleTopics,
topics: allTopics,
emptyText: "i18n:govoplan-docs.no_module_topics_found.0cbbdc9b"
},
children: moduleTreeNodes(moduleTopics, context)
children: moduleTreeNodes(allTopics, context)
}
];
if (adminDocs) {
@@ -695,7 +729,7 @@ function docsTreeNodes(context: DocsContext | null, adminDocs: boolean): DocsTre
emptyText: "i18n:govoplan-docs.no_administration_topics_found.6213dff2"
},
children: [
...referenceTopics.map(topicTreeNode),
...referenceTopics.map((topic) => topicTreeNode(topic)),
{
id: "tree:administration:technical-reference",
title: "i18n:govoplan-docs.technical_reference.f271430d",
@@ -717,7 +751,7 @@ function docsTreeNodes(context: DocsContext | null, adminDocs: boolean): DocsTre
"i18n:govoplan-docs.no_troubleshooting_topics_found.8c275468"
));
}
return nodes;
return qualifyTreeOccurrences(nodes);
}
function categoryTreeNode(id: string, title: string, topics: DocsDocumentationTopic[], emptyText: string): DocsTreeNode {
@@ -757,9 +791,11 @@ function topicTreeNode(topic: DocsDocumentationTopic): DocsTreeNode {
function moduleTreeNodes(topics: DocsDocumentationTopic[], context: DocsContext | null): DocsTreeNode[] {
const topicsByModule = new Map<string, DocsDocumentationTopic[]>();
for (const topic of topics) {
const moduleTopics = topicsByModule.get(topic.source_module_id) ?? [];
moduleTopics.push(topic);
topicsByModule.set(topic.source_module_id, moduleTopics);
for (const areaId of topicAreaIds(topic, context)) {
const moduleTopics = topicsByModule.get(areaId) ?? [];
moduleTopics.push(topic);
topicsByModule.set(areaId, moduleTopics);
}
}
const configuredModules = context?.layers.configured.modules ?? [];
const moduleOrder = new Map(configuredModules.map((module, index) => [module.id, index]));
@@ -823,23 +859,12 @@ function defaultExpandedNodeIds(nodes: DocsTreeNode[]): string[] {
return nodes.filter((node) => node.children.length).map((node) => node.id);
}
function ancestorNodeIdsForPage(nodes: DocsTreeNode[], pageId: string, ancestors: string[] = []): string[] {
for (const node of nodes) {
if (node.page.id === pageId) return ancestors;
const match = ancestorNodeIdsForPage(node.children, pageId, [...ancestors, node.id]);
if (match.length) return match;
}
return [];
}
function activeNodeIdForPage(nodes: DocsTreeNode[], pageId: string): string {
if (!pageId) return "";
for (const node of nodes) {
if (node.page.id === pageId) return node.id;
const childId = activeNodeIdForPage(node.children, pageId);
if (childId) return childId;
}
return "";
function filterTopicTree(nodes: DocsTreeNode[], matchingIds: Set<string>): DocsTreeNode[] {
return nodes.flatMap((node) => {
const children = filterTopicTree(node.children, matchingIds);
const matches = node.page.kind === "topic" && matchingIds.has(node.page.topic.id);
return matches || children.length ? [{ ...node, children }] : [];
});
}
function allDocumentationTopics(context: DocsContext | null): DocsDocumentationTopic[] {
@@ -1140,7 +1165,7 @@ function compactSourceRecord(value: object): Array<[string, unknown]> {
}
function humanizeSourceKey(value: string): string {
const words = value.replaceAll("_", " ");
const words = value.replace(/_/g, " ");
return words.charAt(0).toUpperCase() + words.slice(1);
}
+73
View File
@@ -0,0 +1,73 @@
import type { DocsContext, DocsDocumentationTopic } from "../../api/docs";
export type DocumentationTag = { value: string; label: string };
export function topicAreaIds(topic: DocsDocumentationTopic, context: DocsContext | null): string[] {
const visibleModules = new Set(context?.layers.configured.modules.map((module) => module.id) ?? []);
return [...new Set(topic.area_module_ids ?? [
topic.source_module_id,
...topic.related_modules.filter((id) => visibleModules.has(id))
])];
}
export function documentationTags(topic: DocsDocumentationTopic, context: DocsContext | null): DocumentationTag[] {
const modules = new Map(context?.layers.configured.modules.map((module) => [module.id, module.name]) ?? []);
const areas = topicAreaIds(topic, context).map((id) => ({ value: `area:${id}`, label: modules.get(id) ?? humanizeArea(id) }));
const tags = Array.isArray(topic.metadata.tags) ? topic.metadata.tags : [];
return [...areas, ...tags.filter((tag): tag is string => typeof tag === "string" && Boolean(tag.trim()))
.map((tag) => ({ value: `tag:${normalizeSearch(tag)}`, label: tag.trim() }))]
.filter((tag, index, all) => all.findIndex((other) => other.value === tag.value) === index);
}
export function documentationTagOptions(topics: DocsDocumentationTopic[], context: DocsContext | null): DocumentationTag[] {
const tags = new Map<string, DocumentationTag>();
for (const topic of topics) for (const tag of documentationTags(topic, context)) tags.set(tag.value, tag);
return [...tags.values()].sort((left, right) => left.label.localeCompare(right.label));
}
export function matchesDocumentationTopic(
topic: DocsDocumentationTopic, context: DocsContext | null, query: string, selectedTags: string[] | null
): boolean {
const tags = documentationTags(topic, context);
if (selectedTags !== null && !tags.some((tag) => selectedTags.includes(tag.value))) return false;
const content = normalizeSearch([topic.title, topic.summary, topic.body, ...tags.flatMap((tag) => [tag.label, tag.value])].join(" "));
return normalizeSearch(query).split(/\s+/).filter(Boolean).every((word) => content.includes(word));
}
function normalizeSearch(value: string): string {
return value.normalize("NFKD").replace(/[\u0300-\u036f]/g, "").toLocaleLowerCase();
}
function humanizeArea(value: string): string {
return value.replace(/^govoplan[-_]/, "").split(/[-_]+/).filter(Boolean)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(" ");
}
type TreeNode<T> = { id: string; page: { id: string }; children: T[] };
/** Semantic topic IDs remain stable; navigation IDs identify one occurrence only. */
export function qualifyTreeOccurrences<T extends TreeNode<T>>(nodes: T[], parentId = ""): T[] {
return nodes.map((node) => {
const id = `${parentId}/${encodeURIComponent(node.id)}`;
return { ...node, id, children: qualifyTreeOccurrences(node.children, id) };
});
}
export function selectedTreeOccurrence<T extends TreeNode<T>>(nodes: T[], pageId: string, occurrenceId: string | null): T | null {
const all = flattenTreeOccurrences(nodes);
return all.find((node) => node.id === occurrenceId && node.page.id === pageId)
?? all.find((node) => node.page.id === pageId) ?? null;
}
export function flattenTreeOccurrences<T extends TreeNode<T>>(nodes: T[]): T[] {
return nodes.flatMap((node) => [node, ...flattenTreeOccurrences(node.children)]);
}
export function ancestorOccurrenceIds<T extends TreeNode<T>>(nodes: T[], occurrenceId: string, ancestors: string[] = []): string[] {
for (const node of nodes) {
if (node.id === occurrenceId) return ancestors;
const found = ancestorOccurrenceIds(node.children, occurrenceId, [...ancestors, node.id]);
if (found.length) return found;
}
return [];
}
+16
View File
@@ -2,6 +2,14 @@ import type { PlatformTranslations } from "@govoplan/core-webui";
export const generatedTranslations: PlatformTranslations = {
"en": {
"i18n:govoplan-docs.search_topics": "Search help topics",
"i18n:govoplan-docs.search_topics_hint": "Question, keyword or tag…",
"i18n:govoplan-docs.topic_tags": "Areas and tags",
"i18n:govoplan-docs.topic_areas": "Topics by area",
"i18n:govoplan-docs.matching_topics": "{count} matching topics",
"i18n:govoplan-docs.clear_topic_filters": "Clear filters",
"i18n:govoplan-docs.search_results": "Matching help topics",
"i18n:govoplan-docs.no_matching_topics": "No topics match. Try another keyword or clear the tag filter.",
"i18n:govoplan-docs.additional_permissions.8042cb01": "additional permissions",
"i18n:govoplan-docs.about_govoplan.6b2d7127": "About GovOPlaN",
"i18n:govoplan-docs.admin_docs.bf504a56": "Admin docs",
@@ -104,6 +112,14 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-docs.your_documentation.8a4cd9a3": "Your documentation"
},
"de": {
"i18n:govoplan-docs.search_topics": "Hilfethemen suchen",
"i18n:govoplan-docs.search_topics_hint": "Frage, Stichwort oder Schlagwort…",
"i18n:govoplan-docs.topic_tags": "Bereiche und Schlagwörter",
"i18n:govoplan-docs.topic_areas": "Themen nach Bereich",
"i18n:govoplan-docs.matching_topics": "{count} passende Themen",
"i18n:govoplan-docs.clear_topic_filters": "Filter zurücksetzen",
"i18n:govoplan-docs.search_results": "Passende Hilfethemen",
"i18n:govoplan-docs.no_matching_topics": "Keine passenden Themen. Versuchen Sie ein anderes Stichwort oder setzen Sie den Schlagwortfilter zurück.",
"i18n:govoplan-docs.additional_permissions.8042cb01": "additional permissions",
"i18n:govoplan-docs.about_govoplan.6b2d7127": "Über GovOPlaN",
"i18n:govoplan-docs.admin_docs.bf504a56": "Administrationsdokumentation",