fix: 修复博客文章目录Tab生成
This commit is contained in:
parent
52cbef7d65
commit
3d85b91c57
@ -1,3 +1,6 @@
|
|||||||
|
packages:
|
||||||
|
- '.'
|
||||||
|
|
||||||
overrides:
|
overrides:
|
||||||
'vue': 'beta'
|
'vue': 'beta'
|
||||||
'@vue/compiler-core': 'beta'
|
'@vue/compiler-core': 'beta'
|
||||||
|
|||||||
@ -78,6 +78,13 @@ describe('useBlogArticles', () => {
|
|||||||
cover: 'https://img.demo/cover.jpg',
|
cover: 'https://img.demo/cover.jpg',
|
||||||
date: '2026-06-05 10:20',
|
date: '2026-06-05 10:20',
|
||||||
excerpt: '摘要',
|
excerpt: '摘要',
|
||||||
|
headings: [
|
||||||
|
{
|
||||||
|
id: 'header-id-1',
|
||||||
|
level: 2,
|
||||||
|
text: '标题',
|
||||||
|
},
|
||||||
|
],
|
||||||
readTime: '1 分钟',
|
readTime: '1 分钟',
|
||||||
slug: decodedSlug,
|
slug: decodedSlug,
|
||||||
tags: ['Milkdown'],
|
tags: ['Milkdown'],
|
||||||
@ -218,10 +225,54 @@ describe('useBlogArticles', () => {
|
|||||||
expect(detailUrl.pathname).toBe('/api/blog/article/public/detail');
|
expect(detailUrl.pathname).toBe('/api/blog/article/public/detail');
|
||||||
expect(detailUrl.searchParams.get('slug')).toBe(decodedSlug);
|
expect(detailUrl.searchParams.get('slug')).toBe(decodedSlug);
|
||||||
expect(article?.contentHtml).toBe('<h1>详情标题</h1><p>详情正文</p>');
|
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([
|
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(
|
function mockFetch(
|
||||||
|
|||||||
@ -11,6 +11,12 @@ export type WordpressResolvedTerm = {
|
|||||||
slug?: string;
|
slug?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type WordpressArticleHeading = {
|
||||||
|
id?: string;
|
||||||
|
level?: number;
|
||||||
|
text?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type WordpressPublicArticle = {
|
export type WordpressPublicArticle = {
|
||||||
authorName?: string;
|
authorName?: string;
|
||||||
categoriesResolved?: WordpressResolvedTerm[];
|
categoriesResolved?: WordpressResolvedTerm[];
|
||||||
@ -21,6 +27,7 @@ export type WordpressPublicArticle = {
|
|||||||
date?: string;
|
date?: string;
|
||||||
excerpt?: string | { rendered?: string };
|
excerpt?: string | { rendered?: string };
|
||||||
excerptText?: string;
|
excerptText?: string;
|
||||||
|
headings?: WordpressArticleHeading[];
|
||||||
id: number;
|
id: number;
|
||||||
link?: string;
|
link?: string;
|
||||||
modified?: string;
|
modified?: string;
|
||||||
|
|||||||
@ -13,10 +13,12 @@ import {
|
|||||||
isArticleInCategory,
|
isArticleInCategory,
|
||||||
tags as fallbackTags,
|
tags as fallbackTags,
|
||||||
type BlogArticle,
|
type BlogArticle,
|
||||||
|
type BlogArticleHeading,
|
||||||
type BlogCategory,
|
type BlogCategory,
|
||||||
type BlogTag,
|
type BlogTag,
|
||||||
} from '@/data/blog';
|
} from '@/data/blog';
|
||||||
import { PREVIOUS_BLOG_BACKGROUND_IMAGE, resolveBlogStaticAsset } from '@/data/blogStaticAssets';
|
import { PREVIOUS_BLOG_BACKGROUND_IMAGE, resolveBlogStaticAsset } from '@/data/blogStaticAssets';
|
||||||
|
import { blogGeneratedHeadingId } from '@/factories/blogDomFactory';
|
||||||
|
|
||||||
const defaultCover = PREVIOUS_BLOG_BACKGROUND_IMAGE;
|
const defaultCover = PREVIOUS_BLOG_BACKGROUND_IMAGE;
|
||||||
const colorPool = ['blue', 'purple', 'green', 'orange', 'geekblue', 'cyan', 'volcano', 'magenta'];
|
const colorPool = ['blue', 'purple', 'green', 'orange', 'geekblue', 'cyan', 'volcano', 'magenta'];
|
||||||
@ -220,6 +222,7 @@ function normalizeWordpressArticle(article: WordpressPublicArticle): BlogArticle
|
|||||||
cover: resolveBlogStaticAsset(article.cover, defaultCover),
|
cover: resolveBlogStaticAsset(article.cover, defaultCover),
|
||||||
date: formatDate(article.date || article.modified),
|
date: formatDate(article.date || article.modified),
|
||||||
excerpt,
|
excerpt,
|
||||||
|
headings: normalizeArticleHeadings(article.headings, contentHtml),
|
||||||
id: article.id,
|
id: article.id,
|
||||||
readTime: `${Math.max(1, Math.ceil(words / 500))} 分钟`,
|
readTime: `${Math.max(1, Math.ceil(words / 500))} 分钟`,
|
||||||
slug: decodeSlug(article.slug),
|
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.
|
* @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.
|
* @returns Category list with counts based on all article category terms, not only the primary term.
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user