@karaoke-cms/module-tags 0.11.2
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 +40 -0
- package/src/css-contract.ts +12 -0
- package/src/index.ts +50 -0
- package/src/pages/index.astro +46 -0
- package/src/pages/tag-rss.ts +77 -0
- package/src/pages/tag.astro +70 -0
- package/src/utils.ts +28 -0
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@karaoke-cms/module-tags",
|
|
3
|
+
"type": "module",
|
|
4
|
+
"version": "0.11.2",
|
|
5
|
+
"description": "Tags module for karaoke-cms — cross-collection tag index with per-tag RSS feeds",
|
|
6
|
+
"main": "./src/index.ts",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/index.ts",
|
|
9
|
+
"./pages/index": "./src/pages/index.astro",
|
|
10
|
+
"./pages/tag": "./src/pages/tag.astro",
|
|
11
|
+
"./pages/tag-rss": "./src/pages/tag-rss.ts"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"src/"
|
|
15
|
+
],
|
|
16
|
+
"keywords": [
|
|
17
|
+
"astro",
|
|
18
|
+
"cms",
|
|
19
|
+
"tags",
|
|
20
|
+
"rss",
|
|
21
|
+
"karaoke-cms"
|
|
22
|
+
],
|
|
23
|
+
"dependencies": {
|
|
24
|
+
"@astrojs/rss": "^4.0.17"
|
|
25
|
+
},
|
|
26
|
+
"peerDependencies": {
|
|
27
|
+
"astro": ">=6.0.0",
|
|
28
|
+
"@karaoke-cms/contracts": "^0.11.2",
|
|
29
|
+
"@karaoke-cms/astro": "^0.11.2"
|
|
30
|
+
},
|
|
31
|
+
"devDependencies": {
|
|
32
|
+
"astro": "^6.0.8",
|
|
33
|
+
"vitest": "^4.1.1",
|
|
34
|
+
"@karaoke-cms/contracts": "0.11.2",
|
|
35
|
+
"@karaoke-cms/astro": "0.11.2"
|
|
36
|
+
},
|
|
37
|
+
"scripts": {
|
|
38
|
+
"test": "vitest run test/module-tags.test.js"
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { CssContract } from '@karaoke-cms/contracts';
|
|
2
|
+
|
|
3
|
+
export const cssContract: CssContract = {
|
|
4
|
+
'tag-listing-header': { description: 'Wrapper for the /tags page title + subtitle', required: true },
|
|
5
|
+
'tag-list': { description: 'Ordered/unordered list of tag links on the index page', required: true },
|
|
6
|
+
'tag-count': { description: 'Post count badge next to each tag name', required: false },
|
|
7
|
+
'tag-page-header': { description: 'Wrapper for the /tags/[tag] page title + back link', required: true },
|
|
8
|
+
'tag-post-list': { description: 'List of posts matching the current tag', required: true },
|
|
9
|
+
'tag-post-date': { description: 'Publication date on each tagged post item', required: false },
|
|
10
|
+
'tag-post-collection': { description: 'Collection badge (Blog / Docs) on each post item', required: false },
|
|
11
|
+
'tag-empty-state': { description: 'Shown when a tag has no matching public entries', required: false },
|
|
12
|
+
};
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { ModuleInstance } from '@karaoke-cms/contracts';
|
|
2
|
+
import { cssContract } from './css-contract.js';
|
|
3
|
+
|
|
4
|
+
export interface TagsConfig {
|
|
5
|
+
/** Mount path for the tags section. Default: '/tags'. */
|
|
6
|
+
mount?: string;
|
|
7
|
+
/** When false, tags routes are not injected. Default: true. */
|
|
8
|
+
enabled?: boolean;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Tags module — cross-collection tag index with per-tag RSS feeds.
|
|
13
|
+
*
|
|
14
|
+
* Aggregates tags from the blog collection and all active docs sections automatically.
|
|
15
|
+
* No explicit `sources` config required — mirrors current theme-default behavior.
|
|
16
|
+
*
|
|
17
|
+
* @example
|
|
18
|
+
* // karaoke.config.ts
|
|
19
|
+
* import { tags } from '@karaoke-cms/module-tags';
|
|
20
|
+
* modules: [blog(), docs(), tags()]
|
|
21
|
+
*
|
|
22
|
+
* // Custom mount:
|
|
23
|
+
* modules: [blog(), docs(), tags({ mount: '/topics' })]
|
|
24
|
+
*/
|
|
25
|
+
export function tags(config: TagsConfig = {}): ModuleInstance {
|
|
26
|
+
const rawMount = config.mount ?? '/tags';
|
|
27
|
+
if (!rawMount || rawMount === '/') {
|
|
28
|
+
throw new Error(
|
|
29
|
+
'[module-tags] mount cannot be "/" or empty. Pass a non-root path like "/tags".',
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
const mount = rawMount.replace(/\/$/, '');
|
|
33
|
+
|
|
34
|
+
return {
|
|
35
|
+
_type: 'module-instance',
|
|
36
|
+
id: 'tags',
|
|
37
|
+
mount,
|
|
38
|
+
enabled: config.enabled ?? true,
|
|
39
|
+
routes: [
|
|
40
|
+
{ pattern: mount, entrypoint: '@karaoke-cms/module-tags/pages/index' },
|
|
41
|
+
{ pattern: `${mount}/[tag]`, entrypoint: '@karaoke-cms/module-tags/pages/tag' },
|
|
42
|
+
{ pattern: `${mount}/[tag]/rss.xml`, entrypoint: '@karaoke-cms/module-tags/pages/tag-rss' },
|
|
43
|
+
],
|
|
44
|
+
menuEntries: [{ id: 'tags', name: 'Tags', path: mount, section: 'main', weight: 30 }],
|
|
45
|
+
cssContract,
|
|
46
|
+
hasDefaultCss: false,
|
|
47
|
+
defaultCssPath: undefined,
|
|
48
|
+
scaffoldPages: undefined,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { getCollection } from 'astro:content';
|
|
3
|
+
import DefaultPage from '@karaoke-cms/astro/layouts/DefaultPage.astro';
|
|
4
|
+
import { siteTitle } from 'virtual:karaoke-cms/config';
|
|
5
|
+
import { tagsMount } from 'virtual:karaoke-cms/tags-config';
|
|
6
|
+
import { docsSections } from 'virtual:karaoke-cms/docs-sections';
|
|
7
|
+
import { slugifyTag } from '../utils.js';
|
|
8
|
+
|
|
9
|
+
// Auto-discover: always blog + all active docs sections
|
|
10
|
+
const settled = await Promise.allSettled([
|
|
11
|
+
getCollection('blog', ({ data }) => data.publish === true),
|
|
12
|
+
...docsSections.map(s => getCollection(s.collection as any, ({ data }) => data.publish === true)),
|
|
13
|
+
]);
|
|
14
|
+
const allEntries = settled.flatMap(r => r.status === 'fulfilled' ? r.value : []);
|
|
15
|
+
|
|
16
|
+
// Count occurrences of each tag across all collections
|
|
17
|
+
const counts = new Map<string, number>();
|
|
18
|
+
for (const entry of allEntries) {
|
|
19
|
+
for (const tag of entry.data.tags ?? []) {
|
|
20
|
+
counts.set(tag, (counts.get(tag) ?? 0) + 1);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const tags = [...counts.entries()].sort((a, b) => a[0].localeCompare(b[0]));
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
<DefaultPage title={`Tags — ${siteTitle}`}>
|
|
28
|
+
<div class="listing-header">
|
|
29
|
+
<h1>Tags</h1>
|
|
30
|
+
</div>
|
|
31
|
+
{tags.length > 0 ? (
|
|
32
|
+
<ul class="tag-list">
|
|
33
|
+
{tags.map(([tag, count]) => (
|
|
34
|
+
<li>
|
|
35
|
+
<a href={`${tagsMount}/${slugifyTag(tag)}`}>{tag}</a>
|
|
36
|
+
<span class="tag-count">{count}</span>
|
|
37
|
+
</li>
|
|
38
|
+
))}
|
|
39
|
+
</ul>
|
|
40
|
+
) : (
|
|
41
|
+
<div class="empty-state">
|
|
42
|
+
<p>No tags yet.</p>
|
|
43
|
+
<p>Tags are added to posts via the <code>tags</code> frontmatter field or by AI enrichment.</p>
|
|
44
|
+
</div>
|
|
45
|
+
)}
|
|
46
|
+
</DefaultPage>
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-tag RSS feed endpoint.
|
|
3
|
+
*
|
|
4
|
+
* Route: /tags/[tag]/rss.xml (or whatever mount was configured)
|
|
5
|
+
*
|
|
6
|
+
* NOTE: This uses injectRoute({ pattern: '${mount}/[tag]/rss.xml' }).
|
|
7
|
+
* The [tag] segment is populated by getStaticPaths in SSG mode.
|
|
8
|
+
* The tag param is a URL-safe slug (from slugifyTag); the original tag string
|
|
9
|
+
* is passed via props.originalTag for display and filtering.
|
|
10
|
+
*/
|
|
11
|
+
import rss from '@astrojs/rss';
|
|
12
|
+
import { getCollection } from 'astro:content';
|
|
13
|
+
import { siteTitle, siteDescription, blogMount } from 'virtual:karaoke-cms/config';
|
|
14
|
+
import { docsSections } from 'virtual:karaoke-cms/docs-sections';
|
|
15
|
+
import { slugifyTag, slugToTag } from '../utils.js';
|
|
16
|
+
import type { APIRoute, GetStaticPaths } from 'astro';
|
|
17
|
+
|
|
18
|
+
async function getAllEntries() {
|
|
19
|
+
const settled = await Promise.allSettled([
|
|
20
|
+
getCollection('blog', (e) => e.data.publish === true),
|
|
21
|
+
...docsSections.map(s => getCollection(s.collection as any, (e) => e.data.publish === true)),
|
|
22
|
+
]);
|
|
23
|
+
return settled.flatMap(r => r.status === 'fulfilled' ? r.value : []);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const mountByCollection: Record<string, string> = {
|
|
27
|
+
blog: blogMount,
|
|
28
|
+
...Object.fromEntries(docsSections.map((s) => [s.collection, s.mount])),
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
function entryHref(entry: { collection: string; id: string }) {
|
|
32
|
+
const mount = mountByCollection[entry.collection];
|
|
33
|
+
return mount ? `${mount}/${entry.id}` : `/${entry.collection}/${entry.id}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const getStaticPaths: GetStaticPaths = async () => {
|
|
37
|
+
const all = await getAllEntries();
|
|
38
|
+
const tags = new Set(all.flatMap(e => e.data.tags ?? []));
|
|
39
|
+
return [...tags].map(tag => ({
|
|
40
|
+
params: { tag: slugifyTag(tag) },
|
|
41
|
+
props: { originalTag: tag },
|
|
42
|
+
}));
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export const GET: APIRoute = async ({ params, props, site }) => {
|
|
46
|
+
// RSS feeds require absolute URLs. site comes from Astro.site (set in astro.config.mjs).
|
|
47
|
+
if (!site) {
|
|
48
|
+
return new Response(
|
|
49
|
+
'<?xml version="1.0" encoding="UTF-8"?>' +
|
|
50
|
+
'<!-- RSS feeds require `site` to be configured in astro.config.mjs -->',
|
|
51
|
+
{ status: 200, headers: { 'Content-Type': 'application/xml' } },
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const all = await getAllEntries();
|
|
56
|
+
|
|
57
|
+
// Prefer originalTag passed from getStaticPaths; fall back to reverse slug lookup
|
|
58
|
+
const allTagStrings = [...new Set(all.flatMap(e => e.data.tags ?? []))];
|
|
59
|
+
const tag = (props as { originalTag?: string }).originalTag ?? slugToTag(params.tag!, allTagStrings);
|
|
60
|
+
if (!tag) return new Response('Not found', { status: 404 });
|
|
61
|
+
|
|
62
|
+
const tagged = all
|
|
63
|
+
.filter(e => e.data.tags?.includes(tag))
|
|
64
|
+
.sort((a, b) => (b.data.date?.valueOf() ?? 0) - (a.data.date?.valueOf() ?? 0));
|
|
65
|
+
|
|
66
|
+
return rss({
|
|
67
|
+
title: `#${tag} — ${siteTitle}`,
|
|
68
|
+
description: `${siteDescription ? siteDescription + ' · ' : ''}Posts tagged #${tag}`,
|
|
69
|
+
site,
|
|
70
|
+
items: tagged.map(e => ({
|
|
71
|
+
title: e.data.title,
|
|
72
|
+
pubDate: e.data.date,
|
|
73
|
+
description: e.data.description,
|
|
74
|
+
link: entryHref(e),
|
|
75
|
+
})),
|
|
76
|
+
});
|
|
77
|
+
};
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { getCollection } from 'astro:content';
|
|
3
|
+
import DefaultPage from '@karaoke-cms/astro/layouts/DefaultPage.astro';
|
|
4
|
+
import { siteTitle, blogMount } from 'virtual:karaoke-cms/config';
|
|
5
|
+
import { tagsMount } from 'virtual:karaoke-cms/tags-config';
|
|
6
|
+
import { docsSections } from 'virtual:karaoke-cms/docs-sections';
|
|
7
|
+
import { slugifyTag, slugToTag } from '../utils.js';
|
|
8
|
+
|
|
9
|
+
export async function getStaticPaths() {
|
|
10
|
+
const settled = await Promise.allSettled([
|
|
11
|
+
getCollection('blog', ({ data }) => data.publish === true),
|
|
12
|
+
...docsSections.map(s => getCollection(s.collection as any, ({ data }) => data.publish === true)),
|
|
13
|
+
]);
|
|
14
|
+
const allEntries = settled.flatMap(r => r.status === 'fulfilled' ? r.value : []);
|
|
15
|
+
|
|
16
|
+
const tags = new Set<string>();
|
|
17
|
+
for (const entry of allEntries) {
|
|
18
|
+
for (const tag of entry.data.tags ?? []) tags.add(tag);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
return [...tags].map(tag => ({
|
|
22
|
+
params: { tag: slugifyTag(tag) },
|
|
23
|
+
props: {
|
|
24
|
+
originalTag: tag,
|
|
25
|
+
entries: allEntries
|
|
26
|
+
.filter(e => e.data.tags?.includes(tag))
|
|
27
|
+
.sort((a, b) => (b.data.date?.valueOf() ?? 0) - (a.data.date?.valueOf() ?? 0)),
|
|
28
|
+
},
|
|
29
|
+
}));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const { tag } = Astro.params;
|
|
33
|
+
const { originalTag, entries } = Astro.props;
|
|
34
|
+
|
|
35
|
+
// Map collection name → mount path for correct href generation
|
|
36
|
+
const mountByCollection: Record<string, string> = {
|
|
37
|
+
blog: blogMount,
|
|
38
|
+
...Object.fromEntries(docsSections.map(s => [s.collection, s.mount])),
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
function href(entry: { collection: string; id: string }) {
|
|
42
|
+
const mount = mountByCollection[entry.collection];
|
|
43
|
+
return mount ? `${mount}/${entry.id}` : `/${entry.collection}/${entry.id}`;
|
|
44
|
+
}
|
|
45
|
+
---
|
|
46
|
+
|
|
47
|
+
<DefaultPage title={`#${originalTag} — ${siteTitle}`}>
|
|
48
|
+
<link rel="alternate" type="application/rss+xml" title={`#${originalTag} RSS Feed`} href={`${tagsMount}/${tag}/rss.xml`} slot="head" />
|
|
49
|
+
<div class="listing-header">
|
|
50
|
+
<h1>#{originalTag}</h1>
|
|
51
|
+
<p><a href={tagsMount}>← All tags</a></p>
|
|
52
|
+
</div>
|
|
53
|
+
{entries.length > 0 ? (
|
|
54
|
+
<ul class="post-list">
|
|
55
|
+
{entries.map(entry => (
|
|
56
|
+
<li>
|
|
57
|
+
{entry.data.date && (
|
|
58
|
+
<span class="post-date">{entry.data.date.toISOString().slice(0, 10)}</span>
|
|
59
|
+
)}
|
|
60
|
+
<a href={href(entry)}>{entry.data.title}</a>
|
|
61
|
+
<span class="post-collection">{entry.collection}</span>
|
|
62
|
+
</li>
|
|
63
|
+
))}
|
|
64
|
+
</ul>
|
|
65
|
+
) : (
|
|
66
|
+
<div class="empty-state">
|
|
67
|
+
<p>No published posts tagged <strong>#{originalTag}</strong>.</p>
|
|
68
|
+
</div>
|
|
69
|
+
)}
|
|
70
|
+
</DefaultPage>
|
package/src/utils.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Convert a raw tag string to a URL-safe slug.
|
|
3
|
+
* Used in all three module pages and the RSS endpoint for consistent URL generation.
|
|
4
|
+
*
|
|
5
|
+
* Examples:
|
|
6
|
+
* "C++" → "c-plus-plus"
|
|
7
|
+
* "AI/ML" → "ai-ml"
|
|
8
|
+
* "Node.js"→ "node-js"
|
|
9
|
+
* "web dev"→ "web-dev"
|
|
10
|
+
*/
|
|
11
|
+
export function slugifyTag(tag: string): string {
|
|
12
|
+
return tag
|
|
13
|
+
.toLowerCase()
|
|
14
|
+
.replace(/\+\+/g, '-plus-plus')
|
|
15
|
+
.replace(/\+/g, '-plus-')
|
|
16
|
+
.replace(/[^\w\s-]/g, '-')
|
|
17
|
+
.replace(/[\s_]+/g, '-')
|
|
18
|
+
.replace(/-+/g, '-')
|
|
19
|
+
.replace(/^-|-$/g, '');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Reverse lookup: given a slug and a set of known original tag strings,
|
|
24
|
+
* return the original tag that maps to this slug.
|
|
25
|
+
*/
|
|
26
|
+
export function slugToTag(slug: string, knownTags: string[]): string | undefined {
|
|
27
|
+
return knownTags.find(t => slugifyTag(t) === slug);
|
|
28
|
+
}
|