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
|
@@ -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, "&")
|
|
131
|
+
.replace(/</g, "<")
|
|
132
|
+
.replace(/>/g, ">")
|
|
133
|
+
.replace(/"/g, """)
|
|
134
|
+
.replace(/'/g, "'");
|
|
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
|
+
}
|