hazo_blog 0.4.1 → 1.1.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.
Files changed (109) hide show
  1. package/CHANGE_LOG.md +280 -0
  2. package/README.md +292 -2
  3. package/SETUP_CHECKLIST.md +33 -0
  4. package/db_setup_postgres.sql +40 -6
  5. package/db_setup_sqlite.sql +37 -6
  6. package/dist/components/admin/featured-image-field.d.ts +13 -0
  7. package/dist/components/admin/featured-image-field.d.ts.map +1 -0
  8. package/dist/components/admin/featured-image-field.js +46 -0
  9. package/dist/components/admin/post-form.d.ts +16 -2
  10. package/dist/components/admin/post-form.d.ts.map +1 -1
  11. package/dist/components/admin/post-form.js +18 -3
  12. package/dist/components/admin/upload-image.d.ts +17 -0
  13. package/dist/components/admin/upload-image.d.ts.map +1 -0
  14. package/dist/components/admin/upload-image.js +52 -0
  15. package/dist/components/author-bio.d.ts.map +1 -1
  16. package/dist/components/author-bio.js +4 -1
  17. package/dist/components/blog-search.d.ts +3 -1
  18. package/dist/components/blog-search.d.ts.map +1 -1
  19. package/dist/components/blog-search.js +8 -2
  20. package/dist/components/editorial-post-card.d.ts +10 -0
  21. package/dist/components/editorial-post-card.d.ts.map +1 -0
  22. package/dist/components/editorial-post-card.js +20 -0
  23. package/dist/components/faq-section.d.ts.map +1 -1
  24. package/dist/components/faq-section.js +4 -1
  25. package/dist/components/featured-post-card.d.ts +8 -0
  26. package/dist/components/featured-post-card.d.ts.map +1 -0
  27. package/dist/components/featured-post-card.js +18 -0
  28. package/dist/components/icons.d.ts +14 -0
  29. package/dist/components/icons.d.ts.map +1 -0
  30. package/dist/components/icons.js +38 -0
  31. package/dist/components/index.d.ts +1 -0
  32. package/dist/components/index.d.ts.map +1 -1
  33. package/dist/components/index.js +1 -0
  34. package/dist/components/layouts/blog-index-editorial.d.ts +3 -0
  35. package/dist/components/layouts/blog-index-editorial.d.ts.map +1 -0
  36. package/dist/components/layouts/blog-index-editorial.js +30 -0
  37. package/dist/components/layouts/blog-index-grid.d.ts +3 -0
  38. package/dist/components/layouts/blog-index-grid.d.ts.map +1 -0
  39. package/dist/components/layouts/blog-index-grid.js +23 -0
  40. package/dist/components/layouts/index.d.ts +28 -0
  41. package/dist/components/layouts/index.d.ts.map +1 -0
  42. package/dist/components/layouts/index.js +15 -0
  43. package/dist/components/mdx/youtube.d.ts.map +1 -1
  44. package/dist/components/mdx/youtube.js +4 -1
  45. package/dist/components/post-card.d.ts.map +1 -1
  46. package/dist/components/post-card.js +4 -1
  47. package/dist/components/post-hero.d.ts.map +1 -1
  48. package/dist/components/post-hero.js +4 -1
  49. package/dist/components/related-posts.d.ts.map +1 -1
  50. package/dist/components/related-posts.js +4 -1
  51. package/dist/components/table-of-contents.d.ts.map +1 -1
  52. package/dist/components/table-of-contents.js +4 -1
  53. package/dist/index.d.ts +3 -2
  54. package/dist/index.d.ts.map +1 -1
  55. package/dist/index.js +3 -2
  56. package/dist/lib/create_package_t.d.ts +43 -0
  57. package/dist/lib/create_package_t.d.ts.map +1 -0
  58. package/dist/lib/create_package_t.js +98 -0
  59. package/dist/lib/dates.d.ts +9 -0
  60. package/dist/lib/dates.d.ts.map +1 -0
  61. package/dist/lib/dates.js +22 -0
  62. package/dist/lib/index.d.ts +2 -0
  63. package/dist/lib/index.d.ts.map +1 -1
  64. package/dist/lib/index.js +2 -0
  65. package/dist/lib/layouts.d.ts +22 -0
  66. package/dist/lib/layouts.d.ts.map +1 -0
  67. package/dist/lib/layouts.js +32 -0
  68. package/dist/locales/en.json +24 -0
  69. package/dist/next/blog-content.d.ts.map +1 -1
  70. package/dist/next/blog-content.js +4 -1
  71. package/dist/next/image-upload.d.ts +17 -0
  72. package/dist/next/image-upload.d.ts.map +1 -0
  73. package/dist/next/image-upload.js +102 -0
  74. package/dist/next/pages.d.ts +4 -0
  75. package/dist/next/pages.d.ts.map +1 -1
  76. package/dist/next/pages.js +35 -21
  77. package/dist/next/routes.d.ts +28 -0
  78. package/dist/next/routes.d.ts.map +1 -1
  79. package/dist/next/routes.js +133 -9
  80. package/dist/repository/index.d.ts +31 -4
  81. package/dist/repository/index.d.ts.map +1 -1
  82. package/dist/repository/index.js +159 -15
  83. package/dist/seo/index.d.ts +11 -2
  84. package/dist/seo/index.d.ts.map +1 -1
  85. package/dist/seo/index.js +32 -5
  86. package/dist/service/index.d.ts +31 -3
  87. package/dist/service/index.d.ts.map +1 -1
  88. package/dist/service/index.js +87 -6
  89. package/dist/types/index.d.ts +98 -0
  90. package/dist/types/index.d.ts.map +1 -1
  91. package/dist/ui/blog-admin-panel.d.ts +15 -0
  92. package/dist/ui/blog-admin-panel.d.ts.map +1 -0
  93. package/dist/ui/blog-admin-panel.js +48 -0
  94. package/dist/ui/index.d.ts +6 -0
  95. package/dist/ui/index.d.ts.map +1 -0
  96. package/dist/ui/index.js +9 -0
  97. package/dist/ui/layout-selector.d.ts +10 -0
  98. package/dist/ui/layout-selector.d.ts.map +1 -0
  99. package/dist/ui/layout-selector.js +44 -0
  100. package/dist/ui/panel-dialog.d.ts +10 -0
  101. package/dist/ui/panel-dialog.d.ts.map +1 -0
  102. package/dist/ui/panel-dialog.js +22 -0
  103. package/dist/ui/post-list-table.d.ts +11 -0
  104. package/dist/ui/post-list-table.d.ts.map +1 -0
  105. package/dist/ui/post-list-table.js +43 -0
  106. package/dist/ui/use-blog-admin.d.ts +41 -0
  107. package/dist/ui/use-blog-admin.d.ts.map +1 -0
  108. package/dist/ui/use-blog-admin.js +150 -0
  109. package/package.json +22 -14
package/CHANGE_LOG.md CHANGED
@@ -3,6 +3,286 @@
3
3
  All notable changes are documented here. This project follows
4
4
  [Semantic Versioning](https://semver.org/).
5
5
 
6
+ ## 1.1.0 — 2026-09-11
7
+
8
+ **`onPostSaved` config hook + `createBlogManageSlugRoutes` factory**
9
+
10
+ Two additive capabilities requested by a consumer (stocktools) that needs to react to individual
11
+ post writes/deletes and to manage a single post by slug over the programmatic (API-key) surface,
12
+ rather than only through the collection-level `createBlogManageRoutes`.
13
+
14
+ **1. `BlogConfig.onPostSaved?: (post: BlogPost | null, action: 'upsert' | 'delete') => void |
15
+ Promise<void>`** — mirrors the existing `onSettingsChanged` hook. `BlogService.upsertBySlug` calls
16
+ it with the saved `BlogPost` and `'upsert'` after a successful write; `BlogService.deletePost`
17
+ calls it with `null` and `'delete'` after a successful delete. Both call sites wrap the hook in a
18
+ `try/catch` — a throwing (or rejecting) `onPostSaved` never fails the surrounding write/delete,
19
+ same "best-effort" contract as `onSettingsChanged`. Useful for cache invalidation
20
+ (`revalidatePath`) or search-index sync keyed off individual posts rather than the whole settings
21
+ blob.
22
+
23
+ **2. `createBlogManageSlugRoutes(config: BlogConfig)`** — a new route-handler factory alongside
24
+ `createBlogManageRoutes`, for a Next.js `[slug]` dynamic route on the programmatic management API.
25
+ Returns `{ GET, PATCH, DELETE }`, each gated by `withApiKey` the same way as the collection route
26
+ (`blog:read` for `GET`, `blog:write` for `PATCH`/`DELETE`):
27
+
28
+ - `GET` — fetches one post by slug (including drafts), 404 via `fail("NOT_FOUND", ...)` when
29
+ missing.
30
+ - `PATCH` — fetches the existing post by slug (404 if missing), merges the JSON body's fields onto
31
+ it (same field-by-field merge semantics as `createBlogManageRoutes`'s collection `PATCH`), and
32
+ calls `service.upsertBySlug`.
33
+ - `DELETE` — fetches the existing post by slug (404 if missing, to resolve its `id`), calls
34
+ `service.deletePost(id)`, and returns `{ deleted: slug }`.
35
+
36
+ Exported from the package barrel alongside the other route factories. No breaking changes.
37
+
38
+ ## 1.0.0 — 2026-09-05
39
+
40
+ **FR-034, Part 1 — locale variants + `indexable` (schema + types + repository + service)**
41
+
42
+ This is the schema/data-layer half of FR-034 (locale-variant posts, categories, and tags, plus a
43
+ per-post `indexable` flag for excluding a post from the sitemap/robots without unpublishing it).
44
+ The SEO layer (`src/seo/index.ts`), Next.js pages/routes, and the admin `PostForm` UI are a
45
+ follow-up change — this release only touches schema, `src/types`, `src/repository`, and
46
+ `src/service`. Two breaking changes warrant the major bump:
47
+
48
+ **1. Database schema — new columns + a changed unique constraint**
49
+
50
+ `hazo_blog_posts` gains `locale TEXT DEFAULT NULL`, `translation_of TEXT REFERENCES
51
+ hazo_blog_posts(id) ON DELETE SET NULL` (`UUID` on Postgres), and `indexable INTEGER NOT NULL
52
+ DEFAULT 1` (`BOOLEAN NOT NULL DEFAULT TRUE` on Postgres). `hazo_blog_categories` and
53
+ `hazo_blog_tags` each gain `locale TEXT DEFAULT NULL`. All three tables' `(scope_id, slug)` unique
54
+ index is replaced by `(scope_id, slug, COALESCE(locale, ''))` — the `COALESCE` folds every
55
+ default-locale (`locale IS NULL`) row into one uniqueness bucket per `(scope_id, slug)`, since a
56
+ plain `(scope_id, slug, locale)` unique index would let SQLite/Postgres treat every `NULL` as
57
+ distinct from every other `NULL`, silently allowing duplicate default-locale posts. A new
58
+ `idx_hazo_blog_posts_translation_of` index backs translation-cluster lookups.
59
+
60
+ No SQLite table rebuild is required — `ALTER TABLE ... ADD COLUMN` works fine for these
61
+ plain nullable/defaulted columns, and the old unique constraints were already separate
62
+ `CREATE UNIQUE INDEX` statements rather than inline column constraints, so the index swap is a
63
+ `DROP INDEX` + `CREATE UNIQUE INDEX` pair (both idempotent, safe to run on every process start).
64
+ `db_setup_sqlite.sql`/`db_setup_postgres.sql` and their `migrations/` mirrors carry the new columns
65
+ directly in their `CREATE TABLE IF NOT EXISTS` lists (so a fresh DB gets them immediately) plus the
66
+ idempotent index-swap statements. An already-materialized SQLite dev DB (this package's
67
+ `test-app/data/blog.sqlite`) won't pick up new columns from a skipped `CREATE TABLE IF NOT EXISTS`
68
+ — `test-app/scripts/seed.mjs` gained a small `ensureColumns()` helper (guarded `PRAGMA
69
+ table_info` + `ALTER TABLE ... ADD COLUMN`) that backfills them before the schema file's DROP/CREATE
70
+ INDEX statements run. Postgres needs no such helper — `ALTER TABLE ... ADD COLUMN IF NOT EXISTS`
71
+ is natively idempotent and is included directly in `db_setup_postgres.sql`.
72
+
73
+ **2. `BlogRepository`/`BlogService` breaking signature changes**
74
+
75
+ - `getPostBySlug(slug, includeUnpublished?: boolean)` → `getPostBySlug(slug, opts?: {
76
+ includeUnpublished?: boolean; locale?: string | null })`. The old positional boolean 2nd
77
+ argument is gone.
78
+ ```ts
79
+ // before
80
+ await repo.getPostBySlug(slug, true);
81
+ // after
82
+ await repo.getPostBySlug(slug, { includeUnpublished: true });
83
+ ```
84
+ - `getAllSlugs(includeUnpublished?)` now returns `Array<{ slug: string; locale: string | null }>`
85
+ instead of `string[]` (unfiltered by locale — every translation's slug is included).
86
+ - New `getTranslations(postId)` on both `BlogRepository` and `BlogService`, resolving a post's full
87
+ translation cluster (root + every other variant, normalizing two-hop sibling→root→sibling chains
88
+ into one flat list) as `TranslationInfo[]`.
89
+ - `BlogService.getPost(slug, opts?, req?)` gains `opts.locale` and `opts.fallbackToDefault`
90
+ (default `false`). With `fallbackToDefault: false`, a locale-specific miss returns `null` rather
91
+ than silently serving the default-locale post.
92
+ - `listPosts`, `searchPosts`/`search`, `getRelatedPosts`, and `getOrCreateCategory` are all
93
+ locale-aware now; omitting `locale` anywhere continues to mean "default-locale only"
94
+ (`locale IS NULL`), so **existing single-locale consumers need no query changes** — every new
95
+ parameter is optional and defaults to the pre-1.0 behaviour.
96
+
97
+ **Migration note for existing consumers:** the only required change is any call site using the old
98
+ `getPostBySlug(slug, true)` positional form — switch it to `getPostBySlug(slug, {
99
+ includeUnpublished: true })`. Everything else (locale filtering, translation clusters, `indexable`)
100
+ is additive and opt-in.
101
+
102
+ **New types:** `BlogPost.locale`/`translation_of`/`indexable` (the last is a required `boolean` —
103
+ the repository normalizes SQLite's raw `0`/`1` `INTEGER` read-back to a real boolean so the type is
104
+ honest on both dialects), `UpsertPostInput.locale`/`translation_of`/`indexable`,
105
+ `ListPostsOptions.locale`, `BlogCategory.locale`, `BlogTag.locale`, `BlogPostWithRelations.translations`,
106
+ new `TranslationInfo`, and `BlogConfig.defaultLocale` (default `'en'`, consumed by the follow-up SEO
107
+ change).
108
+
109
+ **Tests:** `src/__tests__/repository.integration.test.ts` gained a `FR-034` describe block (default
110
+ + translated variant CRUD, DB-level uniqueness enforcement including the two-default-locale-NULLs
111
+ case, cross-locale listing, translation-cluster resolution with two-hop normalization, unfiltered
112
+ `getAllSlugs`). New `src/__tests__/locale.integration.test.ts` exercises the schema files directly
113
+ against a fresh SQLite DB: new columns exist, the locale-aware unique indexes replace the old ones,
114
+ the `COALESCE(locale, '')` NULL-folding behavior, `translation_of`'s FK (including `ON DELETE SET
115
+ NULL`), the `indexable` default, and DDL idempotency (schema file re-run twice, no errors, no data
116
+ loss).
117
+
118
+ **FR-034, Part 2 — SEO / Next.js pages & routes / admin UI**
119
+
120
+ The SEO-facing and presentation half of FR-034, building on Part 1's schema/repository/service
121
+ layer above. Additive only — no further breaking changes on top of Part 1's major bump.
122
+
123
+ - **`src/seo/index.ts`:**
124
+ - `isIndexable(post)` now also returns `false` when `post.indexable === false`, independent of
125
+ the existing draft/publish-date gate — a live published post can still be excluded from the
126
+ sitemap/RSS/robots via the per-post flag without being unpublished.
127
+ - `buildBlogPostingJsonLd`'s JSON-LD `inLanguage` is now `post.locale || config.defaultLocale ||
128
+ "en"` instead of a hardcoded `"en"`.
129
+ - `getBlogSitemapEntries` gained a new `SitemapEntry.alternates?: Array<{ locale: string; url:
130
+ string }>` field, populated from a post's `translations` (indexable siblings only, via
131
+ `postUrl`) when present. This is intentionally passive: the function does no DB lookups of its
132
+ own, so a host wanting hreflang alternates in its sitemap must attach `.translations` (from
133
+ `BlogService.getTranslations`) to each post before calling it — the existing
134
+ `createBlogSitemapRoute` call site doesn't do this yet, since it lists posts in bulk and
135
+ per-post translation lookups there are a host-level tradeoff, not this package's call to make.
136
+ - `getBlogRssXml`'s channel `<language>` now follows `config.defaultLocale` (default `"en"`)
137
+ instead of a hardcoded `"en"`. Items whose `post.locale` differs from the feed-level language
138
+ get a per-item `<dc:language>` element, and the `<rss>` root gains the `xmlns:dc` namespace
139
+ attribute only when at least one item actually needed it (an all-default-locale feed is
140
+ byte-identical to before this change).
141
+ - `src/__tests__/seo.test.ts`'s `makePost()` helper now sets the required `indexable: true` field
142
+ (fixes a `type-check` failure introduced by Part 1's `BlogPost.indexable: boolean` becoming
143
+ non-optional). New tests cover locale-aware `inLanguage`, `indexable: false` sitemap exclusion,
144
+ sitemap `alternates` construction (including skipping non-indexable siblings), and the RSS
145
+ `<language>`/`<dc:language>`/`xmlns:dc` behavior.
146
+
147
+ - **`src/next/pages.tsx`** (`createBlogPostPage`):
148
+ - `generateMetadata` now also awaits `service.getTranslations(post.id)` and, when the cluster is
149
+ non-empty, adds a Next.js Metadata API `alternates.languages` map (`{ [locale]: url }`, built
150
+ via `postUrl`, indexable siblings only) alongside the existing `alternates.canonical`.
151
+ - Both `generateMetadata` and the page component now accept an optional `params.locale` (in
152
+ addition to the existing `params.slug`) and, when present, thread it through to
153
+ `service.getPost(slug, { locale })`. When absent, behavior is unchanged from before this
154
+ release — locale-prefixed routing remains the consuming app's concern (see `postUrl`'s
155
+ doc-comment); this package only adds the pass-through slot.
156
+
157
+ - **`src/next/routes.ts`:**
158
+ - `createBlogSearchRoute` now reads an optional `?locale=` query param and passes it through to
159
+ `service.search(q, 20, req, locale)`.
160
+ - `createBlogManageRoutes`'s `PATCH` handler now accepts `locale`, `translation_of`, and
161
+ `indexable` in the request body and merges them into the existing-post-derived
162
+ `UpsertPostInput`, same pattern as every other partial field on that handler.
163
+
164
+ - **`src/components/admin/post-form.tsx`:** `PostForm` gained three new fields, all sent through
165
+ the existing unified `save()` payload: a free-text **Locale** input (BCP-47 code, blank = default
166
+ locale), a free-text **Translation of (post ID)** input (labeled explicitly since
167
+ `translation_of` stores the parent post's `id`, not its slug — no post-picker UI exists in this
168
+ form to reuse), and an **Indexable** checkbox (defaults to `true`, matching the DB default). All
169
+ three keep the file's existing `bg-background`/`border-border` token classes per the isolation
170
+ rule documented in `CLAUDE.md` (this form is the one accepted exception inside the
171
+ otherwise-token-free `src/ui/*` boundary).
172
+
173
+ - **`test-app/app/autotest/hazo_blog_scenarios.ts`:** the browser-side `POST` fixture used by the
174
+ `/autotest` SEO-builder scenarios gained the required `indexable: true` field (same root cause as
175
+ the `seo.test.ts` fix above — this only surfaces in a full test-app Next build, not `tsc
176
+ --noEmit`, since it's outside this package's own `tsconfig.json` project).
177
+
178
+ **Out of scope for Part 2 (deliberately):** wiring `createBlogSitemapRoute`/`createBlogFeedRoute` to
179
+ actually populate `.translations` on each post before calling `getBlogSitemapEntries`/
180
+ `getBlogRssXml` — that requires either N+1 `getTranslations` calls per sitemap post or a new bulk
181
+ repository method, and no call site in this package currently needs it (`SitemapEntry.alternates`
182
+ and the RSS locale fields work correctly today for any host that already attaches `.translations`
183
+ itself, e.g. via a custom sitemap route). A locale-prefixed URL scheme for `postUrl`/route
184
+ generation (e.g. `/blog/fr/hello-world`) — still explicitly the consuming app's routing concern per
185
+ existing package convention. A post-picker UI for `translation_of` in `PostForm` — the plain
186
+ post-ID text input is the proportionate choice given no such picker exists elsewhere in this form.
187
+
188
+ ## 0.5.0 — 2026-08-17
189
+
190
+ **Two named `/blog` index layouts — `grid` (unchanged) + new `editorial`**
191
+
192
+ `BlogIndexLayout` (`src/lib/layouts.ts`) is now `"grid" | "editorial"`, with `BLOG_INDEX_LAYOUT_META`
193
+ carrying a label + description for each. `BlogIndexGrid` is a verbatim extraction of the previous
194
+ inline index markup — the back-compat anchor. `BlogIndexEditorial` is new: a big title/subtitle, a
195
+ pill search box, a full-width featured split card for the latest post, then a staggered 3-column
196
+ grid. `createBlogIndexPage` selects between them via `BLOG_INDEX_LAYOUTS`
197
+ (`src/components/layouts/index.ts`), keyed by the host's persisted `BlogSettings.indexLayout` (or
198
+ `BlogConfig.defaultIndexLayout` when nothing is persisted). Both layouts accept the identical
199
+ `BlogIndexLayoutProps` — that's the point, they're swappable without touching the page factory.
200
+
201
+ **Package-owned settings store (`hazo_blog_settings`, new table)**
202
+
203
+ A single host-wide settings row (`BLOG_SETTINGS_KEY = "display"`, tenant-scoped like every other
204
+ table when `scope_id` is set), read/written through three new `BlogService` methods:
205
+ `getSettings()` (never throws — falls back to `defaultIndexLayout`), `updateSettings(patch)`
206
+ (merge + persist, throws a typed `BlogSettingsUnavailableError` if the table can't be written),
207
+ and `settingsAvailable()` (a cheap existence probe). Read degrades gracefully, write surfaces
208
+ failure clearly — see "Key invariants" in `CLAUDE.md` for the reasoning. Exposed over HTTP via
209
+ the new `createBlogSettingsRoutes` (`GET`/`PUT`, cookie/session-guarded via the newly-extracted
210
+ `createAuthGuard` helper, now shared with `createBlogAdminRoutes`).
211
+
212
+ **Real admin panel — new `./ui` export, `BlogAdminPanel`**
213
+
214
+ Replaces the "coming soon" stub story for host admin shells (e.g. `hazo_admin`) with a working
215
+ panel: `BlogAdminPanel` composes `PostListTable` + the existing `PostForm` (in a new `PanelDialog`)
216
+ + a new `LayoutSelector` for the index-layout setting, all driven by the `useBlogAdmin` hook. The
217
+ whole tree is pure React, `next/*`-free, and deliberately token-free Tailwind (not this package's
218
+ usual `bg-background`-style tokens) so it renders correctly inside a host shell that may not define
219
+ `hazo_theme`'s CSS variables — the strictest export-boundary tier in the package. See `AGENTS.md`
220
+ for the full isolation rule.
221
+
222
+ **Featured-image + MDX-image uploads — one endpoint, one policy**
223
+
224
+ `createBlogImageUploadRoute` (`hazo_blog/next`) delegates to `hazo_images`' image-processing
225
+ handler (an optional peer, dynamically imported so hosts without it installed don't crash — 501s
226
+ with a named-package message instead) and rewrites the saved URL through the new
227
+ `BlogConfig.media` config (`getFileManager`/`pathPrefix`/`publicUrlBase`/`toPublicUrl`). Deliberately
228
+ one endpoint with one upload policy: a host wires the same route to both the featured-image picker
229
+ (new `featured-image-field.tsx`, used by `PostForm`) and an MDX editor's "insert image" action,
230
+ rather than standing up two handlers. 501s clearly when `media.getFileManager` isn't configured,
231
+ independent of the `hazo_images`-not-installed 501.
232
+
233
+ **Dark-mode search dropdown fix**
234
+
235
+ `BlogSearch`'s results dropdown previously hardcoded `style={{ backgroundColor: "white" }}` plus
236
+ raw `zinc-*` classes, which was genuinely illegible against a dark host theme. Fixed to use theme
237
+ tokens. New optional `variant?: "default" | "pill"` prop (default preserves the exact prior
238
+ markup/behaviour) — this is a fix, not a new feature; call it out explicitly as such.
239
+
240
+ **Back-compat: zero break**
241
+
242
+ Every consumer on `^0.4.x` upgrades with no code changes required. The default index layout
243
+ (`grid`) renders byte-identical markup to the pre-0.5.0 inline implementation. Every new
244
+ `BlogConfig` field (`defaultIndexLayout`, `media`, `settingsApiPath`, `imageUploadApiPath`,
245
+ `onSettingsChanged`) is optional. `createBlogAdminRoutes`' `collection.GET` only gains a response
246
+ key. A host that never migrates the new `hazo_blog_settings` table simply gets `getSettings()`
247
+ falling back to `defaultIndexLayout` (or `"grid"`) forever, with one process-lifetime console
248
+ warning — never a crash.
249
+
250
+ **Internal**
251
+
252
+ - New `src/ui/*` test coverage alongside the existing `src/__tests__/` suites.
253
+ - `__mocks__/hazo_core.cjs` was missing `globalSingleton`/`resetGlobalSingleton` — a real
254
+ pre-existing gap, not something this release introduced. `hazo_connect`'s dependency chain now
255
+ calls both at module load, and the stale mock was blocking 2 existing test suites from loading
256
+ at all ("hazo_core does not provide an export named 'globalSingleton'"). Fixed with a
257
+ self-contained promise-cache implementation matching real `hazo_core`'s evict-on-reject
258
+ semantics, deliberately kept separate from the older `registerSingleton`/`getSingleton` mock
259
+ pair rather than unifying the two (that pair already diverges from the real factory-less API,
260
+ and changing it risked currently-passing tests).
261
+ - `src/__tests__/seo.test.ts`'s `ResolvedBlogConfig` test fixture was missing `adminBasePath`/
262
+ `adminApiBasePath` — a pre-existing, unrelated `tsc` error, fixed incidentally while touching
263
+ this release's config surface.
264
+
265
+ ## 0.4.2 — 2026-08-14
266
+
267
+ **`BlogConfig.apiKeyService` (new, optional) — fixes a `createBlogManageRoutes` contract bug**
268
+
269
+ `createBlogManageRoutes` (the programmatic, hazo_api-key-guarded `/api/blog/manage` routes)
270
+ always built its API-key validation adapter as
271
+ `createApiKeyService({ getHazoConnect: config.getHazoConnect })`. But `getHazoConnect` is
272
+ documented — and used elsewhere, by `createBlogRepository` — as a hazo_connect CRUD adapter,
273
+ a *different* shape than hazo_api's raw-SQL `{dialect, query(sql, params)}` contract that
274
+ `createApiKeyService` actually needs. Any host whose `getHazoConnect` returns the (expected)
275
+ CRUD adapter got a route that threw on every request to `/api/blog/manage` — found while wiring
276
+ this into stocktools, whose `getHazoConnect` returns its hazo_connect singleton.
277
+
278
+ `apiKeyService?: ApiKeyService` on `BlogConfig` lets a host inject an already-built
279
+ `ApiKeyService` — from `hazo_api`'s `createApiKeyService` (raw-SQL) or
280
+ `createPostgrestApiKeyService` (no raw-SQL conduit needed, the better fit for a PostgREST
281
+ backend) — that `createBlogManageRoutes` uses in preference to the old
282
+ `getHazoConnect`-derived fallback. Fully backward-compatible: omitting the field preserves the
283
+ exact pre-existing behaviour (and its pre-existing footgun, for hosts that happen to already
284
+ satisfy the raw-SQL shape). New test: `src/__tests__/routes.manage-api-key.test.ts`.
285
+
6
286
  ## 0.4.0 — 2026-07-14
7
287
 
8
288
  **`validatePost()` — pre-publish content-quality gate (new, `hazo_blog/lib`)**
package/README.md CHANGED
@@ -4,7 +4,11 @@ SEO-first blogging for Next.js App Router apps: posts, categories, tags, MDX
4
4
  content, and GA4 / Google Search Console / Bing-ready output (BlogPosting +
5
5
  BreadcrumbList + FAQPage JSON-LD, canonical, OG, sitemap, RSS). The package owns
6
6
  the page layout **and** all SEO; the host customizes via Tailwind theme tokens
7
- and a single `BlogConfig`.
7
+ and a single `BlogConfig`. The `/blog` index ships two selectable layouts (grid
8
+ and editorial — see [Layouts](#layouts)), and the admin surface can be mounted
9
+ either as sealed pages or embedded as one framework-agnostic panel component
10
+ (`hazo_blog/ui`'s `BlogAdminPanel` — see
11
+ [Option C](#option-c--embed-the-panel-in-your-admin-shell)).
8
12
 
9
13
  ## Install
10
14
 
@@ -42,6 +46,13 @@ export const blogConfig: BlogConfig = {
42
46
  defaultOgImage: "https://acme.com/og.png",
43
47
  author: { name: "Acme Team", url: "https://acme.com/about" },
44
48
  getHazoConnect: () => myHazoConnectAdapter, // SQLite or Postgres
49
+ // Only needed if you mount createBlogManageRoutes (step 4's `manage`
50
+ // export) — `getHazoConnect` above is a hazo_connect CRUD adapter, a
51
+ // DIFFERENT shape than the raw-SQL adapter hazo_api's API-key validation
52
+ // needs, so most hosts must supply this explicitly rather than relying on
53
+ // the (rarely-satisfiable) getHazoConnect fallback. Build one with
54
+ // hazo_api's `createApiKeyService`/`createPostgrestApiKeyService`:
55
+ // apiKeyService: createPostgrestApiKeyService({ postgrest_url, postgrest_api_key }),
45
56
  authorize: (req) => checkAdminPermission(req), // wire to hazo_auth
46
57
  // resolveScope: (req) => orgIdFrom(req), // multi-tenant only
47
58
  // getAuthor: (id) => lookupAuthor(id), // per-post authors
@@ -137,6 +148,233 @@ export default async function sitemap() {
137
148
  }
138
149
  ```
139
150
 
151
+ ## Settings
152
+
153
+ hazo_blog owns a small settings table (`hazo_blog_settings`) for host-configurable
154
+ display/behavior settings. As of 0.5.0 it holds one setting — `indexLayout` (see
155
+ [Layouts](#layouts)) — with room for more to be added later without a config or
156
+ DDL churn for hosts already wired up.
157
+
158
+ **Read** — `service.getSettings(req?)` never throws. It reads the persisted row
159
+ and falls back to `{ indexLayout: config.defaultIndexLayout ?? "grid" }` if the
160
+ table is missing or the read errors — logging one warning per process, not per
161
+ request, so a fleet of requests hitting an un-migrated table doesn't spam logs.
162
+
163
+ **Write** — `service.updateSettings(patch, req?)` merges `patch` into the current
164
+ settings and persists it. Throws `BlogSettingsUnavailableError` if the table
165
+ can't be written to (e.g. not migrated yet) — the route factory below turns
166
+ this into a `503`.
167
+
168
+ **HTTP surface** — mount `createBlogSettingsRoutes(config)` at
169
+ `config.settingsApiPath` (default `${adminApiBasePath}/settings`):
170
+
171
+ ```ts
172
+ // app/api/admin/blog/settings/route.ts
173
+ import { createBlogSettingsRoutes } from "hazo_blog";
174
+ import { blogConfig } from "@/lib/blog-config";
175
+
176
+ const routes = createBlogSettingsRoutes(blogConfig);
177
+ export const GET = routes.GET;
178
+ export const PUT = routes.PUT;
179
+ ```
180
+
181
+ Both `GET` and `PUT` are guarded by `config.authorize`, same as the other admin
182
+ routes. `GET` returns:
183
+
184
+ ```ts
185
+ {
186
+ settings: BlogSettings; // { indexLayout: "grid" | "editorial" }
187
+ defaults: { indexLayout?: BlogIndexLayout }; // echoes config.defaultIndexLayout
188
+ layouts: typeof BLOG_INDEX_LAYOUT_META; // id/label/description per layout
189
+ persisted: boolean; // false = table not migrated, read-only
190
+ }
191
+ ```
192
+
193
+ `PUT` accepts a `Partial<BlogSettings>` body and returns `{ settings }` on
194
+ success, or `503 SETTINGS_UNAVAILABLE` when the table can't be written to (the
195
+ same `BlogSettingsUnavailableError` from `getSettings`/`updateSettings` above).
196
+
197
+ **Schema** — `hazo_blog_settings` (dual-dialect, defined in `db_setup_sqlite.sql`
198
+ / `db_setup_postgres.sql` alongside the other four tables): one row per
199
+ `(scope_id, setting_key)` with a JSON `payload` column. Apply it the same way as
200
+ the rest of this package's DDL — through your migration runner on SQLite, or a
201
+ manual `psql -f` step on Postgres, per your app's own DDL convention.
202
+
203
+ **`onSettingsChanged`** — fires after a successful `updateSettings` write,
204
+ inside its own `try/catch` so a host revalidate failure can't fail the settings
205
+ save itself:
206
+
207
+ ```ts
208
+ onSettingsChanged: () => {
209
+ try {
210
+ revalidatePath("/blog");
211
+ } catch {
212
+ // No-op outside a request/render context (e.g. called from a script).
213
+ }
214
+ },
215
+ ```
216
+
217
+ This matters because `/blog` is typically ISR-cached (`revalidate = 3600` from
218
+ step 3) — without a revalidate hook, switching layouts via the admin panel
219
+ won't be visible on the public index until the next natural ISR window.
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
+
262
+ ## Layouts
263
+
264
+ `config.defaultIndexLayout` (and the runtime override in [Settings](#settings))
265
+ picks which component renders the `/blog` index — `BLOG_INDEX_LAYOUT_META`
266
+ (exported from `hazo_blog` and `hazo_blog/lib`) describes both:
267
+
268
+ | Layout | Looks like | Use it when |
269
+ |---|---|---|
270
+ | `grid` (default) | Classic responsive card grid — one flat `sm:grid-cols-2 lg:grid-cols-3` layout for every post. Byte-identical to the pre-0.5.0 index markup. | You want the plainest, safest option, or your posts don't reliably carry a `featured_image`. |
271
+ | `editorial` | Big title + subtitle, pill search, a hairline rule, a full-width featured split card for the newest post, then a staggered 3-column grid below. | You want a more magazine-like landing page and most posts have a `featured_image`. |
272
+
273
+ **Degradation behavior** (same for both layouts unless noted):
274
+
275
+ - **0 posts** — empty state.
276
+ - **1 post** — `editorial` renders the featured-card only (no grid below); `grid` renders one card.
277
+ - **2 posts** — `editorial` renders the featured card + 1 grid card below.
278
+ - **Active search (`?q=`)** — `editorial` drops the featured card entirely; every matching post
279
+ (including the newest) renders as a plain grid card, same as `grid`.
280
+ - **`featured_image: null`** — both the featured card and grid cards render a
281
+ gradient + icon placeholder instead of an image block. No broken-image state either layout.
282
+ - **Mobile** — single column; `editorial`'s featured card stacks image-over-text instead of
283
+ side-by-side.
284
+
285
+ **Set the default** at boot via `defaultIndexLayout` in `BlogConfig` (falls back
286
+ to `"grid"` when omitted). **Switch it live** at runtime via the admin panel's
287
+ built-in `LayoutSelector` (see [Option C](#option-c--embed-the-panel-in-your-admin-shell)),
288
+ which writes through `service.updateSettings({ indexLayout })` to the
289
+ `hazo_blog_settings` table above — no redeploy required, only an ISR revalidate
290
+ (wire `onSettingsChanged` as shown in [Settings](#settings), or wait out the
291
+ natural `revalidate` window).
292
+
293
+ > **Remote images:** the `editorial` layout's featured card leans hard on
294
+ > `featured_image` being visible (`grid`'s thumbnails always did too, just
295
+ > smaller). If any post's `featured_image` is a remote, non-same-origin URL,
296
+ > configure `images.remotePatterns` in your own `next.config.js` — otherwise
297
+ > `next/image` refuses to render it. See
298
+ > [Required Next.js config](#required-nextjs-config).
299
+
300
+ ## Images
301
+
302
+ Featured-image and MDX-body-image uploads share **one endpoint and one upload
303
+ policy** — there's no separate pipeline for the two.
304
+
305
+ **Server** — `createBlogImageUploadRoute(config, deps?)`, mounted at
306
+ `config.imageUploadApiPath` (default `${adminApiBasePath}/images`):
307
+
308
+ ```ts
309
+ // app/api/admin/blog/images/route.ts
310
+ import { createBlogImageUploadRoute } from "hazo_blog";
311
+ import { blogConfig } from "@/lib/blog-config";
312
+
313
+ export const POST = createBlogImageUploadRoute(blogConfig);
314
+ ```
315
+
316
+ It requires **both**:
317
+ - `hazo_images` installed (optional peer) — `501 IMAGE_PIPELINE_UNAVAILABLE` naming the missing
318
+ package if it isn't.
319
+ - `config.media` configured (see `BlogMediaConfig` below) — `501 MEDIA_NOT_CONFIGURED` if absent.
320
+
321
+ On success it rewrites the saved file's URL via `config.media.toPublicUrl` (if
322
+ given), or by joining `config.media.publicUrlBase` with the stored path
323
+ otherwise:
324
+
325
+ ```ts
326
+ interface BlogMediaConfig {
327
+ getFileManager: () => BlogFileManager | Promise<BlogFileManager>;
328
+ pathPrefix?: string; // default "/hazo-blog"
329
+ publicUrlBase?: string;
330
+ toPublicUrl?: (saved: { path: string; url: string }) => string;
331
+ maxUploadBytes?: number;
332
+ allowedTypes?: string[];
333
+ processOptions?: Record<string, unknown>;
334
+ }
335
+ ```
336
+
337
+ `BlogFileManager` is a **structural** interface (`uploadFile(source, remotePath, options?)`) — you
338
+ don't need an adapter to satisfy it with `hazo_files`' `FileManager`/`TrackedFileManager`, e.g.:
339
+
340
+ ```ts
341
+ // lib/blog-config.ts
342
+ import { createInitializedFileManager, type FileManager } from "hazo_files";
343
+
344
+ let fileManagerPromise: Promise<FileManager> | null = null;
345
+ function getFileManager(): Promise<FileManager> {
346
+ if (!fileManagerPromise) {
347
+ fileManagerPromise = createInitializedFileManager({
348
+ config: { provider: "local", local: { basePath: "/path/to/public/uploads" } },
349
+ });
350
+ }
351
+ return fileManagerPromise;
352
+ }
353
+
354
+ export const blogConfig: BlogConfig = {
355
+ // ...
356
+ media: {
357
+ getFileManager: () => getFileManager(),
358
+ pathPrefix: "/hazo-blog",
359
+ publicUrlBase: "/uploads",
360
+ },
361
+ };
362
+ ```
363
+
364
+ **Client** — `createBlogImageUploader({ endpoint, fetchFn?, processOptions? })`
365
+ (exported from `hazo_blog/client`) returns a `(file: File) => Promise<string | null>`
366
+ function — never throws, resolves `null` on any failure. This is exactly what
367
+ `PostForm`'s featured-image field and its `MarkdownEditor`'s inline-image button
368
+ call under the hood when `PostForm` is given `imageUploadEndpoint`:
369
+
370
+ ```tsx
371
+ "use client";
372
+ import { createBlogImageUploader } from "hazo_blog/client";
373
+
374
+ const uploadImage = createBlogImageUploader({ endpoint: "/api/admin/blog/images" });
375
+ const url = await uploadImage(file); // string | null
376
+ ```
377
+
140
378
  ## 6. Admin pages
141
379
 
142
380
  ### Option A — Sealed admin pages (recommended)
@@ -185,6 +423,51 @@ import { PostForm } from "hazo_blog/client";
185
423
  // For edit: <PostForm post={existingPost} categories={categories} ... />
186
424
  ```
187
425
 
426
+ ### Option C — embed the panel in your admin shell
427
+
428
+ `hazo_blog/ui` exports `BlogAdminPanel` — a **framework-agnostic** (no `next/*`
429
+ imports), **token-free-styled** (raw Tailwind palette classes, not this
430
+ package's theme CSS variables) React component. Token-free means it drops into
431
+ any host admin shell without that host needing to adopt `hazo_theme`.
432
+
433
+ ```ts
434
+ interface BlogAdminPanelProps {
435
+ fetchFn: (path: string, init?: RequestInit) => Promise<Response>;
436
+ basePath: string; // e.g. "/api/admin/blog"
437
+ blogBasePath?: string; // public link base, default "/blog"
438
+ settingsPath?: string; // default `${basePath}/settings`
439
+ imageUploadPath?: string; // default `${basePath}/images`
440
+ className?: string;
441
+ }
442
+ ```
443
+
444
+ ```tsx
445
+ // A "use client" host component (fetchFn is a closure — it can't cross the
446
+ // server→client prop boundary unless it's a Server Action, so this has to be
447
+ // a client module, not a server component rendering a client child).
448
+ "use client";
449
+ import { BlogAdminPanel } from "hazo_blog/ui";
450
+
451
+ export function BlogAdmin() {
452
+ return (
453
+ <BlogAdminPanel
454
+ fetchFn={(path, init) => fetch(path, { credentials: "include", ...init })}
455
+ basePath="/api/admin/blog"
456
+ />
457
+ );
458
+ }
459
+ ```
460
+
461
+ It provides: a searchable/filterable post list, create/edit via a built-in
462
+ dialog (wraps `PostForm`), delete, and the layout selector from
463
+ [Layouts](#layouts) (with a "not migrated, read-only" banner when settings
464
+ persistence isn't available — see [Settings](#settings)).
465
+
466
+ This is the pattern `hazo_admin` 0.13.2 uses to lazy-mount the panel (a dynamic
467
+ `import("hazo_blog/ui")` behind a `.catch()` fallback, same shape as its
468
+ `hazo_jobs/ui` integration) — see `hazo_admin`'s own docs/CHANGE_LOG for that
469
+ side of the wiring, not this package's concern.
470
+
188
471
  ## Required Next.js config
189
472
 
190
473
  ```js
@@ -202,7 +485,13 @@ const nextConfig = {
202
485
  `node_modules/sql.js/dist/sql-wasm.wasm` to your app's `public/sql-wasm.wasm`.
203
486
  - **Tailwind v4:** add `@source "../node_modules/hazo_blog/dist";` to your CSS so
204
487
  the package's classes are compiled. The admin editor's styles load automatically
205
- via `hazo_ui`'s `MarkdownEditor`.
488
+ via `hazo_ui`'s `MarkdownEditor`. This single, whole-`dist`-directory entry already
489
+ covers the 0.5.0 `dist/ui` (`BlogAdminPanel`) and `dist/components/layouts`
490
+ (grid/editorial index layouts) output — no extra `@source` line needed.
491
+ - **Remote images:** if any post's `featured_image` is a non-same-origin URL, add it
492
+ to `images.remotePatterns` — required for `next/image` to render it, and far more
493
+ visible now that the `editorial` layout's featured card leans on it (see
494
+ [Layouts](#layouts)).
206
495
 
207
496
  ### Build resilience
208
497
 
@@ -227,6 +516,7 @@ pipelines.
227
516
  | `hazo_blog/lib` | pure text utilities: `sanitizeMdx`, `mdxToPlainText`, `buildExcerpt`, `slugify`, `calculateReadingTime`, `extractToc` + `validatePost` (pre-publish content-quality gate) |
228
517
  | `hazo_blog/seo` | `buildBlogPostingJsonLd`, `buildBreadcrumbJsonLd`, `buildFaqJsonLd`, `getBlogSitemapEntries`, `getBlogRobotsRules`, `getBlogRssXml` |
229
518
  | `hazo_blog/config` | `BlogConfig` types + `resolveConfig` |
519
+ | `hazo_blog/ui` | `BlogAdminPanel` — framework-agnostic, token-free admin panel component for embedding in a host admin shell (e.g. `hazo_admin`). No `next/*`, no server-only imports — pure React + Tailwind. |
230
520
 
231
521
  ### Search
232
522