feat: complete public wiki and embedded previews
This commit is contained in:
1 parent
bc49df4561
commit
30943159bf
18 files changed
+926
-48
No files matched your search
@@ -5,6 +5,38 @@ export type WikiPageUrlBuilder = (
|
||||
wikiSlug: string,
|
||||
) => string;
|
||||
|
||||
export type WikiPageLinkTarget = {
|
||||
slug: string;
|
||||
title: string;
|
||||
aliases?: string[];
|
||||
page_type?: string;
|
||||
summary?: string;
|
||||
category_path?: string[];
|
||||
};
|
||||
|
||||
export type WikiIndexGroup = {
|
||||
type: string;
|
||||
total: number;
|
||||
items: WikiPageLinkTarget[];
|
||||
};
|
||||
|
||||
type WikiIndexCategoryNode = {
|
||||
label: string;
|
||||
pages: WikiPageLinkTarget[];
|
||||
children: Map<string, WikiIndexCategoryNode>;
|
||||
};
|
||||
|
||||
const WIKI_INDEX_TYPE_LABELS: Record<string, string> = {
|
||||
summary: "摘要",
|
||||
entity: "实体",
|
||||
concept: "概念",
|
||||
overview: "概览",
|
||||
directory: "目录",
|
||||
article: "文章",
|
||||
};
|
||||
|
||||
const WIKI_INDEX_TYPE_ORDER = ["summary", "entity", "concept"];
|
||||
|
||||
export function stripWikiFrontmatter(value: string) {
|
||||
return value.replace(/^\s*---\s*\n[\s\S]*?\n---\s*(?:\n|$)/, "").trim();
|
||||
}
|
||||
@@ -38,18 +70,192 @@ export function wikiLinkMarkdown(
|
||||
.replace(/\[\[([^\]]+)\]\]/g, (_match, slug) => link(slug));
|
||||
}
|
||||
|
||||
function normalizeWikiLinkLabel(value: string): string {
|
||||
return value
|
||||
.replace(/\\([\\`*_[\]{}()#+\-.!])/g, "$1")
|
||||
.replace(/[*_`~]/g, "")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
.toLocaleLowerCase("zh-CN");
|
||||
}
|
||||
|
||||
function isLegacyWikiPlaceholderUrl(value: string): boolean {
|
||||
return /\/platform\/knowledge-bases\/[^/?#)]+/i.test(value);
|
||||
}
|
||||
|
||||
function wikiIndexTypeRank(pageType: string): number {
|
||||
const index = WIKI_INDEX_TYPE_ORDER.indexOf(pageType);
|
||||
return index >= 0 ? index : WIKI_INDEX_TYPE_ORDER.length;
|
||||
}
|
||||
|
||||
function wikiIndexPageLine(
|
||||
page: WikiPageLinkTarget,
|
||||
knowledgeBaseId: string,
|
||||
buildPageUrl: WikiPageUrlBuilder,
|
||||
): string {
|
||||
const label = (page.title || page.slug).replace(/\]/g, "\\]");
|
||||
const summary = (page.summary || "").replace(/\s+/g, " ").trim();
|
||||
return `[${label}](${buildPageUrl(knowledgeBaseId, page.slug)})${summary ? ` — ${summary}` : ""}`;
|
||||
}
|
||||
|
||||
function renderWikiIndexCategory(
|
||||
node: WikiIndexCategoryNode,
|
||||
knowledgeBaseId: string,
|
||||
buildPageUrl: WikiPageUrlBuilder,
|
||||
): string[] {
|
||||
const lines = node.label ? [`**${node.label}**`] : [];
|
||||
lines.push(...node.pages.map((page) => wikiIndexPageLine(page, knowledgeBaseId, buildPageUrl)));
|
||||
const children = Array.from(node.children.values()).sort((left, right) =>
|
||||
left.label.localeCompare(right.label, "zh-Hans-CN"),
|
||||
);
|
||||
for (const child of children) {
|
||||
lines.push(...renderWikiIndexCategory(child, knowledgeBaseId, buildPageUrl));
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
/** Build the category catalogue that WeKnora renders dynamically for its index page. */
|
||||
export function buildWikiIndexCatalogMarkdown(
|
||||
pages: WikiPageLinkTarget[],
|
||||
knowledgeBaseId: string,
|
||||
buildPageUrl: WikiPageUrlBuilder = buildWeKnoraWikiPageUrl,
|
||||
indexGroups: WikiIndexGroup[] = [],
|
||||
): string {
|
||||
if (indexGroups.length > 0) {
|
||||
const sections: string[] = [];
|
||||
for (const group of indexGroups) {
|
||||
if (group.items.length === 0) continue;
|
||||
const seenCategories = new Set<string>();
|
||||
const lines: string[] = [];
|
||||
for (const page of group.items) {
|
||||
const categories = (page.category_path || []).map((item) => item.trim()).filter(Boolean);
|
||||
for (let depth = 0; depth < categories.length; depth += 1) {
|
||||
const path = categories.slice(0, depth + 1).join("/");
|
||||
if (seenCategories.has(path)) continue;
|
||||
seenCategories.add(path);
|
||||
lines.push(`${" ".repeat(depth)}**${categories[depth]}**`);
|
||||
}
|
||||
lines.push(
|
||||
`${" ".repeat(categories.length)}${wikiIndexPageLine(page, knowledgeBaseId, buildPageUrl)}`,
|
||||
);
|
||||
}
|
||||
const label = WIKI_INDEX_TYPE_LABELS[group.type] || group.type;
|
||||
sections.push(`## ${label} (${group.total})`, lines.join("\n"));
|
||||
}
|
||||
return sections.join("\n\n");
|
||||
}
|
||||
|
||||
const groups = new Map<string, WikiPageLinkTarget[]>();
|
||||
for (const page of pages) {
|
||||
const pageType = (page.page_type || "article").trim().toLowerCase();
|
||||
const slugName = page.slug.split("/").filter(Boolean).at(-1)?.toLowerCase();
|
||||
if (pageType === "index" || ["index", "home", "readme"].includes(slugName || "")) continue;
|
||||
const group = groups.get(pageType) || [];
|
||||
group.push(page);
|
||||
groups.set(pageType, group);
|
||||
}
|
||||
|
||||
const pageTypes = Array.from(groups.keys()).sort((left, right) => {
|
||||
const rankDifference = wikiIndexTypeRank(left) - wikiIndexTypeRank(right);
|
||||
if (rankDifference !== 0) return rankDifference;
|
||||
return (WIKI_INDEX_TYPE_LABELS[left] || left).localeCompare(
|
||||
WIKI_INDEX_TYPE_LABELS[right] || right,
|
||||
"zh-Hans-CN",
|
||||
);
|
||||
});
|
||||
|
||||
const sections: string[] = [];
|
||||
for (const pageType of pageTypes) {
|
||||
const groupPages = groups.get(pageType) || [];
|
||||
const root: WikiIndexCategoryNode = { label: "", pages: [], children: new Map() };
|
||||
for (const page of groupPages) {
|
||||
let node = root;
|
||||
for (const category of page.category_path || []) {
|
||||
const label = category.trim();
|
||||
if (!label) continue;
|
||||
let child = node.children.get(label);
|
||||
if (!child) {
|
||||
child = { label, pages: [], children: new Map() };
|
||||
node.children.set(label, child);
|
||||
}
|
||||
node = child;
|
||||
}
|
||||
node.pages.push(page);
|
||||
}
|
||||
const label = WIKI_INDEX_TYPE_LABELS[pageType] || pageType;
|
||||
sections.push(
|
||||
`## ${label} (${groupPages.length})`,
|
||||
...renderWikiIndexCategory(root, knowledgeBaseId, buildPageUrl),
|
||||
);
|
||||
}
|
||||
return sections.join("\n\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* WeKnora-generated index pages may contain Markdown links whose href only
|
||||
* points at the knowledge-base Wiki shell. Resolve those placeholders by the
|
||||
* visible link label so the native/public reader can open the exact page.
|
||||
*/
|
||||
export function resolveWikiIndexPageLinks(
|
||||
markdown: string,
|
||||
knowledgeBaseId: string,
|
||||
pages: WikiPageLinkTarget[],
|
||||
buildPageUrl: WikiPageUrlBuilder = buildWeKnoraWikiPageUrl,
|
||||
): string {
|
||||
const pagesByLabel = new Map<string, WikiPageLinkTarget>();
|
||||
for (const page of pages) {
|
||||
const labels = [page.title, page.slug, page.slug.split("/").filter(Boolean).at(-1), ...(page.aliases || [])];
|
||||
for (const label of labels) {
|
||||
const normalized = normalizeWikiLinkLabel(label || "");
|
||||
if (normalized && !pagesByLabel.has(normalized)) pagesByLabel.set(normalized, page);
|
||||
}
|
||||
}
|
||||
|
||||
return markdown.replace(
|
||||
/(?<!!)\[([^\]\n]+)\]\(([^)\n]+)\)/g,
|
||||
(original, rawLabel: string, rawUrl: string) => {
|
||||
if (!isLegacyWikiPlaceholderUrl(rawUrl)) return original;
|
||||
const normalizedLabel = normalizeWikiLinkLabel(rawLabel);
|
||||
const withoutSummarySuffix = normalizedLabel.replace(/\s+-\s+summary$/i, "");
|
||||
const target = pagesByLabel.get(normalizedLabel) || pagesByLabel.get(withoutSummarySuffix);
|
||||
if (!target) return original;
|
||||
return `[${rawLabel}](${buildPageUrl(knowledgeBaseId, target.slug)})`;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function renderableWikiMarkdown(
|
||||
title: string,
|
||||
content: string,
|
||||
knowledgeBaseId: string,
|
||||
buildPageUrl?: WikiPageUrlBuilder,
|
||||
pages: WikiPageLinkTarget[] = [],
|
||||
indexGroups: WikiIndexGroup[] = [],
|
||||
) {
|
||||
const lines = content.trim().split("\n");
|
||||
if (lines[0]?.replace(/^#+\s*/, "").trim() === title.trim()) {
|
||||
lines[0] = `# ${title}`;
|
||||
}
|
||||
const baseMarkdown = exportableWikiMarkdown(title, "", lines.join("\n"));
|
||||
const isIndex = pages.some((page) => {
|
||||
const slugName = page.slug.split("/").filter(Boolean).at(-1)?.toLowerCase();
|
||||
return page.title === title && (
|
||||
page.page_type?.toLowerCase() === "index" ||
|
||||
["index", "home", "readme"].includes(slugName || "")
|
||||
);
|
||||
});
|
||||
const existingMarkdownLinkCount = (baseMarkdown.match(/(?<!!)\[[^\]\n]+\]\([^)\n]+\)/g) || []).length;
|
||||
const catalogue = isIndex && existingMarkdownLinkCount === 0
|
||||
? buildWikiIndexCatalogMarkdown(pages, knowledgeBaseId, buildPageUrl, indexGroups)
|
||||
: "";
|
||||
const markdown = resolveWikiIndexPageLinks(
|
||||
[baseMarkdown, catalogue].filter(Boolean).join("\n\n"),
|
||||
knowledgeBaseId,
|
||||
pages,
|
||||
buildPageUrl,
|
||||
);
|
||||
return wikiLinkMarkdown(
|
||||
exportableWikiMarkdown(title, "", lines.join("\n")),
|
||||
markdown,
|
||||
knowledgeBaseId,
|
||||
buildPageUrl,
|
||||
);
|
||||
|
||||
@@ -13,6 +13,34 @@ export type WikiTree = {
|
||||
parentKeysBySlug: Map<string, string[]>;
|
||||
};
|
||||
|
||||
export type BuildWikiTreeOptions = {
|
||||
preferredRootPageSlug?: string;
|
||||
};
|
||||
|
||||
export function isWikiIndexPage(page: LlmWikiWikiPage): boolean {
|
||||
const pageType = (page.page_type || "").trim().toLowerCase();
|
||||
const title = (page.title || "").trim().toLowerCase();
|
||||
const slug = (page.slug || "").trim().replace(/^\/+|\/+$/g, "");
|
||||
const slugName = slug.split("/").pop()?.toLowerCase() || "";
|
||||
return (
|
||||
pageType === "index" ||
|
||||
title === "索引" ||
|
||||
title === "index" ||
|
||||
["index", "home", "readme"].includes(slugName)
|
||||
);
|
||||
}
|
||||
|
||||
export function selectDefaultWikiPage(
|
||||
pages: LlmWikiWikiPage[],
|
||||
): LlmWikiWikiPage | undefined {
|
||||
return (
|
||||
pages.find(isWikiIndexPage) ||
|
||||
pages.find((page) => page.page_type?.toLowerCase() === "overview") ||
|
||||
pages.find((page) => !page.parent_slug && (page.depth ?? 0) === 0) ||
|
||||
pages[0]
|
||||
);
|
||||
}
|
||||
|
||||
function pageDirectoryParts(page: LlmWikiWikiPage): string[] {
|
||||
const wikiPath = (page.wiki_path || "").split("/").map((item) => item.trim()).filter(Boolean);
|
||||
if (wikiPath.length > 2) return wikiPath.slice(1, -1);
|
||||
@@ -62,20 +90,49 @@ function attachNode(parent: WikiTreeNode | null, roots: WikiTreeNode[], node: Wi
|
||||
if (!siblings.some((item) => item.key === node.key)) siblings.push(node);
|
||||
}
|
||||
|
||||
function sortWikiNodes(nodes: WikiTreeNode[]) {
|
||||
function sortWikiNodes(
|
||||
nodes: WikiTreeNode[],
|
||||
preferredRootPageSlug?: string,
|
||||
isRoot = false,
|
||||
) {
|
||||
nodes.sort((left, right) => {
|
||||
if (isRoot && preferredRootPageSlug) {
|
||||
const leftPreferred = left.page?.slug === preferredRootPageSlug;
|
||||
const rightPreferred = right.page?.slug === preferredRootPageSlug;
|
||||
if (leftPreferred !== rightPreferred) return leftPreferred ? -1 : 1;
|
||||
}
|
||||
if (!!left.children.length !== !!right.children.length) return left.children.length ? -1 : 1;
|
||||
return left.label.localeCompare(right.label, "zh-Hans-CN");
|
||||
});
|
||||
nodes.forEach((node) => sortWikiNodes(node.children));
|
||||
}
|
||||
|
||||
function detachPageNode(nodes: WikiTreeNode[], slug: string): WikiTreeNode | null {
|
||||
const index = nodes.findIndex((node) => node.page?.slug === slug);
|
||||
if (index >= 0) return nodes.splice(index, 1)[0] || null;
|
||||
for (const node of nodes) {
|
||||
const detached = detachPageNode(node.children, slug);
|
||||
if (detached) return detached;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function pruneEmptyDirectories(nodes: WikiTreeNode[]): WikiTreeNode[] {
|
||||
return nodes.filter((node) => {
|
||||
node.children = pruneEmptyDirectories(node.children);
|
||||
return node.page !== null || node.children.length > 0;
|
||||
});
|
||||
}
|
||||
|
||||
function countPages(node: WikiTreeNode): number {
|
||||
node.count = (node.page ? 1 : 0) + node.children.reduce((sum, child) => sum + countPages(child), 0);
|
||||
return node.count;
|
||||
}
|
||||
|
||||
export function buildWikiTree(pages: LlmWikiWikiPage[]): WikiTree {
|
||||
export function buildWikiTree(
|
||||
pages: LlmWikiWikiPage[],
|
||||
options: BuildWikiTreeOptions = {},
|
||||
): WikiTree {
|
||||
const roots: WikiTreeNode[] = [];
|
||||
const pageNodes = new Map<string, WikiTreeNode>();
|
||||
const directoryNodes = new Map<string, WikiTreeNode>();
|
||||
@@ -140,7 +197,17 @@ export function buildWikiTree(pages: LlmWikiWikiPage[]): WikiTree {
|
||||
parentKeysBySlug.set(page.slug, ancestorKeys);
|
||||
}
|
||||
|
||||
const preferredSlug = options.preferredRootPageSlug?.trim();
|
||||
if (preferredSlug) {
|
||||
const preferredNode = detachPageNode(roots, preferredSlug);
|
||||
if (preferredNode) {
|
||||
roots.splice(0, roots.length, ...pruneEmptyDirectories(roots));
|
||||
roots.unshift(preferredNode);
|
||||
parentKeysBySlug.set(preferredSlug, []);
|
||||
}
|
||||
}
|
||||
|
||||
roots.forEach(countPages);
|
||||
sortWikiNodes(roots);
|
||||
sortWikiNodes(roots, preferredSlug, true);
|
||||
return { roots, parentKeysBySlug };
|
||||
}
|
||||
Reference in new issue
Block a user