Skip to content

fix: blog tag list when large number of tags#121

Merged
olliethedev merged 5 commits intomainfrom
fix/blog-tag-ui
Apr 20, 2026
Merged

fix: blog tag list when large number of tags#121
olliethedev merged 5 commits intomainfrom
fix/blog-tag-ui

Conversation

@olliethedev
Copy link
Copy Markdown
Collaborator

@olliethedev olliethedev commented Apr 20, 2026

Summary

  • blog tag list now has collapse/expand button for large number of tags

Type of change

  • Bug fix
  • New plugin
  • Feature / enhancement to an existing plugin
  • Documentation
  • Chore / refactor / tooling

Checklist

  • pnpm build passes
  • pnpm typecheck passes
  • pnpm lint passes
  • Tests added or updated (unit and/or E2E)
  • Docs updated (docs/content/docs/) if consumer-facing types or behavior changed
  • All three codegen-projects create successfully and pass E2E tests
  • New plugin: submission checklist in CONTRIBUTING.md completed

Screenshots


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 CollapsibleTagList that 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/stack version to 2.11.8.

Reviewed by Cursor Bugbot for commit e7c3052. Bugbot is set up for automated code reviews on this repo. Configure here.

@vercel
Copy link
Copy Markdown

vercel Bot commented Apr 20, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
better-stack-docs Ready Ready Preview, Comment Apr 20, 2026 6:05pm
better-stack-playground Ready Ready Preview, Comment Apr 20, 2026 6:05pm

Request Review

Copy link
Copy Markdown

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Open in Web View Automation 

Sent by Cursor Automation: Find vulnerabilities

Copy link
Copy Markdown

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

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 CollapsibleTagList component in collapsible-tag-list.tsx, then updated both TagsList and PostHeaderTop to use it, eliminating ~55 lines of duplication.
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.

Comment thread packages/stack/src/plugins/blog/client/components/shared/tags-list.tsx Outdated
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 20, 2026

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>
@olliethedev olliethedev merged commit 28e6673 into main Apr 20, 2026
3 checks passed
@olliethedev olliethedev deleted the fix/blog-tag-ui branch April 20, 2026 18:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants