Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/components/ArchivePanel.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ import I18nKey from "../i18n/i18nKey";
import { i18n } from "../i18n/translation";
import { getPostUrlBySlug } from "../utils/url-utils";

export let tags: string[];
export let categories: string[];
export let tags: string[] = [];
export let categories: string[] = [];
export let sortedPosts: Post[] = [];

const params = new URLSearchParams(window.location.search);
Expand Down
15 changes: 5 additions & 10 deletions src/components/widget/Categories.astro
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,11 @@
import I18nKey from "../../i18n/i18nKey";
import { i18n } from "../../i18n/translation";
import { getCategoryList } from "../../utils/content-utils";
import ButtonLink from "../control/ButtonLink.astro";
import WidgetLayout from "./WidgetLayout.astro";
import CategoryNode from "./CategoryNode.astro";

const categories = await getCategoryList();
const rootHasExpandable = categories.some((c) => c.children.length > 0);

const COLLAPSED_HEIGHT = "7.5rem";
const COLLAPSE_THRESHOLD = 5;
Expand All @@ -23,13 +24,7 @@ const style = Astro.props.style;
<WidgetLayout name={i18n(I18nKey.categories)} id="categories" isCollapsed={isCollapsed} collapsedHeight={COLLAPSED_HEIGHT}
class={className} style={style}
>
{categories.map((c) =>
<ButtonLink
url={c.url}
badge={String(c.count)}
label={`View all posts in the ${c.name.trim()} category`}
>
{c.name.trim()}
</ButtonLink>
)}
{categories.map((category) => (
<CategoryNode node={category} depth={0} hasExpandableSibling={rootHasExpandable} />
))}
</WidgetLayout>
87 changes: 87 additions & 0 deletions src/components/widget/CategoryNode.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
---
import type { Category } from "../../utils/content-utils";

interface Props {
node: Category;
depth?: number;
hasExpandableSibling?: boolean;
}

const { node, depth = 0, hasExpandableSibling = false } = Astro.props;
const INDENT_PER_LEVEL = 12;
const indentPx = depth * INDENT_PER_LEVEL;
const hasChildren = node.children.length > 0;
---

{hasChildren ? (
<div style={`margin-left: ${indentPx}px`}>
<details class="category-details">
<summary class="w-full h-10 rounded-lg bg-none hover:bg-[var(--btn-plain-bg-hover)] active:bg-[var(--btn-plain-bg-active)] transition-all pl-2 hover:pl-3 text-neutral-700 hover:text-[var(--primary)] dark:text-neutral-300 dark:hover:text-[var(--primary)] cursor-pointer list-none flex items-center justify-between">
<div class="flex items-center relative mr-2 flex-1">
<span class="category-arrow text-[8px] transition-transform inline-block w-4 flex-shrink-0 origin-center flex items-center justify-center">▶</span>
<a
data-category-link
href={node.url}
aria-label={`View all posts in the ${node.fullPath} category`}
class="overflow-hidden text-left whitespace-nowrap overflow-ellipsis no-underline text-inherit hover:text-[var(--primary)]"
>
{node.name.trim()}
</a>
</div>
{node.count > 0 && (
<div class="transition px-2 h-7 mr-2 min-w-[2rem] rounded-lg text-sm font-bold text-[var(--btn-content)] dark:text-[var(--deep-text)] bg-[var(--btn-regular-bg)] dark:bg-[var(--primary)] flex items-center justify-center">
{node.count}
</div>
)}
</summary>
<div class="ml-4 mt-1">
{node.children.map((child) => (
<Astro.self
node={child}
depth={depth + 1}
hasExpandableSibling={node.children.some((n) => n.children.length > 0)}
/>
))}
</div>
</details>
</div>
) : (
<div style={`margin-left: ${indentPx}px`}>
<a href={node.url} aria-label={`View all posts in the ${node.fullPath} category`}>
<button class="w-full h-10 rounded-lg bg-none hover:bg-[var(--btn-plain-bg-hover)] active:bg-[var(--btn-plain-bg-active)] transition-all pl-2 hover:pl-3 text-neutral-700 hover:text-[var(--primary)] dark:text-neutral-300 dark:hover:text-[var(--primary)]">
<div class="flex items-center justify-between relative mr-2">
<div class="flex items-center overflow-hidden text-left whitespace-nowrap overflow-ellipsis">
{hasExpandableSibling && (
<span class="text-[8px] inline-block w-4 flex-shrink-0 origin-center flex items-center justify-center text-neutral-500 dark:text-neutral-500">●</span>
)}
<span class="overflow-hidden text-left whitespace-nowrap overflow-ellipsis">{node.name.trim()}</span>
</div>
{node.count > 0 && (
<div class="transition px-2 h-7 ml-4 min-w-[2rem] rounded-lg text-sm font-bold text-[var(--btn-content)] dark:text-[var(--deep-text)] bg-[var(--btn-regular-bg)] dark:bg-[var(--primary)] flex items-center justify-center">
{node.count}
</div>
)}
</div>
</button>
</a>
</div>
)}

<style>
.category-details[open] > summary .category-arrow {
transform: rotate(90deg);
}
</style>

<script>
if (typeof window !== "undefined") {
window.addEventListener("click", (e) => {
const target = e.target as HTMLElement | null;
if (!target) return;
const link = target.closest("[data-category-link]");
if (link) {
e.stopPropagation();
}
});
}
</script>
2 changes: 1 addition & 1 deletion src/content/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ const postsCollection = defineCollection({
description: z.string().optional().default(""),
image: z.string().optional().default(""),
tags: z.array(z.string()).optional().default([]),
category: z.string().optional().nullable().default(""),
category: z.string().optional().default(""),
lang: z.string().optional().default(""),

/* For internal use */
Expand Down
2 changes: 1 addition & 1 deletion src/pages/archive.astro
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,6 @@ const sortedPostsList = await getSortedPostsList();
---

<MainGridLayout title={i18n(I18nKey.archive)}>
<ArchivePanel sortedPosts={sortedPostsList} client:only="svelte"></ArchivePanel>
<ArchivePanel sortedPosts={sortedPostsList as any} tags={[]} categories={[]} client:only="svelte"></ArchivePanel>
</MainGridLayout>

2 changes: 1 addition & 1 deletion src/plugins/remark-directive-rehype.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { h } from "hastscript";
import { visit } from "unist-util-visit";

export function parseDirectiveNode() {
return (tree, { _data }) => {
return (tree, _file) => {
visit(tree, (node) => {
if (
node.type === "containerDirective" ||
Expand Down
109 changes: 83 additions & 26 deletions src/utils/content-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,42 +73,99 @@ export async function getTagList(): Promise<Tag[]> {
}

export type Category = {
name: string;
count: number;
name: string; // current segment name
fullPath: string; // joined path like "Java/MouseMacros"
count: number; // includes children
url: string;
children: Category[];
};

type CategoryNodeInternal = Category & {
childrenMap: Map<string, CategoryNodeInternal>;
};

function createCategoryNode(
name: string,
fullPath: string,
): CategoryNodeInternal {
return {
name,
fullPath,
count: 0,
url: getCategoryUrl(fullPath),
children: [],
childrenMap: new Map<string, CategoryNodeInternal>(),
};
}

function normalizeCategory(raw: unknown): string {
if (!raw) return "";
if (typeof raw === "string") return raw.trim();
return String(raw).trim();
}

function addCategoryToTree(
root: Map<string, CategoryNodeInternal>,
segments: string[],
uncategorizedLabel: string,
) {
// If no valid segments, treat as uncategorized
if (segments.length === 0) {
const key = uncategorizedLabel;
const existing = root.get(key) ?? createCategoryNode(key, key);
existing.count += 1;
root.set(key, existing);
return;
}

let currentMap = root;
let currentPath = "";

segments.forEach((segment) => {
currentPath = currentPath ? `${currentPath}/${segment}` : segment;
let node = currentMap.get(segment);
if (!node) {
node = createCategoryNode(segment, currentPath);
currentMap.set(segment, node);
}
node.count += 1; // accumulate counts on all levels
currentMap = node.childrenMap;
});
}

function mapToSortedCategories(
map: Map<string, CategoryNodeInternal>,
): Category[] {
const nodes = Array.from(map.values()).sort((a, b) =>
a.name.toLowerCase().localeCompare(b.name.toLowerCase()),
);

return nodes.map((node) => ({
name: node.name,
fullPath: node.fullPath,
count: node.count,
url: node.url,
children: mapToSortedCategories(node.childrenMap),
}));
}

export async function getCategoryList(): Promise<Category[]> {
const allBlogPosts = await getCollection<"posts">("posts", ({ data }) => {
return import.meta.env.PROD ? data.draft !== true : true;
});
const count: { [key: string]: number } = {};
allBlogPosts.forEach((post: { data: { category: string | null } }) => {
if (!post.data.category) {
const ucKey = i18n(I18nKey.uncategorized);
count[ucKey] = count[ucKey] ? count[ucKey] + 1 : 1;
return;
}

const categoryName =
typeof post.data.category === "string"
? post.data.category.trim()
: String(post.data.category).trim();
const uncategorizedLabel = i18n(I18nKey.uncategorized);
const rootMap = new Map<string, CategoryNodeInternal>();

count[categoryName] = count[categoryName] ? count[categoryName] + 1 : 1;
});
allBlogPosts.forEach((post: { data: { category: string | null } }) => {
const normalized = normalizeCategory(post.data.category);
const segments = normalized
.split("/")
.map((s) => s.trim())
.filter(Boolean);

const lst = Object.keys(count).sort((a, b) => {
return a.toLowerCase().localeCompare(b.toLowerCase());
addCategoryToTree(rootMap, segments, uncategorizedLabel);
});

const ret: Category[] = [];
for (const c of lst) {
ret.push({
name: c,
count: count[c],
url: getCategoryUrl(c),
});
}
return ret;
return mapToSortedCategories(rootMap);
}
2 changes: 1 addition & 1 deletion src/utils/url-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ export function getCategoryUrl(category: string | null): string {
category.trim().toLowerCase() === i18n(I18nKey.uncategorized).toLowerCase()
)
return url("/archive/?uncategorized=true");
return url(`/archive/?category=${encodeURIComponent(category.trim())}`);
return url(`/categories/${encodeURIComponent(category.trim())}/`);
}

export function getDir(path: string): string {
Expand Down