feat: Phase 14 — hierarchical folder structure (GitHub-style nested folders)
CI / typecheck + build (turbo) (push) Canceled after 0s
CI / typecheck + build (turbo) (push) Canceled after 0s
- Section index pages ([section]/page.tsx): shows folder tree + flat doc list
- Folder index pages: [section]/[...slug]/page.tsx detects folder paths
and renders subfolder + document listing instead of 404
- Sidebar tree: hierarchical grouping from flat doc slugs, folder icons (📁)
with proper indentation per depth level
- DocForm v2: parent folder dropdown (populated from existing folders),
slug input for leaf name, resolved path display
- Core helpers: extractFoldersForSection + classifyPath exported from @mcpedia/core
- CTF writeup reorganized: pwn-100 ret2win moved into pwn/ subfolder
- _index.md folder intro pages for ctf/ and defcon-quals-2024/
- STANDARD_KEYS includes extraFields to avoid badge duplication
This commit is contained in:
1 parent
90a43e5d0c
commit
ab3a2dc456
10 files changed
+840
-111
No files matched your search
@@ -18,6 +18,113 @@ const SECTIONS = [
|
||||
{ id: "notes", label: "Notes", icon: "📌" },
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Build a hierarchical folder tree from a flat list of documents.
|
||||
* Each doc's slug is like "section/sub/sub2/leaf-name".
|
||||
* The tree groups by path segments so the sidebar shows nested folders
|
||||
* with proper indentation (like GitHub's file tree).
|
||||
*/
|
||||
interface TreeNode {
|
||||
name: string;
|
||||
slug: string;
|
||||
title: string;
|
||||
children: TreeNode[];
|
||||
}
|
||||
|
||||
function buildFolderTree(docs: Doc[]): Record<string, TreeNode> {
|
||||
// Returns a map from section id → tree root for that section.
|
||||
const result: Record<string, TreeNode> = {};
|
||||
|
||||
for (const section of SECTIONS) {
|
||||
const sectionDocs = docs.filter((d) => d.section === section.id);
|
||||
const root: TreeNode = {
|
||||
name: section.id,
|
||||
slug: section.id,
|
||||
title: section.label,
|
||||
children: [],
|
||||
};
|
||||
const nodeMap = new Map<string, TreeNode>();
|
||||
nodeMap.set(section.id, root);
|
||||
|
||||
for (const doc of sectionDocs) {
|
||||
// slug is like "docs/websocket/contract" → parts after section
|
||||
const parts = doc.slug.split("/");
|
||||
// parts[0] should be the section
|
||||
let current = root;
|
||||
|
||||
for (let i = 1; i < parts.length; i++) {
|
||||
const part = parts[i];
|
||||
const path = parts.slice(0, i + 1).join("/");
|
||||
const isLeaf = i === parts.length - 1;
|
||||
|
||||
let child = current.children.find((n) => n.name === part);
|
||||
if (!child) {
|
||||
child = {
|
||||
name: part,
|
||||
slug: path,
|
||||
title: isLeaf ? doc.title : part,
|
||||
children: [],
|
||||
};
|
||||
current.children.push(child);
|
||||
nodeMap.set(path, child);
|
||||
} else if (isLeaf) {
|
||||
child.title = doc.title;
|
||||
}
|
||||
current = child;
|
||||
}
|
||||
}
|
||||
|
||||
root.children.sort((a, b) => {
|
||||
// Folders first, then docs
|
||||
const aFolder = a.children.length > 0 ? 0 : 1;
|
||||
const bFolder = b.children.length > 0 ? 0 : 1;
|
||||
if (aFolder !== bFolder) return aFolder - bFolder;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
result[section.id] = root;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively render the folder tree with indentation.
|
||||
* depth = 0 is the top-level items under a section.
|
||||
*/
|
||||
function renderTreeNode(node: TreeNode, depth: number, pathname: string) {
|
||||
const isFolder = node.children.length > 0;
|
||||
const isActive = pathname === `/${node.slug}`;
|
||||
const hasActiveChild = pathname.startsWith(`/${node.slug}/`);
|
||||
|
||||
const indent = depth * 12; // 12px per level
|
||||
|
||||
return (
|
||||
<li key={node.slug}>
|
||||
<Link
|
||||
href={`/${node.slug}`}
|
||||
className={`flex items-center gap-1.5 text-xs py-0.5 rounded transition-all ${
|
||||
isActive
|
||||
? "text-[#7170ff] bg-[#191a1b]"
|
||||
: hasActiveChild
|
||||
? "text-[#d0d6e0]"
|
||||
: "text-[#8a8f98] hover:text-[#d0d6e0] hover:bg-[#191a1b]"
|
||||
}`}
|
||||
style={{ marginLeft: `${indent}px` }}
|
||||
title={node.title}
|
||||
>
|
||||
<span>{isFolder ? "📁" : "📄"}</span>
|
||||
{node.title || node.name}
|
||||
</Link>
|
||||
{node.children && node.children.length > 0 && (
|
||||
<ul>
|
||||
{node.children.map((child) => renderTreeNode(child, 0, pathname))}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
/** Client-side sidebar (fetches doc list via /api/docs at runtime).
|
||||
* Client component so `next build` SSG doesn't hit Postgres (CI has no DB). */
|
||||
export default function Sidebar() {
|
||||
@@ -34,66 +141,64 @@ export default function Sidebar() {
|
||||
.catch(() => setDocs([]));
|
||||
}, []);
|
||||
|
||||
const bySection: Record<string, Doc[]> = {};
|
||||
for (const doc of docs) {
|
||||
if (!bySection[doc.section]) bySection[doc.section] = [];
|
||||
bySection[doc.section].push(doc);
|
||||
}
|
||||
const tree = buildFolderTree(docs);
|
||||
|
||||
// Flatten all docs count for display
|
||||
const totalDocs = docs.length;
|
||||
|
||||
return (
|
||||
<nav className="h-full overflow-y-auto py-6">
|
||||
<ul className="space-y-1 px-3 text-sm">
|
||||
<div className="mb-4 px-3">
|
||||
<span className="text-xs text-[#62666d] uppercase">
|
||||
{totalDocs} documents · {SECTIONS.length} sections
|
||||
</span>
|
||||
</div>
|
||||
<ul className="space-y-0.5 px-3">
|
||||
{SECTIONS.map(({ id, label, icon }) => {
|
||||
const sectionDocs = bySection[id] || [];
|
||||
const treeRoot = tree[id];
|
||||
const sectionDocs = docs.filter((d) => d.section === id);
|
||||
if (sectionDocs.length === 0) return null;
|
||||
|
||||
// Check if any doc in this section is the active page
|
||||
const isActive = sectionDocs.some(
|
||||
(doc) => pathname === `/${doc.slug}` || pathname === `/${doc.section}`,
|
||||
);
|
||||
|
||||
const isActive = pathname.startsWith(`/${id}`);
|
||||
return (
|
||||
<li key={id}>
|
||||
<div
|
||||
className={`text-xs font-medium mb-1 mt-4 first:mt-0 flex items-center gap-1.5 ${
|
||||
isActive
|
||||
? "text-[#7170ff]"
|
||||
: "text-[#62666d]"
|
||||
className={`text-xs font-medium mb-1 flex items-center gap-1.5 ${
|
||||
isActive ? "text-[#7170ff]" : "text-[#62666d]"
|
||||
}`}
|
||||
>
|
||||
<span>{icon}</span>
|
||||
{label}
|
||||
</div>
|
||||
<ul className="space-y-0.5">
|
||||
{sectionDocs.map((doc) => {
|
||||
const parts = doc.slug.split("/").slice(1);
|
||||
const label = parts[parts.length - 1];
|
||||
const formatted = label
|
||||
.split(/[-_]/)
|
||||
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
||||
.join(" ");
|
||||
const depth = doc.slug.split("/").length;
|
||||
const indent = depth - 1;
|
||||
const isDocActive = pathname === `/${doc.slug}`;
|
||||
return (
|
||||
<li
|
||||
key={doc.slug}
|
||||
style={{ marginLeft: `${indent * 12}px` }}
|
||||
>
|
||||
<Link
|
||||
href={`/${doc.slug}`}
|
||||
className={`block text-xs py-0.5 rounded transition-all ${
|
||||
isDocActive
|
||||
? "text-[#7170ff] bg-[#191a1b]"
|
||||
: "text-[#d0d6e0] hover:text-[#f7f8f8] hover:bg-[#191a1b]"
|
||||
}`}
|
||||
title={doc.title}
|
||||
>
|
||||
{formatted || doc.slug}
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
{treeRoot && treeRoot.children.length > 0
|
||||
? treeRoot.children.map((node) =>
|
||||
renderTreeNode(node, 0, pathname),
|
||||
)
|
||||
: sectionDocs.map((doc) => {
|
||||
const parts = doc.slug.split("/").slice(1);
|
||||
const label = parts[parts.length - 1]
|
||||
.split(/[-_]/)
|
||||
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
||||
.join(" ");
|
||||
const isDocActive = pathname === `/${doc.slug}`;
|
||||
return (
|
||||
<li key={doc.slug}>
|
||||
<Link
|
||||
href={`/${doc.slug}`}
|
||||
className={`flex items-center gap-1.5 text-xs py-0.5 rounded transition-all ${
|
||||
isDocActive
|
||||
? "text-[#7170ff] bg-[#191a1b]"
|
||||
: "text-[#d0d6e0] hover:text-[#f7f8f8] hover:bg-[#191a1b]"
|
||||
}`}
|
||||
title={doc.title}
|
||||
>
|
||||
<span>📄</span>
|
||||
{label}
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</li>
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user