fix: blog tag list when large number of tags#121
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
There was a problem hiding this comment.
Security Review — No High-Confidence Vulnerabilities Found
This PR makes purely client-side UI changes (show/hide toggle for blog tag lists). After reviewing the diff against the threat checklist, no exploitable vulnerabilities were identified.
Checklist results:
| Area | Finding |
|---|---|
| XSS / Injection | Tag names rendered as React text children (not dangerouslySetInnerHTML). aria-label/title values come from static localization strings. No new HTML injection surface. |
| Path traversal | Tag slugs embedded in hrefs (/blog/tag/${tag.slug}) were already present pre-PR; no change to construction or sanitization. |
| Authn / Authz | No authentication or authorization logic touched. |
| Secrets / Logging | No secrets, tokens, or log statements added or modified. |
| SSRF / CSRF | No server requests or form submissions introduced. |
| Dependencies | lucide-react (ChevronDown, ChevronUp) is an existing project dependency; package.json adds no new packages. |
| Supply chain | Registry JSON (btst-blog.json) updated to mirror source changes only; no external URLs or remote script loading introduced. |
Summary: The change is limited to client-side useState-based visibility toggling and two new localization strings. No high-confidence security issues are present.
Sent by Cursor Automation: Find vulnerabilities
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
Bugbot Autofix prepared a fix for the issue found in the latest run.
- ✅ Fixed: Duplicated collapsible tag list logic across two components
- Extracted the shared collapse/expand tag list logic into a new
CollapsibleTagListcomponent incollapsible-tag-list.tsx, then updated bothTagsListandPostHeaderTopto use it, eliminating ~55 lines of duplication.
- Extracted the shared collapse/expand tag list logic into a new
Preview (e7c3052b3a)
diff --git a/packages/stack/package.json b/packages/stack/package.json
--- a/packages/stack/package.json
+++ b/packages/stack/package.json
@@ -1,6 +1,6 @@
{
"name": "@btst/stack",
- "version": "2.11.7",
+ "version": "2.11.8",
"description": "A composable, plugin-based library for building full-stack applications.",
"repository": {
"type": "git",
diff --git a/packages/stack/registry/btst-blog.json b/packages/stack/registry/btst-blog.json
--- a/packages/stack/registry/btst-blog.json
+++ b/packages/stack/registry/btst-blog.json
@@ -190,7 +190,7 @@
{
"path": "btst/blog/client/components/pages/post-page.internal.tsx",
"type": "registry:component",
- "content": "\"use client\";\n\nimport { usePluginOverrides, useBasePath } from \"@btst/stack/context\";\nimport { formatDate } from \"date-fns\";\nimport {\n\tuseSuspensePost,\n\tuseNextPreviousPosts,\n\tuseRecentPosts,\n} from \"@btst/stack/plugins/blog/client/hooks\";\nimport { EmptyList } from \"../shared/empty-list\";\nimport { MarkdownContent } from \"../shared/markdown-content\";\nimport { PageHeader } from \"../shared/page-header\";\nimport { PageWrapper } from \"../shared/page-wrapper\";\nimport type { BlogPluginOverrides } from \"../../overrides\";\nimport { DefaultImage, DefaultLink } from \"../shared/defaults\";\nimport { BLOG_LOCALIZATION } from \"../../localization\";\nimport { PostNavigation } from \"../shared/post-navigation\";\nimport { RecentPostsCarousel } from \"../shared/recent-posts-carousel\";\nimport { Badge } from \"@/components/ui/badge\";\nimport { useRouteLifecycle } from \"@/hooks/use-route-lifecycle\";\nimport { OnThisPage, OnThisPageSelect } from \"../shared/on-this-page\";\nimport type { SerializedPost } from \"../../../types\";\nimport { useRegisterPageAIContext } from \"@btst/stack/plugins/ai-chat/client/context\";\nimport { WhenVisible } from \"@/components/ui/when-visible\";\nimport { PostNavigationSkeleton } from \"../loading/post-navigation-skeleton\";\nimport { RecentPostsCarouselSkeleton } from \"../loading/recent-posts-carousel-skeleton\";\n\n// Internal component with actual page content\nexport function PostPage({ slug }: { slug: string }) {\n\tconst overrides = usePluginOverrides<\n\t\tBlogPluginOverrides,\n\t\tPartial<BlogPluginOverrides>\n\t>(\"blog\", {\n\t\tImage: DefaultImage,\n\t\tlocalization: BLOG_LOCALIZATION,\n\t});\n\tconst { Image, localization } = overrides;\n\n\t// Call lifecycle hooks\n\tuseRouteLifecycle({\n\t\trouteName: \"post\",\n\t\tcontext: {\n\t\t\tpath: `/blog/${slug}`,\n\t\t\tparams: { slug },\n\t\t\tisSSR: typeof window === \"undefined\",\n\t\t},\n\t\toverrides,\n\t\tbeforeRenderHook: (overrides, context) => {\n\t\t\tif (overrides.onBeforePostPageRendered) {\n\t\t\t\treturn overrides.onBeforePostPageRendered(slug, context);\n\t\t\t}\n\t\t\treturn true;\n\t\t},\n\t});\n\n\tconst { post } = useSuspensePost(slug ?? \"\");\n\n\tconst { previousPost, nextPost } = useNextPreviousPosts(\n\t\tpost?.createdAt ?? new Date(),\n\t\t{\n\t\t\tenabled: !!post,\n\t\t},\n\t);\n\n\tconst { recentPosts } = useRecentPosts({\n\t\tlimit: 5,\n\t\texcludeSlug: slug,\n\t\tenabled: !!post,\n\t});\n\n\t// Register page AI context so the chat can summarize and discuss this post\n\tuseRegisterPageAIContext(\n\t\tpost\n\t\t\t? {\n\t\t\t\t\trouteName: \"blog-post\",\n\t\t\t\t\tpageDescription:\n\t\t\t\t\t\t`Blog post: \"${post.title}\"\\nAuthor: ${post.authorId ?? \"Unknown\"}\\n\\n${post.content ?? \"\"}`.slice(\n\t\t\t\t\t\t\t0,\n\t\t\t\t\t\t\t16000,\n\t\t\t\t\t\t),\n\t\t\t\t\tsuggestions: [\n\t\t\t\t\t\t\"Summarize this post\",\n\t\t\t\t\t\t\"What are the key takeaways?\",\n\t\t\t\t\t\t\"Explain this in simpler terms\",\n\t\t\t\t\t],\n\t\t\t\t}\n\t\t\t: null,\n\t);\n\n\tif (!slug || !post) {\n\t\treturn (\n\t\t\t<PageWrapper>\n\t\t\t\t<EmptyList message={localization.BLOG_PAGE_NOT_FOUND_DESCRIPTION} />\n\t\t\t</PageWrapper>\n\t\t);\n\t}\n\n\treturn (\n\t\t<PageWrapper className=\"gap-0 px-4 lg:px-2 py-0 pb-18\" testId=\"post-page\">\n\t\t\t<div className=\"flex items-start w-full\">\n\t\t\t\t<div className=\"w-44 shrink-0 hidden xl:flex mr-auto\" />\n\t\t\t\t<div className=\"flex flex-col items-center flex-1 mx-auto w-full max-w-4xl min-w-0\">\n\t\t\t\t\t<OnThisPageSelect markdown={post.content} />\n\n\t\t\t\t\t<PageHeader\n\t\t\t\t\t\ttitle={post.title}\n\t\t\t\t\t\tdescription={post.excerpt}\n\t\t\t\t\t\tchildrenTop={<PostHeaderTop post={post} />}\n\t\t\t\t\t/>\n\n\t\t\t\t\t{post.image && (\n\t\t\t\t\t\t<div className=\"flex flex-col gap-2 my-6 aspect-video w-full relative\">\n\t\t\t\t\t\t\t<Image\n\t\t\t\t\t\t\t\tsrc={post.image}\n\t\t\t\t\t\t\t\talt={post.title}\n\t\t\t\t\t\t\t\tclassName=\"object-cover transition-transform duration-200\"\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t)}\n\n\t\t\t\t\t<div className=\"w-full px-3\">\n\t\t\t\t\t\t<MarkdownContent markdown={post.content} />\n\t\t\t\t\t</div>\n\n\t\t\t\t\t<div className=\"flex flex-col gap-4 w-full\">\n\t\t\t\t\t\t<WhenVisible\n\t\t\t\t\t\t\trootMargin=\"200px\"\n\t\t\t\t\t\t\tfallback={<PostNavigationSkeleton />}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<PostNavigation previousPost={previousPost} nextPost={nextPost} />\n\t\t\t\t\t\t</WhenVisible>\n\n\t\t\t\t\t\t<WhenVisible\n\t\t\t\t\t\t\trootMargin=\"200px\"\n\t\t\t\t\t\t\tfallback={<RecentPostsCarouselSkeleton />}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<RecentPostsCarousel posts={recentPosts} />\n\t\t\t\t\t\t</WhenVisible>\n\n\t\t\t\t\t\t{overrides.postBottomSlot && (\n\t\t\t\t\t\t\t<div data-testid=\"post-bottom-slot\">\n\t\t\t\t\t\t\t\t{overrides.postBottomSlot(post)}\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t)}\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t\t<OnThisPage markdown={post.content} />\n\t\t\t</div>\n\t\t</PageWrapper>\n\t);\n}\n\nfunction PostHeaderTop({ post }: { post: SerializedPost }) {\n\tconst { Link } = usePluginOverrides<\n\t\tBlogPluginOverrides,\n\t\tPartial<BlogPluginOverrides>\n\t>(\"blog\", {\n\t\tLink: DefaultLink,\n\t});\n\tconst basePath = useBasePath();\n\treturn (\n\t\t<div className=\"flex flex-row items-center justify-center gap-2 flex-wrap mt-8\">\n\t\t\t<span className=\"font-light text-muted-foreground text-sm\">\n\t\t\t\t{formatDate(post.createdAt, \"MMMM d, yyyy\")}\n\t\t\t</span>\n\t\t\t{post.tags && post.tags.length > 0 && (\n\t\t\t\t<>\n\t\t\t\t\t{post.tags.map((tag) => (\n\t\t\t\t\t\t<Link key={tag.id} href={`${basePath}/blog/tag/${tag.slug}`}>\n\t\t\t\t\t\t\t<Badge variant=\"secondary\" className=\"text-xs\">\n\t\t\t\t\t\t\t\t{tag.name}\n\t\t\t\t\t\t\t</Badge>\n\t\t\t\t\t\t</Link>\n\t\t\t\t\t))}\n\t\t\t\t</>\n\t\t\t)}\n\t\t</div>\n\t);\n}\n",
+ "content": "\"use client\";\n\nimport { usePluginOverrides, useBasePath } from \"@btst/stack/context\";\nimport { formatDate } from \"date-fns\";\nimport {\n\tuseSuspensePost,\n\tuseNextPreviousPosts,\n\tuseRecentPosts,\n} from \"@btst/stack/plugins/blog/client/hooks\";\nimport { EmptyList } from \"../shared/empty-list\";\nimport { MarkdownContent } from \"../shared/markdown-content\";\nimport { PageHeader } from \"../shared/page-header\";\nimport { PageWrapper } from \"../shared/page-wrapper\";\nimport type { BlogPluginOverrides } from \"../../overrides\";\nimport { DefaultImage, DefaultLink } from \"../shared/defaults\";\nimport { BLOG_LOCALIZATION } from \"../../localization\";\nimport { PostNavigation } from \"../shared/post-navigation\";\nimport { RecentPostsCarousel } from \"../shared/recent-posts-carousel\";\nimport { Badge } from \"@/components/ui/badge\";\nimport { useRouteLifecycle } from \"@/hooks/use-route-lifecycle\";\nimport { OnThisPage, OnThisPageSelect } from \"../shared/on-this-page\";\nimport type { SerializedPost } from \"../../../types\";\nimport { useRegisterPageAIContext } from \"@btst/stack/plugins/ai-chat/client/context\";\nimport { WhenVisible } from \"@/components/ui/when-visible\";\nimport { PostNavigationSkeleton } from \"../loading/post-navigation-skeleton\";\nimport { RecentPostsCarouselSkeleton } from \"../loading/recent-posts-carousel-skeleton\";\nimport { ChevronDown, ChevronUp } from \"lucide-react\";\nimport { useState } from \"react\";\n\nconst MAX_VISIBLE_POST_TAGS = 15;\n\n// Internal component with actual page content\nexport function PostPage({ slug }: { slug: string }) {\n\tconst overrides = usePluginOverrides<\n\t\tBlogPluginOverrides,\n\t\tPartial<BlogPluginOverrides>\n\t>(\"blog\", {\n\t\tImage: DefaultImage,\n\t\tlocalization: BLOG_LOCALIZATION,\n\t});\n\tconst { Image, localization } = overrides;\n\n\t// Call lifecycle hooks\n\tuseRouteLifecycle({\n\t\trouteName: \"post\",\n\t\tcontext: {\n\t\t\tpath: `/blog/${slug}`,\n\t\t\tparams: { slug },\n\t\t\tisSSR: typeof window === \"undefined\",\n\t\t},\n\t\toverrides,\n\t\tbeforeRenderHook: (overrides, context) => {\n\t\t\tif (overrides.onBeforePostPageRendered) {\n\t\t\t\treturn overrides.onBeforePostPageRendered(slug, context);\n\t\t\t}\n\t\t\treturn true;\n\t\t},\n\t});\n\n\tconst { post } = useSuspensePost(slug ?? \"\");\n\n\tconst { previousPost, nextPost } = useNextPreviousPosts(\n\t\tpost?.createdAt ?? new Date(),\n\t\t{\n\t\t\tenabled: !!post,\n\t\t},\n\t);\n\n\tconst { recentPosts } = useRecentPosts({\n\t\tlimit: 5,\n\t\texcludeSlug: slug,\n\t\tenabled: !!post,\n\t});\n\n\t// Register page AI context so the chat can summarize and discuss this post\n\tuseRegisterPageAIContext(\n\t\tpost\n\t\t\t? {\n\t\t\t\t\trouteName: \"blog-post\",\n\t\t\t\t\tpageDescription:\n\t\t\t\t\t\t`Blog post: \"${post.title}\"\\nAuthor: ${post.authorId ?? \"Unknown\"}\\n\\n${post.content ?? \"\"}`.slice(\n\t\t\t\t\t\t\t0,\n\t\t\t\t\t\t\t16000,\n\t\t\t\t\t\t),\n\t\t\t\t\tsuggestions: [\n\t\t\t\t\t\t\"Summarize this post\",\n\t\t\t\t\t\t\"What are the key takeaways?\",\n\t\t\t\t\t\t\"Explain this in simpler terms\",\n\t\t\t\t\t],\n\t\t\t\t}\n\t\t\t: null,\n\t);\n\n\tif (!slug || !post) {\n\t\treturn (\n\t\t\t<PageWrapper>\n\t\t\t\t<EmptyList message={localization.BLOG_PAGE_NOT_FOUND_DESCRIPTION} />\n\t\t\t</PageWrapper>\n\t\t);\n\t}\n\n\treturn (\n\t\t<PageWrapper className=\"gap-0 px-4 lg:px-2 py-0 pb-18\" testId=\"post-page\">\n\t\t\t<div className=\"flex items-start w-full\">\n\t\t\t\t<div className=\"w-44 shrink-0 hidden xl:flex mr-auto\" />\n\t\t\t\t<div className=\"flex flex-col items-center flex-1 mx-auto w-full max-w-4xl min-w-0\">\n\t\t\t\t\t<OnThisPageSelect markdown={post.content} />\n\n\t\t\t\t\t<PageHeader\n\t\t\t\t\t\ttitle={post.title}\n\t\t\t\t\t\tdescription={post.excerpt}\n\t\t\t\t\t\tchildrenTop={<PostHeaderTop post={post} />}\n\t\t\t\t\t/>\n\n\t\t\t\t\t{post.image && (\n\t\t\t\t\t\t<div className=\"flex flex-col gap-2 my-6 aspect-video w-full relative\">\n\t\t\t\t\t\t\t<Image\n\t\t\t\t\t\t\t\tsrc={post.image}\n\t\t\t\t\t\t\t\talt={post.title}\n\t\t\t\t\t\t\t\tclassName=\"object-cover transition-transform duration-200\"\n\t\t\t\t\t\t\t/>\n\t\t\t\t\t\t</div>\n\t\t\t\t\t)}\n\n\t\t\t\t\t<div className=\"w-full px-3\">\n\t\t\t\t\t\t<MarkdownContent markdown={post.content} />\n\t\t\t\t\t</div>\n\n\t\t\t\t\t<div className=\"flex flex-col gap-4 w-full\">\n\t\t\t\t\t\t<WhenVisible\n\t\t\t\t\t\t\trootMargin=\"200px\"\n\t\t\t\t\t\t\tfallback={<PostNavigationSkeleton />}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<PostNavigation previousPost={previousPost} nextPost={nextPost} />\n\t\t\t\t\t\t</WhenVisible>\n\n\t\t\t\t\t\t<WhenVisible\n\t\t\t\t\t\t\trootMargin=\"200px\"\n\t\t\t\t\t\t\tfallback={<RecentPostsCarouselSkeleton />}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<RecentPostsCarousel posts={recentPosts} />\n\t\t\t\t\t\t</WhenVisible>\n\n\t\t\t\t\t\t{overrides.postBottomSlot && (\n\t\t\t\t\t\t\t<div data-testid=\"post-bottom-slot\">\n\t\t\t\t\t\t\t\t{overrides.postBottomSlot(post)}\n\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t)}\n\t\t\t\t\t</div>\n\t\t\t\t</div>\n\t\t\t\t<OnThisPage markdown={post.content} />\n\t\t\t</div>\n\t\t</PageWrapper>\n\t);\n}\n\nfunction PostHeaderTop({ post }: { post: SerializedPost }) {\n\tconst { Link, localization } = usePluginOverrides<\n\t\tBlogPluginOverrides,\n\t\tPartial<BlogPluginOverrides>\n\t>(\"blog\", {\n\t\tLink: DefaultLink,\n\t\tlocalization: BLOG_LOCALIZATION,\n\t});\n\tconst basePath = useBasePath();\n\tconst [showAll, setShowAll] = useState(false);\n\n\tconst allTags = post.tags ?? [];\n\tconst hasMore = allTags.length > MAX_VISIBLE_POST_TAGS;\n\tconst visibleTags =\n\t\tshowAll || !hasMore ? allTags : allTags.slice(0, MAX_VISIBLE_POST_TAGS);\n\n\treturn (\n\t\t<div className=\"flex flex-row items-center justify-center gap-2 flex-wrap mt-8\">\n\t\t\t<span className=\"font-light text-muted-foreground text-sm\">\n\t\t\t\t{formatDate(post.createdAt, \"MMMM d, yyyy\")}\n\t\t\t</span>\n\t\t\t{visibleTags.map((tag) => (\n\t\t\t\t<Link key={tag.id} href={`${basePath}/blog/tag/${tag.slug}`}>\n\t\t\t\t\t<Badge variant=\"secondary\" className=\"text-xs\">\n\t\t\t\t\t\t{tag.name}\n\t\t\t\t\t</Badge>\n\t\t\t\t</Link>\n\t\t\t))}\n\t\t\t{hasMore && (\n\t\t\t\t<Badge asChild variant=\"secondary\" className=\"text-xs cursor-pointer\">\n\t\t\t\t\t<button\n\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\tonClick={() => setShowAll((prev) => !prev)}\n\t\t\t\t\t\taria-expanded={showAll}\n\t\t\t\t\t\taria-label={\n\t\t\t\t\t\t\tshowAll\n\t\t\t\t\t\t\t\t? localization.BLOG_TAGS_SHOW_LESS\n\t\t\t\t\t\t\t\t: localization.BLOG_TAGS_SHOW_ALL\n\t\t\t\t\t\t}\n\t\t\t\t\t\ttitle={\n\t\t\t\t\t\t\tshowAll\n\t\t\t\t\t\t\t\t? localization.BLOG_TAGS_SHOW_LESS\n\t\t\t\t\t\t\t\t: localization.BLOG_TAGS_SHOW_ALL\n\t\t\t\t\t\t}\n\t\t\t\t\t>\n\t\t\t\t\t\t{showAll ? (\n\t\t\t\t\t\t\t<ChevronUp aria-hidden=\"true\" />\n\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t<ChevronDown aria-hidden=\"true\" />\n\t\t\t\t\t\t)}\n\t\t\t\t\t</button>\n\t\t\t\t</Badge>\n\t\t\t)}\n\t\t</div>\n\t);\n}\n",
"target": "src/components/btst/blog/client/components/pages/post-page.internal.tsx"
},
{
@@ -310,7 +310,7 @@
{
"path": "btst/blog/client/components/shared/tags-list.tsx",
"type": "registry:component",
- "content": "\"use client\";\n\nimport { usePluginOverrides, useBasePath } from \"@btst/stack/context\";\nimport type { BlogPluginOverrides } from \"../../overrides\";\nimport { DefaultLink } from \"./defaults\";\nimport { Badge } from \"@/components/ui/badge\";\nimport { useSuspenseTags } from \"@btst/stack/plugins/blog/client/hooks\";\n\nexport function TagsList() {\n\tconst { tags } = useSuspenseTags();\n\tconst { Link } = usePluginOverrides<\n\t\tBlogPluginOverrides,\n\t\tPartial<BlogPluginOverrides>\n\t>(\"blog\", {\n\t\tLink: DefaultLink,\n\t});\n\tconst basePath = useBasePath();\n\n\tif (!tags || tags.length === 0) {\n\t\treturn null;\n\t}\n\n\treturn (\n\t\t<div className=\"flex flex-wrap gap-2 justify-center\">\n\t\t\t{tags.map((tag) => (\n\t\t\t\t<Link key={tag.id} href={`${basePath}/blog/tag/${tag.slug}`}>\n\t\t\t\t\t<Badge variant=\"secondary\" className=\"text-xs\">\n\t\t\t\t\t\t{tag.name}\n\t\t\t\t\t</Badge>\n\t\t\t\t</Link>\n\t\t\t))}\n\t\t</div>\n\t);\n}\n",
+ "content": "\"use client\";\n\nimport { usePluginOverrides, useBasePath } from \"@btst/stack/context\";\nimport type { BlogPluginOverrides } from \"../../overrides\";\nimport { DefaultLink } from \"./defaults\";\nimport { Badge } from \"@/components/ui/badge\";\nimport { useSuspenseTags } from \"@btst/stack/plugins/blog/client/hooks\";\nimport { BLOG_LOCALIZATION } from \"../../localization\";\nimport { ChevronDown, ChevronUp } from \"lucide-react\";\nimport { useState } from \"react\";\n\nconst MAX_VISIBLE_TAGS = 15;\n\nexport function TagsList() {\n\tconst { tags } = useSuspenseTags();\n\tconst { Link, localization } = usePluginOverrides<\n\t\tBlogPluginOverrides,\n\t\tPartial<BlogPluginOverrides>\n\t>(\"blog\", {\n\t\tLink: DefaultLink,\n\t\tlocalization: BLOG_LOCALIZATION,\n\t});\n\tconst basePath = useBasePath();\n\tconst [showAll, setShowAll] = useState(false);\n\n\tif (!tags || tags.length === 0) {\n\t\treturn null;\n\t}\n\n\tconst hasMore = tags.length > MAX_VISIBLE_TAGS;\n\tconst visibleTags =\n\t\tshowAll || !hasMore ? tags : tags.slice(0, MAX_VISIBLE_TAGS);\n\n\treturn (\n\t\t<div className=\"flex flex-wrap gap-2 justify-center\">\n\t\t\t{visibleTags.map((tag) => (\n\t\t\t\t<Link key={tag.id} href={`${basePath}/blog/tag/${tag.slug}`}>\n\t\t\t\t\t<Badge variant=\"secondary\" className=\"text-xs\">\n\t\t\t\t\t\t{tag.name}\n\t\t\t\t\t</Badge>\n\t\t\t\t</Link>\n\t\t\t))}\n\t\t\t{hasMore && (\n\t\t\t\t<Badge asChild variant=\"secondary\" className=\"text-xs cursor-pointer\">\n\t\t\t\t\t<button\n\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\tonClick={() => setShowAll((prev) => !prev)}\n\t\t\t\t\t\taria-expanded={showAll}\n\t\t\t\t\t\taria-label={\n\t\t\t\t\t\t\tshowAll\n\t\t\t\t\t\t\t\t? localization.BLOG_TAGS_SHOW_LESS\n\t\t\t\t\t\t\t\t: localization.BLOG_TAGS_SHOW_ALL\n\t\t\t\t\t\t}\n\t\t\t\t\t\ttitle={\n\t\t\t\t\t\t\tshowAll\n\t\t\t\t\t\t\t\t? localization.BLOG_TAGS_SHOW_LESS\n\t\t\t\t\t\t\t\t: localization.BLOG_TAGS_SHOW_ALL\n\t\t\t\t\t\t}\n\t\t\t\t\t>\n\t\t\t\t\t\t{showAll ? (\n\t\t\t\t\t\t\t<ChevronUp aria-hidden=\"true\" />\n\t\t\t\t\t\t) : (\n\t\t\t\t\t\t\t<ChevronDown aria-hidden=\"true\" />\n\t\t\t\t\t\t)}\n\t\t\t\t\t</button>\n\t\t\t\t</Badge>\n\t\t\t)}\n\t\t</div>\n\t);\n}\n",
"target": "src/components/btst/blog/client/components/shared/tags-list.tsx"
},
{
@@ -322,7 +322,7 @@
{
"path": "btst/blog/client/localization/blog-common.ts",
"type": "registry:lib",
- "content": "export const BLOG_COMMON = {\n\tBLOG_GENERIC_ERROR_TITLE: \"Something went wrong\",\n\tBLOG_GENERIC_ERROR_MESSAGE: \"An unexpected error occurred.\",\n\tBLOG_PAGE_NOT_FOUND_TITLE: \"Not Found\",\n\tBLOG_PAGE_NOT_FOUND_DESCRIPTION:\n\t\t\"The page you are looking for does not exist.\",\n};\n",
+ "content": "export const BLOG_COMMON = {\n\tBLOG_GENERIC_ERROR_TITLE: \"Something went wrong\",\n\tBLOG_GENERIC_ERROR_MESSAGE: \"An unexpected error occurred.\",\n\tBLOG_PAGE_NOT_FOUND_TITLE: \"Not Found\",\n\tBLOG_PAGE_NOT_FOUND_DESCRIPTION:\n\t\t\"The page you are looking for does not exist.\",\n\tBLOG_TAGS_SHOW_ALL: \"Show all tags\",\n\tBLOG_TAGS_SHOW_LESS: \"Show fewer tags\",\n};\n",
"target": "src/components/btst/blog/client/localization/blog-common.ts"
},
{
diff --git a/packages/stack/src/plugins/blog/client/components/pages/post-page.internal.tsx b/packages/stack/src/plugins/blog/client/components/pages/post-page.internal.tsx
--- a/packages/stack/src/plugins/blog/client/components/pages/post-page.internal.tsx
+++ b/packages/stack/src/plugins/blog/client/components/pages/post-page.internal.tsx
@@ -1,6 +1,6 @@
"use client";
-import { usePluginOverrides, useBasePath } from "@btst/stack/context";
+import { usePluginOverrides } from "@btst/stack/context";
import { formatDate } from "date-fns";
import {
useSuspensePost,
@@ -12,11 +12,10 @@
import { PageHeader } from "../shared/page-header";
import { PageWrapper } from "../shared/page-wrapper";
import type { BlogPluginOverrides } from "../../overrides";
-import { DefaultImage, DefaultLink } from "../shared/defaults";
+import { DefaultImage } from "../shared/defaults";
import { BLOG_LOCALIZATION } from "../../localization";
import { PostNavigation } from "../shared/post-navigation";
import { RecentPostsCarousel } from "../shared/recent-posts-carousel";
-import { Badge } from "@workspace/ui/components/badge";
import { useRouteLifecycle } from "@workspace/ui/hooks/use-route-lifecycle";
import { OnThisPage, OnThisPageSelect } from "../shared/on-this-page";
import type { SerializedPost } from "../../../types";
@@ -24,6 +23,7 @@
import { WhenVisible } from "@workspace/ui/components/when-visible";
import { PostNavigationSkeleton } from "../loading/post-navigation-skeleton";
import { RecentPostsCarouselSkeleton } from "../loading/recent-posts-carousel-skeleton";
+import { CollapsibleTagList } from "../shared/collapsible-tag-list";
// Internal component with actual page content
export function PostPage({ slug }: { slug: string }) {
@@ -151,29 +151,12 @@
}
function PostHeaderTop({ post }: { post: SerializedPost }) {
- const { Link } = usePluginOverrides<
- BlogPluginOverrides,
- Partial<BlogPluginOverrides>
- >("blog", {
- Link: DefaultLink,
- });
- const basePath = useBasePath();
return (
<div className="flex flex-row items-center justify-center gap-2 flex-wrap mt-8">
<span className="font-light text-muted-foreground text-sm">
{formatDate(post.createdAt, "MMMM d, yyyy")}
</span>
- {post.tags && post.tags.length > 0 && (
- <>
- {post.tags.map((tag) => (
- <Link key={tag.id} href={`${basePath}/blog/tag/${tag.slug}`}>
- <Badge variant="secondary" className="text-xs">
- {tag.name}
- </Badge>
- </Link>
- ))}
- </>
- )}
+ <CollapsibleTagList tags={post.tags ?? []} />
</div>
);
}
diff --git a/packages/stack/src/plugins/blog/client/components/shared/collapsible-tag-list.tsx b/packages/stack/src/plugins/blog/client/components/shared/collapsible-tag-list.tsx
new file mode 100644
--- /dev/null
+++ b/packages/stack/src/plugins/blog/client/components/shared/collapsible-tag-list.tsx
@@ -1,0 +1,76 @@
+"use client";
+
+import { usePluginOverrides, useBasePath } from "@btst/stack/context";
+import type { BlogPluginOverrides } from "../../overrides";
+import { DefaultLink } from "./defaults";
+import { Badge } from "@workspace/ui/components/badge";
+import { BLOG_LOCALIZATION } from "../../localization";
+import { ChevronDown, ChevronUp } from "lucide-react";
+import { useState } from "react";
+import type { SerializedTag } from "../../../types";
+
+const MAX_VISIBLE_TAGS = 15;
+
+interface CollapsibleTagListProps {
+ tags: SerializedTag[];
+ maxVisible?: number;
+}
+
+export function CollapsibleTagList({
+ tags,
+ maxVisible = MAX_VISIBLE_TAGS,
+}: CollapsibleTagListProps) {
+ const { Link, localization } = usePluginOverrides<
+ BlogPluginOverrides,
+ Partial<BlogPluginOverrides>
+ >("blog", {
+ Link: DefaultLink,
+ localization: BLOG_LOCALIZATION,
+ });
+ const basePath = useBasePath();
+ const [showAll, setShowAll] = useState(false);
+
+ if (!tags || tags.length === 0) {
+ return null;
+ }
+
+ const hasMore = tags.length > maxVisible;
+ const visibleTags = showAll || !hasMore ? tags : tags.slice(0, maxVisible);
+
+ return (
+ <>
+ {visibleTags.map((tag) => (
+ <Link key={tag.id} href={`${basePath}/blog/tag/${tag.slug}`}>
+ <Badge variant="secondary" className="text-xs">
+ {tag.name}
+ </Badge>
+ </Link>
+ ))}
+ {hasMore && (
+ <Badge asChild variant="secondary" className="text-xs cursor-pointer">
+ <button
+ type="button"
+ onClick={() => setShowAll((prev) => !prev)}
+ aria-expanded={showAll}
+ aria-label={
+ showAll
+ ? localization.BLOG_TAGS_SHOW_LESS
+ : localization.BLOG_TAGS_SHOW_ALL
+ }
+ title={
+ showAll
+ ? localization.BLOG_TAGS_SHOW_LESS
+ : localization.BLOG_TAGS_SHOW_ALL
+ }
+ >
+ {showAll ? (
+ <ChevronUp aria-hidden="true" />
+ ) : (
+ <ChevronDown aria-hidden="true" />
+ )}
+ </button>
+ </Badge>
+ )}
+ </>
+ );
+}
diff --git a/packages/stack/src/plugins/blog/client/components/shared/tags-list.tsx b/packages/stack/src/plugins/blog/client/components/shared/tags-list.tsx
--- a/packages/stack/src/plugins/blog/client/components/shared/tags-list.tsx
+++ b/packages/stack/src/plugins/blog/client/components/shared/tags-list.tsx
@@ -1,20 +1,10 @@
"use client";
-import { usePluginOverrides, useBasePath } from "@btst/stack/context";
-import type { BlogPluginOverrides } from "../../overrides";
-import { DefaultLink } from "./defaults";
-import { Badge } from "@workspace/ui/components/badge";
import { useSuspenseTags } from "../../hooks/blog-hooks";
+import { CollapsibleTagList } from "./collapsible-tag-list";
export function TagsList() {
const { tags } = useSuspenseTags();
- const { Link } = usePluginOverrides<
- BlogPluginOverrides,
- Partial<BlogPluginOverrides>
- >("blog", {
- Link: DefaultLink,
- });
- const basePath = useBasePath();
if (!tags || tags.length === 0) {
return null;
@@ -22,13 +12,7 @@
return (
<div className="flex flex-wrap gap-2 justify-center">
- {tags.map((tag) => (
- <Link key={tag.id} href={`${basePath}/blog/tag/${tag.slug}`}>
- <Badge variant="secondary" className="text-xs">
- {tag.name}
- </Badge>
- </Link>
- ))}
+ <CollapsibleTagList tags={tags} />
</div>
);
}
diff --git a/packages/stack/src/plugins/blog/client/localization/blog-common.ts b/packages/stack/src/plugins/blog/client/localization/blog-common.ts
--- a/packages/stack/src/plugins/blog/client/localization/blog-common.ts
+++ b/packages/stack/src/plugins/blog/client/localization/blog-common.ts
@@ -4,4 +4,6 @@
BLOG_PAGE_NOT_FOUND_TITLE: "Not Found",
BLOG_PAGE_NOT_FOUND_DESCRIPTION:
"The page you are looking for does not exist.",
+ BLOG_TAGS_SHOW_ALL: "Show all tags",
+ BLOG_TAGS_SHOW_LESS: "Show fewer tags",
};You can send follow-ups to the cloud agent here.
Reviewed by Cursor Bugbot for commit 565cfa3. Configure here.
|
✅ Shadcn registry updated — registry JSON files were rebuilt and committed to this branch. |
…g expand/collapse logic Co-authored-by: Ollie <olliethedev@users.noreply.github.com>



Summary
Type of change
Checklist
pnpm buildpassespnpm typecheckpassespnpm lintpassesdocs/content/docs/) if consumer-facing types or behavior changedScreenshots
Note
Low Risk
Low risk UI-only change that adds client-side collapse/expand behavior for long tag lists; minimal impact beyond blog rendering and localization strings.
Overview
Improves blog tag rendering for posts and the global tags list by introducing a reusable
CollapsibleTagListthat shows up to 15 tags by default and adds a show all / show fewer toggle.Adds new localization keys (
BLOG_TAGS_SHOW_ALL,BLOG_TAGS_SHOW_LESS) for the toggle’s accessible labels/tooltips, and bumps@btst/stackversion to2.11.8.Reviewed by Cursor Bugbot for commit e7c3052. Bugbot is set up for automated code reviews on this repo. Configure here.