diff --git a/packages/stack/package.json b/packages/stack/package.json index 70b0b5362..7f75e62ac 100644 --- a/packages/stack/package.json +++ b/packages/stack/package.json @@ -1,6 +1,6 @@ { "name": "@btst/stack", - "version": "3.0.0", + "version": "3.0.1", "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 index 4404e1d08..1454e2a34 100644 --- a/packages/stack/registry/btst-blog.json +++ b/packages/stack/registry/btst-blog.json @@ -196,7 +196,7 @@ { "path": "btst/blog/client/components/pages/post-page.internal.tsx", "type": "registry:component", - "content": "\"use client\";\n\nimport {\n\tusePluginOverrides,\n\tuseStack,\n\tuseTranslate,\n} 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 { BLOG_PLUGIN_ID } from \"../../constants\";\nimport { DefaultImage } from \"../shared/defaults\";\nimport { PostNavigation } from \"../shared/post-navigation\";\nimport { RecentPostsCarousel } from \"../shared/recent-posts-carousel\";\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 { CollapsibleTagList } from \"../shared/collapsible-tag-list\";\n\n// Internal component with actual page content\nexport function PostPage({ slug }: { slug: string }) {\n\tconst t = useTranslate();\n\tconst overrides = usePluginOverrides(BLOG_PLUGIN_ID);\n\tconst { localization } = overrides;\n\tconst { router } = useStack();\n\tconst Image = router?.Image ?? DefaultImage;\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});\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\n\t\t\t\t\n\t\t\t\n\t\t);\n\t}\n\n\treturn (\n\t\t\n\t\t\t
\n\t\t\t\t\n\t\t\n\t);\n}\n\nfunction PostHeaderTop({ post }: { post: SerializedPost }) {\n\treturn (\n\t\t
\n\t\t\t\n\t\t\t\t{formatDate(post.createdAt, \"MMMM d, yyyy\")}\n\t\t\t\n\t\t\t\n\t\t
\n\t);\n}\n", + "content": "\"use client\";\n\nimport {\n\tusePluginOverrides,\n\tuseStack,\n\tuseTranslate,\n} from \"@btst/stack/context\";\nimport { formatPostDate } from \"../shared/format-post-date\";\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 { BLOG_PLUGIN_ID } from \"../../constants\";\nimport { DefaultImage } from \"../shared/defaults\";\nimport { PostNavigation } from \"../shared/post-navigation\";\nimport { RecentPostsCarousel } from \"../shared/recent-posts-carousel\";\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 { CollapsibleTagList } from \"../shared/collapsible-tag-list\";\n\n// Internal component with actual page content\nexport function PostPage({ slug }: { slug: string }) {\n\tconst t = useTranslate();\n\tconst overrides = usePluginOverrides(BLOG_PLUGIN_ID);\n\tconst { localization } = overrides;\n\tconst { router } = useStack();\n\tconst Image = router?.Image ?? DefaultImage;\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});\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\n\t\t\t\t\n\t\t\t\n\t\t);\n\t}\n\n\treturn (\n\t\t\n\t\t\t
\n\t\t\t\t\n\t\t\n\t);\n}\n\nfunction PostHeaderTop({ post }: { post: SerializedPost }) {\n\treturn (\n\t\t
\n\t\t\t\n\t\t\t\t{formatPostDate(post.createdAt)}\n\t\t\t\n\t\t\t\n\t\t
\n\t);\n}\n", "target": "src/components/btst/blog/client/components/pages/post-page.internal.tsx" }, { @@ -247,6 +247,12 @@ "content": "import { CloudAlert } from \"lucide-react\";\nimport { PageHeader } from \"./page-header\";\n\nexport function ErrorPlaceholder({\n\ttitle,\n\tmessage,\n}: {\n\ttitle: string;\n\tmessage: string;\n}) {\n\treturn (\n\t\t\n\t\t\t\n\t\t\t\n\t\t
\n\t);\n}\n", "target": "src/components/btst/blog/client/components/shared/error-placeholder.tsx" }, + { + "path": "btst/blog/client/components/shared/format-post-date.ts", + "type": "registry:lib", + "content": "// Public article dates must render identically during SSR and browser hydration.\n// Keep the existing English date style, with an explicit publication timezone.\nconst formatter = new Intl.DateTimeFormat(\"en-US\", {\n\tmonth: \"long\",\n\tday: \"numeric\",\n\tyear: \"numeric\",\n\ttimeZone: \"UTC\",\n});\n\nexport function formatPostDate(date: string) {\n\treturn formatter.format(new Date(date));\n}\n", + "target": "src/components/btst/blog/client/components/shared/format-post-date.ts" + }, { "path": "btst/blog/client/components/shared/highlight-text.tsx", "type": "registry:component", @@ -286,7 +292,7 @@ { "path": "btst/blog/client/components/shared/post-card.tsx", "type": "registry:component", - "content": "\"use client\";\nimport { Badge } from \"@/components/ui/badge\";\nimport {\n\tCard,\n\tCardContent,\n\tCardHeader,\n\tCardTitle,\n} from \"@/components/ui/card\";\nimport {\n\tuseBasePath,\n\tusePluginOverrides,\n\tuseStack,\n\tuseTranslate,\n} from \"@btst/stack/context\";\nimport { formatDate } from \"date-fns\";\nimport type { SerializedPost } from \"../../../types\";\nimport { CalendarIcon } from \"lucide-react\";\nimport type { BlogPluginOverrides } from \"../../overrides\";\nimport { BLOG_PLUGIN_ID } from \"../../constants\";\nimport { DefaultLink, DefaultImage } from \"./defaults\";\n\n// Beautiful gradient color combinations\nconst GRADIENT_PALETTES = [\n\t{\n\t\tfrom: \"from-purple-500\",\n\t\tvia: \"via-pink-500\",\n\t\tto: \"to-orange-500\",\n\t\toverlay: \"from-blue-600/50\",\n\t},\n\t{\n\t\tfrom: \"from-blue-500\",\n\t\tvia: \"via-cyan-500\",\n\t\tto: \"to-teal-500\",\n\t\toverlay: \"from-indigo-600/50\",\n\t},\n\t{\n\t\tfrom: \"from-pink-500\",\n\t\tvia: \"via-rose-500\",\n\t\tto: \"to-orange-500\",\n\t\toverlay: \"from-purple-600/50\",\n\t},\n\t{\n\t\tfrom: \"from-green-500\",\n\t\tvia: \"via-emerald-500\",\n\t\tto: \"to-teal-500\",\n\t\toverlay: \"from-cyan-600/50\",\n\t},\n\t{\n\t\tfrom: \"from-indigo-500\",\n\t\tvia: \"via-purple-500\",\n\t\tto: \"to-pink-500\",\n\t\toverlay: \"from-violet-600/50\",\n\t},\n\t{\n\t\tfrom: \"from-orange-500\",\n\t\tvia: \"via-amber-500\",\n\t\tto: \"to-yellow-500\",\n\t\toverlay: \"from-orange-600/50\",\n\t},\n\t{\n\t\tfrom: \"from-cyan-500\",\n\t\tvia: \"via-blue-500\",\n\t\tto: \"to-indigo-500\",\n\t\toverlay: \"from-purple-600/50\",\n\t},\n\t{\n\t\tfrom: \"from-violet-500\",\n\t\tvia: \"via-purple-500\",\n\t\tto: \"to-fuchsia-500\",\n\t\toverlay: \"from-pink-600/50\",\n\t},\n\t{\n\t\tfrom: \"from-emerald-500\",\n\t\tvia: \"via-green-500\",\n\t\tto: \"to-lime-500\",\n\t\toverlay: \"from-teal-600/50\",\n\t},\n\t{\n\t\tfrom: \"from-rose-500\",\n\t\tvia: \"via-pink-500\",\n\t\tto: \"to-fuchsia-500\",\n\t\toverlay: \"from-purple-600/50\",\n\t},\n\t{\n\t\tfrom: \"from-sky-500\",\n\t\tvia: \"via-blue-500\",\n\t\tto: \"to-indigo-600\",\n\t\toverlay: \"from-purple-700/40\",\n\t},\n\t{\n\t\tfrom: \"from-fuchsia-600\",\n\t\tvia: \"via-purple-600\",\n\t\tto: \"to-indigo-600\",\n\t\toverlay: \"from-pink-700/40\",\n\t},\n\t{\n\t\tfrom: \"from-teal-500\",\n\t\tvia: \"via-emerald-500\",\n\t\tto: \"to-green-600\",\n\t\toverlay: \"from-cyan-700/40\",\n\t},\n\t{\n\t\tfrom: \"from-amber-500\",\n\t\tvia: \"via-orange-500\",\n\t\tto: \"to-pink-500\",\n\t\toverlay: \"from-yellow-600/40\",\n\t},\n\t{\n\t\tfrom: \"from-indigo-600\",\n\t\tvia: \"via-blue-600\",\n\t\tto: \"to-purple-600\",\n\t\toverlay: \"from-violet-700/40\",\n\t},\n\t{\n\t\tfrom: \"from-pink-600\",\n\t\tvia: \"via-rose-600\",\n\t\tto: \"to-orange-600\",\n\t\toverlay: \"from-fuchsia-700/40\",\n\t},\n\t{\n\t\tfrom: \"from-cyan-600\",\n\t\tvia: \"via-teal-600\",\n\t\tto: \"to-emerald-600\",\n\t\toverlay: \"from-blue-700/40\",\n\t},\n\t{\n\t\tfrom: \"from-purple-600\",\n\t\tvia: \"via-fuchsia-600\",\n\t\tto: \"to-pink-600\",\n\t\toverlay: \"from-violet-700/40\",\n\t},\n\t{\n\t\tfrom: \"from-blue-600\",\n\t\tvia: \"via-indigo-600\",\n\t\tto: \"to-purple-600\",\n\t\toverlay: \"from-cyan-700/40\",\n\t},\n\t{\n\t\tfrom: \"from-green-600\",\n\t\tvia: \"via-emerald-600\",\n\t\tto: \"to-teal-600\",\n\t\toverlay: \"from-lime-700/40\",\n\t},\n\t{\n\t\tfrom: \"from-rose-600\",\n\t\tvia: \"via-pink-600\",\n\t\tto: \"to-fuchsia-600\",\n\t\toverlay: \"from-pink-700/40\",\n\t},\n\t{\n\t\tfrom: \"from-orange-600\",\n\t\tvia: \"via-pink-600\",\n\t\tto: \"to-rose-600\",\n\t\toverlay: \"from-amber-700/40\",\n\t},\n\t{\n\t\tfrom: \"from-violet-600\",\n\t\tvia: \"via-purple-600\",\n\t\tto: \"to-fuchsia-600\",\n\t\toverlay: \"from-indigo-700/40\",\n\t},\n\t{\n\t\tfrom: \"from-teal-600\",\n\t\tvia: \"via-cyan-600\",\n\t\tto: \"to-blue-600\",\n\t\toverlay: \"from-emerald-700/40\",\n\t},\n\t{\n\t\tfrom: \"from-emerald-600\",\n\t\tvia: \"via-teal-600\",\n\t\tto: \"to-cyan-600\",\n\t\toverlay: \"from-green-700/40\",\n\t},\n\t{\n\t\tfrom: \"from-indigo-700\",\n\t\tvia: \"via-purple-700\",\n\t\tto: \"to-pink-700\",\n\t\toverlay: \"from-violet-800/30\",\n\t},\n\t{\n\t\tfrom: \"from-blue-700\",\n\t\tvia: \"via-cyan-700\",\n\t\tto: \"to-teal-700\",\n\t\toverlay: \"from-indigo-800/30\",\n\t},\n\t{\n\t\tfrom: \"from-purple-700\",\n\t\tvia: \"via-fuchsia-700\",\n\t\tto: \"to-pink-700\",\n\t\toverlay: \"from-violet-800/30\",\n\t},\n\t{\n\t\tfrom: \"from-pink-700\",\n\t\tvia: \"via-rose-700\",\n\t\tto: \"to-orange-700\",\n\t\toverlay: \"from-fuchsia-800/30\",\n\t},\n\t{\n\t\tfrom: \"from-cyan-700\",\n\t\tvia: \"via-blue-700\",\n\t\tto: \"to-indigo-700\",\n\t\toverlay: \"from-teal-800/30\",\n\t},\n] as const;\n\n// Simple deterministic hash function\nfunction hashString(str: string): number {\n\tlet hash = 0;\n\tfor (let i = 0; i < str.length; i++) {\n\t\tconst char = str.charCodeAt(i);\n\t\thash = (hash << 5) - hash + char;\n\t\thash = hash & hash; // Convert to 32-bit integer\n\t}\n\treturn Math.abs(hash);\n}\n\nfunction getGradientFromTitle(title: string) {\n\tconst hash = hashString(title);\n\tconst palette = GRADIENT_PALETTES[hash % GRADIENT_PALETTES.length];\n\treturn palette;\n}\n\nexport function PostCard({ post }: { post: SerializedPost }) {\n\tconst t = useTranslate();\n\tconst { localization } = usePluginOverrides<\n\t\tBlogPluginOverrides,\n\t\tPartial\n\t>(BLOG_PLUGIN_ID, {});\n\tconst { router } = useStack();\n\tconst Link = router?.Link ?? DefaultLink;\n\tconst Image = router?.Image ?? DefaultImage;\n\tconst basePath = useBasePath();\n\tconst blogPath = `${basePath}/blog/${post.slug}`;\n\tconst postDate = formatDate(\n\t\tpost.publishedAt || post.createdAt,\n\t\t\"MMMM d, yyyy\",\n\t);\n\tconst gradient = post.image ? null : getGradientFromTitle(post.title);\n\n\treturn (\n\t\t\n\t\t\t{/* Image or Placeholder */}\n\t\t\t\n\t\t\t\t{post.image ? (\n\t\t\t\t\t\n\t\t\t\t) : (\n\t\t\t\t\t
\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t
\n\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t\t{post.title}\n\t\t\t\t\t\t\t

\n\t\t\t\t\t\t
\n\t\t\t\t\t
\n\t\t\t\t)}\n\t\t\t\n\n\t\t\t{!post.published && (\n\t\t\t\t\n\t\t\t\t\t{localization?.BLOG_CARD_DRAFT_BADGE ??\n\t\t\t\t\t\tt(\"blog.card.draftBadge\", \"Draft\")}\n\t\t\t\t\n\t\t\t)}\n\n\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t{post.title}\n\t\t\t\t\t\n\t\t\t\t\n\n\t\t\t\t\n\t\t\t\t\t
\n\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t
\n\t\t\t\t\t\t{post.tags &&\n\t\t\t\t\t\t\tpost.tags.length > 0 &&\n\t\t\t\t\t\t\tpost.tags.map((tag) => (\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t{tag.name}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t))}\n\t\t\t\t\t
\n\t\t\t\t
\n\t\t\t\n\t\t
\n\t);\n}\n", + "content": "\"use client\";\nimport { Badge } from \"@/components/ui/badge\";\nimport {\n\tCard,\n\tCardContent,\n\tCardHeader,\n\tCardTitle,\n} from \"@/components/ui/card\";\nimport {\n\tuseBasePath,\n\tusePluginOverrides,\n\tuseStack,\n\tuseTranslate,\n} from \"@btst/stack/context\";\nimport { formatPostDate } from \"./format-post-date\";\nimport type { SerializedPost } from \"../../../types\";\nimport { CalendarIcon } from \"lucide-react\";\nimport type { BlogPluginOverrides } from \"../../overrides\";\nimport { BLOG_PLUGIN_ID } from \"../../constants\";\nimport { DefaultLink, DefaultImage } from \"./defaults\";\n\n// Beautiful gradient color combinations\nconst GRADIENT_PALETTES = [\n\t{\n\t\tfrom: \"from-purple-500\",\n\t\tvia: \"via-pink-500\",\n\t\tto: \"to-orange-500\",\n\t\toverlay: \"from-blue-600/50\",\n\t},\n\t{\n\t\tfrom: \"from-blue-500\",\n\t\tvia: \"via-cyan-500\",\n\t\tto: \"to-teal-500\",\n\t\toverlay: \"from-indigo-600/50\",\n\t},\n\t{\n\t\tfrom: \"from-pink-500\",\n\t\tvia: \"via-rose-500\",\n\t\tto: \"to-orange-500\",\n\t\toverlay: \"from-purple-600/50\",\n\t},\n\t{\n\t\tfrom: \"from-green-500\",\n\t\tvia: \"via-emerald-500\",\n\t\tto: \"to-teal-500\",\n\t\toverlay: \"from-cyan-600/50\",\n\t},\n\t{\n\t\tfrom: \"from-indigo-500\",\n\t\tvia: \"via-purple-500\",\n\t\tto: \"to-pink-500\",\n\t\toverlay: \"from-violet-600/50\",\n\t},\n\t{\n\t\tfrom: \"from-orange-500\",\n\t\tvia: \"via-amber-500\",\n\t\tto: \"to-yellow-500\",\n\t\toverlay: \"from-orange-600/50\",\n\t},\n\t{\n\t\tfrom: \"from-cyan-500\",\n\t\tvia: \"via-blue-500\",\n\t\tto: \"to-indigo-500\",\n\t\toverlay: \"from-purple-600/50\",\n\t},\n\t{\n\t\tfrom: \"from-violet-500\",\n\t\tvia: \"via-purple-500\",\n\t\tto: \"to-fuchsia-500\",\n\t\toverlay: \"from-pink-600/50\",\n\t},\n\t{\n\t\tfrom: \"from-emerald-500\",\n\t\tvia: \"via-green-500\",\n\t\tto: \"to-lime-500\",\n\t\toverlay: \"from-teal-600/50\",\n\t},\n\t{\n\t\tfrom: \"from-rose-500\",\n\t\tvia: \"via-pink-500\",\n\t\tto: \"to-fuchsia-500\",\n\t\toverlay: \"from-purple-600/50\",\n\t},\n\t{\n\t\tfrom: \"from-sky-500\",\n\t\tvia: \"via-blue-500\",\n\t\tto: \"to-indigo-600\",\n\t\toverlay: \"from-purple-700/40\",\n\t},\n\t{\n\t\tfrom: \"from-fuchsia-600\",\n\t\tvia: \"via-purple-600\",\n\t\tto: \"to-indigo-600\",\n\t\toverlay: \"from-pink-700/40\",\n\t},\n\t{\n\t\tfrom: \"from-teal-500\",\n\t\tvia: \"via-emerald-500\",\n\t\tto: \"to-green-600\",\n\t\toverlay: \"from-cyan-700/40\",\n\t},\n\t{\n\t\tfrom: \"from-amber-500\",\n\t\tvia: \"via-orange-500\",\n\t\tto: \"to-pink-500\",\n\t\toverlay: \"from-yellow-600/40\",\n\t},\n\t{\n\t\tfrom: \"from-indigo-600\",\n\t\tvia: \"via-blue-600\",\n\t\tto: \"to-purple-600\",\n\t\toverlay: \"from-violet-700/40\",\n\t},\n\t{\n\t\tfrom: \"from-pink-600\",\n\t\tvia: \"via-rose-600\",\n\t\tto: \"to-orange-600\",\n\t\toverlay: \"from-fuchsia-700/40\",\n\t},\n\t{\n\t\tfrom: \"from-cyan-600\",\n\t\tvia: \"via-teal-600\",\n\t\tto: \"to-emerald-600\",\n\t\toverlay: \"from-blue-700/40\",\n\t},\n\t{\n\t\tfrom: \"from-purple-600\",\n\t\tvia: \"via-fuchsia-600\",\n\t\tto: \"to-pink-600\",\n\t\toverlay: \"from-violet-700/40\",\n\t},\n\t{\n\t\tfrom: \"from-blue-600\",\n\t\tvia: \"via-indigo-600\",\n\t\tto: \"to-purple-600\",\n\t\toverlay: \"from-cyan-700/40\",\n\t},\n\t{\n\t\tfrom: \"from-green-600\",\n\t\tvia: \"via-emerald-600\",\n\t\tto: \"to-teal-600\",\n\t\toverlay: \"from-lime-700/40\",\n\t},\n\t{\n\t\tfrom: \"from-rose-600\",\n\t\tvia: \"via-pink-600\",\n\t\tto: \"to-fuchsia-600\",\n\t\toverlay: \"from-pink-700/40\",\n\t},\n\t{\n\t\tfrom: \"from-orange-600\",\n\t\tvia: \"via-pink-600\",\n\t\tto: \"to-rose-600\",\n\t\toverlay: \"from-amber-700/40\",\n\t},\n\t{\n\t\tfrom: \"from-violet-600\",\n\t\tvia: \"via-purple-600\",\n\t\tto: \"to-fuchsia-600\",\n\t\toverlay: \"from-indigo-700/40\",\n\t},\n\t{\n\t\tfrom: \"from-teal-600\",\n\t\tvia: \"via-cyan-600\",\n\t\tto: \"to-blue-600\",\n\t\toverlay: \"from-emerald-700/40\",\n\t},\n\t{\n\t\tfrom: \"from-emerald-600\",\n\t\tvia: \"via-teal-600\",\n\t\tto: \"to-cyan-600\",\n\t\toverlay: \"from-green-700/40\",\n\t},\n\t{\n\t\tfrom: \"from-indigo-700\",\n\t\tvia: \"via-purple-700\",\n\t\tto: \"to-pink-700\",\n\t\toverlay: \"from-violet-800/30\",\n\t},\n\t{\n\t\tfrom: \"from-blue-700\",\n\t\tvia: \"via-cyan-700\",\n\t\tto: \"to-teal-700\",\n\t\toverlay: \"from-indigo-800/30\",\n\t},\n\t{\n\t\tfrom: \"from-purple-700\",\n\t\tvia: \"via-fuchsia-700\",\n\t\tto: \"to-pink-700\",\n\t\toverlay: \"from-violet-800/30\",\n\t},\n\t{\n\t\tfrom: \"from-pink-700\",\n\t\tvia: \"via-rose-700\",\n\t\tto: \"to-orange-700\",\n\t\toverlay: \"from-fuchsia-800/30\",\n\t},\n\t{\n\t\tfrom: \"from-cyan-700\",\n\t\tvia: \"via-blue-700\",\n\t\tto: \"to-indigo-700\",\n\t\toverlay: \"from-teal-800/30\",\n\t},\n] as const;\n\n// Simple deterministic hash function\nfunction hashString(str: string): number {\n\tlet hash = 0;\n\tfor (let i = 0; i < str.length; i++) {\n\t\tconst char = str.charCodeAt(i);\n\t\thash = (hash << 5) - hash + char;\n\t\thash = hash & hash; // Convert to 32-bit integer\n\t}\n\treturn Math.abs(hash);\n}\n\nfunction getGradientFromTitle(title: string) {\n\tconst hash = hashString(title);\n\tconst palette = GRADIENT_PALETTES[hash % GRADIENT_PALETTES.length];\n\treturn palette;\n}\n\nexport function PostCard({ post }: { post: SerializedPost }) {\n\tconst t = useTranslate();\n\tconst { localization } = usePluginOverrides<\n\t\tBlogPluginOverrides,\n\t\tPartial\n\t>(BLOG_PLUGIN_ID, {});\n\tconst { router } = useStack();\n\tconst Link = router?.Link ?? DefaultLink;\n\tconst Image = router?.Image ?? DefaultImage;\n\tconst basePath = useBasePath();\n\tconst blogPath = `${basePath}/blog/${post.slug}`;\n\tconst timestamp = post.publishedAt || post.createdAt;\n\tconst postDate = formatPostDate(timestamp);\n\tconst gradient = post.image ? null : getGradientFromTitle(post.title);\n\n\treturn (\n\t\t\n\t\t\t{/* Image or Placeholder */}\n\t\t\t\n\t\t\t\t{post.image ? (\n\t\t\t\t\t\n\t\t\t\t) : (\n\t\t\t\t\t
\n\t\t\t\t\t\t\n\t\t\t\t\t\t\n\t\t\t\t\t\t
\n\t\t\t\t\t\t\t

\n\t\t\t\t\t\t\t\t{post.title}\n\t\t\t\t\t\t\t

\n\t\t\t\t\t\t
\n\t\t\t\t\t
\n\t\t\t\t)}\n\t\t\t\n\n\t\t\t{!post.published && (\n\t\t\t\t\n\t\t\t\t\t{localization?.BLOG_CARD_DRAFT_BADGE ??\n\t\t\t\t\t\tt(\"blog.card.draftBadge\", \"Draft\")}\n\t\t\t\t\n\t\t\t)}\n\n\t\t\t\n\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t{post.title}\n\t\t\t\t\t\n\t\t\t\t\n\n\t\t\t\t\n\t\t\t\t\t
\n\t\t\t\t\t\t
\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\t\t
\n\t\t\t\t\t\t{post.tags &&\n\t\t\t\t\t\t\tpost.tags.length > 0 &&\n\t\t\t\t\t\t\tpost.tags.map((tag) => (\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t{tag.name}\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t))}\n\t\t\t\t\t
\n\t\t\t\t
\n\t\t\t\n\t\t
\n\t);\n}\n", "target": "src/components/btst/blog/client/components/shared/post-card.tsx" }, { diff --git a/packages/stack/src/plugins/blog/__tests__/date-hydration.test.tsx b/packages/stack/src/plugins/blog/__tests__/date-hydration.test.tsx new file mode 100644 index 000000000..04a136913 --- /dev/null +++ b/packages/stack/src/plugins/blog/__tests__/date-hydration.test.tsx @@ -0,0 +1,78 @@ +// @vitest-environment jsdom +import { act } from "react"; +import { renderToString } from "react-dom/server"; +import { hydrateRoot } from "react-dom/client"; +import { describe, expect, it } from "vitest"; +import { StackProvider } from "@btst/stack/context"; +import { createTestClientStack } from "../../../__tests__/client-stack-test-utils"; +import { blogClientPlugin } from "../client/plugin"; +import { PostCard } from "../client/components/shared/post-card"; +import type { SerializedPost } from "../types"; + +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +const post = { + id: "date-post", + title: "Timezone regression", + content: "Article content", + excerpt: "Article excerpt", + slug: "timezone-regression", + published: true, + image: "", + tags: [], + authorId: undefined, + publishedAt: "2026-04-28T18:30:00.000Z", + createdAt: "2026-01-01T00:30:00.000Z", + updatedAt: "2026-04-28T18:30:00.000Z", +} satisfies SerializedPost; + +describe("public blog date hydration", () => { + for (const published of [true, false]) { + for (const timezone of ["America/Los_Angeles", "Pacific/Auckland"]) { + it(`hydrates a ${published ? "published post" : "draft"} in ${timezone} from UTC HTML`, async () => { + const previousTZ = process.env.TZ; + const item = { + ...post, + published, + publishedAt: published ? post.publishedAt : undefined, + }; + const timestamp = item.publishedAt || item.createdAt; + const expected = published ? "April 28, 2026" : "January 1, 2026"; + const stack = createTestClientStack({ blog: blogClientPlugin() }); + const ui = ( + + + + ); + const container = document.createElement("div"); + const errors: unknown[] = []; + let root: ReturnType | undefined; + try { + process.env.TZ = "UTC"; + const html = renderToString(ui); + container.innerHTML = html; + document.body.appendChild(container); + process.env.TZ = timezone; + // Both the card's text and machine-readable date must match across hosts. + expect(renderToString(ui)).toBe(html); + await act(async () => { + root = hydrateRoot(container, ui, { + onRecoverableError: (error) => errors.push(error), + }); + }); + expect(errors).toEqual([]); + expect(container.querySelector("time")?.textContent).toBe(expected); + expect( + container.querySelector("time")?.getAttribute("datetime"), + ).toBe(timestamp); + } finally { + if (root) await act(async () => root!.unmount()); + container.remove(); + if (previousTZ === undefined) + Reflect.deleteProperty(process.env, "TZ"); + else process.env.TZ = previousTZ; + } + }); + } + } +}); 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 index 45ecca648..ca79789ff 100644 --- 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 @@ -5,7 +5,7 @@ import { useStack, useTranslate, } from "@btst/stack/context"; -import { formatDate } from "date-fns"; +import { formatPostDate } from "../shared/format-post-date"; import { useSuspensePost, useNextPreviousPosts, @@ -157,7 +157,7 @@ function PostHeaderTop({ post }: { post: SerializedPost }) { return (
- {formatDate(post.createdAt, "MMMM d, yyyy")} + {formatPostDate(post.createdAt)}
diff --git a/packages/stack/src/plugins/blog/client/components/shared/format-post-date.ts b/packages/stack/src/plugins/blog/client/components/shared/format-post-date.ts new file mode 100644 index 000000000..11467d85f --- /dev/null +++ b/packages/stack/src/plugins/blog/client/components/shared/format-post-date.ts @@ -0,0 +1,12 @@ +// Public article dates must render identically during SSR and browser hydration. +// Keep the existing English date style, with an explicit publication timezone. +const formatter = new Intl.DateTimeFormat("en-US", { + month: "long", + day: "numeric", + year: "numeric", + timeZone: "UTC", +}); + +export function formatPostDate(date: string) { + return formatter.format(new Date(date)); +} diff --git a/packages/stack/src/plugins/blog/client/components/shared/post-card.tsx b/packages/stack/src/plugins/blog/client/components/shared/post-card.tsx index 137068d0e..8f1cb6846 100644 --- a/packages/stack/src/plugins/blog/client/components/shared/post-card.tsx +++ b/packages/stack/src/plugins/blog/client/components/shared/post-card.tsx @@ -12,7 +12,7 @@ import { useStack, useTranslate, } from "@btst/stack/context"; -import { formatDate } from "date-fns"; +import { formatPostDate } from "./format-post-date"; import type { SerializedPost } from "../../../types"; import { CalendarIcon } from "lucide-react"; import type { BlogPluginOverrides } from "../../overrides"; @@ -231,10 +231,8 @@ export function PostCard({ post }: { post: SerializedPost }) { const Image = router?.Image ?? DefaultImage; const basePath = useBasePath(); const blogPath = `${basePath}/blog/${post.slug}`; - const postDate = formatDate( - post.publishedAt || post.createdAt, - "MMMM d, yyyy", - ); + const timestamp = post.publishedAt || post.createdAt; + const postDate = formatPostDate(timestamp); const gradient = post.image ? null : getGradientFromTitle(post.title); return ( @@ -292,7 +290,7 @@ export function PostCard({ post }: { post: SerializedPost }) {
- +
{post.tags && post.tags.length > 0 &&