From 110f1808411bc396ed79875a9c8422a0bb0b8262 Mon Sep 17 00:00:00 2001 From: olliethedev <5933733+olliethedev@users.noreply.github.com> Date: Mon, 6 Jul 2026 14:21:46 +0000 Subject: [PATCH 01/10] feat(blog): replace direct sonner toasts with useNotify in image field Co-authored-by: Cursor --- .../blog/client/components/forms/image-field.tsx | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/packages/stack/src/plugins/blog/client/components/forms/image-field.tsx b/packages/stack/src/plugins/blog/client/components/forms/image-field.tsx index 8c553df8..01d341d1 100644 --- a/packages/stack/src/plugins/blog/client/components/forms/image-field.tsx +++ b/packages/stack/src/plugins/blog/client/components/forms/image-field.tsx @@ -10,7 +10,6 @@ import { Input } from "@workspace/ui/components/input"; import { usePluginOverrides } from "@btst/stack/context"; import { Loader2, Upload } from "lucide-react"; import { useRef, useState } from "react"; -import { toast } from "sonner"; import type { BlogPluginOverrides } from "../../overrides"; import { BLOG_LOCALIZATION } from "../../localization"; @@ -27,6 +26,7 @@ export function FeaturedImageField({ }) { const fileInputRef = useRef(null); const [isUploading, setIsUploading] = useState(false); + const notify = useNotify(); const { uploadImage, @@ -73,12 +73,12 @@ export function FeaturedImageField({ if (!file) return; if (!file.type.startsWith("image/")) { - toast.error(localization.BLOG_FORMS_FEATURED_IMAGE_ERROR_NOT_IMAGE); + notify.error(localization.BLOG_FORMS_FEATURED_IMAGE_ERROR_NOT_IMAGE); return; } if (file.size > 4 * 1024 * 1024) { - toast.error(localization.BLOG_FORMS_FEATURED_IMAGE_ERROR_TOO_LARGE); + notify.error(localization.BLOG_FORMS_FEATURED_IMAGE_ERROR_TOO_LARGE); return; } @@ -87,11 +87,10 @@ export function FeaturedImageField({ setFeaturedImageUploading(true); const url = await uploadImage(file); onChange(url); - toast.success(localization.BLOG_FORMS_FEATURED_IMAGE_TOAST_SUCCESS); + notify.success(localization.BLOG_FORMS_FEATURED_IMAGE_TOAST_SUCCESS); } catch (error) { - toast.error(localization.BLOG_FORMS_FEATURED_IMAGE_TOAST_FAILURE); console.error("Failed to upload image:", error); - toast.error(localization.BLOG_FORMS_FEATURED_IMAGE_TOAST_FAILURE); + notify.error(localization.BLOG_FORMS_FEATURED_IMAGE_TOAST_FAILURE); } finally { setIsUploading(false); setFeaturedImageUploading(false); From c7374223231a5270590dd66514567f99ddb96857 Mon Sep 17 00:00:00 2001 From: olliethedev <5933733+olliethedev@users.noreply.github.com> Date: Mon, 6 Jul 2026 14:35:03 +0000 Subject: [PATCH 02/10] feat(blog): convert UI strings to useTranslate with override-wins localization fallback Every user-visible string resolves as localization?.KEY ?? t("blog..", "English default"): an app-provided overrides.localization still wins byte-for-byte, otherwise the string goes through the i18n provider (or its literal English default without one). Client resolver schemas now carry translatable required-field messages; server-side schemas.ts is unchanged. Adds the i18n key reference docs page with the blog key catalog. Co-authored-by: Cursor --- docs/content/docs/i18n.mdx | 116 ++++++++++ docs/content/docs/meta.json | 1 + .../client/components/forms/image-field.tsx | 105 ++++++--- .../forms/markdown-editor-with-overrides.tsx | 14 +- .../client/components/forms/post-forms.tsx | 217 +++++++++++++----- .../components/forms/tags-multiselect.tsx | 7 +- .../blog/client/components/pages/404-page.tsx | 23 +- .../pages/edit-post-page.internal.tsx | 25 +- .../components/pages/home-page.internal.tsx | 17 +- .../pages/new-post-page.internal.tsx | 25 +- .../components/pages/post-page.internal.tsx | 15 +- .../components/pages/tag-page.internal.tsx | 39 +++- .../shared/collapsible-tag-list.tsx | 27 +-- .../components/shared/default-error.tsx | 22 +- .../client/components/shared/on-this-page.tsx | 29 ++- .../client/components/shared/post-card.tsx | 19 +- .../components/shared/post-navigation.tsx | 11 +- .../client/components/shared/posts-list.tsx | 43 ++-- .../shared/recent-posts-carousel.tsx | 15 +- .../client/components/shared/search-modal.tsx | 21 +- 20 files changed, 553 insertions(+), 238 deletions(-) create mode 100644 docs/content/docs/i18n.mdx diff --git a/docs/content/docs/i18n.mdx b/docs/content/docs/i18n.mdx new file mode 100644 index 00000000..44295b8f --- /dev/null +++ b/docs/content/docs/i18n.mdx @@ -0,0 +1,116 @@ +--- +title: i18n Keys +description: Translation key conventions and the reference catalog of plugin i18n keys +--- + +Plugins render every user-visible string through `useTranslate()` from `@btst/stack/context`: + +```tsx +const t = useTranslate(); + +t("blog.forms.titleLabel", "Title"); +t("blog.list.tagPageTitle", "{{tag}} Posts", { tag: tag.name }); +``` + +Without an `i18n` provider on `StackProvider`, `t()` returns the English default (with `{{param}}` interpolation), so apps with no provider behave exactly as before. With a provider, your `translate(key, defaultValue, params)` implementation decides what to render. + +## Key conventions + +- Keys are namespaced `..`, e.g. `blog.forms.titleLabel`. Areas group related UI (`common`, `list`, `card`, `post`, `forms`, `search`, ...). +- The second argument is always a **string literal** English default, so the key catalog can be regenerated mechanically: + +```bash +pnpm exec tsx packages/stack/scripts/extract-i18n-keys.ts +``` + +- Parameters use `{{param}}` interpolation in the default value. +- Plugins that support a legacy per-plugin `localization` override object resolve it with higher precedence than `t()`: + +```tsx +localization?.BLOG_FORMS_TITLE_LABEL ?? t("blog.forms.titleLabel", "Title"); +``` + +An app-provided `localization` override wins byte-for-byte; otherwise the string goes through the i18n provider. + +## Blog + +| Key | Default | +| --- | --- | +| `blog.card.draftBadge` | Draft | +| `blog.common.genericErrorMessage` | An unexpected error occurred. | +| `blog.common.genericErrorTitle` | Something went wrong | +| `blog.common.pageNotFoundDescription` | The page you are looking for does not exist. | +| `blog.common.pageNotFoundTitle` | Not Found | +| `blog.common.tagsShowAll` | Show all tags | +| `blog.common.tagsShowLess` | Show fewer tags | +| `blog.forms.cancelButton` | Cancel | +| `blog.forms.contentLabel` | Content | +| `blog.forms.deleteButton` | Delete Post | +| `blog.forms.deleteDialogCancel` | Cancel | +| `blog.forms.deleteDialogConfirm` | Delete | +| `blog.forms.deleteDialogDescription` | Are you sure you want to delete this post? This action cannot be undone. | +| `blog.forms.deleteDialogTitle` | Delete Post | +| `blog.forms.deletePending` | Deleting... | +| `blog.forms.editorPlaceholder` | Write something... | +| `blog.forms.excerptLabel` | Excerpt | +| `blog.forms.excerptPlaceholder` | Brief summary of your post... | +| `blog.forms.featuredImageErrorNotImage` | Please select an image file | +| `blog.forms.featuredImageErrorTooLarge` | Image size must be less than 4MB | +| `blog.forms.featuredImageInputPlaceholder` | Image URL or upload below... | +| `blog.forms.featuredImageLabel` | Image | +| `blog.forms.featuredImagePreviewAlt` | Featured image preview | +| `blog.forms.featuredImageRequiredAsterisk` |  * | +| `blog.forms.featuredImageToastFailure` | Failed to upload image | +| `blog.forms.featuredImageToastSuccess` | Image uploaded successfully | +| `blog.forms.featuredImageUploadButton` | Upload | +| `blog.forms.featuredImageUploadingButton` | Uploading... | +| `blog.forms.featuredImageUploadingText` | Uploading image... | +| `blog.forms.publishedDescription` | Toggle to publish immediately | +| `blog.forms.publishedLabel` | Published | +| `blog.forms.requiredAsterisk` |  * | +| `blog.forms.slugLabel` | Slug | +| `blog.forms.slugPlaceholder` | url-friendly-slug | +| `blog.forms.submitCreateIdle` | Create Post | +| `blog.forms.submitCreatePending` | Creating... | +| `blog.forms.submitUpdateIdle` | Update Post | +| `blog.forms.submitUpdatePending` | Updating... | +| `blog.forms.tagsLabel` | Tags | +| `blog.forms.tagsPlaceholder` | Enter your post tags... | +| `blog.forms.tagsSearchPlaceholder` | Search or create tags... | +| `blog.forms.titleLabel` | Title | +| `blog.forms.titlePlaceholder` | Enter your post title... | +| `blog.forms.toastCreateSuccess` | Post created successfully | +| `blog.forms.toastDeleteFailure` | Failed to delete post | +| `blog.forms.toastDeleteSuccess` | Post deleted successfully | +| `blog.forms.toastUpdateSuccess` | Post updated successfully | +| `blog.forms.validation.contentRequired` | Content is required | +| `blog.forms.validation.excerptRequired` | Excerpt is required | +| `blog.forms.validation.slugRequired` | Slug is required | +| `blog.forms.validation.titleRequired` | Title is required | +| `blog.list.draftsTitle` | Draft Posts | +| `blog.list.empty` | There are no posts here yet. | +| `blog.list.loadMore` | Load more posts | +| `blog.list.loadingMore` | Loading more... | +| `blog.list.searchButton` | Search Posts | +| `blog.list.searchEmpty` | No blog posts found. | +| `blog.list.searchPlaceholder` | Search Blog Posts... | +| `blog.list.tagNotFound` | Tag not found | +| `blog.list.tagNotFoundDescription` | The tag you are looking for does not exist. | +| `blog.list.tagPageDescription` | Browse all posts with this tag | +| `blog.list.tagPageTitle` | \{\{tag\}\} Posts | +| `blog.list.title` | Blog Posts | +| `blog.post.addDescription` | Create a new blog post. | +| `blog.post.addTitle` | Add New Post | +| `blog.post.editDescription` | Update your blog post. | +| `blog.post.editTitle` | Edit Post | +| `blog.post.keepReading` | Keep Reading | +| `blog.post.next` | Next | +| `blog.post.onThisPage` | In This Post | +| `blog.post.previous` | Previous | +| `blog.post.viewAll` | View all | +| `blog.search.button` | Search | +| `blog.search.empty` | No results found. | +| `blog.search.placeholder` | Type to search... | +| `blog.search.searching` | Searching... | + +Other plugins adopt the same convention as their phase-2 sweeps land. diff --git a/docs/content/docs/meta.json b/docs/content/docs/meta.json index 50f9178e..8acc8336 100644 --- a/docs/content/docs/meta.json +++ b/docs/content/docs/meta.json @@ -27,6 +27,7 @@ "databases/adapters", "---[BookOpenCheck]Concepts---", "auth", + "i18n", "cli", "api-reference", "standalone-components", diff --git a/packages/stack/src/plugins/blog/client/components/forms/image-field.tsx b/packages/stack/src/plugins/blog/client/components/forms/image-field.tsx index 01d341d1..b3c935c2 100644 --- a/packages/stack/src/plugins/blog/client/components/forms/image-field.tsx +++ b/packages/stack/src/plugins/blog/client/components/forms/image-field.tsx @@ -7,11 +7,14 @@ import { FormMessage, } from "@workspace/ui/components/form"; import { Input } from "@workspace/ui/components/input"; -import { usePluginOverrides } from "@btst/stack/context"; +import { + useNotify, + usePluginOverrides, + useTranslate, +} from "@btst/stack/context"; import { Loader2, Upload } from "lucide-react"; import { useRef, useState } from "react"; import type { BlogPluginOverrides } from "../../overrides"; -import { BLOG_LOCALIZATION } from "../../localization"; export function FeaturedImageField({ isRequired, @@ -27,32 +30,36 @@ export function FeaturedImageField({ const fileInputRef = useRef(null); const [isUploading, setIsUploading] = useState(false); const notify = useNotify(); + const t = useTranslate(); const { uploadImage, Image, localization, imageInputField: ImageInput, - } = usePluginOverrides>( - "blog", - { localization: BLOG_LOCALIZATION }, - ); + } = usePluginOverrides("blog"); const ImageComponent = Image ? Image : DefaultImage; + const label = ( + + {localization?.BLOG_FORMS_FEATURED_IMAGE_LABEL ?? + t("blog.forms.featuredImageLabel", "Image")} + {isRequired && ( + + {" "} + {localization?.BLOG_FORMS_FEATURED_IMAGE_REQUIRED_ASTERISK ?? + t("blog.forms.featuredImageRequiredAsterisk", " *")} + + )} + + ); + // When a custom imageInput component is provided via overrides, delegate to it. if (ImageInput) { return ( - - {localization.BLOG_FORMS_FEATURED_IMAGE_LABEL} - {isRequired && ( - - {" "} - {localization.BLOG_FORMS_FEATURED_IMAGE_REQUIRED_ASTERISK} - - )} - + {label} 4 * 1024 * 1024) { - notify.error(localization.BLOG_FORMS_FEATURED_IMAGE_ERROR_TOO_LARGE); + notify.error( + localization?.BLOG_FORMS_FEATURED_IMAGE_ERROR_TOO_LARGE ?? + t( + "blog.forms.featuredImageErrorTooLarge", + "Image size must be less than 4MB", + ), + ); return; } @@ -87,10 +106,19 @@ export function FeaturedImageField({ setFeaturedImageUploading(true); const url = await uploadImage(file); onChange(url); - notify.success(localization.BLOG_FORMS_FEATURED_IMAGE_TOAST_SUCCESS); + notify.success( + localization?.BLOG_FORMS_FEATURED_IMAGE_TOAST_SUCCESS ?? + t( + "blog.forms.featuredImageToastSuccess", + "Image uploaded successfully", + ), + ); } catch (error) { console.error("Failed to upload image:", error); - notify.error(localization.BLOG_FORMS_FEATURED_IMAGE_TOAST_FAILURE); + notify.error( + localization?.BLOG_FORMS_FEATURED_IMAGE_TOAST_FAILURE ?? + t("blog.forms.featuredImageToastFailure", "Failed to upload image"), + ); } finally { setIsUploading(false); setFeaturedImageUploading(false); @@ -99,21 +127,17 @@ export function FeaturedImageField({ return ( - - {localization.BLOG_FORMS_FEATURED_IMAGE_LABEL} - {isRequired && ( - - {" "} - {localization.BLOG_FORMS_FEATURED_IMAGE_REQUIRED_ASTERISK} - - )} - + {label}
onChange(e.target.value)} @@ -128,12 +152,17 @@ export function FeaturedImageField({ {isUploading ? ( <> - {localization.BLOG_FORMS_FEATURED_IMAGE_UPLOADING_BUTTON} + {localization?.BLOG_FORMS_FEATURED_IMAGE_UPLOADING_BUTTON ?? + t( + "blog.forms.featuredImageUploadingButton", + "Uploading...", + )} ) : ( <> - {localization.BLOG_FORMS_FEATURED_IMAGE_UPLOAD_BUTTON} + {localization?.BLOG_FORMS_FEATURED_IMAGE_UPLOAD_BUTTON ?? + t("blog.forms.featuredImageUploadButton", "Upload")} )} @@ -148,14 +177,24 @@ export function FeaturedImageField({ {isUploading && (
- {localization.BLOG_FORMS_FEATURED_IMAGE_UPLOADING_TEXT} + {localization?.BLOG_FORMS_FEATURED_IMAGE_UPLOADING_TEXT ?? + t( + "blog.forms.featuredImageUploadingText", + "Uploading image...", + )}
)} {value && !isUploading && (
>( - "blog", - { localization: BLOG_LOCALIZATION }, - ); + } = usePluginOverrides("blog"); const insertImageRef = useRef<((url: string) => void) | null>(null); // Holds the Crepe-image-block `setUrl` callback while the picker is open. @@ -64,7 +61,10 @@ export function MarkdownEditorWithOverrides( default: module.MarkdownEditorWithOverrides, })), ); -import { BLOG_LOCALIZATION } from "../../localization"; -import { useNotify, usePluginOverrides } from "@btst/stack/context"; +import { + useNotify, + usePluginOverrides, + useTranslate, + type TranslateFn, +} from "@btst/stack/context"; import type { BlogPluginOverrides } from "../../overrides"; import { EmptyList } from "../shared/empty-list"; import { TagsMultiSelect } from "./tags-multiselect"; @@ -123,13 +135,12 @@ function PostFormBody({ setFeaturedImageUploading: (uploading: boolean) => void; initialSlugTouched?: boolean; }) { - const { localization } = usePluginOverrides< - BlogPluginOverrides, - Partial - >("blog", { - localization: BLOG_LOCALIZATION, - }); + const t = useTranslate(); + const { localization } = usePluginOverrides("blog"); const [slugTouched, setSlugTouched] = useState(initialSlugTouched); + const requiredAsterisk = + localization?.BLOG_FORMS_REQUIRED_ASTERISK ?? + t("blog.forms.requiredAsterisk", " *"); const nameTitle = "title" as FieldPath; const nameSlug = "slug" as FieldPath; const nameExcerpt = "excerpt" as FieldPath; @@ -152,14 +163,16 @@ function PostFormBody({ render={({ field }) => ( - {localization.BLOG_FORMS_TITLE_LABEL} - - {localization.BLOG_FORMS_REQUIRED_ASTERISK} - + {localization?.BLOG_FORMS_TITLE_LABEL ?? + t("blog.forms.titleLabel", "Title")} + {requiredAsterisk} { @@ -188,10 +201,16 @@ function PostFormBody({ return ( - {localization.BLOG_FORMS_SLUG_LABEL} + + {localization?.BLOG_FORMS_SLUG_LABEL ?? + t("blog.forms.slugLabel", "Slug")} + { @@ -217,14 +236,19 @@ function PostFormBody({ render={({ field }) => ( - {localization.BLOG_FORMS_EXCERPT_LABEL} - - {localization.BLOG_FORMS_REQUIRED_ASTERISK} - + {localization?.BLOG_FORMS_EXCERPT_LABEL ?? + t("blog.forms.excerptLabel", "Excerpt")} + {requiredAsterisk}