astro-blog-kit 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.
@@ -0,0 +1,122 @@
1
+ export const prerender = false;
2
+
3
+ import type { APIRoute } from "astro";
4
+
5
+ const WP_API_URL = process.env.WP_API_URL ?? import.meta.env.WP_API_URL;
6
+ const WP_APP_PASSWORD = process.env.WP_APP_PASSWORD ?? import.meta.env.WP_APP_PASSWORD;
7
+ const WP_APP_USER = process.env.WP_APP_USER ?? import.meta.env.WP_APP_USER;
8
+
9
+ export const POST: APIRoute = async ({ request }) => {
10
+ const contentType = request.headers.get("content-type") ?? "";
11
+ if (!contentType.includes("application/json")) {
12
+ return new Response(JSON.stringify({ error: "Invalid content type" }), {
13
+ status: 400,
14
+ headers: { "Content-Type": "application/json" },
15
+ });
16
+ }
17
+
18
+ let body: {
19
+ post: number;
20
+ author_name: string;
21
+ author_email: string;
22
+ content: string;
23
+ parent?: number;
24
+ };
25
+
26
+ try {
27
+ body = await request.json();
28
+ } catch {
29
+ return new Response(JSON.stringify({ error: "Invalid JSON" }), {
30
+ status: 400,
31
+ headers: { "Content-Type": "application/json" },
32
+ });
33
+ }
34
+
35
+ if (!body.post || !body.author_name || !body.author_email || !body.content) {
36
+ return new Response(JSON.stringify({ error: "Missing required fields" }), {
37
+ status: 422,
38
+ headers: { "Content-Type": "application/json" },
39
+ });
40
+ }
41
+
42
+ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
43
+ if (!emailRegex.test(body.author_email)) {
44
+ return new Response(JSON.stringify({ error: "Invalid email" }), {
45
+ status: 422,
46
+ headers: { "Content-Type": "application/json" },
47
+ });
48
+ }
49
+
50
+ const sanitizedContent = body.content
51
+ .replace(/<[^>]*>/g, "")
52
+ .trim()
53
+ .slice(0, 5000);
54
+
55
+ if (sanitizedContent.length === 0) {
56
+ return new Response(JSON.stringify({ error: "Comment content is empty" }), {
57
+ status: 422,
58
+ headers: { "Content-Type": "application/json" },
59
+ });
60
+ }
61
+
62
+ const wpPayload = {
63
+ post: body.post,
64
+ author_name: body.author_name.trim().slice(0, 245),
65
+ author_email: body.author_email.trim(),
66
+ content: sanitizedContent,
67
+ parent: body.parent ?? 0,
68
+ };
69
+
70
+ try {
71
+ const credentials = btoa(`${WP_APP_USER}:${WP_APP_PASSWORD}`);
72
+
73
+ const wpResponse = await fetch(`${WP_API_URL}/wp-json/wp/v2/comments`, {
74
+ method: "POST",
75
+ headers: {
76
+ "Content-Type": "application/json",
77
+ "Authorization": `Basic ${credentials}`,
78
+ },
79
+ body: JSON.stringify(wpPayload),
80
+ });
81
+
82
+ const wpData = await wpResponse.json();
83
+
84
+ if (!wpResponse.ok) {
85
+ console.error("WP API error:", wpData);
86
+ return new Response(
87
+ JSON.stringify({ error: "Failed to submit comment" }),
88
+ {
89
+ status: wpResponse.status,
90
+ headers: { "Content-Type": "application/json" },
91
+ }
92
+ );
93
+ }
94
+
95
+ return new Response(
96
+ JSON.stringify({
97
+ success: true,
98
+ message: "Comment submitted and awaiting moderation",
99
+ id: wpData.id,
100
+ }),
101
+ {
102
+ status: 201,
103
+ headers: { "Content-Type": "application/json" },
104
+ }
105
+ );
106
+ } catch (error) {
107
+ console.error("Proxy error:", error);
108
+ return new Response(
109
+ JSON.stringify({ error: "Internal server error" }),
110
+ {
111
+ status: 500,
112
+ headers: { "Content-Type": "application/json" },
113
+ }
114
+ );
115
+ }
116
+ };
117
+
118
+ export const GET: APIRoute = () =>
119
+ new Response(JSON.stringify({ error: "Method not allowed" }), {
120
+ status: 405,
121
+ headers: { "Content-Type": "application/json" },
122
+ });
@@ -0,0 +1,24 @@
1
+ import { defineBlogConfig } from 'astro-blog-kit';
2
+
3
+ export default defineBlogConfig({
4
+ wpUrl: '__WP_URL__',
5
+ postsPerPage: __POSTS_PER_PAGE__,
6
+ defaultLayout: '__DEFAULT_LAYOUT__',
7
+ locale: '__LOCALE__',
8
+ theme: {
9
+ accent: '#facc15',
10
+ background: '#ffffff',
11
+ surface: '#f8f8f8',
12
+ text: '#0a0a0a',
13
+ muted: '#6b7280',
14
+ mutedLight: '#4b5563',
15
+ border: '#e5e7eb',
16
+ black: '#0a0a0a',
17
+ white: '#ffffff',
18
+ fontHeading: 'Georgia, serif',
19
+ fontBody: 'system-ui, sans-serif',
20
+ fontMono: 'monospace',
21
+ fontDisplay: 'Georgia, serif',
22
+ containerMax: '1200px',
23
+ },
24
+ });
@@ -0,0 +1,41 @@
1
+ ---
2
+ import { BlogList } from "astro-blog-kit/components";
3
+ import { createWPClient } from "astro-blog-kit/utils";
4
+ import config from "../../blog.config";
5
+
6
+ const wp = createWPClient(config.wpUrl);
7
+ const { posts, totalPages } = await wp.getPosts({ perPage: config.postsPerPage ?? 5 });
8
+
9
+ const t = {
10
+ blog: {
11
+ tagline: "Our Blog",
12
+ title_line1: "Latest",
13
+ title_line2: "Articles",
14
+ description: "Welcome to our blog.",
15
+ btncta: "Read more",
16
+ btn_prev: "Previous",
17
+ btn_next: "Next",
18
+ },
19
+ };
20
+ ---
21
+
22
+ <html lang={config.locale ?? "en"}>
23
+ <head>
24
+ <meta charset="UTF-8" />
25
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
26
+ <title>Blog</title>
27
+ </head>
28
+ <body>
29
+ <BlogList
30
+ posts={posts}
31
+ currentPage={1}
32
+ totalPages={totalPages}
33
+ basePath="/blog/page/"
34
+ blogBase="/blog/"
35
+ dateLocale={config.locale ?? "en"}
36
+ t={t}
37
+ locale={config.locale ?? "en"}
38
+ layout={config.defaultLayout ?? "magazine"}
39
+ />
40
+ </body>
41
+ </html>
@@ -0,0 +1,46 @@
1
+ ---
2
+ import { BlogList } from "astro-blog-kit/components";
3
+ import { createWPClient, getStaticPathsForPages } from "astro-blog-kit/utils";
4
+ import config from "../../../../blog.config";
5
+
6
+ export async function getStaticPaths() {
7
+ const wp = createWPClient(config.wpUrl);
8
+ const { posts } = await wp.getPosts({ perPage: 100 });
9
+ return getStaticPathsForPages(posts, { postsPerPage: config.postsPerPage ?? 5 });
10
+ }
11
+
12
+ const { posts, currentPage, totalPages } = Astro.props;
13
+
14
+ const t = {
15
+ blog: {
16
+ tagline: "Our Blog",
17
+ title_line1: "Latest",
18
+ title_line2: "Articles",
19
+ description: "Welcome to our blog.",
20
+ btncta: "Read more",
21
+ btn_prev: "Previous",
22
+ btn_next: "Next",
23
+ },
24
+ };
25
+ ---
26
+
27
+ <html lang={config.locale ?? "en"}>
28
+ <head>
29
+ <meta charset="UTF-8" />
30
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
31
+ <title>Blog — Page {currentPage}</title>
32
+ </head>
33
+ <body>
34
+ <BlogList
35
+ posts={posts}
36
+ currentPage={currentPage}
37
+ totalPages={totalPages}
38
+ basePath="/blog/page/"
39
+ blogBase="/blog/"
40
+ dateLocale={config.locale ?? "en"}
41
+ t={t}
42
+ locale={config.locale ?? "en"}
43
+ layout={config.defaultLayout ?? "magazine"}
44
+ />
45
+ </body>
46
+ </html>
@@ -0,0 +1,41 @@
1
+ ---
2
+ import { BlogPost, Comments, CommentForm } from "astro-blog-kit/components";
3
+ import { createWPClient, getStaticPathsForPosts } from "astro-blog-kit/utils";
4
+ import config from "../../../blog.config";
5
+
6
+ export async function getStaticPaths() {
7
+ const wp = createWPClient(config.wpUrl);
8
+ const posts = await wp.getAllPosts();
9
+ return getStaticPathsForPosts(posts);
10
+ }
11
+
12
+ const { post } = Astro.props;
13
+
14
+ const wp = createWPClient(config.wpUrl);
15
+ const comments = post.id ? await wp.getComments(post.id) : [];
16
+
17
+ const t = {
18
+ blog: {
19
+ tagline: "Our Blog",
20
+ title_line1: "Latest",
21
+ title_line2: "Articles",
22
+ description: "Welcome to our blog.",
23
+ btncta: "Read more",
24
+ btn_prev: "Back to blog",
25
+ btn_next: "Next",
26
+ },
27
+ };
28
+ ---
29
+
30
+ <html lang={config.locale ?? "en"}>
31
+ <head>
32
+ <meta charset="UTF-8" />
33
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
34
+ <title set:html={post.title.rendered} />
35
+ </head>
36
+ <body>
37
+ <BlogPost post={post} t={t} lang={config.locale ?? "en"} />
38
+ <Comments comments={comments} postId={post.id ?? 0} />
39
+ <CommentForm postId={post.id ?? 0} apiRoute="/api/comments" />
40
+ </body>
41
+ </html>
package/types.ts ADDED
@@ -0,0 +1,157 @@
1
+ // ─────────────────────────────────────────────────────────────
2
+ // astro-blog-kit · types.ts
3
+ // ─────────────────────────────────────────────────────────────
4
+
5
+ // ── Post ──────────────────────────────────────────────────────
6
+
7
+ /**
8
+ * Forma normalizada de un post de blog.
9
+ * Compatible con WordPress REST API (_embedded).
10
+ */
11
+ export interface BlogPost {
12
+ id?: number;
13
+ slug: string;
14
+ date: string;
15
+ modified?: string;
16
+ title: {
17
+ rendered: string;
18
+ };
19
+ excerpt: {
20
+ rendered: string;
21
+ };
22
+ content: {
23
+ rendered: string;
24
+ };
25
+ _embedded?: {
26
+ "wp:featuredmedia"?: Array<{
27
+ source_url: string;
28
+ alt_text?: string;
29
+ }>;
30
+ "wp:term"?: {
31
+ id: number;
32
+ name: string;
33
+ slug: string;
34
+ }[][];
35
+ };
36
+ heroImage?: string;
37
+ lang?: string;
38
+ tags?: string[];
39
+ readingTime?: number;
40
+ }
41
+
42
+ // ── Layouts ───────────────────────────────────────────────────
43
+
44
+ export type BlogListLayout = "grid" | "list" | "magazine";
45
+
46
+ // ── i18n ──────────────────────────────────────────────────────
47
+
48
+ export interface I18nConfig {
49
+ locales: string[];
50
+ defaultLocale: string;
51
+ }
52
+
53
+ export interface BlogTranslations {
54
+ blog: {
55
+ tagline: string;
56
+ title_line1: string;
57
+ title_line2: string;
58
+ description: string;
59
+ btncta: string;
60
+ btn_prev: string;
61
+ btn_next: string;
62
+ };
63
+ }
64
+
65
+ // ── Paginación ────────────────────────────────────────────────
66
+
67
+ export interface PaginationProps {
68
+ currentPage: number;
69
+ totalPages: number;
70
+ basePath: string;
71
+ blogBase: string;
72
+ t: BlogTranslations;
73
+ }
74
+
75
+ // ── BlogList ──────────────────────────────────────────────────
76
+
77
+ export interface BlogListProps {
78
+ posts: BlogPost[];
79
+ currentPage: number;
80
+ totalPages: number;
81
+ basePath: string;
82
+ blogBase: string;
83
+ dateLocale: string;
84
+ t: BlogTranslations;
85
+ locale: string;
86
+ /** @default "magazine" */
87
+ layout?: BlogListLayout;
88
+ }
89
+
90
+ // ── BlogPost componente ───────────────────────────────────────
91
+
92
+ export interface BlogPostProps {
93
+ post: BlogPost;
94
+ t: BlogTranslations;
95
+ lang: string;
96
+ }
97
+
98
+ // ── Config del paquete ────────────────────────────────────────
99
+
100
+ export interface BlogTheme {
101
+ /** Color de acento principal. @default "#facc15" */
102
+ accent?: string;
103
+ /** Color de fondo. @default "#ffffff" */
104
+ background?: string;
105
+ /** Color de superficie (cards, sidebars). @default "#f8f8f8" */
106
+ surface?: string;
107
+ /** Color de texto principal. @default "#0a0a0a" */
108
+ text?: string;
109
+ /** Color de texto secundario. @default "#6b7280" */
110
+ muted?: string;
111
+ /** Color de texto secundario claro. @default "#9ca3af" */
112
+ mutedLight?: string;
113
+ /** Color de bordes. @default "#e5e7eb" */
114
+ border?: string;
115
+ /** Color negro para layouts. @default "#0a0a0a" */
116
+ black?: string;
117
+ /** Color blanco para layouts. @default "#ffffff" */
118
+ white?: string;
119
+ /** Fuente de títulos. @default "Georgia, serif" */
120
+ fontHeading?: string;
121
+ /** Fuente de cuerpo. @default "system-ui, sans-serif" */
122
+ fontBody?: string;
123
+ /** Fuente monospace. @default "monospace" */
124
+ fontMono?: string;
125
+ /** Fuente display (títulos grandes). @default "Georgia, serif" */
126
+ fontDisplay?: string;
127
+ /** Ancho máximo del contenedor. @default "1200px" */
128
+ containerMax?: string;
129
+ }
130
+
131
+ export interface BlogKitConfig {
132
+ /** @default 5 */
133
+ postsPerPage?: number;
134
+ i18n?: I18nConfig;
135
+ /** @default "magazine" */
136
+ defaultLayout?: BlogListLayout;
137
+ /** @default "blog" */
138
+ collectionName?: string;
139
+ /** Tema visual del blog */
140
+ theme?: BlogTheme;
141
+ }
142
+
143
+ // ── getStaticPaths ────────────────────────────────────────────
144
+
145
+ export interface PageStaticPath {
146
+ params: { page: string };
147
+ props: {
148
+ posts: BlogPost[];
149
+ currentPage: number;
150
+ totalPages: number;
151
+ };
152
+ }
153
+
154
+ export interface PostStaticPath {
155
+ params: { slug: string };
156
+ props: { post: BlogPost };
157
+ }
@@ -0,0 +1,87 @@
1
+ // ─────────────────────────────────────────────────────────────
2
+ // astro-blog-kit · utils/collections.ts
3
+ // Normalización de datos y helpers de filtrado/ordenamiento.
4
+ // Enfocado en WordPress como fuente principal.
5
+ // ─────────────────────────────────────────────────────────────
6
+
7
+ import type { BlogPost, BlogKitConfig } from "../types";
8
+
9
+ // ── Normalización WordPress → BlogPost ────────────────────────
10
+
11
+ /**
12
+ * Normaliza un post de la WordPress REST API al formato BlogPost.
13
+ *
14
+ * @example
15
+ * const response = await fetch('/wp-json/wp/v2/posts?_embed');
16
+ * const wpPosts = await response.json();
17
+ * const posts = wpPosts.map(normalizeWPPost);
18
+ */
19
+ export function normalizeWPPost(wpPost: Record<string, any>): BlogPost {
20
+ return {
21
+ id: wpPost.id,
22
+ slug: wpPost.slug,
23
+ date: wpPost.date,
24
+ modified: wpPost.modified,
25
+ title: {
26
+ rendered: wpPost.title?.rendered ?? "",
27
+ },
28
+ excerpt: {
29
+ rendered: wpPost.excerpt?.rendered ?? "",
30
+ },
31
+ content: {
32
+ rendered: wpPost.content?.rendered ?? "",
33
+ },
34
+ _embedded: wpPost._embedded,
35
+ lang: wpPost.lang,
36
+ tags: wpPost._embedded?.["wp:term"]?.[0]?.map(
37
+ (t: { name: string }) => t.name
38
+ ),
39
+ };
40
+ }
41
+
42
+ // ── Filtros y ordenamiento ────────────────────────────────────
43
+
44
+ /**
45
+ * Ordena posts por fecha descendente (más reciente primero).
46
+ */
47
+ export function sortPostsByDate(posts: BlogPost[]): BlogPost[] {
48
+ return [...posts].sort(
49
+ (a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()
50
+ );
51
+ }
52
+
53
+ /**
54
+ * Filtra posts por locale.
55
+ * Si el post no tiene lang definido, se incluye en todos los locales.
56
+ *
57
+ * @example
58
+ * filterPostsByLang(posts, 'es') // posts en español + posts sin lang
59
+ */
60
+ export function filterPostsByLang(
61
+ posts: BlogPost[],
62
+ lang: string
63
+ ): BlogPost[] {
64
+ return posts.filter((post) => !post.lang || post.lang === lang);
65
+ }
66
+
67
+ /**
68
+ * Aplica filtros según la config y ordena por fecha:
69
+ * - Filtra por lang si i18n está activo
70
+ * - Ordena por fecha descendente
71
+ *
72
+ * @example
73
+ * const posts = preparePosts(allPosts, 'es', config);
74
+ */
75
+ export function preparePosts(
76
+ posts: BlogPost[],
77
+ lang?: string,
78
+ config?: BlogKitConfig
79
+ ): BlogPost[] {
80
+ let result = [...posts];
81
+
82
+ if (lang && config?.i18n) {
83
+ result = filterPostsByLang(result, lang);
84
+ }
85
+
86
+ return sortPostsByDate(result);
87
+ }
package/utils/i18n.ts ADDED
@@ -0,0 +1,129 @@
1
+ // ─────────────────────────────────────────────────────────────
2
+ // astro-blog-kit · utils/i18n.ts
3
+ // ─────────────────────────────────────────────────────────────
4
+
5
+ import type { BlogKitConfig, BlogTranslations, I18nConfig } from "../types";
6
+
7
+ // ── Helper interno ────────────────────────────────────────────
8
+
9
+ /**
10
+ * Determina si i18n está habilitado en la config del paquete.
11
+ */
12
+ export function isI18nEnabled(
13
+ config?: BlogKitConfig
14
+ ): config is BlogKitConfig & { i18n: I18nConfig } {
15
+ return Array.isArray(config?.i18n?.locales) && config.i18n.locales.length > 0;
16
+ }
17
+
18
+ // ── getLang ───────────────────────────────────────────────────
19
+
20
+ /**
21
+ * Extrae el locale activo de la URL actual.
22
+ * Si i18n no está activo, devuelve el defaultLocale.
23
+ *
24
+ * @example
25
+ * getLang(Astro.url, import.meta.env.BASE_URL, config) // → 'es'
26
+ */
27
+ export function getLang(
28
+ url: URL,
29
+ base: string,
30
+ config?: BlogKitConfig
31
+ ): string {
32
+ const defaultLocale = config?.i18n?.defaultLocale ?? "en";
33
+
34
+ if (!isI18nEnabled(config)) {
35
+ return defaultLocale;
36
+ }
37
+
38
+ const basePath = base.endsWith("/") ? base.slice(0, -1) : base;
39
+ const pathname = url.pathname.replace(basePath, "") || "/";
40
+ const segments = pathname.split("/").filter(Boolean);
41
+ const candidate = segments[0];
42
+
43
+ if (candidate && config.i18n.locales.includes(candidate)) {
44
+ return candidate;
45
+ }
46
+
47
+ return defaultLocale;
48
+ }
49
+
50
+ // ── useTranslations ───────────────────────────────────────────
51
+
52
+ /**
53
+ * Devuelve el objeto de traducciones para el locale dado.
54
+ *
55
+ * @example
56
+ * const t = useTranslations('es', { en, es });
57
+ */
58
+ export function useTranslations<T extends BlogTranslations>(
59
+ lang: string,
60
+ translations: Record<string, T>,
61
+ fallbackLang = "en"
62
+ ): T {
63
+ return (
64
+ translations[lang] ??
65
+ translations[fallbackLang] ??
66
+ Object.values(translations)[0]
67
+ );
68
+ }
69
+
70
+ // ── URL helpers ───────────────────────────────────────────────
71
+
72
+ /**
73
+ * Devuelve la URL base del blog para el locale actual.
74
+ *
75
+ * - Sin i18n: /blog/
76
+ * - Con i18n + default: /blog/
77
+ * - Con i18n + otro lang: /es/blog/
78
+ */
79
+ export function getBlogBase(
80
+ lang: string,
81
+ base: string,
82
+ config?: BlogKitConfig
83
+ ): string {
84
+ const b = base.endsWith("/") ? base : `${base}/`;
85
+
86
+ if (!isI18nEnabled(config)) {
87
+ return `${b}blog/`;
88
+ }
89
+
90
+ const isDefault = lang === config.i18n.defaultLocale;
91
+ return isDefault ? `${b}blog/` : `${b}${lang}/blog/`;
92
+ }
93
+
94
+ /**
95
+ * Devuelve la URL base de paginación para el locale actual.
96
+ *
97
+ * @example
98
+ * getPageBase('es', '/', config) // → '/es/blog/page/'
99
+ */
100
+ export function getPageBase(
101
+ lang: string,
102
+ base: string,
103
+ config?: BlogKitConfig
104
+ ): string {
105
+ return `${getBlogBase(lang, base, config)}page/`;
106
+ }
107
+
108
+ /**
109
+ * Devuelve el locale BCP 47 para toLocaleDateString.
110
+ *
111
+ * @example
112
+ * getDateLocale('es') // → 'es-US'
113
+ */
114
+ export function getDateLocale(
115
+ lang: string,
116
+ map?: Record<string, string>
117
+ ): string {
118
+ const defaults: Record<string, string> = {
119
+ en: "en-US",
120
+ es: "es-US",
121
+ fr: "fr-FR",
122
+ de: "de-DE",
123
+ pt: "pt-BR",
124
+ it: "it-IT",
125
+ };
126
+
127
+ const merged = { ...defaults, ...map };
128
+ return merged[lang] ?? `${lang}-${lang.toUpperCase()}`;
129
+ }
package/utils/index.ts ADDED
@@ -0,0 +1,41 @@
1
+ // ─────────────────────────────────────────────────────────────
2
+ // astro-blog-kit · utils/index.ts
3
+ // Punto de entrada de todas las utilidades.
4
+ // Importa desde: 'astro-blog-kit/utils'
5
+ // ─────────────────────────────────────────────────────────────
6
+
7
+ export {
8
+ isI18nEnabled,
9
+ getLang,
10
+ useTranslations,
11
+ getBlogBase,
12
+ getPageBase,
13
+ getDateLocale,
14
+ } from "./i18n";
15
+
16
+ export {
17
+ getStaticPathsForPosts,
18
+ getStaticPathsForPages,
19
+ toSlug,
20
+ estimateReadingTime,
21
+ getFeaturedImageUrl,
22
+ } from "./slug";
23
+
24
+ export {
25
+ normalizeWPPost,
26
+ sortPostsByDate,
27
+ filterPostsByLang,
28
+ preparePosts,
29
+ } from "./collections";
30
+
31
+ export {
32
+ createWPClient,
33
+ } from "./wordpress";
34
+
35
+ export type {
36
+ WPComment,
37
+ WPCategory,
38
+ WPTag,
39
+ FetchPostsOptions,
40
+ SubmitCommentPayload,
41
+ } from "./wordpress";