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/package.json +28 -0
- package/src/articles.ts +124 -0
- package/src/categories.ts +35 -0
- package/src/cli/generate.ts +190 -0
- package/src/cli/index.ts +22 -0
- package/src/cli/init.ts +173 -0
- package/src/config.ts +12 -0
- package/src/index.ts +34 -0
- package/src/loaders.ts +135 -0
- package/src/markdown.ts +74 -0
- package/src/pages.ts +23 -0
- package/src/plugin.ts +67 -0
- package/src/types.ts +65 -0
- package/src/virtual.d.ts +8 -0
- package/templates/full/lib/components/Pagination.svelte +68 -0
- package/templates/full/routes/(pages)/[slug]/+page.svelte +14 -0
- package/templates/full/routes/+page.svelte +84 -0
- package/templates/full/routes/articles/[slug]/+page.svelte +92 -0
- package/templates/full/routes/categories/[slug]/+page.svelte +58 -0
- package/templates/full/routes/tags/[tag]/+page.svelte +56 -0
- package/templates/shared/cosmolo.config.ts +10 -0
- package/templates/shared/routes/(pages)/[slug]/+page.server.ts +5 -0
- package/templates/shared/routes/+page.server.ts +4 -0
- package/templates/shared/routes/articles/[slug]/+page.server.ts +21 -0
- package/templates/shared/routes/categories/[slug]/+page.server.ts +5 -0
- package/templates/shared/routes/rss.xml/+server.ts +55 -0
- package/templates/shared/routes/sitemap.xml/+server.ts +30 -0
- package/templates/shared/routes/tags/[tag]/+page.server.ts +5 -0
- package/templates/shared/vite.config.ts +8 -0
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
|
+
}
|
package/src/markdown.ts
ADDED
|
@@ -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
|
+
}
|
package/src/virtual.d.ts
ADDED
|
@@ -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
|
+
←
|
|
44
|
+
</button>
|
|
45
|
+
|
|
46
|
+
{#each pageItems as item}
|
|
47
|
+
{#if item === 0}
|
|
48
|
+
<span aria-hidden="true">…</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
|
+
→
|
|
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 “{query}”</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}">← {data.seriesPrev.title}</a>
|
|
44
|
+
{/if}
|
|
45
|
+
{#if data.seriesNext}
|
|
46
|
+
<a href="/articles/{data.seriesNext.slug}">{data.seriesNext.title} →</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="/">← 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>
|