feat(site): deepen docs IA with pathways and taxonomy

This commit is contained in:
Chummy 2026-02-26 07:15:49 +00:00 committed by Chum Yin
parent 9fbab15222
commit d9b3d6f3e5
4 changed files with 6072 additions and 27 deletions

View File

@ -64,6 +64,242 @@ function detectSection(relativePath) {
return parts[1] || "docs";
}
function detectJourney(relativePath) {
const rel = normalizePath(relativePath).toLowerCase();
if (!rel.startsWith("docs/")) {
return "start";
}
if (rel.includes("/i18n/") || rel.startsWith("docs/i18n-")) {
return "localize";
}
if (
rel.includes("/security/") ||
rel.includes("security-roadmap") ||
rel.includes("sandbox") ||
rel.includes("advisory") ||
rel.includes("vulnerability") ||
rel.includes("agnostic-security") ||
rel.includes("audit-logging")
) {
return "secure";
}
if (
rel.includes("/operations/") ||
rel.includes("/sop/") ||
rel.includes("runbook") ||
rel.includes("release-process") ||
rel.includes("ci-map") ||
rel.includes("stage-gates") ||
rel.includes("required-check")
) {
return "operate";
}
if (
rel.includes("/contributing/") ||
rel.includes("pr-workflow") ||
rel.includes("reviewer-playbook") ||
rel.includes("project-triage") ||
rel.includes("/project/") ||
rel.includes("doc-template")
) {
return "contribute";
}
if (
rel.includes("/datasheets/") ||
rel.includes("/hardware/") ||
rel.includes("arduino") ||
rel.includes("esp32") ||
rel.includes("nucleo") ||
rel.includes("hardware-peripherals")
) {
return "hardware";
}
if (rel.includes("troubleshooting")) {
return "troubleshoot";
}
if (
rel.includes("/reference/") ||
rel.includes("commands-reference") ||
rel.includes("config-reference") ||
rel.includes("providers-reference") ||
rel.includes("channels-reference") ||
rel.includes("resource-limits") ||
rel.includes("audit-event-schema")
) {
return "reference";
}
if (
rel.includes("langgraph") ||
rel.includes("custom-providers") ||
rel.includes("nextcloud-talk") ||
rel.includes("mattermost") ||
rel.includes("matrix-e2ee") ||
rel.includes("zai-glm") ||
rel.includes("qwen-provider") ||
rel.includes("proxy-agent")
) {
return "integrate";
}
if (
rel.includes("getting-started") ||
rel.includes("one-click-bootstrap") ||
rel.includes("docker-setup") ||
rel.includes("network-deployment") ||
rel === "docs/readme.md"
) {
return "start";
}
return "build";
}
function detectAudience(relativePath, journey) {
const rel = normalizePath(relativePath).toLowerCase();
if (journey === "start") return "newcomer";
if (journey === "operate") return "operator";
if (journey === "secure") return "security";
if (journey === "contribute" || journey === "localize") return "contributor";
if (journey === "integrate") return "integrator";
if (journey === "hardware") return "hardware";
if (rel.includes("troubleshooting")) {
return "operator";
}
return "builder";
}
function detectKind(relativePath) {
const rel = normalizePath(relativePath).toLowerCase();
if (
rel.includes("runbook") ||
rel.includes("playbook") ||
rel.includes("/sop/") ||
rel.includes("operations/")
) {
return "runbook";
}
if (
rel.includes("policy") ||
rel.includes("roadmap") ||
rel.includes("release-process") ||
rel.includes("required-check-mapping") ||
rel.includes("stage-gates")
) {
return "policy";
}
if (rel.includes("template") || rel.includes("checklist")) {
return "template";
}
if (
rel.includes("report") ||
rel.includes("snapshot") ||
rel.includes("inventory") ||
rel.includes("docs-audit")
) {
return "report";
}
if (
rel.includes("/reference/") ||
rel.includes("reference") ||
rel.includes("schema") ||
rel.includes("summary.md")
) {
return "reference";
}
return "guide";
}
function isStartHere(relativePath, journey) {
const rel = normalizePath(relativePath).toLowerCase();
if (journey === "start" && /readme\.md$/.test(rel)) {
return true;
}
return (
rel === "readme.md" ||
rel === "docs/getting-started/readme.md" ||
rel === "docs/one-click-bootstrap.md" ||
rel === "docs/docker-setup.md" ||
rel === "docs/network-deployment.md" ||
rel === "docs/commands-reference.md" ||
rel === "docs/config-reference.md" ||
rel === "docs/troubleshooting.md"
);
}
function wordCount(markdown) {
const plain = stripMarkdownSyntax(markdown)
.replace(/```[\s\S]*?```/g, " ")
.replace(/`[^`]+`/g, " ")
.replace(/\s+/g, " ")
.trim();
if (!plain) {
return 0;
}
return plain.split(" ").filter(Boolean).length;
}
function estimateReadingMinutes(markdown) {
const words = wordCount(markdown);
if (words <= 0) {
return 1;
}
return Math.max(1, Math.min(35, Math.ceil(words / 220)));
}
function inferTags(relativePath, title, summary, journey, audience, kind, section) {
const rel = normalizePath(relativePath).toLowerCase();
const bag = `${rel} ${title} ${summary}`.toLowerCase();
const tags = new Set([journey, audience, kind, section]);
const rules = [
{ pattern: /(getting-started|one-click-bootstrap|onboard|setup|readme)/, tag: "onboarding" },
{ pattern: /(docker|network|gateway|deploy|daemon)/, tag: "deployment" },
{ pattern: /(security|sandbox|advisory|vulnerability|audit)/, tag: "security" },
{ pattern: /(commands|config|providers|channels|reference|schema)/, tag: "reference" },
{ pattern: /(operations|runbook|sop|release|ci|workflow|gate)/, tag: "operations" },
{ pattern: /(langgraph|matrix|mattermost|nextcloud|qwen|glm|custom-provider)/, tag: "integrations" },
{ pattern: /(hardware|arduino|esp32|nucleo|datasheet)/, tag: "hardware" },
{ pattern: /(i18n|translation|locale)/, tag: "i18n" },
{ pattern: /(contributing|reviewer|pull request|pr-workflow|project)/, tag: "contributing" },
{ pattern: /(troubleshoot|diagnos|doctor|debug)/, tag: "troubleshooting" },
];
for (const rule of rules) {
if (rule.pattern.test(bag)) {
tags.add(rule.tag);
}
}
return [...tags]
.map((tag) => tag.trim())
.filter(Boolean)
.sort((a, b) => a.localeCompare(b))
.slice(0, 8);
}
function fallbackTitle(relativePath) {
const filename = path.basename(relativePath).replace(/\.(md|mdx)$/i, "");
@ -215,14 +451,27 @@ async function main() {
for (const filePath of markdownFiles) {
const relativePath = normalizePath(path.relative(repoRoot, filePath));
const content = await fs.readFile(filePath, "utf8");
const section = detectSection(relativePath);
const journey = detectJourney(relativePath);
const audience = detectAudience(relativePath, journey);
const kind = detectKind(relativePath);
const title = extractTitle(content, relativePath);
const summary = extractSummary(content);
const readingMinutes = estimateReadingMinutes(content);
manifestEntries.push({
id: toId(relativePath),
path: relativePath,
title: extractTitle(content, relativePath),
summary: extractSummary(content),
section: detectSection(relativePath),
title,
summary,
section,
language: detectLanguage(relativePath),
journey,
audience,
kind,
tags: inferTags(relativePath, title, summary, journey, audience, kind, section),
readingMinutes,
startHere: isStartHere(relativePath, journey),
sourceUrl: `https://github.com/zeroclaw-labs/zeroclaw/blob/main/${relativePath}`,
});
}

View File

@ -15,6 +15,27 @@ type ThemeMode = "system" | "dark" | "light";
type ResolvedTheme = "dark" | "light";
type ReaderScale = "compact" | "comfortable" | "relaxed";
type ReaderWidth = "normal" | "wide";
type Journey =
| "start"
| "build"
| "integrate"
| "operate"
| "secure"
| "contribute"
| "reference"
| "hardware"
| "localize"
| "troubleshoot";
type Audience =
| "newcomer"
| "builder"
| "operator"
| "security"
| "contributor"
| "integrator"
| "hardware";
type DocKind = "guide" | "reference" | "runbook" | "policy" | "template" | "report";
type GroupMode = "journey" | "section" | "kind" | "language";
type ManifestDoc = {
id: string;
@ -23,9 +44,26 @@ type ManifestDoc = {
summary: string;
section: string;
language: string;
journey: Journey;
audience: Audience;
kind: DocKind;
tags: string[];
readingMinutes: number;
startHere: boolean;
sourceUrl: string;
};
type ManifestDocRaw = Omit<
ManifestDoc,
"journey" | "audience" | "kind" | "tags" | "readingMinutes" | "startHere"
> &
Partial<
Pick<
ManifestDoc,
"journey" | "audience" | "kind" | "tags" | "readingMinutes" | "startHere"
>
>;
type HeadingItem = {
id: string;
level: number;
@ -41,10 +79,6 @@ type PaletteEntry = {
run: () => void;
};
const docs = [...(manifestRaw as ManifestDoc[])].sort((a, b) =>
a.path.localeCompare(b.path)
);
const repoBase = "https://github.com/zeroclaw-labs/zeroclaw/blob/main";
const rawBase = "https://raw.githubusercontent.com/zeroclaw-labs/zeroclaw/main";
@ -58,6 +92,173 @@ const languageNames: Record<string, Localized> = {
el: { en: "Greek", zh: "希腊文" },
};
const journeyOrder: Journey[] = [
"start",
"build",
"integrate",
"operate",
"secure",
"contribute",
"reference",
"hardware",
"localize",
"troubleshoot",
];
const audienceOrder: Audience[] = [
"newcomer",
"builder",
"integrator",
"operator",
"security",
"contributor",
"hardware",
];
const kindOrder: DocKind[] = ["guide", "reference", "runbook", "policy", "template", "report"];
const journeyNames: Record<Journey, Localized> = {
start: { en: "Start", zh: "起步" },
build: { en: "Build", zh: "构建" },
integrate: { en: "Integrate", zh: "集成" },
operate: { en: "Operate", zh: "运维" },
secure: { en: "Secure", zh: "安全" },
contribute: { en: "Contribute", zh: "贡献" },
reference: { en: "Reference", zh: "参考" },
hardware: { en: "Hardware", zh: "硬件" },
localize: { en: "Localization", zh: "多语言" },
troubleshoot: { en: "Troubleshoot", zh: "排障" },
};
const audienceNames: Record<Audience, Localized> = {
newcomer: { en: "Newcomer", zh: "新手" },
builder: { en: "Builder", zh: "开发者" },
integrator: { en: "Integrator", zh: "集成者" },
operator: { en: "Operator", zh: "运维" },
security: { en: "Security", zh: "安全" },
contributor: { en: "Contributor", zh: "贡献者" },
hardware: { en: "Hardware", zh: "硬件工程师" },
};
const kindNames: Record<DocKind, Localized> = {
guide: { en: "Guide", zh: "指南" },
reference: { en: "Reference", zh: "参考" },
runbook: { en: "Runbook", zh: "运行手册" },
policy: { en: "Policy", zh: "策略规范" },
template: { en: "Template", zh: "模板" },
report: { en: "Report", zh: "报告" },
};
const readingPaths: Array<{
id: string;
label: Localized;
detail: Localized;
filters: Partial<{
journey: Journey;
audience: Audience;
kind: DocKind;
section: string;
}>;
}> = [
{
id: "newcomer",
label: { en: "New to ZeroClaw", zh: "初次了解 ZeroClaw" },
detail: {
en: "Onboarding and first successful run in the shortest path.",
zh: "最短路径完成安装、配置和首个可运行实例。",
},
filters: { journey: "start", audience: "newcomer" },
},
{
id: "builder",
label: { en: "Build & Extend", zh: "开发与扩展" },
detail: {
en: "Commands, config, providers, channels, and architecture references.",
zh: "命令、配置、Provider、Channel 与架构参考。",
},
filters: { journey: "build", audience: "builder" },
},
{
id: "operate",
label: { en: "Operate in Production", zh: "生产运维" },
detail: {
en: "Runbooks, CI/CD gates, release flow, and observability checks.",
zh: "运行手册、CI/CD 门禁、发布流程与可观测性校验。",
},
filters: { journey: "operate", audience: "operator" },
},
{
id: "secure",
label: { en: "Security Hardening", zh: "安全强化" },
detail: {
en: "Sandboxing, advisories, and secure runtime baseline.",
zh: "沙箱、安全公告与安全运行时基线。",
},
filters: { journey: "secure", audience: "security" },
},
{
id: "integrate",
label: { en: "Integrations", zh: "外部集成" },
detail: {
en: "Connect ZeroClaw with chat platforms and external providers.",
zh: "将 ZeroClaw 接入聊天平台与外部模型能力。",
},
filters: { journey: "integrate", audience: "integrator" },
},
{
id: "contribute",
label: { en: "Contribute", zh: "贡献流程" },
detail: {
en: "Contributor workflow, reviews, and collaboration playbooks.",
zh: "贡献者流程、评审规范与协作手册。",
},
filters: { journey: "contribute", audience: "contributor" },
},
];
function asJourney(value: string | undefined): Journey {
const candidate = (value ?? "").toLowerCase() as Journey;
return journeyOrder.includes(candidate) ? candidate : "build";
}
function asAudience(value: string | undefined): Audience {
const candidate = (value ?? "").toLowerCase() as Audience;
return audienceOrder.includes(candidate) ? candidate : "builder";
}
function asKind(value: string | undefined): DocKind {
const candidate = (value ?? "").toLowerCase() as DocKind;
return kindOrder.includes(candidate) ? candidate : "guide";
}
function normalizeManifestDoc(doc: ManifestDocRaw): ManifestDoc {
const summary = doc.summary?.trim() || "Project documentation.";
const tags = Array.isArray(doc.tags)
? doc.tags
.map((tag) => tag.trim().toLowerCase())
.filter((tag) => tag.length > 0)
.slice(0, 8)
: [];
return {
...doc,
summary,
journey: asJourney(doc.journey),
audience: asAudience(doc.audience),
kind: asKind(doc.kind),
tags,
readingMinutes:
typeof doc.readingMinutes === "number" && Number.isFinite(doc.readingMinutes)
? Math.max(1, Math.round(doc.readingMinutes))
: 1,
startHere: Boolean(doc.startHere),
};
}
const docs = [...(manifestRaw as ManifestDocRaw[])]
.map((doc) => normalizeManifestDoc(doc))
.sort((a, b) => a.path.localeCompare(b.path));
const copy = {
en: {
navDocs: "Docs",
@ -81,11 +282,39 @@ const copy = {
docsIndexed: "Indexed",
docsFiltered: "Filtered",
docsActive: "Active",
readingPathsTitle: "Reading paths",
readingPathsLead:
"Choose a task-oriented route first, then drill down with taxonomy filters.",
startHereTitle: "Start here",
startHereLead:
"Core docs for first-time users who want the fastest reliable onboarding.",
noStartHere: "No starter docs matched the current language/filter context.",
startBadge: "Starter",
sectionFilter: "Section",
languageFilter: "Language",
journeyFilter: "Journey",
audienceFilter: "Audience",
kindFilter: "Doc type",
groupBy: "Group by",
allJourneys: "All journeys",
allAudiences: "All audiences",
allKinds: "All doc types",
groupJourney: "Journey",
groupSection: "Section",
groupKind: "Doc type",
groupLanguage: "Language",
resetFilters: "Reset filters",
search: "Search docs by title, path, summary, or keyword",
commandPalette: "Command palette",
sourceLabel: "Source",
docJourney: "Journey",
docAudience: "Audience",
docKind: "Type",
docReadTime: "Read time",
docTags: "Tags",
minuteUnit: "min",
relatedDocs: "Related docs",
noRelated: "No strongly related docs found yet.",
openOnGithub: "Open on GitHub",
openRaw: "Open raw",
loading: "Loading document...",
@ -134,11 +363,37 @@ const copy = {
docsIndexed: "总文档",
docsFiltered: "筛选后",
docsActive: "当前文档",
readingPathsTitle: "阅读路径",
readingPathsLead: "先按任务路径进入,再用分类筛选做深入浏览。",
startHereTitle: "新手起步",
startHereLead: "面向首次接触 ZeroClaw 的核心文档,快速完成有效上手。",
noStartHere: "当前语言/筛选条件下暂无起步文档。",
startBadge: "起步",
sectionFilter: "分组",
languageFilter: "语言",
journeyFilter: "阶段路径",
audienceFilter: "适用角色",
kindFilter: "文档类型",
groupBy: "分组方式",
allJourneys: "全部路径",
allAudiences: "全部角色",
allKinds: "全部类型",
groupJourney: "按路径",
groupSection: "按目录",
groupKind: "按类型",
groupLanguage: "按语言",
resetFilters: "重置筛选",
search: "按标题、路径、摘要或关键字搜索",
commandPalette: "命令面板",
sourceLabel: "来源",
docJourney: "阶段",
docAudience: "角色",
docKind: "类型",
docReadTime: "阅读时长",
docTags: "标签",
minuteUnit: "分钟",
relatedDocs: "相关推荐",
noRelated: "暂未找到高相关文档。",
openOnGithub: "在 GitHub 打开",
openRaw: "打开原文",
loading: "文档加载中...",
@ -340,6 +595,48 @@ function formatLanguage(language: string, locale: Locale): string {
return language;
}
function formatJourney(journey: Journey, locale: Locale): string {
return journeyNames[journey][locale];
}
function formatAudience(audience: Audience, locale: Locale): string {
return audienceNames[audience][locale];
}
function formatKind(kind: DocKind, locale: Locale): string {
return kindNames[kind][locale];
}
function groupLabel(groupBy: GroupMode, key: string, locale: Locale) {
if (groupBy === "journey") {
return formatJourney(asJourney(key), locale);
}
if (groupBy === "kind") {
return formatKind(asKind(key), locale);
}
if (groupBy === "language") {
return formatLanguage(key, locale);
}
return inferSectionLabel(key, locale);
}
function groupOrderIndex(groupBy: GroupMode, key: string): number {
if (groupBy === "journey") {
return journeyOrder.indexOf(asJourney(key));
}
if (groupBy === "kind") {
return kindOrder.indexOf(asKind(key));
}
if (groupBy === "language") {
return key === "en" ? -1 : 0;
}
if (groupBy === "section") {
if (key === "root") return -2;
if (key === "docs") return -1;
}
return 999;
}
function canonicalDocPath(candidate: string, docSet: Set<string>): string | null {
const normalized = normalizePath(candidate);
const attempts = new Set<string>([normalized]);
@ -424,6 +721,11 @@ export default function App(): JSX.Element {
const [query, setQuery] = useState("");
const [sectionFilter, setSectionFilter] = useState("all");
const [languageFilter, setLanguageFilter] = useState("all");
const [journeyFilter, setJourneyFilter] = useState<Journey | "all">("all");
const [audienceFilter, setAudienceFilter] = useState<Audience | "all">("all");
const [kindFilter, setKindFilter] = useState<DocKind | "all">("all");
const [groupBy, setGroupBy] = useState<GroupMode>("journey");
const [activePathway, setActivePathway] = useState<string | null>(null);
const [markdownCache, setMarkdownCache] = useState<Record<string, string>>({});
const [loading, setLoading] = useState(false);
@ -478,6 +780,21 @@ export default function App(): JSX.Element {
[]
);
const journeyOptions = useMemo(
() => ["all", ...journeyOrder.filter((journey) => docs.some((doc) => doc.journey === journey))],
[]
);
const audienceOptions = useMemo(
() => ["all", ...audienceOrder.filter((audience) => docs.some((doc) => doc.audience === audience))],
[]
);
const kindOptions = useMemo(
() => ["all", ...kindOrder.filter((kind) => docs.some((doc) => doc.kind === kind))],
[]
);
const filteredDocs = useMemo(() => {
const needle = query.trim().toLowerCase();
@ -490,6 +807,18 @@ export default function App(): JSX.Element {
return false;
}
if (journeyFilter !== "all" && doc.journey !== journeyFilter) {
return false;
}
if (audienceFilter !== "all" && doc.audience !== audienceFilter) {
return false;
}
if (kindFilter !== "all" && doc.kind !== kindFilter) {
return false;
}
if (!needle) {
return true;
}
@ -500,13 +829,60 @@ export default function App(): JSX.Element {
doc.path,
doc.section,
doc.language,
doc.journey,
doc.audience,
doc.kind,
doc.tags.join(" "),
]
.join(" ")
.toLowerCase();
return bag.includes(needle);
});
}, [languageFilter, query, sectionFilter]);
}, [audienceFilter, journeyFilter, kindFilter, languageFilter, query, sectionFilter]);
const pathwayStats = useMemo(
() =>
readingPaths.map((pathway) => {
const total = docs.filter((doc) => {
if (pathway.filters.journey && doc.journey !== pathway.filters.journey) {
return false;
}
if (pathway.filters.audience && doc.audience !== pathway.filters.audience) {
return false;
}
if (pathway.filters.kind && doc.kind !== pathway.filters.kind) {
return false;
}
if (pathway.filters.section && doc.section !== pathway.filters.section) {
return false;
}
return true;
}).length;
return { ...pathway, total };
}),
[]
);
const starterDocs = useMemo(() => {
const preferredLanguages = locale === "zh" ? ["zh-CN", "en"] : ["en"];
return docs
.filter((doc) => doc.startHere)
.map((doc) => {
const languageRank = preferredLanguages.indexOf(doc.language);
return {
...doc,
_rank:
(languageRank === -1 ? 9 : languageRank) * 100 +
journeyOrder.indexOf(doc.journey) * 10 +
doc.readingMinutes,
};
})
.sort((a, b) => a._rank - b._rank)
.slice(0, 8);
}, [locale]);
const docsByPath = useMemo(() => new Map(docs.map((doc) => [doc.path, doc])), []);
@ -639,14 +1015,35 @@ export default function App(): JSX.Element {
const grouped = new Map<string, ManifestDoc[]>();
for (const doc of filteredDocs) {
if (!grouped.has(doc.section)) {
grouped.set(doc.section, []);
const key =
groupBy === "journey"
? doc.journey
: groupBy === "kind"
? doc.kind
: groupBy === "language"
? doc.language
: doc.section;
if (!grouped.has(key)) {
grouped.set(key, []);
}
grouped.get(doc.section)?.push(doc);
grouped.get(key)?.push(doc);
}
return [...grouped.entries()].sort((a, b) => a[0].localeCompare(b[0]));
}, [filteredDocs]);
return [...grouped.entries()]
.map(([key, entries]) => [
key,
[...entries].sort((a, b) => a.title.localeCompare(b.title)),
] as const)
.sort((a, b) => {
const aIndex = groupOrderIndex(groupBy, a[0]);
const bIndex = groupOrderIndex(groupBy, b[0]);
if (aIndex !== bIndex) {
return aIndex - bIndex;
}
return groupLabel(groupBy, a[0], locale).localeCompare(groupLabel(groupBy, b[0], locale));
});
}, [filteredDocs, groupBy, locale]);
const currentIndex = filteredDocs.findIndex((doc) => doc.path === activePath);
const previousDoc = currentIndex > 0 ? filteredDocs[currentIndex - 1] : null;
@ -655,6 +1052,43 @@ export default function App(): JSX.Element {
? filteredDocs[currentIndex + 1]
: null;
const relatedDocs = useMemo(() => {
if (!selectedDoc) {
return [];
}
const selectedTags = new Set(selectedDoc.tags);
return docs
.filter((doc) => doc.path !== selectedDoc.path)
.map((doc) => {
let score = 0;
if (doc.journey === selectedDoc.journey) score += 4;
if (doc.audience === selectedDoc.audience) score += 3;
if (doc.kind === selectedDoc.kind) score += 2;
if (doc.section === selectedDoc.section) score += 2;
if (doc.language === selectedDoc.language) score += 1;
const sharedTags = doc.tags.filter((tag) => selectedTags.has(tag)).length;
score += sharedTags * 2;
return { doc, score };
})
.filter((entry) => entry.score > 0)
.sort((a, b) => {
if (b.score !== a.score) {
return b.score - a.score;
}
if (a.doc.readingMinutes !== b.doc.readingMinutes) {
return a.doc.readingMinutes - b.doc.readingMinutes;
}
return a.doc.title.localeCompare(b.doc.title);
})
.slice(0, 8)
.map((entry) => entry.doc);
}, [selectedDoc]);
const openDoc = (docPath: string, anchor = ""): void => {
if (!docSet.has(docPath)) {
return;
@ -682,6 +1116,33 @@ export default function App(): JSX.Element {
docsSearchRef.current?.focus();
};
const applyPathway = (pathwayId: string): void => {
const pathway = readingPaths.find((entry) => entry.id === pathwayId);
if (!pathway) {
return;
}
setActivePathway(pathway.id);
setQuery("");
setSectionFilter(pathway.filters.section ?? "all");
setLanguageFilter("all");
setJourneyFilter(pathway.filters.journey ?? "all");
setAudienceFilter(pathway.filters.audience ?? "all");
setKindFilter(pathway.filters.kind ?? "all");
setGroupBy("journey");
};
const resetFilters = (): void => {
setActivePathway(null);
setQuery("");
setSectionFilter("all");
setLanguageFilter("all");
setJourneyFilter("all");
setAudienceFilter("all");
setKindFilter("all");
setGroupBy("journey");
};
const paletteActions = useMemo(
() => [
{
@ -728,7 +1189,17 @@ export default function App(): JSX.Element {
return true;
}
return [doc.title, doc.summary, doc.path, doc.section, doc.language]
return [
doc.title,
doc.summary,
doc.path,
doc.section,
doc.language,
doc.journey,
doc.audience,
doc.kind,
doc.tags.join(" "),
]
.join(" ")
.toLowerCase()
.includes(needle);
@ -737,7 +1208,7 @@ export default function App(): JSX.Element {
.map((doc) => ({
id: `doc-${doc.id}`,
label: doc.title,
hint: doc.path,
hint: `${formatJourney(doc.journey, locale)} · ${doc.path}`,
run: () => {
openDoc(doc.path);
document
@ -755,7 +1226,7 @@ export default function App(): JSX.Element {
);
return [...matchedActions, ...docEntries];
}, [docs, paletteActions, paletteQuery]);
}, [locale, paletteActions, paletteQuery]);
useEffect(() => {
function onKeyDown(event: KeyboardEvent): void {
@ -937,21 +1408,137 @@ export default function App(): JSX.Element {
<span>
{text.docsActive}: <strong>{selectedDoc.title}</strong>
</span>
{activePathway ? (
<span>
{text.readingPathsTitle}:{" "}
<strong>
{readingPaths.find((entry) => entry.id === activePathway)?.label[locale] ?? "-"}
</strong>
</span>
) : null}
</div>
<section className="pathway-shell" aria-label={text.readingPathsTitle}>
<header className="pathway-head">
<h3>{text.readingPathsTitle}</h3>
<p>{text.readingPathsLead}</p>
</header>
<div className="pathway-grid">
{pathwayStats.map((pathway) => (
<button
key={pathway.id}
type="button"
className={`pathway-card ${activePathway === pathway.id ? "active" : ""}`}
onClick={() => applyPathway(pathway.id)}
>
<span className="pathway-title">{pathway.label[locale]}</span>
<span className="pathway-detail">{pathway.detail[locale]}</span>
<span className="pathway-count">{pathway.total}</span>
</button>
))}
</div>
</section>
<section className="starter-shell" aria-label={text.startHereTitle}>
<header className="starter-head">
<h3>{text.startHereTitle}</h3>
<p>{text.startHereLead}</p>
</header>
<div className="starter-list">
{starterDocs.length === 0 ? (
<p className="empty-hint">{text.noStartHere}</p>
) : (
starterDocs.map((doc) => (
<button
key={doc.id}
type="button"
className={`starter-item ${doc.path === activePath ? "active" : ""}`}
onClick={() => openDoc(doc.path)}
>
<span className="starter-title">{doc.title}</span>
<span className="starter-meta">
{formatJourney(doc.journey, locale)} · {doc.readingMinutes}
{text.minuteUnit}
</span>
</button>
))
)}
</div>
</section>
<div className="docs-toolbar">
<input
ref={docsSearchRef}
type="search"
value={query}
onChange={(event) => setQuery(event.target.value)}
onChange={(event) => {
setActivePathway(null);
setQuery(event.target.value);
}}
placeholder={text.search}
aria-label={text.search}
/>
<select
value={journeyFilter}
onChange={(event) => {
setActivePathway(null);
setJourneyFilter(event.target.value as Journey | "all");
}}
aria-label={text.journeyFilter}
>
<option value="all">{text.allJourneys}</option>
{journeyOptions
.filter((journey) => journey !== "all")
.map((journey) => (
<option key={journey} value={journey}>
{formatJourney(journey as Journey, locale)}
</option>
))}
</select>
<select
value={audienceFilter}
onChange={(event) => {
setActivePathway(null);
setAudienceFilter(event.target.value as Audience | "all");
}}
aria-label={text.audienceFilter}
>
<option value="all">{text.allAudiences}</option>
{audienceOptions
.filter((audience) => audience !== "all")
.map((audience) => (
<option key={audience} value={audience}>
{formatAudience(audience as Audience, locale)}
</option>
))}
</select>
<select
value={kindFilter}
onChange={(event) => {
setActivePathway(null);
setKindFilter(event.target.value as DocKind | "all");
}}
aria-label={text.kindFilter}
>
<option value="all">{text.allKinds}</option>
{kindOptions
.filter((kind) => kind !== "all")
.map((kind) => (
<option key={kind} value={kind}>
{formatKind(kind as DocKind, locale)}
</option>
))}
</select>
<select
value={sectionFilter}
onChange={(event) => setSectionFilter(event.target.value)}
onChange={(event) => {
setActivePathway(null);
setSectionFilter(event.target.value);
}}
aria-label={text.sectionFilter}
>
<option value="all">{text.allSections}</option>
@ -966,7 +1553,10 @@ export default function App(): JSX.Element {
<select
value={languageFilter}
onChange={(event) => setLanguageFilter(event.target.value)}
onChange={(event) => {
setActivePathway(null);
setLanguageFilter(event.target.value);
}}
aria-label={text.languageFilter}
>
<option value="all">{text.allLanguages}</option>
@ -979,6 +1569,21 @@ export default function App(): JSX.Element {
))}
</select>
<select
value={groupBy}
onChange={(event) => setGroupBy(event.target.value as GroupMode)}
aria-label={text.groupBy}
>
<option value="journey">{text.groupJourney}</option>
<option value="section">{text.groupSection}</option>
<option value="kind">{text.groupKind}</option>
<option value="language">{text.groupLanguage}</option>
</select>
<button type="button" className="btn ghost" onClick={resetFilters}>
{text.resetFilters}
</button>
<button type="button" className="btn ghost" onClick={() => setPaletteOpen(true)}>
{text.commandPalette}
</button>
@ -989,9 +1594,9 @@ export default function App(): JSX.Element {
{filteredDocs.length === 0 ? (
<p className="empty-hint">{text.empty}</p>
) : (
groupedDocs.map(([section, sectionDocs]) => (
<section key={section} className="doc-group">
<h3>{inferSectionLabel(section, locale)}</h3>
groupedDocs.map(([groupKey, sectionDocs]) => (
<section key={groupKey} className="doc-group">
<h3>{groupLabel(groupBy, groupKey, locale)}</h3>
<div>
{sectionDocs.map((doc) => {
const isActive = doc.path === activePath;
@ -1003,10 +1608,20 @@ export default function App(): JSX.Element {
onClick={() => openDoc(doc.path)}
>
<span className="doc-meta">
{formatLanguage(doc.language, locale)}
<span>{formatLanguage(doc.language, locale)}</span>
<span>{formatJourney(doc.journey, locale)}</span>
<span>{formatKind(doc.kind, locale)}</span>
</span>
<span className="doc-title">{doc.title}</span>
<span className="doc-summary">{doc.summary}</span>
<span className="doc-chip-row">
<span className="doc-chip">{formatAudience(doc.audience, locale)}</span>
<span className="doc-chip">
{doc.readingMinutes}
{text.minuteUnit}
</span>
{doc.startHere ? <span className="doc-chip">{text.startBadge}</span> : null}
</span>
<span className="doc-path">{doc.path}</span>
</button>
);
@ -1021,8 +1636,38 @@ export default function App(): JSX.Element {
<header className="reader-head">
<div>
<p>{text.sourceLabel}</p>
<div className="doc-breadcrumb">
<span>{formatJourney(selectedDoc.journey, locale)}</span>
<span>/</span>
<span>{inferSectionLabel(selectedDoc.section, locale)}</span>
<span>/</span>
<span>{selectedDoc.title}</span>
</div>
<h3>{selectedDoc.title}</h3>
<code>{activePath}</code>
<div className="reader-meta-line">
<span>
{text.docJourney}: <strong>{formatJourney(selectedDoc.journey, locale)}</strong>
</span>
<span>
{text.docAudience}:{" "}
<strong>{formatAudience(selectedDoc.audience, locale)}</strong>
</span>
<span>
{text.docKind}: <strong>{formatKind(selectedDoc.kind, locale)}</strong>
</span>
<span>
{text.docReadTime}: <strong>{selectedDoc.readingMinutes}</strong>{" "}
{text.minuteUnit}
</span>
</div>
{selectedDoc.tags.length > 0 ? (
<div className="reader-tags" aria-label={text.docTags}>
{selectedDoc.tags.map((tag) => (
<span key={tag}>{tag}</span>
))}
</div>
) : null}
</div>
<div className="reader-actions">
<a href={withRepo(activePath)} target="_blank" rel="noreferrer">
@ -1204,6 +1849,25 @@ export default function App(): JSX.Element {
)}
</section>
<section className="side-card">
<h3>{text.relatedDocs}</h3>
{relatedDocs.length === 0 ? (
<p>{text.noRelated}</p>
) : (
<div className="related-list">
{relatedDocs.map((doc) => (
<button key={doc.id} type="button" onClick={() => openDoc(doc.path)}>
<span>{doc.title}</span>
<small>
{formatJourney(doc.journey, locale)} · {doc.readingMinutes}
{text.minuteUnit}
</small>
</button>
))}
</div>
)}
</section>
<section className="side-card">
<h3>{text.reading}</h3>

File diff suppressed because it is too large Load Diff

View File

@ -470,11 +470,152 @@ main {
font-weight: 600;
}
.pathway-shell {
margin-top: 0.72rem;
border: 1px solid var(--line);
border-radius: var(--radius-l);
background: color-mix(in srgb, var(--bg-soft) 80%, transparent);
padding: 0.68rem;
}
.pathway-head h3 {
margin: 0;
color: var(--text);
font-size: 0.95rem;
}
.pathway-head p {
margin: 0.3rem 0 0;
color: var(--text-muted);
font-size: 0.82rem;
}
.pathway-grid {
margin-top: 0.6rem;
display: grid;
gap: 0.48rem;
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.pathway-card {
border: 1px solid var(--line);
border-radius: 10px;
background: color-mix(in srgb, var(--bg-elevated) 88%, transparent);
color: var(--text-soft);
text-align: left;
display: grid;
gap: 0.24rem;
padding: 0.56rem 0.62rem;
transition: border-color 150ms ease, background-color 150ms ease;
}
.pathway-card:hover {
border-color: var(--line-strong);
background: color-mix(in srgb, var(--accent-soft) 54%, transparent);
}
.pathway-card.active {
border-color: var(--line-strong);
background: color-mix(in srgb, var(--accent-soft) 72%, transparent);
}
.pathway-title {
color: var(--text);
font-size: 0.86rem;
font-weight: 600;
}
.pathway-detail {
color: var(--text-muted);
font-size: 0.76rem;
line-height: 1.45;
}
.pathway-count {
justify-self: start;
border: 1px solid var(--line);
border-radius: 999px;
min-width: 28px;
min-height: 22px;
padding: 0 0.46rem;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 0.72rem;
color: var(--accent);
font-family: "Geist Mono", "IBM Plex Mono", monospace;
}
.starter-shell {
margin-top: 0.72rem;
border: 1px solid var(--line);
border-radius: var(--radius-l);
background: color-mix(in srgb, var(--bg-soft) 84%, transparent);
padding: 0.68rem;
}
.starter-head h3 {
margin: 0;
color: var(--text);
font-size: 0.95rem;
}
.starter-head p {
margin: 0.3rem 0 0;
color: var(--text-muted);
font-size: 0.82rem;
}
.starter-list {
margin-top: 0.55rem;
display: grid;
gap: 0.4rem;
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.starter-item {
border: 1px solid var(--line);
border-radius: 9px;
background: color-mix(in srgb, var(--bg-elevated) 90%, transparent);
color: var(--text-soft);
text-align: left;
display: grid;
gap: 0.22rem;
padding: 0.5rem 0.56rem;
transition: border-color 150ms ease, background-color 150ms ease;
}
.starter-item:hover {
border-color: var(--line-strong);
background: color-mix(in srgb, var(--accent-soft) 56%, transparent);
}
.starter-item.active {
border-color: var(--line-strong);
background: color-mix(in srgb, var(--accent-soft) 72%, transparent);
}
.starter-title {
color: var(--text);
font-size: 0.82rem;
font-weight: 600;
line-height: 1.35;
}
.starter-meta {
color: var(--text-muted);
font-size: 0.72rem;
}
.docs-toolbar {
margin-top: 0.72rem;
display: grid;
gap: 0.56rem;
grid-template-columns: minmax(220px, 1fr) minmax(130px, 180px) minmax(130px, 180px) auto;
grid-template-columns:
minmax(220px, 1.4fr)
repeat(6, minmax(128px, 0.75fr))
minmax(120px, auto)
minmax(120px, auto);
}
.docs-toolbar input,
@ -492,6 +633,10 @@ main {
color: var(--text-muted);
}
.docs-toolbar .btn {
min-height: 42px;
}
.workspace-grid {
margin-top: 0.76rem;
display: grid;
@ -555,13 +700,39 @@ main {
}
.doc-meta {
display: flex;
flex-wrap: wrap;
gap: 0.24rem;
color: var(--accent);
font-size: 0.68rem;
font-size: 0.66rem;
text-transform: uppercase;
letter-spacing: 0.08em;
font-family: "Geist Mono", "IBM Plex Mono", monospace;
}
.doc-meta span {
border: 1px solid var(--line);
border-radius: 999px;
padding: 0.08rem 0.32rem;
background: color-mix(in srgb, var(--bg-soft) 86%, transparent);
}
.doc-chip-row {
display: flex;
flex-wrap: wrap;
gap: 0.28rem;
}
.doc-chip {
border: 1px solid var(--line);
border-radius: 999px;
padding: 0.1rem 0.38rem;
color: var(--text-muted);
font-size: 0.67rem;
font-family: "Geist Mono", "IBM Plex Mono", monospace;
background: color-mix(in srgb, var(--bg-soft) 86%, transparent);
}
.doc-title {
color: var(--text);
font-size: 0.9rem;
@ -618,6 +789,17 @@ main {
font-size: 0.98rem;
}
.doc-breadcrumb {
margin-top: 0.26rem;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.28rem;
color: var(--text-muted);
font-size: 0.74rem;
font-family: "Geist Mono", "IBM Plex Mono", monospace;
}
.reader-head code {
color: var(--text-muted);
font-size: 0.72rem;
@ -625,6 +807,44 @@ main {
overflow-wrap: anywhere;
}
.reader-meta-line {
margin-top: 0.4rem;
display: flex;
flex-wrap: wrap;
gap: 0.34rem;
}
.reader-meta-line span {
border: 1px solid var(--line);
border-radius: 999px;
padding: 0.14rem 0.48rem;
color: var(--text-muted);
font-size: 0.7rem;
background: color-mix(in srgb, var(--bg-soft) 82%, transparent);
}
.reader-meta-line strong {
color: var(--text);
font-weight: 600;
}
.reader-tags {
margin-top: 0.4rem;
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
}
.reader-tags span {
border: 1px solid var(--line);
border-radius: 999px;
padding: 0.1rem 0.44rem;
color: var(--accent);
font-size: 0.68rem;
font-family: "Geist Mono", "IBM Plex Mono", monospace;
background: color-mix(in srgb, var(--accent-soft) 56%, transparent);
}
.reader-actions {
display: flex;
gap: 0.42rem;
@ -860,6 +1080,39 @@ main {
background: color-mix(in srgb, var(--accent-soft) 54%, transparent);
}
.related-list {
margin-top: 0.5rem;
display: grid;
gap: 0.3rem;
}
.related-list button {
width: 100%;
border: 1px solid var(--line);
border-radius: 9px;
background: color-mix(in srgb, var(--bg-soft) 86%, transparent);
color: var(--text-soft);
text-align: left;
display: grid;
gap: 0.22rem;
padding: 0.42rem 0.48rem;
}
.related-list button:hover {
border-color: var(--line-strong);
background: color-mix(in srgb, var(--accent-soft) 56%, transparent);
}
.related-list span {
color: var(--text);
font-size: 0.8rem;
}
.related-list small {
color: var(--text-muted);
font-size: 0.72rem;
}
.side-control + .side-control {
margin-top: 0.58rem;
}
@ -981,6 +1234,22 @@ main {
}
@media (max-width: 1240px) {
.pathway-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.starter-list {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.docs-toolbar {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.docs-toolbar input {
grid-column: span 2;
}
.workspace-grid {
grid-template-columns: minmax(220px, 300px) minmax(0, 1fr);
}
@ -1008,6 +1277,11 @@ main {
grid-template-columns: 1fr;
}
.pathway-grid,
.starter-list {
grid-template-columns: 1fr;
}
.reader-side {
grid-column: auto;
grid-template-columns: 1fr;
@ -1021,7 +1295,8 @@ main {
grid-template-columns: 1fr 1fr;
}
.docs-toolbar input {
.docs-toolbar input,
.docs-toolbar .btn:last-child {
grid-column: span 2;
}
}
@ -1062,10 +1337,15 @@ main {
grid-template-columns: 1fr;
}
.docs-toolbar input {
.docs-toolbar input,
.docs-toolbar .btn:last-child {
grid-column: auto;
}
.workspace-meta span {
width: 100%;
}
.reader-head {
flex-direction: column;
align-items: stretch;