@karaoke-cms/module-blog 0.9.3

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/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "@karaoke-cms/module-blog",
3
+ "type": "module",
4
+ "version": "0.9.3",
5
+ "description": "Blog module for karaoke-cms — posts, tags, RSS",
6
+ "main": "./src/index.ts",
7
+ "exports": {
8
+ ".": "./src/index.ts",
9
+ "./pages/list": "./src/pages/list.astro",
10
+ "./pages/post": "./src/pages/post.astro"
11
+ },
12
+ "files": [
13
+ "src/"
14
+ ],
15
+ "keywords": [
16
+ "astro",
17
+ "cms",
18
+ "blog",
19
+ "karaoke-cms"
20
+ ],
21
+ "peerDependencies": {
22
+ "astro": ">=6.0.0",
23
+ "@karaoke-cms/astro": "^0.9.2"
24
+ },
25
+ "devDependencies": {
26
+ "@karaoke-cms/astro": "workspace:*",
27
+ "astro": "^6.0.8"
28
+ },
29
+ "scripts": {
30
+ "test": "echo \"Stub — no tests\""
31
+ }
32
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * CSS class names the blog module promises to use in its markup.
3
+ * Themes implement these classes to style the blog.
4
+ */
5
+ export const cssContract = [
6
+ 'blog-list',
7
+ 'blog-card',
8
+ 'blog-card-title',
9
+ 'blog-card-meta',
10
+ 'blog-post',
11
+ 'blog-post-title',
12
+ 'blog-post-meta',
13
+ 'blog-tag',
14
+ 'blog-tag-list',
15
+ ] as const;
package/src/index.ts ADDED
@@ -0,0 +1,35 @@
1
+ import { defineModule } from '@karaoke-cms/astro';
2
+ import { cssContract } from './css-contract.js';
3
+
4
+ export { blogSchema } from './schema.js';
5
+ export { cssContract } from './css-contract.js';
6
+
7
+ /**
8
+ * Blog module — posts, listing, and tag pages.
9
+ *
10
+ * @example
11
+ * // karaoke.config.ts
12
+ * import { blog } from '@karaoke-cms/module-blog';
13
+ * export default defineConfig({
14
+ * modules: [blog({ mount: '/blog' })],
15
+ * theme: themeDefault({ implements: [blog({ mount: '/blog' })] }),
16
+ * });
17
+ */
18
+ export const blog = defineModule({
19
+ id: 'blog',
20
+ cssContract,
21
+
22
+ // No default CSS — requires a theme implementation.
23
+ // Add defaultCss here once a bare/unstyled default exists.
24
+ defaultCss: undefined,
25
+
26
+ routes: (mount) => [
27
+ { pattern: mount || '/', entrypoint: '@karaoke-cms/module-blog/pages/list' },
28
+ { pattern: `${mount}/[slug]`, entrypoint: '@karaoke-cms/module-blog/pages/post' },
29
+ ],
30
+
31
+ menuEntries: (mount, id) => [
32
+ { id, name: 'Blog', path: '/', section: 'main', weight: 10 },
33
+ { id: `${id}-rss`, name: 'RSS', path: '/rss.xml', section: 'footer', weight: 10 },
34
+ ],
35
+ });
@@ -0,0 +1,33 @@
1
+ ---
2
+ // TODO: use mount path from virtual module once mount-aware routing is implemented.
3
+ // For now, mount is assumed to be /blog.
4
+ import { getCollection } from 'astro:content';
5
+ import DefaultPage from '@karaoke-cms/astro/layouts/DefaultPage.astro';
6
+ import { siteTitle } from 'virtual:karaoke-cms/config';
7
+
8
+ const posts = (await getCollection('blog', ({ data }) => data.publish === true))
9
+ .sort((a, b) => (b.data.date?.valueOf() ?? 0) - (a.data.date?.valueOf() ?? 0));
10
+ ---
11
+
12
+ <DefaultPage title={`Blog — ${siteTitle}`}>
13
+ <div class="listing-header">
14
+ <h1>Blog</h1>
15
+ </div>
16
+ {posts.length > 0 ? (
17
+ <ul class="post-list">
18
+ {posts.map(post => (
19
+ <li>
20
+ {post.data.date && (
21
+ <span class="post-date">{post.data.date.toISOString().slice(0, 10)}</span>
22
+ )}
23
+ <a href={`/blog/${post.id}`}>{post.data.title}</a>
24
+ </li>
25
+ ))}
26
+ </ul>
27
+ ) : (
28
+ <div class="empty-state">
29
+ <p>No posts published yet.</p>
30
+ <p>Create a Markdown file in your vault's <code>blog/</code> folder and set <code>publish: true</code> in the frontmatter to make it appear here.</p>
31
+ </div>
32
+ )}
33
+ </DefaultPage>
@@ -0,0 +1,68 @@
1
+ ---
2
+ // TODO: use mount path from virtual module once mount-aware routing is implemented.
3
+ // For now, mount is assumed to be /blog.
4
+ import { getCollection, render } from 'astro:content';
5
+ import DefaultPage from '@karaoke-cms/astro/layouts/DefaultPage.astro';
6
+ import ModuleLoader from '@karaoke-cms/astro/components/ModuleLoader.astro';
7
+ import { siteTitle } from 'virtual:karaoke-cms/config';
8
+
9
+ export async function getStaticPaths() {
10
+ const posts = await getCollection('blog', ({ data }) => data.publish === true);
11
+ return posts.map(entry => ({
12
+ params: { slug: entry.id },
13
+ props: { entry },
14
+ }));
15
+ }
16
+
17
+ const { entry } = Astro.props;
18
+ const { Content } = await render(entry);
19
+
20
+ // Resolve related entries by ID across both collections
21
+ const relatedIds = entry.data.related ?? [];
22
+ const related = relatedIds.length > 0
23
+ ? (await Promise.all([
24
+ getCollection('blog', ({ data }) => data.publish === true),
25
+ getCollection('docs', ({ data }) => data.publish === true),
26
+ ]))
27
+ .flat()
28
+ .filter(e => relatedIds.includes(e.id))
29
+ : [];
30
+ ---
31
+
32
+ <DefaultPage title={`${entry.data.title} — ${siteTitle}`} description={entry.data.description} type="article">
33
+ <article>
34
+ <div class="post-header">
35
+ <h1>{entry.data.title}</h1>
36
+ <div class="post-meta">
37
+ {entry.data.date && <span>{entry.data.date.toISOString().slice(0, 10)}</span>}
38
+ {entry.data.author && entry.data.date && <span> · </span>}
39
+ {entry.data.author && (
40
+ <span>{Array.isArray(entry.data.author) ? entry.data.author.join(' · ') : entry.data.author}</span>
41
+ )}
42
+ {entry.data.reading_time && <span> · {entry.data.reading_time} min read</span>}
43
+ </div>
44
+ </div>
45
+ <div class="prose">
46
+ <Content />
47
+ </div>
48
+ <div class="post-footer">
49
+ {entry.data.tags && entry.data.tags.length > 0 && (
50
+ <div class="post-tags">
51
+ {entry.data.tags.map(tag => <a href={`/tags/${tag}`} class="tag">#{tag}</a>)}
52
+ </div>
53
+ )}
54
+ {related.length > 0 && (
55
+ <div class="related-posts">
56
+ <p class="related-label">Related</p>
57
+ <ul>
58
+ {related.map(r => (
59
+ <li><a href={`/${r.collection}/${r.id}`}>{r.data.title}</a></li>
60
+ ))}
61
+ </ul>
62
+ </div>
63
+ )}
64
+ <a href="/blog">← Blog</a>
65
+ </div>
66
+ </article>
67
+ <ModuleLoader comments={entry.data.comments} />
68
+ </DefaultPage>
package/src/schema.ts ADDED
@@ -0,0 +1,19 @@
1
+ import { z } from 'zod';
2
+
3
+ /**
4
+ * Frontmatter schema for blog posts.
5
+ * All fields except `title` are optional — posts validate before and after AI enrichment.
6
+ */
7
+ export const blogSchema = z.object({
8
+ title: z.string(),
9
+ publish: z.boolean().optional().default(false),
10
+ date: z.coerce.date().optional(),
11
+ author: z.union([z.string(), z.array(z.string())]).optional(),
12
+ // v0.2 AI-enriched fields
13
+ tags: z.array(z.string()).optional(),
14
+ description: z.string().optional(),
15
+ reading_time: z.number().optional(),
16
+ related: z.array(z.string()).optional(),
17
+ // per-post comments override
18
+ comments: z.boolean().optional().default(true),
19
+ });