hazo_blog 0.5.0 → 1.2.0

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