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,52 @@
|
|
|
1
|
+
import type { BlogConfig } from "../types/index.js";
|
|
2
|
+
type Metadata = Record<string, unknown>;
|
|
3
|
+
/** Sealed post page: /blog/[slug] */
|
|
4
|
+
export declare function createBlogPostPage(rawConfig: BlogConfig): {
|
|
5
|
+
default: ({ params, }: {
|
|
6
|
+
params: Promise<{
|
|
7
|
+
slug: string;
|
|
8
|
+
}> | {
|
|
9
|
+
slug: string;
|
|
10
|
+
};
|
|
11
|
+
}) => Promise<import("react/jsx-runtime").JSX.Element>;
|
|
12
|
+
generateMetadata: ({ params, }: {
|
|
13
|
+
params: Promise<{
|
|
14
|
+
slug: string;
|
|
15
|
+
}> | {
|
|
16
|
+
slug: string;
|
|
17
|
+
};
|
|
18
|
+
}) => Promise<Metadata>;
|
|
19
|
+
generateStaticParams: () => Promise<{
|
|
20
|
+
slug: string;
|
|
21
|
+
}[]>;
|
|
22
|
+
revalidate: number;
|
|
23
|
+
dynamicParams: boolean;
|
|
24
|
+
};
|
|
25
|
+
/** Sealed index page: /blog */
|
|
26
|
+
export declare function createBlogIndexPage(rawConfig: BlogConfig): {
|
|
27
|
+
default: ({ searchParams, }: {
|
|
28
|
+
searchParams?: Promise<Record<string, string>> | Record<string, string>;
|
|
29
|
+
}) => Promise<import("react/jsx-runtime").JSX.Element>;
|
|
30
|
+
generateMetadata: () => Promise<Metadata>;
|
|
31
|
+
revalidate: number;
|
|
32
|
+
};
|
|
33
|
+
/** Sealed tag page: /blog/tag/[tag] */
|
|
34
|
+
export declare function createBlogTagPage(rawConfig: BlogConfig): {
|
|
35
|
+
default: ({ params, }: {
|
|
36
|
+
params: Promise<{
|
|
37
|
+
tag: string;
|
|
38
|
+
}> | {
|
|
39
|
+
tag: string;
|
|
40
|
+
};
|
|
41
|
+
}) => Promise<import("react/jsx-runtime").JSX.Element>;
|
|
42
|
+
generateMetadata: ({ params, }: {
|
|
43
|
+
params: Promise<{
|
|
44
|
+
tag: string;
|
|
45
|
+
}> | {
|
|
46
|
+
tag: string;
|
|
47
|
+
};
|
|
48
|
+
}) => Promise<Metadata>;
|
|
49
|
+
revalidate: number;
|
|
50
|
+
};
|
|
51
|
+
export {};
|
|
52
|
+
//# sourceMappingURL=pages.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"pages.d.ts","sourceRoot":"","sources":["../../src/next/pages.tsx"],"names":[],"mappings":"AA+BA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAC;AAGpD,KAAK,QAAQ,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;AAexC,qCAAqC;AACrC,wBAAgB,kBAAkB,CAAC,SAAS,EAAE,UAAU;2BAuCnD;QACD,MAAM,EAAE,OAAO,CAAC;YAAE,IAAI,EAAE,MAAM,CAAA;SAAE,CAAC,GAAG;YAAE,IAAI,EAAE,MAAM,CAAA;SAAE,CAAC;KACtD;oCA9BE;QACD,MAAM,EAAE,OAAO,CAAC;YAAE,IAAI,EAAE,MAAM,CAAA;SAAE,CAAC,GAAG;YAAE,IAAI,EAAE,MAAM,CAAA;SAAE,CAAC;KACtD,KAAG,OAAO,CAAC,QAAQ,CAAC;;;;;;EA8DtB;AAED,+BAA+B;AAC/B,wBAAgB,mBAAmB,CAAC,SAAS,EAAE,UAAU;iCAcpD;QACD,YAAY,CAAC,EAAE,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;KACzE;4BAZkC,OAAO,CAAC,QAAQ,CAAC;;EA8DrD;AAED,uCAAuC;AACvC,wBAAgB,iBAAiB,CAAC,SAAS,EAAE,UAAU;2BAMlD;QACD,MAAM,EAAE,OAAO,CAAC;YAAE,GAAG,EAAE,MAAM,CAAA;SAAE,CAAC,GAAG;YAAE,GAAG,EAAE,MAAM,CAAA;SAAE,CAAC;KACpD;oCAuBE;QACD,MAAM,EAAE,OAAO,CAAC;YAAE,GAAG,EAAE,MAAM,CAAA;SAAE,CAAC,GAAG;YAAE,GAAG,EAAE,MAAM,CAAA;SAAE,CAAC;KACpD,KAAG,OAAO,CAAC,QAAQ,CAAC;;EAStB"}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// hazo_blog — sealed Next.js page factories.
|
|
3
|
+
//
|
|
4
|
+
// The package owns the page implementations + all SEO; the host supplies config
|
|
5
|
+
// in a tiny file, e.g.:
|
|
6
|
+
//
|
|
7
|
+
// // app/blog/[slug]/page.tsx
|
|
8
|
+
// import { createBlogPostPage } from "hazo_blog/next";
|
|
9
|
+
// import { blogConfig } from "@/lib/blog-config";
|
|
10
|
+
// export const { default, generateMetadata, generateStaticParams,
|
|
11
|
+
// revalidate, dynamicParams } = createBlogPostPage(blogConfig);
|
|
12
|
+
import { notFound } from "next/navigation";
|
|
13
|
+
import { createBlogService } from "../service/index.js";
|
|
14
|
+
import { resolveConfig } from "../service/index.js";
|
|
15
|
+
import { buildBlogPostingJsonLd, buildBreadcrumbJsonLd, buildFaqJsonLd, canonicalFor, ogImageFor, } from "../seo/index.js";
|
|
16
|
+
import { extractToc } from "../lib/text.js";
|
|
17
|
+
import { PostHero } from "../components/post-hero.js";
|
|
18
|
+
import { AuthorBio } from "../components/author-bio.js";
|
|
19
|
+
import { FaqSection } from "../components/faq-section.js";
|
|
20
|
+
import { RelatedPosts } from "../components/related-posts.js";
|
|
21
|
+
import { TableOfContents } from "../components/table-of-contents.js";
|
|
22
|
+
import { PostCard } from "../components/post-card.js";
|
|
23
|
+
import { BlogSearch } from "../components/blog-search.js";
|
|
24
|
+
import { BlogPostViewTracker } from "../components/blog-post-view-tracker.js";
|
|
25
|
+
import { BlogContent } from "./blog-content.js";
|
|
26
|
+
function JsonLd({ data }) {
|
|
27
|
+
// Escape `<` (and the line/para separators) so author-supplied strings can't
|
|
28
|
+
// break out of the <script> with `</script>`. The payload is structured JSON,
|
|
29
|
+
// never raw HTML.
|
|
30
|
+
const json = JSON.stringify(data)
|
|
31
|
+
.replace(/</g, "\\u003c")
|
|
32
|
+
.replace(/\u2028/g, "\\u2028")
|
|
33
|
+
.replace(/\u2029/g, "\\u2029");
|
|
34
|
+
return (_jsx("script", { type: "application/ld+json", dangerouslySetInnerHTML: { __html: json } }));
|
|
35
|
+
}
|
|
36
|
+
/** Sealed post page: /blog/[slug] */
|
|
37
|
+
export function createBlogPostPage(rawConfig) {
|
|
38
|
+
const config = resolveConfig(rawConfig);
|
|
39
|
+
const service = createBlogService(config);
|
|
40
|
+
async function generateStaticParams() {
|
|
41
|
+
const slugs = await service.getAllSlugs(false);
|
|
42
|
+
return slugs.map((slug) => ({ slug }));
|
|
43
|
+
}
|
|
44
|
+
async function generateMetadata({ params, }) {
|
|
45
|
+
const { slug } = params instanceof Promise ? await params : params;
|
|
46
|
+
const post = await service.getPost(slug);
|
|
47
|
+
if (!post)
|
|
48
|
+
return { title: "Not found" };
|
|
49
|
+
const canonical = canonicalFor(config, post);
|
|
50
|
+
const image = ogImageFor(config, post);
|
|
51
|
+
const description = post.meta_description || post.excerpt || "";
|
|
52
|
+
return {
|
|
53
|
+
title: post.meta_title || post.title,
|
|
54
|
+
description,
|
|
55
|
+
alternates: { canonical },
|
|
56
|
+
openGraph: {
|
|
57
|
+
type: "article",
|
|
58
|
+
title: post.meta_title || post.title,
|
|
59
|
+
description,
|
|
60
|
+
url: canonical,
|
|
61
|
+
images: [image],
|
|
62
|
+
publishedTime: post.publish_date ?? undefined,
|
|
63
|
+
modifiedTime: post.updated_at,
|
|
64
|
+
},
|
|
65
|
+
twitter: { card: "summary_large_image", title: post.title, images: [image] },
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
async function Page({ params, }) {
|
|
69
|
+
const { slug } = params instanceof Promise ? await params : params;
|
|
70
|
+
const post = await service.getPost(slug);
|
|
71
|
+
if (!post)
|
|
72
|
+
notFound();
|
|
73
|
+
const author = await service.resolveAuthor(post);
|
|
74
|
+
const related = await service.getRelatedPosts(post, 3);
|
|
75
|
+
const toc = extractToc(post.content);
|
|
76
|
+
const faqLd = buildFaqJsonLd(post);
|
|
77
|
+
return (_jsxs("article", { className: "mx-auto max-w-3xl px-4 py-10", children: [_jsx(JsonLd, { data: buildBlogPostingJsonLd(config, post, author) }), _jsx(JsonLd, { data: buildBreadcrumbJsonLd(config, post) }), faqLd && _jsx(JsonLd, { data: faqLd }), _jsx(BlogPostViewTracker, { slug: post.slug, title: post.title }), _jsx(PostHero, { post: post }), toc.length > 2 && _jsx(TableOfContents, { headings: toc, className: "mb-8" }), _jsx(BlogContent, { source: post.content, config: config }), _jsx(AuthorBio, { author: author }), _jsx(FaqSection, { faq: post.faq }), _jsx(RelatedPosts, { posts: related, basePath: config.basePath })] }));
|
|
78
|
+
}
|
|
79
|
+
return {
|
|
80
|
+
default: Page,
|
|
81
|
+
generateMetadata,
|
|
82
|
+
generateStaticParams,
|
|
83
|
+
revalidate: config.revalidateSeconds,
|
|
84
|
+
dynamicParams: true,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
/** Sealed index page: /blog */
|
|
88
|
+
export function createBlogIndexPage(rawConfig) {
|
|
89
|
+
const config = resolveConfig(rawConfig);
|
|
90
|
+
const service = createBlogService(config);
|
|
91
|
+
async function generateMetadata() {
|
|
92
|
+
return {
|
|
93
|
+
title: `Blog | ${config.siteName}`,
|
|
94
|
+
description: `Articles and guides from ${config.siteName}.`,
|
|
95
|
+
alternates: { canonical: `${config.baseUrl}${config.basePath}` },
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
async function Page({ searchParams, }) {
|
|
99
|
+
const sp = searchParams
|
|
100
|
+
? searchParams instanceof Promise
|
|
101
|
+
? await searchParams
|
|
102
|
+
: searchParams
|
|
103
|
+
: {};
|
|
104
|
+
const page = Number(sp.page ?? "1") || 1;
|
|
105
|
+
const { posts, totalPages } = await service.listPosts({ page, perPage: 12 });
|
|
106
|
+
return (_jsxs("div", { className: "mx-auto max-w-6xl px-4 py-10", children: [_jsxs("div", { className: "mb-8 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between", children: [_jsx("h1", { className: "text-3xl font-bold text-foreground", children: "Blog" }), _jsx(BlogSearch, { basePath: config.basePath, className: "sm:w-72" })] }), posts.length === 0 ? (_jsx("p", { className: "text-muted-foreground", children: "No posts yet." })) : (_jsx("div", { className: "grid gap-6 sm:grid-cols-2 lg:grid-cols-3", children: posts.map((p) => (_jsx(PostCard, { post: p, basePath: config.basePath }, p.id))) })), totalPages > 1 && (_jsx("nav", { className: "mt-10 flex justify-center gap-2 text-sm", children: Array.from({ length: totalPages }, (_, i) => i + 1).map((n) => (_jsx("a", { href: `${config.basePath}?page=${n}`, className: n === page
|
|
107
|
+
? "rounded bg-primary px-3 py-1 text-primary-foreground"
|
|
108
|
+
: "rounded border border-border px-3 py-1 hover:bg-accent", children: n }, n))) }))] }));
|
|
109
|
+
}
|
|
110
|
+
return {
|
|
111
|
+
default: Page,
|
|
112
|
+
generateMetadata,
|
|
113
|
+
revalidate: config.revalidateSeconds,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
/** Sealed tag page: /blog/tag/[tag] */
|
|
117
|
+
export function createBlogTagPage(rawConfig) {
|
|
118
|
+
const config = resolveConfig(rawConfig);
|
|
119
|
+
const service = createBlogService(config);
|
|
120
|
+
async function Page({ params, }) {
|
|
121
|
+
const { tag } = params instanceof Promise ? await params : params;
|
|
122
|
+
const { posts } = await service.listPosts({ tagSlug: tag, perPage: 50 });
|
|
123
|
+
return (_jsxs("div", { className: "mx-auto max-w-6xl px-4 py-10", children: [_jsxs("h1", { className: "mb-8 text-3xl font-bold text-foreground", children: ["Tagged: ", _jsx("span", { className: "text-primary", children: tag })] }), posts.length === 0 ? (_jsx("p", { className: "text-muted-foreground", children: "No posts with this tag." })) : (_jsx("div", { className: "grid gap-6 sm:grid-cols-2 lg:grid-cols-3", children: posts.map((p) => (_jsx(PostCard, { post: p, basePath: config.basePath }, p.id))) }))] }));
|
|
124
|
+
}
|
|
125
|
+
async function generateMetadata({ params, }) {
|
|
126
|
+
const { tag } = params instanceof Promise ? await params : params;
|
|
127
|
+
return {
|
|
128
|
+
title: `${tag} | ${config.siteName} Blog`,
|
|
129
|
+
alternates: { canonical: `${config.baseUrl}${config.basePath}/tag/${tag}` },
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
return { default: Page, generateMetadata, revalidate: config.revalidateSeconds };
|
|
133
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { BlogConfig } from "../types/index.js";
|
|
2
|
+
type RouteCtx = {
|
|
3
|
+
params: Promise<Record<string, string>>;
|
|
4
|
+
} | {
|
|
5
|
+
params: Record<string, string>;
|
|
6
|
+
};
|
|
7
|
+
/** Public search: GET ?q=term → { posts }. */
|
|
8
|
+
export declare function createBlogSearchRoute(config: BlogConfig): (req: Request) => Promise<Response>;
|
|
9
|
+
/** Public RSS feed: GET → application/rss+xml. */
|
|
10
|
+
export declare function createBlogFeedRoute(config: BlogConfig): (req: Request) => Promise<Response>;
|
|
11
|
+
/** Standalone sitemap entries: GET → { entries } (host can also merge directly). */
|
|
12
|
+
export declare function createBlogSitemapRoute(config: BlogConfig): (req: Request) => Promise<Response>;
|
|
13
|
+
/**
|
|
14
|
+
* Management API (programmatic, hazo_api keys). Returns { GET, POST, PATCH }.
|
|
15
|
+
* GET — list posts (scope: blog:read)
|
|
16
|
+
* POST — upsert by slug, full record (scope: blog:write)
|
|
17
|
+
* PATCH — partial upsert by slug (scope: blog:write)
|
|
18
|
+
*/
|
|
19
|
+
export declare function createBlogManageRoutes(config: BlogConfig): {
|
|
20
|
+
GET: (req: Request) => Promise<Response>;
|
|
21
|
+
POST: (req: Request) => Promise<Response>;
|
|
22
|
+
PATCH: (req: Request) => Promise<Response>;
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* Admin API (cookie/session, guarded by config.authorize).
|
|
26
|
+
* collection.GET — list posts (incl. drafts)
|
|
27
|
+
* collection.POST — create/upsert by slug
|
|
28
|
+
* item.PUT — update by id (upsert by slug)
|
|
29
|
+
* item.DELETE — delete by id
|
|
30
|
+
*/
|
|
31
|
+
export declare function createBlogAdminRoutes(config: BlogConfig): {
|
|
32
|
+
collection: {
|
|
33
|
+
GET(req: Request): Promise<Response>;
|
|
34
|
+
POST(req: Request): Promise<Response>;
|
|
35
|
+
};
|
|
36
|
+
item: {
|
|
37
|
+
PUT(req: Request, ctx?: RouteCtx): Promise<Response>;
|
|
38
|
+
DELETE(req: Request, ctx?: RouteCtx): Promise<Response>;
|
|
39
|
+
};
|
|
40
|
+
};
|
|
41
|
+
export {};
|
|
42
|
+
//# sourceMappingURL=routes.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"routes.d.ts","sourceRoot":"","sources":["../../src/next/routes.ts"],"names":[],"mappings":"AASA,OAAO,KAAK,EAAE,UAAU,EAAmB,MAAM,mBAAmB,CAAC;AAErE,KAAK,QAAQ,GAAG;IAAE,MAAM,EAAE,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAA;CAAE,GAAG;IAAE,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;CAAE,CAAC;AAYjG,8CAA8C;AAC9C,wBAAgB,qBAAqB,CAAC,MAAM,EAAE,UAAU,IAE5B,KAAK,OAAO,KAAG,OAAO,CAAC,QAAQ,CAAC,CAQ3D;AAED,kDAAkD;AAClD,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,UAAU,IAE1B,KAAK,OAAO,KAAG,OAAO,CAAC,QAAQ,CAAC,CAO3D;AAED,oFAAoF;AACpF,wBAAgB,sBAAsB,CAAC,MAAM,EAAE,UAAU,IAE7B,KAAK,OAAO,KAAG,OAAO,CAAC,QAAQ,CAAC,CAI3D;AAED;;;;;GAKG;AACH,wBAAgB,sBAAsB,CAAC,MAAM,EAAE,UAAU;;;;EAoDxD;AAED;;;;;;GAMG;AACH,wBAAgB,qBAAqB,CAAC,MAAM,EAAE,UAAU;;iBASrC,OAAO,GAAG,OAAO,CAAC,QAAQ,CAAC;kBAS1B,OAAO,GAAG,OAAO,CAAC,QAAQ,CAAC;;;iBAe5B,OAAO,QAAQ,QAAQ,GAAG,OAAO,CAAC,QAAQ,CAAC;oBAaxC,OAAO,QAAQ,QAAQ,GAAG,OAAO,CAAC,QAAQ,CAAC;;EAWhE"}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
// hazo_blog — Next.js route-handler factories the host re-exports.
|
|
2
|
+
//
|
|
3
|
+
// Admin (cookie/session) routes are guarded by config.authorize.
|
|
4
|
+
// Management (programmatic) routes use hazo_api API keys + envelopes.
|
|
5
|
+
// Search / RSS / sitemap are public.
|
|
6
|
+
import { ok, fail, withApiKey, createApiKeyService } from "hazo_api";
|
|
7
|
+
import { createBlogService } from "../service/index.js";
|
|
8
|
+
import { getBlogRssXml, getBlogSitemapEntries } from "../seo/index.js";
|
|
9
|
+
async function readParams(ctx) {
|
|
10
|
+
if (!ctx)
|
|
11
|
+
return {};
|
|
12
|
+
const p = ctx.params;
|
|
13
|
+
return (p instanceof Promise ? await p : p) ?? {};
|
|
14
|
+
}
|
|
15
|
+
function apiKeyService(config) {
|
|
16
|
+
return createApiKeyService({ getHazoConnect: config.getHazoConnect });
|
|
17
|
+
}
|
|
18
|
+
/** Public search: GET ?q=term → { posts }. */
|
|
19
|
+
export function createBlogSearchRoute(config) {
|
|
20
|
+
const service = createBlogService(config);
|
|
21
|
+
return async function GET(req) {
|
|
22
|
+
const url = new URL(req.url);
|
|
23
|
+
const q = url.searchParams.get("q") ?? "";
|
|
24
|
+
const posts = await service.search(q, 20, req);
|
|
25
|
+
return Response.json({
|
|
26
|
+
posts: posts.map((p) => ({ slug: p.slug, title: p.title, excerpt: p.excerpt })),
|
|
27
|
+
});
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
/** Public RSS feed: GET → application/rss+xml. */
|
|
31
|
+
export function createBlogFeedRoute(config) {
|
|
32
|
+
const service = createBlogService(config);
|
|
33
|
+
return async function GET(req) {
|
|
34
|
+
const { posts } = await service.listPosts({ perPage: 50 }, req);
|
|
35
|
+
const xml = getBlogRssXml(service.config, posts);
|
|
36
|
+
return new Response(xml, {
|
|
37
|
+
headers: { "content-type": "application/rss+xml; charset=utf-8" },
|
|
38
|
+
});
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
/** Standalone sitemap entries: GET → { entries } (host can also merge directly). */
|
|
42
|
+
export function createBlogSitemapRoute(config) {
|
|
43
|
+
const service = createBlogService(config);
|
|
44
|
+
return async function GET(req) {
|
|
45
|
+
const { posts } = await service.listPosts({ perPage: 1000 }, req);
|
|
46
|
+
return Response.json({ entries: getBlogSitemapEntries(service.config, posts) });
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Management API (programmatic, hazo_api keys). Returns { GET, POST, PATCH }.
|
|
51
|
+
* GET — list posts (scope: blog:read)
|
|
52
|
+
* POST — upsert by slug, full record (scope: blog:write)
|
|
53
|
+
* PATCH — partial upsert by slug (scope: blog:write)
|
|
54
|
+
*/
|
|
55
|
+
export function createBlogManageRoutes(config) {
|
|
56
|
+
const service = createBlogService(config);
|
|
57
|
+
const keys = apiKeyService(config);
|
|
58
|
+
const GET = withApiKey({ service: keys, require_scopes: ["blog:read"] }, async (req) => {
|
|
59
|
+
const { posts, total } = await service.listPosts({ perPage: 1000, includeUnpublished: true }, req);
|
|
60
|
+
return ok({ posts, total });
|
|
61
|
+
});
|
|
62
|
+
const POST = withApiKey({ service: keys, require_scopes: ["blog:write"] }, async (req) => {
|
|
63
|
+
let body;
|
|
64
|
+
try {
|
|
65
|
+
body = (await req.json());
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return fail("VALIDATION_FAILED", "Invalid JSON body");
|
|
69
|
+
}
|
|
70
|
+
if (!body?.slug && !body?.title) {
|
|
71
|
+
return fail("VALIDATION_FAILED", "slug or title is required");
|
|
72
|
+
}
|
|
73
|
+
const saved = await service.upsertBySlug(body, req);
|
|
74
|
+
return ok({ post: saved }, { status: 201 });
|
|
75
|
+
});
|
|
76
|
+
const PATCH = withApiKey({ service: keys, require_scopes: ["blog:write"] }, async (req) => {
|
|
77
|
+
let body;
|
|
78
|
+
try {
|
|
79
|
+
body = (await req.json());
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
return fail("VALIDATION_FAILED", "Invalid JSON body");
|
|
83
|
+
}
|
|
84
|
+
if (!body?.slug)
|
|
85
|
+
return fail("VALIDATION_FAILED", "slug is required for PATCH");
|
|
86
|
+
const existing = await service.getPost(body.slug, { includeUnpublished: true }, req);
|
|
87
|
+
if (!existing)
|
|
88
|
+
return fail("NOT_FOUND", `No post with slug ${body.slug}`);
|
|
89
|
+
const merged = {
|
|
90
|
+
slug: existing.slug,
|
|
91
|
+
title: body.title ?? existing.title,
|
|
92
|
+
content: body.content ?? existing.content,
|
|
93
|
+
category_id: body.category_id ?? existing.category_id,
|
|
94
|
+
meta_title: body.meta_title ?? existing.meta_title,
|
|
95
|
+
meta_description: body.meta_description ?? existing.meta_description,
|
|
96
|
+
featured_image: body.featured_image ?? existing.featured_image,
|
|
97
|
+
image_ref: body.image_ref ?? existing.image_ref,
|
|
98
|
+
faq: body.faq ?? existing.faq,
|
|
99
|
+
status: body.status ?? existing.status,
|
|
100
|
+
publish_date: body.publish_date ?? existing.publish_date,
|
|
101
|
+
tags: body.tags ?? existing.tags.map((t) => t.name),
|
|
102
|
+
};
|
|
103
|
+
const saved = await service.upsertBySlug(merged, req);
|
|
104
|
+
return ok({ post: saved });
|
|
105
|
+
});
|
|
106
|
+
return { GET, POST, PATCH };
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Admin API (cookie/session, guarded by config.authorize).
|
|
110
|
+
* collection.GET — list posts (incl. drafts)
|
|
111
|
+
* collection.POST — create/upsert by slug
|
|
112
|
+
* item.PUT — update by id (upsert by slug)
|
|
113
|
+
* item.DELETE — delete by id
|
|
114
|
+
*/
|
|
115
|
+
export function createBlogAdminRoutes(config) {
|
|
116
|
+
const service = createBlogService(config);
|
|
117
|
+
async function guard(req) {
|
|
118
|
+
const okAuth = await config.authorize(req);
|
|
119
|
+
return okAuth ? null : fail("FORBIDDEN", "Not authorized", { status: 403 });
|
|
120
|
+
}
|
|
121
|
+
const collection = {
|
|
122
|
+
async GET(req) {
|
|
123
|
+
const denied = await guard(req);
|
|
124
|
+
if (denied)
|
|
125
|
+
return denied;
|
|
126
|
+
const { posts, total } = await service.listPosts({ perPage: 1000, includeUnpublished: true }, req);
|
|
127
|
+
return ok({ posts, total });
|
|
128
|
+
},
|
|
129
|
+
async POST(req) {
|
|
130
|
+
const denied = await guard(req);
|
|
131
|
+
if (denied)
|
|
132
|
+
return denied;
|
|
133
|
+
let body;
|
|
134
|
+
try {
|
|
135
|
+
body = (await req.json());
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
return fail("VALIDATION_FAILED", "Invalid JSON body");
|
|
139
|
+
}
|
|
140
|
+
const saved = await service.upsertBySlug(body, req);
|
|
141
|
+
return ok({ post: saved }, { status: 201 });
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
const item = {
|
|
145
|
+
async PUT(req, ctx) {
|
|
146
|
+
const denied = await guard(req);
|
|
147
|
+
if (denied)
|
|
148
|
+
return denied;
|
|
149
|
+
const { id } = await readParams(ctx);
|
|
150
|
+
let body;
|
|
151
|
+
try {
|
|
152
|
+
body = (await req.json());
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
return fail("VALIDATION_FAILED", "Invalid JSON body");
|
|
156
|
+
}
|
|
157
|
+
const saved = await service.upsertBySlug(body, req);
|
|
158
|
+
return ok({ post: saved, id });
|
|
159
|
+
},
|
|
160
|
+
async DELETE(req, ctx) {
|
|
161
|
+
const denied = await guard(req);
|
|
162
|
+
if (denied)
|
|
163
|
+
return denied;
|
|
164
|
+
const { id } = await readParams(ctx);
|
|
165
|
+
if (!id)
|
|
166
|
+
return fail("VALIDATION_FAILED", "id is required");
|
|
167
|
+
await service.deletePost(id, req);
|
|
168
|
+
return ok({ deleted: id });
|
|
169
|
+
},
|
|
170
|
+
};
|
|
171
|
+
return { collection, item };
|
|
172
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { BlogCategory, BlogPost, BlogPostWithRelations, BlogTag } from "../types/index.js";
|
|
2
|
+
export interface RepositoryOptions {
|
|
3
|
+
/** Tenant scope; when set, all reads/writes are filtered/stamped with it. */
|
|
4
|
+
scope?: string | null;
|
|
5
|
+
}
|
|
6
|
+
export interface ListQuery {
|
|
7
|
+
page?: number;
|
|
8
|
+
perPage?: number;
|
|
9
|
+
categorySlug?: string;
|
|
10
|
+
tagSlug?: string;
|
|
11
|
+
includeUnpublished?: boolean;
|
|
12
|
+
}
|
|
13
|
+
export interface BlogRepository {
|
|
14
|
+
listPosts(query?: ListQuery): Promise<{
|
|
15
|
+
posts: BlogPostWithRelations[];
|
|
16
|
+
total: number;
|
|
17
|
+
}>;
|
|
18
|
+
getPostBySlug(slug: string, includeUnpublished?: boolean): Promise<BlogPostWithRelations | null>;
|
|
19
|
+
getAllSlugs(includeUnpublished?: boolean): Promise<string[]>;
|
|
20
|
+
upsertPost(row: Partial<BlogPost>, tags?: string[]): Promise<BlogPost>;
|
|
21
|
+
deletePost(id: string): Promise<void>;
|
|
22
|
+
setPostTags(postId: string, tagSlugs: string[]): Promise<void>;
|
|
23
|
+
listCategories(): Promise<BlogCategory[]>;
|
|
24
|
+
getOrCreateCategory(input: {
|
|
25
|
+
slug?: string;
|
|
26
|
+
name: string;
|
|
27
|
+
}): Promise<BlogCategory>;
|
|
28
|
+
listTags(): Promise<BlogTag[]>;
|
|
29
|
+
getOrCreateTag(name: string): Promise<BlogTag>;
|
|
30
|
+
getRelatedPosts(post: BlogPostWithRelations, limit?: number): Promise<BlogPostWithRelations[]>;
|
|
31
|
+
searchPosts(term: string, limit?: number): Promise<BlogPostWithRelations[]>;
|
|
32
|
+
}
|
|
33
|
+
export declare function createBlogRepository(adapter: unknown, options?: RepositoryOptions): BlogRepository;
|
|
34
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/repository/index.ts"],"names":[],"mappings":"AAYA,OAAO,KAAK,EACV,YAAY,EACZ,QAAQ,EACR,qBAAqB,EACrB,OAAO,EAER,MAAM,mBAAmB,CAAC;AAe3B,MAAM,WAAW,iBAAiB;IAChC,6EAA6E;IAC7E,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CACvB;AAED,MAAM,WAAW,SAAS;IACxB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,kBAAkB,CAAC,EAAE,OAAO,CAAC;CAC9B;AAED,MAAM,WAAW,cAAc;IAC7B,SAAS,CAAC,KAAK,CAAC,EAAE,SAAS,GAAG,OAAO,CAAC;QAAE,KAAK,EAAE,qBAAqB,EAAE,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IACzF,aAAa,CAAC,IAAI,EAAE,MAAM,EAAE,kBAAkB,CAAC,EAAE,OAAO,GAAG,OAAO,CAAC,qBAAqB,GAAG,IAAI,CAAC,CAAC;IACjG,WAAW,CAAC,kBAAkB,CAAC,EAAE,OAAO,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CAAC;IAC7D,UAAU,CAAC,GAAG,EAAE,OAAO,CAAC,QAAQ,CAAC,EAAE,IAAI,CAAC,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAC;IACvE,UAAU,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACtC,WAAW,CAAC,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC/D,cAAc,IAAI,OAAO,CAAC,YAAY,EAAE,CAAC,CAAC;IAC1C,mBAAmB,CAAC,KAAK,EAAE;QAAE,IAAI,CAAC,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,GAAG,OAAO,CAAC,YAAY,CAAC,CAAC;IACnF,QAAQ,IAAI,OAAO,CAAC,OAAO,EAAE,CAAC,CAAC;IAC/B,cAAc,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IAC/C,eAAe,CAAC,IAAI,EAAE,qBAAqB,EAAE,KAAK,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,qBAAqB,EAAE,CAAC,CAAC;IAC/F,WAAW,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,qBAAqB,EAAE,CAAC,CAAC;CAC7E;AAeD,wBAAgB,oBAAoB,CAClC,OAAO,EAAE,OAAO,EAChB,OAAO,GAAE,iBAAsB,GAC9B,cAAc,CAyRhB"}
|