@karaoke-cms/astro 0.6.1 → 0.6.3

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/client.d.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  */
7
7
 
8
8
  declare module 'virtual:karaoke-cms/config' {
9
- import type { ResolvedModules, ResolvedLayout } from '@karaoke-cms/astro';
9
+ import type { ResolvedModules, ResolvedLayout, ResolvedCollections } from '@karaoke-cms/astro';
10
10
 
11
11
  /** Site title from karaoke.config.ts */
12
12
  export const siteTitle: string;
@@ -16,4 +16,6 @@ declare module 'virtual:karaoke-cms/config' {
16
16
  export const resolvedModules: ResolvedModules;
17
17
  /** Resolved layout config (defaults filled in) */
18
18
  export const resolvedLayout: ResolvedLayout;
19
+ /** Resolved collections with enabled/disabled status for current build mode */
20
+ export const resolvedCollections: ResolvedCollections;
19
21
  }
package/package.json CHANGED
@@ -1,13 +1,14 @@
1
1
  {
2
2
  "name": "@karaoke-cms/astro",
3
3
  "type": "module",
4
- "version": "0.6.1",
4
+ "version": "0.6.3",
5
5
  "description": "Astro integration for karaoke-cms — ships all routes, themes, and modules",
6
6
  "main": "./src/index.ts",
7
7
  "exports": {
8
8
  ".": "./src/index.ts",
9
9
  "./client": "./client.d.ts",
10
10
  "./collections": "./src/collections.ts",
11
+ "./env": "./src/utils/load-env.ts",
11
12
  "./pages/index.astro": "./src/pages/index.astro",
12
13
  "./pages/blog/index.astro": "./src/pages/blog/index.astro",
13
14
  "./pages/blog/[slug].astro": "./src/pages/blog/[slug].astro",
@@ -16,7 +17,9 @@
16
17
  "./pages/tags/index.astro": "./src/pages/tags/index.astro",
17
18
  "./pages/tags/[tag].astro": "./src/pages/tags/[tag].astro",
18
19
  "./pages/404.astro": "./src/pages/404.astro",
19
- "./pages/rss.xml.ts": "./src/pages/rss.xml.ts"
20
+ "./pages/rss.xml.ts": "./src/pages/rss.xml.ts",
21
+ "./pages/karaoke-cms/index.astro": "./src/pages/karaoke-cms/index.astro",
22
+ "./pages/karaoke-cms/[...slug].astro": "./src/pages/karaoke-cms/[...slug].astro"
20
23
  },
21
24
  "files": [
22
25
  "src/",
@@ -34,13 +37,14 @@
34
37
  "dependencies": {
35
38
  "@astrojs/rss": "^4.0.17",
36
39
  "@astrojs/sitemap": "^3.7.1",
37
- "remark-wiki-link": "^2.0.1"
40
+ "remark-wiki-link": "^2.0.1",
41
+ "yaml": "^2.7.0"
38
42
  },
39
43
  "devDependencies": {
40
44
  "vitest": "^4.1.1",
41
45
  "astro": "^6.0.8"
42
46
  },
43
47
  "scripts": {
44
- "test": "vitest run test/validate-config.test.js test/resolve-modules.test.js test/resolve-layout.test.js"
48
+ "test": "vitest run test/validate-config.test.js test/resolve-modules.test.js test/resolve-layout.test.js test/resolve-collections.test.js test/load-env.test.js"
45
49
  }
46
50
  }
@@ -1,7 +1,9 @@
1
1
  import { defineCollection, z } from 'astro:content';
2
2
  import { glob } from 'astro/loaders';
3
3
  import { fileURLToPath } from 'url';
4
- import { join } from 'path';
4
+ import { join, resolve, isAbsolute } from 'path';
5
+ import { resolveCollections } from './utils/resolve-collections.js';
6
+ import type { CollectionConfig } from './types.js';
5
7
 
6
8
  const baseSchema = z.object({
7
9
  title: z.string(),
@@ -15,28 +17,83 @@ const baseSchema = z.object({
15
17
  related: z.array(z.string()).optional(),
16
18
  });
17
19
 
20
+ // Relaxed schema for handbook / custom collections — title is optional
21
+ const relaxedSchema = z.object({
22
+ title: z.string().optional().default('Untitled'),
23
+ publish: z.boolean().optional().default(false),
24
+ date: z.coerce.date().optional(),
25
+ author: z.union([z.string(), z.array(z.string())]).optional(),
26
+ tags: z.array(z.string()).optional(),
27
+ description: z.string().optional(),
28
+ reading_time: z.number().optional(),
29
+ related: z.array(z.string()).optional(),
30
+ });
31
+
18
32
  /**
19
33
  * Create Astro content collections for karaoke-cms.
20
34
  *
21
- * @param root - URL pointing to the project root (where content/ lives).
22
- * Typically: `new URL('../..', import.meta.url)` from src/content.config.ts
35
+ * Reads collections.yaml from the vault to determine which collections are
36
+ * active for the current build mode. The `karaoke-cms` collection (handbook)
37
+ * is dev-only and never enters the production build graph.
38
+ *
39
+ * @param root - URL pointing to the Astro project root (where astro.config.mjs lives).
40
+ * Typically: `new URL('..', import.meta.url)` from src/content.config.ts
41
+ * @param vault - Path to the Obsidian vault root (where content/ lives).
42
+ * Absolute, or relative to `root`. Defaults to `root` when omitted (vault = project).
43
+ * Typically sourced from `loadEnv(new URL('..', import.meta.url)).KARAOKE_VAULT`.
44
+ * @param configCollections - Optional per-collection overrides from karaoke.config.ts.
23
45
  *
24
46
  * @example
25
47
  * // src/content.config.ts
26
48
  * import { makeCollections } from '@karaoke-cms/astro/collections';
27
- * export const collections = makeCollections(new URL('../..', import.meta.url));
49
+ * import { loadEnv } from '@karaoke-cms/astro/env';
50
+ * const env = loadEnv(new URL('..', import.meta.url));
51
+ * export const collections = makeCollections(new URL('..', import.meta.url), env.KARAOKE_VAULT);
28
52
  */
29
- export function makeCollections(root: URL) {
53
+ export function makeCollections(
54
+ root: URL,
55
+ vault?: string,
56
+ configCollections?: Record<string, CollectionConfig>,
57
+ ) {
30
58
  const rootDir = fileURLToPath(root);
31
- return {
32
- // base paths are absolute, resolving content/ relative to the project root
33
- blog: defineCollection({
34
- loader: glob({ pattern: '**/*.md', base: join(rootDir, 'content/blog') }),
59
+ const vaultDir = vault
60
+ ? (isAbsolute(vault) ? vault : resolve(rootDir, vault))
61
+ : rootDir;
62
+ const isProd = import.meta.env.PROD;
63
+ const resolved = resolveCollections(vaultDir, isProd, configCollections);
64
+
65
+ const collections: Record<string, ReturnType<typeof defineCollection>> = {};
66
+
67
+ if (resolved.blog?.enabled) {
68
+ collections.blog = defineCollection({
69
+ loader: glob({ pattern: '**/*.md', base: join(vaultDir, 'blog') }),
35
70
  schema: baseSchema.extend({ comments: z.boolean().optional().default(true) }),
36
- }),
37
- docs: defineCollection({
38
- loader: glob({ pattern: '**/*.md', base: join(rootDir, 'content/docs') }),
71
+ });
72
+ }
73
+
74
+ if (resolved.docs?.enabled) {
75
+ collections.docs = defineCollection({
76
+ loader: glob({ pattern: '**/*.md', base: join(vaultDir, 'docs') }),
39
77
  schema: baseSchema.extend({ comments: z.boolean().optional().default(false) }),
40
- }),
41
- };
78
+ });
79
+ }
80
+
81
+ if (resolved['karaoke-cms']?.enabled) {
82
+ collections['karaoke-cms'] = defineCollection({
83
+ loader: glob({ pattern: '**/*.md', base: join(vaultDir, 'karaoke-cms') }),
84
+ schema: relaxedSchema,
85
+ });
86
+ }
87
+
88
+ // Custom collections defined in yaml or configCollections
89
+ for (const [name, col] of Object.entries(resolved)) {
90
+ if (!collections[name] && col.enabled) {
91
+ collections[name] = defineCollection({
92
+ loader: glob({ pattern: '**/*.md', base: join(vaultDir, name) }),
93
+ schema: relaxedSchema,
94
+ });
95
+ }
96
+ }
97
+
98
+ return collections;
42
99
  }
@@ -1,9 +1,17 @@
1
1
  ---
2
2
  import { getCollection } from 'astro:content';
3
3
 
4
- const posts = (await getCollection('blog', ({ data }) => data.publish === true))
4
+ const LIMIT = 5;
5
+
6
+ // Detect which collection we're in and exclude the current page
7
+ const pathname = Astro.url.pathname.replace(/\/$/, '');
8
+ const collection = pathname.startsWith('/docs/') ? 'docs' : 'blog';
9
+ const currentSlug = pathname.split('/').at(-1) ?? '';
10
+
11
+ const posts = (await getCollection(collection, ({ data }) => data.publish === true))
5
12
  .sort((a, b) => (b.data.date?.valueOf() ?? 0) - (a.data.date?.valueOf() ?? 0))
6
- .slice(0, 5);
13
+ .filter(post => post.id !== currentSlug)
14
+ .slice(0, LIMIT);
7
15
  ---
8
16
 
9
17
  {posts.length > 0 && (
@@ -12,7 +20,7 @@ const posts = (await getCollection('blog', ({ data }) => data.publish === true))
12
20
  <ul class="sidebar-list">
13
21
  {posts.map(post => (
14
22
  <li>
15
- <a href={`/blog/${post.id}`}>{post.data.title}</a>
23
+ <a href={`/${collection}/${post.id}`}>{post.data.title}</a>
16
24
  {post.data.date && (
17
25
  <span class="post-date">{post.data.date.toISOString().slice(0, 10)}</span>
18
26
  )}
package/src/index.ts CHANGED
@@ -1,12 +1,13 @@
1
1
  import type { AstroIntegration } from 'astro';
2
2
  import { fileURLToPath } from 'url';
3
- import { join } from 'path';
3
+ import { join, resolve, isAbsolute } from 'path';
4
4
  import { wikiLinkPlugin } from 'remark-wiki-link';
5
5
  import sitemap from '@astrojs/sitemap';
6
6
  import { getTheme, validateModules } from './validate-config.js';
7
7
  import { resolveModules } from './utils/resolve-modules.js';
8
8
  import { resolveLayout } from './utils/resolve-layout.js';
9
- import type { KaraokeConfig, ResolvedModules, ResolvedLayout } from './types.js';
9
+ import { resolveCollections } from './utils/resolve-collections.js';
10
+ import type { KaraokeConfig, ResolvedModules, ResolvedLayout, ResolvedCollections } from './types.js';
10
11
 
11
12
  const __dirname = fileURLToPath(new URL('.', import.meta.url));
12
13
 
@@ -17,6 +18,8 @@ const __dirname = fileURLToPath(new URL('.', import.meta.url));
17
18
  * /tags, /tags/[tag], /rss.xml, /404), sets up the @theme CSS alias,
18
19
  * enables wikilinks, and provides a virtual module with your resolved config.
19
20
  *
21
+ * The /karaoke-cms and /karaoke-cms/[...slug] routes are injected in dev only.
22
+ *
20
23
  * @example
21
24
  * // astro.config.mjs
22
25
  * import { defineConfig } from 'astro/config';
@@ -42,7 +45,14 @@ export default function karaoke(config: KaraokeConfig = {}): AstroIntegration {
42
45
  return {
43
46
  name: '@karaoke-cms/astro',
44
47
  hooks: {
45
- 'astro:config:setup'({ injectRoute, updateConfig }) {
48
+ 'astro:config:setup'({ injectRoute, updateConfig, command, config: astroConfig }) {
49
+ const rootDir = fileURLToPath(astroConfig.root);
50
+ const vaultDir = config.vault
51
+ ? (isAbsolute(config.vault) ? config.vault : resolve(rootDir, config.vault))
52
+ : rootDir;
53
+ const isProd = command === 'build';
54
+ const resolvedCollections = resolveCollections(vaultDir, isProd, config.collections);
55
+
46
56
  // ── Inject all framework routes ──────────────────────────────────
47
57
  injectRoute({ pattern: '/', entrypoint: '@karaoke-cms/astro/pages/index.astro' });
48
58
  injectRoute({ pattern: '/blog', entrypoint: '@karaoke-cms/astro/pages/blog/index.astro' });
@@ -54,6 +64,12 @@ export default function karaoke(config: KaraokeConfig = {}): AstroIntegration {
54
64
  injectRoute({ pattern: '/404', entrypoint: '@karaoke-cms/astro/pages/404.astro' });
55
65
  injectRoute({ pattern: '/rss.xml', entrypoint: '@karaoke-cms/astro/pages/rss.xml.ts' });
56
66
 
67
+ // ── Handbook routes — dev only ───────────────────────────────────
68
+ if (resolvedCollections['karaoke-cms']?.enabled) {
69
+ injectRoute({ pattern: '/karaoke-cms', entrypoint: '@karaoke-cms/astro/pages/karaoke-cms/index.astro' });
70
+ injectRoute({ pattern: '/karaoke-cms/[...slug]', entrypoint: '@karaoke-cms/astro/pages/karaoke-cms/[...slug].astro' });
71
+ }
72
+
57
73
  updateConfig({
58
74
  integrations: [sitemap()],
59
75
  vite: {
@@ -63,7 +79,7 @@ export default function karaoke(config: KaraokeConfig = {}): AstroIntegration {
63
79
  '@theme': join(themesDir, theme),
64
80
  },
65
81
  },
66
- plugins: [virtualConfigPlugin(config, resolved, layout)],
82
+ plugins: [virtualConfigPlugin(config, resolved, layout, resolvedCollections)],
67
83
  },
68
84
  markdown: {
69
85
  remarkPlugins: [
@@ -87,7 +103,12 @@ export default function karaoke(config: KaraokeConfig = {}): AstroIntegration {
87
103
  * Vite plugin providing `virtual:karaoke-cms/config`.
88
104
  * Base.astro and ModuleLoader.astro import from it at build time.
89
105
  */
90
- function virtualConfigPlugin(config: KaraokeConfig, resolved: ResolvedModules, layout: ResolvedLayout) {
106
+ function virtualConfigPlugin(
107
+ config: KaraokeConfig,
108
+ resolved: ResolvedModules,
109
+ layout: ResolvedLayout,
110
+ resolvedCollections: ResolvedCollections,
111
+ ) {
91
112
  const VIRTUAL_ID = 'virtual:karaoke-cms/config';
92
113
  const RESOLVED_ID = '\0' + VIRTUAL_ID;
93
114
 
@@ -103,10 +124,11 @@ export const siteTitle = ${JSON.stringify(config.title ?? 'Karaoke')};
103
124
  export const siteDescription = ${JSON.stringify(config.description ?? '')};
104
125
  export const resolvedModules = ${JSON.stringify(resolved)};
105
126
  export const resolvedLayout = ${JSON.stringify(layout)};
127
+ export const resolvedCollections = ${JSON.stringify(resolvedCollections)};
106
128
  `;
107
129
  }
108
130
  },
109
131
  };
110
132
  }
111
133
 
112
- export type { KaraokeConfig, ResolvedModules, ResolvedLayout, RegionComponent } from './types.js';
134
+ export type { KaraokeConfig, ResolvedModules, ResolvedLayout, ResolvedCollections, RegionComponent } from './types.js';
@@ -25,7 +25,7 @@ const posts = (await getCollection('blog', ({ data }) => data.publish === true))
25
25
  ) : (
26
26
  <div class="empty-state">
27
27
  <p>No posts published yet.</p>
28
- <p>Create a Markdown file in <code>content/blog/</code> and set <code>publish: true</code> in the frontmatter to make it appear here.</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
29
  </div>
30
30
  )}
31
31
  </Base>
@@ -25,7 +25,7 @@ const docs = (await getCollection('docs', ({ data }) => data.publish === true))
25
25
  ) : (
26
26
  <div class="empty-state">
27
27
  <p>No docs published yet.</p>
28
- <p>Create a Markdown file in <code>content/docs/</code> and set <code>publish: true</code> in the frontmatter to make it appear here.</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
29
  </div>
30
30
  )}
31
31
  </Base>
@@ -0,0 +1,44 @@
1
+ ---
2
+ import { getCollection, render } from 'astro:content';
3
+ import Base from '../../layouts/Base.astro';
4
+ import { SITE_TITLE } from '../../consts';
5
+
6
+ // Handbook is dev-only — this route is never injected in production.
7
+ export async function getStaticPaths() {
8
+ const entries = await getCollection('karaoke-cms');
9
+ return entries.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
+
19
+ <Base title={`${entry.data.title} — ${SITE_TITLE}`} description={entry.data.description}>
20
+ <article>
21
+ <div class="post-header">
22
+ <span class="handbook-badge">Handbook — dev only</span>
23
+ <h1>{entry.data.title}</h1>
24
+ </div>
25
+ <div class="prose">
26
+ <Content />
27
+ </div>
28
+ <div class="post-footer">
29
+ <a href="/karaoke-cms">← Handbook</a>
30
+ </div>
31
+ </article>
32
+ </Base>
33
+
34
+ <style>
35
+ .handbook-badge {
36
+ display: inline-block;
37
+ font-size: 0.75rem;
38
+ background: var(--color-accent, #f59e0b);
39
+ color: #000;
40
+ padding: 0.1rem 0.5rem;
41
+ border-radius: 999px;
42
+ margin-bottom: 0.5rem;
43
+ }
44
+ </style>
@@ -0,0 +1,43 @@
1
+ ---
2
+ import { getCollection } from 'astro:content';
3
+ import Base from '../../layouts/Base.astro';
4
+ import { SITE_TITLE } from '../../consts';
5
+
6
+ // Handbook is dev-only — this route is never injected in production.
7
+ // Show all entries (no publish filter — handbook pages are always visible in dev).
8
+ const entries = (await getCollection('karaoke-cms'))
9
+ .sort((a, b) => (a.data.title ?? '').localeCompare(b.data.title ?? ''));
10
+ ---
11
+
12
+ <Base title={`Handbook — ${SITE_TITLE}`}>
13
+ <div class="listing-header">
14
+ <h1>Handbook</h1>
15
+ <p class="handbook-badge">dev only</p>
16
+ </div>
17
+ {entries.length > 0 ? (
18
+ <ul class="post-list">
19
+ {entries.map(entry => (
20
+ <li>
21
+ <a href={`/karaoke-cms/${entry.id}`}>{entry.data.title}</a>
22
+ </li>
23
+ ))}
24
+ </ul>
25
+ ) : (
26
+ <div class="empty-state">
27
+ <p>No handbook pages found.</p>
28
+ <p>Add Markdown files to <code>content/karaoke-cms/</code> in your vault.</p>
29
+ </div>
30
+ )}
31
+ </Base>
32
+
33
+ <style>
34
+ .handbook-badge {
35
+ display: inline-block;
36
+ font-size: 0.75rem;
37
+ background: var(--color-accent, #f59e0b);
38
+ color: #000;
39
+ padding: 0.1rem 0.5rem;
40
+ border-radius: 999px;
41
+ margin-top: 0.25rem;
42
+ }
43
+ </style>
package/src/types.ts CHANGED
@@ -1,6 +1,30 @@
1
1
  export type RegionComponent = 'header' | 'main-menu' | 'search' | 'recent-posts' | 'footer';
2
2
 
3
+ export type CollectionMode = 'dev' | 'prod';
4
+
5
+ export interface CollectionConfig {
6
+ modes?: CollectionMode[];
7
+ label?: string;
8
+ }
9
+
10
+ export interface ResolvedCollection {
11
+ modes: CollectionMode[];
12
+ label: string;
13
+ enabled: boolean;
14
+ }
15
+
16
+ export type ResolvedCollections = Record<string, ResolvedCollection>;
17
+
3
18
  export interface KaraokeConfig {
19
+ /**
20
+ * Path to the Obsidian vault root (where content/ lives).
21
+ * Absolute, or relative to the Astro project root.
22
+ * Defaults to the project root (vault and project are the same directory).
23
+ * Typically set via KARAOKE_VAULT in .env (gitignored) with .env.default as fallback.
24
+ */
25
+ vault?: string;
26
+ /** Per-collection mode overrides. Merges with collections.yaml; this field takes precedence. */
27
+ collections?: Record<string, CollectionConfig>;
4
28
  /** Site title — displayed in the browser tab and nav bar. Defaults to 'Karaoke'. */
5
29
  title?: string;
6
30
  /** Site description — used in RSS feed and OG meta tags. */
@@ -0,0 +1,37 @@
1
+ import { readFileSync } from 'fs';
2
+ import { join } from 'path';
3
+ import { fileURLToPath } from 'url';
4
+
5
+ function parseEnvFile(filePath: string): Record<string, string> {
6
+ try {
7
+ const content = readFileSync(filePath, 'utf8');
8
+ const result: Record<string, string> = {};
9
+ for (const line of content.split('\n')) {
10
+ const trimmed = line.trim();
11
+ if (!trimmed || trimmed.startsWith('#')) continue;
12
+ const eq = trimmed.indexOf('=');
13
+ if (eq === -1) continue;
14
+ const key = trimmed.slice(0, eq).trim();
15
+ const val = trimmed.slice(eq + 1).trim().replace(/^["']|["']$/g, '');
16
+ if (key) result[key] = val;
17
+ }
18
+ return result;
19
+ } catch {
20
+ return {};
21
+ }
22
+ }
23
+
24
+ /**
25
+ * Load env vars from .env.default (committed defaults) and .env (local overrides).
26
+ * .env takes precedence over .env.default.
27
+ * Missing files are silently skipped.
28
+ *
29
+ * @param dir - URL or path of the directory containing the .env files.
30
+ */
31
+ export function loadEnv(dir: URL | string): Record<string, string> {
32
+ const dirPath = dir instanceof URL ? fileURLToPath(dir) : dir;
33
+ return {
34
+ ...parseEnvFile(join(dirPath, '.env.default')),
35
+ ...parseEnvFile(join(dirPath, '.env')),
36
+ };
37
+ }
@@ -0,0 +1,83 @@
1
+ import { readFileSync } from 'fs';
2
+ import { join } from 'path';
3
+ import { parse } from 'yaml';
4
+ import type { CollectionConfig, CollectionMode, ResolvedCollections } from '../types.js';
5
+
6
+ // Hardcoded defaults — used when collections.yaml is absent or unreadable.
7
+ // karaoke-cms is dev-only so it never enters the production build graph.
8
+ const DEFAULTS: Record<string, { modes: CollectionMode[]; label: string }> = {
9
+ blog: { modes: ['dev', 'prod'], label: 'Blog' },
10
+ docs: { modes: ['dev', 'prod'], label: 'Docs' },
11
+ 'karaoke-cms': { modes: ['dev'], label: 'Handbook' },
12
+ };
13
+
14
+ interface YamlShape {
15
+ collections?: Record<string, { modes?: string[]; label?: string }>;
16
+ }
17
+
18
+ /**
19
+ * Resolve the active content collections for the current build mode.
20
+ *
21
+ * Data flow:
22
+ * DEFAULTS
23
+ * ← merged with collections.yaml (YAML overrides defaults per-key)
24
+ * ← merged with configCollections (karaoke.config.ts overrides per-key)
25
+ * → filter by isProd
26
+ * → ResolvedCollections
27
+ *
28
+ * @param rootDir Absolute path to the vault root (where blog/, docs/, karaoke-cms/ live).
29
+ * @param isProd True during `astro build`, false during `astro dev`.
30
+ * @param configCollections Optional overrides from karaoke.config.ts `collections` field.
31
+ */
32
+ export function resolveCollections(
33
+ rootDir: string,
34
+ isProd: boolean,
35
+ configCollections?: Record<string, CollectionConfig>,
36
+ ): ResolvedCollections {
37
+ // 1. Read and parse collections.yaml (graceful fallback to defaults if absent)
38
+ let yamlCollections: Record<string, { modes?: CollectionMode[]; label?: string }> = {};
39
+ try {
40
+ const yamlPath = join(rootDir, 'karaoke-cms/config/collections.yaml');
41
+ const raw = readFileSync(yamlPath, 'utf8');
42
+ const parsed = parse(raw) as YamlShape;
43
+ if (parsed?.collections && typeof parsed.collections === 'object') {
44
+ for (const [name, val] of Object.entries(parsed.collections)) {
45
+ yamlCollections[name] = {
46
+ modes: Array.isArray(val?.modes)
47
+ ? (val.modes.filter(m => m === 'dev' || m === 'prod') as CollectionMode[])
48
+ : undefined,
49
+ label: typeof val?.label === 'string' ? val.label : undefined,
50
+ };
51
+ }
52
+ }
53
+ } catch {
54
+ // File absent or unreadable — use hardcoded defaults
55
+ }
56
+
57
+ // 2. Merge: DEFAULTS ← yaml ← configCollections
58
+ const allNames = new Set([
59
+ ...Object.keys(DEFAULTS),
60
+ ...Object.keys(yamlCollections),
61
+ ...(configCollections ? Object.keys(configCollections) : []),
62
+ ]);
63
+
64
+ const result: ResolvedCollections = {};
65
+ for (const name of allNames) {
66
+ const base = DEFAULTS[name];
67
+ const yaml = yamlCollections[name];
68
+ const cfg = configCollections?.[name];
69
+
70
+ const modes: CollectionMode[] =
71
+ cfg?.modes ?? yaml?.modes ?? base?.modes ?? ['dev', 'prod'];
72
+ const label: string =
73
+ cfg?.label ?? yaml?.label ?? base?.label ?? name;
74
+
75
+ result[name] = {
76
+ modes,
77
+ label,
78
+ enabled: isProd ? modes.includes('prod') : modes.includes('dev'),
79
+ };
80
+ }
81
+
82
+ return result;
83
+ }