hazo_blog 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGE_LOG.md +45 -0
- package/README.md +179 -0
- package/SETUP_CHECKLIST.md +54 -0
- package/db_setup_postgres.sql +71 -0
- package/db_setup_sqlite.sql +77 -0
- package/dist/components/admin/post-form.d.ts +18 -0
- package/dist/components/admin/post-form.d.ts.map +1 -0
- package/dist/components/admin/post-form.js +78 -0
- package/dist/components/author-bio.d.ts +7 -0
- package/dist/components/author-bio.d.ts.map +1 -0
- package/dist/components/author-bio.js +6 -0
- package/dist/components/blog-post-view-tracker.d.ts +10 -0
- package/dist/components/blog-post-view-tracker.d.ts.map +1 -0
- package/dist/components/blog-post-view-tracker.js +27 -0
- package/dist/components/blog-search.d.ts +9 -0
- package/dist/components/blog-search.d.ts.map +1 -0
- package/dist/components/blog-search.js +43 -0
- package/dist/components/faq-section.d.ts +9 -0
- package/dist/components/faq-section.d.ts.map +1 -0
- package/dist/components/faq-section.js +8 -0
- package/dist/components/index.d.ts +12 -0
- package/dist/components/index.d.ts.map +1 -0
- package/dist/components/index.js +13 -0
- package/dist/components/mdx/blog-image.d.ts +10 -0
- package/dist/components/mdx/blog-image.d.ts.map +1 -0
- package/dist/components/mdx/blog-image.js +7 -0
- package/dist/components/mdx/callout.d.ts +10 -0
- package/dist/components/mdx/callout.d.ts.map +1 -0
- package/dist/components/mdx/callout.js +17 -0
- package/dist/components/mdx/code-block.d.ts +8 -0
- package/dist/components/mdx/code-block.d.ts.map +1 -0
- package/dist/components/mdx/code-block.js +8 -0
- package/dist/components/mdx/index.d.ts +13 -0
- package/dist/components/mdx/index.d.ts.map +1 -0
- package/dist/components/mdx/index.js +13 -0
- package/dist/components/mdx/youtube.d.ts +8 -0
- package/dist/components/mdx/youtube.d.ts.map +1 -0
- package/dist/components/mdx/youtube.js +7 -0
- package/dist/components/post-card.d.ts +8 -0
- package/dist/components/post-card.d.ts.map +1 -0
- package/dist/components/post-card.js +10 -0
- package/dist/components/post-hero.d.ts +7 -0
- package/dist/components/post-hero.d.ts.map +1 -0
- package/dist/components/post-hero.js +17 -0
- package/dist/components/related-posts.d.ts +9 -0
- package/dist/components/related-posts.d.ts.map +1 -0
- package/dist/components/related-posts.js +8 -0
- package/dist/components/table-of-contents.d.ts +8 -0
- package/dist/components/table-of-contents.d.ts.map +1 -0
- package/dist/components/table-of-contents.js +7 -0
- package/dist/config/index.d.ts +3 -0
- package/dist/config/index.d.ts.map +1 -0
- package/dist/config/index.js +1 -0
- package/dist/index.client.d.ts +5 -0
- package/dist/index.client.d.ts.map +1 -0
- package/dist/index.client.js +8 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +15 -0
- package/dist/lib/analytics.d.ts +4 -0
- package/dist/lib/analytics.d.ts.map +1 -0
- package/dist/lib/analytics.js +14 -0
- package/dist/lib/index.d.ts +2 -0
- package/dist/lib/index.d.ts.map +1 -0
- package/dist/lib/index.js +2 -0
- package/dist/lib/text.d.ts +25 -0
- package/dist/lib/text.d.ts.map +1 -0
- package/dist/lib/text.js +69 -0
- package/dist/next/blog-content.d.ts +7 -0
- package/dist/next/blog-content.d.ts.map +1 -0
- package/dist/next/blog-content.js +10 -0
- package/dist/next/index.d.ts +5 -0
- package/dist/next/index.d.ts.map +1 -0
- package/dist/next/index.js +15 -0
- package/dist/next/pages.d.ts +52 -0
- package/dist/next/pages.d.ts.map +1 -0
- package/dist/next/pages.js +133 -0
- package/dist/next/routes.d.ts +42 -0
- package/dist/next/routes.d.ts.map +1 -0
- package/dist/next/routes.js +172 -0
- package/dist/repository/index.d.ts +34 -0
- package/dist/repository/index.d.ts.map +1 -0
- package/dist/repository/index.js +285 -0
- package/dist/seo/index.d.ts +44 -0
- package/dist/seo/index.d.ts.map +1 -0
- package/dist/seo/index.js +175 -0
- package/dist/service/index.d.ts +21 -0
- package/dist/service/index.d.ts.map +1 -0
- package/dist/service/index.js +110 -0
- package/dist/types/index.d.ts +141 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/index.js +3 -0
- package/package.json +100 -0
package/CHANGE_LOG.md
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# hazo_blog — Change Log
|
|
2
|
+
|
|
3
|
+
All notable changes are documented here. This project follows
|
|
4
|
+
[Semantic Versioning](https://semver.org/).
|
|
5
|
+
|
|
6
|
+
## 0.1.0 — 2026-06-01
|
|
7
|
+
|
|
8
|
+
Initial release. SEO-first blogging package extracted from gotimer.
|
|
9
|
+
|
|
10
|
+
**Core**
|
|
11
|
+
- `BlogConfig` contract — the host supplies brand/author/URLs, a `hazo_connect`
|
|
12
|
+
adapter (`getHazoConnect`), an admin `authorize(req)` guard, optional tenant
|
|
13
|
+
`resolveScope(req)`, MDX component overrides, and a GA4 `onAnalyticsEvent` sink.
|
|
14
|
+
- Storage via `hazo_connect` (SQLite local / PostgreSQL prod). Typed
|
|
15
|
+
`BlogRepository` over `createCrudService`; portable live-post gate
|
|
16
|
+
(`status <> 'draft' AND publish_date <= now`).
|
|
17
|
+
- `BlogService` — slug/reading-time/excerpt/publish-date rules, upsert-by-slug,
|
|
18
|
+
category/tag resolution, author resolution.
|
|
19
|
+
- Tables (dual SQLite + PostgreSQL migrations): `hazo_blog_posts`,
|
|
20
|
+
`hazo_blog_categories`, `hazo_blog_tags`, `hazo_blog_post_tags`. Every table has
|
|
21
|
+
a nullable `scope_id` for optional multi-tenancy.
|
|
22
|
+
|
|
23
|
+
**SEO (GSC/Bing-ready)** — BlogPosting + BreadcrumbList + FAQPage JSON-LD,
|
|
24
|
+
per-post canonical + OG image, `noindex` for drafts/scheduled, composable
|
|
25
|
+
`getBlogSitemapEntries` / `getBlogRobotsRules` / `getBlogRssXml`.
|
|
26
|
+
|
|
27
|
+
**Rendering** — sealed Next.js page factories (`createBlogIndexPage`,
|
|
28
|
+
`createBlogPostPage`, `createBlogTagPage`) using SSG + ISR + `dynamicParams`;
|
|
29
|
+
MDX rendered to static HTML at build/ISR via `next-mdx-remote/rsc`.
|
|
30
|
+
|
|
31
|
+
**Auth** — admin routes guarded by injectable `authorize`; management API uses
|
|
32
|
+
`hazo_api` API keys + `ok`/`fail` envelopes.
|
|
33
|
+
|
|
34
|
+
**UI** — `PostCard`, `PostHero`, `AuthorBio`, `FaqSection`, `TableOfContents`,
|
|
35
|
+
`RelatedPosts`, `BlogSearch`, MDX components (`YouTube`, `Callout`, `BlogImage`,
|
|
36
|
+
`CodeBlock`), and an admin `PostForm` built on `hazo_ui`'s `MarkdownEditor`.
|
|
37
|
+
|
|
38
|
+
**Analytics** — `blog_post_view` / `blog_read_complete` / `blog_search` /
|
|
39
|
+
`blog_share` events via an injectable hook with a `window.gtag`/`dataLayer`
|
|
40
|
+
fallback (no GA script bundled).
|
|
41
|
+
|
|
42
|
+
**v1 features:** tags + tag pages, search, related posts, table of contents.
|
|
43
|
+
Deferred: view counts, non-destructive sync CLI, file-based MDX content.
|
|
44
|
+
|
|
45
|
+
Requires `hazo_ui >= 3.2.0` (for `MarkdownEditor`).
|
package/README.md
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
# hazo_blog
|
|
2
|
+
|
|
3
|
+
SEO-first blogging for Next.js App Router apps: posts, categories, tags, MDX
|
|
4
|
+
content, and GA4 / Google Search Console / Bing-ready output (BlogPosting +
|
|
5
|
+
BreadcrumbList + FAQPage JSON-LD, canonical, OG, sitemap, RSS). The package owns
|
|
6
|
+
the page layout **and** all SEO; the host customizes via Tailwind theme tokens
|
|
7
|
+
and a single `BlogConfig`.
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npm install hazo_blog
|
|
13
|
+
# peers:
|
|
14
|
+
npm install hazo_core hazo_connect hazo_api hazo_files hazo_ui react react-dom next
|
|
15
|
+
# optional peers: hazo_images (AI images), hazo_jobs (scheduled publish), hazo_auth
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Requires `hazo_ui >= 3.2.0` (provides the `MarkdownEditor` used by the admin form).
|
|
19
|
+
|
|
20
|
+
## 1. Database
|
|
21
|
+
|
|
22
|
+
Apply the schema for your engine (also runnable through your migration runner):
|
|
23
|
+
|
|
24
|
+
- `db_setup_sqlite.sql` — SQLite
|
|
25
|
+
- `db_setup_postgres.sql` — PostgreSQL
|
|
26
|
+
|
|
27
|
+
Tables: `hazo_blog_posts`, `hazo_blog_categories`, `hazo_blog_tags`,
|
|
28
|
+
`hazo_blog_post_tags` (all carry a nullable `scope_id` for optional
|
|
29
|
+
multi-tenancy).
|
|
30
|
+
|
|
31
|
+
## 2. Define your `BlogConfig`
|
|
32
|
+
|
|
33
|
+
```ts
|
|
34
|
+
// lib/blog-config.ts
|
|
35
|
+
import type { BlogConfig } from "hazo_blog";
|
|
36
|
+
|
|
37
|
+
export const blogConfig: BlogConfig = {
|
|
38
|
+
siteName: "Acme",
|
|
39
|
+
baseUrl: "https://acme.com",
|
|
40
|
+
basePath: "/blog", // default "/blog"
|
|
41
|
+
logoUrl: "https://acme.com/logo.png",
|
|
42
|
+
defaultOgImage: "https://acme.com/og.png",
|
|
43
|
+
author: { name: "Acme Team", url: "https://acme.com/about" },
|
|
44
|
+
getHazoConnect: () => myHazoConnectAdapter, // SQLite or Postgres
|
|
45
|
+
authorize: (req) => checkAdminPermission(req), // wire to hazo_auth
|
|
46
|
+
// resolveScope: (req) => orgIdFrom(req), // multi-tenant only
|
|
47
|
+
// getAuthor: (id) => lookupAuthor(id), // per-post authors
|
|
48
|
+
// onAnalyticsEvent: (name, params) => gtag("event", name, params),
|
|
49
|
+
revalidateSeconds: 3600,
|
|
50
|
+
};
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## 3. Mount the sealed pages
|
|
54
|
+
|
|
55
|
+
The package owns the page implementations; you write tiny files that pass config.
|
|
56
|
+
**`revalidate` and `dynamicParams` must be static literals** (a Next requirement),
|
|
57
|
+
and `default` can't be destructured (reserved word):
|
|
58
|
+
|
|
59
|
+
```tsx
|
|
60
|
+
// app/blog/page.tsx
|
|
61
|
+
import { createBlogIndexPage } from "hazo_blog/next";
|
|
62
|
+
import { blogConfig } from "@/lib/blog-config";
|
|
63
|
+
const page = createBlogIndexPage(blogConfig);
|
|
64
|
+
export default page.default;
|
|
65
|
+
export const generateMetadata = page.generateMetadata;
|
|
66
|
+
export const revalidate = 3600;
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
```tsx
|
|
70
|
+
// app/blog/[slug]/page.tsx
|
|
71
|
+
import { createBlogPostPage } from "hazo_blog/next";
|
|
72
|
+
import { blogConfig } from "@/lib/blog-config";
|
|
73
|
+
const page = createBlogPostPage(blogConfig);
|
|
74
|
+
export default page.default;
|
|
75
|
+
export const generateMetadata = page.generateMetadata;
|
|
76
|
+
export const generateStaticParams = page.generateStaticParams;
|
|
77
|
+
export const revalidate = 3600;
|
|
78
|
+
export const dynamicParams = true;
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
```tsx
|
|
82
|
+
// app/blog/tag/[tag]/page.tsx
|
|
83
|
+
import { createBlogTagPage } from "hazo_blog/next";
|
|
84
|
+
import { blogConfig } from "@/lib/blog-config";
|
|
85
|
+
const page = createBlogTagPage(blogConfig);
|
|
86
|
+
export default page.default;
|
|
87
|
+
export const generateMetadata = page.generateMetadata;
|
|
88
|
+
export const revalidate = 3600;
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## 4. Mount the API routes
|
|
92
|
+
|
|
93
|
+
Route-handler factories are exported from the **main `hazo_blog` entry** (they are
|
|
94
|
+
React-free, so they don't pull the React page components into your API bundles):
|
|
95
|
+
|
|
96
|
+
```ts
|
|
97
|
+
// app/api/blog/search/route.ts
|
|
98
|
+
import { createBlogSearchRoute } from "hazo_blog";
|
|
99
|
+
import { blogConfig } from "@/lib/blog-config";
|
|
100
|
+
export const GET = createBlogSearchRoute(blogConfig);
|
|
101
|
+
|
|
102
|
+
// app/api/blog/feed/route.ts → RSS
|
|
103
|
+
export const GET = createBlogFeedRoute(blogConfig);
|
|
104
|
+
|
|
105
|
+
// app/api/admin/blog/route.ts (cookie/session, guarded by authorize)
|
|
106
|
+
const admin = createBlogAdminRoutes(blogConfig);
|
|
107
|
+
export const GET = admin.collection.GET;
|
|
108
|
+
export const POST = admin.collection.POST;
|
|
109
|
+
|
|
110
|
+
// app/api/admin/blog/[id]/route.ts
|
|
111
|
+
export const PUT = admin.item.PUT;
|
|
112
|
+
export const DELETE = admin.item.DELETE;
|
|
113
|
+
|
|
114
|
+
// app/api/blog/manage/route.ts (programmatic, hazo_api keys; scopes blog:read / blog:write)
|
|
115
|
+
const manage = createBlogManageRoutes(blogConfig);
|
|
116
|
+
export const { GET, POST, PATCH } = manage;
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
## 5. Sitemap & robots
|
|
120
|
+
|
|
121
|
+
`sitemap.xml` / `robots.txt` are site-level singletons — merge the blog's
|
|
122
|
+
contributions into your root files:
|
|
123
|
+
|
|
124
|
+
```ts
|
|
125
|
+
// app/sitemap.ts
|
|
126
|
+
import { createBlogService, getBlogSitemapEntries, resolveConfig } from "hazo_blog";
|
|
127
|
+
import { blogConfig } from "@/lib/blog-config";
|
|
128
|
+
|
|
129
|
+
export default async function sitemap() {
|
|
130
|
+
const service = createBlogService(blogConfig);
|
|
131
|
+
const { posts } = await service.listPosts({ perPage: 1000 });
|
|
132
|
+
return [/* ...your routes */, ...getBlogSitemapEntries(resolveConfig(blogConfig), posts)];
|
|
133
|
+
}
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
## 6. Admin authoring form
|
|
137
|
+
|
|
138
|
+
```tsx
|
|
139
|
+
"use client";
|
|
140
|
+
import { PostForm } from "hazo_blog/client";
|
|
141
|
+
// pass categories + an endpoint; wire onImageUpload to hazo_files for paste-upload
|
|
142
|
+
<PostForm categories={categories} endpoint="/api/admin/blog" onSaved={...} />
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
## Required Next.js config
|
|
146
|
+
|
|
147
|
+
```js
|
|
148
|
+
// next.config.js
|
|
149
|
+
const nextConfig = {
|
|
150
|
+
// Transpile next-mdx-remote so Turbopack dedupes a single React copy
|
|
151
|
+
// (otherwise MDX prerender throws "React Element from an older version").
|
|
152
|
+
transpilePackages: ["hazo_blog", "hazo_ui", "next-mdx-remote"],
|
|
153
|
+
// Only if you use hazo_connect's SQLite (better-sqlite3) adapter:
|
|
154
|
+
serverExternalPackages: ["better-sqlite3"],
|
|
155
|
+
};
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
- **hazo_connect SQLite uses `sql.js` (WASM)** by default — copy
|
|
159
|
+
`node_modules/sql.js/dist/sql-wasm.wasm` to your app's `public/sql-wasm.wasm`.
|
|
160
|
+
- **Tailwind v4:** add `@source "../node_modules/hazo_blog/dist";` to your CSS so
|
|
161
|
+
the package's classes are compiled. The admin editor's styles load automatically
|
|
162
|
+
via `hazo_ui`'s `MarkdownEditor`.
|
|
163
|
+
|
|
164
|
+
## Exports
|
|
165
|
+
|
|
166
|
+
| Entry | Contents |
|
|
167
|
+
|---|---|
|
|
168
|
+
| `hazo_blog` | server: `createBlogService`, `createBlogRepository`, SEO builders, **route-handler factories** (React-free), types |
|
|
169
|
+
| `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) |
|
|
171
|
+
| `hazo_blog/seo` | `buildBlogPostingJsonLd`, `buildBreadcrumbJsonLd`, `buildFaqJsonLd`, `getBlogSitemapEntries`, `getBlogRobotsRules`, `getBlogRssXml` |
|
|
172
|
+
| `hazo_blog/config` | `BlogConfig` types + `resolveConfig` |
|
|
173
|
+
|
|
174
|
+
See `SETUP_CHECKLIST.md` for a step-by-step integration list. A runnable demo
|
|
175
|
+
lives in `test-app/`.
|
|
176
|
+
|
|
177
|
+
## License
|
|
178
|
+
|
|
179
|
+
MIT
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# hazo_blog — Setup Checklist
|
|
2
|
+
|
|
3
|
+
Step-by-step for adding hazo_blog to a Next.js App Router app. See `README.md`
|
|
4
|
+
for full code samples.
|
|
5
|
+
|
|
6
|
+
## 1. Install
|
|
7
|
+
|
|
8
|
+
- [ ] `npm install hazo_blog`
|
|
9
|
+
- [ ] Install peers: `hazo_core hazo_connect hazo_api hazo_files hazo_ui react react-dom next`
|
|
10
|
+
- [ ] Confirm `hazo_ui >= 3.2.0` (needed for `MarkdownEditor`)
|
|
11
|
+
- [ ] Optional peers as needed: `hazo_images`, `hazo_jobs`, `hazo_auth`
|
|
12
|
+
|
|
13
|
+
## 2. Database
|
|
14
|
+
|
|
15
|
+
- [ ] Apply `db_setup_sqlite.sql` or `db_setup_postgres.sql` (or add to your migration runner)
|
|
16
|
+
- [ ] Confirm tables exist: `hazo_blog_posts`, `hazo_blog_categories`, `hazo_blog_tags`, `hazo_blog_post_tags`
|
|
17
|
+
- [ ] If using `hazo_api`'s management API: ensure `hazo_api_keys` table exists (ships with hazo_api)
|
|
18
|
+
|
|
19
|
+
## 3. Config
|
|
20
|
+
|
|
21
|
+
- [ ] Create `lib/blog-config.ts` exporting a `BlogConfig`
|
|
22
|
+
- [ ] Wire `getHazoConnect` to your hazo_connect adapter
|
|
23
|
+
- [ ] Wire `authorize(req)` to your admin permission check (e.g. hazo_auth)
|
|
24
|
+
- [ ] Set `siteName`, `baseUrl`, `logoUrl`, `defaultOgImage`, `author`
|
|
25
|
+
- [ ] (multi-tenant only) set `resolveScope(req)`
|
|
26
|
+
- [ ] (per-post authors) set `getAuthor(id)`
|
|
27
|
+
- [ ] (analytics) set `onAnalyticsEvent` or rely on the `window.gtag` fallback
|
|
28
|
+
|
|
29
|
+
## 4. Routes
|
|
30
|
+
|
|
31
|
+
- [ ] `app/blog/page.tsx` → `createBlogIndexPage` (+ `export const revalidate = <number>`)
|
|
32
|
+
- [ ] `app/blog/[slug]/page.tsx` → `createBlogPostPage` (+ `generateStaticParams`, `revalidate`, `dynamicParams = true`)
|
|
33
|
+
- [ ] `app/blog/tag/[tag]/page.tsx` → `createBlogTagPage`
|
|
34
|
+
- [ ] `app/api/blog/search/route.ts` → `createBlogSearchRoute` (import from `hazo_blog`)
|
|
35
|
+
- [ ] `app/api/blog/feed/route.ts` → `createBlogFeedRoute`
|
|
36
|
+
- [ ] `app/api/admin/blog/route.ts` + `[id]/route.ts` → `createBlogAdminRoutes`
|
|
37
|
+
- [ ] (optional) `app/api/blog/manage/route.ts` → `createBlogManageRoutes`
|
|
38
|
+
- [ ] Merge `getBlogSitemapEntries()` into root `app/sitemap.ts` and `getBlogRobotsRules()` into `app/robots.ts`
|
|
39
|
+
|
|
40
|
+
## 5. Next.js config (required)
|
|
41
|
+
|
|
42
|
+
- [ ] `transpilePackages: ["hazo_blog", "hazo_ui", "next-mdx-remote"]` ← transpiling `next-mdx-remote` prevents the "React Element from an older version" MDX prerender error
|
|
43
|
+
- [ ] `serverExternalPackages: ["better-sqlite3"]` (if using the SQLite adapter)
|
|
44
|
+
- [ ] Copy `node_modules/sql.js/dist/sql-wasm.wasm` → `public/sql-wasm.wasm` (hazo_connect SQLite uses sql.js/WASM)
|
|
45
|
+
- [ ] Tailwind v4: add `@source "../node_modules/hazo_blog/dist";` to your CSS
|
|
46
|
+
|
|
47
|
+
## 6. Verify
|
|
48
|
+
|
|
49
|
+
- [ ] `next build` succeeds; `/blog` and `/blog/[slug]` prerender (SSG)
|
|
50
|
+
- [ ] View-source a post: BlogPosting + BreadcrumbList JSON-LD present (FAQPage when the post has FAQ)
|
|
51
|
+
- [ ] Drafts/future-scheduled posts return 404 and are absent from sitemap/RSS
|
|
52
|
+
- [ ] `/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
|
|
54
|
+
- [ ] Paste a post's HTML into Google's Rich Results Test — no errors
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
-- hazo_blog database schema — PostgreSQL
|
|
2
|
+
--
|
|
3
|
+
-- Supports both PostgreSQL (this file) and SQLite (db_setup_sqlite.sql).
|
|
4
|
+
-- All tables use the `hazo_blog_` prefix per workspace standards.
|
|
5
|
+
--
|
|
6
|
+
-- Multi-tenancy: every table carries a nullable `scope_id`. Single-tenant
|
|
7
|
+
-- consumers leave it NULL; multi-tenant consumers populate it and pass a scope
|
|
8
|
+
-- resolver so the repository filters by it.
|
|
9
|
+
|
|
10
|
+
CREATE TABLE IF NOT EXISTS hazo_blog_categories (
|
|
11
|
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
12
|
+
scope_id TEXT,
|
|
13
|
+
slug TEXT NOT NULL,
|
|
14
|
+
name TEXT NOT NULL,
|
|
15
|
+
description TEXT NOT NULL DEFAULT '',
|
|
16
|
+
colour TEXT NOT NULL DEFAULT '#3B82F6',
|
|
17
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
18
|
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
19
|
+
);
|
|
20
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_hazo_blog_categories_scope_slug
|
|
21
|
+
ON hazo_blog_categories (scope_id, slug);
|
|
22
|
+
|
|
23
|
+
CREATE TABLE IF NOT EXISTS hazo_blog_tags (
|
|
24
|
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
25
|
+
scope_id TEXT,
|
|
26
|
+
slug TEXT NOT NULL,
|
|
27
|
+
name TEXT NOT NULL,
|
|
28
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
29
|
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
30
|
+
);
|
|
31
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_hazo_blog_tags_scope_slug
|
|
32
|
+
ON hazo_blog_tags (scope_id, slug);
|
|
33
|
+
|
|
34
|
+
CREATE TABLE IF NOT EXISTS hazo_blog_posts (
|
|
35
|
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
36
|
+
scope_id TEXT,
|
|
37
|
+
slug TEXT NOT NULL,
|
|
38
|
+
title TEXT NOT NULL,
|
|
39
|
+
content TEXT NOT NULL DEFAULT '', -- raw MDX
|
|
40
|
+
content_html TEXT, -- cached rendered HTML (nullable)
|
|
41
|
+
excerpt TEXT,
|
|
42
|
+
category_id UUID REFERENCES hazo_blog_categories(id) ON DELETE SET NULL,
|
|
43
|
+
author_id TEXT, -- optional; resolved via getAuthor()
|
|
44
|
+
meta_title TEXT NOT NULL DEFAULT '',
|
|
45
|
+
meta_description TEXT NOT NULL DEFAULT '',
|
|
46
|
+
canonical_url TEXT, -- per-post canonical override
|
|
47
|
+
og_image TEXT, -- per-post OG image override
|
|
48
|
+
featured_image TEXT,
|
|
49
|
+
image_ref TEXT, -- generic image ref (was character_id)
|
|
50
|
+
faq_json JSONB NOT NULL DEFAULT '[]', -- [{question, answer}]
|
|
51
|
+
reading_time INTEGER NOT NULL DEFAULT 0, -- cached, minutes
|
|
52
|
+
status TEXT NOT NULL DEFAULT 'draft', -- draft | published | scheduled
|
|
53
|
+
publish_date TIMESTAMPTZ,
|
|
54
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
55
|
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
56
|
+
);
|
|
57
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_hazo_blog_posts_scope_slug
|
|
58
|
+
ON hazo_blog_posts (scope_id, slug);
|
|
59
|
+
CREATE INDEX IF NOT EXISTS idx_hazo_blog_posts_status_date
|
|
60
|
+
ON hazo_blog_posts (status, publish_date);
|
|
61
|
+
CREATE INDEX IF NOT EXISTS idx_hazo_blog_posts_category
|
|
62
|
+
ON hazo_blog_posts (category_id);
|
|
63
|
+
|
|
64
|
+
CREATE TABLE IF NOT EXISTS hazo_blog_post_tags (
|
|
65
|
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
66
|
+
post_id UUID NOT NULL REFERENCES hazo_blog_posts(id) ON DELETE CASCADE,
|
|
67
|
+
tag_id UUID NOT NULL REFERENCES hazo_blog_tags(id) ON DELETE CASCADE
|
|
68
|
+
);
|
|
69
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_hazo_blog_post_tags_unique
|
|
70
|
+
ON hazo_blog_post_tags (post_id, tag_id);
|
|
71
|
+
CREATE INDEX IF NOT EXISTS idx_hazo_blog_post_tags_tag ON hazo_blog_post_tags (tag_id);
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
-- hazo_blog database schema — SQLite
|
|
2
|
+
--
|
|
3
|
+
-- Supports both PostgreSQL (db_setup_postgres.sql) and SQLite (this file).
|
|
4
|
+
-- All tables use the `hazo_blog_` prefix per workspace standards.
|
|
5
|
+
--
|
|
6
|
+
-- Tables:
|
|
7
|
+
-- hazo_blog_posts — blog posts (raw MDX content + cached HTML + SEO fields)
|
|
8
|
+
-- hazo_blog_categories — single category per post
|
|
9
|
+
-- hazo_blog_tags — free-form tags (many-to-many with posts)
|
|
10
|
+
-- hazo_blog_post_tags — post↔tag join
|
|
11
|
+
--
|
|
12
|
+
-- Multi-tenancy: every table carries a nullable `scope_id`. Single-tenant
|
|
13
|
+
-- consumers (e.g. gotimer) leave it NULL; multi-tenant consumers populate it
|
|
14
|
+
-- and pass a scope resolver so the repository filters by it.
|
|
15
|
+
|
|
16
|
+
CREATE TABLE IF NOT EXISTS hazo_blog_categories (
|
|
17
|
+
id TEXT PRIMARY KEY,
|
|
18
|
+
scope_id TEXT,
|
|
19
|
+
slug TEXT NOT NULL,
|
|
20
|
+
name TEXT NOT NULL,
|
|
21
|
+
description TEXT NOT NULL DEFAULT '',
|
|
22
|
+
colour TEXT NOT NULL DEFAULT '#3B82F6',
|
|
23
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
24
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
25
|
+
);
|
|
26
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_hazo_blog_categories_scope_slug
|
|
27
|
+
ON hazo_blog_categories (scope_id, slug);
|
|
28
|
+
|
|
29
|
+
CREATE TABLE IF NOT EXISTS hazo_blog_tags (
|
|
30
|
+
id TEXT PRIMARY KEY,
|
|
31
|
+
scope_id TEXT,
|
|
32
|
+
slug TEXT NOT NULL,
|
|
33
|
+
name TEXT NOT NULL,
|
|
34
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
35
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
36
|
+
);
|
|
37
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_hazo_blog_tags_scope_slug
|
|
38
|
+
ON hazo_blog_tags (scope_id, slug);
|
|
39
|
+
|
|
40
|
+
CREATE TABLE IF NOT EXISTS hazo_blog_posts (
|
|
41
|
+
id TEXT PRIMARY KEY,
|
|
42
|
+
scope_id TEXT,
|
|
43
|
+
slug TEXT NOT NULL,
|
|
44
|
+
title TEXT NOT NULL,
|
|
45
|
+
content TEXT NOT NULL DEFAULT '', -- raw MDX
|
|
46
|
+
content_html TEXT, -- cached rendered HTML (nullable)
|
|
47
|
+
excerpt TEXT,
|
|
48
|
+
category_id TEXT REFERENCES hazo_blog_categories(id) ON DELETE SET NULL,
|
|
49
|
+
author_id TEXT, -- optional; resolved via getAuthor()
|
|
50
|
+
meta_title TEXT NOT NULL DEFAULT '',
|
|
51
|
+
meta_description TEXT NOT NULL DEFAULT '',
|
|
52
|
+
canonical_url TEXT, -- per-post canonical override
|
|
53
|
+
og_image TEXT, -- per-post OG image override
|
|
54
|
+
featured_image TEXT,
|
|
55
|
+
image_ref TEXT, -- generic image ref (was character_id)
|
|
56
|
+
faq_json TEXT NOT NULL DEFAULT '[]', -- [{question, answer}]
|
|
57
|
+
reading_time INTEGER NOT NULL DEFAULT 0, -- cached, minutes
|
|
58
|
+
status TEXT NOT NULL DEFAULT 'draft', -- draft | published | scheduled
|
|
59
|
+
publish_date TEXT,
|
|
60
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
61
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
62
|
+
);
|
|
63
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_hazo_blog_posts_scope_slug
|
|
64
|
+
ON hazo_blog_posts (scope_id, slug);
|
|
65
|
+
CREATE INDEX IF NOT EXISTS idx_hazo_blog_posts_status_date
|
|
66
|
+
ON hazo_blog_posts (status, publish_date);
|
|
67
|
+
CREATE INDEX IF NOT EXISTS idx_hazo_blog_posts_category
|
|
68
|
+
ON hazo_blog_posts (category_id);
|
|
69
|
+
|
|
70
|
+
CREATE TABLE IF NOT EXISTS hazo_blog_post_tags (
|
|
71
|
+
id TEXT PRIMARY KEY,
|
|
72
|
+
post_id TEXT NOT NULL REFERENCES hazo_blog_posts(id) ON DELETE CASCADE,
|
|
73
|
+
tag_id TEXT NOT NULL REFERENCES hazo_blog_tags(id) ON DELETE CASCADE
|
|
74
|
+
);
|
|
75
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_hazo_blog_post_tags_unique
|
|
76
|
+
ON hazo_blog_post_tags (post_id, tag_id);
|
|
77
|
+
CREATE INDEX IF NOT EXISTS idx_hazo_blog_post_tags_tag ON hazo_blog_post_tags (tag_id);
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { type MarkdownEmbed } from "hazo_ui";
|
|
2
|
+
import type { BlogCategory, BlogPostWithRelations } from "../../types/index.js";
|
|
3
|
+
export interface PostFormProps {
|
|
4
|
+
/** Existing post when editing; omit to create. */
|
|
5
|
+
post?: BlogPostWithRelations | null;
|
|
6
|
+
categories?: BlogCategory[];
|
|
7
|
+
/** Endpoint that accepts the upsert payload (POST). Default "/api/admin/blog". */
|
|
8
|
+
endpoint?: string;
|
|
9
|
+
/** Called after a successful save with the server response. */
|
|
10
|
+
onSaved?: (result: unknown) => void;
|
|
11
|
+
/** MDX embeds offered in the editor toolbar (host can extend). */
|
|
12
|
+
embeds?: MarkdownEmbed[];
|
|
13
|
+
/** Paste-to-upload handler wired to hazo_files by the host. */
|
|
14
|
+
onImageUpload?: (file: File) => Promise<string | null>;
|
|
15
|
+
className?: string;
|
|
16
|
+
}
|
|
17
|
+
export declare function PostForm({ post, categories, endpoint, onSaved, embeds, onImageUpload, className, }: PostFormProps): import("react/jsx-runtime").JSX.Element;
|
|
18
|
+
//# sourceMappingURL=post-form.d.ts.map
|
|
@@ -0,0 +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;AAE9B,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,+DAA+D;IAC/D,aAAa,CAAC,EAAE,CAAC,IAAI,EAAE,IAAI,KAAK,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;IACvD,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAgBD,wBAAgB,QAAQ,CAAC,EACvB,IAAI,EACJ,UAAe,EACf,QAA4B,EAC5B,OAAO,EACP,MAAuB,EACvB,aAAa,EACb,SAAS,GACV,EAAE,aAAa,2CA+Mf"}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { MarkdownEditor } from "hazo_ui";
|
|
5
|
+
import { cn } from "hazo_ui/utils";
|
|
6
|
+
import { slugify } from "../../lib/text.js";
|
|
7
|
+
const DEFAULT_EMBEDS = [
|
|
8
|
+
{ name: "youtube", label: "YT", snippet: '\n<YouTube id="VIDEO_ID" title="" />\n' },
|
|
9
|
+
{
|
|
10
|
+
name: "callout",
|
|
11
|
+
label: "Note",
|
|
12
|
+
snippet: (sel) => `\n<Callout type="info">\n${sel || "..."}\n</Callout>\n`,
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
name: "image",
|
|
16
|
+
label: "Img",
|
|
17
|
+
snippet: '\n<BlogImage src="/path.jpg" alt="" caption="" />\n',
|
|
18
|
+
},
|
|
19
|
+
];
|
|
20
|
+
export function PostForm({ post, categories = [], endpoint = "/api/admin/blog", onSaved, embeds = DEFAULT_EMBEDS, onImageUpload, className, }) {
|
|
21
|
+
const [title, setTitle] = useState(post?.title ?? "");
|
|
22
|
+
const [slug, setSlug] = useState(post?.slug ?? "");
|
|
23
|
+
const [slugTouched, setSlugTouched] = useState(!!post);
|
|
24
|
+
const [content, setContent] = useState(post?.content ?? "");
|
|
25
|
+
const [categoryId, setCategoryId] = useState(post?.category_id ?? "");
|
|
26
|
+
const [metaTitle, setMetaTitle] = useState(post?.meta_title ?? "");
|
|
27
|
+
const [metaDescription, setMetaDescription] = useState(post?.meta_description ?? "");
|
|
28
|
+
const [featuredImage, setFeaturedImage] = useState(post?.featured_image ?? "");
|
|
29
|
+
const [tags, setTags] = useState((post?.tags ?? []).map((t) => t.name).join(", "));
|
|
30
|
+
const [faq, setFaq] = useState(post?.faq ?? []);
|
|
31
|
+
const [saving, setSaving] = useState(false);
|
|
32
|
+
const [error, setError] = useState("");
|
|
33
|
+
function effectiveSlug() {
|
|
34
|
+
return slugTouched && slug ? slugify(slug) : slugify(title);
|
|
35
|
+
}
|
|
36
|
+
async function save(status) {
|
|
37
|
+
setSaving(true);
|
|
38
|
+
setError("");
|
|
39
|
+
try {
|
|
40
|
+
const payload = {
|
|
41
|
+
slug: effectiveSlug(),
|
|
42
|
+
title,
|
|
43
|
+
content,
|
|
44
|
+
category_id: categoryId || null,
|
|
45
|
+
meta_title: metaTitle,
|
|
46
|
+
meta_description: metaDescription,
|
|
47
|
+
featured_image: featuredImage || null,
|
|
48
|
+
tags: tags
|
|
49
|
+
.split(",")
|
|
50
|
+
.map((t) => t.trim())
|
|
51
|
+
.filter(Boolean),
|
|
52
|
+
faq: faq.filter((f) => f.question.trim() && f.answer.trim()),
|
|
53
|
+
status,
|
|
54
|
+
};
|
|
55
|
+
const res = await fetch(endpoint, {
|
|
56
|
+
method: "POST",
|
|
57
|
+
headers: { "content-type": "application/json" },
|
|
58
|
+
body: JSON.stringify(payload),
|
|
59
|
+
});
|
|
60
|
+
if (!res.ok) {
|
|
61
|
+
setError(`Save failed (${res.status})`);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
onSaved?.(await res.json());
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
setError("Save failed");
|
|
68
|
+
}
|
|
69
|
+
finally {
|
|
70
|
+
setSaving(false);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
const inputCls = "w-full rounded-lg border border-border bg-background px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-primary";
|
|
74
|
+
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) => {
|
|
75
|
+
setSlugTouched(true);
|
|
76
|
+
setSlug(e.target.value);
|
|
77
|
+
}, 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: onImageUpload, 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" })] })] }), _jsxs("div", { children: [_jsx("label", { className: "mb-1 block text-sm font-medium", children: "Featured image URL" }), _jsx("input", { className: inputCls, value: featuredImage, onChange: (e) => setFeaturedImage(e.target.value), placeholder: "https://\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: "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: [_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" })] })] }));
|
|
78
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { AuthorInfo } from "../types/index.js";
|
|
2
|
+
export interface AuthorBioProps {
|
|
3
|
+
author: AuthorInfo;
|
|
4
|
+
className?: string;
|
|
5
|
+
}
|
|
6
|
+
export declare function AuthorBio({ author, className }: AuthorBioProps): import("react/jsx-runtime").JSX.Element;
|
|
7
|
+
//# sourceMappingURL=author-bio.d.ts.map
|
|
@@ -0,0 +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"}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import Image from "next/image";
|
|
3
|
+
import { cn } from "hazo_ui/utils";
|
|
4
|
+
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 })] })] }));
|
|
6
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export interface BlogPostViewTrackerProps {
|
|
2
|
+
slug: string;
|
|
3
|
+
title: string;
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* Fires `blog_post_view` on mount and `blog_read_complete` once the reader
|
|
7
|
+
* scrolls near the bottom. Renders nothing.
|
|
8
|
+
*/
|
|
9
|
+
export declare function BlogPostViewTracker({ slug, title }: BlogPostViewTrackerProps): null;
|
|
10
|
+
//# sourceMappingURL=blog-post-view-tracker.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"blog-post-view-tracker.d.ts","sourceRoot":"","sources":["../../src/components/blog-post-view-tracker.tsx"],"names":[],"mappings":"AAKA,MAAM,WAAW,wBAAwB;IACvC,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC;CACf;AAED;;;GAGG;AACH,wBAAgB,mBAAmB,CAAC,EAAE,IAAI,EAAE,KAAK,EAAE,EAAE,wBAAwB,QAoB5E"}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useEffect } from "react";
|
|
3
|
+
import { trackBlogEvent } from "../lib/analytics.js";
|
|
4
|
+
/**
|
|
5
|
+
* Fires `blog_post_view` on mount and `blog_read_complete` once the reader
|
|
6
|
+
* scrolls near the bottom. Renders nothing.
|
|
7
|
+
*/
|
|
8
|
+
export function BlogPostViewTracker({ slug, title }) {
|
|
9
|
+
useEffect(() => {
|
|
10
|
+
trackBlogEvent("blog_post_view", { slug, title });
|
|
11
|
+
let completed = false;
|
|
12
|
+
function onScroll() {
|
|
13
|
+
if (completed)
|
|
14
|
+
return;
|
|
15
|
+
const scrolled = window.scrollY + window.innerHeight;
|
|
16
|
+
const threshold = document.body.scrollHeight * 0.9;
|
|
17
|
+
if (scrolled >= threshold) {
|
|
18
|
+
completed = true;
|
|
19
|
+
trackBlogEvent("blog_read_complete", { slug, title });
|
|
20
|
+
window.removeEventListener("scroll", onScroll);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
window.addEventListener("scroll", onScroll, { passive: true });
|
|
24
|
+
return () => window.removeEventListener("scroll", onScroll);
|
|
25
|
+
}, [slug, title]);
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export interface BlogSearchProps {
|
|
2
|
+
/** Endpoint that returns { posts: SearchResult[] } for ?q=. Default `${basePath}/api/search`. */
|
|
3
|
+
endpoint?: string;
|
|
4
|
+
basePath?: string;
|
|
5
|
+
placeholder?: string;
|
|
6
|
+
className?: string;
|
|
7
|
+
}
|
|
8
|
+
export declare function BlogSearch({ endpoint, basePath, placeholder, className, }: BlogSearchProps): import("react/jsx-runtime").JSX.Element;
|
|
9
|
+
//# sourceMappingURL=blog-search.d.ts.map
|
|
@@ -0,0 +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"}
|