hazo_blog 1.4.0 → 2.0.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 +189 -0
- package/db_setup_postgres.sql +76 -38
- package/db_setup_sqlite.sql +85 -38
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/lib/index.js +6 -0
- package/dist/lib/mdx_mask.d.ts +28 -0
- package/dist/lib/mdx_mask.d.ts.map +1 -0
- package/dist/lib/mdx_mask.js +165 -0
- package/dist/lib/mdx_segment.d.ts +15 -0
- package/dist/lib/mdx_segment.d.ts.map +1 -0
- package/dist/lib/mdx_segment.js +56 -0
- package/dist/locales/de.json +4 -0
- package/dist/locales/en.json +4 -0
- package/dist/locales/sv.json +4 -0
- package/dist/next/pages.d.ts +1 -0
- package/dist/next/pages.d.ts.map +1 -1
- package/dist/next/pages.js +8 -5
- package/dist/next/routes.d.ts +33 -0
- package/dist/next/routes.d.ts.map +1 -1
- package/dist/next/routes.js +115 -3
- package/dist/repository/index.d.ts +2 -0
- package/dist/repository/index.d.ts.map +1 -1
- package/dist/repository/index.js +13 -0
- package/dist/seo/index.d.ts +10 -2
- package/dist/seo/index.d.ts.map +1 -1
- package/dist/seo/index.js +17 -5
- package/dist/service/index.d.ts +88 -0
- package/dist/service/index.d.ts.map +1 -1
- package/dist/service/index.js +268 -0
- package/dist/types/index.d.ts +8 -0
- package/dist/types/index.d.ts.map +1 -1
- package/dist/ui/blog-admin-panel.d.ts.map +1 -1
- package/dist/ui/blog-admin-panel.js +20 -1
- package/dist/ui/post-list-table.d.ts +9 -1
- package/dist/ui/post-list-table.d.ts.map +1 -1
- package/dist/ui/post-list-table.js +10 -2
- package/dist/ui/use-blog-admin.d.ts +7 -0
- package/dist/ui/use-blog-admin.d.ts.map +1 -1
- package/dist/ui/use-blog-admin.js +24 -5
- package/package.json +18 -14
package/CHANGE_LOG.md
CHANGED
|
@@ -3,6 +3,195 @@
|
|
|
3
3
|
All notable changes are documented here. This project follows
|
|
4
4
|
[Semantic Versioning](https://semver.org/).
|
|
5
5
|
|
|
6
|
+
## 2.0.0 — 2026-10-31
|
|
7
|
+
|
|
8
|
+
**⚠️ BREAKING — new posts + pages foundation for BlogSketch (Phase 1a issue 04).**
|
|
9
|
+
|
|
10
|
+
Replaces `hazo_blog_posts`' v1.6 shape entirely with a content-type-agnostic
|
|
11
|
+
model: `type` (free-form string, default `'post'`) instead of a fixed
|
|
12
|
+
post/page split; `_rev` optimistic concurrency; `live_revision_id`/
|
|
13
|
+
`draft_revision_id` pointers into a new `hazo_blog_revisions` table (every
|
|
14
|
+
save is an immutable snapshot — the live/draft promotion workflow itself is
|
|
15
|
+
Phase 1a issue 06); `custom_fields` JSONB; a current-value SEO/meta snapshot
|
|
16
|
+
(`seo_title`/`seo_description`/`og_image`/`noindex`/`canonical_override`) on
|
|
17
|
+
the post, with historical snapshots on each revision; `deleted_at` (soft
|
|
18
|
+
delete — restore/auto-purge is issue 07); `publish_at` (scheduled — auto-publish
|
|
19
|
+
worker is issue 07). **`scope_id` is now NOT NULL** on `hazo_blog_posts`/
|
|
20
|
+
`hazo_blog_revisions` — add-on/single-site mode stamps hazo_auth's
|
|
21
|
+
`DEFAULT_SYSTEM_SCOPE_ID` (`resolvePostScope()` in `src/service/posts.ts`,
|
|
22
|
+
via `optional_import("hazo_auth/server-lib")` since `hazo_auth` is an
|
|
23
|
+
optional peer, with a literal fallback if it's not installed).
|
|
24
|
+
|
|
25
|
+
Gone from the table entirely (content/category/faq/i18n fields now live
|
|
26
|
+
elsewhere or are deferred): `content`, `content_html`, `excerpt`,
|
|
27
|
+
`category_id`, `author_id`, `meta_title`, `meta_description`,
|
|
28
|
+
`canonical_url`, `featured_image`, `image_ref`, `faq_json`, `reading_time`,
|
|
29
|
+
`status`, `publish_date`, `translation_of`, `review_notes`, `indexable`.
|
|
30
|
+
`status`/transitions/trash-restore are deliberately NOT part of this issue —
|
|
31
|
+
see issue 07.
|
|
32
|
+
|
|
33
|
+
**This is intentionally a hard break, not a migration.** v1.6 consumers
|
|
34
|
+
(GoTimer, kinstripe) stay on `~1.6.x` and are unaffected — their deployments
|
|
35
|
+
bundle v1.6's own `db_setup_*.sql`. The v1.6 `BlogRepository`/`BlogService`/
|
|
36
|
+
routes/components/SEO glue in this package (`src/repository/index.ts`,
|
|
37
|
+
`src/service/index.ts`, `src/next/routes.ts`, `src/components/*`, `src/ui/*`,
|
|
38
|
+
`src/seo/*`) are UNTOUCHED by this change and now reference a dropped shape
|
|
39
|
+
— their own tests fail against the new schema until later Phase 1a issues
|
|
40
|
+
(05 admin shell, 10 media pipeline, 11 SEO admin, 12 post list, 15 post
|
|
41
|
+
editor) rewrite each consumer against the v2 model. `npx tsc --noEmit`
|
|
42
|
+
stays green (the breakage is schema/runtime, not type-level — `BlogPost`
|
|
43
|
+
etc. are plain interfaces with no DB coupling).
|
|
44
|
+
|
|
45
|
+
New, additive, and NOT touching any v1.6 code path:
|
|
46
|
+
|
|
47
|
+
- **`src/types/index.ts`** — `Post`, `PostRevision`, `CreatePostInput`,
|
|
48
|
+
`UpdatePostInput`, `ListPostsQuery`, `PaginatedPostsResult`.
|
|
49
|
+
- **`src/repository/posts.ts`** — `createPostRepository(adapter, scope)`.
|
|
50
|
+
`updatePost()`'s optimistic-concurrency check is a single atomic
|
|
51
|
+
`UPDATE ... WHERE id = ? AND scope_id = ? AND _rev = ? RETURNING *` (via
|
|
52
|
+
`hazo_connect`'s `QueryBuilder`+`CrudService.query()`) — the WHERE clause
|
|
53
|
+
itself is what prevents a lost update between two concurrent callers, not
|
|
54
|
+
a prior read (that read only exists to tell `PostNotFoundError` apart from
|
|
55
|
+
`PostConflictError` with a clearer error). Postgres full-text search uses
|
|
56
|
+
native `tsvector`/`ts_rank` via a duck-typed `adapter.raw()` escape hatch
|
|
57
|
+
(PgAdapter-only — not part of the common `HazoConnectAdapter` interface;
|
|
58
|
+
every other adapter, including PostgREST/Supabase, degrades to a plain
|
|
59
|
+
title substring match). SQLite add-on mode gets a stub `hazo_blog_posts_fts`
|
|
60
|
+
FTS5 table, kept in sync on write but not yet wired into `searchPosts()`.
|
|
61
|
+
- **`src/service/posts.ts`** — `createPostService(config)`, `resolvePostScope()`.
|
|
62
|
+
Fails CLOSED (`ScopeUnresolvedError`) when a multi-site `resolveScope` is
|
|
63
|
+
configured but returns no scope for a request — only falls back to
|
|
64
|
+
hazo_auth's `DEFAULT_SYSTEM_SCOPE_ID` when no `resolveScope` is configured
|
|
65
|
+
at all. Defaulting to the system scope on an unresolved multi-tenant
|
|
66
|
+
request would be a fail-open tenant-isolation bug, not a convenience.
|
|
67
|
+
- **`src/next/post-routes.ts`** — `createPostManageRoutes`/`createPostItemRoutes`
|
|
68
|
+
(same `blog:read`/`blog:write` hazo_api scopes as v1.6's manage routes).
|
|
69
|
+
- **`db_setup_sqlite.sql`/`db_setup_postgres.sql`/`migrations/*`** — new
|
|
70
|
+
`hazo_blog_posts` shape, new `hazo_blog_revisions` table, new
|
|
71
|
+
`hazo_blog_posts_fts` SQLite FTS5 stub. `hazo_blog_categories`/
|
|
72
|
+
`hazo_blog_tags`/`hazo_blog_post_tags`/`hazo_blog_settings` are unchanged
|
|
73
|
+
and not yet wired to v2 posts (categorization is deferred to a later
|
|
74
|
+
issue).
|
|
75
|
+
|
|
76
|
+
## 1.6.0 — 2026-10-09
|
|
77
|
+
|
|
78
|
+
**Fix — `getPostById` ignored tenant scoping (IDOR).**
|
|
79
|
+
|
|
80
|
+
`repository.getPostById(id)` used `posts.findById(id)` directly, with no `scope_id`
|
|
81
|
+
predicate — unlike every other read in this file (`getPostBySlug`, `getAllSlugs`, `listPosts`),
|
|
82
|
+
which all go through the `scoped()` helper. One tenant could fetch (and, via the new
|
|
83
|
+
translate/review flow below, read/mutate) another tenant's post by guessing or enumerating its
|
|
84
|
+
id. Fixed by routing `getPostById` through `posts.list()` with the same `scoped(qb)` predicate
|
|
85
|
+
the slug lookup uses.
|
|
86
|
+
|
|
87
|
+
**Blog translation, Phase 4C: AI translate + AI review of translation-cluster drafts**
|
|
88
|
+
|
|
89
|
+
Takes a Phase 4B draft (created via `createTranslation()`, content copied verbatim,
|
|
90
|
+
untranslated) and fills it in — plus an independent quality-review pass. Both are new
|
|
91
|
+
`BlogService` methods over the `hazo_llm_api` optional peer, mirroring `hazo_i18n`'s own
|
|
92
|
+
`api/translate.ts`/`api/review.ts` shape and philosophy exactly (lazy `optional_import()`
|
|
93
|
+
probe, local re-declared module-shape interface, per-call `HazoUnavailableError` 503,
|
|
94
|
+
per-chunk graceful degradation).
|
|
95
|
+
|
|
96
|
+
- **`src/lib/mdx_mask.ts`** — `maskMdx()`/`unmaskMdx()`. Sets aside leading YAML frontmatter
|
|
97
|
+
(defensive only — `BlogPost.content` is pure MDX body today, title/excerpt/meta live in
|
|
98
|
+
their own columns, so no post in this schema actually has frontmatter), masks every
|
|
99
|
+
capitalized JSX component tag (`<Callout>`, `<YouTube id="..."/>`, ANY `[A-Z]...` tag name —
|
|
100
|
+
not an enumerated list, since a host's `config.mdxComponents` can add more) into its own
|
|
101
|
+
`jN` placeholder namespace, then delegates the remainder to `hazo_i18n/html`'s
|
|
102
|
+
`maskHtml()`/`unmaskHtml()` (`xN` namespace) for plain attributed HTML tags. The JSX pass
|
|
103
|
+
exists because `maskHtml()` only masks tags that carry attributes — right for plain HTML,
|
|
104
|
+
wrong for MDX, where a bare `<Callout>` (no attrs) is exactly as fragile to translate
|
|
105
|
+
around as an attributed one. **`hazo_i18n/html` is resolved LAZILY** (`optional_import`,
|
|
106
|
+
same convention as the `hazo_llm_api` peer below) — a static import here would turn
|
|
107
|
+
`hazo_i18n` (an optional peer) into a hard dependency for every consumer of this package,
|
|
108
|
+
not just ones calling `translatePost()`. For the same reason this module is NOT re-exported
|
|
109
|
+
through the general `./lib` barrel.
|
|
110
|
+
- **`src/lib/mdx_segment.ts`** — `segmentMdxForTranslation(content, maxChars = 2000)`. Splits
|
|
111
|
+
masked MDX at paragraph (blank-line) boundaries into LLM-call-sized chunks, grouping
|
|
112
|
+
consecutive paragraphs up to the budget; a single oversized paragraph still gets its own
|
|
113
|
+
chunk rather than being split mid-paragraph.
|
|
114
|
+
- **`BlogService.translatePost(postId)`** — `postId` is an EXISTING translation-cluster
|
|
115
|
+
DRAFT's own id (not a new `targetLocale` parameter): its `translation_of` is read to find
|
|
116
|
+
the source post, and its own `locale` is the target — a deliberate refinement of the
|
|
117
|
+
original plan now that `createTranslation()` already owns "create the row". Masks the
|
|
118
|
+
source content, segments it, translates each segment sequentially with its own
|
|
119
|
+
`hazo_llm_text_text()` call (plain-text response — "return ONLY the translated text, no
|
|
120
|
+
commentary" — not JSON; there's nothing to extract-as-JSON for free-form prose, unlike
|
|
121
|
+
`hazo_i18n`'s key-value chunks), then the title with one more call. One segment's failure
|
|
122
|
+
never aborts the others, but if ANY segment (or the title) fails, nothing is written — a
|
|
123
|
+
partially translated MDX post going live is worse than failing the whole call. Never
|
|
124
|
+
changes `status` (stays `'draft'`).
|
|
125
|
+
- **`BlogService.reviewTranslatedPost(postId)`** — one LLM call (not chunked; a blog post
|
|
126
|
+
comfortably fits one call) judging accuracy/naturalness/completeness/MDX-preservation
|
|
127
|
+
against the source, requesting structured `{ ok, notes }` JSON (parsed via
|
|
128
|
+
`try_extract_json()`, since a free-text verdict would need re-parsing anyway). Persists
|
|
129
|
+
`notes` to the post's new `review_notes` column on success; a failed review call leaves
|
|
130
|
+
`review_notes` untouched (`persisted: false`) rather than overwriting it with an error.
|
|
131
|
+
Never flips `status` — unlike `hazo_i18n`'s `reviewMessages()`, a human editor decides what
|
|
132
|
+
to do with the notes via the existing admin UI.
|
|
133
|
+
- **`hazo_blog_posts.review_notes TEXT`** (nullable) — new column, both `db_setup_sqlite.sql`
|
|
134
|
+
and `db_setup_postgres.sql` (+ their `migrations/` mirrors). `excerpt` was deliberately NOT
|
|
135
|
+
repurposed — it's an actively-used teaser/meta-description field.
|
|
136
|
+
- **New routes** (`src/next/routes.ts`, exported from the package root): `createBlogTranslateRoutes`
|
|
137
|
+
(`[id]/translate`, `POST` → `translatePost`) and `createBlogReviewRoutes` (`[id]/review`,
|
|
138
|
+
`POST` → `reviewTranslatedPost`) — mounted on their own resources rather than another method
|
|
139
|
+
on `[id]`, since that route's `POST`/`PUT`/`DELETE` are already spoken for (Phase 4B's
|
|
140
|
+
`createTranslation`, plus upsert/delete). Same cookie/session `config.authorize` guard, same
|
|
141
|
+
`HazoUnavailableError` → 503 convention as everywhere else in this file.
|
|
142
|
+
- **`hazo_llm_api` is now a declared OPTIONAL peer** (`peerDependenciesMeta`), same convention
|
|
143
|
+
as `hazo_i18n`. **`hazo_i18n`'s declared range bumped `^0.9.0` → `^0.12.0`** — its new `./html`
|
|
144
|
+
subpath (this phase's `maskHtml`/`unmaskHtml` reuse) only exists from 0.12.0. Note: this
|
|
145
|
+
workspace's own `node_modules/hazo_i18n` held a STALE real copy (0.5.9 — not even satisfying
|
|
146
|
+
the old `^0.9.0` range) that shadowed the correctly-hoisted 0.12.0 at the monorepo root;
|
|
147
|
+
removed so resolution falls through to the hoisted copy. A fresh `npm install` anywhere this
|
|
148
|
+
stale-copy situation doesn't already exist should resolve cleanly against the declared range.
|
|
149
|
+
|
|
150
|
+
## 1.5.0 — 2026-10-09
|
|
151
|
+
|
|
152
|
+
**Blog translation, Phase 4B: locale-aware post URLs + static params + admin "Translate to..."**
|
|
153
|
+
|
|
154
|
+
Closes three gaps left over from 1.4.0's locale infrastructure (locale columns, `translation_of`,
|
|
155
|
+
locale-filtered repository reads, `getTranslations`) — URLs, static generation, and the admin UI
|
|
156
|
+
never actually used any of it.
|
|
157
|
+
|
|
158
|
+
- **`postUrl(config, slug, locale?)`** (`src/seo/index.ts`) gained an optional third parameter.
|
|
159
|
+
Omitted, `null`, or equal to `config.defaultLocale` (`"en"` when unset): byte-identical output
|
|
160
|
+
to before this parameter existed — no prefix, fully backward compatible. Any other locale gets
|
|
161
|
+
a `/${locale}` segment prepended **before** `basePath`: `https://site.com/de/blog/my-slug`, not
|
|
162
|
+
`/blog/de/my-slug` — matching gotimer's own `/[locale]/<slug>` convention (locale segment
|
|
163
|
+
first, default locale unprefixed) so a locale-aware host's `generateStaticParams`/routing
|
|
164
|
+
doesn't have to special-case blog URLs. `canonicalFor` now threads the post's own `locale`
|
|
165
|
+
through (a translated post's canonical was previously default-locale-shaped), and the sitemap's
|
|
166
|
+
per-post URL and hreflang `alternates` do the same.
|
|
167
|
+
- **`createBlogPostPage(...).generateStaticParams`** now returns a `{ slug, locale }` entry for
|
|
168
|
+
**every** locale variant from `getAllSlugs(false)` — not just the default locale. Null-locale
|
|
169
|
+
(default) rows get `config.defaultLocale` (`"en"` fallback) filled in as their `locale` value,
|
|
170
|
+
matching `postUrl`'s own default-locale comparison. Previously only default-locale posts ever
|
|
171
|
+
got a static route; every translation 404'd until `dynamicParams` kicked in on-demand.
|
|
172
|
+
- **Admin locale filter + "Translate to..."** (`BlogAdminPanel`, `PostListTable`,
|
|
173
|
+
`useBlogAdmin`, `createBlogAdminRoutes`):
|
|
174
|
+
- `collection.GET` now reads `?locale=` (same omitted-means-default-locale semantics as
|
|
175
|
+
`ListPostsOptions.locale`) and also returns a derived `locales: string[]` — every locale
|
|
176
|
+
currently in use, default locale first. No new DB query: reuses the existing
|
|
177
|
+
`getAllSlugs(true)` lookup (already built for `generateStaticParams`) rather than adding an
|
|
178
|
+
`enabledLocales` config field, since none exists today.
|
|
179
|
+
- New `BlogService.createTranslation(postId, targetLocale, req?)` + `BlogRepository.getPostById`
|
|
180
|
+
— creates a **draft** post in `targetLocale`, linked via `translation_of` to the source's
|
|
181
|
+
cluster root, with every content field (title/content/excerpt/meta/FAQ/tags/images) copied
|
|
182
|
+
**verbatim** from the source. Not a translation step — no LLM call; that's Phase 4C.
|
|
183
|
+
- New `item.POST` route (same `[id]` resource as the existing `PUT`/`DELETE`) wraps
|
|
184
|
+
`createTranslation`; body `{ locale: string }`.
|
|
185
|
+
- `BlogAdminPanel` gained a locale `<select>` filtering the post list (hidden when only one
|
|
186
|
+
locale is in use); `PostListTable` gained a Locale column and a per-row "Translate to…"
|
|
187
|
+
picker + button (hidden when `availableLocales` is empty). Both token-free Tailwind, per this
|
|
188
|
+
tree's isolation rule. New locale catalog keys (`post_list_table.col_locale`,
|
|
189
|
+
`post_list_table.translate_to`, `post_list_table.translate_failed`,
|
|
190
|
+
`blog_admin_panel.locale_filter_label`) in `en`/`de`/`sv`.
|
|
191
|
+
- Out of scope (separate, later phases): gotimer's own hazo_blog 1.4.0 schema upgrade (4A) and
|
|
192
|
+
the LLM `translatePost`/`reviewTranslatedPost` pipeline (4C) — `createTranslation` deliberately
|
|
193
|
+
does a verbatim copy, not a translation.
|
|
194
|
+
|
|
6
195
|
## 1.4.0 — 2026-10-06
|
|
7
196
|
|
|
8
197
|
**Locale-aware UI text and dates (`en` / `de` / `sv`)**
|
package/db_setup_postgres.sql
CHANGED
|
@@ -3,9 +3,11 @@
|
|
|
3
3
|
-- Supports both PostgreSQL (this file) and SQLite (db_setup_sqlite.sql).
|
|
4
4
|
-- All tables use the `hazo_blog_` prefix per workspace standards.
|
|
5
5
|
--
|
|
6
|
-
-- Multi-tenancy:
|
|
7
|
-
--
|
|
8
|
-
--
|
|
6
|
+
-- Multi-tenancy: hazo_blog_posts/hazo_blog_revisions require a `scope_id`
|
|
7
|
+
-- (NOT NULL, as of v2.0.0) — add-on/single-site mode stamps hazo_auth's
|
|
8
|
+
-- DEFAULT_SYSTEM_SCOPE_ID. Every other table still carries a nullable
|
|
9
|
+
-- `scope_id`: single-tenant consumers leave it NULL; multi-tenant consumers
|
|
10
|
+
-- populate it and pass a scope resolver so the repository filters by it.
|
|
9
11
|
|
|
10
12
|
CREATE TABLE IF NOT EXISTS hazo_blog_categories (
|
|
11
13
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
@@ -42,45 +44,81 @@ DROP INDEX IF EXISTS idx_hazo_blog_tags_scope_slug;
|
|
|
42
44
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_hazo_blog_tags_scope_slug_locale
|
|
43
45
|
ON hazo_blog_tags (scope_id, slug, COALESCE(locale, ''));
|
|
44
46
|
|
|
47
|
+
-- hazo_blog_posts (v2.0.0) — content-type-agnostic foundation for posts and
|
|
48
|
+
-- pages. Supersedes the v1.6 shape: status/category_id/faq_json/content/etc.
|
|
49
|
+
-- are gone. `type` picks post vs page vs any site-defined type; the actual
|
|
50
|
+
-- MDX body + excerpt + per-save SEO snapshot live on hazo_blog_revisions, not
|
|
51
|
+
-- here — this table only carries the current pointer (_rev/live/draft) plus
|
|
52
|
+
-- the SEO/meta fields needed to render without a join.
|
|
53
|
+
--
|
|
54
|
+
-- scope_id is NOT NULL (unlike every other hazo_blog table): v2 requires a
|
|
55
|
+
-- concrete tenant for every post, including add-on/single-site mode, which
|
|
56
|
+
-- stamps hazo_auth's DEFAULT_SYSTEM_SCOPE_ID rather than leaving it NULL.
|
|
57
|
+
--
|
|
58
|
+
-- live_revision_id/draft_revision_id intentionally carry no FK constraint:
|
|
59
|
+
-- hazo_blog_revisions.post_id references this table, so a FK the other way
|
|
60
|
+
-- would be circular at CREATE TABLE time. The repository enforces the
|
|
61
|
+
-- pointer's integrity (see src/repository/posts.ts).
|
|
62
|
+
--
|
|
63
|
+
-- search_vector is maintained by the repository, not a generated column —
|
|
64
|
+
-- Postgres generated columns can't reach across tables, and the searchable
|
|
65
|
+
-- body text lives on hazo_blog_revisions.
|
|
45
66
|
CREATE TABLE IF NOT EXISTS hazo_blog_posts (
|
|
46
|
-
id
|
|
47
|
-
scope_id
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
69
|
-
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
67
|
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
68
|
+
scope_id TEXT NOT NULL,
|
|
69
|
+
type TEXT NOT NULL DEFAULT 'post',
|
|
70
|
+
slug TEXT NOT NULL,
|
|
71
|
+
title TEXT NOT NULL,
|
|
72
|
+
_rev INTEGER NOT NULL DEFAULT 1,
|
|
73
|
+
live_revision_id UUID,
|
|
74
|
+
draft_revision_id UUID,
|
|
75
|
+
deleted_at TIMESTAMPTZ,
|
|
76
|
+
template TEXT,
|
|
77
|
+
locale TEXT DEFAULT NULL, -- BCP-47 locale; NULL = default locale
|
|
78
|
+
custom_fields JSONB NOT NULL DEFAULT '{}'::jsonb,
|
|
79
|
+
seo_title TEXT,
|
|
80
|
+
seo_description TEXT,
|
|
81
|
+
og_image TEXT,
|
|
82
|
+
noindex BOOLEAN NOT NULL DEFAULT FALSE,
|
|
83
|
+
canonical_override TEXT,
|
|
84
|
+
search_vector tsvector,
|
|
85
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
86
|
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
87
|
+
published_at TIMESTAMPTZ,
|
|
88
|
+
publish_at TIMESTAMPTZ
|
|
70
89
|
);
|
|
71
|
-
ALTER TABLE hazo_blog_posts ADD COLUMN IF NOT EXISTS locale TEXT DEFAULT NULL;
|
|
72
|
-
ALTER TABLE hazo_blog_posts ADD COLUMN IF NOT EXISTS translation_of UUID REFERENCES hazo_blog_posts(id) ON DELETE SET NULL;
|
|
73
|
-
ALTER TABLE hazo_blog_posts ADD COLUMN IF NOT EXISTS indexable BOOLEAN NOT NULL DEFAULT TRUE;
|
|
74
|
-
-- FR-034: locale-aware uniqueness (see categories index comment above).
|
|
75
|
-
DROP INDEX IF EXISTS idx_hazo_blog_posts_scope_slug;
|
|
76
90
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_hazo_blog_posts_scope_slug_locale
|
|
77
91
|
ON hazo_blog_posts (scope_id, slug, COALESCE(locale, ''));
|
|
78
|
-
CREATE INDEX IF NOT EXISTS
|
|
79
|
-
ON hazo_blog_posts (
|
|
80
|
-
CREATE INDEX IF NOT EXISTS
|
|
81
|
-
ON hazo_blog_posts (
|
|
82
|
-
CREATE INDEX IF NOT EXISTS
|
|
83
|
-
ON hazo_blog_posts (
|
|
92
|
+
CREATE INDEX IF NOT EXISTS idx_hazo_blog_posts_scope_type
|
|
93
|
+
ON hazo_blog_posts (scope_id, type);
|
|
94
|
+
CREATE INDEX IF NOT EXISTS idx_hazo_blog_posts_deleted_at
|
|
95
|
+
ON hazo_blog_posts (deleted_at);
|
|
96
|
+
CREATE INDEX IF NOT EXISTS idx_hazo_blog_posts_publish_at
|
|
97
|
+
ON hazo_blog_posts (publish_at);
|
|
98
|
+
CREATE INDEX IF NOT EXISTS idx_hazo_blog_posts_search
|
|
99
|
+
ON hazo_blog_posts USING GIN (search_vector);
|
|
100
|
+
|
|
101
|
+
-- hazo_blog_revisions: full immutable snapshot on every save (draft or
|
|
102
|
+
-- publish). posts.live_revision_id/draft_revision_id point into this table.
|
|
103
|
+
CREATE TABLE IF NOT EXISTS hazo_blog_revisions (
|
|
104
|
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
105
|
+
post_id UUID NOT NULL REFERENCES hazo_blog_posts(id) ON DELETE CASCADE,
|
|
106
|
+
scope_id TEXT NOT NULL,
|
|
107
|
+
title TEXT NOT NULL,
|
|
108
|
+
slug TEXT NOT NULL,
|
|
109
|
+
body TEXT NOT NULL DEFAULT '',
|
|
110
|
+
excerpt TEXT,
|
|
111
|
+
custom_fields JSONB NOT NULL DEFAULT '{}'::jsonb,
|
|
112
|
+
seo_title TEXT,
|
|
113
|
+
seo_description TEXT,
|
|
114
|
+
og_image TEXT,
|
|
115
|
+
noindex BOOLEAN NOT NULL DEFAULT FALSE,
|
|
116
|
+
canonical_override TEXT,
|
|
117
|
+
created_by TEXT,
|
|
118
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
119
|
+
);
|
|
120
|
+
CREATE INDEX IF NOT EXISTS idx_hazo_blog_revisions_post_created
|
|
121
|
+
ON hazo_blog_revisions (post_id, created_at);
|
|
84
122
|
|
|
85
123
|
CREATE TABLE IF NOT EXISTS hazo_blog_post_tags (
|
|
86
124
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
package/db_setup_sqlite.sql
CHANGED
|
@@ -4,15 +4,20 @@
|
|
|
4
4
|
-- All tables use the `hazo_blog_` prefix per workspace standards.
|
|
5
5
|
--
|
|
6
6
|
-- Tables:
|
|
7
|
-
-- hazo_blog_posts —
|
|
8
|
-
--
|
|
9
|
-
--
|
|
7
|
+
-- hazo_blog_posts — posts/pages (v2.0.0: type/_rev/live+draft pointers; see table comment)
|
|
8
|
+
-- hazo_blog_revisions — immutable per-save content snapshots for hazo_blog_posts
|
|
9
|
+
-- hazo_blog_posts_fts — FTS5 stub search index for hazo_blog_posts (add-on/SQLite mode)
|
|
10
|
+
-- hazo_blog_categories — single category per post (v1.6; not yet wired to v2 posts)
|
|
11
|
+
-- hazo_blog_tags — free-form tags (many-to-many with posts; v1.6, not yet wired to v2 posts)
|
|
10
12
|
-- hazo_blog_post_tags — post↔tag join
|
|
11
13
|
-- hazo_blog_settings — package-owned display/behavior settings
|
|
12
14
|
--
|
|
13
|
-
-- Multi-tenancy:
|
|
14
|
-
--
|
|
15
|
-
--
|
|
15
|
+
-- Multi-tenancy: hazo_blog_posts/hazo_blog_revisions require a `scope_id`
|
|
16
|
+
-- (NOT NULL, as of v2.0.0) — add-on/single-site mode stamps hazo_auth's
|
|
17
|
+
-- DEFAULT_SYSTEM_SCOPE_ID. Every other table still carries a nullable
|
|
18
|
+
-- `scope_id`: single-tenant consumers (e.g. gotimer, on v1.6) leave it NULL;
|
|
19
|
+
-- multi-tenant consumers populate it and pass a scope resolver so the
|
|
20
|
+
-- repository filters by it.
|
|
16
21
|
|
|
17
22
|
CREATE TABLE IF NOT EXISTS hazo_blog_categories (
|
|
18
23
|
id TEXT PRIMARY KEY,
|
|
@@ -48,42 +53,84 @@ DROP INDEX IF EXISTS idx_hazo_blog_tags_scope_slug;
|
|
|
48
53
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_hazo_blog_tags_scope_slug_locale
|
|
49
54
|
ON hazo_blog_tags (scope_id, slug, COALESCE(locale, ''));
|
|
50
55
|
|
|
56
|
+
-- hazo_blog_posts (v2.0.0) — content-type-agnostic foundation for posts and
|
|
57
|
+
-- pages. Supersedes the v1.6 shape: status/category_id/faq_json/content/etc.
|
|
58
|
+
-- are gone. `type` picks post vs page vs any site-defined type; the actual
|
|
59
|
+
-- MDX body + excerpt + per-save SEO snapshot live on hazo_blog_revisions, not
|
|
60
|
+
-- here — this table only carries the current pointer (_rev/live/draft) plus
|
|
61
|
+
-- the SEO/meta fields needed to render without a join.
|
|
62
|
+
--
|
|
63
|
+
-- scope_id is NOT NULL (unlike every other hazo_blog table): v2 requires a
|
|
64
|
+
-- concrete tenant for every post, including add-on/single-site mode, which
|
|
65
|
+
-- stamps hazo_auth's DEFAULT_SYSTEM_SCOPE_ID rather than leaving it NULL.
|
|
66
|
+
--
|
|
67
|
+
-- live_revision_id/draft_revision_id intentionally carry no FK constraint:
|
|
68
|
+
-- hazo_blog_revisions.post_id references this table, so a FK the other way
|
|
69
|
+
-- would be circular at CREATE TABLE time. The repository enforces the
|
|
70
|
+
-- pointer's integrity (see src/repository/posts.ts).
|
|
51
71
|
CREATE TABLE IF NOT EXISTS hazo_blog_posts (
|
|
52
|
-
id
|
|
53
|
-
scope_id
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
indexable INTEGER NOT NULL DEFAULT 1, -- 0/1; FALSE excludes from sitemap/robots
|
|
74
|
-
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
75
|
-
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
72
|
+
id TEXT PRIMARY KEY,
|
|
73
|
+
scope_id TEXT NOT NULL,
|
|
74
|
+
type TEXT NOT NULL DEFAULT 'post',
|
|
75
|
+
slug TEXT NOT NULL,
|
|
76
|
+
title TEXT NOT NULL,
|
|
77
|
+
_rev INTEGER NOT NULL DEFAULT 1,
|
|
78
|
+
live_revision_id TEXT,
|
|
79
|
+
draft_revision_id TEXT,
|
|
80
|
+
deleted_at TEXT,
|
|
81
|
+
template TEXT,
|
|
82
|
+
locale TEXT DEFAULT NULL, -- BCP-47 locale; NULL = default locale
|
|
83
|
+
custom_fields TEXT NOT NULL DEFAULT '{}', -- JSON object
|
|
84
|
+
seo_title TEXT,
|
|
85
|
+
seo_description TEXT,
|
|
86
|
+
og_image TEXT,
|
|
87
|
+
noindex INTEGER NOT NULL DEFAULT 0, -- 0/1
|
|
88
|
+
canonical_override TEXT,
|
|
89
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
90
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
91
|
+
published_at TEXT,
|
|
92
|
+
publish_at TEXT -- scheduled publish time
|
|
76
93
|
);
|
|
77
|
-
-- FR-034: locale-aware uniqueness (see categories index comment above).
|
|
78
|
-
DROP INDEX IF EXISTS idx_hazo_blog_posts_scope_slug;
|
|
79
94
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_hazo_blog_posts_scope_slug_locale
|
|
80
95
|
ON hazo_blog_posts (scope_id, slug, COALESCE(locale, ''));
|
|
81
|
-
CREATE INDEX IF NOT EXISTS
|
|
82
|
-
ON hazo_blog_posts (
|
|
83
|
-
CREATE INDEX IF NOT EXISTS
|
|
84
|
-
ON hazo_blog_posts (
|
|
85
|
-
CREATE INDEX IF NOT EXISTS
|
|
86
|
-
ON hazo_blog_posts (
|
|
96
|
+
CREATE INDEX IF NOT EXISTS idx_hazo_blog_posts_scope_type
|
|
97
|
+
ON hazo_blog_posts (scope_id, type);
|
|
98
|
+
CREATE INDEX IF NOT EXISTS idx_hazo_blog_posts_deleted_at
|
|
99
|
+
ON hazo_blog_posts (deleted_at);
|
|
100
|
+
CREATE INDEX IF NOT EXISTS idx_hazo_blog_posts_publish_at
|
|
101
|
+
ON hazo_blog_posts (publish_at);
|
|
102
|
+
|
|
103
|
+
-- Stub FTS5 index for add-on (SQLite) mode — see db_setup_postgres.sql's
|
|
104
|
+
-- search_vector/GIN index for the real multi-tenant implementation. Kept in
|
|
105
|
+
-- sync by the repository (not SQLite's external-content mechanism: that
|
|
106
|
+
-- needs an INTEGER rowid content table, and this PK is TEXT).
|
|
107
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS hazo_blog_posts_fts USING fts5(
|
|
108
|
+
post_id UNINDEXED,
|
|
109
|
+
title,
|
|
110
|
+
body
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
-- hazo_blog_revisions: full immutable snapshot on every save (draft or
|
|
114
|
+
-- publish). posts.live_revision_id/draft_revision_id point into this table.
|
|
115
|
+
CREATE TABLE IF NOT EXISTS hazo_blog_revisions (
|
|
116
|
+
id TEXT PRIMARY KEY,
|
|
117
|
+
post_id TEXT NOT NULL REFERENCES hazo_blog_posts(id) ON DELETE CASCADE,
|
|
118
|
+
scope_id TEXT NOT NULL,
|
|
119
|
+
title TEXT NOT NULL,
|
|
120
|
+
slug TEXT NOT NULL,
|
|
121
|
+
body TEXT NOT NULL DEFAULT '', -- raw MDX
|
|
122
|
+
excerpt TEXT,
|
|
123
|
+
custom_fields TEXT NOT NULL DEFAULT '{}', -- JSON object
|
|
124
|
+
seo_title TEXT,
|
|
125
|
+
seo_description TEXT,
|
|
126
|
+
og_image TEXT,
|
|
127
|
+
noindex INTEGER NOT NULL DEFAULT 0,
|
|
128
|
+
canonical_override TEXT,
|
|
129
|
+
created_by TEXT,
|
|
130
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
131
|
+
);
|
|
132
|
+
CREATE INDEX IF NOT EXISTS idx_hazo_blog_revisions_post_created
|
|
133
|
+
ON hazo_blog_revisions (post_id, created_at);
|
|
87
134
|
|
|
88
135
|
CREATE TABLE IF NOT EXISTS hazo_blog_post_tags (
|
|
89
136
|
id TEXT PRIMARY KEY,
|
package/dist/index.d.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
export * from "./types/index.js";
|
|
2
2
|
export * from "./lib/index.js";
|
|
3
3
|
export { createBlogRepository, type BlogRepository, type RepositoryOptions, type ListQuery, } from "./repository/index.js";
|
|
4
|
-
export { createBlogService, resolveConfig, BlogSettingsUnavailableError, type BlogService, } from "./service/index.js";
|
|
4
|
+
export { createBlogService, resolveConfig, BlogSettingsUnavailableError, type BlogService, type TranslatePostResult, type TranslatePostUsage, type TranslatePostSegmentResult, type ReviewTranslatedPostResult, type ReviewTranslatedPostUsage, } from "./service/index.js";
|
|
5
5
|
export * from "./seo/index.js";
|
|
6
|
-
export { createBlogManageRoutes, createBlogManageSlugRoutes, createBlogAdminRoutes, createBlogSettingsRoutes, createBlogSearchRoute, createBlogFeedRoute, createBlogSitemapRoute, } from "./next/routes.js";
|
|
6
|
+
export { createBlogManageRoutes, createBlogManageSlugRoutes, createBlogAdminRoutes, createBlogSettingsRoutes, createBlogTranslateRoutes, createBlogReviewRoutes, createBlogSearchRoute, createBlogFeedRoute, createBlogSitemapRoute, } from "./next/routes.js";
|
|
7
7
|
export { createBlogImageUploadRoute, type CreateBlogImageUploadRouteDeps, } from "./next/image-upload.js";
|
|
8
8
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAKA,cAAc,kBAAkB,CAAC;AAGjC,cAAc,gBAAgB,CAAC;AAG/B,OAAO,EACL,oBAAoB,EACpB,KAAK,cAAc,EACnB,KAAK,iBAAiB,EACtB,KAAK,SAAS,GACf,MAAM,uBAAuB,CAAC;AAC/B,OAAO,EACL,iBAAiB,EACjB,aAAa,EACb,4BAA4B,EAC5B,KAAK,WAAW,
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAKA,cAAc,kBAAkB,CAAC;AAGjC,cAAc,gBAAgB,CAAC;AAG/B,OAAO,EACL,oBAAoB,EACpB,KAAK,cAAc,EACnB,KAAK,iBAAiB,EACtB,KAAK,SAAS,GACf,MAAM,uBAAuB,CAAC;AAC/B,OAAO,EACL,iBAAiB,EACjB,aAAa,EACb,4BAA4B,EAC5B,KAAK,WAAW,EAChB,KAAK,mBAAmB,EACxB,KAAK,kBAAkB,EACvB,KAAK,0BAA0B,EAC/B,KAAK,0BAA0B,EAC/B,KAAK,yBAAyB,GAC/B,MAAM,oBAAoB,CAAC;AAG5B,cAAc,gBAAgB,CAAC;AAI/B,OAAO,EACL,sBAAsB,EACtB,0BAA0B,EAC1B,qBAAqB,EACrB,wBAAwB,EACxB,yBAAyB,EACzB,sBAAsB,EACtB,qBAAqB,EACrB,mBAAmB,EACnB,sBAAsB,GACvB,MAAM,kBAAkB,CAAC;AAC1B,OAAO,EACL,0BAA0B,EAC1B,KAAK,8BAA8B,GACpC,MAAM,wBAAwB,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -12,5 +12,5 @@ export { createBlogService, resolveConfig, BlogSettingsUnavailableError, } from
|
|
|
12
12
|
export * from "./seo/index.js";
|
|
13
13
|
// Route-handler factories — React-free, so API routes can import them without
|
|
14
14
|
// pulling in the React page components from `hazo_blog/next`.
|
|
15
|
-
export { createBlogManageRoutes, createBlogManageSlugRoutes, createBlogAdminRoutes, createBlogSettingsRoutes, createBlogSearchRoute, createBlogFeedRoute, createBlogSitemapRoute, } from "./next/routes.js";
|
|
15
|
+
export { createBlogManageRoutes, createBlogManageSlugRoutes, createBlogAdminRoutes, createBlogSettingsRoutes, createBlogTranslateRoutes, createBlogReviewRoutes, createBlogSearchRoute, createBlogFeedRoute, createBlogSitemapRoute, } from "./next/routes.js";
|
|
16
16
|
export { createBlogImageUploadRoute, } from "./next/image-upload.js";
|
package/dist/lib/index.js
CHANGED
|
@@ -4,3 +4,9 @@ export * from "./validate.js";
|
|
|
4
4
|
export * from "./dates.js";
|
|
5
5
|
export * from "./layouts.js";
|
|
6
6
|
export * from "./i18n.js";
|
|
7
|
+
// `mdx_mask.ts` and `mdx_segment.ts` are deliberately NOT re-exported here —
|
|
8
|
+
// `mdx_mask.ts` lazily depends on the OPTIONAL `hazo_i18n` peer (see its own
|
|
9
|
+
// header), and this barrel backs the general-purpose `./lib` subpath every
|
|
10
|
+
// consumer of this package's pure utilities pulls in, whether or not they
|
|
11
|
+
// use `translatePost()`. Import `../lib/mdx_mask.js`/`../lib/mdx_segment.js`
|
|
12
|
+
// directly (as `service/index.ts` does) instead of widening this barrel.
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/** One masked tag's original open/close text (mirrors `hazo_i18n/html`'s own `MaskedTagEntry` shape). `close` is `''` for a self-closing tag. */
|
|
2
|
+
export interface MaskedTagEntry {
|
|
3
|
+
open: string;
|
|
4
|
+
close: string;
|
|
5
|
+
}
|
|
6
|
+
export interface MaskMdxResult {
|
|
7
|
+
masked: string;
|
|
8
|
+
frontmatter: string | null;
|
|
9
|
+
map: Record<string, MaskedTagEntry>;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Sets aside leading YAML frontmatter (if present — see this file's header,
|
|
13
|
+
* most posts have none), then masks every capitalized JSX component tag
|
|
14
|
+
* (`maskJsxComponents`, this file's own `jN` namespace) and finally every
|
|
15
|
+
* remaining attributed plain-HTML tag (`maskHtml()`'s `xN` namespace). The
|
|
16
|
+
* two placeholder maps are merged into one — the numeric prefixes (`j`/`x`)
|
|
17
|
+
* never collide, so a single `map` round-trips through `unmaskMdx()`.
|
|
18
|
+
*/
|
|
19
|
+
export declare function maskMdx(content: string): Promise<MaskMdxResult>;
|
|
20
|
+
/**
|
|
21
|
+
* Exact inverse of `maskMdx()`: restores HTML tag placeholders, then JSX
|
|
22
|
+
* component placeholders, then re-prepends the frontmatter byte-for-byte.
|
|
23
|
+
* Order doesn't matter between the two unmask passes (disjoint placeholder
|
|
24
|
+
* namespaces), but running `unmaskHtml` first mirrors `maskMdx()`'s own
|
|
25
|
+
* "JSX pass, then HTML pass" order in reverse.
|
|
26
|
+
*/
|
|
27
|
+
export declare function unmaskMdx(masked: string, frontmatter: string | null, map: Record<string, MaskedTagEntry>): Promise<string>;
|
|
28
|
+
//# sourceMappingURL=mdx_mask.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"mdx_mask.d.ts","sourceRoot":"","sources":["../../src/lib/mdx_mask.ts"],"names":[],"mappings":"AAyEA,iJAAiJ;AACjJ,MAAM,WAAW,cAAc;IAC7B,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;CACf;AAOD,MAAM,WAAW,aAAa;IAC5B,MAAM,EAAE,MAAM,CAAC;IACf,WAAW,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3B,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,cAAc,CAAC,CAAC;CACrC;AAsED;;;;;;;GAOG;AACH,wBAAsB,OAAO,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,aAAa,CAAC,CAerE;AAED;;;;;;GAMG;AACH,wBAAsB,SAAS,CAC7B,MAAM,EAAE,MAAM,EACd,WAAW,EAAE,MAAM,GAAG,IAAI,EAC1B,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,cAAc,CAAC,GAClC,OAAO,CAAC,MAAM,CAAC,CAKjB"}
|