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.