Release govoplan-docs v0.1.23: unify help discovery and batch semantic reads
Module Package Release / publish-packages (push) Successful in 12s
Module Package Release / publish-packages (push) Successful in 12s
This commit is contained in:
+2
-2
@@ -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",
|
||||
|
||||
@@ -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<{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 [];
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user