@karaoke-cms/module-blog 0.9.3 → 0.9.5

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/README.md ADDED
@@ -0,0 +1,85 @@
1
+ # @karaoke-cms/module-blog
2
+
3
+ Blog module for karaoke-cms — paginated post listing, individual post pages, tag filtering, pagination, and an RSS link in the footer nav.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ npm install @karaoke-cms/module-blog
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ```ts
14
+ // karaoke.config.ts
15
+ import { defineConfig } from '@karaoke-cms/astro';
16
+ import { loadEnv } from '@karaoke-cms/astro/env';
17
+ import { blog } from '@karaoke-cms/module-blog';
18
+ import { themeDefault } from '@karaoke-cms/theme-default';
19
+
20
+ const env = loadEnv(new URL('.', import.meta.url));
21
+
22
+ export default defineConfig({
23
+ vault: env.KARAOKE_VAULT,
24
+ theme: themeDefault({
25
+ implements: [blog({ mount: '/blog' })],
26
+ }),
27
+ });
28
+ ```
29
+
30
+ ## Configuration
31
+
32
+ | Option | Type | Default | Description |
33
+ |--------|------|---------|-------------|
34
+ | `mount` | `string` | `'/blog'` | URL prefix for all blog routes |
35
+ | `enabled` | `boolean` | `true` | Set `false` to disable the module entirely |
36
+
37
+ ## Routes
38
+
39
+ All routes are relative to `mount`:
40
+
41
+ | Pattern | Description |
42
+ |---------|-------------|
43
+ | `{mount}` | Paginated post list |
44
+ | `{mount}/[slug]` | Single post page |
45
+ | `{mount}/page/[page]` | Overflow pagination pages |
46
+
47
+ ## Frontmatter
48
+
49
+ ```yaml
50
+ ---
51
+ title: "My Post" # required
52
+ publish: true # required to appear on site
53
+ date: 2026-01-15 # optional — YYYY-MM-DD
54
+ author: "Name" # optional — string or array
55
+ featured_image: "img/hero.jpg" # optional — relative to vault
56
+ description: "..." # optional — OG tags, RSS, AI-enriched
57
+ tags: [writing, tutorial] # optional — powers /tags pages
58
+ reading_time: 5 # optional — AI-enriched, minutes
59
+ related: [slug-a, slug-b] # optional — AI-enriched, slugs
60
+ comments: true # optional — per-post Giscus override
61
+ ---
62
+ ```
63
+
64
+ ## Components
65
+
66
+ The module ships reusable components you can import in your own pages:
67
+
68
+ ```ts
69
+ import FeaturedPost from '@karaoke-cms/module-blog/components/FeaturedPost';
70
+ import RecentPosts from '@karaoke-cms/module-blog/components/RecentPosts';
71
+ import PaginatedPostList from '@karaoke-cms/module-blog/components/PaginatedPostList';
72
+ ```
73
+
74
+ ## Scaffold
75
+
76
+ On first `npm run dev`, the module copies starter page files into your project's `src/pages/{mountDir}/`. You can edit these files to customise the layout without modifying the npm package.
77
+
78
+ ## What's new in 0.9.5
79
+
80
+ - **Full page scaffold** — on first dev run, `list.astro`, `[slug].astro`, and `page/[page].astro` are copied to your `src/pages/blog/` so you can customise them
81
+ - **`FeaturedPost`, `RecentPosts`, `PaginatedPostList` components** exported for use in custom pages
82
+ - **30 demo posts** included in new project vaults so the site looks populated on first visit
83
+ - **Default CSS** (`src/styles/blog.css`) copied on first dev run and auto-imported into `global.css`
84
+ - **`enabled` flag** — `blog({ mount: '/blog', enabled: false })` disables the module without removing it from config
85
+ - **Fixed zod import** — `zod` now correctly declared as a dependency (was missing from lockfile)
package/package.json CHANGED
@@ -1,13 +1,17 @@
1
1
  {
2
2
  "name": "@karaoke-cms/module-blog",
3
3
  "type": "module",
4
- "version": "0.9.3",
4
+ "version": "0.9.5",
5
5
  "description": "Blog module for karaoke-cms — posts, tags, RSS",
6
6
  "main": "./src/index.ts",
7
7
  "exports": {
8
8
  ".": "./src/index.ts",
9
- "./pages/list": "./src/pages/list.astro",
10
- "./pages/post": "./src/pages/post.astro"
9
+ "./schema": "./src/schema.ts",
10
+ "./pages/post": "./src/pages/post.astro",
11
+ "./pages/page": "./src/pages/page/[page].astro",
12
+ "./components/FeaturedPost": "./src/components/FeaturedPost.astro",
13
+ "./components/RecentPosts": "./src/components/RecentPosts.astro",
14
+ "./components/PaginatedPostList": "./src/components/PaginatedPostList.astro"
11
15
  },
12
16
  "files": [
13
17
  "src/"
@@ -18,9 +22,12 @@
18
22
  "blog",
19
23
  "karaoke-cms"
20
24
  ],
25
+ "dependencies": {
26
+ "zod": "^4.0.0"
27
+ },
21
28
  "peerDependencies": {
22
29
  "astro": ">=6.0.0",
23
- "@karaoke-cms/astro": "^0.9.2"
30
+ "@karaoke-cms/astro": "^0.9.5"
24
31
  },
25
32
  "devDependencies": {
26
33
  "@karaoke-cms/astro": "workspace:*",
@@ -0,0 +1,32 @@
1
+ ---
2
+ import type { CollectionEntry } from 'astro:content';
3
+
4
+ interface Props {
5
+ post: CollectionEntry<'blog'>;
6
+ mount: string;
7
+ }
8
+
9
+ const { post, mount } = Astro.props;
10
+ const href = `${mount}/${post.id}`;
11
+ ---
12
+
13
+ <article class="blog-card blog-featured-post">
14
+ {post.data.featured_image && (
15
+ <a href={href} tabindex="-1" aria-hidden="true">
16
+ <img src={post.data.featured_image} alt={post.data.title} class="blog-featured-image" />
17
+ </a>
18
+ )}
19
+ <div class="blog-card-body">
20
+ <h2 class="blog-card-title"><a href={href}>{post.data.title}</a></h2>
21
+ <div class="blog-card-meta">
22
+ {post.data.date && <span>{post.data.date.toISOString().slice(0, 10)}</span>}
23
+ {post.data.reading_time && <span> · {post.data.reading_time} min read</span>}
24
+ </div>
25
+ {post.data.description && <p class="blog-card-description">{post.data.description}</p>}
26
+ {post.data.tags && post.data.tags.length > 0 && (
27
+ <div class="blog-tag-list">
28
+ {post.data.tags.map(tag => <a href={`/tags/${tag}`} class="blog-tag">#{tag}</a>)}
29
+ </div>
30
+ )}
31
+ </div>
32
+ </article>
@@ -0,0 +1,54 @@
1
+ ---
2
+ import type { CollectionEntry } from 'astro:content';
3
+ import FeaturedPost from './FeaturedPost.astro';
4
+
5
+ interface Props {
6
+ posts: CollectionEntry<'blog'>[];
7
+ mount: string;
8
+ currentPage: number;
9
+ totalPages: number;
10
+ /** Show the first post as a featured hero card. Only on page 1. Defaults to true. */
11
+ showFeatured?: boolean;
12
+ }
13
+
14
+ const { posts, mount, currentPage, totalPages, showFeatured = true } = Astro.props;
15
+ const featuredPost = showFeatured && currentPage === 1 ? posts[0] : undefined;
16
+ const listPosts = featuredPost ? posts.slice(1) : posts;
17
+ ---
18
+
19
+ <div class="blog-list">
20
+ {featuredPost && <FeaturedPost post={featuredPost} mount={mount} />}
21
+ {listPosts.map(post => (
22
+ <article class="blog-card">
23
+ {post.data.featured_image && (
24
+ <a href={`${mount}/${post.id}`} tabindex="-1" aria-hidden="true">
25
+ <img src={post.data.featured_image} alt={post.data.title} class="blog-featured-image" />
26
+ </a>
27
+ )}
28
+ <h2 class="blog-card-title">
29
+ <a href={`${mount}/${post.id}`}>{post.data.title}</a>
30
+ </h2>
31
+ <div class="blog-card-meta">
32
+ {post.data.date && <span>{post.data.date.toISOString().slice(0, 10)}</span>}
33
+ {post.data.reading_time && <span> · {post.data.reading_time} min read</span>}
34
+ </div>
35
+ {post.data.description && <p class="blog-card-description">{post.data.description}</p>}
36
+ {post.data.tags && post.data.tags.length > 0 && (
37
+ <div class="blog-tag-list">
38
+ {post.data.tags.map(tag => <a href={`/tags/${tag}`} class="blog-tag">#{tag}</a>)}
39
+ </div>
40
+ )}
41
+ </article>
42
+ ))}
43
+ {totalPages > 1 && (
44
+ <nav class="blog-pagination" aria-label="Blog pagination">
45
+ {currentPage > 1 && (
46
+ <a href={currentPage === 2 ? mount : `${mount}/page/${currentPage - 1}`} class="blog-pagination-prev">← Previous</a>
47
+ )}
48
+ <span class="blog-pagination-current">Page {currentPage} of {totalPages}</span>
49
+ {currentPage < totalPages && (
50
+ <a href={`${mount}/page/${currentPage + 1}`} class="blog-pagination-next">Next →</a>
51
+ )}
52
+ </nav>
53
+ )}
54
+ </div>
@@ -0,0 +1,33 @@
1
+ ---
2
+ import { getCollection } from 'astro:content';
3
+
4
+ interface Props {
5
+ mount: string;
6
+ /** Max posts to show. Defaults to 5. */
7
+ limit?: number;
8
+ /** Post ID to exclude (typically the currently viewed post). */
9
+ exclude?: string;
10
+ }
11
+
12
+ const { mount, limit = 5, exclude } = Astro.props;
13
+ const posts = (await getCollection('blog', ({ data }) => data.publish === true))
14
+ .sort((a, b) => (b.data.date?.valueOf() ?? 0) - (a.data.date?.valueOf() ?? 0))
15
+ .filter(p => p.id !== exclude)
16
+ .slice(0, limit);
17
+ ---
18
+
19
+ {posts.length > 0 && (
20
+ <div class="blog-sidebar-recent">
21
+ <h3>Recent Posts</h3>
22
+ <ul class="blog-list">
23
+ {posts.map(post => (
24
+ <li class="blog-card">
25
+ <a href={`${mount}/${post.id}`} class="blog-card-title">{post.data.title}</a>
26
+ {post.data.date && (
27
+ <span class="blog-card-meta">{post.data.date.toISOString().slice(0, 10)}</span>
28
+ )}
29
+ </li>
30
+ ))}
31
+ </ul>
32
+ </div>
33
+ )}
@@ -3,13 +3,27 @@
3
3
  * Themes implement these classes to style the blog.
4
4
  */
5
5
  export const cssContract = [
6
+ // List / card layout
6
7
  'blog-list',
7
8
  'blog-card',
8
9
  'blog-card-title',
9
10
  'blog-card-meta',
11
+ 'blog-card-description',
12
+ // Featured post
13
+ 'blog-featured-post',
14
+ 'blog-featured-image',
15
+ // Post page
10
16
  'blog-post',
11
17
  'blog-post-title',
12
18
  'blog-post-meta',
19
+ // Tags
13
20
  'blog-tag',
14
21
  'blog-tag-list',
22
+ // Pagination
23
+ 'blog-pagination',
24
+ 'blog-pagination-prev',
25
+ 'blog-pagination-next',
26
+ 'blog-pagination-current',
27
+ // Sidebar
28
+ 'blog-sidebar-recent',
15
29
  ] as const;
package/src/index.ts CHANGED
@@ -1,35 +1,47 @@
1
1
  import { defineModule } from '@karaoke-cms/astro';
2
+ import { fileURLToPath } from 'url';
3
+ import { join } from 'path';
2
4
  import { cssContract } from './css-contract.js';
3
5
 
4
- export { blogSchema } from './schema.js';
5
6
  export { cssContract } from './css-contract.js';
7
+ // blogSchema is exported from '@karaoke-cms/module-blog/schema' to avoid
8
+ // pulling zod into the config-loading context.
9
+
10
+ const _srcDir = fileURLToPath(new URL('.', import.meta.url));
6
11
 
7
12
  /**
8
- * Blog module — posts, listing, and tag pages.
13
+ * Blog module — posts, listing, pagination, and tag pages.
9
14
  *
10
15
  * @example
11
16
  * // karaoke.config.ts
12
17
  * import { blog } from '@karaoke-cms/module-blog';
18
+ * import { themeDefault } from '@karaoke-cms/theme-default';
13
19
  * export default defineConfig({
14
20
  * modules: [blog({ mount: '/blog' })],
15
- * theme: themeDefault({ implements: [blog({ mount: '/blog' })] }),
21
+ * theme: themeDefault(),
16
22
  * });
17
23
  */
18
24
  export const blog = defineModule({
19
25
  id: 'blog',
20
26
  cssContract,
21
27
 
22
- // No default CSS — requires a theme implementation.
23
- // Add defaultCss here once a bare/unstyled default exists.
24
- defaultCss: undefined,
28
+ defaultCssPath: join(_srcDir, 'styles', 'blog.css'),
25
29
 
26
30
  routes: (mount) => [
27
- { pattern: mount || '/', entrypoint: '@karaoke-cms/module-blog/pages/list' },
28
- { pattern: `${mount}/[slug]`, entrypoint: '@karaoke-cms/module-blog/pages/post' },
31
+ { pattern: `${mount}/[slug]`, entrypoint: '@karaoke-cms/module-blog/pages/post' },
32
+ { pattern: `${mount}/page/[page]`, entrypoint: '@karaoke-cms/module-blog/pages/page' },
29
33
  ],
30
34
 
31
35
  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 },
36
+ { id, name: 'Blog', path: mount || '/', section: 'main', weight: 10 },
37
+ { id: `${id}-rss`, name: 'RSS', path: '/rss.xml', section: 'footer', weight: 10 },
34
38
  ],
39
+
40
+ scaffoldPages: (mount) => {
41
+ const mountDir = mount.replace(/^\//, '') || 'blog';
42
+ return [
43
+ { src: join(_srcDir, 'pages', 'post.astro'), dest: `${mountDir}/[slug].astro` },
44
+ { src: join(_srcDir, 'pages', 'page', '[page].astro'), dest: `${mountDir}/page/[page].astro` },
45
+ ];
46
+ },
35
47
  });
@@ -0,0 +1,41 @@
1
+ ---
2
+ import { getCollection } from 'astro:content';
3
+ import DefaultPage from '@karaoke-cms/astro/layouts/DefaultPage.astro';
4
+ import PaginatedPostList from '@karaoke-cms/module-blog/components/PaginatedPostList';
5
+ import RecentPosts from '@karaoke-cms/module-blog/components/RecentPosts';
6
+ import { siteTitle, blogMount } from 'virtual:karaoke-cms/config';
7
+
8
+ const PAGE_SIZE = 10;
9
+
10
+ export async function getStaticPaths() {
11
+ const PAGE_SIZE = 10;
12
+ const allPosts = (await getCollection('blog', ({ data }) => data.publish === true))
13
+ .sort((a, b) => (b.data.date?.valueOf() ?? 0) - (a.data.date?.valueOf() ?? 0));
14
+ const totalPages = Math.max(1, Math.ceil(allPosts.length / PAGE_SIZE));
15
+
16
+ return Array.from({ length: Math.max(0, totalPages - 1) }, (_, i) => {
17
+ const page = i + 2;
18
+ return {
19
+ params: { page: String(page) },
20
+ props: {
21
+ posts: allPosts.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE),
22
+ currentPage: page,
23
+ totalPages,
24
+ },
25
+ };
26
+ });
27
+ }
28
+
29
+ const { posts, currentPage, totalPages } = Astro.props;
30
+ ---
31
+
32
+ <DefaultPage title={`Blog — Page ${currentPage} — ${siteTitle}`}>
33
+ <PaginatedPostList
34
+ posts={posts}
35
+ mount={blogMount}
36
+ currentPage={currentPage}
37
+ totalPages={totalPages}
38
+ showFeatured={false}
39
+ />
40
+ <RecentPosts mount={blogMount} slot="right" />
41
+ </DefaultPage>
@@ -1,10 +1,9 @@
1
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
2
  import { getCollection, render } from 'astro:content';
5
3
  import DefaultPage from '@karaoke-cms/astro/layouts/DefaultPage.astro';
6
4
  import ModuleLoader from '@karaoke-cms/astro/components/ModuleLoader.astro';
7
- import { siteTitle } from 'virtual:karaoke-cms/config';
5
+ import RecentPosts from '@karaoke-cms/module-blog/components/RecentPosts';
6
+ import { siteTitle, blogMount } from 'virtual:karaoke-cms/config';
8
7
 
9
8
  export async function getStaticPaths() {
10
9
  const posts = await getCollection('blog', ({ data }) => data.publish === true);
@@ -31,9 +30,9 @@ const related = relatedIds.length > 0
31
30
 
32
31
  <DefaultPage title={`${entry.data.title} — ${siteTitle}`} description={entry.data.description} type="article">
33
32
  <article>
34
- <div class="post-header">
33
+ <div class="blog-post-title">
35
34
  <h1>{entry.data.title}</h1>
36
- <div class="post-meta">
35
+ <div class="blog-post-meta">
37
36
  {entry.data.date && <span>{entry.data.date.toISOString().slice(0, 10)}</span>}
38
37
  {entry.data.author && entry.data.date && <span> · </span>}
39
38
  {entry.data.author && (
@@ -41,16 +40,19 @@ const related = relatedIds.length > 0
41
40
  )}
42
41
  {entry.data.reading_time && <span> · {entry.data.reading_time} min read</span>}
43
42
  </div>
43
+ {entry.data.tags && entry.data.tags.length > 0 && (
44
+ <div class="blog-tag-list">
45
+ {entry.data.tags.map(tag => <a href={`/tags/${tag}`} class="blog-tag">#{tag}</a>)}
46
+ </div>
47
+ )}
44
48
  </div>
45
- <div class="prose">
49
+ {entry.data.featured_image && (
50
+ <img src={entry.data.featured_image} alt={entry.data.title} class="blog-featured-image" />
51
+ )}
52
+ <div class="prose blog-post">
46
53
  <Content />
47
54
  </div>
48
55
  <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
56
  {related.length > 0 && (
55
57
  <div class="related-posts">
56
58
  <p class="related-label">Related</p>
@@ -61,8 +63,9 @@ const related = relatedIds.length > 0
61
63
  </ul>
62
64
  </div>
63
65
  )}
64
- <a href="/blog">← Blog</a>
66
+ <a href={blogMount}>← Blog</a>
65
67
  </div>
66
68
  </article>
67
69
  <ModuleLoader comments={entry.data.comments} />
70
+ <RecentPosts mount={blogMount} exclude={entry.id} slot="right" />
68
71
  </DefaultPage>
package/src/schema.ts CHANGED
@@ -14,6 +14,8 @@ export const blogSchema = z.object({
14
14
  description: z.string().optional(),
15
15
  reading_time: z.number().optional(),
16
16
  related: z.array(z.string()).optional(),
17
+ // media
18
+ featured_image: z.string().optional(),
17
19
  // per-post comments override
18
20
  comments: z.boolean().optional().default(true),
19
21
  });
@@ -0,0 +1,135 @@
1
+ /* Blog module default styles — copy of this file lives at src/styles/blog.css */
2
+
3
+ .blog-list {
4
+ list-style: none;
5
+ padding: 0;
6
+ margin: 0;
7
+ display: flex;
8
+ flex-direction: column;
9
+ gap: 1.5rem;
10
+ }
11
+
12
+ .blog-card {
13
+ padding: 1rem 0;
14
+ border-bottom: 1px solid var(--color-border, #e5e7eb);
15
+ }
16
+
17
+ .blog-card:last-child {
18
+ border-bottom: none;
19
+ }
20
+
21
+ .blog-card-title {
22
+ font-size: 1.125rem;
23
+ font-weight: 600;
24
+ margin: 0 0 0.25rem;
25
+ }
26
+
27
+ .blog-card-title a {
28
+ text-decoration: none;
29
+ color: inherit;
30
+ }
31
+
32
+ .blog-card-title a:hover {
33
+ text-decoration: underline;
34
+ }
35
+
36
+ .blog-card-meta {
37
+ font-size: 0.875rem;
38
+ opacity: 0.65;
39
+ margin-bottom: 0.5rem;
40
+ }
41
+
42
+ .blog-card-description {
43
+ margin: 0.5rem 0 0;
44
+ font-size: 0.9375rem;
45
+ line-height: 1.6;
46
+ }
47
+
48
+ /* Featured post */
49
+ .blog-featured-post {
50
+ padding: 0 0 1.5rem;
51
+ margin-bottom: 0.5rem;
52
+ border-bottom: 2px solid var(--color-border, #e5e7eb);
53
+ }
54
+
55
+ .blog-featured-image {
56
+ width: 100%;
57
+ height: auto;
58
+ border-radius: 0.5rem;
59
+ display: block;
60
+ margin-bottom: 1rem;
61
+ }
62
+
63
+ /* Post page */
64
+ .blog-post {
65
+ line-height: 1.75;
66
+ }
67
+
68
+ .blog-post-title h1 {
69
+ margin-bottom: 0.5rem;
70
+ }
71
+
72
+ .blog-post-meta {
73
+ font-size: 0.875rem;
74
+ opacity: 0.65;
75
+ margin-bottom: 1.5rem;
76
+ }
77
+
78
+ /* Tags */
79
+ .blog-tag-list {
80
+ display: flex;
81
+ flex-wrap: wrap;
82
+ gap: 0.375rem;
83
+ margin-top: 0.75rem;
84
+ }
85
+
86
+ .blog-tag {
87
+ font-size: 0.75rem;
88
+ padding: 0.125rem 0.5rem;
89
+ border-radius: 999px;
90
+ background: var(--color-tag-bg, #f3f4f6);
91
+ color: var(--color-tag-text, #374151);
92
+ text-decoration: none;
93
+ }
94
+
95
+ .blog-tag:hover {
96
+ background: var(--color-tag-bg-hover, #e5e7eb);
97
+ }
98
+
99
+ /* Pagination */
100
+ .blog-pagination {
101
+ display: flex;
102
+ align-items: center;
103
+ gap: 1rem;
104
+ margin-top: 2rem;
105
+ padding-top: 1rem;
106
+ border-top: 1px solid var(--color-border, #e5e7eb);
107
+ }
108
+
109
+ .blog-pagination-current {
110
+ font-size: 0.875rem;
111
+ opacity: 0.65;
112
+ }
113
+
114
+ /* Sidebar recent posts */
115
+ .blog-sidebar-recent h3 {
116
+ font-size: 0.875rem;
117
+ font-weight: 600;
118
+ text-transform: uppercase;
119
+ letter-spacing: 0.05em;
120
+ opacity: 0.65;
121
+ margin-bottom: 0.75rem;
122
+ }
123
+
124
+ .blog-sidebar-recent .blog-list {
125
+ gap: 0.75rem;
126
+ }
127
+
128
+ .blog-sidebar-recent .blog-card {
129
+ padding: 0;
130
+ border-bottom: none;
131
+ }
132
+
133
+ .blog-sidebar-recent .blog-card-title {
134
+ font-size: 0.9375rem;
135
+ }
@@ -1,33 +0,0 @@
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>