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.
Files changed (93) hide show
  1. package/CHANGE_LOG.md +45 -0
  2. package/README.md +179 -0
  3. package/SETUP_CHECKLIST.md +54 -0
  4. package/db_setup_postgres.sql +71 -0
  5. package/db_setup_sqlite.sql +77 -0
  6. package/dist/components/admin/post-form.d.ts +18 -0
  7. package/dist/components/admin/post-form.d.ts.map +1 -0
  8. package/dist/components/admin/post-form.js +78 -0
  9. package/dist/components/author-bio.d.ts +7 -0
  10. package/dist/components/author-bio.d.ts.map +1 -0
  11. package/dist/components/author-bio.js +6 -0
  12. package/dist/components/blog-post-view-tracker.d.ts +10 -0
  13. package/dist/components/blog-post-view-tracker.d.ts.map +1 -0
  14. package/dist/components/blog-post-view-tracker.js +27 -0
  15. package/dist/components/blog-search.d.ts +9 -0
  16. package/dist/components/blog-search.d.ts.map +1 -0
  17. package/dist/components/blog-search.js +43 -0
  18. package/dist/components/faq-section.d.ts +9 -0
  19. package/dist/components/faq-section.d.ts.map +1 -0
  20. package/dist/components/faq-section.js +8 -0
  21. package/dist/components/index.d.ts +12 -0
  22. package/dist/components/index.d.ts.map +1 -0
  23. package/dist/components/index.js +13 -0
  24. package/dist/components/mdx/blog-image.d.ts +10 -0
  25. package/dist/components/mdx/blog-image.d.ts.map +1 -0
  26. package/dist/components/mdx/blog-image.js +7 -0
  27. package/dist/components/mdx/callout.d.ts +10 -0
  28. package/dist/components/mdx/callout.d.ts.map +1 -0
  29. package/dist/components/mdx/callout.js +17 -0
  30. package/dist/components/mdx/code-block.d.ts +8 -0
  31. package/dist/components/mdx/code-block.d.ts.map +1 -0
  32. package/dist/components/mdx/code-block.js +8 -0
  33. package/dist/components/mdx/index.d.ts +13 -0
  34. package/dist/components/mdx/index.d.ts.map +1 -0
  35. package/dist/components/mdx/index.js +13 -0
  36. package/dist/components/mdx/youtube.d.ts +8 -0
  37. package/dist/components/mdx/youtube.d.ts.map +1 -0
  38. package/dist/components/mdx/youtube.js +7 -0
  39. package/dist/components/post-card.d.ts +8 -0
  40. package/dist/components/post-card.d.ts.map +1 -0
  41. package/dist/components/post-card.js +10 -0
  42. package/dist/components/post-hero.d.ts +7 -0
  43. package/dist/components/post-hero.d.ts.map +1 -0
  44. package/dist/components/post-hero.js +17 -0
  45. package/dist/components/related-posts.d.ts +9 -0
  46. package/dist/components/related-posts.d.ts.map +1 -0
  47. package/dist/components/related-posts.js +8 -0
  48. package/dist/components/table-of-contents.d.ts +8 -0
  49. package/dist/components/table-of-contents.d.ts.map +1 -0
  50. package/dist/components/table-of-contents.js +7 -0
  51. package/dist/config/index.d.ts +3 -0
  52. package/dist/config/index.d.ts.map +1 -0
  53. package/dist/config/index.js +1 -0
  54. package/dist/index.client.d.ts +5 -0
  55. package/dist/index.client.d.ts.map +1 -0
  56. package/dist/index.client.js +8 -0
  57. package/dist/index.d.ts +7 -0
  58. package/dist/index.d.ts.map +1 -0
  59. package/dist/index.js +15 -0
  60. package/dist/lib/analytics.d.ts +4 -0
  61. package/dist/lib/analytics.d.ts.map +1 -0
  62. package/dist/lib/analytics.js +14 -0
  63. package/dist/lib/index.d.ts +2 -0
  64. package/dist/lib/index.d.ts.map +1 -0
  65. package/dist/lib/index.js +2 -0
  66. package/dist/lib/text.d.ts +25 -0
  67. package/dist/lib/text.d.ts.map +1 -0
  68. package/dist/lib/text.js +69 -0
  69. package/dist/next/blog-content.d.ts +7 -0
  70. package/dist/next/blog-content.d.ts.map +1 -0
  71. package/dist/next/blog-content.js +10 -0
  72. package/dist/next/index.d.ts +5 -0
  73. package/dist/next/index.d.ts.map +1 -0
  74. package/dist/next/index.js +15 -0
  75. package/dist/next/pages.d.ts +52 -0
  76. package/dist/next/pages.d.ts.map +1 -0
  77. package/dist/next/pages.js +133 -0
  78. package/dist/next/routes.d.ts +42 -0
  79. package/dist/next/routes.d.ts.map +1 -0
  80. package/dist/next/routes.js +172 -0
  81. package/dist/repository/index.d.ts +34 -0
  82. package/dist/repository/index.d.ts.map +1 -0
  83. package/dist/repository/index.js +285 -0
  84. package/dist/seo/index.d.ts +44 -0
  85. package/dist/seo/index.d.ts.map +1 -0
  86. package/dist/seo/index.js +175 -0
  87. package/dist/service/index.d.ts +21 -0
  88. package/dist/service/index.d.ts.map +1 -0
  89. package/dist/service/index.js +110 -0
  90. package/dist/types/index.d.ts +141 -0
  91. package/dist/types/index.d.ts.map +1 -0
  92. package/dist/types/index.js +3 -0
  93. package/package.json +100 -0
@@ -0,0 +1,285 @@
1
+ // hazo_blog — typed repository over hazo_connect.
2
+ // Server-only. Uses createCrudService for writes and QueryBuilder-backed reads.
3
+ //
4
+ // The "live" gate (published OR scheduled-and-due) is expressed as a single
5
+ // portable predicate: status <> 'draft' AND publish_date <= now. The service
6
+ // guarantees published/scheduled posts always carry a publish_date, so drafts
7
+ // (and future-scheduled posts) are naturally excluded on both SQLite and
8
+ // PostgreSQL without engine-specific SQL.
9
+ import { randomUUID } from "node:crypto";
10
+ import { createCrudService } from "hazo_connect/server";
11
+ import { safeJsonParse } from "hazo_core";
12
+ function generateId() {
13
+ return randomUUID();
14
+ }
15
+ const POSTS = "hazo_blog_posts";
16
+ const CATEGORIES = "hazo_blog_categories";
17
+ const TAGS = "hazo_blog_tags";
18
+ const POST_TAGS = "hazo_blog_post_tags";
19
+ function nowIso() {
20
+ return new Date().toISOString();
21
+ }
22
+ function parseFaq(faqJson) {
23
+ const parsed = safeJsonParse(faqJson ?? "[]");
24
+ if (!Array.isArray(parsed))
25
+ return [];
26
+ return parsed.filter((x) => !!x && typeof x.question === "string" && typeof x.answer === "string");
27
+ }
28
+ export function createBlogRepository(adapter, options = {}) {
29
+ const scope = options.scope ?? null;
30
+ const a = adapter;
31
+ const posts = createCrudService(a, POSTS);
32
+ const categories = createCrudService(a, CATEGORIES);
33
+ const tags = createCrudService(a, TAGS);
34
+ const postTags = createCrudService(a, POST_TAGS);
35
+ /** Apply the tenant-scope filter to a query builder when a scope is set. */
36
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
37
+ function scoped(qb) {
38
+ return scope ? qb.where("scope_id", "eq", scope) : qb;
39
+ }
40
+ /** Apply the live-status gate to a query builder. */
41
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
42
+ function live(qb) {
43
+ return qb.where("status", "neq", "draft").where("publish_date", "lte", nowIso());
44
+ }
45
+ async function resolveTagsFor(postIds) {
46
+ const map = new Map();
47
+ if (postIds.length === 0)
48
+ return map;
49
+ const links = await postTags.list(
50
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
51
+ (qb) => qb.whereIn("post_id", postIds));
52
+ const tagIds = [...new Set(links.map((l) => l.tag_id))];
53
+ if (tagIds.length === 0)
54
+ return map;
55
+ const allTags = await tags.list(
56
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
57
+ (qb) => qb.whereIn("id", tagIds));
58
+ const byId = new Map(allTags.map((t) => [t.id, t]));
59
+ for (const link of links) {
60
+ const tag = byId.get(link.tag_id);
61
+ if (!tag)
62
+ continue;
63
+ const arr = map.get(link.post_id) ?? [];
64
+ arr.push(tag);
65
+ map.set(link.post_id, arr);
66
+ }
67
+ return map;
68
+ }
69
+ async function hydrate(rows) {
70
+ if (rows.length === 0)
71
+ return [];
72
+ const catIds = [...new Set(rows.map((r) => r.category_id).filter(Boolean))];
73
+ const cats = catIds.length > 0
74
+ ? // eslint-disable-next-line @typescript-eslint/no-explicit-any
75
+ await categories.list((qb) => qb.whereIn("id", catIds))
76
+ : [];
77
+ const catById = new Map(cats.map((c) => [c.id, c]));
78
+ const tagMap = await resolveTagsFor(rows.map((r) => r.id));
79
+ return rows.map((r) => ({
80
+ ...r,
81
+ category: r.category_id ? catById.get(r.category_id) ?? null : null,
82
+ tags: tagMap.get(r.id) ?? [],
83
+ faq: parseFaq(r.faq_json),
84
+ }));
85
+ }
86
+ return {
87
+ async listPosts(query = {}) {
88
+ const page = Math.max(1, query.page ?? 1);
89
+ const perPage = Math.max(1, query.perPage ?? 10);
90
+ // Optional category filter → resolve slug to id.
91
+ let categoryId;
92
+ if (query.categorySlug) {
93
+ const cat = await categories.findOneBy(scope
94
+ ? { slug: query.categorySlug, scope_id: scope }
95
+ : { slug: query.categorySlug });
96
+ if (!cat)
97
+ return { posts: [], total: 0 };
98
+ categoryId = cat.id;
99
+ }
100
+ // Optional tag filter → resolve slug to the set of post ids.
101
+ let tagPostIds;
102
+ if (query.tagSlug) {
103
+ const tag = await tags.findOneBy(scope ? { slug: query.tagSlug, scope_id: scope } : { slug: query.tagSlug });
104
+ if (!tag)
105
+ return { posts: [], total: 0 };
106
+ const links = await postTags.list(
107
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
108
+ (qb) => qb.where("tag_id", "eq", tag.id));
109
+ tagPostIds = links.map((l) => l.post_id);
110
+ if (tagPostIds.length === 0)
111
+ return { posts: [], total: 0 };
112
+ }
113
+ const all = await posts.list(
114
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
115
+ (qb) => {
116
+ let b = scoped(qb);
117
+ if (!query.includeUnpublished)
118
+ b = live(b);
119
+ if (categoryId)
120
+ b = b.where("category_id", "eq", categoryId);
121
+ if (tagPostIds)
122
+ b = b.whereIn("id", tagPostIds);
123
+ return b.order("publish_date", "desc");
124
+ });
125
+ const total = all.length;
126
+ const slice = all.slice((page - 1) * perPage, (page - 1) * perPage + perPage);
127
+ return { posts: await hydrate(slice), total };
128
+ },
129
+ async getPostBySlug(slug, includeUnpublished = false) {
130
+ const row = await posts.findOneBy(scope ? { slug, scope_id: scope } : { slug });
131
+ if (!row)
132
+ return null;
133
+ if (!includeUnpublished) {
134
+ const isLive = row.status !== "draft" &&
135
+ !!row.publish_date &&
136
+ new Date(row.publish_date).getTime() <= Date.now();
137
+ if (!isLive)
138
+ return null;
139
+ }
140
+ const [hydrated] = await hydrate([row]);
141
+ return hydrated ?? null;
142
+ },
143
+ async getAllSlugs(includeUnpublished = false) {
144
+ const rows = await posts.list(
145
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
146
+ (qb) => {
147
+ let b = scoped(qb);
148
+ if (!includeUnpublished)
149
+ b = live(b);
150
+ return b;
151
+ });
152
+ return rows.map((r) => r.slug);
153
+ },
154
+ async upsertPost(row, tagSlugs) {
155
+ const existing = await posts.findOneBy(scope ? { slug: row.slug, scope_id: scope } : { slug: row.slug });
156
+ let saved;
157
+ if (existing) {
158
+ const [updated] = await posts.updateById(existing.id, {
159
+ ...row,
160
+ updated_at: nowIso(),
161
+ });
162
+ saved = updated;
163
+ }
164
+ else {
165
+ const [inserted] = await posts.insert({
166
+ id: generateId(),
167
+ scope_id: scope,
168
+ created_at: nowIso(),
169
+ updated_at: nowIso(),
170
+ ...row,
171
+ });
172
+ saved = inserted;
173
+ }
174
+ if (tagSlugs)
175
+ await this.setPostTags(saved.id, tagSlugs);
176
+ return saved;
177
+ },
178
+ async deletePost(id) {
179
+ // Best-effort link cleanup for engines without enforced ON DELETE CASCADE
180
+ // (SQLite requires PRAGMA foreign_keys=ON), then delete the post.
181
+ const links = await postTags.list(
182
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
183
+ (qb) => qb.where("post_id", "eq", id));
184
+ await Promise.all(links.map((l) => postTags.deleteById(l.id)));
185
+ await posts.deleteById(id);
186
+ },
187
+ async setPostTags(postId, tagSlugs) {
188
+ const desired = [];
189
+ for (const name of tagSlugs) {
190
+ desired.push(await this.getOrCreateTag(name));
191
+ }
192
+ const existing = await postTags.list(
193
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
194
+ (qb) => qb.where("post_id", "eq", postId));
195
+ const existingTagIds = new Set(existing.map((e) => e.tag_id));
196
+ const desiredIds = new Set(desired.map((t) => t.id));
197
+ // Add missing links.
198
+ for (const tag of desired) {
199
+ if (!existingTagIds.has(tag.id)) {
200
+ await postTags.insert({ id: generateId(), post_id: postId, tag_id: tag.id });
201
+ }
202
+ }
203
+ // Remove stale links by their surrogate id.
204
+ for (const link of existing) {
205
+ if (!desiredIds.has(link.tag_id)) {
206
+ await postTags.deleteById(link.id);
207
+ }
208
+ }
209
+ },
210
+ async listCategories() {
211
+ return categories.list(
212
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
213
+ (qb) => scoped(qb).order("name", "asc"));
214
+ },
215
+ async getOrCreateCategory(input) {
216
+ const slug = input.slug ?? input.name;
217
+ const found = await categories.findOneBy(scope ? { slug, scope_id: scope } : { slug });
218
+ if (found)
219
+ return found;
220
+ const [created] = await categories.insert({
221
+ id: generateId(),
222
+ scope_id: scope,
223
+ slug,
224
+ name: input.name,
225
+ description: "",
226
+ colour: "#3B82F6",
227
+ created_at: nowIso(),
228
+ updated_at: nowIso(),
229
+ });
230
+ return created;
231
+ },
232
+ async listTags() {
233
+ return tags.list(
234
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
235
+ (qb) => scoped(qb).order("name", "asc"));
236
+ },
237
+ async getOrCreateTag(name) {
238
+ const found = await tags.findOneBy(scope ? { name, scope_id: scope } : { name });
239
+ if (found)
240
+ return found;
241
+ const [created] = await tags.insert({
242
+ id: generateId(),
243
+ scope_id: scope,
244
+ slug: name,
245
+ name,
246
+ created_at: nowIso(),
247
+ updated_at: nowIso(),
248
+ });
249
+ return created;
250
+ },
251
+ async getRelatedPosts(post, limit = 3) {
252
+ const { posts: candidates } = await this.listPosts({ perPage: 50 });
253
+ const tagSlugs = new Set(post.tags.map((t) => t.slug));
254
+ const scored = candidates
255
+ .filter((p) => p.id !== post.id)
256
+ .map((p) => {
257
+ let score = 0;
258
+ if (post.category_id && p.category_id === post.category_id)
259
+ score += 2;
260
+ for (const t of p.tags)
261
+ if (tagSlugs.has(t.slug))
262
+ score += 1;
263
+ return { p, score };
264
+ })
265
+ .sort((a, b) => b.score - a.score);
266
+ return scored.slice(0, limit).map((s) => s.p);
267
+ },
268
+ async searchPosts(term, limit = 20) {
269
+ const q = term.trim();
270
+ if (!q)
271
+ return [];
272
+ const needle = `%${q}%`;
273
+ const rows = await posts.list(
274
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
275
+ (qb) => live(scoped(qb))
276
+ .whereOr([
277
+ { field: "title", operator: "like", value: needle },
278
+ { field: "content", operator: "like", value: needle },
279
+ ])
280
+ .order("publish_date", "desc")
281
+ .limit(limit));
282
+ return hydrate(rows);
283
+ },
284
+ };
285
+ }
@@ -0,0 +1,44 @@
1
+ import type { AuthorInfo, BlogPostWithRelations, ResolvedBlogConfig } from "../types/index.js";
2
+ /** Absolute URL for a post. */
3
+ export declare function postUrl(config: ResolvedBlogConfig, slug: string): string;
4
+ /** Absolute URL for the blog index. */
5
+ export declare function indexUrl(config: ResolvedBlogConfig): string;
6
+ /** Absolute URL for a tag page. */
7
+ export declare function tagUrl(config: ResolvedBlogConfig, tagSlug: string): string;
8
+ /** Resolve the canonical URL for a post (per-post override wins). */
9
+ export declare function canonicalFor(config: ResolvedBlogConfig, post: BlogPostWithRelations): string;
10
+ /** Resolve the OG image for a post, falling back to the site default. */
11
+ export declare function ogImageFor(config: ResolvedBlogConfig, post: BlogPostWithRelations): string;
12
+ /** A post is indexable only when it is live (published/scheduled-due). */
13
+ export declare function isIndexable(post: BlogPostWithRelations): boolean;
14
+ /** Build schema.org BlogPosting JSON-LD for a post. */
15
+ export declare function buildBlogPostingJsonLd(config: ResolvedBlogConfig, post: BlogPostWithRelations, author: AuthorInfo): Record<string, unknown>;
16
+ /** Build schema.org FAQPage JSON-LD, or null when there are no FAQ items. */
17
+ export declare function buildFaqJsonLd(post: BlogPostWithRelations): Record<string, unknown> | null;
18
+ /** Build schema.org BreadcrumbList JSON-LD: Home → Blog → Post. */
19
+ export declare function buildBreadcrumbJsonLd(config: ResolvedBlogConfig, post: BlogPostWithRelations): Record<string, unknown>;
20
+ /** A single sitemap entry (shape matches Next.js MetadataRoute.Sitemap items). */
21
+ export interface SitemapEntry {
22
+ url: string;
23
+ lastModified: string;
24
+ changeFrequency: "always" | "hourly" | "daily" | "weekly" | "monthly" | "yearly" | "never";
25
+ priority: number;
26
+ }
27
+ /**
28
+ * Build sitemap entries for the blog. Host merges these into its root sitemap.
29
+ * Includes the index + each live post (drafts/future-scheduled excluded).
30
+ */
31
+ export declare function getBlogSitemapEntries(config: ResolvedBlogConfig, posts: BlogPostWithRelations[]): SitemapEntry[];
32
+ /** robots.txt rules the host spreads into its root robots config. */
33
+ export interface RobotsRules {
34
+ allow: string[];
35
+ disallow: string[];
36
+ sitemap: string;
37
+ }
38
+ export declare function getBlogRobotsRules(config: ResolvedBlogConfig): RobotsRules;
39
+ /**
40
+ * Build an RSS 2.0 feed XML string for the blog. Descriptions are plain-text
41
+ * excerpts (feeds don't execute MDX components). Only live posts are included.
42
+ */
43
+ export declare function getBlogRssXml(config: ResolvedBlogConfig, posts: BlogPostWithRelations[]): string;
44
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/seo/index.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EACV,UAAU,EACV,qBAAqB,EACrB,kBAAkB,EACnB,MAAM,mBAAmB,CAAC;AAS3B,+BAA+B;AAC/B,wBAAgB,OAAO,CAAC,MAAM,EAAE,kBAAkB,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,CAExE;AAED,uCAAuC;AACvC,wBAAgB,QAAQ,CAAC,MAAM,EAAE,kBAAkB,GAAG,MAAM,CAE3D;AAED,mCAAmC;AACnC,wBAAgB,MAAM,CAAC,MAAM,EAAE,kBAAkB,EAAE,OAAO,EAAE,MAAM,GAAG,MAAM,CAE1E;AAED,qEAAqE;AACrE,wBAAgB,YAAY,CAAC,MAAM,EAAE,kBAAkB,EAAE,IAAI,EAAE,qBAAqB,GAAG,MAAM,CAE5F;AAED,yEAAyE;AACzE,wBAAgB,UAAU,CAAC,MAAM,EAAE,kBAAkB,EAAE,IAAI,EAAE,qBAAqB,GAAG,MAAM,CAE1F;AAED,0EAA0E;AAC1E,wBAAgB,WAAW,CAAC,IAAI,EAAE,qBAAqB,GAAG,OAAO,CAIhE;AAED,uDAAuD;AACvD,wBAAgB,sBAAsB,CACpC,MAAM,EAAE,kBAAkB,EAC1B,IAAI,EAAE,qBAAqB,EAC3B,MAAM,EAAE,UAAU,GACjB,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CA0BzB;AAED,6EAA6E;AAC7E,wBAAgB,cAAc,CAC5B,IAAI,EAAE,qBAAqB,GAC1B,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAWhC;AAED,mEAAmE;AACnE,wBAAgB,qBAAqB,CACnC,MAAM,EAAE,kBAAkB,EAC1B,IAAI,EAAE,qBAAqB,GAC1B,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAezB;AAED,kFAAkF;AAClF,MAAM,WAAW,YAAY;IAC3B,GAAG,EAAE,MAAM,CAAC;IACZ,YAAY,EAAE,MAAM,CAAC;IACrB,eAAe,EACX,QAAQ,GACR,QAAQ,GACR,OAAO,GACP,QAAQ,GACR,SAAS,GACT,QAAQ,GACR,OAAO,CAAC;IACZ,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED;;;GAGG;AACH,wBAAgB,qBAAqB,CACnC,MAAM,EAAE,kBAAkB,EAC1B,KAAK,EAAE,qBAAqB,EAAE,GAC7B,YAAY,EAAE,CAmBhB;AAED,qEAAqE;AACrE,MAAM,WAAW,WAAW;IAC1B,KAAK,EAAE,MAAM,EAAE,CAAC;IAChB,QAAQ,EAAE,MAAM,EAAE,CAAC;IACnB,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,kBAAkB,GAAG,WAAW,CAM1E;AAWD;;;GAGG;AACH,wBAAgB,aAAa,CAC3B,MAAM,EAAE,kBAAkB,EAC1B,KAAK,EAAE,qBAAqB,EAAE,GAC7B,MAAM,CAoCR"}
@@ -0,0 +1,175 @@
1
+ // hazo_blog — SEO emission helpers (GSC/Bing-ready).
2
+ // Pure, server/client-neutral builders: JSON-LD, sitemap entries, robots rules,
3
+ // RSS/Atom feeds, and canonical/OG metadata. No I/O — callers pass data in.
4
+ import { buildExcerpt, mdxToPlainText } from "../lib/text.js";
5
+ const DEFAULT_BASE_PATH = "/blog";
6
+ function basePath(config) {
7
+ return config.basePath || DEFAULT_BASE_PATH;
8
+ }
9
+ /** Absolute URL for a post. */
10
+ export function postUrl(config, slug) {
11
+ return `${config.baseUrl}${basePath(config)}/${slug}`;
12
+ }
13
+ /** Absolute URL for the blog index. */
14
+ export function indexUrl(config) {
15
+ return `${config.baseUrl}${basePath(config)}`;
16
+ }
17
+ /** Absolute URL for a tag page. */
18
+ export function tagUrl(config, tagSlug) {
19
+ return `${config.baseUrl}${basePath(config)}/tag/${tagSlug}`;
20
+ }
21
+ /** Resolve the canonical URL for a post (per-post override wins). */
22
+ export function canonicalFor(config, post) {
23
+ return post.canonical_url || postUrl(config, post.slug);
24
+ }
25
+ /** Resolve the OG image for a post, falling back to the site default. */
26
+ export function ogImageFor(config, post) {
27
+ return post.og_image || post.featured_image || config.defaultOgImage;
28
+ }
29
+ /** A post is indexable only when it is live (published/scheduled-due). */
30
+ export function isIndexable(post) {
31
+ if (post.status === "draft")
32
+ return false;
33
+ if (!post.publish_date)
34
+ return false;
35
+ return new Date(post.publish_date).getTime() <= Date.now();
36
+ }
37
+ /** Build schema.org BlogPosting JSON-LD for a post. */
38
+ export function buildBlogPostingJsonLd(config, post, author) {
39
+ const url = canonicalFor(config, post);
40
+ const text = mdxToPlainText(post.content);
41
+ return {
42
+ "@context": "https://schema.org",
43
+ "@type": "BlogPosting",
44
+ headline: post.meta_title || post.title,
45
+ description: post.meta_description || buildExcerpt(post.content),
46
+ image: ogImageFor(config, post),
47
+ datePublished: post.publish_date ?? post.created_at,
48
+ dateModified: post.updated_at,
49
+ inLanguage: "en",
50
+ ...(post.category ? { articleSection: post.category.name } : {}),
51
+ wordCount: text ? text.split(/\s+/).length : 0,
52
+ author: {
53
+ "@type": "Person",
54
+ name: author.name,
55
+ url: author.url,
56
+ },
57
+ publisher: {
58
+ "@type": "Organization",
59
+ name: config.siteName,
60
+ logo: { "@type": "ImageObject", url: config.logoUrl },
61
+ },
62
+ mainEntityOfPage: { "@type": "WebPage", "@id": url },
63
+ };
64
+ }
65
+ /** Build schema.org FAQPage JSON-LD, or null when there are no FAQ items. */
66
+ export function buildFaqJsonLd(post) {
67
+ if (!post.faq || post.faq.length === 0)
68
+ return null;
69
+ return {
70
+ "@context": "https://schema.org",
71
+ "@type": "FAQPage",
72
+ mainEntity: post.faq.map((item) => ({
73
+ "@type": "Question",
74
+ name: item.question,
75
+ acceptedAnswer: { "@type": "Answer", text: item.answer },
76
+ })),
77
+ };
78
+ }
79
+ /** Build schema.org BreadcrumbList JSON-LD: Home → Blog → Post. */
80
+ export function buildBreadcrumbJsonLd(config, post) {
81
+ return {
82
+ "@context": "https://schema.org",
83
+ "@type": "BreadcrumbList",
84
+ itemListElement: [
85
+ { "@type": "ListItem", position: 1, name: config.siteName, item: config.baseUrl },
86
+ { "@type": "ListItem", position: 2, name: "Blog", item: indexUrl(config) },
87
+ {
88
+ "@type": "ListItem",
89
+ position: 3,
90
+ name: post.title,
91
+ item: canonicalFor(config, post),
92
+ },
93
+ ],
94
+ };
95
+ }
96
+ /**
97
+ * Build sitemap entries for the blog. Host merges these into its root sitemap.
98
+ * Includes the index + each live post (drafts/future-scheduled excluded).
99
+ */
100
+ export function getBlogSitemapEntries(config, posts) {
101
+ const entries = [
102
+ {
103
+ url: indexUrl(config),
104
+ lastModified: new Date().toISOString(),
105
+ changeFrequency: "weekly",
106
+ priority: 0.8,
107
+ },
108
+ ];
109
+ for (const post of posts) {
110
+ if (!isIndexable(post))
111
+ continue;
112
+ entries.push({
113
+ url: postUrl(config, post.slug),
114
+ lastModified: post.updated_at,
115
+ changeFrequency: "weekly",
116
+ priority: 0.7,
117
+ });
118
+ }
119
+ return entries;
120
+ }
121
+ export function getBlogRobotsRules(config) {
122
+ return {
123
+ allow: [basePath(config)],
124
+ disallow: [`${basePath(config)}/admin`],
125
+ sitemap: `${config.baseUrl}/sitemap.xml`,
126
+ };
127
+ }
128
+ function escapeXml(value) {
129
+ return value
130
+ .replace(/&/g, "&amp;")
131
+ .replace(/</g, "&lt;")
132
+ .replace(/>/g, "&gt;")
133
+ .replace(/"/g, "&quot;")
134
+ .replace(/'/g, "&apos;");
135
+ }
136
+ /**
137
+ * Build an RSS 2.0 feed XML string for the blog. Descriptions are plain-text
138
+ * excerpts (feeds don't execute MDX components). Only live posts are included.
139
+ */
140
+ export function getBlogRssXml(config, posts) {
141
+ const items = posts
142
+ .filter(isIndexable)
143
+ .map((post) => {
144
+ const url = canonicalFor(config, post);
145
+ const description = post.meta_description || buildExcerpt(post.content, 300);
146
+ const pubDate = new Date(post.publish_date ?? post.created_at).toUTCString();
147
+ return [
148
+ " <item>",
149
+ ` <title>${escapeXml(post.title)}</title>`,
150
+ ` <link>${escapeXml(url)}</link>`,
151
+ ` <guid isPermaLink="true">${escapeXml(url)}</guid>`,
152
+ ` <pubDate>${pubDate}</pubDate>`,
153
+ post.category ? ` <category>${escapeXml(post.category.name)}</category>` : "",
154
+ ` <description>${escapeXml(description)}</description>`,
155
+ " </item>",
156
+ ]
157
+ .filter(Boolean)
158
+ .join("\n");
159
+ })
160
+ .join("\n");
161
+ return [
162
+ '<?xml version="1.0" encoding="UTF-8"?>',
163
+ '<rss version="2.0">',
164
+ " <channel>",
165
+ ` <title>${escapeXml(config.siteName)} Blog</title>`,
166
+ ` <link>${escapeXml(indexUrl(config))}</link>`,
167
+ ` <description>${escapeXml(config.siteName)} blog</description>`,
168
+ " <language>en</language>",
169
+ items,
170
+ " </channel>",
171
+ "</rss>",
172
+ ]
173
+ .filter(Boolean)
174
+ .join("\n");
175
+ }
@@ -0,0 +1,21 @@
1
+ import type { AuthorInfo, BlogConfig, BlogPost, BlogPostWithRelations, PaginatedPosts, ResolvedBlogConfig, UpsertPostInput } from "../types/index.js";
2
+ import { type BlogRepository, type ListQuery } from "../repository/index.js";
3
+ /** Apply defaults to a host-supplied BlogConfig. */
4
+ export declare function resolveConfig(config: BlogConfig): ResolvedBlogConfig;
5
+ export interface BlogService {
6
+ config: ResolvedBlogConfig;
7
+ /** Build a repository scoped to a request (uses resolveScope when present). */
8
+ repository(req?: Request): Promise<BlogRepository>;
9
+ listPosts(query?: ListQuery, req?: Request): Promise<PaginatedPosts>;
10
+ getPost(slug: string, opts?: {
11
+ includeUnpublished?: boolean;
12
+ }, req?: Request): Promise<BlogPostWithRelations | null>;
13
+ getAllSlugs(includeUnpublished?: boolean, req?: Request): Promise<string[]>;
14
+ upsertBySlug(input: UpsertPostInput, req?: Request): Promise<BlogPost>;
15
+ deletePost(id: string, req?: Request): Promise<void>;
16
+ resolveAuthor(post: BlogPostWithRelations): Promise<AuthorInfo>;
17
+ getRelatedPosts(post: BlogPostWithRelations, limit?: number, req?: Request): Promise<BlogPostWithRelations[]>;
18
+ search(term: string, limit?: number, req?: Request): Promise<BlogPostWithRelations[]>;
19
+ }
20
+ export declare function createBlogService(rawConfig: BlogConfig): BlogService;
21
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +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"}
@@ -0,0 +1,110 @@
1
+ // hazo_blog — service layer. Resolves config defaults, builds a scoped
2
+ // repository per request, and owns business logic (slug/reading-time/publish
3
+ // rules, author resolution, FAQ serialization). Server-only.
4
+ import { createBlogRepository, } from "../repository/index.js";
5
+ import { buildExcerpt, calculateReadingTime, slugify } from "../lib/text.js";
6
+ /** Apply defaults to a host-supplied BlogConfig. */
7
+ export function resolveConfig(config) {
8
+ return {
9
+ ...config,
10
+ basePath: config.basePath?.replace(/\/$/, "") || "/blog",
11
+ revalidateSeconds: config.revalidateSeconds ?? 3600,
12
+ };
13
+ }
14
+ export function createBlogService(rawConfig) {
15
+ const config = resolveConfig(rawConfig);
16
+ async function repository(req) {
17
+ const adapter = await config.getHazoConnect();
18
+ const scope = req && config.resolveScope ? config.resolveScope(req) : null;
19
+ return createBlogRepository(adapter, { scope });
20
+ }
21
+ return {
22
+ config,
23
+ repository,
24
+ async listPosts(query = {}, req) {
25
+ const repo = await repository(req);
26
+ const perPage = Math.max(1, query.perPage ?? 10);
27
+ const page = Math.max(1, query.page ?? 1);
28
+ const { posts, total } = await repo.listPosts({ ...query, page, perPage });
29
+ return {
30
+ posts,
31
+ total,
32
+ page,
33
+ perPage,
34
+ totalPages: Math.max(1, Math.ceil(total / perPage)),
35
+ };
36
+ },
37
+ async getPost(slug, opts = {}, req) {
38
+ const repo = await repository(req);
39
+ return repo.getPostBySlug(slug, opts.includeUnpublished ?? false);
40
+ },
41
+ async getAllSlugs(includeUnpublished = false, req) {
42
+ const repo = await repository(req);
43
+ return repo.getAllSlugs(includeUnpublished);
44
+ },
45
+ async upsertBySlug(input, req) {
46
+ const repo = await repository(req);
47
+ const slug = input.slug?.trim() ? slugify(input.slug) : slugify(input.title);
48
+ const status = input.status ?? "draft";
49
+ // Resolve category from name when an id isn't supplied.
50
+ let categoryId = input.category_id ?? null;
51
+ if (!categoryId && input.category_name) {
52
+ const cat = await repo.getOrCreateCategory({ name: input.category_name });
53
+ categoryId = cat.id;
54
+ }
55
+ // Publish-date rules: published/scheduled must carry a date so the live
56
+ // gate works. Published with no date → now. Scheduled keeps its future
57
+ // date. Draft clears the date.
58
+ let publishDate = input.publish_date ?? null;
59
+ if (status === "published" && !publishDate) {
60
+ publishDate = new Date().toISOString();
61
+ }
62
+ else if (status === "draft") {
63
+ publishDate = null;
64
+ }
65
+ const faq = input.faq ?? [];
66
+ const row = {
67
+ slug,
68
+ title: input.title,
69
+ content: input.content,
70
+ excerpt: buildExcerpt(input.content),
71
+ reading_time: calculateReadingTime(input.content),
72
+ category_id: categoryId,
73
+ author_id: input.author_id ?? null,
74
+ meta_title: input.meta_title ?? "",
75
+ meta_description: input.meta_description ?? buildExcerpt(input.content),
76
+ canonical_url: input.canonical_url ?? null,
77
+ og_image: input.og_image ?? null,
78
+ featured_image: input.featured_image ?? null,
79
+ image_ref: input.image_ref ?? null,
80
+ faq_json: JSON.stringify(faq),
81
+ status,
82
+ publish_date: publishDate,
83
+ };
84
+ return repo.upsertPost(row, input.tags);
85
+ },
86
+ async deletePost(id, req) {
87
+ const repo = await repository(req);
88
+ await repo.deletePost(id);
89
+ },
90
+ async resolveAuthor(post) {
91
+ if (post.author_id && config.getAuthor) {
92
+ try {
93
+ return await config.getAuthor(post.author_id);
94
+ }
95
+ catch {
96
+ return config.author;
97
+ }
98
+ }
99
+ return config.author;
100
+ },
101
+ async getRelatedPosts(post, limit = 3, req) {
102
+ const repo = await repository(req);
103
+ return repo.getRelatedPosts(post, limit);
104
+ },
105
+ async search(term, limit = 20, req) {
106
+ const repo = await repository(req);
107
+ return repo.searchPosts(term, limit);
108
+ },
109
+ };
110
+ }