hazo_blog 0.5.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGE_LOG.md +209 -0
- package/README.md +41 -0
- package/db_setup_postgres.sql +27 -6
- package/db_setup_sqlite.sql +23 -6
- package/dist/components/admin/post-form.d.ts.map +1 -1
- package/dist/components/admin/post-form.js +7 -1
- package/dist/components/author-bio.d.ts.map +1 -1
- package/dist/components/author-bio.js +4 -1
- package/dist/components/blog-search.d.ts.map +1 -1
- package/dist/components/blog-search.js +4 -1
- package/dist/components/faq-section.d.ts.map +1 -1
- package/dist/components/faq-section.js +4 -1
- package/dist/components/featured-post-card.d.ts.map +1 -1
- package/dist/components/featured-post-card.js +4 -1
- package/dist/components/layouts/blog-index-editorial.d.ts.map +1 -1
- package/dist/components/layouts/blog-index-editorial.js +9 -2
- package/dist/components/layouts/blog-index-grid.d.ts.map +1 -1
- package/dist/components/layouts/blog-index-grid.js +9 -2
- package/dist/components/mdx/youtube.d.ts.map +1 -1
- package/dist/components/mdx/youtube.js +4 -1
- package/dist/components/post-card.d.ts.map +1 -1
- package/dist/components/post-card.js +4 -1
- package/dist/components/post-hero.d.ts.map +1 -1
- package/dist/components/post-hero.js +4 -1
- package/dist/components/related-posts.d.ts.map +1 -1
- package/dist/components/related-posts.js +4 -1
- package/dist/components/table-of-contents.d.ts.map +1 -1
- package/dist/components/table-of-contents.js +4 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/lib/create_package_t.d.ts +43 -0
- package/dist/lib/create_package_t.d.ts.map +1 -0
- package/dist/lib/create_package_t.js +98 -0
- package/dist/locales/en.json +24 -0
- package/dist/next/blog-content.d.ts.map +1 -1
- package/dist/next/blog-content.js +4 -1
- package/dist/next/pages.d.ts +4 -0
- package/dist/next/pages.d.ts.map +1 -1
- package/dist/next/pages.js +30 -12
- package/dist/next/routes.d.ts +12 -0
- package/dist/next/routes.d.ts.map +1 -1
- package/dist/next/routes.js +64 -1
- package/dist/repository/index.d.ts +25 -4
- package/dist/repository/index.d.ts.map +1 -1
- package/dist/repository/index.js +107 -15
- package/dist/seo/index.d.ts +11 -2
- package/dist/seo/index.d.ts.map +1 -1
- package/dist/seo/index.js +32 -5
- package/dist/service/index.d.ts +18 -3
- package/dist/service/index.d.ts.map +1 -1
- package/dist/service/index.js +40 -5
- package/dist/types/index.d.ts +31 -0
- package/dist/types/index.d.ts.map +1 -1
- package/dist/ui/blog-admin-panel.d.ts.map +1 -1
- package/dist/ui/blog-admin-panel.js +6 -1
- package/dist/ui/index.d.ts +1 -0
- package/dist/ui/index.d.ts.map +1 -1
- package/dist/ui/index.js +1 -0
- package/dist/ui/panel-dialog.d.ts +5 -1
- package/dist/ui/panel-dialog.d.ts.map +1 -1
- package/dist/ui/panel-dialog.js +7 -2
- package/dist/ui/post-preview.d.ts +7 -0
- package/dist/ui/post-preview.d.ts.map +1 -0
- package/dist/ui/post-preview.js +10 -0
- package/package.json +17 -13
package/CHANGE_LOG.md
CHANGED
|
@@ -3,6 +3,215 @@
|
|
|
3
3
|
All notable changes are documented here. This project follows
|
|
4
4
|
[Semantic Versioning](https://semver.org/).
|
|
5
5
|
|
|
6
|
+
## 1.2.0 — 2026-09-12
|
|
7
|
+
|
|
8
|
+
**Side preview in the post editor (`PanelDialog` + `PostPreview`)**
|
|
9
|
+
|
|
10
|
+
The admin edit dialog now supports a split-pane layout showing the published article alongside the
|
|
11
|
+
editor. When editing a published post, a "Preview" toggle appears in the dialog header; clicking it
|
|
12
|
+
expands the dialog to `max-w-7xl` and loads the published article in an iframe on the right half.
|
|
13
|
+
|
|
14
|
+
**`PanelDialog`** gained two new optional props:
|
|
15
|
+
- `sideContent?: ReactNode` — when provided, the dialog widens and renders a two-column layout
|
|
16
|
+
(children on the left, sideContent on the right).
|
|
17
|
+
- `headerActions?: ReactNode` — rendered inline after the title, used by `BlogAdminPanel` for the
|
|
18
|
+
preview toggle button.
|
|
19
|
+
|
|
20
|
+
**`PostPreview`** — new component exported from `hazo_blog/ui`. A lightweight iframe wrapper that
|
|
21
|
+
takes a `url` prop (the post's public URL), shows a loading state, and provides an "Open in new
|
|
22
|
+
tab" link. Token-free Tailwind, same isolation rule as the rest of `src/ui/*`.
|
|
23
|
+
|
|
24
|
+
**`BlogAdminPanel`** wires it up automatically: when the `dialogTarget` is a published post, the
|
|
25
|
+
preview toggle and `PostPreview` are passed to `PanelDialog`. The iframe URL is constructed from
|
|
26
|
+
`blogBasePath` + slug (e.g. `/guides/my-post`), so consuming apps that set a custom `blogBasePath`
|
|
27
|
+
get correct preview URLs out of the box.
|
|
28
|
+
|
|
29
|
+
**Back-compat: zero break.** Both new `PanelDialog` props are optional; `PostPreview` is additive.
|
|
30
|
+
The dialog renders identically to before when `sideContent` is not provided (the only layout
|
|
31
|
+
difference is the title now sits in a border-bottom header row — visually near-identical).
|
|
32
|
+
|
|
33
|
+
## 1.1.0 — 2026-09-11
|
|
34
|
+
|
|
35
|
+
**`onPostSaved` config hook + `createBlogManageSlugRoutes` factory**
|
|
36
|
+
|
|
37
|
+
Two additive capabilities requested by a consumer (stocktools) that needs to react to individual
|
|
38
|
+
post writes/deletes and to manage a single post by slug over the programmatic (API-key) surface,
|
|
39
|
+
rather than only through the collection-level `createBlogManageRoutes`.
|
|
40
|
+
|
|
41
|
+
**1. `BlogConfig.onPostSaved?: (post: BlogPost | null, action: 'upsert' | 'delete') => void |
|
|
42
|
+
Promise<void>`** — mirrors the existing `onSettingsChanged` hook. `BlogService.upsertBySlug` calls
|
|
43
|
+
it with the saved `BlogPost` and `'upsert'` after a successful write; `BlogService.deletePost`
|
|
44
|
+
calls it with `null` and `'delete'` after a successful delete. Both call sites wrap the hook in a
|
|
45
|
+
`try/catch` — a throwing (or rejecting) `onPostSaved` never fails the surrounding write/delete,
|
|
46
|
+
same "best-effort" contract as `onSettingsChanged`. Useful for cache invalidation
|
|
47
|
+
(`revalidatePath`) or search-index sync keyed off individual posts rather than the whole settings
|
|
48
|
+
blob.
|
|
49
|
+
|
|
50
|
+
**2. `createBlogManageSlugRoutes(config: BlogConfig)`** — a new route-handler factory alongside
|
|
51
|
+
`createBlogManageRoutes`, for a Next.js `[slug]` dynamic route on the programmatic management API.
|
|
52
|
+
Returns `{ GET, PATCH, DELETE }`, each gated by `withApiKey` the same way as the collection route
|
|
53
|
+
(`blog:read` for `GET`, `blog:write` for `PATCH`/`DELETE`):
|
|
54
|
+
|
|
55
|
+
- `GET` — fetches one post by slug (including drafts), 404 via `fail("NOT_FOUND", ...)` when
|
|
56
|
+
missing.
|
|
57
|
+
- `PATCH` — fetches the existing post by slug (404 if missing), merges the JSON body's fields onto
|
|
58
|
+
it (same field-by-field merge semantics as `createBlogManageRoutes`'s collection `PATCH`), and
|
|
59
|
+
calls `service.upsertBySlug`.
|
|
60
|
+
- `DELETE` — fetches the existing post by slug (404 if missing, to resolve its `id`), calls
|
|
61
|
+
`service.deletePost(id)`, and returns `{ deleted: slug }`.
|
|
62
|
+
|
|
63
|
+
Exported from the package barrel alongside the other route factories. No breaking changes.
|
|
64
|
+
|
|
65
|
+
## 1.0.0 — 2026-09-05
|
|
66
|
+
|
|
67
|
+
**FR-034, Part 1 — locale variants + `indexable` (schema + types + repository + service)**
|
|
68
|
+
|
|
69
|
+
This is the schema/data-layer half of FR-034 (locale-variant posts, categories, and tags, plus a
|
|
70
|
+
per-post `indexable` flag for excluding a post from the sitemap/robots without unpublishing it).
|
|
71
|
+
The SEO layer (`src/seo/index.ts`), Next.js pages/routes, and the admin `PostForm` UI are a
|
|
72
|
+
follow-up change — this release only touches schema, `src/types`, `src/repository`, and
|
|
73
|
+
`src/service`. Two breaking changes warrant the major bump:
|
|
74
|
+
|
|
75
|
+
**1. Database schema — new columns + a changed unique constraint**
|
|
76
|
+
|
|
77
|
+
`hazo_blog_posts` gains `locale TEXT DEFAULT NULL`, `translation_of TEXT REFERENCES
|
|
78
|
+
hazo_blog_posts(id) ON DELETE SET NULL` (`UUID` on Postgres), and `indexable INTEGER NOT NULL
|
|
79
|
+
DEFAULT 1` (`BOOLEAN NOT NULL DEFAULT TRUE` on Postgres). `hazo_blog_categories` and
|
|
80
|
+
`hazo_blog_tags` each gain `locale TEXT DEFAULT NULL`. All three tables' `(scope_id, slug)` unique
|
|
81
|
+
index is replaced by `(scope_id, slug, COALESCE(locale, ''))` — the `COALESCE` folds every
|
|
82
|
+
default-locale (`locale IS NULL`) row into one uniqueness bucket per `(scope_id, slug)`, since a
|
|
83
|
+
plain `(scope_id, slug, locale)` unique index would let SQLite/Postgres treat every `NULL` as
|
|
84
|
+
distinct from every other `NULL`, silently allowing duplicate default-locale posts. A new
|
|
85
|
+
`idx_hazo_blog_posts_translation_of` index backs translation-cluster lookups.
|
|
86
|
+
|
|
87
|
+
No SQLite table rebuild is required — `ALTER TABLE ... ADD COLUMN` works fine for these
|
|
88
|
+
plain nullable/defaulted columns, and the old unique constraints were already separate
|
|
89
|
+
`CREATE UNIQUE INDEX` statements rather than inline column constraints, so the index swap is a
|
|
90
|
+
`DROP INDEX` + `CREATE UNIQUE INDEX` pair (both idempotent, safe to run on every process start).
|
|
91
|
+
`db_setup_sqlite.sql`/`db_setup_postgres.sql` and their `migrations/` mirrors carry the new columns
|
|
92
|
+
directly in their `CREATE TABLE IF NOT EXISTS` lists (so a fresh DB gets them immediately) plus the
|
|
93
|
+
idempotent index-swap statements. An already-materialized SQLite dev DB (this package's
|
|
94
|
+
`test-app/data/blog.sqlite`) won't pick up new columns from a skipped `CREATE TABLE IF NOT EXISTS`
|
|
95
|
+
— `test-app/scripts/seed.mjs` gained a small `ensureColumns()` helper (guarded `PRAGMA
|
|
96
|
+
table_info` + `ALTER TABLE ... ADD COLUMN`) that backfills them before the schema file's DROP/CREATE
|
|
97
|
+
INDEX statements run. Postgres needs no such helper — `ALTER TABLE ... ADD COLUMN IF NOT EXISTS`
|
|
98
|
+
is natively idempotent and is included directly in `db_setup_postgres.sql`.
|
|
99
|
+
|
|
100
|
+
**2. `BlogRepository`/`BlogService` breaking signature changes**
|
|
101
|
+
|
|
102
|
+
- `getPostBySlug(slug, includeUnpublished?: boolean)` → `getPostBySlug(slug, opts?: {
|
|
103
|
+
includeUnpublished?: boolean; locale?: string | null })`. The old positional boolean 2nd
|
|
104
|
+
argument is gone.
|
|
105
|
+
```ts
|
|
106
|
+
// before
|
|
107
|
+
await repo.getPostBySlug(slug, true);
|
|
108
|
+
// after
|
|
109
|
+
await repo.getPostBySlug(slug, { includeUnpublished: true });
|
|
110
|
+
```
|
|
111
|
+
- `getAllSlugs(includeUnpublished?)` now returns `Array<{ slug: string; locale: string | null }>`
|
|
112
|
+
instead of `string[]` (unfiltered by locale — every translation's slug is included).
|
|
113
|
+
- New `getTranslations(postId)` on both `BlogRepository` and `BlogService`, resolving a post's full
|
|
114
|
+
translation cluster (root + every other variant, normalizing two-hop sibling→root→sibling chains
|
|
115
|
+
into one flat list) as `TranslationInfo[]`.
|
|
116
|
+
- `BlogService.getPost(slug, opts?, req?)` gains `opts.locale` and `opts.fallbackToDefault`
|
|
117
|
+
(default `false`). With `fallbackToDefault: false`, a locale-specific miss returns `null` rather
|
|
118
|
+
than silently serving the default-locale post.
|
|
119
|
+
- `listPosts`, `searchPosts`/`search`, `getRelatedPosts`, and `getOrCreateCategory` are all
|
|
120
|
+
locale-aware now; omitting `locale` anywhere continues to mean "default-locale only"
|
|
121
|
+
(`locale IS NULL`), so **existing single-locale consumers need no query changes** — every new
|
|
122
|
+
parameter is optional and defaults to the pre-1.0 behaviour.
|
|
123
|
+
|
|
124
|
+
**Migration note for existing consumers:** the only required change is any call site using the old
|
|
125
|
+
`getPostBySlug(slug, true)` positional form — switch it to `getPostBySlug(slug, {
|
|
126
|
+
includeUnpublished: true })`. Everything else (locale filtering, translation clusters, `indexable`)
|
|
127
|
+
is additive and opt-in.
|
|
128
|
+
|
|
129
|
+
**New types:** `BlogPost.locale`/`translation_of`/`indexable` (the last is a required `boolean` —
|
|
130
|
+
the repository normalizes SQLite's raw `0`/`1` `INTEGER` read-back to a real boolean so the type is
|
|
131
|
+
honest on both dialects), `UpsertPostInput.locale`/`translation_of`/`indexable`,
|
|
132
|
+
`ListPostsOptions.locale`, `BlogCategory.locale`, `BlogTag.locale`, `BlogPostWithRelations.translations`,
|
|
133
|
+
new `TranslationInfo`, and `BlogConfig.defaultLocale` (default `'en'`, consumed by the follow-up SEO
|
|
134
|
+
change).
|
|
135
|
+
|
|
136
|
+
**Tests:** `src/__tests__/repository.integration.test.ts` gained a `FR-034` describe block (default
|
|
137
|
+
+ translated variant CRUD, DB-level uniqueness enforcement including the two-default-locale-NULLs
|
|
138
|
+
case, cross-locale listing, translation-cluster resolution with two-hop normalization, unfiltered
|
|
139
|
+
`getAllSlugs`). New `src/__tests__/locale.integration.test.ts` exercises the schema files directly
|
|
140
|
+
against a fresh SQLite DB: new columns exist, the locale-aware unique indexes replace the old ones,
|
|
141
|
+
the `COALESCE(locale, '')` NULL-folding behavior, `translation_of`'s FK (including `ON DELETE SET
|
|
142
|
+
NULL`), the `indexable` default, and DDL idempotency (schema file re-run twice, no errors, no data
|
|
143
|
+
loss).
|
|
144
|
+
|
|
145
|
+
**FR-034, Part 2 — SEO / Next.js pages & routes / admin UI**
|
|
146
|
+
|
|
147
|
+
The SEO-facing and presentation half of FR-034, building on Part 1's schema/repository/service
|
|
148
|
+
layer above. Additive only — no further breaking changes on top of Part 1's major bump.
|
|
149
|
+
|
|
150
|
+
- **`src/seo/index.ts`:**
|
|
151
|
+
- `isIndexable(post)` now also returns `false` when `post.indexable === false`, independent of
|
|
152
|
+
the existing draft/publish-date gate — a live published post can still be excluded from the
|
|
153
|
+
sitemap/RSS/robots via the per-post flag without being unpublished.
|
|
154
|
+
- `buildBlogPostingJsonLd`'s JSON-LD `inLanguage` is now `post.locale || config.defaultLocale ||
|
|
155
|
+
"en"` instead of a hardcoded `"en"`.
|
|
156
|
+
- `getBlogSitemapEntries` gained a new `SitemapEntry.alternates?: Array<{ locale: string; url:
|
|
157
|
+
string }>` field, populated from a post's `translations` (indexable siblings only, via
|
|
158
|
+
`postUrl`) when present. This is intentionally passive: the function does no DB lookups of its
|
|
159
|
+
own, so a host wanting hreflang alternates in its sitemap must attach `.translations` (from
|
|
160
|
+
`BlogService.getTranslations`) to each post before calling it — the existing
|
|
161
|
+
`createBlogSitemapRoute` call site doesn't do this yet, since it lists posts in bulk and
|
|
162
|
+
per-post translation lookups there are a host-level tradeoff, not this package's call to make.
|
|
163
|
+
- `getBlogRssXml`'s channel `<language>` now follows `config.defaultLocale` (default `"en"`)
|
|
164
|
+
instead of a hardcoded `"en"`. Items whose `post.locale` differs from the feed-level language
|
|
165
|
+
get a per-item `<dc:language>` element, and the `<rss>` root gains the `xmlns:dc` namespace
|
|
166
|
+
attribute only when at least one item actually needed it (an all-default-locale feed is
|
|
167
|
+
byte-identical to before this change).
|
|
168
|
+
- `src/__tests__/seo.test.ts`'s `makePost()` helper now sets the required `indexable: true` field
|
|
169
|
+
(fixes a `type-check` failure introduced by Part 1's `BlogPost.indexable: boolean` becoming
|
|
170
|
+
non-optional). New tests cover locale-aware `inLanguage`, `indexable: false` sitemap exclusion,
|
|
171
|
+
sitemap `alternates` construction (including skipping non-indexable siblings), and the RSS
|
|
172
|
+
`<language>`/`<dc:language>`/`xmlns:dc` behavior.
|
|
173
|
+
|
|
174
|
+
- **`src/next/pages.tsx`** (`createBlogPostPage`):
|
|
175
|
+
- `generateMetadata` now also awaits `service.getTranslations(post.id)` and, when the cluster is
|
|
176
|
+
non-empty, adds a Next.js Metadata API `alternates.languages` map (`{ [locale]: url }`, built
|
|
177
|
+
via `postUrl`, indexable siblings only) alongside the existing `alternates.canonical`.
|
|
178
|
+
- Both `generateMetadata` and the page component now accept an optional `params.locale` (in
|
|
179
|
+
addition to the existing `params.slug`) and, when present, thread it through to
|
|
180
|
+
`service.getPost(slug, { locale })`. When absent, behavior is unchanged from before this
|
|
181
|
+
release — locale-prefixed routing remains the consuming app's concern (see `postUrl`'s
|
|
182
|
+
doc-comment); this package only adds the pass-through slot.
|
|
183
|
+
|
|
184
|
+
- **`src/next/routes.ts`:**
|
|
185
|
+
- `createBlogSearchRoute` now reads an optional `?locale=` query param and passes it through to
|
|
186
|
+
`service.search(q, 20, req, locale)`.
|
|
187
|
+
- `createBlogManageRoutes`'s `PATCH` handler now accepts `locale`, `translation_of`, and
|
|
188
|
+
`indexable` in the request body and merges them into the existing-post-derived
|
|
189
|
+
`UpsertPostInput`, same pattern as every other partial field on that handler.
|
|
190
|
+
|
|
191
|
+
- **`src/components/admin/post-form.tsx`:** `PostForm` gained three new fields, all sent through
|
|
192
|
+
the existing unified `save()` payload: a free-text **Locale** input (BCP-47 code, blank = default
|
|
193
|
+
locale), a free-text **Translation of (post ID)** input (labeled explicitly since
|
|
194
|
+
`translation_of` stores the parent post's `id`, not its slug — no post-picker UI exists in this
|
|
195
|
+
form to reuse), and an **Indexable** checkbox (defaults to `true`, matching the DB default). All
|
|
196
|
+
three keep the file's existing `bg-background`/`border-border` token classes per the isolation
|
|
197
|
+
rule documented in `CLAUDE.md` (this form is the one accepted exception inside the
|
|
198
|
+
otherwise-token-free `src/ui/*` boundary).
|
|
199
|
+
|
|
200
|
+
- **`test-app/app/autotest/hazo_blog_scenarios.ts`:** the browser-side `POST` fixture used by the
|
|
201
|
+
`/autotest` SEO-builder scenarios gained the required `indexable: true` field (same root cause as
|
|
202
|
+
the `seo.test.ts` fix above — this only surfaces in a full test-app Next build, not `tsc
|
|
203
|
+
--noEmit`, since it's outside this package's own `tsconfig.json` project).
|
|
204
|
+
|
|
205
|
+
**Out of scope for Part 2 (deliberately):** wiring `createBlogSitemapRoute`/`createBlogFeedRoute` to
|
|
206
|
+
actually populate `.translations` on each post before calling `getBlogSitemapEntries`/
|
|
207
|
+
`getBlogRssXml` — that requires either N+1 `getTranslations` calls per sitemap post or a new bulk
|
|
208
|
+
repository method, and no call site in this package currently needs it (`SitemapEntry.alternates`
|
|
209
|
+
and the RSS locale fields work correctly today for any host that already attaches `.translations`
|
|
210
|
+
itself, e.g. via a custom sitemap route). A locale-prefixed URL scheme for `postUrl`/route
|
|
211
|
+
generation (e.g. `/blog/fr/hello-world`) — still explicitly the consuming app's routing concern per
|
|
212
|
+
existing package convention. A post-picker UI for `translation_of` in `PostForm` — the plain
|
|
213
|
+
post-ID text input is the proportionate choice given no such picker exists elsewhere in this form.
|
|
214
|
+
|
|
6
215
|
## 0.5.0 — 2026-08-17
|
|
7
216
|
|
|
8
217
|
**Two named `/blog` index layouts — `grid` (unchanged) + new `editorial`**
|
package/README.md
CHANGED
|
@@ -218,6 +218,47 @@ This matters because `/blog` is typically ISR-cached (`revalidate = 3600` from
|
|
|
218
218
|
step 3) — without a revalidate hook, switching layouts via the admin panel
|
|
219
219
|
won't be visible on the public index until the next natural ISR window.
|
|
220
220
|
|
|
221
|
+
## Locale variants
|
|
222
|
+
|
|
223
|
+
> **Breaking change in 1.0.0** — see the migration note below if you're upgrading from 0.x.
|
|
224
|
+
|
|
225
|
+
`hazo_blog_posts`/`_categories`/`_tags` each carry a nullable `locale` column, uniquely constrained per `(scope_id, slug, locale)` — a `NULL` locale is your "default" post, and only one default (and one per non-null locale) can exist per slug. Posts also carry `translation_of` (the id of the post this is a translation of) and `indexable` (excludes a post from the sitemap and marks its JSON-LD/RSS as non-indexable without unpublishing it).
|
|
226
|
+
|
|
227
|
+
```ts
|
|
228
|
+
// Create a translated variant of an existing post
|
|
229
|
+
await service.upsertBySlug({
|
|
230
|
+
slug: "my-post",
|
|
231
|
+
locale: "fr",
|
|
232
|
+
translation_of: originalPost.id,
|
|
233
|
+
title: "Mon article",
|
|
234
|
+
// ...
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
// Fetch a specific locale — returns null (not the default post) when that
|
|
238
|
+
// locale doesn't exist, unless you opt into fallback:
|
|
239
|
+
const fr = await service.getPost("my-post", { locale: "fr" });
|
|
240
|
+
const frOrDefault = await service.getPost("my-post", { locale: "fr", fallbackToDefault: true });
|
|
241
|
+
|
|
242
|
+
// Walk a post's translation cluster (root + every sibling translation, minus itself)
|
|
243
|
+
const translations = await service.getTranslations(post.id);
|
|
244
|
+
// => [{ locale, slug, id, indexable }, ...]
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
`listPosts()`/`searchPosts()` default to `locale`-omitted, which returns **default-locale posts only** — existing single-locale consumers see no behavior change and never double-count translations in a listing. Pass `locale` explicitly to list/search one locale's posts.
|
|
248
|
+
|
|
249
|
+
**SEO**: `buildBlogPostingJsonLd` sets `inLanguage` from `post.locale || config.defaultLocale || "en"`; `getBlogSitemapEntries` excludes `indexable: false` posts and adds an `alternates: [{ locale, url }]` entry per sitemap item when the post carries a `.translations` array (populate it yourself via `getTranslations` before calling the sitemap builder — the package doesn't do this join automatically); `getBlogRssXml` uses `config.defaultLocale` for the feed `<language>` and adds a per-item `<dc:language>` when an item's locale differs from the feed default. `generateMetadata` on the post page adds `alternates.languages` (hreflang) automatically from `getTranslations`.
|
|
250
|
+
|
|
251
|
+
**Migrating from 0.x**: `getPostBySlug`'s second parameter is now an options object, not a boolean —
|
|
252
|
+
|
|
253
|
+
```ts
|
|
254
|
+
// before (0.x)
|
|
255
|
+
await repo.getPostBySlug(slug, true);
|
|
256
|
+
// after (1.0.0)
|
|
257
|
+
await repo.getPostBySlug(slug, { includeUnpublished: true });
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
— and `getAllSlugs()` now returns `Array<{ slug, locale }>` instead of `string[]`. Existing single-locale data needs no migration: every current row's `locale` is `NULL` already, which *is* the default-locale bucket every existing query path reads from.
|
|
261
|
+
|
|
221
262
|
## Layouts
|
|
222
263
|
|
|
223
264
|
`config.defaultIndexLayout` (and the runtime override in [Settings](#settings))
|
package/db_setup_postgres.sql
CHANGED
|
@@ -14,22 +14,33 @@ CREATE TABLE IF NOT EXISTS hazo_blog_categories (
|
|
|
14
14
|
name TEXT NOT NULL,
|
|
15
15
|
description TEXT NOT NULL DEFAULT '',
|
|
16
16
|
colour TEXT NOT NULL DEFAULT '#3B82F6',
|
|
17
|
+
locale TEXT DEFAULT NULL, -- BCP-47 locale; NULL = default locale
|
|
17
18
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
18
19
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
19
20
|
);
|
|
20
|
-
|
|
21
|
-
|
|
21
|
+
ALTER TABLE hazo_blog_categories ADD COLUMN IF NOT EXISTS locale TEXT DEFAULT NULL;
|
|
22
|
+
-- FR-034: locale-aware uniqueness. DROP+CREATE is idempotent and safe to
|
|
23
|
+
-- always include. COALESCE(locale, '') folds NULL (default locale) into one
|
|
24
|
+
-- bucket so two default-locale rows can't collide with Postgres's
|
|
25
|
+
-- distinct-per-NULL UNIQUE-index semantics.
|
|
26
|
+
DROP INDEX IF EXISTS idx_hazo_blog_categories_scope_slug;
|
|
27
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_hazo_blog_categories_scope_slug_locale
|
|
28
|
+
ON hazo_blog_categories (scope_id, slug, COALESCE(locale, ''));
|
|
22
29
|
|
|
23
30
|
CREATE TABLE IF NOT EXISTS hazo_blog_tags (
|
|
24
31
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
25
32
|
scope_id TEXT,
|
|
26
33
|
slug TEXT NOT NULL,
|
|
27
34
|
name TEXT NOT NULL,
|
|
35
|
+
locale TEXT DEFAULT NULL, -- BCP-47 locale; NULL = default locale
|
|
28
36
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
29
37
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
30
38
|
);
|
|
31
|
-
|
|
32
|
-
|
|
39
|
+
ALTER TABLE hazo_blog_tags ADD COLUMN IF NOT EXISTS locale TEXT DEFAULT NULL;
|
|
40
|
+
-- FR-034: locale-aware uniqueness (see categories index comment above).
|
|
41
|
+
DROP INDEX IF EXISTS idx_hazo_blog_tags_scope_slug;
|
|
42
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_hazo_blog_tags_scope_slug_locale
|
|
43
|
+
ON hazo_blog_tags (scope_id, slug, COALESCE(locale, ''));
|
|
33
44
|
|
|
34
45
|
CREATE TABLE IF NOT EXISTS hazo_blog_posts (
|
|
35
46
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
@@ -51,15 +62,25 @@ CREATE TABLE IF NOT EXISTS hazo_blog_posts (
|
|
|
51
62
|
reading_time INTEGER NOT NULL DEFAULT 0, -- cached, minutes
|
|
52
63
|
status TEXT NOT NULL DEFAULT 'draft', -- draft | published | scheduled
|
|
53
64
|
publish_date TIMESTAMPTZ,
|
|
65
|
+
locale TEXT DEFAULT NULL, -- BCP-47 locale; NULL = default locale
|
|
66
|
+
translation_of UUID REFERENCES hazo_blog_posts(id) ON DELETE SET NULL, -- root post of this translation cluster
|
|
67
|
+
indexable BOOLEAN NOT NULL DEFAULT TRUE, -- FALSE excludes from sitemap/robots
|
|
54
68
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
55
69
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
56
70
|
);
|
|
57
|
-
|
|
58
|
-
|
|
71
|
+
ALTER TABLE hazo_blog_posts ADD COLUMN IF NOT EXISTS locale TEXT DEFAULT NULL;
|
|
72
|
+
ALTER TABLE hazo_blog_posts ADD COLUMN IF NOT EXISTS translation_of UUID REFERENCES hazo_blog_posts(id) ON DELETE SET NULL;
|
|
73
|
+
ALTER TABLE hazo_blog_posts ADD COLUMN IF NOT EXISTS indexable BOOLEAN NOT NULL DEFAULT TRUE;
|
|
74
|
+
-- FR-034: locale-aware uniqueness (see categories index comment above).
|
|
75
|
+
DROP INDEX IF EXISTS idx_hazo_blog_posts_scope_slug;
|
|
76
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_hazo_blog_posts_scope_slug_locale
|
|
77
|
+
ON hazo_blog_posts (scope_id, slug, COALESCE(locale, ''));
|
|
59
78
|
CREATE INDEX IF NOT EXISTS idx_hazo_blog_posts_status_date
|
|
60
79
|
ON hazo_blog_posts (status, publish_date);
|
|
61
80
|
CREATE INDEX IF NOT EXISTS idx_hazo_blog_posts_category
|
|
62
81
|
ON hazo_blog_posts (category_id);
|
|
82
|
+
CREATE INDEX IF NOT EXISTS idx_hazo_blog_posts_translation_of
|
|
83
|
+
ON hazo_blog_posts (translation_of);
|
|
63
84
|
|
|
64
85
|
CREATE TABLE IF NOT EXISTS hazo_blog_post_tags (
|
|
65
86
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
package/db_setup_sqlite.sql
CHANGED
|
@@ -21,22 +21,32 @@ CREATE TABLE IF NOT EXISTS hazo_blog_categories (
|
|
|
21
21
|
name TEXT NOT NULL,
|
|
22
22
|
description TEXT NOT NULL DEFAULT '',
|
|
23
23
|
colour TEXT NOT NULL DEFAULT '#3B82F6',
|
|
24
|
+
locale TEXT DEFAULT NULL, -- BCP-47 locale; NULL = default locale
|
|
24
25
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
25
26
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
26
27
|
);
|
|
27
|
-
CREATE
|
|
28
|
-
|
|
28
|
+
-- FR-034: locale-aware uniqueness. DROP+CREATE is idempotent and safe to
|
|
29
|
+
-- re-run every process start (no table rebuild needed — this is an index
|
|
30
|
+
-- swap, not a column change). COALESCE(locale, '') folds NULL (default
|
|
31
|
+
-- locale) into one bucket so two default-locale rows can't collide with a
|
|
32
|
+
-- distinct-per-NULL SQL semantics bug.
|
|
33
|
+
DROP INDEX IF EXISTS idx_hazo_blog_categories_scope_slug;
|
|
34
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_hazo_blog_categories_scope_slug_locale
|
|
35
|
+
ON hazo_blog_categories (scope_id, slug, COALESCE(locale, ''));
|
|
29
36
|
|
|
30
37
|
CREATE TABLE IF NOT EXISTS hazo_blog_tags (
|
|
31
38
|
id TEXT PRIMARY KEY,
|
|
32
39
|
scope_id TEXT,
|
|
33
40
|
slug TEXT NOT NULL,
|
|
34
41
|
name TEXT NOT NULL,
|
|
42
|
+
locale TEXT DEFAULT NULL, -- BCP-47 locale; NULL = default locale
|
|
35
43
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
36
44
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
37
45
|
);
|
|
38
|
-
|
|
39
|
-
|
|
46
|
+
-- FR-034: locale-aware uniqueness (see categories index comment above).
|
|
47
|
+
DROP INDEX IF EXISTS idx_hazo_blog_tags_scope_slug;
|
|
48
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_hazo_blog_tags_scope_slug_locale
|
|
49
|
+
ON hazo_blog_tags (scope_id, slug, COALESCE(locale, ''));
|
|
40
50
|
|
|
41
51
|
CREATE TABLE IF NOT EXISTS hazo_blog_posts (
|
|
42
52
|
id TEXT PRIMARY KEY,
|
|
@@ -58,15 +68,22 @@ CREATE TABLE IF NOT EXISTS hazo_blog_posts (
|
|
|
58
68
|
reading_time INTEGER NOT NULL DEFAULT 0, -- cached, minutes
|
|
59
69
|
status TEXT NOT NULL DEFAULT 'draft', -- draft | published | scheduled
|
|
60
70
|
publish_date TEXT,
|
|
71
|
+
locale TEXT DEFAULT NULL, -- BCP-47 locale; NULL = default locale
|
|
72
|
+
translation_of TEXT REFERENCES hazo_blog_posts(id) ON DELETE SET NULL, -- root post of this translation cluster
|
|
73
|
+
indexable INTEGER NOT NULL DEFAULT 1, -- 0/1; FALSE excludes from sitemap/robots
|
|
61
74
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
62
75
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
63
76
|
);
|
|
64
|
-
|
|
65
|
-
|
|
77
|
+
-- FR-034: locale-aware uniqueness (see categories index comment above).
|
|
78
|
+
DROP INDEX IF EXISTS idx_hazo_blog_posts_scope_slug;
|
|
79
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_hazo_blog_posts_scope_slug_locale
|
|
80
|
+
ON hazo_blog_posts (scope_id, slug, COALESCE(locale, ''));
|
|
66
81
|
CREATE INDEX IF NOT EXISTS idx_hazo_blog_posts_status_date
|
|
67
82
|
ON hazo_blog_posts (status, publish_date);
|
|
68
83
|
CREATE INDEX IF NOT EXISTS idx_hazo_blog_posts_category
|
|
69
84
|
ON hazo_blog_posts (category_id);
|
|
85
|
+
CREATE INDEX IF NOT EXISTS idx_hazo_blog_posts_translation_of
|
|
86
|
+
ON hazo_blog_posts (translation_of);
|
|
70
87
|
|
|
71
88
|
CREATE TABLE IF NOT EXISTS hazo_blog_post_tags (
|
|
72
89
|
id TEXT PRIMARY KEY,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"post-form.d.ts","sourceRoot":"","sources":["../../../src/components/admin/post-form.tsx"],"names":[],"mappings":"AAGA,OAAO,EAAkB,KAAK,aAAa,EAAE,MAAM,SAAS,CAAC;AAG7D,OAAO,KAAK,EACV,YAAY,EACZ,qBAAqB,EAGtB,MAAM,sBAAsB,CAAC;AAI9B,MAAM,WAAW,aAAa;IAC5B,kDAAkD;IAClD,IAAI,CAAC,EAAE,qBAAqB,GAAG,IAAI,CAAC;IACpC,UAAU,CAAC,EAAE,YAAY,EAAE,CAAC;IAC5B,kFAAkF;IAClF,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,+DAA+D;IAC/D,OAAO,CAAC,EAAE,CAAC,MAAM,EAAE,OAAO,KAAK,IAAI,CAAC;IACpC,kEAAkE;IAClE,MAAM,CAAC,EAAE,aAAa,EAAE,CAAC;IACzB;;;;OAIG;IACH,aAAa,CAAC,EAAE,CAAC,IAAI,EAAE,IAAI,KAAK,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;IACvD;;;;OAIG;IACH,mBAAmB,CAAC,EAAE,MAAM,CAAC;IAC7B,8EAA8E;IAC9E,OAAO,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,WAAW,KAAK,OAAO,CAAC,QAAQ,CAAC,CAAC;IAClE,4FAA4F;IAC5F,QAAQ,CAAC,EAAE,MAAM,IAAI,CAAC;IACtB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAgBD,wBAAgB,QAAQ,CAAC,EACvB,IAAI,EACJ,UAAe,EACf,QAA4B,EAC5B,OAAO,EACP,MAAuB,EACvB,aAAa,EACb,mBAAmB,EACnB,OAAO,EACP,QAAQ,EACR,SAAS,GACV,EAAE,aAAa,
|
|
1
|
+
{"version":3,"file":"post-form.d.ts","sourceRoot":"","sources":["../../../src/components/admin/post-form.tsx"],"names":[],"mappings":"AAGA,OAAO,EAAkB,KAAK,aAAa,EAAE,MAAM,SAAS,CAAC;AAG7D,OAAO,KAAK,EACV,YAAY,EACZ,qBAAqB,EAGtB,MAAM,sBAAsB,CAAC;AAI9B,MAAM,WAAW,aAAa;IAC5B,kDAAkD;IAClD,IAAI,CAAC,EAAE,qBAAqB,GAAG,IAAI,CAAC;IACpC,UAAU,CAAC,EAAE,YAAY,EAAE,CAAC;IAC5B,kFAAkF;IAClF,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,+DAA+D;IAC/D,OAAO,CAAC,EAAE,CAAC,MAAM,EAAE,OAAO,KAAK,IAAI,CAAC;IACpC,kEAAkE;IAClE,MAAM,CAAC,EAAE,aAAa,EAAE,CAAC;IACzB;;;;OAIG;IACH,aAAa,CAAC,EAAE,CAAC,IAAI,EAAE,IAAI,KAAK,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;IACvD;;;;OAIG;IACH,mBAAmB,CAAC,EAAE,MAAM,CAAC;IAC7B,8EAA8E;IAC9E,OAAO,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,WAAW,KAAK,OAAO,CAAC,QAAQ,CAAC,CAAC;IAClE,4FAA4F;IAC5F,QAAQ,CAAC,EAAE,MAAM,IAAI,CAAC;IACtB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAgBD,wBAAgB,QAAQ,CAAC,EACvB,IAAI,EACJ,UAAe,EACf,QAA4B,EAC5B,OAAO,EACP,MAAuB,EACvB,aAAa,EACb,mBAAmB,EACnB,OAAO,EACP,QAAQ,EACR,SAAS,GACV,EAAE,aAAa,2CAyQf"}
|
|
@@ -37,6 +37,9 @@ export function PostForm({ post, categories = [], endpoint = "/api/admin/blog",
|
|
|
37
37
|
const [featuredImage, setFeaturedImage] = useState(post?.featured_image ?? "");
|
|
38
38
|
const [tags, setTags] = useState((post?.tags ?? []).map((t) => t.name).join(", "));
|
|
39
39
|
const [faq, setFaq] = useState(post?.faq ?? []);
|
|
40
|
+
const [locale, setLocale] = useState(post?.locale ?? "");
|
|
41
|
+
const [translationOf, setTranslationOf] = useState(post?.translation_of ?? "");
|
|
42
|
+
const [indexable, setIndexable] = useState(post?.indexable ?? true);
|
|
40
43
|
const [saving, setSaving] = useState(false);
|
|
41
44
|
const [error, setError] = useState("");
|
|
42
45
|
function effectiveSlug() {
|
|
@@ -60,6 +63,9 @@ export function PostForm({ post, categories = [], endpoint = "/api/admin/blog",
|
|
|
60
63
|
.filter(Boolean),
|
|
61
64
|
faq: faq.filter((f) => f.question.trim() && f.answer.trim()),
|
|
62
65
|
status,
|
|
66
|
+
locale: locale.trim() || undefined,
|
|
67
|
+
translation_of: translationOf.trim() || undefined,
|
|
68
|
+
indexable,
|
|
63
69
|
};
|
|
64
70
|
const res = await fetch(endpoint, {
|
|
65
71
|
method: "POST",
|
|
@@ -83,5 +89,5 @@ export function PostForm({ post, categories = [], endpoint = "/api/admin/blog",
|
|
|
83
89
|
return (_jsxs("form", { className: cn("space-y-5", className), onSubmit: (e) => e.preventDefault(), children: [_jsxs("div", { children: [_jsx("label", { className: "mb-1 block text-sm font-medium", children: "Title" }), _jsx("input", { className: inputCls, value: title, onChange: (e) => setTitle(e.target.value), placeholder: "Post title" })] }), _jsxs("div", { children: [_jsx("label", { className: "mb-1 block text-sm font-medium", children: "Slug" }), _jsx("input", { className: inputCls, value: slugTouched ? slug : effectiveSlug(), onChange: (e) => {
|
|
84
90
|
setSlugTouched(true);
|
|
85
91
|
setSlug(e.target.value);
|
|
86
|
-
}, placeholder: "auto-generated-from-title" })] }), _jsxs("div", { children: [_jsx("label", { className: "mb-1 block text-sm font-medium", children: "Content (MDX)" }), _jsx(MarkdownEditor, { value: content, onChange: setContent, height: 480, embeds: embeds, onImageUpload: uploader, placeholder: "Write your post in Markdown / MDX\u2026" })] }), _jsxs("div", { className: "grid gap-4 sm:grid-cols-2", children: [_jsxs("div", { children: [_jsx("label", { className: "mb-1 block text-sm font-medium", children: "Category" }), _jsxs("select", { className: inputCls, value: categoryId, onChange: (e) => setCategoryId(e.target.value), children: [_jsx("option", { value: "", children: "\u2014 none \u2014" }), categories.map((c) => (_jsx("option", { value: c.id, children: c.name }, c.id)))] })] }), _jsxs("div", { children: [_jsx("label", { className: "mb-1 block text-sm font-medium", children: "Tags (comma-separated)" }), _jsx("input", { className: inputCls, value: tags, onChange: (e) => setTags(e.target.value), placeholder: "guides, timers" })] })] }), _jsx(FeaturedImageField, { value: featuredImage, onChange: setFeaturedImage, uploader: uploader }), _jsxs("div", { className: "grid gap-4 sm:grid-cols-2", children: [_jsxs("div", { children: [_jsx("label", { className: "mb-1 block text-sm font-medium", children: "Meta title (SEO)" }), _jsx("input", { className: inputCls, value: metaTitle, onChange: (e) => setMetaTitle(e.target.value) })] }), _jsxs("div", { children: [_jsx("label", { className: "mb-1 block text-sm font-medium", children: "Meta description (SEO)" }), _jsx("input", { className: inputCls, value: metaDescription, onChange: (e) => setMetaDescription(e.target.value) })] })] }), _jsxs("fieldset", { className: "rounded-lg border border-border p-4", children: [_jsx("legend", { className: "px-1 text-sm font-medium", children: "FAQ" }), faq.map((item, i) => (_jsxs("div", { className: "mb-2 grid gap-2 sm:grid-cols-2", children: [_jsx("input", { className: inputCls, value: item.question, placeholder: "Question", onChange: (e) => setFaq((prev) => prev.map((f, j) => (j === i ? { ...f, question: e.target.value } : f))) }), _jsx("input", { className: inputCls, value: item.answer, placeholder: "Answer", onChange: (e) => setFaq((prev) => prev.map((f, j) => (j === i ? { ...f, answer: e.target.value } : f))) })] }, i))), _jsx("button", { type: "button", className: "mt-1 text-sm text-primary hover:underline", onClick: () => setFaq((p) => [...p, { question: "", answer: "" }]), children: "+ Add FAQ item" })] }), error && _jsx("p", { className: "text-sm text-destructive", children: error }), _jsxs("div", { className: "flex gap-3", children: [onCancel && (_jsx("button", { type: "button", disabled: saving, onClick: onCancel, className: "rounded-lg border border-border px-4 py-2 text-sm font-medium hover:bg-accent disabled:opacity-50", children: "Cancel" })), _jsx("button", { type: "button", disabled: saving, onClick: () => save("draft"), className: "rounded-lg border border-border px-4 py-2 text-sm font-medium hover:bg-accent disabled:opacity-50", children: "Save draft" }), _jsx("button", { type: "button", disabled: saving, onClick: () => save("published"), className: "rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90 disabled:opacity-50", children: saving ? "Saving…" : "Publish" })] })] }));
|
|
92
|
+
}, placeholder: "auto-generated-from-title" })] }), _jsxs("div", { children: [_jsx("label", { className: "mb-1 block text-sm font-medium", children: "Content (MDX)" }), _jsx(MarkdownEditor, { value: content, onChange: setContent, height: 480, embeds: embeds, onImageUpload: uploader, placeholder: "Write your post in Markdown / MDX\u2026" })] }), _jsxs("div", { className: "grid gap-4 sm:grid-cols-2", children: [_jsxs("div", { children: [_jsx("label", { className: "mb-1 block text-sm font-medium", children: "Category" }), _jsxs("select", { className: inputCls, value: categoryId, onChange: (e) => setCategoryId(e.target.value), children: [_jsx("option", { value: "", children: "\u2014 none \u2014" }), categories.map((c) => (_jsx("option", { value: c.id, children: c.name }, c.id)))] })] }), _jsxs("div", { children: [_jsx("label", { className: "mb-1 block text-sm font-medium", children: "Tags (comma-separated)" }), _jsx("input", { className: inputCls, value: tags, onChange: (e) => setTags(e.target.value), placeholder: "guides, timers" })] })] }), _jsx(FeaturedImageField, { value: featuredImage, onChange: setFeaturedImage, uploader: uploader }), _jsxs("div", { className: "grid gap-4 sm:grid-cols-2", children: [_jsxs("div", { children: [_jsx("label", { className: "mb-1 block text-sm font-medium", children: "Locale" }), _jsx("input", { className: inputCls, value: locale, onChange: (e) => setLocale(e.target.value), placeholder: "e.g. fr, pt-BR (blank = default locale)" })] }), _jsxs("div", { children: [_jsx("label", { className: "mb-1 block text-sm font-medium", children: "Translation of (post ID)" }), _jsx("input", { className: inputCls, value: translationOf, onChange: (e) => setTranslationOf(e.target.value), placeholder: "Leave blank for the root post" })] })] }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx("input", { id: "post-form-indexable", type: "checkbox", className: "h-4 w-4 rounded border-border accent-primary", checked: indexable, onChange: (e) => setIndexable(e.target.checked) }), _jsx("label", { htmlFor: "post-form-indexable", className: "text-sm font-medium", children: "Indexable (include in sitemap/robots)" })] }), _jsxs("div", { className: "grid gap-4 sm:grid-cols-2", children: [_jsxs("div", { children: [_jsx("label", { className: "mb-1 block text-sm font-medium", children: "Meta title (SEO)" }), _jsx("input", { className: inputCls, value: metaTitle, onChange: (e) => setMetaTitle(e.target.value) })] }), _jsxs("div", { children: [_jsx("label", { className: "mb-1 block text-sm font-medium", children: "Meta description (SEO)" }), _jsx("input", { className: inputCls, value: metaDescription, onChange: (e) => setMetaDescription(e.target.value) })] })] }), _jsxs("fieldset", { className: "rounded-lg border border-border p-4", children: [_jsx("legend", { className: "px-1 text-sm font-medium", children: "FAQ" }), faq.map((item, i) => (_jsxs("div", { className: "mb-2 grid gap-2 sm:grid-cols-2", children: [_jsx("input", { className: inputCls, value: item.question, placeholder: "Question", onChange: (e) => setFaq((prev) => prev.map((f, j) => (j === i ? { ...f, question: e.target.value } : f))) }), _jsx("input", { className: inputCls, value: item.answer, placeholder: "Answer", onChange: (e) => setFaq((prev) => prev.map((f, j) => (j === i ? { ...f, answer: e.target.value } : f))) })] }, i))), _jsx("button", { type: "button", className: "mt-1 text-sm text-primary hover:underline", onClick: () => setFaq((p) => [...p, { question: "", answer: "" }]), children: "+ Add FAQ item" })] }), error && _jsx("p", { className: "text-sm text-destructive", children: error }), _jsxs("div", { className: "flex gap-3", children: [onCancel && (_jsx("button", { type: "button", disabled: saving, onClick: onCancel, className: "rounded-lg border border-border px-4 py-2 text-sm font-medium hover:bg-accent disabled:opacity-50", children: "Cancel" })), _jsx("button", { type: "button", disabled: saving, onClick: () => save("draft"), className: "rounded-lg border border-border px-4 py-2 text-sm font-medium hover:bg-accent disabled:opacity-50", children: "Save draft" }), _jsx("button", { type: "button", disabled: saving, onClick: () => save("published"), className: "rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90 disabled:opacity-50", children: saving ? "Saving…" : "Publish" })] })] }));
|
|
87
93
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"author-bio.d.ts","sourceRoot":"","sources":["../../src/components/author-bio.tsx"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAC;
|
|
1
|
+
{"version":3,"file":"author-bio.d.ts","sourceRoot":"","sources":["../../src/components/author-bio.tsx"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAC;AAMpD,MAAM,WAAW,cAAc;IAC7B,MAAM,EAAE,UAAU,CAAC;IACnB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED,wBAAgB,SAAS,CAAC,EAAE,MAAM,EAAE,SAAS,EAAE,EAAE,cAAc,2CA0B9D"}
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import Image from "next/image";
|
|
3
3
|
import { cn } from "hazo_ui/utils";
|
|
4
|
+
import en from "../locales/en.json" with { type: "json" };
|
|
5
|
+
import { createPackageT } from "../lib/create_package_t.js";
|
|
6
|
+
const t = createPackageT("hazo_blog", en);
|
|
4
7
|
export function AuthorBio({ author, className }) {
|
|
5
|
-
return (_jsxs("aside", { className: cn("my-8 flex items-center gap-4 rounded-xl border border-border bg-muted/30 p-5", className), children: [author.avatar && (_jsx(Image, { src: author.avatar, alt: author.name, width: 56, height: 56, className: "h-14 w-14 rounded-full object-cover" })), _jsxs("div", { className: "min-w-0", children: [_jsx("p", { className: "text-sm text-muted-foreground", children: "
|
|
8
|
+
return (_jsxs("aside", { className: cn("my-8 flex items-center gap-4 rounded-xl border border-border bg-muted/30 p-5", className), children: [author.avatar && (_jsx(Image, { src: author.avatar, alt: author.name, width: 56, height: 56, className: "h-14 w-14 rounded-full object-cover" })), _jsxs("div", { className: "min-w-0", children: [_jsx("p", { className: "text-sm text-muted-foreground", children: t("author_bio.written_by") }), _jsx("a", { href: author.url, className: "font-semibold text-foreground hover:text-primary", children: author.name }), author.bio && _jsx("p", { className: "mt-1 text-sm text-muted-foreground", children: author.bio })] })] }));
|
|
6
9
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"blog-search.d.ts","sourceRoot":"","sources":["../../src/components/blog-search.tsx"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"blog-search.d.ts","sourceRoot":"","sources":["../../src/components/blog-search.tsx"],"names":[],"mappings":"AAmBA,MAAM,WAAW,eAAe;IAC9B,iGAAiG;IACjG,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,gHAAgH;IAChH,OAAO,CAAC,EAAE,SAAS,GAAG,MAAM,CAAC;CAC9B;AAED,wBAAgB,UAAU,CAAC,EACzB,QAAQ,EACR,QAAkB,EAClB,WAA0C,EAC1C,SAAS,EACT,OAAmB,GACpB,EAAE,eAAe,2CAyFjB"}
|
|
@@ -6,7 +6,10 @@ import Link from "next/link";
|
|
|
6
6
|
import { cn } from "hazo_ui/utils";
|
|
7
7
|
import { trackBlogEvent } from "../lib/analytics.js";
|
|
8
8
|
import { SearchIcon } from "./icons.js";
|
|
9
|
-
|
|
9
|
+
import en from "../locales/en.json" with { type: "json" };
|
|
10
|
+
import { createPackageT } from "../lib/create_package_t.js";
|
|
11
|
+
const t = createPackageT("hazo_blog", en);
|
|
12
|
+
export function BlogSearch({ endpoint, basePath = "/blog", placeholder = t("blog_search.placeholder"), className, variant = "default", }) {
|
|
10
13
|
const url = endpoint ?? `${basePath}/api/search`;
|
|
11
14
|
const [query, setQuery] = useState("");
|
|
12
15
|
const [results, setResults] = useState([]);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"faq-section.d.ts","sourceRoot":"","sources":["../../src/components/faq-section.tsx"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,mBAAmB,CAAC;
|
|
1
|
+
{"version":3,"file":"faq-section.d.ts","sourceRoot":"","sources":["../../src/components/faq-section.tsx"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,mBAAmB,CAAC;AAMjD,MAAM,WAAW,eAAe;IAC9B,GAAG,EAAE,OAAO,EAAE,CAAC;IACf,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED,8FAA8F;AAC9F,wBAAgB,UAAU,CAAC,EAAE,GAAG,EAAE,KAA8B,EAAE,SAAS,EAAE,EAAE,eAAe,kDAiB7F"}
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { cn } from "hazo_ui/utils";
|
|
3
|
+
import en from "../locales/en.json" with { type: "json" };
|
|
4
|
+
import { createPackageT } from "../lib/create_package_t.js";
|
|
5
|
+
const t = createPackageT("hazo_blog", en);
|
|
3
6
|
/** Renders FAQ as a no-JS <details> accordion (the FAQPage JSON-LD is emitted separately). */
|
|
4
|
-
export function FaqSection({ faq, title = "
|
|
7
|
+
export function FaqSection({ faq, title = t("faq_section.title"), className }) {
|
|
5
8
|
if (!faq || faq.length === 0)
|
|
6
9
|
return null;
|
|
7
10
|
return (_jsxs("section", { className: cn("my-8", className), children: [_jsx("h2", { className: "mb-4 text-2xl font-bold text-foreground", children: title }), _jsx("dl", { className: "divide-y divide-border rounded-xl border border-border", children: faq.map((item, i) => (_jsxs("details", { className: "group p-4", children: [_jsx("summary", { className: "cursor-pointer list-none font-medium text-foreground", children: _jsx("dt", { className: "inline", children: item.question }) }), _jsx("dd", { className: "mt-2 text-sm text-muted-foreground", children: item.answer })] }, i))) })] }));
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"featured-post-card.d.ts","sourceRoot":"","sources":["../../src/components/featured-post-card.tsx"],"names":[],"mappings":"AASA,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,mBAAmB,CAAC;
|
|
1
|
+
{"version":3,"file":"featured-post-card.d.ts","sourceRoot":"","sources":["../../src/components/featured-post-card.tsx"],"names":[],"mappings":"AASA,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,mBAAmB,CAAC;AAM/D,MAAM,WAAW,qBAAqB;IACpC,IAAI,EAAE,qBAAqB,CAAC;IAC5B,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED,wBAAgB,gBAAgB,CAAC,EAAE,IAAI,EAAE,QAAkB,EAAE,SAAS,EAAE,EAAE,qBAAqB,2CA+D9F"}
|
|
@@ -7,9 +7,12 @@ import Image from "next/image";
|
|
|
7
7
|
import { cn } from "hazo_ui/utils";
|
|
8
8
|
import { ClockIcon, ArrowRightIcon, ImageIcon } from "./icons.js";
|
|
9
9
|
import { formatPostDate } from "../lib/dates.js";
|
|
10
|
+
import en from "../locales/en.json" with { type: "json" };
|
|
11
|
+
import { createPackageT } from "../lib/create_package_t.js";
|
|
12
|
+
const t = createPackageT("hazo_blog", en);
|
|
10
13
|
export function FeaturedPostCard({ post, basePath = "/blog", className }) {
|
|
11
14
|
return (_jsxs("article", { className: cn("grid overflow-hidden rounded-3xl border border-border bg-card md:grid-cols-[3fr_2fr]", className), children: [_jsx("div", { className: "relative aspect-video w-full overflow-hidden md:aspect-auto md:h-full", children: post.featured_image ? (_jsx(Image, { src: post.featured_image, alt: post.title, fill: true, className: "object-cover", sizes: "(max-width: 768px) 100vw, 60vw", priority: true })) : (_jsx("div", { className: "flex h-full min-h-48 w-full items-center justify-center bg-gradient-to-br from-muted to-muted/50", children: _jsx(ImageIcon, { className: "h-12 w-12 text-muted-foreground/40" }) })) }), _jsxs("div", { className: "flex flex-col justify-center gap-4 p-6 sm:p-8 lg:p-10", children: [_jsxs("div", { className: "flex items-center gap-3 text-xs font-medium text-muted-foreground", children: [post.category && (_jsx("span", { className: "inline-block rounded-full px-2.5 py-0.5 text-xs font-medium", style: {
|
|
12
15
|
backgroundColor: `${post.category.colour}20`,
|
|
13
16
|
color: post.category.colour,
|
|
14
|
-
}, children: post.category.name })), _jsxs("span", { className: "inline-flex items-center gap-1", children: [_jsx(ClockIcon, { className: "h-3.5 w-3.5" }),
|
|
17
|
+
}, children: post.category.name })), _jsxs("span", { className: "inline-flex items-center gap-1", children: [_jsx(ClockIcon, { className: "h-3.5 w-3.5" }), t("featured_post_card.reading_time", { minutes: String(post.reading_time) })] })] }), _jsx("h2", { className: "text-2xl font-bold leading-tight text-foreground sm:text-3xl lg:text-4xl", children: _jsx(Link, { href: `${basePath}/${post.slug}`, className: "hover:text-primary", children: post.title }) }), post.excerpt && (_jsx("p", { className: "line-clamp-3 text-base text-muted-foreground", children: post.excerpt })), _jsxs("div", { className: "mt-2 flex items-center justify-between", children: [_jsx("time", { className: "text-sm text-muted-foreground", children: formatPostDate(post.publish_date) }), _jsx(Link, { href: `${basePath}/${post.slug}`, "aria-label": t("featured_post_card.read_aria", { title: post.title }), className: "flex h-10 w-10 shrink-0 items-center justify-center rounded-full border border-border text-foreground transition-colors hover:bg-accent", children: _jsx(ArrowRightIcon, { className: "h-5 w-5" }) })] })] })] }));
|
|
15
18
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"blog-index-editorial.d.ts","sourceRoot":"","sources":["../../../src/components/layouts/blog-index-editorial.tsx"],"names":[],"mappings":"AAeA,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,YAAY,CAAC;
|
|
1
|
+
{"version":3,"file":"blog-index-editorial.d.ts","sourceRoot":"","sources":["../../../src/components/layouts/blog-index-editorial.tsx"],"names":[],"mappings":"AAeA,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,YAAY,CAAC;AAMvD,wBAAgB,kBAAkB,CAAC,EACjC,KAAK,EACL,QAAQ,EACR,cAAc,EACd,KAAK,EACL,IAAI,EACJ,UAAU,EACV,KAAsC,EACtC,WAAW,GACZ,EAAE,oBAAoB,2CA+EtB"}
|
|
@@ -13,11 +13,18 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
13
13
|
import { FeaturedPostCard } from "../featured-post-card.js";
|
|
14
14
|
import { EditorialPostCard } from "../editorial-post-card.js";
|
|
15
15
|
import { BlogSearch } from "../blog-search.js";
|
|
16
|
-
|
|
16
|
+
import en from "../../locales/en.json" with { type: "json" };
|
|
17
|
+
import { createPackageT } from "../../lib/create_package_t.js";
|
|
18
|
+
const t = createPackageT("hazo_blog", en);
|
|
19
|
+
export function BlogIndexEditorial({ posts, basePath, searchEndpoint, query, page, totalPages, title = t("common.default_blog_title"), description, }) {
|
|
17
20
|
const showFeatured = !query && posts.length > 0;
|
|
18
21
|
const featured = showFeatured ? posts[0] : null;
|
|
19
22
|
const gridPosts = showFeatured ? posts.slice(1) : posts;
|
|
20
|
-
return (_jsxs("div", { className: "mx-auto max-w-6xl px-4 py-10", children: [_jsxs("div", { className: "mb-8 flex flex-col gap-6 sm:flex-row sm:items-end sm:justify-between", children: [_jsxs("div", { children: [_jsx("h1", { className: "text-4xl font-bold text-foreground sm:text-5xl", children: title }), description && (_jsx("p", { className: "mt-3 max-w-xl text-base text-muted-foreground", children: description })), query && (_jsxs("p", { className: "mt-3 text-sm text-muted-foreground", children: [
|
|
23
|
+
return (_jsxs("div", { className: "mx-auto max-w-6xl px-4 py-10", children: [_jsxs("div", { className: "mb-8 flex flex-col gap-6 sm:flex-row sm:items-end sm:justify-between", children: [_jsxs("div", { children: [_jsx("h1", { className: "text-4xl font-bold text-foreground sm:text-5xl", children: title }), description && (_jsx("p", { className: "mt-3 max-w-xl text-base text-muted-foreground", children: description })), query && (_jsxs("p", { className: "mt-3 text-sm text-muted-foreground", children: [t("common.results_count", {
|
|
24
|
+
count: String(posts.length),
|
|
25
|
+
plural: posts.length !== 1 ? "s" : "",
|
|
26
|
+
query,
|
|
27
|
+
}), "\u00A0\u00B7\u00A0", _jsx("a", { href: basePath, className: "text-primary hover:underline", children: t("common.clear") })] }))] }), _jsx(BlogSearch, { variant: "pill", basePath: basePath, endpoint: searchEndpoint, className: "sm:w-80" })] }), _jsx("div", { className: "border-t border-border" }), posts.length === 0 ? (_jsx("p", { className: "py-16 text-center text-muted-foreground", children: query ? t("common.no_results_for_query", { query }) : t("common.no_posts_yet") })) : (_jsxs(_Fragment, { children: [featured && (_jsx("div", { className: "mt-8", children: _jsx(FeaturedPostCard, { post: featured, basePath: basePath }) })), gridPosts.length > 0 && (_jsx("div", { className: "mt-8 grid items-start sm:grid-cols-2 lg:grid-cols-3 gap-6", children: gridPosts.map((p, i) => (_jsx(EditorialPostCard, { post: p, index: i, basePath: basePath }, p.id))) }))] })), !query && totalPages > 1 && (_jsx("nav", { className: "mt-10 flex justify-center gap-2 text-sm", children: Array.from({ length: totalPages }, (_, i) => i + 1).map((n) => (_jsx("a", { href: `${basePath}?page=${n}`, className: n === page
|
|
21
28
|
? "rounded-full bg-primary px-4 py-1.5 text-primary-foreground"
|
|
22
29
|
: "rounded-full border border-border px-4 py-1.5 hover:bg-accent", children: n }, n))) }))] }));
|
|
23
30
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"blog-index-grid.d.ts","sourceRoot":"","sources":["../../../src/components/layouts/blog-index-grid.tsx"],"names":[],"mappings":"AAWA,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,YAAY,CAAC;
|
|
1
|
+
{"version":3,"file":"blog-index-grid.d.ts","sourceRoot":"","sources":["../../../src/components/layouts/blog-index-grid.tsx"],"names":[],"mappings":"AAWA,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,YAAY,CAAC;AAMvD,wBAAgB,aAAa,CAAC,EAC5B,KAAK,EACL,QAAQ,EACR,cAAc,EACd,KAAK,EACL,IAAI,EACJ,UAAU,EACV,KAAsC,GACvC,EAAE,oBAAoB,2CAoDtB"}
|
|
@@ -9,8 +9,15 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
9
9
|
// Do NOT modernize this file — new layouts belong alongside it, not inside it.
|
|
10
10
|
import { PostCard } from "../post-card.js";
|
|
11
11
|
import { BlogSearch } from "../blog-search.js";
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
import en from "../../locales/en.json" with { type: "json" };
|
|
13
|
+
import { createPackageT } from "../../lib/create_package_t.js";
|
|
14
|
+
const t = createPackageT("hazo_blog", en);
|
|
15
|
+
export function BlogIndexGrid({ posts, basePath, searchEndpoint, query, page, totalPages, title = t("common.default_blog_title"), }) {
|
|
16
|
+
return (_jsxs("div", { className: "mx-auto max-w-6xl px-4 py-10", children: [_jsxs("div", { className: "mb-8 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between", children: [_jsxs("div", { children: [_jsx("h1", { className: "text-3xl font-bold text-foreground", children: title }), query && (_jsxs("p", { className: "mt-1 text-sm text-muted-foreground", children: [t("common.results_count", {
|
|
17
|
+
count: String(posts.length),
|
|
18
|
+
plural: posts.length !== 1 ? "s" : "",
|
|
19
|
+
query,
|
|
20
|
+
}), "\u00A0\u00B7\u00A0", _jsx("a", { href: basePath, className: "text-primary hover:underline", children: t("common.clear") })] }))] }), _jsx(BlogSearch, { basePath: basePath, endpoint: searchEndpoint, className: "sm:w-72" })] }), posts.length === 0 ? (_jsx("p", { className: "text-muted-foreground", children: query ? t("common.no_results_for_query", { query }) : t("common.no_posts_yet") })) : (_jsx("div", { className: "grid gap-6 sm:grid-cols-2 lg:grid-cols-3", children: posts.map((p) => (_jsx(PostCard, { post: p, basePath: basePath }, p.id))) })), !query && totalPages > 1 && (_jsx("nav", { className: "mt-10 flex justify-center gap-2 text-sm", children: Array.from({ length: totalPages }, (_, i) => i + 1).map((n) => (_jsx("a", { href: `${basePath}?page=${n}`, className: n === page
|
|
14
21
|
? "rounded bg-primary px-3 py-1 text-primary-foreground"
|
|
15
22
|
: "rounded border border-border px-3 py-1 hover:bg-accent", children: n }, n))) }))] }));
|
|
16
23
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"youtube.d.ts","sourceRoot":"","sources":["../../../src/components/mdx/youtube.tsx"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"youtube.d.ts","sourceRoot":"","sources":["../../../src/components/mdx/youtube.tsx"],"names":[],"mappings":"AAOA,MAAM,WAAW,YAAY;IAC3B,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED,wBAAgB,OAAO,CAAC,EAAE,EAAE,EAAE,KAAkC,EAAE,KAAK,EAAE,SAAS,EAAE,EAAE,YAAY,2CAcjG"}
|