diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index 0318eae..9a19117 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -1,3 +1,6 @@
+packages:
+ - '.'
+
overrides:
'vue': 'beta'
'@vue/compiler-core': 'beta'
diff --git a/src/__tests__/useBlogArticles.spec.ts b/src/__tests__/useBlogArticles.spec.ts
index 87f361b..5bd0a5e 100644
--- a/src/__tests__/useBlogArticles.spec.ts
+++ b/src/__tests__/useBlogArticles.spec.ts
@@ -78,6 +78,13 @@ describe('useBlogArticles', () => {
cover: 'https://img.demo/cover.jpg',
date: '2026-06-05 10:20',
excerpt: '摘要',
+ headings: [
+ {
+ id: 'header-id-1',
+ level: 2,
+ text: '标题',
+ },
+ ],
readTime: '1 分钟',
slug: decodedSlug,
tags: ['Milkdown'],
@@ -218,10 +225,54 @@ describe('useBlogArticles', () => {
expect(detailUrl.pathname).toBe('/api/blog/article/public/detail');
expect(detailUrl.searchParams.get('slug')).toBe(decodedSlug);
expect(article?.contentHtml).toBe('
详情标题
详情正文
');
+ expect(article?.headings).toEqual([
+ {
+ id: 'header-id-1',
+ level: 1,
+ text: '详情标题',
+ },
+ ]);
expect(blogArticles.getArticleBySlug(decodedSlug)?.content).toEqual([
'详情标题 详情正文',
]);
});
+
+ it('derives article catalog headings from public API html when heading metadata is absent', async () => {
+ mockFetch([
+ {
+ body: {
+ code: 200,
+ data: {
+ list: [
+ {
+ ...publicArticle,
+ contentHtml: '介绍
正文
细节
',
+ },
+ ],
+ total: 1,
+ },
+ },
+ status: 200,
+ },
+ ]);
+ const { useBlogArticles } = await import('@/hooks/useBlogArticles');
+ const blogArticles = useBlogArticles();
+
+ await blogArticles.loadArticles();
+
+ expect(blogArticles.articles.value[0]?.headings).toEqual([
+ {
+ id: 'intro',
+ level: 2,
+ text: '介绍',
+ },
+ {
+ id: 'header-id-2',
+ level: 3,
+ text: '细节',
+ },
+ ]);
+ });
});
function mockFetch(
diff --git a/src/api/blogArticles.ts b/src/api/blogArticles.ts
index 1b76bed..6d28eb7 100644
--- a/src/api/blogArticles.ts
+++ b/src/api/blogArticles.ts
@@ -11,6 +11,12 @@ export type WordpressResolvedTerm = {
slug?: string;
};
+export type WordpressArticleHeading = {
+ id?: string;
+ level?: number;
+ text?: string;
+};
+
export type WordpressPublicArticle = {
authorName?: string;
categoriesResolved?: WordpressResolvedTerm[];
@@ -21,6 +27,7 @@ export type WordpressPublicArticle = {
date?: string;
excerpt?: string | { rendered?: string };
excerptText?: string;
+ headings?: WordpressArticleHeading[];
id: number;
link?: string;
modified?: string;
diff --git a/src/hooks/useBlogArticles.ts b/src/hooks/useBlogArticles.ts
index 32fb776..59e99b8 100644
--- a/src/hooks/useBlogArticles.ts
+++ b/src/hooks/useBlogArticles.ts
@@ -13,10 +13,12 @@ import {
isArticleInCategory,
tags as fallbackTags,
type BlogArticle,
+ type BlogArticleHeading,
type BlogCategory,
type BlogTag,
} from '@/data/blog';
import { PREVIOUS_BLOG_BACKGROUND_IMAGE, resolveBlogStaticAsset } from '@/data/blogStaticAssets';
+import { blogGeneratedHeadingId } from '@/factories/blogDomFactory';
const defaultCover = PREVIOUS_BLOG_BACKGROUND_IMAGE;
const colorPool = ['blue', 'purple', 'green', 'orange', 'geekblue', 'cyan', 'volcano', 'magenta'];
@@ -220,6 +222,7 @@ function normalizeWordpressArticle(article: WordpressPublicArticle): BlogArticle
cover: resolveBlogStaticAsset(article.cover, defaultCover),
date: formatDate(article.date || article.modified),
excerpt,
+ headings: normalizeArticleHeadings(article.headings, contentHtml),
id: article.id,
readTime: `${Math.max(1, Math.ceil(words / 500))} 分钟`,
slug: decodeSlug(article.slug),
@@ -230,6 +233,56 @@ function normalizeWordpressArticle(article: WordpressPublicArticle): BlogArticle
};
}
+/**
+ * @param headings Optional heading outline supplied directly by the public API.
+ * @param contentHtml Rendered WordPress HTML used as the source of truth when the API omits headings.
+ * @returns Heading outline used only to decide whether Argon's article/sidebar catalog tab should be visible.
+ */
+function normalizeArticleHeadings(
+ headings: WordpressPublicArticle['headings'],
+ contentHtml: string,
+): BlogArticleHeading[] {
+ if (headings?.length) {
+ return headings.map((heading, index) => ({
+ id: heading.id || blogGeneratedHeadingId(index + 1),
+ level: normalizeHeadingLevel(heading.level),
+ text: stripHtml(heading.text),
+ })).filter((heading) => heading.text);
+ }
+
+ return extractArticleHeadingsFromHtml(contentHtml);
+}
+
+/**
+ * @param contentHtml Rendered WordPress article HTML returned by the public article API.
+ * @returns Ordered h1-h6 headings parsed from the article body so post pages can expose the catalog tab.
+ */
+function extractArticleHeadingsFromHtml(contentHtml: string): BlogArticleHeading[] {
+ const parser = typeof DOMParser === 'undefined' ? null : new DOMParser();
+ if (!parser) return [];
+
+ const document = parser.parseFromString(contentHtml, 'text/html');
+
+ return Array.from(document.querySelectorAll('h1, h2, h3, h4, h5, h6'))
+ .map((heading, index) => ({
+ id: heading.id || blogGeneratedHeadingId(index + 1),
+ level: normalizeHeadingLevel(Number(heading.tagName.slice(1))),
+ text: stripHtml(heading.textContent),
+ }))
+ .filter((heading) => heading.text);
+}
+
+/**
+ * @param value Heading level from API metadata or a parsed HTML tag name.
+ * @returns Safe heading level constrained to the h1-h6 range accepted by the catalog model.
+ */
+function normalizeHeadingLevel(value: unknown): BlogArticleHeading['level'] {
+ const level = Number(value);
+ if (level >= 1 && level <= 6) return level as BlogArticleHeading['level'];
+
+ return 2;
+}
+
/**
* @param articles Local article list whose WordPress category memberships should be counted.
* @returns Category list with counts based on all article category terms, not only the primary term.