fix: 修复博客文章目录Tab生成

This commit is contained in:
sunlei 2026-07-03 09:47:22 +08:00
parent 52cbef7d65
commit 3d85b91c57
4 changed files with 114 additions and 0 deletions

View File

@ -1,3 +1,6 @@
packages:
- '.'
overrides:
'vue': 'beta'
'@vue/compiler-core': 'beta'

View File

@ -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('<h1>详情标题</h1><p>详情正文</p>');
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: '<h2 id="intro">介绍</h2><p>正文</p><h3>细节</h3>',
},
],
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(

View File

@ -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;

View File

@ -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<HTMLHeadingElement>('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.