@karaoke-cms/astro 0.6.3 → 0.9.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/README.md +94 -0
- package/client.d.ts +3 -1
- package/package.json +8 -12
- package/src/blog-schema.ts +13 -0
- package/src/collections.ts +19 -4
- package/src/components/Menu.astro +71 -0
- package/src/components/MenuItems.astro +28 -0
- package/src/components/regions/MainMenu.astro +2 -20
- package/src/components/regions/SiteFooter.astro +4 -4
- package/src/index.ts +74 -40
- package/src/layouts/Base.astro +8 -7
- package/src/types.ts +34 -0
- package/src/utils/resolve-menus.ts +95 -0
- package/src/validate-config.js +0 -33
- package/src/pages/404.astro +0 -14
- package/src/pages/blog/[slug].astro +0 -66
- package/src/pages/blog/index.astro +0 -31
- package/src/pages/docs/[slug].astro +0 -66
- package/src/pages/docs/index.astro +0 -31
- package/src/pages/index.astro +0 -65
- package/src/pages/tags/[tag].astro +0 -53
- package/src/pages/tags/index.astro +0 -41
- package/src/themes/default/styles.css +0 -446
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { readFileSync } from 'fs';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
import { parse } from 'yaml';
|
|
4
|
+
import type { MenuConfig, MenuEntryConfig, ResolvedMenuEntry, ResolvedMenu, ResolvedMenus } from '../types.js';
|
|
5
|
+
|
|
6
|
+
/** Reject javascript: and data: hrefs to prevent stored XSS via menus.yaml. */
|
|
7
|
+
function sanitizeHref(href: string): string | undefined {
|
|
8
|
+
const lower = href.trim().toLowerCase();
|
|
9
|
+
if (lower.startsWith('javascript:') || lower.startsWith('data:')) return undefined;
|
|
10
|
+
return href;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function normalizeEntries(raw: MenuEntryConfig[], depth = 0): ResolvedMenuEntry[] {
|
|
14
|
+
// Guard against YAML anchor cycles (e.g., entries: &loop ... entries: *loop).
|
|
15
|
+
if (depth > 10) return [];
|
|
16
|
+
return (raw ?? [])
|
|
17
|
+
.map(e => ({
|
|
18
|
+
text: String(e?.text ?? ''),
|
|
19
|
+
href: typeof e?.href === 'string' ? sanitizeHref(e.href) : undefined,
|
|
20
|
+
weight: typeof e?.weight === 'number' ? e.weight : 0,
|
|
21
|
+
when: typeof e?.when === 'string' ? e.when : undefined,
|
|
22
|
+
entries: Array.isArray(e?.entries) ? normalizeEntries(e.entries, depth + 1) : [],
|
|
23
|
+
}))
|
|
24
|
+
.sort((a, b) => a.weight - b.weight);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function defaultMain(): ResolvedMenu {
|
|
28
|
+
return {
|
|
29
|
+
name: 'main',
|
|
30
|
+
orientation: 'horizontal',
|
|
31
|
+
entries: [
|
|
32
|
+
{ text: 'Blog', href: '/blog', weight: 10, when: 'collection:blog', entries: [] },
|
|
33
|
+
{ text: 'Docs', href: '/docs', weight: 20, when: 'collection:docs', entries: [] },
|
|
34
|
+
{ text: 'Tags', href: '/tags', weight: 30, entries: [] },
|
|
35
|
+
],
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function defaultFooter(): ResolvedMenu {
|
|
40
|
+
return {
|
|
41
|
+
name: 'footer',
|
|
42
|
+
orientation: 'horizontal',
|
|
43
|
+
entries: [
|
|
44
|
+
{ text: 'RSS', href: '/rss.xml', weight: 10, entries: [] },
|
|
45
|
+
],
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Resolve menus from {vault}/karaoke-cms/config/menus.yaml.
|
|
51
|
+
*
|
|
52
|
+
* Always ensures `main` and `footer` exist in the result — generating defaults
|
|
53
|
+
* when absent so that MainMenu.astro and SiteFooter.astro always have something
|
|
54
|
+
* to render. ENOENT (no menus.yaml) is the normal zero-config case; all other
|
|
55
|
+
* errors warn and fall back to defaults.
|
|
56
|
+
*/
|
|
57
|
+
export function resolveMenus(vaultDir: string): ResolvedMenus {
|
|
58
|
+
try {
|
|
59
|
+
const path = join(vaultDir, 'karaoke-cms/config/menus.yaml');
|
|
60
|
+
const raw = readFileSync(path, 'utf8');
|
|
61
|
+
const parsed = parse(raw) as { menus?: Record<string, MenuConfig> };
|
|
62
|
+
|
|
63
|
+
if (!parsed?.menus || typeof parsed.menus !== 'object') {
|
|
64
|
+
return { main: defaultMain(), footer: defaultFooter() };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const result: ResolvedMenus = {};
|
|
68
|
+
for (const [name, cfg] of Object.entries(parsed.menus)) {
|
|
69
|
+
if (
|
|
70
|
+
cfg?.orientation !== undefined &&
|
|
71
|
+
cfg.orientation !== 'horizontal' &&
|
|
72
|
+
cfg.orientation !== 'vertical'
|
|
73
|
+
) {
|
|
74
|
+
console.warn(
|
|
75
|
+
`[karaoke-cms] menus.yaml: menu "${name}" has invalid orientation "${cfg.orientation}". Using "horizontal".`,
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
result[name] = {
|
|
79
|
+
name,
|
|
80
|
+
orientation: cfg?.orientation === 'vertical' ? 'vertical' : 'horizontal',
|
|
81
|
+
entries: normalizeEntries(cfg?.entries ?? []),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
if (!result.main) result.main = defaultMain();
|
|
85
|
+
if (!result.footer) result.footer = defaultFooter();
|
|
86
|
+
return result;
|
|
87
|
+
} catch (err: unknown) {
|
|
88
|
+
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
|
|
89
|
+
console.warn(
|
|
90
|
+
`[karaoke-cms] Failed to parse menus.yaml: ${(err as Error).message}. Using default menus.`,
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
return { main: defaultMain(), footer: defaultFooter() };
|
|
94
|
+
}
|
|
95
|
+
}
|
package/src/validate-config.js
CHANGED
|
@@ -4,18 +4,6 @@
|
|
|
4
4
|
* Extracted here so the logic is unit-testable separately from astro.config.mjs.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import { readdirSync, statSync } from 'fs'
|
|
8
|
-
import { join } from 'path'
|
|
9
|
-
|
|
10
|
-
/**
|
|
11
|
-
* Resolve the active theme name from karaoke.config.
|
|
12
|
-
* Falls back to 'default' if config is absent.
|
|
13
|
-
* Throws a clear error if the named theme folder doesn't exist.
|
|
14
|
-
*
|
|
15
|
-
* @param {import('../karaoke.config').KaraokeConfig | null | undefined} config
|
|
16
|
-
* @param {string} themesDir - absolute path to the src/themes directory
|
|
17
|
-
* @returns {string} the resolved theme name
|
|
18
|
-
*/
|
|
19
7
|
/**
|
|
20
8
|
* Validate module config at build time.
|
|
21
9
|
* Throws a clear error if comments is enabled but required fields are missing.
|
|
@@ -36,24 +24,3 @@ export function validateModules(config) {
|
|
|
36
24
|
}
|
|
37
25
|
}
|
|
38
26
|
|
|
39
|
-
export function getTheme(config, themesDir) {
|
|
40
|
-
const theme = config?.theme ?? 'default'
|
|
41
|
-
let available
|
|
42
|
-
try {
|
|
43
|
-
available = readdirSync(themesDir)
|
|
44
|
-
} catch {
|
|
45
|
-
throw new Error(
|
|
46
|
-
`karaoke-cms: src/themes/ directory not found at ${themesDir} — is this a karaoke-cms repo?`
|
|
47
|
-
)
|
|
48
|
-
}
|
|
49
|
-
available = available.filter(d => {
|
|
50
|
-
try { return statSync(join(themesDir, d)).isDirectory() } catch { return false }
|
|
51
|
-
})
|
|
52
|
-
if (!available.includes(theme)) {
|
|
53
|
-
throw new Error(
|
|
54
|
-
`karaoke-cms: theme "${theme}" not found in src/themes/. ` +
|
|
55
|
-
`Available: ${available.join(', ')}`
|
|
56
|
-
)
|
|
57
|
-
}
|
|
58
|
-
return theme
|
|
59
|
-
}
|
package/src/pages/404.astro
DELETED
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
import Base from '../layouts/Base.astro';
|
|
3
|
-
import { SITE_TITLE } from '../consts';
|
|
4
|
-
---
|
|
5
|
-
|
|
6
|
-
<Base title={`Page not found — ${SITE_TITLE}`}>
|
|
7
|
-
<div class="post-header">
|
|
8
|
-
<h1>Page not found</h1>
|
|
9
|
-
<p class="post-meta">The page you're looking for doesn't exist or hasn't been published.</p>
|
|
10
|
-
</div>
|
|
11
|
-
<div class="prose">
|
|
12
|
-
<p><a href="/">Go home →</a></p>
|
|
13
|
-
</div>
|
|
14
|
-
</Base>
|
|
@@ -1,66 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
import { getCollection, render } from 'astro:content';
|
|
3
|
-
import Base from '../../layouts/Base.astro';
|
|
4
|
-
import ModuleLoader from '../../components/ModuleLoader.astro';
|
|
5
|
-
import { SITE_TITLE } from '../../consts';
|
|
6
|
-
|
|
7
|
-
export async function getStaticPaths() {
|
|
8
|
-
const posts = await getCollection('blog', ({ data }) => data.publish === true);
|
|
9
|
-
return posts.map(entry => ({
|
|
10
|
-
params: { slug: entry.id },
|
|
11
|
-
props: { entry },
|
|
12
|
-
}));
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
const { entry } = Astro.props;
|
|
16
|
-
const { Content } = await render(entry);
|
|
17
|
-
|
|
18
|
-
// Resolve related entries by ID across both collections
|
|
19
|
-
const relatedIds = entry.data.related ?? [];
|
|
20
|
-
const related = relatedIds.length > 0
|
|
21
|
-
? (await Promise.all([
|
|
22
|
-
getCollection('blog', ({ data }) => data.publish === true),
|
|
23
|
-
getCollection('docs', ({ data }) => data.publish === true),
|
|
24
|
-
]))
|
|
25
|
-
.flat()
|
|
26
|
-
.filter(e => relatedIds.includes(e.id))
|
|
27
|
-
: [];
|
|
28
|
-
---
|
|
29
|
-
|
|
30
|
-
<Base title={`${entry.data.title} — ${SITE_TITLE}`} description={entry.data.description} type="article">
|
|
31
|
-
<article>
|
|
32
|
-
<div class="post-header">
|
|
33
|
-
<h1>{entry.data.title}</h1>
|
|
34
|
-
<div class="post-meta">
|
|
35
|
-
{entry.data.date && <span>{entry.data.date.toISOString().slice(0, 10)}</span>}
|
|
36
|
-
{entry.data.author && entry.data.date && <span> · </span>}
|
|
37
|
-
{entry.data.author && (
|
|
38
|
-
<span>{Array.isArray(entry.data.author) ? entry.data.author.join(' · ') : entry.data.author}</span>
|
|
39
|
-
)}
|
|
40
|
-
{entry.data.reading_time && <span> · {entry.data.reading_time} min read</span>}
|
|
41
|
-
</div>
|
|
42
|
-
</div>
|
|
43
|
-
<div class="prose">
|
|
44
|
-
<Content />
|
|
45
|
-
</div>
|
|
46
|
-
<div class="post-footer">
|
|
47
|
-
{entry.data.tags && entry.data.tags.length > 0 && (
|
|
48
|
-
<div class="post-tags">
|
|
49
|
-
{entry.data.tags.map(tag => <a href={`/tags/${tag}`} class="tag">#{tag}</a>)}
|
|
50
|
-
</div>
|
|
51
|
-
)}
|
|
52
|
-
{related.length > 0 && (
|
|
53
|
-
<div class="related-posts">
|
|
54
|
-
<p class="related-label">Related</p>
|
|
55
|
-
<ul>
|
|
56
|
-
{related.map(r => (
|
|
57
|
-
<li><a href={`/${r.collection}/${r.id}`}>{r.data.title}</a></li>
|
|
58
|
-
))}
|
|
59
|
-
</ul>
|
|
60
|
-
</div>
|
|
61
|
-
)}
|
|
62
|
-
<a href="/blog">← Blog</a>
|
|
63
|
-
</div>
|
|
64
|
-
</article>
|
|
65
|
-
<ModuleLoader comments={entry.data.comments} />
|
|
66
|
-
</Base>
|
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
import { getCollection } from 'astro:content';
|
|
3
|
-
import Base from '../../layouts/Base.astro';
|
|
4
|
-
import { SITE_TITLE } from '../../consts';
|
|
5
|
-
|
|
6
|
-
const posts = (await getCollection('blog', ({ data }) => data.publish === true))
|
|
7
|
-
.sort((a, b) => (b.data.date?.valueOf() ?? 0) - (a.data.date?.valueOf() ?? 0));
|
|
8
|
-
---
|
|
9
|
-
|
|
10
|
-
<Base title={`Blog — ${SITE_TITLE}`}>
|
|
11
|
-
<div class="listing-header">
|
|
12
|
-
<h1>Blog</h1>
|
|
13
|
-
</div>
|
|
14
|
-
{posts.length > 0 ? (
|
|
15
|
-
<ul class="post-list">
|
|
16
|
-
{posts.map(post => (
|
|
17
|
-
<li>
|
|
18
|
-
{post.data.date && (
|
|
19
|
-
<span class="post-date">{post.data.date.toISOString().slice(0, 10)}</span>
|
|
20
|
-
)}
|
|
21
|
-
<a href={`/blog/${post.id}`}>{post.data.title}</a>
|
|
22
|
-
</li>
|
|
23
|
-
))}
|
|
24
|
-
</ul>
|
|
25
|
-
) : (
|
|
26
|
-
<div class="empty-state">
|
|
27
|
-
<p>No posts published yet.</p>
|
|
28
|
-
<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>
|
|
29
|
-
</div>
|
|
30
|
-
)}
|
|
31
|
-
</Base>
|
|
@@ -1,66 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
import { getCollection, render } from 'astro:content';
|
|
3
|
-
import Base from '../../layouts/Base.astro';
|
|
4
|
-
import ModuleLoader from '../../components/ModuleLoader.astro';
|
|
5
|
-
import { SITE_TITLE } from '../../consts';
|
|
6
|
-
|
|
7
|
-
export async function getStaticPaths() {
|
|
8
|
-
const docs = await getCollection('docs', ({ data }) => data.publish === true);
|
|
9
|
-
return docs.map(entry => ({
|
|
10
|
-
params: { slug: entry.id },
|
|
11
|
-
props: { entry },
|
|
12
|
-
}));
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
const { entry } = Astro.props;
|
|
16
|
-
const { Content } = await render(entry);
|
|
17
|
-
|
|
18
|
-
// Resolve related entries by ID across both collections
|
|
19
|
-
const relatedIds = entry.data.related ?? [];
|
|
20
|
-
const related = relatedIds.length > 0
|
|
21
|
-
? (await Promise.all([
|
|
22
|
-
getCollection('blog', ({ data }) => data.publish === true),
|
|
23
|
-
getCollection('docs', ({ data }) => data.publish === true),
|
|
24
|
-
]))
|
|
25
|
-
.flat()
|
|
26
|
-
.filter(e => relatedIds.includes(e.id))
|
|
27
|
-
: [];
|
|
28
|
-
---
|
|
29
|
-
|
|
30
|
-
<Base title={`${entry.data.title} — ${SITE_TITLE}`} description={entry.data.description} type="article">
|
|
31
|
-
<article>
|
|
32
|
-
<div class="post-header">
|
|
33
|
-
<h1>{entry.data.title}</h1>
|
|
34
|
-
<div class="post-meta">
|
|
35
|
-
{entry.data.date && <span>{entry.data.date.toISOString().slice(0, 10)}</span>}
|
|
36
|
-
{entry.data.author && entry.data.date && <span> · </span>}
|
|
37
|
-
{entry.data.author && (
|
|
38
|
-
<span>{Array.isArray(entry.data.author) ? entry.data.author.join(' · ') : entry.data.author}</span>
|
|
39
|
-
)}
|
|
40
|
-
{entry.data.reading_time && <span> · {entry.data.reading_time} min read</span>}
|
|
41
|
-
</div>
|
|
42
|
-
</div>
|
|
43
|
-
<div class="prose">
|
|
44
|
-
<Content />
|
|
45
|
-
</div>
|
|
46
|
-
<div class="post-footer">
|
|
47
|
-
{entry.data.tags && entry.data.tags.length > 0 && (
|
|
48
|
-
<div class="post-tags">
|
|
49
|
-
{entry.data.tags.map(tag => <a href={`/tags/${tag}`} class="tag">#{tag}</a>)}
|
|
50
|
-
</div>
|
|
51
|
-
)}
|
|
52
|
-
{related.length > 0 && (
|
|
53
|
-
<div class="related-posts">
|
|
54
|
-
<p class="related-label">Related</p>
|
|
55
|
-
<ul>
|
|
56
|
-
{related.map(r => (
|
|
57
|
-
<li><a href={`/${r.collection}/${r.id}`}>{r.data.title}</a></li>
|
|
58
|
-
))}
|
|
59
|
-
</ul>
|
|
60
|
-
</div>
|
|
61
|
-
)}
|
|
62
|
-
<a href="/docs">← Docs</a>
|
|
63
|
-
</div>
|
|
64
|
-
</article>
|
|
65
|
-
<ModuleLoader comments={entry.data.comments} />
|
|
66
|
-
</Base>
|
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
import { getCollection } from 'astro:content';
|
|
3
|
-
import Base from '../../layouts/Base.astro';
|
|
4
|
-
import { SITE_TITLE } from '../../consts';
|
|
5
|
-
|
|
6
|
-
const docs = (await getCollection('docs', ({ data }) => data.publish === true))
|
|
7
|
-
.sort((a, b) => a.data.title.localeCompare(b.data.title));
|
|
8
|
-
---
|
|
9
|
-
|
|
10
|
-
<Base title={`Docs — ${SITE_TITLE}`}>
|
|
11
|
-
<div class="listing-header">
|
|
12
|
-
<h1>Docs</h1>
|
|
13
|
-
</div>
|
|
14
|
-
{docs.length > 0 ? (
|
|
15
|
-
<ul class="post-list">
|
|
16
|
-
{docs.map(doc => (
|
|
17
|
-
<li>
|
|
18
|
-
{doc.data.date && (
|
|
19
|
-
<span class="post-date">{doc.data.date.toISOString().slice(0, 10)}</span>
|
|
20
|
-
)}
|
|
21
|
-
<a href={`/docs/${doc.id}`}>{doc.data.title}</a>
|
|
22
|
-
</li>
|
|
23
|
-
))}
|
|
24
|
-
</ul>
|
|
25
|
-
) : (
|
|
26
|
-
<div class="empty-state">
|
|
27
|
-
<p>No docs published yet.</p>
|
|
28
|
-
<p>Create a Markdown file in your vault's <code>docs/</code> folder and set <code>publish: true</code> in the frontmatter to make it appear here.</p>
|
|
29
|
-
</div>
|
|
30
|
-
)}
|
|
31
|
-
</Base>
|
package/src/pages/index.astro
DELETED
|
@@ -1,65 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
import { getCollection } from 'astro:content';
|
|
3
|
-
import Base from '../layouts/Base.astro';
|
|
4
|
-
import { SITE_TITLE, SITE_DESCRIPTION } from '../consts';
|
|
5
|
-
|
|
6
|
-
const posts = (await getCollection('blog', ({ data }) => data.publish === true))
|
|
7
|
-
.sort((a, b) => (b.data.date?.valueOf() ?? 0) - (a.data.date?.valueOf() ?? 0))
|
|
8
|
-
.slice(0, 5);
|
|
9
|
-
|
|
10
|
-
const docs = (await getCollection('docs', ({ data }) => data.publish === true))
|
|
11
|
-
.sort((a, b) => a.data.title.localeCompare(b.data.title))
|
|
12
|
-
.slice(0, 5);
|
|
13
|
-
---
|
|
14
|
-
|
|
15
|
-
<Base title={SITE_TITLE} description={SITE_DESCRIPTION}>
|
|
16
|
-
<div class="home-grid">
|
|
17
|
-
<section class="home-section">
|
|
18
|
-
<h2>Blog</h2>
|
|
19
|
-
{posts.length > 0 ? (
|
|
20
|
-
<>
|
|
21
|
-
<ul class="post-list">
|
|
22
|
-
{posts.map(post => (
|
|
23
|
-
<li>
|
|
24
|
-
<a href={`/blog/${post.id}`}>{post.data.title}</a>
|
|
25
|
-
{post.data.date && (
|
|
26
|
-
<span class="post-date">{post.data.date.toISOString().slice(0, 10)}</span>
|
|
27
|
-
)}
|
|
28
|
-
</li>
|
|
29
|
-
))}
|
|
30
|
-
</ul>
|
|
31
|
-
<a href="/blog" class="view-all">View all →</a>
|
|
32
|
-
</>
|
|
33
|
-
) : (
|
|
34
|
-
<div class="empty-state">
|
|
35
|
-
<p>No posts yet.</p>
|
|
36
|
-
<p>Add a Markdown file to <code>content/blog/</code> with <code>publish: true</code> in the frontmatter.</p>
|
|
37
|
-
</div>
|
|
38
|
-
)}
|
|
39
|
-
</section>
|
|
40
|
-
|
|
41
|
-
<section class="home-section">
|
|
42
|
-
<h2>Docs</h2>
|
|
43
|
-
{docs.length > 0 ? (
|
|
44
|
-
<>
|
|
45
|
-
<ul class="post-list">
|
|
46
|
-
{docs.map(doc => (
|
|
47
|
-
<li>
|
|
48
|
-
<a href={`/docs/${doc.id}`}>{doc.data.title}</a>
|
|
49
|
-
{doc.data.date && (
|
|
50
|
-
<span class="post-date">{doc.data.date.toISOString().slice(0, 10)}</span>
|
|
51
|
-
)}
|
|
52
|
-
</li>
|
|
53
|
-
))}
|
|
54
|
-
</ul>
|
|
55
|
-
<a href="/docs" class="view-all">View all →</a>
|
|
56
|
-
</>
|
|
57
|
-
) : (
|
|
58
|
-
<div class="empty-state">
|
|
59
|
-
<p>No docs yet.</p>
|
|
60
|
-
<p>Add a Markdown file to <code>content/docs/</code> with <code>publish: true</code> in the frontmatter.</p>
|
|
61
|
-
</div>
|
|
62
|
-
)}
|
|
63
|
-
</section>
|
|
64
|
-
</div>
|
|
65
|
-
</Base>
|
|
@@ -1,53 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
import { getCollection } from 'astro:content';
|
|
3
|
-
import Base from '../../layouts/Base.astro';
|
|
4
|
-
import { SITE_TITLE } from '../../consts';
|
|
5
|
-
|
|
6
|
-
export async function getStaticPaths() {
|
|
7
|
-
const [blog, docs] = await Promise.all([
|
|
8
|
-
getCollection('blog', ({ data }) => data.publish === true),
|
|
9
|
-
getCollection('docs', ({ data }) => data.publish === true),
|
|
10
|
-
]);
|
|
11
|
-
|
|
12
|
-
// Collect all unique tags
|
|
13
|
-
const tags = new Set<string>();
|
|
14
|
-
for (const entry of [...blog, ...docs]) {
|
|
15
|
-
for (const tag of entry.data.tags ?? []) tags.add(tag);
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
return [...tags].map(tag => ({
|
|
19
|
-
params: { tag },
|
|
20
|
-
props: {
|
|
21
|
-
tag,
|
|
22
|
-
entries: [...blog, ...docs]
|
|
23
|
-
.filter(e => e.data.tags?.includes(tag))
|
|
24
|
-
.sort((a, b) => (b.data.date?.valueOf() ?? 0) - (a.data.date?.valueOf() ?? 0)),
|
|
25
|
-
},
|
|
26
|
-
}));
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
const { tag, entries } = Astro.props;
|
|
30
|
-
|
|
31
|
-
// Determine the URL prefix for each entry by its collection
|
|
32
|
-
function href(entry: { collection: string; id: string }) {
|
|
33
|
-
return `/${entry.collection}/${entry.id}`;
|
|
34
|
-
}
|
|
35
|
-
---
|
|
36
|
-
|
|
37
|
-
<Base title={`#${tag} — ${SITE_TITLE}`}>
|
|
38
|
-
<div class="listing-header">
|
|
39
|
-
<h1>#{tag}</h1>
|
|
40
|
-
<p><a href="/tags">← All tags</a></p>
|
|
41
|
-
</div>
|
|
42
|
-
<ul class="post-list">
|
|
43
|
-
{entries.map(entry => (
|
|
44
|
-
<li>
|
|
45
|
-
{entry.data.date && (
|
|
46
|
-
<span class="post-date">{entry.data.date.toISOString().slice(0, 10)}</span>
|
|
47
|
-
)}
|
|
48
|
-
<a href={href(entry)}>{entry.data.title}</a>
|
|
49
|
-
<span class="post-collection">{entry.collection}</span>
|
|
50
|
-
</li>
|
|
51
|
-
))}
|
|
52
|
-
</ul>
|
|
53
|
-
</Base>
|
|
@@ -1,41 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
import { getCollection } from 'astro:content';
|
|
3
|
-
import Base from '../../layouts/Base.astro';
|
|
4
|
-
import { SITE_TITLE } from '../../consts';
|
|
5
|
-
|
|
6
|
-
const [blog, docs] = await Promise.all([
|
|
7
|
-
getCollection('blog', ({ data }) => data.publish === true),
|
|
8
|
-
getCollection('docs', ({ data }) => data.publish === true),
|
|
9
|
-
]);
|
|
10
|
-
|
|
11
|
-
// Count occurrences of each tag across both collections
|
|
12
|
-
const counts = new Map<string, number>();
|
|
13
|
-
for (const entry of [...blog, ...docs]) {
|
|
14
|
-
for (const tag of entry.data.tags ?? []) {
|
|
15
|
-
counts.set(tag, (counts.get(tag) ?? 0) + 1);
|
|
16
|
-
}
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
const tags = [...counts.entries()].sort((a, b) => a[0].localeCompare(b[0]));
|
|
20
|
-
---
|
|
21
|
-
|
|
22
|
-
<Base title={`Tags — ${SITE_TITLE}`}>
|
|
23
|
-
<div class="listing-header">
|
|
24
|
-
<h1>Tags</h1>
|
|
25
|
-
</div>
|
|
26
|
-
{tags.length > 0 ? (
|
|
27
|
-
<ul class="tag-list">
|
|
28
|
-
{tags.map(([tag, count]) => (
|
|
29
|
-
<li>
|
|
30
|
-
<a href={`/tags/${tag}`}>{tag}</a>
|
|
31
|
-
<span class="tag-count">{count}</span>
|
|
32
|
-
</li>
|
|
33
|
-
))}
|
|
34
|
-
</ul>
|
|
35
|
-
) : (
|
|
36
|
-
<div class="empty-state">
|
|
37
|
-
<p>No tags yet.</p>
|
|
38
|
-
<p>Tags are added to posts via the <code>tags</code> frontmatter field or by AI enrichment.</p>
|
|
39
|
-
</div>
|
|
40
|
-
)}
|
|
41
|
-
</Base>
|