cosmolo 0.3.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/src/loaders.ts ADDED
@@ -0,0 +1,135 @@
1
+ import { getArticle, getArticles, getArticlesByCategory, getArticlesBySeries, getArticlesByTag, getSlugs, getTags } from './articles.js';
2
+ import { getCategorySlugs, getCategoryLabel, getCategoryDescription } from './categories.js';
3
+ import { getPage, getPageSlugs } from './pages.js';
4
+ import type { Article, ResolvedCosmoloConfig } from './types.js';
5
+
6
+ /**
7
+ * Factory for the article listing page load function.
8
+ *
9
+ * @example
10
+ * // src/routes/+page.server.ts
11
+ * import { createArticlesLoader } from 'cosmolo';
12
+ * import config from '../../cosmolo.config';
13
+ * export const load = createArticlesLoader(config);
14
+ */
15
+ export function createArticlesLoader(config: ResolvedCosmoloConfig) {
16
+ return () => ({ articles: getArticles(config) });
17
+ }
18
+
19
+ /**
20
+ * Factory for a single article page load function.
21
+ * Resolves manual related articles, series prev/next, and git updated date.
22
+ *
23
+ * @example
24
+ * // src/routes/articles/[slug]/+page.server.ts
25
+ * import { createArticleLoader } from 'cosmolo';
26
+ * import config from '../../../../cosmolo.config';
27
+ * export const entries = () => getSlugs(config).map(slug => ({ slug }));
28
+ * export const load = createArticleLoader(config);
29
+ */
30
+ export function createArticleLoader(
31
+ config: ResolvedCosmoloConfig,
32
+ options: { getUpdatedAt?: (slug: string) => string } = {}
33
+ ) {
34
+ return async ({ params }: { params: { slug: string } }) => {
35
+ const article = await getArticle(config, params.slug);
36
+ const updatedAt = options.getUpdatedAt?.(params.slug) ?? '';
37
+
38
+ let related: Article[];
39
+ if (article.related.length > 0) {
40
+ const all = getArticles(config);
41
+ related = article.related
42
+ .map((s) => all.find((a) => a.slug === s))
43
+ .filter((a): a is Article => a !== undefined)
44
+ .slice(0, 4);
45
+ } else {
46
+ related = getArticlesByCategory(config, article.category, params.slug).slice(0, 4);
47
+ }
48
+
49
+ let seriesPrev: Article | null = null;
50
+ let seriesNext: Article | null = null;
51
+ let seriesTotal = 0;
52
+ if (article.series) {
53
+ const seriesArticles = getArticlesBySeries(config, article.series);
54
+ seriesTotal = seriesArticles.length;
55
+ const idx = seriesArticles.findIndex((a) => a.slug === params.slug);
56
+ seriesPrev = idx > 0 ? seriesArticles[idx - 1] : null;
57
+ seriesNext = idx < seriesArticles.length - 1 ? seriesArticles[idx + 1] : null;
58
+ }
59
+
60
+ return { article, related, updatedAt, seriesPrev, seriesNext, seriesTotal };
61
+ };
62
+ }
63
+
64
+ /**
65
+ * Factory for the category listing page load function.
66
+ *
67
+ * @example
68
+ * // src/routes/categories/[slug]/+page.server.ts
69
+ * import { createCategoryLoader } from 'cosmolo';
70
+ * import config from '../../../../cosmolo.config';
71
+ * export const entries = () => getCategorySlugs(config).map(slug => ({ slug }));
72
+ * export const load = createCategoryLoader(config);
73
+ */
74
+ export function createCategoryLoader(config: ResolvedCosmoloConfig) {
75
+ return ({ params }: { params: { slug: string } }) => {
76
+ const { slug } = params;
77
+ return {
78
+ slug,
79
+ label: getCategoryLabel(config, slug),
80
+ description: getCategoryDescription(config, slug),
81
+ articles: getArticlesByCategory(config, slug),
82
+ };
83
+ };
84
+ }
85
+
86
+ /**
87
+ * Factory for the tag listing page load function.
88
+ *
89
+ * @example
90
+ * // src/routes/tags/[tag]/+page.server.ts
91
+ * import { createTagLoader } from 'cosmolo';
92
+ * import config from '../../../../cosmolo.config';
93
+ * export const entries = () => getTags(config).map(tag => ({ tag }));
94
+ * export const load = createTagLoader(config);
95
+ */
96
+ export function createTagLoader(config: ResolvedCosmoloConfig) {
97
+ return ({ params }: { params: { tag: string } }) => ({
98
+ tag: params.tag,
99
+ articles: getArticlesByTag(config, params.tag),
100
+ });
101
+ }
102
+
103
+ /**
104
+ * Factory for the static page load function.
105
+ *
106
+ * @example
107
+ * // src/routes/(pages)/[slug]/+page.server.ts
108
+ * import { createPageLoader } from 'cosmolo';
109
+ * import config from '../../../../cosmolo.config';
110
+ * export const entries = () => getPageSlugs(config).map(slug => ({ slug }));
111
+ * export const load = createPageLoader(config);
112
+ */
113
+ export function createPageLoader(config: ResolvedCosmoloConfig) {
114
+ return async ({ params }: { params: { slug: string } }) => ({
115
+ page: await getPage(config, params.slug),
116
+ });
117
+ }
118
+
119
+ // ─── entries generators ───────────────────────────────────────────────────────
120
+
121
+ export function createArticleEntries(config: ResolvedCosmoloConfig) {
122
+ return () => getSlugs(config).map((slug) => ({ slug }));
123
+ }
124
+
125
+ export function createCategoryEntries(config: ResolvedCosmoloConfig) {
126
+ return () => getCategorySlugs(config).map((slug) => ({ slug }));
127
+ }
128
+
129
+ export function createTagEntries(config: ResolvedCosmoloConfig) {
130
+ return () => getTags(config).map((tag) => ({ tag }));
131
+ }
132
+
133
+ export function createPageEntries(config: ResolvedCosmoloConfig) {
134
+ return () => getPageSlugs(config).map((slug) => ({ slug }));
135
+ }
@@ -0,0 +1,74 @@
1
+ import { marked } from 'marked';
2
+ import type { TocEntry } from './types.js';
3
+
4
+ function slugifyHeading(text: string): string {
5
+ return text
6
+ .toLowerCase()
7
+ .replace(/<[^>]+>/g, '')
8
+ .replace(/[^\w\s-]/g, '')
9
+ .trim()
10
+ .replace(/\s+/g, '-');
11
+ }
12
+
13
+ marked.use({
14
+ extensions: [
15
+ {
16
+ name: 'youtube',
17
+ level: 'block',
18
+ start(src: string) {
19
+ return src.indexOf('::youtube[');
20
+ },
21
+ tokenizer(src: string) {
22
+ const match = /^::youtube\[([^\]]+)\]/.exec(src);
23
+ if (match) return { type: 'youtube', raw: match[0], videoId: match[1].trim() };
24
+ },
25
+ renderer(token) {
26
+ const { videoId } = token as unknown as { videoId: string };
27
+ return `<div class="youtube-embed"><iframe src="https://www.youtube.com/embed/${videoId}" title="YouTube video" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe></div>\n`;
28
+ },
29
+ },
30
+ ],
31
+ });
32
+
33
+ marked.use({
34
+ renderer: {
35
+ link({ href, title, text }: { href: string; title?: string | null; text: string }) {
36
+ const isExternal = /^https?:\/\//.test(href ?? '');
37
+ const rel = isExternal ? ' target="_blank" rel="noopener noreferrer"' : '';
38
+ const titleAttr = title ? ` title="${title}"` : '';
39
+ return `<a href="${href}"${titleAttr}${rel}>${text}</a>`;
40
+ },
41
+ heading({ text, depth }: { text: string; depth: number }) {
42
+ const id = slugifyHeading(text);
43
+ return `<h${depth} id="${id}">${text}</h${depth}>\n`;
44
+ },
45
+ },
46
+ });
47
+
48
+ export async function renderMarkdown(content: string): Promise<string> {
49
+ return marked.parse(content);
50
+ }
51
+
52
+ export function generateToc(content: string): TocEntry[] {
53
+ const entries: TocEntry[] = [];
54
+ const seenIds = new Map<string, number>();
55
+ const headingRegex = /^(#{2,6})\s+(.+?)$/gm;
56
+ let match;
57
+ while ((match = headingRegex.exec(content)) !== null) {
58
+ const level = match[1].length;
59
+ const rawText = match[2].trim();
60
+ const plainText = rawText
61
+ .replace(/\*\*([^*]+)\*\*/g, '$1')
62
+ .replace(/\*([^*]+)\*/g, '$1')
63
+ .replace(/__([^_]+)__/g, '$1')
64
+ .replace(/_([^_]+)_/g, '$1')
65
+ .replace(/`([^`]+)`/g, '$1')
66
+ .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1');
67
+ const baseId = slugifyHeading(plainText);
68
+ const count = seenIds.get(baseId) ?? 0;
69
+ const id = count === 0 ? baseId : `${baseId}-${count}`;
70
+ seenIds.set(baseId, count + 1);
71
+ entries.push({ level, id, text: plainText });
72
+ }
73
+ return entries;
74
+ }
package/src/pages.ts ADDED
@@ -0,0 +1,23 @@
1
+ import matter from 'gray-matter';
2
+ import { renderMarkdown } from './markdown.js';
3
+ import type { Page, ResolvedCosmoloConfig } from './types.js';
4
+ import { rawPageFiles } from 'cosmolo:content';
5
+
6
+ function slugFromPath(filePath: string, dir: string): string {
7
+ const prefix = dir.replace(/^\//, '');
8
+ return filePath.replace(new RegExp(`^/?${prefix}/`), '').replace(/\.md$/, '');
9
+ }
10
+
11
+ export function getPageSlugs(config: ResolvedCosmoloConfig): string[] {
12
+ return Object.keys(rawPageFiles).map((p) => slugFromPath(p, config.pagesDir));
13
+ }
14
+
15
+ export async function getPage(config: ResolvedCosmoloConfig, slug: string): Promise<Page> {
16
+ const dir = config.pagesDir.replace(/^\//, '');
17
+ const filePath = `/${dir}/${slug}.md`;
18
+ const raw = rawPageFiles[filePath];
19
+ if (raw === undefined) throw new Error(`Page not found: ${slug}`);
20
+ const { data, content } = matter(raw);
21
+ const html = await renderMarkdown(content);
22
+ return { slug, title: String(data.title ?? slug), html };
23
+ }
package/src/plugin.ts ADDED
@@ -0,0 +1,67 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import type { Plugin } from 'vite';
4
+ import type { CosmoloConfig } from './types.js';
5
+ import { resolveConfig } from './config.js';
6
+
7
+ const VIRTUAL_ID = 'cosmolo:content';
8
+ const RESOLVED_ID = '\0cosmolo:content';
9
+
10
+ /**
11
+ * Vite plugin that generates the `cosmolo:content` virtual module.
12
+ *
13
+ * Content files are bundled via `import.meta.glob` (evaluated at build time).
14
+ * JSON config files (categories, site config) are inlined as object literals so
15
+ * the runtime code has no `fs` dependency — required for Cloudflare Workers and
16
+ * other serverless runtimes.
17
+ *
18
+ * Usage in vite.config.ts:
19
+ * import { cosmoloPlugin } from 'cosmolo/plugin';
20
+ * plugins: [sveltekit(), cosmoloPlugin({ articlesDir: 'content/articles' })]
21
+ */
22
+ export function cosmoloPlugin(userConfig: CosmoloConfig = {}): Plugin {
23
+ const config = resolveConfig(userConfig);
24
+
25
+ const articlesDir = '/' + config.articlesDir.replace(/^\/|\/$/g, '');
26
+ const pagesDir = '/' + config.pagesDir.replace(/^\/|\/$/g, '');
27
+
28
+ const categoriesAbsPath = path.resolve(process.cwd(), config.categoriesConfigPath);
29
+ const siteConfigAbsPath = path.resolve(process.cwd(), config.siteConfigPath);
30
+
31
+ return {
32
+ name: 'cosmolo',
33
+ resolveId(id) {
34
+ if (id === VIRTUAL_ID) return RESOLVED_ID;
35
+ },
36
+ load(id) {
37
+ if (id !== RESOLVED_ID) return;
38
+
39
+ this.addWatchFile(categoriesAbsPath);
40
+ this.addWatchFile(siteConfigAbsPath);
41
+
42
+ const categoriesData = JSON.parse(fs.readFileSync(categoriesAbsPath, 'utf-8'));
43
+ const siteConfigData = JSON.parse(fs.readFileSync(siteConfigAbsPath, 'utf-8'));
44
+
45
+ return `
46
+ export const rawMdFiles = import.meta.glob(
47
+ '${articlesDir}/*.md',
48
+ { query: '?raw', import: 'default', eager: true }
49
+ );
50
+
51
+ export const svxModules = import.meta.glob(
52
+ '${articlesDir}/*.svx',
53
+ { eager: true }
54
+ );
55
+
56
+ export const rawPageFiles = import.meta.glob(
57
+ '${pagesDir}/*.md',
58
+ { query: '?raw', import: 'default', eager: true }
59
+ );
60
+
61
+ export const categoriesData = ${JSON.stringify(categoriesData)};
62
+
63
+ export const siteConfigData = ${JSON.stringify(siteConfigData)};
64
+ `.trim();
65
+ },
66
+ };
67
+ }
package/src/types.ts ADDED
@@ -0,0 +1,65 @@
1
+ // ─── Package configuration ────────────────────────────────────────────────────
2
+
3
+ export interface CosmoloConfig {
4
+ /** Directory containing article files (.md, .svx). @default 'src/content/articles' */
5
+ articlesDir?: string;
6
+ /** Directory containing static page files (.md). @default 'src/content/pages' */
7
+ pagesDir?: string;
8
+ /** Path to site configuration JSON. @default 'config/site.json' */
9
+ siteConfigPath?: string;
10
+ /** Path to categories configuration JSON. @default 'config/categories.json' */
11
+ categoriesConfigPath?: string;
12
+ }
13
+
14
+ export type ResolvedCosmoloConfig = Required<CosmoloConfig>;
15
+
16
+ // ─── Content types ────────────────────────────────────────────────────────────
17
+
18
+ export interface TocEntry {
19
+ level: number;
20
+ id: string;
21
+ text: string;
22
+ }
23
+
24
+ export interface ArticleFrontmatter {
25
+ title: string;
26
+ category: string;
27
+ excerpt: string;
28
+ sort: number;
29
+ date: string;
30
+ tags: string[];
31
+ series?: string;
32
+ seriesOrder?: number;
33
+ draft: boolean;
34
+ related: string[];
35
+ }
36
+
37
+ export interface Article extends ArticleFrontmatter {
38
+ slug: string;
39
+ html: string;
40
+ markdown: string;
41
+ toc: TocEntry[];
42
+ }
43
+
44
+ export interface Page {
45
+ slug: string;
46
+ title: string;
47
+ html: string;
48
+ }
49
+
50
+ export interface CategoryEntry {
51
+ slug: string;
52
+ label: string;
53
+ description: string;
54
+ }
55
+
56
+ export interface SiteConfig {
57
+ url: string;
58
+ name: string;
59
+ description: string;
60
+ twitterHandle: string;
61
+ fallbackCategoryLabel: string;
62
+ articlesPerPage: number;
63
+ ogImage: { mode: 'static' | 'generated' };
64
+ api: { articleBody: 'html' | 'markdown' | 'plaintext' };
65
+ }
@@ -0,0 +1,8 @@
1
+ declare module 'cosmolo:content' {
2
+ const rawMdFiles: Record<string, string>;
3
+ const svxModules: Record<string, { metadata: Record<string, unknown>; default: unknown }>;
4
+ const rawPageFiles: Record<string, string>;
5
+ const categoriesData: Record<string, { label: string; description: string }>;
6
+ const siteConfigData: import('./types.js').SiteConfig;
7
+ export { rawMdFiles, svxModules, rawPageFiles, categoriesData, siteConfigData };
8
+ }
@@ -0,0 +1,68 @@
1
+ <script lang="ts">
2
+ interface Props {
3
+ total: number;
4
+ perPage: number;
5
+ currentPage: number;
6
+ onPageChange: (page: number) => void;
7
+ }
8
+
9
+ const { total, perPage, currentPage, onPageChange }: Props = $props();
10
+
11
+ const totalPages = $derived(Math.ceil(total / perPage));
12
+
13
+ const pageItems = $derived.by(() => {
14
+ if (totalPages <= 7) {
15
+ return Array.from({ length: totalPages }, (_, i) => i + 1);
16
+ }
17
+ const items: number[] = [];
18
+ const delta = 2;
19
+ const left = currentPage - delta;
20
+ const right = currentPage + delta;
21
+
22
+ items.push(1);
23
+ if (left > 2) items.push(0);
24
+
25
+ for (let p = Math.max(2, left); p <= Math.min(totalPages - 1, right); p++) {
26
+ items.push(p);
27
+ }
28
+
29
+ if (right < totalPages - 1) items.push(0);
30
+ items.push(totalPages);
31
+
32
+ return items;
33
+ });
34
+ </script>
35
+
36
+ {#if totalPages > 1}
37
+ <nav aria-label="Pagination">
38
+ <button
39
+ disabled={currentPage === 1}
40
+ onclick={() => onPageChange(currentPage - 1)}
41
+ aria-label="Previous page"
42
+ >
43
+ &larr;
44
+ </button>
45
+
46
+ {#each pageItems as item}
47
+ {#if item === 0}
48
+ <span aria-hidden="true">&hellip;</span>
49
+ {:else}
50
+ <button
51
+ onclick={() => onPageChange(item)}
52
+ aria-label="Page {item}"
53
+ aria-current={item === currentPage ? 'page' : undefined}
54
+ >
55
+ {item}
56
+ </button>
57
+ {/if}
58
+ {/each}
59
+
60
+ <button
61
+ disabled={currentPage === totalPages}
62
+ onclick={() => onPageChange(currentPage + 1)}
63
+ aria-label="Next page"
64
+ >
65
+ &rarr;
66
+ </button>
67
+ </nav>
68
+ {/if}
@@ -0,0 +1,14 @@
1
+ <script lang="ts">
2
+ import type { PageData } from './$types';
3
+
4
+ const { data }: { data: PageData } = $props();
5
+ </script>
6
+
7
+ <article>
8
+ <div class="container">
9
+ <h1>{data.page.title}</h1>
10
+ <div class="prose">
11
+ {@html data.page.html}
12
+ </div>
13
+ </div>
14
+ </article>
@@ -0,0 +1,84 @@
1
+ <script lang="ts">
2
+ import { getCategoryLabel } from 'cosmolo';
3
+ import Pagination from '$lib/components/Pagination.svelte';
4
+ import config from '../../cosmolo.config';
5
+ import type { PageData } from './$types';
6
+
7
+ const { data }: { data: PageData } = $props();
8
+
9
+ const perPage = 10;
10
+
11
+ let query = $state('');
12
+ let currentPage = $state(1);
13
+
14
+ const filtered = $derived(
15
+ query.trim() === ''
16
+ ? data.articles
17
+ : (() => {
18
+ const q = query.toLowerCase();
19
+ return data.articles.filter(
20
+ (a) =>
21
+ a.title.toLowerCase().includes(q) ||
22
+ a.excerpt.toLowerCase().includes(q) ||
23
+ getCategoryLabel(config, a.category).toLowerCase().includes(q)
24
+ );
25
+ })()
26
+ );
27
+
28
+ $effect(() => {
29
+ // eslint-disable-next-line @typescript-eslint/no-unused-expressions
30
+ query;
31
+ currentPage = 1;
32
+ });
33
+
34
+ const paginated = $derived(filtered.slice((currentPage - 1) * perPage, currentPage * perPage));
35
+ </script>
36
+
37
+ <section>
38
+ <div class="container">
39
+ <h1>Articles</h1>
40
+
41
+ <div>
42
+ <input
43
+ type="search"
44
+ placeholder="Search articles…"
45
+ bind:value={query}
46
+ autocomplete="off"
47
+ />
48
+ {#if query.trim() !== ''}
49
+ <p>{filtered.length} result{filtered.length !== 1 ? 's' : ''} for &ldquo;{query}&rdquo;</p>
50
+ {/if}
51
+ </div>
52
+
53
+ {#if data.articles.length === 0}
54
+ <p>No articles yet.</p>
55
+ {:else if filtered.length === 0}
56
+ <p>No articles matched your search.</p>
57
+ {:else}
58
+ <ul>
59
+ {#each paginated as article}
60
+ <li>
61
+ <a href="/articles/{article.slug}">
62
+ <span>{getCategoryLabel(config, article.category)}</span>
63
+ {#if article.date}
64
+ <time datetime={article.date}>{article.date}</time>
65
+ {/if}
66
+ <h2>{article.title}</h2>
67
+ <p>{article.excerpt}</p>
68
+ </a>
69
+ </li>
70
+ {/each}
71
+ </ul>
72
+
73
+ <Pagination
74
+ total={filtered.length}
75
+ {perPage}
76
+ {currentPage}
77
+ onPageChange={(p) => {
78
+ currentPage = p;
79
+ window.scrollTo({ top: 0, behavior: 'smooth' });
80
+ }}
81
+ />
82
+ {/if}
83
+ </div>
84
+ </section>
@@ -0,0 +1,92 @@
1
+ <script lang="ts">
2
+ import { getCategoryLabel, getSvxComponent } from 'cosmolo';
3
+ import config from '../../../../cosmolo.config';
4
+ import type { PageData } from './$types';
5
+ import type { Component } from 'svelte';
6
+
7
+ const { data }: { data: PageData } = $props();
8
+
9
+ const SvxComponent = $derived(getSvxComponent(config, data.article.slug) as Component | undefined);
10
+
11
+ const hasToc = $derived(data.article.toc.length >= 2);
12
+ </script>
13
+
14
+ <article>
15
+ <header>
16
+ <div>
17
+ <a href="/categories/{data.article.category}">
18
+ {getCategoryLabel(config, data.article.category)}
19
+ </a>
20
+ {#if data.article.date}
21
+ <time datetime={data.article.date}>{data.article.date}</time>
22
+ {/if}
23
+ {#if data.updatedAt && data.updatedAt !== data.article.date}
24
+ <span>Updated: <time datetime={data.updatedAt}>{data.updatedAt}</time></span>
25
+ {/if}
26
+ </div>
27
+ <h1>{data.article.title}</h1>
28
+ <p>{data.article.excerpt}</p>
29
+ {#if data.article.tags.length > 0}
30
+ <div>
31
+ {#each data.article.tags as tag}
32
+ <a href="/tags/{tag}">#{tag}</a>
33
+ {/each}
34
+ </div>
35
+ {/if}
36
+ </header>
37
+
38
+ {#if data.article.series}
39
+ <nav>
40
+ <p>Series: {data.article.series} (Part {data.article.seriesOrder ?? '?'} of {data.seriesTotal})</p>
41
+ <div>
42
+ {#if data.seriesPrev}
43
+ <a href="/articles/{data.seriesPrev.slug}">&larr; {data.seriesPrev.title}</a>
44
+ {/if}
45
+ {#if data.seriesNext}
46
+ <a href="/articles/{data.seriesNext.slug}">{data.seriesNext.title} &rarr;</a>
47
+ {/if}
48
+ </div>
49
+ </nav>
50
+ {/if}
51
+
52
+ {#if hasToc}
53
+ <nav aria-label="Table of contents">
54
+ <p>Contents</p>
55
+ <ol>
56
+ {#each data.article.toc as entry}
57
+ <li style="padding-left: {(entry.level - 2) * 1}rem">
58
+ <a href="#{entry.id}">{entry.text}</a>
59
+ </li>
60
+ {/each}
61
+ </ol>
62
+ </nav>
63
+ {/if}
64
+
65
+ <div class="prose">
66
+ {#if SvxComponent}
67
+ <SvxComponent />
68
+ {:else}
69
+ {@html data.article.html}
70
+ {/if}
71
+ </div>
72
+
73
+ <footer>
74
+ <a href="/">&larr; Back to articles</a>
75
+
76
+ {#if data.related.length > 0}
77
+ <section>
78
+ <h2>Related articles</h2>
79
+ <ul>
80
+ {#each data.related as rel}
81
+ <li>
82
+ <a href="/articles/{rel.slug}">
83
+ <span>{rel.title}</span>
84
+ <span>{rel.excerpt}</span>
85
+ </a>
86
+ </li>
87
+ {/each}
88
+ </ul>
89
+ </section>
90
+ {/if}
91
+ </footer>
92
+ </article>
@@ -0,0 +1,58 @@
1
+ <script lang="ts">
2
+ import Pagination from '$lib/components/Pagination.svelte';
3
+ import type { PageData } from './$types';
4
+
5
+ const { data }: { data: PageData } = $props();
6
+
7
+ const perPage = 10;
8
+ let currentPage = $state(1);
9
+
10
+ $effect(() => {
11
+ // eslint-disable-next-line @typescript-eslint/no-unused-expressions
12
+ data.slug;
13
+ currentPage = 1;
14
+ });
15
+
16
+ const paginated = $derived(
17
+ data.articles.slice((currentPage - 1) * perPage, currentPage * perPage)
18
+ );
19
+ </script>
20
+
21
+ <section>
22
+ <div class="container">
23
+ <header>
24
+ <h1>{data.label}</h1>
25
+ {#if data.description}
26
+ <p>{data.description}</p>
27
+ {/if}
28
+ </header>
29
+
30
+ {#if data.articles.length === 0}
31
+ <p>No articles in this category yet.</p>
32
+ {:else}
33
+ <ul>
34
+ {#each paginated as article}
35
+ <li>
36
+ <a href="/articles/{article.slug}">
37
+ {#if article.date}
38
+ <time datetime={article.date}>{article.date}</time>
39
+ {/if}
40
+ <h2>{article.title}</h2>
41
+ <p>{article.excerpt}</p>
42
+ </a>
43
+ </li>
44
+ {/each}
45
+ </ul>
46
+
47
+ <Pagination
48
+ total={data.articles.length}
49
+ {perPage}
50
+ {currentPage}
51
+ onPageChange={(p) => {
52
+ currentPage = p;
53
+ window.scrollTo({ top: 0, behavior: 'smooth' });
54
+ }}
55
+ />
56
+ {/if}
57
+ </div>
58
+ </section>