hazo_blog 1.4.0 → 1.6.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 (42) hide show
  1. package/CHANGE_LOG.md +110 -0
  2. package/db_setup_postgres.sql +2 -0
  3. package/db_setup_sqlite.sql +1 -0
  4. package/dist/index.d.ts +2 -2
  5. package/dist/index.d.ts.map +1 -1
  6. package/dist/index.js +1 -1
  7. package/dist/lib/index.js +6 -0
  8. package/dist/lib/mdx_mask.d.ts +28 -0
  9. package/dist/lib/mdx_mask.d.ts.map +1 -0
  10. package/dist/lib/mdx_mask.js +165 -0
  11. package/dist/lib/mdx_segment.d.ts +15 -0
  12. package/dist/lib/mdx_segment.d.ts.map +1 -0
  13. package/dist/lib/mdx_segment.js +56 -0
  14. package/dist/locales/de.json +4 -0
  15. package/dist/locales/en.json +4 -0
  16. package/dist/locales/sv.json +4 -0
  17. package/dist/next/pages.d.ts +1 -0
  18. package/dist/next/pages.d.ts.map +1 -1
  19. package/dist/next/pages.js +8 -5
  20. package/dist/next/routes.d.ts +33 -0
  21. package/dist/next/routes.d.ts.map +1 -1
  22. package/dist/next/routes.js +115 -3
  23. package/dist/repository/index.d.ts +2 -0
  24. package/dist/repository/index.d.ts.map +1 -1
  25. package/dist/repository/index.js +7 -0
  26. package/dist/seo/index.d.ts +10 -2
  27. package/dist/seo/index.d.ts.map +1 -1
  28. package/dist/seo/index.js +17 -5
  29. package/dist/service/index.d.ts +88 -0
  30. package/dist/service/index.d.ts.map +1 -1
  31. package/dist/service/index.js +268 -0
  32. package/dist/types/index.d.ts +8 -0
  33. package/dist/types/index.d.ts.map +1 -1
  34. package/dist/ui/blog-admin-panel.d.ts.map +1 -1
  35. package/dist/ui/blog-admin-panel.js +20 -1
  36. package/dist/ui/post-list-table.d.ts +9 -1
  37. package/dist/ui/post-list-table.d.ts.map +1 -1
  38. package/dist/ui/post-list-table.js +10 -2
  39. package/dist/ui/use-blog-admin.d.ts +7 -0
  40. package/dist/ui/use-blog-admin.d.ts.map +1 -1
  41. package/dist/ui/use-blog-admin.js +24 -5
  42. package/package.json +6 -2
package/CHANGE_LOG.md CHANGED
@@ -3,6 +3,116 @@
3
3
  All notable changes are documented here. This project follows
4
4
  [Semantic Versioning](https://semver.org/).
5
5
 
6
+ ## 1.6.0 — 2026-10-09
7
+
8
+ **Blog translation, Phase 4C: AI translate + AI review of translation-cluster drafts**
9
+
10
+ Takes a Phase 4B draft (created via `createTranslation()`, content copied verbatim,
11
+ untranslated) and fills it in — plus an independent quality-review pass. Both are new
12
+ `BlogService` methods over the `hazo_llm_api` optional peer, mirroring `hazo_i18n`'s own
13
+ `api/translate.ts`/`api/review.ts` shape and philosophy exactly (lazy `optional_import()`
14
+ probe, local re-declared module-shape interface, per-call `HazoUnavailableError` 503,
15
+ per-chunk graceful degradation).
16
+
17
+ - **`src/lib/mdx_mask.ts`** — `maskMdx()`/`unmaskMdx()`. Sets aside leading YAML frontmatter
18
+ (defensive only — `BlogPost.content` is pure MDX body today, title/excerpt/meta live in
19
+ their own columns, so no post in this schema actually has frontmatter), masks every
20
+ capitalized JSX component tag (`<Callout>`, `<YouTube id="..."/>`, ANY `[A-Z]...` tag name —
21
+ not an enumerated list, since a host's `config.mdxComponents` can add more) into its own
22
+ `jN` placeholder namespace, then delegates the remainder to `hazo_i18n/html`'s
23
+ `maskHtml()`/`unmaskHtml()` (`xN` namespace) for plain attributed HTML tags. The JSX pass
24
+ exists because `maskHtml()` only masks tags that carry attributes — right for plain HTML,
25
+ wrong for MDX, where a bare `<Callout>` (no attrs) is exactly as fragile to translate
26
+ around as an attributed one. **`hazo_i18n/html` is resolved LAZILY** (`optional_import`,
27
+ same convention as the `hazo_llm_api` peer below) — a static import here would turn
28
+ `hazo_i18n` (an optional peer) into a hard dependency for every consumer of this package,
29
+ not just ones calling `translatePost()`. For the same reason this module is NOT re-exported
30
+ through the general `./lib` barrel.
31
+ - **`src/lib/mdx_segment.ts`** — `segmentMdxForTranslation(content, maxChars = 2000)`. Splits
32
+ masked MDX at paragraph (blank-line) boundaries into LLM-call-sized chunks, grouping
33
+ consecutive paragraphs up to the budget; a single oversized paragraph still gets its own
34
+ chunk rather than being split mid-paragraph.
35
+ - **`BlogService.translatePost(postId)`** — `postId` is an EXISTING translation-cluster
36
+ DRAFT's own id (not a new `targetLocale` parameter): its `translation_of` is read to find
37
+ the source post, and its own `locale` is the target — a deliberate refinement of the
38
+ original plan now that `createTranslation()` already owns "create the row". Masks the
39
+ source content, segments it, translates each segment sequentially with its own
40
+ `hazo_llm_text_text()` call (plain-text response — "return ONLY the translated text, no
41
+ commentary" — not JSON; there's nothing to extract-as-JSON for free-form prose, unlike
42
+ `hazo_i18n`'s key-value chunks), then the title with one more call. One segment's failure
43
+ never aborts the others, but if ANY segment (or the title) fails, nothing is written — a
44
+ partially translated MDX post going live is worse than failing the whole call. Never
45
+ changes `status` (stays `'draft'`).
46
+ - **`BlogService.reviewTranslatedPost(postId)`** — one LLM call (not chunked; a blog post
47
+ comfortably fits one call) judging accuracy/naturalness/completeness/MDX-preservation
48
+ against the source, requesting structured `{ ok, notes }` JSON (parsed via
49
+ `try_extract_json()`, since a free-text verdict would need re-parsing anyway). Persists
50
+ `notes` to the post's new `review_notes` column on success; a failed review call leaves
51
+ `review_notes` untouched (`persisted: false`) rather than overwriting it with an error.
52
+ Never flips `status` — unlike `hazo_i18n`'s `reviewMessages()`, a human editor decides what
53
+ to do with the notes via the existing admin UI.
54
+ - **`hazo_blog_posts.review_notes TEXT`** (nullable) — new column, both `db_setup_sqlite.sql`
55
+ and `db_setup_postgres.sql` (+ their `migrations/` mirrors). `excerpt` was deliberately NOT
56
+ repurposed — it's an actively-used teaser/meta-description field.
57
+ - **New routes** (`src/next/routes.ts`, exported from the package root): `createBlogTranslateRoutes`
58
+ (`[id]/translate`, `POST` → `translatePost`) and `createBlogReviewRoutes` (`[id]/review`,
59
+ `POST` → `reviewTranslatedPost`) — mounted on their own resources rather than another method
60
+ on `[id]`, since that route's `POST`/`PUT`/`DELETE` are already spoken for (Phase 4B's
61
+ `createTranslation`, plus upsert/delete). Same cookie/session `config.authorize` guard, same
62
+ `HazoUnavailableError` → 503 convention as everywhere else in this file.
63
+ - **`hazo_llm_api` is now a declared OPTIONAL peer** (`peerDependenciesMeta`), same convention
64
+ as `hazo_i18n`. **`hazo_i18n`'s declared range bumped `^0.9.0` → `^0.12.0`** — its new `./html`
65
+ subpath (this phase's `maskHtml`/`unmaskHtml` reuse) only exists from 0.12.0. Note: this
66
+ workspace's own `node_modules/hazo_i18n` held a STALE real copy (0.5.9 — not even satisfying
67
+ the old `^0.9.0` range) that shadowed the correctly-hoisted 0.12.0 at the monorepo root;
68
+ removed so resolution falls through to the hoisted copy. A fresh `npm install` anywhere this
69
+ stale-copy situation doesn't already exist should resolve cleanly against the declared range.
70
+
71
+ ## 1.5.0 — 2026-10-09
72
+
73
+ **Blog translation, Phase 4B: locale-aware post URLs + static params + admin "Translate to..."**
74
+
75
+ Closes three gaps left over from 1.4.0's locale infrastructure (locale columns, `translation_of`,
76
+ locale-filtered repository reads, `getTranslations`) — URLs, static generation, and the admin UI
77
+ never actually used any of it.
78
+
79
+ - **`postUrl(config, slug, locale?)`** (`src/seo/index.ts`) gained an optional third parameter.
80
+ Omitted, `null`, or equal to `config.defaultLocale` (`"en"` when unset): byte-identical output
81
+ to before this parameter existed — no prefix, fully backward compatible. Any other locale gets
82
+ a `/${locale}` segment prepended **before** `basePath`: `https://site.com/de/blog/my-slug`, not
83
+ `/blog/de/my-slug` — matching gotimer's own `/[locale]/<slug>` convention (locale segment
84
+ first, default locale unprefixed) so a locale-aware host's `generateStaticParams`/routing
85
+ doesn't have to special-case blog URLs. `canonicalFor` now threads the post's own `locale`
86
+ through (a translated post's canonical was previously default-locale-shaped), and the sitemap's
87
+ per-post URL and hreflang `alternates` do the same.
88
+ - **`createBlogPostPage(...).generateStaticParams`** now returns a `{ slug, locale }` entry for
89
+ **every** locale variant from `getAllSlugs(false)` — not just the default locale. Null-locale
90
+ (default) rows get `config.defaultLocale` (`"en"` fallback) filled in as their `locale` value,
91
+ matching `postUrl`'s own default-locale comparison. Previously only default-locale posts ever
92
+ got a static route; every translation 404'd until `dynamicParams` kicked in on-demand.
93
+ - **Admin locale filter + "Translate to..."** (`BlogAdminPanel`, `PostListTable`,
94
+ `useBlogAdmin`, `createBlogAdminRoutes`):
95
+ - `collection.GET` now reads `?locale=` (same omitted-means-default-locale semantics as
96
+ `ListPostsOptions.locale`) and also returns a derived `locales: string[]` — every locale
97
+ currently in use, default locale first. No new DB query: reuses the existing
98
+ `getAllSlugs(true)` lookup (already built for `generateStaticParams`) rather than adding an
99
+ `enabledLocales` config field, since none exists today.
100
+ - New `BlogService.createTranslation(postId, targetLocale, req?)` + `BlogRepository.getPostById`
101
+ — creates a **draft** post in `targetLocale`, linked via `translation_of` to the source's
102
+ cluster root, with every content field (title/content/excerpt/meta/FAQ/tags/images) copied
103
+ **verbatim** from the source. Not a translation step — no LLM call; that's Phase 4C.
104
+ - New `item.POST` route (same `[id]` resource as the existing `PUT`/`DELETE`) wraps
105
+ `createTranslation`; body `{ locale: string }`.
106
+ - `BlogAdminPanel` gained a locale `<select>` filtering the post list (hidden when only one
107
+ locale is in use); `PostListTable` gained a Locale column and a per-row "Translate to…"
108
+ picker + button (hidden when `availableLocales` is empty). Both token-free Tailwind, per this
109
+ tree's isolation rule. New locale catalog keys (`post_list_table.col_locale`,
110
+ `post_list_table.translate_to`, `post_list_table.translate_failed`,
111
+ `blog_admin_panel.locale_filter_label`) in `en`/`de`/`sv`.
112
+ - Out of scope (separate, later phases): gotimer's own hazo_blog 1.4.0 schema upgrade (4A) and
113
+ the LLM `translatePost`/`reviewTranslatedPost` pipeline (4C) — `createTranslation` deliberately
114
+ does a verbatim copy, not a translation.
115
+
6
116
  ## 1.4.0 — 2026-10-06
7
117
 
8
118
  **Locale-aware UI text and dates (`en` / `de` / `sv`)**
@@ -64,6 +64,7 @@ CREATE TABLE IF NOT EXISTS hazo_blog_posts (
64
64
  publish_date TIMESTAMPTZ,
65
65
  locale TEXT DEFAULT NULL, -- BCP-47 locale; NULL = default locale
66
66
  translation_of UUID REFERENCES hazo_blog_posts(id) ON DELETE SET NULL, -- root post of this translation cluster
67
+ review_notes TEXT, -- BlogService#reviewTranslatedPost()'s LLM notes (nullable; never flips status)
67
68
  indexable BOOLEAN NOT NULL DEFAULT TRUE, -- FALSE excludes from sitemap/robots
68
69
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
69
70
  updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
@@ -71,6 +72,7 @@ CREATE TABLE IF NOT EXISTS hazo_blog_posts (
71
72
  ALTER TABLE hazo_blog_posts ADD COLUMN IF NOT EXISTS locale TEXT DEFAULT NULL;
72
73
  ALTER TABLE hazo_blog_posts ADD COLUMN IF NOT EXISTS translation_of UUID REFERENCES hazo_blog_posts(id) ON DELETE SET NULL;
73
74
  ALTER TABLE hazo_blog_posts ADD COLUMN IF NOT EXISTS indexable BOOLEAN NOT NULL DEFAULT TRUE;
75
+ ALTER TABLE hazo_blog_posts ADD COLUMN IF NOT EXISTS review_notes TEXT;
74
76
  -- FR-034: locale-aware uniqueness (see categories index comment above).
75
77
  DROP INDEX IF EXISTS idx_hazo_blog_posts_scope_slug;
76
78
  CREATE UNIQUE INDEX IF NOT EXISTS idx_hazo_blog_posts_scope_slug_locale
@@ -70,6 +70,7 @@ CREATE TABLE IF NOT EXISTS hazo_blog_posts (
70
70
  publish_date TEXT,
71
71
  locale TEXT DEFAULT NULL, -- BCP-47 locale; NULL = default locale
72
72
  translation_of TEXT REFERENCES hazo_blog_posts(id) ON DELETE SET NULL, -- root post of this translation cluster
73
+ review_notes TEXT, -- BlogService#reviewTranslatedPost()'s LLM notes (nullable; never flips status)
73
74
  indexable INTEGER NOT NULL DEFAULT 1, -- 0/1; FALSE excludes from sitemap/robots
74
75
  created_at TEXT NOT NULL DEFAULT (datetime('now')),
75
76
  updated_at TEXT NOT NULL DEFAULT (datetime('now'))
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
@@ -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,GACjB,MAAM,oBAAoB,CAAC;AAG5B,cAAc,gBAAgB,CAAC;AAI/B,OAAO,EACL,sBAAsB,EACtB,0BAA0B,EAC1B,qBAAqB,EACrB,wBAAwB,EACxB,qBAAqB,EACrB,mBAAmB,EACnB,sBAAsB,GACvB,MAAM,kBAAkB,CAAC;AAC1B,OAAO,EACL,0BAA0B,EAC1B,KAAK,8BAA8B,GACpC,MAAM,wBAAwB,CAAC"}
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"}
@@ -0,0 +1,165 @@
1
+ // hazo_blog — MDX masking for the LLM translation round trip
2
+ // (`service/index.ts#translatePost()`). Built on `hazo_i18n/html`'s
3
+ // `maskHtml`/`unmaskHtml` (the same tag-masking primitives hazo_i18n's own
4
+ // export/import/translate pipeline uses for rich-text HTML values) plus a
5
+ // thin MDX-specific layer on top: frontmatter set-aside and a pre-pass for
6
+ // bare (attribute-less) capitalized JSX component tags.
7
+ //
8
+ // ── Frontmatter ──────────────────────────────────────────────────────────────
9
+ // `BlogPost.content` (`src/types/index.ts`) is documented as "raw MDX" and is
10
+ // built exclusively by `service#upsertBySlug()`/`createTranslation()` from a
11
+ // plain MDX body — title/excerpt/meta fields live in their own DB columns,
12
+ // never folded into a `---\n...\n---` frontmatter block anywhere in this
13
+ // package. So frontmatter extraction here is defensive, not load-bearing: if
14
+ // `content` ever does start with a `---` block (e.g. content authored/pasted
15
+ // elsewhere before being saved through this package), it's still set aside
16
+ // untouched rather than risk the LLM mangling YAML syntax it was never asked
17
+ // about.
18
+ //
19
+ // ── Why capitalized JSX tags need their own pass ────────────────────────────
20
+ // `hazo_i18n`'s `maskHtml()` only masks tags that carry attributes (its own
21
+ // doc: "only tags that carry attributes are masked... masking every tag
22
+ // would turn long-form content into an unreadable wall of placeholders").
23
+ // That heuristic is right for plain HTML, where an attribute-less tag like
24
+ // `<p>` or `<strong>` is always a plain structural tag. It is WRONG for MDX,
25
+ // where a bare `<Callout>` (no attrs) is a React component exactly as
26
+ // fragile to translate around as `<Callout type="info">` — an LLM "helpfully"
27
+ // paraphrasing or re-casing the tag name breaks the render. So this module
28
+ // pre-masks every capitalized-tag-name JSX tag (self-closing or paired,
29
+ // attributed or not) to its own placeholder space (`<j1>`/`<j2>`/...) BEFORE
30
+ // handing the remainder to `maskHtml()`, which still runs its own pass over
31
+ // whatever capitalized tags (now placeholders, ignored) and attributed plain
32
+ // HTML tags (e.g. a stray `<a href="...">` inside MDX body text) remain.
33
+ //
34
+ // Any component name is accepted generically (`[A-Z][A-Za-z0-9]*`) — this
35
+ // package can't know the host's `config.mdxComponents` additions in advance
36
+ // (`defaultMdxComponents` ships `YouTube`/`Callout`/`BlogImage`, but a host
37
+ // like gotimer can register more), so we mask by naming convention (JSX
38
+ // components are always capitalized; HTML tags never are), not by an
39
+ // enumerated component list.
40
+ //
41
+ // ── Why `hazo_i18n/html` is imported LAZILY, not statically ─────────────────
42
+ // `hazo_i18n` is an OPTIONAL peer of this package (`package.json`'s
43
+ // `peerDependenciesMeta`) — most hosts don't install it. This file is reached
44
+ // from `service/index.ts`'s top-level imports (every consumer pulls in
45
+ // `createBlogService`, not just ones using `translatePost()`), so a static
46
+ // `import ... from "hazo_i18n/html"` here would turn an optional peer into a
47
+ // hard runtime dependency for EVERY consumer, breaking the package for
48
+ // anyone who doesn't have `hazo_i18n` installed and never calls
49
+ // `translatePost()` in the first place. `optional_import()` (the same
50
+ // `hazo_core` helper `service/index.ts#requireLlmApi()` uses for the
51
+ // `hazo_llm_api` peer) resolves it lazily, only when `maskMdx()`/
52
+ // `unmaskMdx()` are actually called, and throws the same `HazoUnavailableError`
53
+ // 503 convention when it isn't installed.
54
+ import { HazoUnavailableError, optional_import } from "hazo_core";
55
+ async function requireHazoI18nHtml(fnName) {
56
+ const mod = await optional_import("hazo_i18n/html");
57
+ if (!mod) {
58
+ throw new HazoUnavailableError({
59
+ code: "HAZO_BLOG_MDX_MASK_UNAVAILABLE",
60
+ pkg: "hazo_blog",
61
+ message: `${fnName}() requires the hazo_i18n optional peer (its "./html" subpath) — it is not installed.`,
62
+ });
63
+ }
64
+ return mod;
65
+ }
66
+ const FRONTMATTER_PATTERN = /^---\n[\s\S]*?\n---\n?/;
67
+ /** Matches a capitalized JSX tag: `<Name ...>`, `<Name/>`, `<Name>`, `</Name>`. */
68
+ const JSX_TAG_PATTERN = /<(\/)?([A-Z][A-Za-z0-9]*)\b([^>]*?)(\/)?>/g;
69
+ /**
70
+ * Masks every capitalized JSX component tag in `text` (attributed or bare),
71
+ * giving each open/close pair the same placeholder number (`<jN>`/`</jN>`,
72
+ * self-closing `<jN/>`). Mirrors `maskHtml()`'s own stack-based pairing
73
+ * algorithm (`hazo_i18n/src/lib/html_mask.ts`) but keyed on capitalization
74
+ * instead of attribute presence, and in its own `jN` placeholder namespace so
75
+ * it can never collide with `maskHtml()`'s own `xN` placeholders when both
76
+ * passes run over the same text.
77
+ */
78
+ function maskJsxComponents(text) {
79
+ const map = {};
80
+ let counter = 0;
81
+ let result = "";
82
+ let lastIndex = 0;
83
+ const stack = [];
84
+ const pattern = new RegExp(JSX_TAG_PATTERN.source, "g");
85
+ for (const match of text.matchAll(pattern)) {
86
+ const [full, closingSlash, name, , selfClosingSlash] = match;
87
+ const index = match.index ?? 0;
88
+ result += text.slice(lastIndex, index);
89
+ lastIndex = index + full.length;
90
+ if (closingSlash === "/") {
91
+ const top = stack[stack.length - 1];
92
+ if (top && top.name === name) {
93
+ stack.pop();
94
+ map[top.maskName].close = full;
95
+ result += `</${top.maskName}>`;
96
+ }
97
+ else {
98
+ // Unbalanced/mismatched closing tag — leave untouched rather than
99
+ // guess at a pairing (same defensive fallback as `maskHtml()`).
100
+ result += full;
101
+ }
102
+ continue;
103
+ }
104
+ counter += 1;
105
+ const placeholder = `j${counter}`;
106
+ if (selfClosingSlash === "/") {
107
+ map[placeholder] = { open: full, close: "" };
108
+ result += `<${placeholder}/>`;
109
+ continue;
110
+ }
111
+ map[placeholder] = { open: full, close: "" };
112
+ stack.push({ name, maskName: placeholder });
113
+ result += `<${placeholder}>`;
114
+ }
115
+ result += text.slice(lastIndex);
116
+ return { masked: result, map };
117
+ }
118
+ const JSX_PLACEHOLDER_PATTERN = /<(\/?)j(\d+)(\/)?>/g;
119
+ function unmaskJsxComponents(masked, map) {
120
+ const pattern = new RegExp(JSX_PLACEHOLDER_PATTERN.source, "g");
121
+ return masked.replace(pattern, (full, closingSlash, num, selfClosingSlash) => {
122
+ const entry = map[`j${num}`];
123
+ if (!entry)
124
+ return full;
125
+ if (selfClosingSlash === "/")
126
+ return entry.open;
127
+ if (closingSlash === "/")
128
+ return entry.close;
129
+ return entry.open;
130
+ });
131
+ }
132
+ /**
133
+ * Sets aside leading YAML frontmatter (if present — see this file's header,
134
+ * most posts have none), then masks every capitalized JSX component tag
135
+ * (`maskJsxComponents`, this file's own `jN` namespace) and finally every
136
+ * remaining attributed plain-HTML tag (`maskHtml()`'s `xN` namespace). The
137
+ * two placeholder maps are merged into one — the numeric prefixes (`j`/`x`)
138
+ * never collide, so a single `map` round-trips through `unmaskMdx()`.
139
+ */
140
+ export async function maskMdx(content) {
141
+ const html = await requireHazoI18nHtml("maskMdx");
142
+ const frontmatterMatch = content.match(FRONTMATTER_PATTERN);
143
+ const frontmatter = frontmatterMatch ? frontmatterMatch[0] : null;
144
+ const body = frontmatter ? content.slice(frontmatter.length) : content;
145
+ const jsxPass = maskJsxComponents(body);
146
+ const htmlPass = html.maskHtml(jsxPass.masked);
147
+ return {
148
+ masked: htmlPass.masked,
149
+ frontmatter,
150
+ map: { ...jsxPass.map, ...htmlPass.map },
151
+ };
152
+ }
153
+ /**
154
+ * Exact inverse of `maskMdx()`: restores HTML tag placeholders, then JSX
155
+ * component placeholders, then re-prepends the frontmatter byte-for-byte.
156
+ * Order doesn't matter between the two unmask passes (disjoint placeholder
157
+ * namespaces), but running `unmaskHtml` first mirrors `maskMdx()`'s own
158
+ * "JSX pass, then HTML pass" order in reverse.
159
+ */
160
+ export async function unmaskMdx(masked, frontmatter, map) {
161
+ const html = await requireHazoI18nHtml("unmaskMdx");
162
+ const afterHtml = html.unmaskHtml(masked, map);
163
+ const afterJsx = unmaskJsxComponents(afterHtml, map);
164
+ return frontmatter ? frontmatter + afterJsx : afterJsx;
165
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Groups masked MDX content's paragraphs into chunks of at most `maxChars`
3
+ * characters each, never splitting a paragraph across two chunks. A single
4
+ * paragraph longer than `maxChars` still gets its own (oversized) chunk —
5
+ * same "never split a unit" rule `chunkRowsByLimits()` applies to an
6
+ * oversized row.
7
+ *
8
+ * Chunks are rejoined with `"\n\n"` so each chunk is itself valid
9
+ * Markdown/MDX (paragraphs separated by exactly one blank line), independent
10
+ * of whatever exact whitespace separated them in the source (1 blank line vs.
11
+ * 2+ — intentionally normalized; MDX treats any run of blank lines as one
12
+ * paragraph break).
13
+ */
14
+ export declare function segmentMdxForTranslation(content: string, maxChars?: number): string[];
15
+ //# sourceMappingURL=mdx_segment.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"mdx_segment.d.ts","sourceRoot":"","sources":["../../src/lib/mdx_segment.ts"],"names":[],"mappings":"AAwBA;;;;;;;;;;;;GAYG;AACH,wBAAgB,wBAAwB,CAAC,OAAO,EAAE,MAAM,EAAE,QAAQ,SAAO,GAAG,MAAM,EAAE,CAqBnF"}
@@ -0,0 +1,56 @@
1
+ // hazo_blog — MDX segmentation for the LLM translation round trip
2
+ // (`service/index.ts#translatePost()`). Splits MASKED MDX content (see
3
+ // `mdx_mask.ts`) into LLM-call-sized chunks, never splitting a paragraph
4
+ // across two chunks.
5
+ //
6
+ // Mirrors the character-budget reasoning `hazo_i18n/src/lib/export.ts`'s
7
+ // `chunkRowsByLimits()` uses for its own `chunkMaxChars` limit: group
8
+ // consecutive units up to a character budget, and a single unit that alone
9
+ // exceeds the budget still gets its own chunk rather than being split
10
+ // mid-unit (there, a "unit" is one message row; here, one paragraph block).
11
+ // Not literally reused — different package, different unit shape — but the
12
+ // same "character budget, never split a unit" philosophy.
13
+ /**
14
+ * Splits `content` into blank-line-separated paragraph blocks (the
15
+ * conventional Markdown/MDX paragraph boundary — one or more blank lines),
16
+ * preserving each block's internal newlines (a multi-line list or a fenced
17
+ * code block stays one block, since it contains no blank line itself).
18
+ */
19
+ function splitIntoParagraphs(content) {
20
+ // Split on 1+ blank lines (any whitespace-only line counts as blank).
21
+ return content.split(/\n[ \t]*\n+/).filter((block) => block.trim().length > 0);
22
+ }
23
+ /**
24
+ * Groups masked MDX content's paragraphs into chunks of at most `maxChars`
25
+ * characters each, never splitting a paragraph across two chunks. A single
26
+ * paragraph longer than `maxChars` still gets its own (oversized) chunk —
27
+ * same "never split a unit" rule `chunkRowsByLimits()` applies to an
28
+ * oversized row.
29
+ *
30
+ * Chunks are rejoined with `"\n\n"` so each chunk is itself valid
31
+ * Markdown/MDX (paragraphs separated by exactly one blank line), independent
32
+ * of whatever exact whitespace separated them in the source (1 blank line vs.
33
+ * 2+ — intentionally normalized; MDX treats any run of blank lines as one
34
+ * paragraph break).
35
+ */
36
+ export function segmentMdxForTranslation(content, maxChars = 2000) {
37
+ const paragraphs = splitIntoParagraphs(content);
38
+ if (paragraphs.length === 0)
39
+ return [];
40
+ const chunks = [];
41
+ let current = [];
42
+ let currentChars = 0;
43
+ for (const paragraph of paragraphs) {
44
+ const paragraphChars = paragraph.length;
45
+ if (current.length > 0 && currentChars + 2 + paragraphChars > maxChars) {
46
+ chunks.push(current.join("\n\n"));
47
+ current = [];
48
+ currentChars = 0;
49
+ }
50
+ current.push(paragraph);
51
+ currentChars += (current.length > 1 ? 2 : 0) + paragraphChars;
52
+ }
53
+ if (current.length > 0)
54
+ chunks.push(current.join("\n\n"));
55
+ return chunks;
56
+ }
@@ -68,6 +68,7 @@
68
68
  "blog_admin_panel.preview": "Vorschau",
69
69
  "blog_admin_panel.delete_confirm": "„{{title}}“ löschen? Dies kann nicht rückgängig gemacht werden.",
70
70
  "blog_admin_panel.delete_failed": "Löschen fehlgeschlagen",
71
+ "blog_admin_panel.locale_filter_label": "Sprache",
71
72
  "post_list_table.search_placeholder": "Titel oder Slug suchen…",
72
73
  "post_list_table.filter_all": "Alle",
73
74
  "post_list_table.filter_draft": "Entwurf",
@@ -81,6 +82,9 @@
81
82
  "post_list_table.untitled": "(ohne Titel)",
82
83
  "post_list_table.edit": "Bearbeiten",
83
84
  "post_list_table.delete": "Löschen",
85
+ "post_list_table.col_locale": "Sprache",
86
+ "post_list_table.translate_to": "Übersetzen nach…",
87
+ "post_list_table.translate_failed": "Übersetzung fehlgeschlagen",
84
88
  "layout_selector.not_persisted_notice": "Einstellungstabelle nicht migriert – Layout ist schreibgeschützt.",
85
89
  "layout_selector.save_failed": "Layout konnte nicht gespeichert werden",
86
90
  "layout_selector.saving": "Speichern…",
@@ -68,6 +68,7 @@
68
68
  "blog_admin_panel.preview": "Preview",
69
69
  "blog_admin_panel.delete_confirm": "Delete \"{{title}}\"? This cannot be undone.",
70
70
  "blog_admin_panel.delete_failed": "Delete failed",
71
+ "blog_admin_panel.locale_filter_label": "Locale",
71
72
  "post_list_table.search_placeholder": "Search title or slug…",
72
73
  "post_list_table.filter_all": "All",
73
74
  "post_list_table.filter_draft": "Draft",
@@ -81,6 +82,9 @@
81
82
  "post_list_table.untitled": "(untitled)",
82
83
  "post_list_table.edit": "Edit",
83
84
  "post_list_table.delete": "Delete",
85
+ "post_list_table.col_locale": "Locale",
86
+ "post_list_table.translate_to": "Translate to…",
87
+ "post_list_table.translate_failed": "Translation failed",
84
88
  "layout_selector.not_persisted_notice": "Settings table not migrated — layout is read-only.",
85
89
  "layout_selector.save_failed": "Failed to save layout",
86
90
  "layout_selector.saving": "Saving…",
@@ -68,6 +68,7 @@
68
68
  "blog_admin_panel.preview": "Förhandsgranska",
69
69
  "blog_admin_panel.delete_confirm": "Ta bort “{{title}}”? Det går inte att ångra.",
70
70
  "blog_admin_panel.delete_failed": "Borttagningen misslyckades",
71
+ "blog_admin_panel.locale_filter_label": "Språk",
71
72
  "post_list_table.search_placeholder": "Sök på titel eller slug…",
72
73
  "post_list_table.filter_all": "Alla",
73
74
  "post_list_table.filter_draft": "Utkast",
@@ -81,6 +82,9 @@
81
82
  "post_list_table.untitled": "(namnlös)",
82
83
  "post_list_table.edit": "Redigera",
83
84
  "post_list_table.delete": "Ta bort",
85
+ "post_list_table.col_locale": "Språk",
86
+ "post_list_table.translate_to": "Översätt till…",
87
+ "post_list_table.translate_failed": "Översättningen misslyckades",
84
88
  "layout_selector.not_persisted_notice": "Inställningstabellen är inte migrerad – layouten är skrivskyddad.",
85
89
  "layout_selector.save_failed": "Det gick inte att spara layouten",
86
90
  "layout_selector.saving": "Sparar…",
@@ -22,6 +22,7 @@ export declare function createBlogPostPage(rawConfig: BlogConfig): {
22
22
  }) => Promise<Metadata>;
23
23
  generateStaticParams: () => Promise<{
24
24
  slug: string;
25
+ locale: string;
25
26
  }[]>;
26
27
  revalidate: number;
27
28
  dynamicParams: boolean;
@@ -1 +1 @@
1
- {"version":3,"file":"pages.d.ts","sourceRoot":"","sources":["../../src/next/pages.tsx"],"names":[],"mappings":"AAkCA,OAAO,KAAK,EAAE,UAAU,EAAyB,MAAM,mBAAmB,CAAC;AAI3E,KAAK,QAAQ,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;AAexC,qCAAqC;AACrC,wBAAgB,kBAAkB,CAAC,SAAS,EAAE,UAAU;2BAsDnD;QACD,MAAM,EAAE,OAAO,CAAC;YAAE,IAAI,EAAE,MAAM,CAAC;YAAC,MAAM,CAAC,EAAE,MAAM,CAAA;SAAE,CAAC,GAAG;YAAE,IAAI,EAAE,MAAM,CAAC;YAAC,MAAM,CAAC,EAAE,MAAM,CAAA;SAAE,CAAC;KACxF;oCAzCE;QACD,MAAM,EAAE,OAAO,CAAC;YAAE,IAAI,EAAE,MAAM,CAAC;YAAC,MAAM,CAAC,EAAE,MAAM,CAAA;SAAE,CAAC,GAAG;YAAE,IAAI,EAAE,MAAM,CAAC;YAAC,MAAM,CAAC,EAAE,MAAM,CAAA;SAAE,CAAC;KACxF,KAAG,OAAO,CAAC,QAAQ,CAAC;;;;;;EAyEtB;AAED,+BAA+B;AAC/B,wBAAgB,mBAAmB,CAAC,SAAS,EAAE,UAAU;iCAepD;QACD,YAAY,CAAC,EAAE,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;KACzE;4BAZkC,OAAO,CAAC,QAAQ,CAAC;;EAsDrD;AAED,uCAAuC;AACvC,wBAAgB,iBAAiB,CAAC,SAAS,EAAE,UAAU;2BAOlD;QACD,MAAM,EAAE,OAAO,CAAC;YAAE,GAAG,EAAE,MAAM,CAAA;SAAE,CAAC,GAAG;YAAE,GAAG,EAAE,MAAM,CAAA;SAAE,CAAC;KACpD;oCAuBE;QACD,MAAM,EAAE,OAAO,CAAC;YAAE,GAAG,EAAE,MAAM,CAAA;SAAE,CAAC,GAAG;YAAE,GAAG,EAAE,MAAM,CAAA;SAAE,CAAC;KACpD,KAAG,OAAO,CAAC,QAAQ,CAAC;;EAStB;AAED,mFAAmF;AACnF,wBAAgB,uBAAuB,CAAC,SAAS,EAAE,UAAU;;EA0E5D;AAED,mEAAmE;AACnE,wBAAgB,sBAAsB,CAAC,SAAS,EAAE,UAAU;;EAqB3D;AAED,uEAAuE;AACvE,wBAAgB,uBAAuB,CAAC,SAAS,EAAE,UAAU;2BAOxD;QACD,MAAM,EAAE,OAAO,CAAC;YAAE,IAAI,EAAE,MAAM,CAAA;SAAE,CAAC,GAAG;YAAE,IAAI,EAAE,MAAM,CAAA;SAAE,CAAC;KACtD;EAwBF"}
1
+ {"version":3,"file":"pages.d.ts","sourceRoot":"","sources":["../../src/next/pages.tsx"],"names":[],"mappings":"AAkCA,OAAO,KAAK,EAAE,UAAU,EAAyB,MAAM,mBAAmB,CAAC;AAI3E,KAAK,QAAQ,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;AAexC,qCAAqC;AACrC,wBAAgB,kBAAkB,CAAC,SAAS,EAAE,UAAU;2BAyDnD;QACD,MAAM,EAAE,OAAO,CAAC;YAAE,IAAI,EAAE,MAAM,CAAC;YAAC,MAAM,CAAC,EAAE,MAAM,CAAA;SAAE,CAAC,GAAG;YAAE,IAAI,EAAE,MAAM,CAAC;YAAC,MAAM,CAAC,EAAE,MAAM,CAAA;SAAE,CAAC;KACxF;oCAzCE;QACD,MAAM,EAAE,OAAO,CAAC;YAAE,IAAI,EAAE,MAAM,CAAC;YAAC,MAAM,CAAC,EAAE,MAAM,CAAA;SAAE,CAAC,GAAG;YAAE,IAAI,EAAE,MAAM,CAAC;YAAC,MAAM,CAAC,EAAE,MAAM,CAAA;SAAE,CAAC;KACxF,KAAG,OAAO,CAAC,QAAQ,CAAC;;;;;;;EAyEtB;AAED,+BAA+B;AAC/B,wBAAgB,mBAAmB,CAAC,SAAS,EAAE,UAAU;iCAepD;QACD,YAAY,CAAC,EAAE,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;KACzE;4BAZkC,OAAO,CAAC,QAAQ,CAAC;;EAsDrD;AAED,uCAAuC;AACvC,wBAAgB,iBAAiB,CAAC,SAAS,EAAE,UAAU;2BAOlD;QACD,MAAM,EAAE,OAAO,CAAC;YAAE,GAAG,EAAE,MAAM,CAAA;SAAE,CAAC,GAAG;YAAE,GAAG,EAAE,MAAM,CAAA;SAAE,CAAC;KACpD;oCAuBE;QACD,MAAM,EAAE,OAAO,CAAC;YAAE,GAAG,EAAE,MAAM,CAAA;SAAE,CAAC,GAAG;YAAE,GAAG,EAAE,MAAM,CAAA;SAAE,CAAC;KACpD,KAAG,OAAO,CAAC,QAAQ,CAAC;;EAStB;AAED,mFAAmF;AACnF,wBAAgB,uBAAuB,CAAC,SAAS,EAAE,UAAU;;EA0E5D;AAED,mEAAmE;AACnE,wBAAgB,sBAAsB,CAAC,SAAS,EAAE,UAAU;;EAqB3D;AAED,uEAAuE;AACvE,wBAAgB,uBAAuB,CAAC,SAAS,EAAE,UAAU;2BAOxD;QACD,MAAM,EAAE,OAAO,CAAC;YAAE,IAAI,EAAE,MAAM,CAAA;SAAE,CAAC,GAAG;YAAE,IAAI,EAAE,MAAM,CAAA;SAAE,CAAC;KACtD;EAwBF"}
@@ -43,11 +43,14 @@ export function createBlogPostPage(rawConfig) {
43
43
  const service = createBlogService(config);
44
44
  const t = blogTranslator(config.uiLocale);
45
45
  async function generateStaticParams() {
46
- // Default-locale slugs only — getAllSlugs() with no locale filter returns
47
- // every translation variant too, which would generate duplicate static
48
- // routes for the same `slug` segment under different locales.
46
+ // Every locale variant's slug — the route this is designed for expects
47
+ // BOTH `slug` and `locale` segments (see Page/generateMetadata below,
48
+ // which destructure both from `params`), so default-locale rows (where
49
+ // `locale` is null in the DB) are given the configured default locale
50
+ // string here rather than being dropped.
51
+ const defaultLocale = config.defaultLocale || "en";
49
52
  const slugs = await service.getAllSlugs(false);
50
- return slugs.filter((s) => !s.locale).map(({ slug }) => ({ slug }));
53
+ return slugs.map((s) => ({ slug: s.slug, locale: s.locale ?? defaultLocale }));
51
54
  }
52
55
  async function generateMetadata({ params, }) {
53
56
  const { slug, locale } = params instanceof Promise ? await params : params;
@@ -62,7 +65,7 @@ export function createBlogPostPage(rawConfig) {
62
65
  const languages = translations
63
66
  .filter((tr) => tr.indexable !== false)
64
67
  .reduce((acc, tr) => {
65
- acc[tr.locale] = postUrl(config, tr.slug);
68
+ acc[tr.locale] = postUrl(config, tr.slug, tr.locale);
66
69
  return acc;
67
70
  }, {});
68
71
  return {
@@ -52,6 +52,12 @@ export declare function createBlogAdminRoutes(config: BlogConfig): {
52
52
  POST(req: Request): Promise<Response>;
53
53
  };
54
54
  item: {
55
+ /**
56
+ * Create a draft translation of this post ("Translate to...") — body
57
+ * `{ locale: string }`. Mirrors the PUT/DELETE pattern below: mounted on
58
+ * the same `[id]` route, one more HTTP method on the same resource.
59
+ */
60
+ POST(req: Request, ctx?: RouteCtx): Promise<Response>;
55
61
  PUT(req: Request, ctx?: RouteCtx): Promise<Response>;
56
62
  DELETE(req: Request, ctx?: RouteCtx): Promise<Response>;
57
63
  };
@@ -66,5 +72,32 @@ export declare function createBlogSettingsRoutes(config: BlogConfig): {
66
72
  GET: (req: Request) => Promise<Response>;
67
73
  PUT: (req: Request) => Promise<Response>;
68
74
  };
75
+ /**
76
+ * Translate an EXISTING translation-cluster draft's content (cookie/session,
77
+ * guarded by config.authorize). Mounted on its own `[id]/translate` resource
78
+ * rather than another method on the `[id]` route, since that route's `POST`
79
+ * is already `createTranslation` (Phase 4B's "Translate to..." — creates the
80
+ * draft row); this one fills an existing draft in.
81
+ *
82
+ * POST — `service.translatePost(id, req)`. 503 `SERVICE_UNAVAILABLE` if the
83
+ * `hazo_llm_api` optional peer isn't installed; 400 `VALIDATION_FAILED` for
84
+ * any other failure (unknown id, missing `translation_of`/source).
85
+ */
86
+ export declare function createBlogTranslateRoutes(config: BlogConfig): {
87
+ POST: (req: Request, ctx?: RouteCtx) => Promise<Response>;
88
+ };
89
+ /**
90
+ * AI quality-review a translated post against its source (cookie/session,
91
+ * guarded by config.authorize). Mounted on its own `[id]/review` resource
92
+ * for the same reason `createBlogTranslateRoutes` gets its own —
93
+ * `[id]`'s `POST`/`PUT`/`DELETE` are already spoken for.
94
+ *
95
+ * POST — `service.reviewTranslatedPost(id, req)`. 503
96
+ * `SERVICE_UNAVAILABLE` if the `hazo_llm_api` optional peer isn't
97
+ * installed; 400 `VALIDATION_FAILED` for any other failure.
98
+ */
99
+ export declare function createBlogReviewRoutes(config: BlogConfig): {
100
+ POST: (req: Request, ctx?: RouteCtx) => Promise<Response>;
101
+ };
69
102
  export {};
70
103
  //# sourceMappingURL=routes.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"routes.d.ts","sourceRoot":"","sources":["../../src/next/routes.ts"],"names":[],"mappings":"AAUA,OAAO,KAAK,EAAE,UAAU,EAAmB,MAAM,mBAAmB,CAAC;AAErE,KAAK,QAAQ,GAAG;IAAE,MAAM,EAAE,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAA;CAAE,GAAG;IAAE,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;CAAE,CAAC;AAYjG;;;;GAIG;AACH,wBAAgB,eAAe,CAAC,MAAM,EAAE,UAAU,GAAG,CAAC,GAAG,EAAE,OAAO,KAAK,OAAO,CAAC,QAAQ,GAAG,IAAI,CAAC,CAK9F;AAED,8CAA8C;AAC9C,wBAAgB,qBAAqB,CAAC,MAAM,EAAE,UAAU,IAE5B,KAAK,OAAO,KAAG,OAAO,CAAC,QAAQ,CAAC,CAS3D;AAED,kDAAkD;AAClD,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,UAAU,IAE1B,KAAK,OAAO,KAAG,OAAO,CAAC,QAAQ,CAAC,CAO3D;AAED,oFAAoF;AACpF,wBAAgB,sBAAsB,CAAC,MAAM,EAAE,UAAU,IAE7B,KAAK,OAAO,KAAG,OAAO,CAAC,QAAQ,CAAC,CAI3D;AAED;;;;;GAKG;AACH,wBAAgB,sBAAsB,CAAC,MAAM,EAAE,UAAU;;;;EAuDxD;AAED;;;;;;GAMG;AACH,wBAAgB,0BAA0B,CAAC,MAAM,EAAE,UAAU;;;;EA4D5D;AAED;;;;;;GAMG;AACH,wBAAgB,qBAAqB,CAAC,MAAM,EAAE,UAAU;;iBAKrC,OAAO,GAAG,OAAO,CAAC,QAAQ,CAAC;kBAS1B,OAAO,GAAG,OAAO,CAAC,QAAQ,CAAC;;;iBAe5B,OAAO,QAAQ,QAAQ,GAAG,OAAO,CAAC,QAAQ,CAAC;oBAaxC,OAAO,QAAQ,QAAQ,GAAG,OAAO,CAAC,QAAQ,CAAC;;EAWhE;AAED;;;;;GAKG;AACH,wBAAgB,wBAAwB,CAAC,MAAM,EAAE,UAAU;eAIjC,OAAO,KAAG,OAAO,CAAC,QAAQ,CAAC;eAe3B,OAAO,KAAG,OAAO,CAAC,QAAQ,CAAC;EAsBpD"}
1
+ {"version":3,"file":"routes.d.ts","sourceRoot":"","sources":["../../src/next/routes.ts"],"names":[],"mappings":"AAWA,OAAO,KAAK,EAAE,UAAU,EAAmB,MAAM,mBAAmB,CAAC;AAErE,KAAK,QAAQ,GAAG;IAAE,MAAM,EAAE,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAA;CAAE,GAAG;IAAE,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;CAAE,CAAC;AAYjG;;;;GAIG;AACH,wBAAgB,eAAe,CAAC,MAAM,EAAE,UAAU,GAAG,CAAC,GAAG,EAAE,OAAO,KAAK,OAAO,CAAC,QAAQ,GAAG,IAAI,CAAC,CAK9F;AAED,8CAA8C;AAC9C,wBAAgB,qBAAqB,CAAC,MAAM,EAAE,UAAU,IAE5B,KAAK,OAAO,KAAG,OAAO,CAAC,QAAQ,CAAC,CAS3D;AAED,kDAAkD;AAClD,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,UAAU,IAE1B,KAAK,OAAO,KAAG,OAAO,CAAC,QAAQ,CAAC,CAO3D;AAED,oFAAoF;AACpF,wBAAgB,sBAAsB,CAAC,MAAM,EAAE,UAAU,IAE7B,KAAK,OAAO,KAAG,OAAO,CAAC,QAAQ,CAAC,CAI3D;AAED;;;;;GAKG;AACH,wBAAgB,sBAAsB,CAAC,MAAM,EAAE,UAAU;;;;EAuDxD;AAED;;;;;;GAMG;AACH,wBAAgB,0BAA0B,CAAC,MAAM,EAAE,UAAU;;;;EA4D5D;AAED;;;;;;GAMG;AACH,wBAAgB,qBAAqB,CAAC,MAAM,EAAE,UAAU;;iBAKrC,OAAO,GAAG,OAAO,CAAC,QAAQ,CAAC;kBAuB1B,OAAO,GAAG,OAAO,CAAC,QAAQ,CAAC;;;QAe3C;;;;WAIG;kBACa,OAAO,QAAQ,QAAQ,GAAG,OAAO,CAAC,QAAQ,CAAC;iBAmB5C,OAAO,QAAQ,QAAQ,GAAG,OAAO,CAAC,QAAQ,CAAC;oBAaxC,OAAO,QAAQ,QAAQ,GAAG,OAAO,CAAC,QAAQ,CAAC;;EAWhE;AAED;;;;;GAKG;AACH,wBAAgB,wBAAwB,CAAC,MAAM,EAAE,UAAU;eAIjC,OAAO,KAAG,OAAO,CAAC,QAAQ,CAAC;eAe3B,OAAO,KAAG,OAAO,CAAC,QAAQ,CAAC;EAsBpD;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,yBAAyB,CAAC,MAAM,EAAE,UAAU;gBAIjC,OAAO,QAAQ,QAAQ,KAAG,OAAO,CAAC,QAAQ,CAAC;EAkBrE;AAED;;;;;;;;;GASG;AACH,wBAAgB,sBAAsB,CAAC,MAAM,EAAE,UAAU;gBAI9B,OAAO,QAAQ,QAAQ,KAAG,OAAO,CAAC,QAAQ,CAAC;EAkBrE"}