hazo_blog 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGE_LOG.md CHANGED
@@ -3,6 +3,34 @@
3
3
  All notable changes are documented here. This project follows
4
4
  [Semantic Versioning](https://semver.org/).
5
5
 
6
+ ## 0.3.0 — 2026-06-12
7
+
8
+ **Build resilience: sanitizer + async try/catch guard against malformed MDX**
9
+
10
+ - `sanitizeMdx(mdx: string): string` — new public utility in `src/lib/text`. Converts HTML
11
+ comments (`<!-- ... -->`) to MDX comments (`{/* ... */}`) and escapes stray `<!` that are not
12
+ comment openers, leaving fenced and inline code blocks untouched. Idempotent. This is the
13
+ primary protection layer against the known crash class (HTML comments in post body that caused
14
+ `next build` to 500 site-wide on gotimer).
15
+ - `BlogContent` is now an async server component. It calls `MDXRemote` directly with `await`
16
+ and wraps it in `try/catch`: a single malformed post logs an error and renders a "could not
17
+ be displayed" fallback instead of failing the whole SSG build.
18
+ - `MdxErrorBoundary` — new exported client error boundary (`src/next/mdx-error-boundary.tsx`).
19
+ Available for consumers who want an additional client-side defence layer.
20
+ - Write-time sanitize: `upsertBySlug` in `src/service` now runs `sanitizeMdx` before persisting,
21
+ so newly-saved posts are normalized at ingestion.
22
+ - **Consumer note:** `^0.2.0` does not auto-resolve to `0.3.0` (pre-1.0 caret pins the minor).
23
+ Bump your dependency range to `^0.3.0` to receive this fix.
24
+
25
+ ## 0.2.0 — 2026-06-04
26
+
27
+ **SQLite driver unification (breaking internal change; API unchanged)**
28
+
29
+ Unified on native `better-sqlite3` (dropping `sql.js` fallback). Fixes the dual-engine 401
30
+ auth bug where a fresh, uncheckpointed WAL key was invisible to the sql.js reader. Consumers
31
+ who were using the `sql.js` adapter via `sqlite_driver` env-var are unaffected if they set
32
+ `sqlite_driver=better-sqlite3` (now the only valid option). Config option removed.
33
+
6
34
  ## 0.1.0 — 2026-06-01
7
35
 
8
36
  Initial release. SEO-first blogging package extracted from gotimer.
package/README.md CHANGED
@@ -47,6 +47,10 @@ export const blogConfig: BlogConfig = {
47
47
  // getAuthor: (id) => lookupAuthor(id), // per-post authors
48
48
  // onAnalyticsEvent: (name, params) => gtag("event", name, params),
49
49
  revalidateSeconds: 3600,
50
+ // Admin URL overrides (omit to use defaults below):
51
+ // adminBasePath: "/admin/blog", // where admin UI pages are mounted
52
+ // adminApiBasePath: "/api/admin/blog", // where admin API routes are mounted
53
+ // searchApiPath: "/api/blog/search", // where search API route is mounted
50
54
  };
51
55
  ```
52
56
 
@@ -133,13 +137,52 @@ export default async function sitemap() {
133
137
  }
134
138
  ```
135
139
 
136
- ## 6. Admin authoring form
140
+ ## 6. Admin pages
141
+
142
+ ### Option A — Sealed admin pages (recommended)
143
+
144
+ Drop in the three sealed admin page factories. They render a full admin UI
145
+ (post list with status/publish-date badges + edit links, create form, edit form)
146
+ and respect your `authorize(req)` gate via the API routes.
147
+
148
+ ```tsx
149
+ // app/admin/blog/page.tsx — post list
150
+ import { createBlogAdminListPage } from "hazo_blog/next";
151
+ import { blogConfig } from "@/lib/blog-config";
152
+ const page = createBlogAdminListPage(blogConfig);
153
+ export default page.default;
154
+ export const dynamic = "force-dynamic"; // must be a static literal
155
+
156
+ // app/admin/blog/new/page.tsx — create form
157
+ import { createBlogAdminNewPage } from "hazo_blog/next";
158
+ const page = createBlogAdminNewPage(blogConfig);
159
+ export default page.default;
160
+ export const dynamic = "force-dynamic";
161
+
162
+ // app/admin/blog/[slug]/edit/page.tsx — edit form
163
+ import { createBlogAdminEditPage } from "hazo_blog/next";
164
+ const page = createBlogAdminEditPage(blogConfig);
165
+ export default page.default;
166
+ export const dynamic = "force-dynamic";
167
+ ```
168
+
169
+ If your admin API or UI routes live at non-default paths, override in `BlogConfig`:
170
+
171
+ ```ts
172
+ adminBasePath: "/admin/blog", // default — where factory pages link to
173
+ adminApiBasePath: "/api/admin/blog", // default — where PostForm POSTs to
174
+ ```
175
+
176
+ ### Option B — Custom admin form
177
+
178
+ Use `PostForm` directly for a fully custom admin UI:
137
179
 
138
180
  ```tsx
139
181
  "use client";
140
182
  import { PostForm } from "hazo_blog/client";
141
183
  // pass categories + an endpoint; wire onImageUpload to hazo_files for paste-upload
142
184
  <PostForm categories={categories} endpoint="/api/admin/blog" onSaved={...} />
185
+ // For edit: <PostForm post={existingPost} categories={categories} ... />
143
186
  ```
144
187
 
145
188
  ## Required Next.js config
@@ -161,16 +204,38 @@ const nextConfig = {
161
204
  the package's classes are compiled. The admin editor's styles load automatically
162
205
  via `hazo_ui`'s `MarkdownEditor`.
163
206
 
207
+ ### Build resilience
208
+
209
+ `BlogContent` (≥ 0.3.0) is an async server component that catches MDX compilation errors with
210
+ `try/catch`, so a single malformed post renders a graceful fallback instead of failing the whole
211
+ `next build`. It also pre-sanitizes content with `sanitizeMdx` (converts HTML comments → MDX
212
+ comments, escapes stray `<!`) before passing it to the compiler. The sanitizer is also available
213
+ as a standalone export from `hazo_blog/lib` for custom rendering pipelines.
214
+
164
215
  ## Exports
165
216
 
166
217
  | Entry | Contents |
167
218
  |---|---|
168
219
  | `hazo_blog` | server: `createBlogService`, `createBlogRepository`, SEO builders, **route-handler factories** (React-free), types |
169
220
  | `hazo_blog/client` | client components: `PostCard`, `PostHero`, `AuthorBio`, `FaqSection`, `TableOfContents`, `RelatedPosts`, `BlogSearch`, `PostForm`, MDX components, `trackBlogEvent` |
170
- | `hazo_blog/next` | sealed page factories (`createBlogIndexPage/PostPage/TagPage`) + `BlogContent` (React) |
221
+ | `hazo_blog/next` | sealed page factories: public (`createBlogIndexPage`, `createBlogPostPage`, `createBlogTagPage`) + admin (`createBlogAdminListPage`, `createBlogAdminNewPage`, `createBlogAdminEditPage`) + `BlogContent` + `MdxErrorBoundary` |
222
+ | `hazo_blog/lib` | pure text utilities: `sanitizeMdx`, `mdxToPlainText`, `buildExcerpt`, `slugify`, `calculateReadingTime`, `extractToc` |
171
223
  | `hazo_blog/seo` | `buildBlogPostingJsonLd`, `buildBreadcrumbJsonLd`, `buildFaqJsonLd`, `getBlogSitemapEntries`, `getBlogRobotsRules`, `getBlogRssXml` |
172
224
  | `hazo_blog/config` | `BlogConfig` types + `resolveConfig` |
173
225
 
226
+ ### Search
227
+
228
+ `BlogSearch` (rendered inside `createBlogIndexPage`) supports:
229
+ - **Dropdown**: appears after 2+ characters with a 250 ms debounce
230
+ - **Enter key**: navigates to `${basePath}?q={term}` — the index page renders a filtered grid with a result count and Clear link
231
+ - **Escape**: dismisses the dropdown
232
+
233
+ If your search API lives at a non-default path, set `searchApiPath` in `BlogConfig`:
234
+
235
+ ```ts
236
+ searchApiPath: "/api/blog/search", // default
237
+ ```
238
+
174
239
  See `SETUP_CHECKLIST.md` for a step-by-step integration list. A runnable demo
175
240
  lives in `test-app/`.
176
241
 
@@ -25,6 +25,7 @@ for full code samples.
25
25
  - [ ] (multi-tenant only) set `resolveScope(req)`
26
26
  - [ ] (per-post authors) set `getAuthor(id)`
27
27
  - [ ] (analytics) set `onAnalyticsEvent` or rely on the `window.gtag` fallback
28
+ - [ ] (non-default paths) set `adminBasePath`, `adminApiBasePath`, `searchApiPath` if your routes differ from defaults (`/admin/blog`, `/api/admin/blog`, `/api/blog/search`)
28
29
 
29
30
  ## 4. Routes
30
31
 
@@ -37,6 +38,13 @@ for full code samples.
37
38
  - [ ] (optional) `app/api/blog/manage/route.ts` → `createBlogManageRoutes`
38
39
  - [ ] Merge `getBlogSitemapEntries()` into root `app/sitemap.ts` and `getBlogRobotsRules()` into `app/robots.ts`
39
40
 
41
+ ### Admin UI pages (sealed, recommended)
42
+
43
+ - [ ] `app/admin/blog/page.tsx` → `createBlogAdminListPage` + `export const dynamic = "force-dynamic"`
44
+ - [ ] `app/admin/blog/new/page.tsx` → `createBlogAdminNewPage` + `export const dynamic = "force-dynamic"`
45
+ - [ ] `app/admin/blog/[slug]/edit/page.tsx` → `createBlogAdminEditPage` + `export const dynamic = "force-dynamic"`
46
+ - [ ] **Note**: `dynamic` must be a static string literal — cannot re-export from the factory object
47
+
40
48
  ## 5. Next.js config (required)
41
49
 
42
50
  - [ ] `transpilePackages: ["hazo_blog", "hazo_ui", "next-mdx-remote"]` ← transpiling `next-mdx-remote` prevents the "React Element from an older version" MDX prerender error
@@ -50,5 +58,7 @@ for full code samples.
50
58
  - [ ] View-source a post: BlogPosting + BreadcrumbList JSON-LD present (FAQPage when the post has FAQ)
51
59
  - [ ] Drafts/future-scheduled posts return 404 and are absent from sitemap/RSS
52
60
  - [ ] `/api/blog/feed` returns valid RSS; `/api/blog/search?q=...` returns matches
53
- - [ ] Admin form (`PostForm`) saves a draft and publishes; published post appears at its URL
61
+ - [ ] Admin list page shows status badges (green=published, grey=draft) and publish dates
62
+ - [ ] Admin edit page pre-populates all fields; Save draft / Publish both work
63
+ - [ ] Search: typing 2+ chars shows dropdown; Enter navigates to `?q=` filtered index
54
64
  - [ ] Paste a post's HTML into Google's Rich Results Test — no errors
@@ -1 +1 @@
1
- {"version":3,"file":"blog-search.d.ts","sourceRoot":"","sources":["../../src/components/blog-search.tsx"],"names":[],"mappings":"AAaA,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;CACpB;AAED,wBAAgB,UAAU,CAAC,EACzB,QAAQ,EACR,QAAkB,EAClB,WAA6B,EAC7B,SAAS,GACV,EAAE,eAAe,2CA+DjB"}
1
+ {"version":3,"file":"blog-search.d.ts","sourceRoot":"","sources":["../../src/components/blog-search.tsx"],"names":[],"mappings":"AAcA,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;CACpB;AAED,wBAAgB,UAAU,CAAC,EACzB,QAAQ,EACR,QAAkB,EAClB,WAA6B,EAC7B,SAAS,GACV,EAAE,eAAe,2CAqFjB"}
@@ -1,6 +1,7 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { useEffect, useRef, useState } from "react";
4
+ import { useRouter } from "next/navigation";
4
5
  import Link from "next/link";
5
6
  import { cn } from "hazo_ui/utils";
6
7
  import { trackBlogEvent } from "../lib/analytics.js";
@@ -10,6 +11,7 @@ export function BlogSearch({ endpoint, basePath = "/blog", placeholder = "Search
10
11
  const [results, setResults] = useState([]);
11
12
  const [open, setOpen] = useState(false);
12
13
  const timer = useRef(null);
14
+ const router = useRouter();
13
15
  useEffect(() => {
14
16
  if (timer.current)
15
17
  clearTimeout(timer.current);
@@ -39,5 +41,23 @@ export function BlogSearch({ endpoint, basePath = "/blog", placeholder = "Search
39
41
  clearTimeout(timer.current);
40
42
  };
41
43
  }, [query, url]);
42
- return (_jsxs("div", { className: cn("relative", className), children: [_jsx("input", { type: "search", value: query, onChange: (e) => setQuery(e.target.value), placeholder: placeholder, className: "w-full rounded-lg border border-border bg-background px-4 py-2 text-sm outline-none focus:ring-2 focus:ring-primary" }), open && results.length > 0 && (_jsx("ul", { className: "absolute z-10 mt-1 max-h-80 w-full overflow-auto rounded-lg border border-border bg-popover shadow-lg", children: results.map((r) => (_jsx("li", { children: _jsxs(Link, { href: `${basePath}/${r.slug}`, className: "block px-4 py-2 text-sm hover:bg-accent", onClick: () => setOpen(false), children: [_jsx("span", { className: "font-medium", children: r.title }), r.excerpt && (_jsx("span", { className: "block truncate text-xs text-muted-foreground", children: r.excerpt }))] }) }, r.slug))) }))] }));
44
+ function handleKeyDown(e) {
45
+ if (e.key === "Enter") {
46
+ const q = query.trim();
47
+ if (q.length >= 2) {
48
+ if (timer.current)
49
+ clearTimeout(timer.current);
50
+ setOpen(false);
51
+ setResults([]);
52
+ router.push(`${basePath}?q=${encodeURIComponent(q)}`);
53
+ }
54
+ }
55
+ if (e.key === "Escape") {
56
+ if (timer.current)
57
+ clearTimeout(timer.current);
58
+ setOpen(false);
59
+ setResults([]);
60
+ }
61
+ }
62
+ return (_jsxs("div", { className: cn("relative", className), children: [_jsx("input", { type: "search", value: query, onChange: (e) => setQuery(e.target.value), onKeyDown: handleKeyDown, placeholder: placeholder, className: "w-full rounded-lg border border-border bg-background px-4 py-2 text-sm outline-none focus:ring-2 focus:ring-primary" }), open && results.length > 0 && (_jsx("ul", { className: "absolute z-50 mt-1 max-h-80 w-full overflow-auto rounded-lg border border-zinc-200 shadow-xl", style: { backgroundColor: "white" }, children: results.map((r) => (_jsx("li", { className: "border-b border-zinc-100 last:border-0", children: _jsxs(Link, { href: `${basePath}/${r.slug}`, className: "block px-4 py-3 text-sm hover:bg-zinc-50", onClick: () => setOpen(false), children: [_jsx("span", { className: "font-medium text-zinc-900", children: r.title }), r.excerpt && (_jsx("span", { className: "block truncate text-xs text-zinc-500", children: r.excerpt }))] }) }, r.slug))) }))] }));
43
63
  }
@@ -1 +1 @@
1
- {"version":3,"file":"post-card.d.ts","sourceRoot":"","sources":["../../src/components/post-card.tsx"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,mBAAmB,CAAC;AAE/D,MAAM,WAAW,aAAa;IAC5B,IAAI,EAAE,qBAAqB,CAAC;IAC5B,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED,wBAAgB,QAAQ,CAAC,EAAE,IAAI,EAAE,QAAkB,EAAE,SAAS,EAAE,EAAE,aAAa,2CA2C9E"}
1
+ {"version":3,"file":"post-card.d.ts","sourceRoot":"","sources":["../../src/components/post-card.tsx"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,mBAAmB,CAAC;AAE/D,MAAM,WAAW,aAAa;IAC5B,IAAI,EAAE,qBAAqB,CAAC;IAC5B,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED,wBAAgB,QAAQ,CAAC,EAAE,IAAI,EAAE,QAAkB,EAAE,SAAS,EAAE,EAAE,aAAa,2CAkD9E"}
@@ -6,5 +6,5 @@ export function PostCard({ post, basePath = "/blog", className }) {
6
6
  return (_jsx("article", { className: cn("group overflow-hidden rounded-xl border border-border bg-card transition-shadow hover:shadow-md", className), children: _jsxs(Link, { href: `${basePath}/${post.slug}`, className: "block", children: [post.featured_image && (_jsx("div", { className: "relative aspect-video w-full overflow-hidden", children: _jsx(Image, { src: post.featured_image, alt: post.title, fill: true, className: "object-cover transition-transform duration-300 group-hover:scale-105", sizes: "(max-width: 768px) 100vw, 33vw" }) })), _jsxs("div", { className: "p-5", children: [post.category && (_jsx("span", { className: "mb-2 inline-block rounded-full px-2.5 py-0.5 text-xs font-medium", style: {
7
7
  backgroundColor: `${post.category.colour}20`,
8
8
  color: post.category.colour,
9
- }, children: post.category.name })), _jsx("h3", { className: "mb-1 line-clamp-2 text-lg font-semibold text-foreground group-hover:text-primary", children: post.title }), post.excerpt && (_jsx("p", { className: "line-clamp-2 text-sm text-muted-foreground", children: post.excerpt })), _jsxs("p", { className: "mt-3 text-xs text-muted-foreground", children: [post.reading_time, " min read"] })] })] }) }));
9
+ }, children: post.category.name })), _jsx("h3", { className: "mb-1 line-clamp-2 text-lg font-semibold text-foreground group-hover:text-primary", children: post.title }), post.excerpt && (_jsx("p", { className: "line-clamp-2 text-sm text-muted-foreground", children: post.excerpt })), _jsxs("p", { className: "mt-3 text-xs text-muted-foreground", children: [post.reading_time, " min read", post.publish_date && (_jsxs("span", { className: "ml-2 text-muted-foreground/60", children: ["\u00B7 ", new Date(post.publish_date).toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" })] }))] })] })] }) }));
10
10
  }
@@ -22,4 +22,14 @@ export interface TocHeading {
22
22
  id: string;
23
23
  }
24
24
  export declare function extractToc(mdx: string): TocHeading[];
25
+ /**
26
+ * Make raw MDX safe to compile. MDX has no HTML comments — a `<!--` in prose
27
+ * makes the parser expect a JSX tag name after `<` and throw "Unexpected
28
+ * character `!` before name", crashing the whole Next build at prerender.
29
+ * Convert HTML comments to MDX comments ({/*…*\/}, equally non-rendering) and
30
+ * escape any stray `<!`, while leaving fenced/inline code untouched (it is
31
+ * literal in MDX, never errors, and may legitimately show an HTML comment in a
32
+ * sample). Idempotent: after one pass no literal `<!` survives in prose.
33
+ */
34
+ export declare function sanitizeMdx(mdx: string): string;
25
35
  //# sourceMappingURL=text.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"text.d.ts","sourceRoot":"","sources":["../../src/lib/text.ts"],"names":[],"mappings":"AAGA,6CAA6C;AAC7C,wBAAgB,OAAO,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAS7C;AAED;;;;GAIG;AACH,wBAAgB,cAAc,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAalD;AAED,0CAA0C;AAC1C,wBAAgB,UAAU,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAI/C;AAED;;;GAGG;AACH,wBAAgB,oBAAoB,CAAC,GAAG,EAAE,MAAM,EAAE,cAAc,SAAM,GAAG,MAAM,CAG9E;AAED,qFAAqF;AACrF,wBAAgB,YAAY,CAAC,GAAG,EAAE,MAAM,EAAE,QAAQ,SAAM,GAAG,MAAM,CAMhE;AAED,qFAAqF;AACrF,MAAM,WAAW,UAAU;IACzB,KAAK,EAAE,CAAC,GAAG,CAAC,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,EAAE,EAAE,MAAM,CAAC;CACZ;AAED,wBAAgB,UAAU,CAAC,GAAG,EAAE,MAAM,GAAG,UAAU,EAAE,CAYpD"}
1
+ {"version":3,"file":"text.d.ts","sourceRoot":"","sources":["../../src/lib/text.ts"],"names":[],"mappings":"AAGA,6CAA6C;AAC7C,wBAAgB,OAAO,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAS7C;AAED;;;;GAIG;AACH,wBAAgB,cAAc,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAalD;AAED,0CAA0C;AAC1C,wBAAgB,UAAU,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAI/C;AAED;;;GAGG;AACH,wBAAgB,oBAAoB,CAAC,GAAG,EAAE,MAAM,EAAE,cAAc,SAAM,GAAG,MAAM,CAG9E;AAED,qFAAqF;AACrF,wBAAgB,YAAY,CAAC,GAAG,EAAE,MAAM,EAAE,QAAQ,SAAM,GAAG,MAAM,CAMhE;AAED,qFAAqF;AACrF,MAAM,WAAW,UAAU;IACzB,KAAK,EAAE,CAAC,GAAG,CAAC,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,EAAE,EAAE,MAAM,CAAC;CACZ;AAED,wBAAgB,UAAU,CAAC,GAAG,EAAE,MAAM,GAAG,UAAU,EAAE,CAYpD;AAED;;;;;;;;GAQG;AACH,wBAAgB,WAAW,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAkB/C"}
package/dist/lib/text.js CHANGED
@@ -67,3 +67,31 @@ export function extractToc(mdx) {
67
67
  }
68
68
  return headings;
69
69
  }
70
+ /**
71
+ * Make raw MDX safe to compile. MDX has no HTML comments — a `<!--` in prose
72
+ * makes the parser expect a JSX tag name after `<` and throw "Unexpected
73
+ * character `!` before name", crashing the whole Next build at prerender.
74
+ * Convert HTML comments to MDX comments ({/*…*\/}, equally non-rendering) and
75
+ * escape any stray `<!`, while leaving fenced/inline code untouched (it is
76
+ * literal in MDX, never errors, and may legitimately show an HTML comment in a
77
+ * sample). Idempotent: after one pass no literal `<!` survives in prose.
78
+ */
79
+ export function sanitizeMdx(mdx) {
80
+ const stash = [];
81
+ // U+E000 (Private Use Area) sentinel — cannot occur in markdown, so the
82
+ // restore step never collides with real text like "90 seconds".
83
+ const keep = (m) => {
84
+ stash.push(m);
85
+ return `${stash.length - 1}`;
86
+ };
87
+ // Mask fenced code blocks (multiline) first, then inline code spans.
88
+ let masked = mdx
89
+ .replace(/```[\s\S]*?```/g, keep)
90
+ .replace(/~~~[\s\S]*?~~~/g, keep)
91
+ .replace(/`[^`\n]*`/g, keep);
92
+ masked = masked
93
+ // Comment body: neutralize any `*/` so it can't close the MDX comment early.
94
+ .replace(/<!--([\s\S]*?)-->/g, (_, body) => `{/*${body.replace(/\*\//g, "* /")}*/}`)
95
+ .replace(/<!(?!--)/g, "&lt;!");
96
+ return masked.replace(/(\d+)/g, (_, i) => stash[Number(i)]);
97
+ }
@@ -0,0 +1,9 @@
1
+ import type { BlogCategory, BlogPostWithRelations } from "../types/index.js";
2
+ /** Client wrapper used by sealed admin page factories to handle post-save routing. */
3
+ export declare function AdminFormClient({ post, categories, endpoint, redirectTo, }: {
4
+ post?: BlogPostWithRelations | null;
5
+ categories: BlogCategory[];
6
+ endpoint: string;
7
+ redirectTo: string;
8
+ }): import("react/jsx-runtime").JSX.Element;
9
+ //# sourceMappingURL=admin-client.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"admin-client.d.ts","sourceRoot":"","sources":["../../src/next/admin-client.tsx"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,YAAY,EAAE,qBAAqB,EAAE,MAAM,mBAAmB,CAAC;AAE7E,sFAAsF;AACtF,wBAAgB,eAAe,CAAC,EAC9B,IAAI,EACJ,UAAU,EACV,QAAQ,EACR,UAAU,GACX,EAAE;IACD,IAAI,CAAC,EAAE,qBAAqB,GAAG,IAAI,CAAC;IACpC,UAAU,EAAE,YAAY,EAAE,CAAC;IAC3B,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,EAAE,MAAM,CAAC;CACpB,2CAUA"}
@@ -0,0 +1,9 @@
1
+ "use client";
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import { useRouter } from "next/navigation";
4
+ import { PostForm } from "../components/admin/post-form.js";
5
+ /** Client wrapper used by sealed admin page factories to handle post-save routing. */
6
+ export function AdminFormClient({ post, categories, endpoint, redirectTo, }) {
7
+ const router = useRouter();
8
+ return (_jsx(PostForm, { post: post ?? undefined, categories: categories, endpoint: endpoint, onSaved: () => router.push(redirectTo) }));
9
+ }
@@ -3,5 +3,5 @@ export interface BlogContentProps {
3
3
  source: string;
4
4
  config: ResolvedBlogConfig;
5
5
  }
6
- export declare function BlogContent({ source, config }: BlogContentProps): import("react/jsx-runtime").JSX.Element;
6
+ export declare function BlogContent({ source, config }: BlogContentProps): Promise<import("react/jsx-runtime").JSX.Element>;
7
7
  //# sourceMappingURL=blog-content.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"blog-content.d.ts","sourceRoot":"","sources":["../../src/next/blog-content.tsx"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,mBAAmB,CAAC;AAE5D,MAAM,WAAW,gBAAgB;IAC/B,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,kBAAkB,CAAC;CAC5B;AAED,wBAAgB,WAAW,CAAC,EAAE,MAAM,EAAE,MAAM,EAAE,EAAE,gBAAgB,2CAW/D"}
1
+ {"version":3,"file":"blog-content.d.ts","sourceRoot":"","sources":["../../src/next/blog-content.tsx"],"names":[],"mappings":"AAQA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,mBAAmB,CAAC;AAS5D,MAAM,WAAW,gBAAgB;IAC/B,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,kBAAkB,CAAC;CAC5B;AAED,wBAAsB,WAAW,CAAC,EAAE,MAAM,EAAE,MAAM,EAAE,EAAE,gBAAgB,oDAiBrE"}
@@ -1,10 +1,29 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  // Server component that renders raw MDX to HTML at build/ISR time via
3
3
  // next-mdx-remote/rsc, merging the default embed registry with host overrides.
4
+ // Calls MDXRemote as an async function so compilation errors are caught server-
5
+ // side with try/catch before they can fail the SSG build for the whole page.
4
6
  import { MDXRemote } from "next-mdx-remote/rsc";
5
7
  import remarkGfm from "remark-gfm";
6
8
  import { defaultMdxComponents } from "../components/mdx/index.js";
7
- export function BlogContent({ source, config }) {
9
+ import { sanitizeMdx } from "../lib/text.js";
10
+ // MDXRemote is an async RSC but TypeScript surfaces it as ComponentType via
11
+ // React 18 JSX types. Cast to the actual async-function signature so we can
12
+ // call it with await + try/catch to prevent one broken post from failing the
13
+ // entire SSG build.
14
+ const renderMDX = MDXRemote;
15
+ export async function BlogContent({ source, config }) {
8
16
  const components = { ...defaultMdxComponents, ...(config.mdxComponents ?? {}) };
9
- return (_jsx("div", { className: "prose prose-zinc max-w-none dark:prose-invert", children: _jsx(MDXRemote, { source: source, components: components, options: { mdxOptions: { remarkPlugins: [remarkGfm] } } }) }));
17
+ try {
18
+ const content = await renderMDX({
19
+ source: sanitizeMdx(source),
20
+ components,
21
+ options: { mdxOptions: { remarkPlugins: [remarkGfm] } },
22
+ });
23
+ return _jsx("div", { className: "prose prose-zinc max-w-none dark:prose-invert", children: content });
24
+ }
25
+ catch (error) {
26
+ console.error("[hazo_blog] MDX compile failed:", error);
27
+ return (_jsx("div", { className: "prose prose-zinc max-w-none dark:prose-invert", children: _jsx("p", { className: "text-muted-foreground", children: "This content could not be displayed." }) }));
28
+ }
10
29
  }
@@ -1,5 +1,6 @@
1
- export { createBlogIndexPage, createBlogPostPage, createBlogTagPage, } from "./pages.js";
1
+ export { createBlogIndexPage, createBlogPostPage, createBlogTagPage, createBlogAdminListPage, createBlogAdminNewPage, createBlogAdminEditPage, } from "./pages.js";
2
2
  export { BlogContent, type BlogContentProps } from "./blog-content.js";
3
+ export { MdxErrorBoundary } from "./mdx-error-boundary.js";
3
4
  export { resolveConfig } from "../service/index.js";
4
5
  export type { BlogConfig, ResolvedBlogConfig } from "../types/index.js";
5
6
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/next/index.ts"],"names":[],"mappings":"AASA,OAAO,EACL,mBAAmB,EACnB,kBAAkB,EAClB,iBAAiB,GAClB,MAAM,YAAY,CAAC;AAEpB,OAAO,EAAE,WAAW,EAAE,KAAK,gBAAgB,EAAE,MAAM,mBAAmB,CAAC;AAOvE,OAAO,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AACpD,YAAY,EAAE,UAAU,EAAE,kBAAkB,EAAE,MAAM,mBAAmB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/next/index.ts"],"names":[],"mappings":"AAUA,OAAO,EACL,mBAAmB,EACnB,kBAAkB,EAClB,iBAAiB,EACjB,uBAAuB,EACvB,sBAAsB,EACtB,uBAAuB,GACxB,MAAM,YAAY,CAAC;AAEpB,OAAO,EAAE,WAAW,EAAE,KAAK,gBAAgB,EAAE,MAAM,mBAAmB,CAAC;AACvE,OAAO,EAAE,gBAAgB,EAAE,MAAM,yBAAyB,CAAC;AAO3D,OAAO,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AACpD,YAAY,EAAE,UAAU,EAAE,kBAAkB,EAAE,MAAM,mBAAmB,CAAC"}
@@ -6,8 +6,10 @@
6
6
  // createBlogManageRoutes, createBlogAdminRoutes, createBlogSearchRoute,
7
7
  // createBlogFeedRoute, createBlogSitemapRoute
8
8
  // Server MDX renderer: BlogContent
9
- export { createBlogIndexPage, createBlogPostPage, createBlogTagPage, } from "./pages.js";
9
+ // Error boundary: MdxErrorBoundary
10
+ export { createBlogIndexPage, createBlogPostPage, createBlogTagPage, createBlogAdminListPage, createBlogAdminNewPage, createBlogAdminEditPage, } from "./pages.js";
10
11
  export { BlogContent } from "./blog-content.js";
12
+ export { MdxErrorBoundary } from "./mdx-error-boundary.js";
11
13
  // NOTE: route-handler factories (createBlogAdminRoutes, createBlogManageRoutes,
12
14
  // createBlogSearchRoute, createBlogFeedRoute, createBlogSitemapRoute) are exported
13
15
  // from the main `hazo_blog` entry — they are React-free, so API routes import them
@@ -0,0 +1,16 @@
1
+ import { Component, type ReactNode } from "react";
2
+ interface Props {
3
+ children: ReactNode;
4
+ fallback: ReactNode;
5
+ }
6
+ interface State {
7
+ hasError: boolean;
8
+ }
9
+ export declare class MdxErrorBoundary extends Component<Props, State> {
10
+ state: State;
11
+ static getDerivedStateFromError(): State;
12
+ componentDidCatch(error: unknown): void;
13
+ render(): ReactNode;
14
+ }
15
+ export {};
16
+ //# sourceMappingURL=mdx-error-boundary.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"mdx-error-boundary.d.ts","sourceRoot":"","sources":["../../src/next/mdx-error-boundary.tsx"],"names":[],"mappings":"AACA,OAAO,EAAE,SAAS,EAAE,KAAK,SAAS,EAAE,MAAM,OAAO,CAAC;AAElD,UAAU,KAAK;IAAG,QAAQ,EAAE,SAAS,CAAC;IAAC,QAAQ,EAAE,SAAS,CAAC;CAAE;AAC7D,UAAU,KAAK;IAAG,QAAQ,EAAE,OAAO,CAAC;CAAE;AAEtC,qBAAa,gBAAiB,SAAQ,SAAS,CAAC,KAAK,EAAE,KAAK,CAAC;IAC3D,KAAK,EAAE,KAAK,CAAuB;IACnC,MAAM,CAAC,wBAAwB,IAAI,KAAK;IACxC,iBAAiB,CAAC,KAAK,EAAE,OAAO;IAGhC,MAAM;CAGP"}
@@ -0,0 +1,15 @@
1
+ "use client";
2
+ import { Component } from "react";
3
+ export class MdxErrorBoundary extends Component {
4
+ constructor() {
5
+ super(...arguments);
6
+ this.state = { hasError: false };
7
+ }
8
+ static getDerivedStateFromError() { return { hasError: true }; }
9
+ componentDidCatch(error) {
10
+ console.error("[hazo_blog] MDX render failed:", error);
11
+ }
12
+ render() {
13
+ return this.state.hasError ? this.props.fallback : this.props.children;
14
+ }
15
+ }
@@ -48,5 +48,23 @@ export declare function createBlogTagPage(rawConfig: BlogConfig): {
48
48
  }) => Promise<Metadata>;
49
49
  revalidate: number;
50
50
  };
51
+ /** Sealed admin list page: mount at `${adminBasePath}` (default `/admin/blog`). */
52
+ export declare function createBlogAdminListPage(rawConfig: BlogConfig): {
53
+ default: () => Promise<import("react/jsx-runtime").JSX.Element>;
54
+ };
55
+ /** Sealed admin new-post page: mount at `${adminBasePath}/new`. */
56
+ export declare function createBlogAdminNewPage(rawConfig: BlogConfig): {
57
+ default: () => Promise<import("react/jsx-runtime").JSX.Element>;
58
+ };
59
+ /** Sealed admin edit page: mount at `${adminBasePath}/[slug]/edit`. */
60
+ export declare function createBlogAdminEditPage(rawConfig: BlogConfig): {
61
+ default: ({ params, }: {
62
+ params: Promise<{
63
+ slug: string;
64
+ }> | {
65
+ slug: string;
66
+ };
67
+ }) => Promise<import("react/jsx-runtime").JSX.Element>;
68
+ };
51
69
  export {};
52
70
  //# sourceMappingURL=pages.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"pages.d.ts","sourceRoot":"","sources":["../../src/next/pages.tsx"],"names":[],"mappings":"AA+BA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAC;AAGpD,KAAK,QAAQ,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;AAexC,qCAAqC;AACrC,wBAAgB,kBAAkB,CAAC,SAAS,EAAE,UAAU;2BAuCnD;QACD,MAAM,EAAE,OAAO,CAAC;YAAE,IAAI,EAAE,MAAM,CAAA;SAAE,CAAC,GAAG;YAAE,IAAI,EAAE,MAAM,CAAA;SAAE,CAAC;KACtD;oCA9BE;QACD,MAAM,EAAE,OAAO,CAAC;YAAE,IAAI,EAAE,MAAM,CAAA;SAAE,CAAC,GAAG;YAAE,IAAI,EAAE,MAAM,CAAA;SAAE,CAAC;KACtD,KAAG,OAAO,CAAC,QAAQ,CAAC;;;;;;EA8DtB;AAED,+BAA+B;AAC/B,wBAAgB,mBAAmB,CAAC,SAAS,EAAE,UAAU;iCAcpD;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;;EA8DrD;AAED,uCAAuC;AACvC,wBAAgB,iBAAiB,CAAC,SAAS,EAAE,UAAU;2BAMlD;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"}
1
+ {"version":3,"file":"pages.d.ts","sourceRoot":"","sources":["../../src/next/pages.tsx"],"names":[],"mappings":"AAgCA,OAAO,KAAK,EAAE,UAAU,EAAyB,MAAM,mBAAmB,CAAC;AAG3E,KAAK,QAAQ,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;AAexC,qCAAqC;AACrC,wBAAgB,kBAAkB,CAAC,SAAS,EAAE,UAAU;2BAuCnD;QACD,MAAM,EAAE,OAAO,CAAC;YAAE,IAAI,EAAE,MAAM,CAAA;SAAE,CAAC,GAAG;YAAE,IAAI,EAAE,MAAM,CAAA;SAAE,CAAC;KACtD;oCA9BE;QACD,MAAM,EAAE,OAAO,CAAC;YAAE,IAAI,EAAE,MAAM,CAAA;SAAE,CAAC,GAAG;YAAE,IAAI,EAAE,MAAM,CAAA;SAAE,CAAC;KACtD,KAAG,OAAO,CAAC,QAAQ,CAAC;;;;;;EA8DtB;AAED,+BAA+B;AAC/B,wBAAgB,mBAAmB,CAAC,SAAS,EAAE,UAAU;iCAcpD;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;;EAuFrD;AAED,uCAAuC;AACvC,wBAAgB,iBAAiB,CAAC,SAAS,EAAE,UAAU;2BAMlD;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;AAOD,mFAAmF;AACnF,wBAAgB,uBAAuB,CAAC,SAAS,EAAE,UAAU;;EAuE5D;AAED,mEAAmE;AACnE,wBAAgB,sBAAsB,CAAC,SAAS,EAAE,UAAU;;EAoB3D;AAED,uEAAuE;AACvE,wBAAgB,uBAAuB,CAAC,SAAS,EAAE,UAAU;2BAMxD;QACD,MAAM,EAAE,OAAO,CAAC;YAAE,IAAI,EAAE,MAAM,CAAA;SAAE,CAAC,GAAG;YAAE,IAAI,EAAE,MAAM,CAAA;SAAE,CAAC;KACtD;EAsBF"}
@@ -10,8 +10,9 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
10
10
  // export const { default, generateMetadata, generateStaticParams,
11
11
  // revalidate, dynamicParams } = createBlogPostPage(blogConfig);
12
12
  import { notFound } from "next/navigation";
13
- import { createBlogService } from "../service/index.js";
14
- import { resolveConfig } from "../service/index.js";
13
+ import Link from "next/link";
14
+ import { createBlogService, resolveConfig } from "../service/index.js";
15
+ import { AdminFormClient } from "./admin-client.js";
15
16
  import { buildBlogPostingJsonLd, buildBreadcrumbJsonLd, buildFaqJsonLd, canonicalFor, ogImageFor, } from "../seo/index.js";
16
17
  import { extractToc } from "../lib/text.js";
17
18
  import { PostHero } from "../components/post-hero.js";
@@ -101,9 +102,19 @@ export function createBlogIndexPage(rawConfig) {
101
102
  ? await searchParams
102
103
  : searchParams
103
104
  : {};
105
+ const q = (sp.q ?? "").trim();
104
106
  const page = Number(sp.page ?? "1") || 1;
105
- const { posts, totalPages } = await service.listPosts({ page, perPage: 12 });
106
- 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: [_jsx("h1", { className: "text-3xl font-bold text-foreground", children: "Blog" }), _jsx(BlogSearch, { basePath: config.basePath, className: "sm:w-72" })] }), posts.length === 0 ? (_jsx("p", { className: "text-muted-foreground", children: "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: config.basePath }, p.id))) })), 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: `${config.basePath}?page=${n}`, className: n === page
107
+ let displayPosts;
108
+ let totalPages = 1;
109
+ if (q) {
110
+ displayPosts = await service.search(q, 50);
111
+ }
112
+ else {
113
+ const paged = await service.listPosts({ page, perPage: 12 });
114
+ displayPosts = paged.posts;
115
+ totalPages = paged.totalPages;
116
+ }
117
+ 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: "Blog" }), q && (_jsxs("p", { className: "mt-1 text-sm text-muted-foreground", children: [displayPosts.length, " result", displayPosts.length !== 1 ? "s" : "", " for \u201C", q, "\u201D \u00A0\u00B7\u00A0", _jsx("a", { href: config.basePath, className: "text-primary hover:underline", children: "Clear" })] }))] }), _jsx(BlogSearch, { basePath: config.basePath, endpoint: config.searchApiPath ?? `${config.basePath}/api/search`, className: "sm:w-72" })] }), displayPosts.length === 0 ? (_jsx("p", { className: "text-muted-foreground", children: q ? `No posts matching "${q}".` : "No posts yet." })) : (_jsx("div", { className: "grid gap-6 sm:grid-cols-2 lg:grid-cols-3", children: displayPosts.map((p) => (_jsx(PostCard, { post: p, basePath: config.basePath }, p.id))) })), !q && 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: `${config.basePath}?page=${n}`, className: n === page
107
118
  ? "rounded bg-primary px-3 py-1 text-primary-foreground"
108
119
  : "rounded border border-border px-3 py-1 hover:bg-accent", children: n }, n))) }))] }));
109
120
  }
@@ -131,3 +142,50 @@ export function createBlogTagPage(rawConfig) {
131
142
  }
132
143
  return { default: Page, generateMetadata, revalidate: config.revalidateSeconds };
133
144
  }
145
+ function fmtDate(iso) {
146
+ if (!iso)
147
+ return "—";
148
+ return new Date(iso).toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" });
149
+ }
150
+ /** Sealed admin list page: mount at `${adminBasePath}` (default `/admin/blog`). */
151
+ export function createBlogAdminListPage(rawConfig) {
152
+ const config = resolveConfig(rawConfig);
153
+ const service = createBlogService(config);
154
+ async function Page() {
155
+ const { posts } = await service.listPosts({ perPage: 200, includeUnpublished: true });
156
+ return (_jsxs("div", { className: "mx-auto max-w-4xl px-4 py-10", children: [_jsxs("div", { className: "mb-8 flex items-center justify-between", children: [_jsx("h1", { className: "text-2xl font-bold text-foreground", children: "Blog posts" }), _jsx(Link, { href: `${config.adminBasePath}/new`, className: "rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90", children: "+ New post" })] }), posts.length === 0 ? (_jsx("p", { className: "text-muted-foreground", children: "No posts yet." })) : (_jsxs("table", { className: "w-full border-collapse text-sm", children: [_jsx("thead", { children: _jsxs("tr", { className: "border-b border-border text-left text-xs font-medium uppercase tracking-wide text-muted-foreground", children: [_jsx("th", { className: "pb-2 pr-6 font-medium", children: "Title" }), _jsx("th", { className: "pb-2 pr-6 font-medium", children: "Status" }), _jsx("th", { className: "pb-2 pr-6 font-medium", children: "Published" }), _jsx("th", { className: "pb-2 pr-6 font-medium", children: "Slug" }), _jsx("th", { className: "pb-2 font-medium" })] }) }), _jsx("tbody", { children: posts.map((p) => (_jsxs("tr", { className: "border-b border-border/40", children: [_jsx("td", { className: "py-3 pr-6 font-medium", children: _jsx(Link, { href: `${config.basePath}/${p.slug}`, className: "hover:text-primary", children: p.title }) }), _jsx("td", { className: "py-3 pr-6", children: _jsx("span", { className: p.status === "published"
157
+ ? "rounded-full bg-green-100 px-2 py-0.5 text-xs font-medium text-green-700"
158
+ : p.status === "scheduled"
159
+ ? "rounded-full bg-yellow-100 px-2 py-0.5 text-xs font-medium text-yellow-700"
160
+ : "rounded-full bg-muted px-2 py-0.5 text-xs font-medium text-muted-foreground", children: p.status }) }), _jsx("td", { className: "py-3 pr-6 text-muted-foreground", children: fmtDate(p.publish_date) }), _jsx("td", { className: "py-3 pr-6 text-muted-foreground", children: p.slug }), _jsx("td", { className: "py-3", children: _jsx(Link, { href: `${config.adminBasePath}/${p.slug}/edit`, className: "text-xs text-primary hover:underline", children: "Edit" }) })] }, p.id))) })] }))] }));
161
+ }
162
+ return { default: Page };
163
+ }
164
+ /** Sealed admin new-post page: mount at `${adminBasePath}/new`. */
165
+ export function createBlogAdminNewPage(rawConfig) {
166
+ const config = resolveConfig(rawConfig);
167
+ const service = createBlogService(config);
168
+ async function Page() {
169
+ const repo = await service.repository();
170
+ const categories = await repo.listCategories();
171
+ return (_jsxs("div", { className: "mx-auto max-w-4xl px-4 py-10", children: [_jsx("h1", { className: "mb-6 text-2xl font-bold text-foreground", children: "New post" }), _jsx(AdminFormClient, { categories: categories, endpoint: config.adminApiBasePath, redirectTo: config.adminBasePath })] }));
172
+ }
173
+ return { default: Page };
174
+ }
175
+ /** Sealed admin edit page: mount at `${adminBasePath}/[slug]/edit`. */
176
+ export function createBlogAdminEditPage(rawConfig) {
177
+ const config = resolveConfig(rawConfig);
178
+ const service = createBlogService(config);
179
+ async function Page({ params, }) {
180
+ const { slug } = params instanceof Promise ? await params : params;
181
+ const [post, repo] = await Promise.all([
182
+ service.getPost(slug, { includeUnpublished: true }),
183
+ service.repository(),
184
+ ]);
185
+ if (!post)
186
+ notFound();
187
+ const categories = await repo.listCategories();
188
+ return (_jsxs("div", { className: "mx-auto max-w-4xl px-4 py-10", children: [_jsxs("h1", { className: "mb-6 text-2xl font-bold text-foreground", children: ["Edit: ", post.title] }), _jsx(AdminFormClient, { post: post, categories: categories, endpoint: config.adminApiBasePath, redirectTo: config.adminBasePath })] }));
189
+ }
190
+ return { default: Page };
191
+ }
package/dist/seo/index.js CHANGED
@@ -41,7 +41,7 @@ export function buildBlogPostingJsonLd(config, post, author) {
41
41
  return {
42
42
  "@context": "https://schema.org",
43
43
  "@type": "BlogPosting",
44
- headline: post.meta_title || post.title,
44
+ headline: post.title,
45
45
  description: post.meta_description || buildExcerpt(post.content),
46
46
  image: ogImageFor(config, post),
47
47
  datePublished: post.publish_date ?? post.created_at,
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/service/index.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EACV,UAAU,EACV,UAAU,EACV,QAAQ,EACR,qBAAqB,EACrB,cAAc,EACd,kBAAkB,EAClB,eAAe,EAChB,MAAM,mBAAmB,CAAC;AAC3B,OAAO,EAEL,KAAK,cAAc,EACnB,KAAK,SAAS,EACf,MAAM,wBAAwB,CAAC;AAGhC,oDAAoD;AACpD,wBAAgB,aAAa,CAAC,MAAM,EAAE,UAAU,GAAG,kBAAkB,CAMpE;AAED,MAAM,WAAW,WAAW;IAC1B,MAAM,EAAE,kBAAkB,CAAC;IAC3B,+EAA+E;IAC/E,UAAU,CAAC,GAAG,CAAC,EAAE,OAAO,GAAG,OAAO,CAAC,cAAc,CAAC,CAAC;IACnD,SAAS,CAAC,KAAK,CAAC,EAAE,SAAS,EAAE,GAAG,CAAC,EAAE,OAAO,GAAG,OAAO,CAAC,cAAc,CAAC,CAAC;IACrE,OAAO,CACL,IAAI,EAAE,MAAM,EACZ,IAAI,CAAC,EAAE;QAAE,kBAAkB,CAAC,EAAE,OAAO,CAAA;KAAE,EACvC,GAAG,CAAC,EAAE,OAAO,GACZ,OAAO,CAAC,qBAAqB,GAAG,IAAI,CAAC,CAAC;IACzC,WAAW,CAAC,kBAAkB,CAAC,EAAE,OAAO,EAAE,GAAG,CAAC,EAAE,OAAO,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CAAC;IAC5E,YAAY,CAAC,KAAK,EAAE,eAAe,EAAE,GAAG,CAAC,EAAE,OAAO,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAC;IACvE,UAAU,CAAC,EAAE,EAAE,MAAM,EAAE,GAAG,CAAC,EAAE,OAAO,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACrD,aAAa,CAAC,IAAI,EAAE,qBAAqB,GAAG,OAAO,CAAC,UAAU,CAAC,CAAC;IAChE,eAAe,CACb,IAAI,EAAE,qBAAqB,EAC3B,KAAK,CAAC,EAAE,MAAM,EACd,GAAG,CAAC,EAAE,OAAO,GACZ,OAAO,CAAC,qBAAqB,EAAE,CAAC,CAAC;IACpC,MAAM,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,CAAC,EAAE,MAAM,EAAE,GAAG,CAAC,EAAE,OAAO,GAAG,OAAO,CAAC,qBAAqB,EAAE,CAAC,CAAC;CACvF;AAED,wBAAgB,iBAAiB,CAAC,SAAS,EAAE,UAAU,GAAG,WAAW,CA6GpE"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/service/index.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EACV,UAAU,EACV,UAAU,EACV,QAAQ,EACR,qBAAqB,EACrB,cAAc,EACd,kBAAkB,EAClB,eAAe,EAChB,MAAM,mBAAmB,CAAC;AAC3B,OAAO,EAEL,KAAK,cAAc,EACnB,KAAK,SAAS,EACf,MAAM,wBAAwB,CAAC;AAGhC,oDAAoD;AACpD,wBAAgB,aAAa,CAAC,MAAM,EAAE,UAAU,GAAG,kBAAkB,CAQpE;AAED,MAAM,WAAW,WAAW;IAC1B,MAAM,EAAE,kBAAkB,CAAC;IAC3B,+EAA+E;IAC/E,UAAU,CAAC,GAAG,CAAC,EAAE,OAAO,GAAG,OAAO,CAAC,cAAc,CAAC,CAAC;IACnD,SAAS,CAAC,KAAK,CAAC,EAAE,SAAS,EAAE,GAAG,CAAC,EAAE,OAAO,GAAG,OAAO,CAAC,cAAc,CAAC,CAAC;IACrE,OAAO,CACL,IAAI,EAAE,MAAM,EACZ,IAAI,CAAC,EAAE;QAAE,kBAAkB,CAAC,EAAE,OAAO,CAAA;KAAE,EACvC,GAAG,CAAC,EAAE,OAAO,GACZ,OAAO,CAAC,qBAAqB,GAAG,IAAI,CAAC,CAAC;IACzC,WAAW,CAAC,kBAAkB,CAAC,EAAE,OAAO,EAAE,GAAG,CAAC,EAAE,OAAO,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CAAC;IAC5E,YAAY,CAAC,KAAK,EAAE,eAAe,EAAE,GAAG,CAAC,EAAE,OAAO,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAC;IACvE,UAAU,CAAC,EAAE,EAAE,MAAM,EAAE,GAAG,CAAC,EAAE,OAAO,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACrD,aAAa,CAAC,IAAI,EAAE,qBAAqB,GAAG,OAAO,CAAC,UAAU,CAAC,CAAC;IAChE,eAAe,CACb,IAAI,EAAE,qBAAqB,EAC3B,KAAK,CAAC,EAAE,MAAM,EACd,GAAG,CAAC,EAAE,OAAO,GACZ,OAAO,CAAC,qBAAqB,EAAE,CAAC,CAAC;IACpC,MAAM,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,CAAC,EAAE,MAAM,EAAE,GAAG,CAAC,EAAE,OAAO,GAAG,OAAO,CAAC,qBAAqB,EAAE,CAAC,CAAC;CACvF;AAED,wBAAgB,iBAAiB,CAAC,SAAS,EAAE,UAAU,GAAG,WAAW,CA+GpE"}
@@ -2,13 +2,15 @@
2
2
  // repository per request, and owns business logic (slug/reading-time/publish
3
3
  // rules, author resolution, FAQ serialization). Server-only.
4
4
  import { createBlogRepository, } from "../repository/index.js";
5
- import { buildExcerpt, calculateReadingTime, slugify } from "../lib/text.js";
5
+ import { buildExcerpt, calculateReadingTime, sanitizeMdx, slugify } from "../lib/text.js";
6
6
  /** Apply defaults to a host-supplied BlogConfig. */
7
7
  export function resolveConfig(config) {
8
8
  return {
9
9
  ...config,
10
10
  basePath: config.basePath?.replace(/\/$/, "") || "/blog",
11
11
  revalidateSeconds: config.revalidateSeconds ?? 3600,
12
+ adminBasePath: config.adminBasePath?.replace(/\/$/, "") || "/admin/blog",
13
+ adminApiBasePath: config.adminApiBasePath?.replace(/\/$/, "") || "/api/admin/blog",
12
14
  };
13
15
  }
14
16
  export function createBlogService(rawConfig) {
@@ -44,6 +46,7 @@ export function createBlogService(rawConfig) {
44
46
  },
45
47
  async upsertBySlug(input, req) {
46
48
  const repo = await repository(req);
49
+ const content = sanitizeMdx(input.content);
47
50
  const slug = input.slug?.trim() ? slugify(input.slug) : slugify(input.title);
48
51
  const status = input.status ?? "draft";
49
52
  // Resolve category from name when an id isn't supplied.
@@ -66,13 +69,13 @@ export function createBlogService(rawConfig) {
66
69
  const row = {
67
70
  slug,
68
71
  title: input.title,
69
- content: input.content,
70
- excerpt: buildExcerpt(input.content),
71
- reading_time: calculateReadingTime(input.content),
72
+ content,
73
+ excerpt: buildExcerpt(content),
74
+ reading_time: calculateReadingTime(content),
72
75
  category_id: categoryId,
73
76
  author_id: input.author_id ?? null,
74
77
  meta_title: input.meta_title ?? "",
75
- meta_description: input.meta_description ?? buildExcerpt(input.content),
78
+ meta_description: input.meta_description ?? buildExcerpt(content),
76
79
  canonical_url: input.canonical_url ?? null,
77
80
  og_image: input.og_image ?? null,
78
81
  featured_image: input.featured_image ?? null,
@@ -132,10 +132,18 @@ export interface BlogConfig {
132
132
  onAnalyticsEvent?: (name: BlogAnalyticsEvent, params: Record<string, unknown>) => void;
133
133
  /** ISR revalidate window in seconds. Default 3600. */
134
134
  revalidateSeconds?: number;
135
+ /** Override the search API endpoint (default: `${basePath}/api/search`). */
136
+ searchApiPath?: string;
137
+ /** Where the admin UI pages are mounted (default `/admin/blog`). */
138
+ adminBasePath?: string;
139
+ /** Where the admin API routes are mounted (default `/api/admin/blog`). */
140
+ adminApiBasePath?: string;
135
141
  }
136
142
  /** Resolved config with defaults applied. */
137
143
  export interface ResolvedBlogConfig extends BlogConfig {
138
144
  basePath: string;
139
145
  revalidateSeconds: number;
146
+ adminBasePath: string;
147
+ adminApiBasePath: string;
140
148
  }
141
149
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/types/index.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,OAAO,CAAC;AAE3C,gFAAgF;AAChF,MAAM,MAAM,UAAU,GAAG,OAAO,GAAG,WAAW,GAAG,WAAW,CAAC;AAE7D,6EAA6E;AAC7E,MAAM,WAAW,OAAO;IACtB,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,yCAAyC;AACzC,MAAM,WAAW,YAAY;IAC3B,EAAE,EAAE,MAAM,CAAC;IACX,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,EAAE,MAAM,CAAC;IACpB,MAAM,EAAE,MAAM,CAAC;IACf,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,iDAAiD;AACjD,MAAM,WAAW,OAAO;IACtB,EAAE,EAAE,MAAM,CAAC;IACX,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,kFAAkF;AAClF,MAAM,WAAW,QAAQ;IACvB,EAAE,EAAE,MAAM,CAAC;IACX,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,MAAM,CAAC;IAChB,YAAY,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC7B,OAAO,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,WAAW,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5B,SAAS,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B,UAAU,EAAE,MAAM,CAAC;IACnB,gBAAgB,EAAE,MAAM,CAAC;IACzB,aAAa,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC9B,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,cAAc,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/B,SAAS,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B,QAAQ,EAAE,MAAM,CAAC;IACjB,YAAY,EAAE,MAAM,CAAC;IACrB,MAAM,EAAE,UAAU,CAAC;IACnB,YAAY,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC7B,UAAU,EAAE,MAAM,CAAC;IACnB,UAAU,EAAE,MAAM,CAAC;CACpB;AAED,oEAAoE;AACpE,MAAM,WAAW,qBAAsB,SAAQ,QAAQ;IACrD,QAAQ,CAAC,EAAE,YAAY,GAAG,IAAI,CAAC;IAC/B,IAAI,EAAE,OAAO,EAAE,CAAC;IAChB,GAAG,EAAE,OAAO,EAAE,CAAC;CAChB;AAED,iEAAiE;AACjE,MAAM,WAAW,UAAU;IACzB,IAAI,EAAE,MAAM,CAAC;IACb,GAAG,EAAE,MAAM,CAAC;IACZ,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,2DAA2D;AAC3D,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,MAAM,CAAC;IAChB,WAAW,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5B,aAAa,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC9B,SAAS,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,aAAa,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC9B,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,cAAc,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/B,SAAS,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B,GAAG,CAAC,EAAE,OAAO,EAAE,CAAC;IAChB,IAAI,CAAC,EAAE,MAAM,EAAE,CAAC;IAChB,MAAM,CAAC,EAAE,UAAU,CAAC;IACpB,YAAY,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CAC9B;AAED,iCAAiC;AACjC,MAAM,WAAW,gBAAgB;IAC/B,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,sEAAsE;IACtE,kBAAkB,CAAC,EAAE,OAAO,CAAC;CAC9B;AAED,6BAA6B;AAC7B,MAAM,WAAW,cAAc;IAC7B,KAAK,EAAE,qBAAqB,EAAE,CAAC;IAC/B,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;IAChB,UAAU,EAAE,MAAM,CAAC;CACpB;AAED,iDAAiD;AACjD,MAAM,MAAM,kBAAkB,GAC1B,gBAAgB,GAChB,oBAAoB,GACpB,aAAa,GACb,YAAY,CAAC;AAEjB;;;GAGG;AACH,MAAM,WAAW,UAAU;IACzB,qDAAqD;IACrD,QAAQ,EAAE,MAAM,CAAC;IACjB,4EAA4E;IAC5E,OAAO,EAAE,MAAM,CAAC;IAChB,uCAAuC;IACvC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,kDAAkD;IAClD,OAAO,EAAE,MAAM,CAAC;IAChB,sCAAsC;IACtC,cAAc,EAAE,MAAM,CAAC;IACvB,+DAA+D;IAC/D,MAAM,EAAE,UAAU,CAAC;IACnB,6DAA6D;IAC7D,SAAS,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,UAAU,GAAG,OAAO,CAAC,UAAU,CAAC,CAAC;IAC7D,0EAA0E;IAC1E,cAAc,EAAE,MAAM,OAAO,CAAC,OAAO,CAAC,GAAG,OAAO,CAAC;IACjD,qEAAqE;IACrE,SAAS,EAAE,CAAC,GAAG,EAAE,OAAO,KAAK,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IACxD,iEAAiE;IACjE,YAAY,CAAC,EAAE,CAAC,GAAG,EAAE,OAAO,KAAK,MAAM,GAAG,IAAI,CAAC;IAC/C,iEAAiE;IACjE,aAAa,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,aAAa,CAAC,OAAO,CAAC,CAAC,CAAC;IACvD,wEAAwE;IACxE,gBAAgB,CAAC,EAAE,CAAC,IAAI,EAAE,kBAAkB,EAAE,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAK,IAAI,CAAC;IACvF,sDAAsD;IACtD,iBAAiB,CAAC,EAAE,MAAM,CAAC;CAC5B;AAED,6CAA6C;AAC7C,MAAM,WAAW,kBAAmB,SAAQ,UAAU;IACpD,QAAQ,EAAE,MAAM,CAAC;IACjB,iBAAiB,EAAE,MAAM,CAAC;CAC3B"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/types/index.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hazo_blog",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "SEO-optimized blogging package: posts, categories, tags, MDX content, and GA4/GSC/Bing-ready SEO.",
5
5
  "type": "module",
6
6
  "module": "./dist/index.js",
@@ -39,25 +39,33 @@
39
39
  "scripts": {
40
40
  "build": "tsc -p tsconfig.build.json",
41
41
  "type-check": "tsc --noEmit",
42
- "test": "node --experimental-vm-modules ../node_modules/jest/bin/jest.js --config jest.config.cjs"
42
+ "test": "node --experimental-vm-modules ../node_modules/jest/bin/jest.js --config jest.config.cjs",
43
+ "dev:test-app": "npm run build && cd test-app && npm run dev",
44
+ "build:test-app": "npm run build && cd test-app && npm run build"
43
45
  },
44
46
  "peerDependencies": {
45
- "hazo_core": "^1.0.1",
46
- "hazo_connect": "^3.0.0",
47
- "hazo_api": "^2.1.1",
48
- "hazo_files": "^3.0.0",
49
- "hazo_ui": "^3.2.0",
50
- "hazo_images": "^1.2.0",
47
+ "hazo_core": "^1.2.0",
48
+ "hazo_connect": "^3.9.0",
49
+ "hazo_api": "^2.4.0",
50
+ "hazo_files": "^3.1.0",
51
+ "hazo_ui": "^4.6.2",
52
+ "hazo_images": "^1.2.1",
51
53
  "hazo_jobs": "^0.12.0",
52
- "hazo_auth": "^9.0.1",
54
+ "hazo_auth": "^10.0.0",
53
55
  "react": "^18.0.0 || ^19.0.0",
54
56
  "react-dom": "^18.0.0 || ^19.0.0",
55
57
  "next": "^14.0.0 || ^16.0.0"
56
58
  },
57
59
  "peerDependenciesMeta": {
58
- "hazo_images": { "optional": true },
59
- "hazo_jobs": { "optional": true },
60
- "hazo_auth": { "optional": true }
60
+ "hazo_images": {
61
+ "optional": true
62
+ },
63
+ "hazo_jobs": {
64
+ "optional": true
65
+ },
66
+ "hazo_auth": {
67
+ "optional": true
68
+ }
61
69
  },
62
70
  "dependencies": {
63
71
  "next-mdx-remote": "^6.0.0",
@@ -75,11 +83,11 @@
75
83
  "react": "^19.0.0",
76
84
  "react-dom": "^19.0.0",
77
85
  "next": "^16.0.10",
78
- "hazo_core": "^1.0.1",
79
- "hazo_connect": "^3.0.0",
80
- "hazo_api": "^2.1.1",
81
- "hazo_files": "^3.0.0",
82
- "hazo_ui": "^3.2.0",
86
+ "hazo_core": "^1.2.1",
87
+ "hazo_connect": "^3.9.0",
88
+ "hazo_api": "^2.5.1",
89
+ "hazo_files": "^3.1.1",
90
+ "hazo_ui": "^4.7.0",
83
91
  "tailwindcss": "^4.2.4",
84
92
  "@tailwindcss/postcss": "^4.2.4",
85
93
  "postcss": "^8.4.49"