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.
- package/CHANGE_LOG.md +280 -0
- package/README.md +292 -2
- package/SETUP_CHECKLIST.md +33 -0
- package/db_setup_postgres.sql +40 -6
- package/db_setup_sqlite.sql +37 -6
- package/dist/components/admin/featured-image-field.d.ts +13 -0
- package/dist/components/admin/featured-image-field.d.ts.map +1 -0
- package/dist/components/admin/featured-image-field.js +46 -0
- package/dist/components/admin/post-form.d.ts +16 -2
- package/dist/components/admin/post-form.d.ts.map +1 -1
- package/dist/components/admin/post-form.js +18 -3
- package/dist/components/admin/upload-image.d.ts +17 -0
- package/dist/components/admin/upload-image.d.ts.map +1 -0
- package/dist/components/admin/upload-image.js +52 -0
- 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 +3 -1
- package/dist/components/blog-search.d.ts.map +1 -1
- package/dist/components/blog-search.js +8 -2
- package/dist/components/editorial-post-card.d.ts +10 -0
- package/dist/components/editorial-post-card.d.ts.map +1 -0
- package/dist/components/editorial-post-card.js +20 -0
- 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 +8 -0
- package/dist/components/featured-post-card.d.ts.map +1 -0
- package/dist/components/featured-post-card.js +18 -0
- package/dist/components/icons.d.ts +14 -0
- package/dist/components/icons.d.ts.map +1 -0
- package/dist/components/icons.js +38 -0
- package/dist/components/index.d.ts +1 -0
- package/dist/components/index.d.ts.map +1 -1
- package/dist/components/index.js +1 -0
- package/dist/components/layouts/blog-index-editorial.d.ts +3 -0
- package/dist/components/layouts/blog-index-editorial.d.ts.map +1 -0
- package/dist/components/layouts/blog-index-editorial.js +30 -0
- package/dist/components/layouts/blog-index-grid.d.ts +3 -0
- package/dist/components/layouts/blog-index-grid.d.ts.map +1 -0
- package/dist/components/layouts/blog-index-grid.js +23 -0
- package/dist/components/layouts/index.d.ts +28 -0
- package/dist/components/layouts/index.d.ts.map +1 -0
- package/dist/components/layouts/index.js +15 -0
- 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 +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -2
- 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/lib/dates.d.ts +9 -0
- package/dist/lib/dates.d.ts.map +1 -0
- package/dist/lib/dates.js +22 -0
- package/dist/lib/index.d.ts +2 -0
- package/dist/lib/index.d.ts.map +1 -1
- package/dist/lib/index.js +2 -0
- package/dist/lib/layouts.d.ts +22 -0
- package/dist/lib/layouts.d.ts.map +1 -0
- package/dist/lib/layouts.js +32 -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/image-upload.d.ts +17 -0
- package/dist/next/image-upload.d.ts.map +1 -0
- package/dist/next/image-upload.js +102 -0
- package/dist/next/pages.d.ts +4 -0
- package/dist/next/pages.d.ts.map +1 -1
- package/dist/next/pages.js +35 -21
- package/dist/next/routes.d.ts +28 -0
- package/dist/next/routes.d.ts.map +1 -1
- package/dist/next/routes.js +133 -9
- package/dist/repository/index.d.ts +31 -4
- package/dist/repository/index.d.ts.map +1 -1
- package/dist/repository/index.js +159 -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 +31 -3
- package/dist/service/index.d.ts.map +1 -1
- package/dist/service/index.js +87 -6
- package/dist/types/index.d.ts +98 -0
- package/dist/types/index.d.ts.map +1 -1
- package/dist/ui/blog-admin-panel.d.ts +15 -0
- package/dist/ui/blog-admin-panel.d.ts.map +1 -0
- package/dist/ui/blog-admin-panel.js +48 -0
- package/dist/ui/index.d.ts +6 -0
- package/dist/ui/index.d.ts.map +1 -0
- package/dist/ui/index.js +9 -0
- package/dist/ui/layout-selector.d.ts +10 -0
- package/dist/ui/layout-selector.d.ts.map +1 -0
- package/dist/ui/layout-selector.js +44 -0
- package/dist/ui/panel-dialog.d.ts +10 -0
- package/dist/ui/panel-dialog.d.ts.map +1 -0
- package/dist/ui/panel-dialog.js +22 -0
- package/dist/ui/post-list-table.d.ts +11 -0
- package/dist/ui/post-list-table.d.ts.map +1 -0
- package/dist/ui/post-list-table.js +43 -0
- package/dist/ui/use-blog-admin.d.ts +41 -0
- package/dist/ui/use-blog-admin.d.ts.map +1 -0
- package/dist/ui/use-blog-admin.js +150 -0
- 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
|
|