@karaoke-cms/astro 0.6.2 → 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/src/index.ts CHANGED
@@ -1,21 +1,40 @@
1
1
  import type { AstroIntegration } from 'astro';
2
- import { fileURLToPath } from 'url';
3
- import { join } from 'path';
2
+ import { fileURLToPath, pathToFileURL } from 'url';
3
+ import { resolve, isAbsolute } from 'path';
4
+ import { createRequire } from 'module';
4
5
  import { wikiLinkPlugin } from 'remark-wiki-link';
5
6
  import sitemap from '@astrojs/sitemap';
6
- import { getTheme, validateModules } from './validate-config.js';
7
+ import { validateModules } from './validate-config.js';
7
8
  import { resolveModules } from './utils/resolve-modules.js';
8
9
  import { resolveLayout } from './utils/resolve-layout.js';
9
- import type { KaraokeConfig, ResolvedModules, ResolvedLayout } from './types.js';
10
+ import { resolveCollections } from './utils/resolve-collections.js';
11
+ import { resolveMenus } from './utils/resolve-menus.js';
12
+ import type { KaraokeConfig, ResolvedModules, ResolvedLayout, ResolvedCollections, ResolvedMenus } from './types.js';
10
13
 
11
- const __dirname = fileURLToPath(new URL('.', import.meta.url));
14
+ /**
15
+ * Resolve a theme config value to an npm package name.
16
+ * Bare strings ('default', 'minimal') map to @karaoke-cms/theme-* with a deprecation warning.
17
+ */
18
+ function resolveThemePkg(theme: string | undefined): string {
19
+ const raw = theme ?? '@karaoke-cms/theme-default';
20
+ if (raw === 'default' || raw === 'minimal') {
21
+ console.warn(
22
+ `[karaoke-cms] theme: '${raw}' is deprecated. Use theme: '@karaoke-cms/theme-${raw}' instead.`,
23
+ );
24
+ return `@karaoke-cms/theme-${raw}`;
25
+ }
26
+ return raw;
27
+ }
12
28
 
13
29
  /**
14
30
  * karaoke() — the main Astro integration for karaoke-cms.
15
31
  *
16
- * Injects all routes (/, /blog, /blog/[slug], /docs, /docs/[slug],
17
- * /tags, /tags/[tag], /rss.xml, /404), sets up the @theme CSS alias,
18
- * enables wikilinks, and provides a virtual module with your resolved config.
32
+ * Loads the active theme package (an Astro integration) and registers it
33
+ * as a nested integration. The theme owns all content routes (/, /blog, /docs,
34
+ * etc.), the @theme CSS alias, and its own 404 page.
35
+ *
36
+ * Core always registers: /rss.xml, /karaoke-cms/[...slug] (dev only), wikilinks,
37
+ * sitemap, and the virtual:karaoke-cms/config module.
19
38
  *
20
39
  * @example
21
40
  * // astro.config.mjs
@@ -27,50 +46,66 @@ const __dirname = fileURLToPath(new URL('.', import.meta.url));
27
46
  * site: 'https://your-site.pages.dev',
28
47
  * integrations: [karaoke(karaokeConfig)],
29
48
  * });
30
- *
31
- * Module slots (contract for Base.astro — extensible in v0.7+):
32
- * 'nav' — rendered in the nav bar (e.g., Search)
33
- * 'post-footer' — rendered after post content (e.g., Comments)
34
49
  */
35
50
  export default function karaoke(config: KaraokeConfig = {}): AstroIntegration {
36
- const themesDir = join(__dirname, 'themes');
37
- const theme = getTheme(config, themesDir);
38
51
  validateModules(config);
39
52
  const resolved = resolveModules(config);
40
53
  const layout = resolveLayout(config);
41
54
 
55
+ let _resolvedCollections: ResolvedCollections | undefined;
56
+
42
57
  return {
43
58
  name: '@karaoke-cms/astro',
44
59
  hooks: {
45
- 'astro:config:setup'({ injectRoute, updateConfig }) {
46
- // ── Inject all framework routes ──────────────────────────────────
47
- injectRoute({ pattern: '/', entrypoint: '@karaoke-cms/astro/pages/index.astro' });
48
- injectRoute({ pattern: '/blog', entrypoint: '@karaoke-cms/astro/pages/blog/index.astro' });
49
- injectRoute({ pattern: '/blog/[slug]', entrypoint: '@karaoke-cms/astro/pages/blog/[slug].astro' });
50
- injectRoute({ pattern: '/docs', entrypoint: '@karaoke-cms/astro/pages/docs/index.astro' });
51
- injectRoute({ pattern: '/docs/[slug]', entrypoint: '@karaoke-cms/astro/pages/docs/[slug].astro' });
52
- injectRoute({ pattern: '/tags', entrypoint: '@karaoke-cms/astro/pages/tags/index.astro' });
53
- injectRoute({ pattern: '/tags/[tag]', entrypoint: '@karaoke-cms/astro/pages/tags/[tag].astro' });
54
- injectRoute({ pattern: '/404', entrypoint: '@karaoke-cms/astro/pages/404.astro' });
60
+ async 'astro:config:setup'({ injectRoute, updateConfig, command, config: astroConfig }) {
61
+ const rootDir = fileURLToPath(astroConfig.root);
62
+ const vaultDir = config.vault
63
+ ? (isAbsolute(config.vault) ? config.vault : resolve(rootDir, config.vault))
64
+ : rootDir;
65
+ const isProd = command === 'build';
66
+ _resolvedCollections = resolveCollections(vaultDir, isProd, config.collections);
67
+ const _resolvedMenus = resolveMenus(vaultDir);
68
+
69
+ // ── Load active theme as a nested Astro integration ───────────────
70
+ // Resolve the theme package from the user's project root, not from
71
+ // this package's location, so the user's node_modules is searched.
72
+ const themePkg = resolveThemePkg(config.theme);
73
+ const projectRequire = createRequire(new URL('package.json', astroConfig.root));
74
+ let resolvedThemePath: string;
75
+ try {
76
+ resolvedThemePath = projectRequire.resolve(themePkg);
77
+ } catch {
78
+ throw new Error(
79
+ `[karaoke-cms] Theme package "${themePkg}" is not installed.\n` +
80
+ `Run: pnpm add ${themePkg}`,
81
+ );
82
+ }
83
+ // Use new Function to bypass Vite's import() interception —
84
+ // this runs the native Node.js ESM loader, not Vite's module runner.
85
+ const nativeImport: (specifier: string) => Promise<unknown> =
86
+ new Function('specifier', 'return import(specifier)');
87
+ const themeModule = await nativeImport(pathToFileURL(resolvedThemePath).href) as {
88
+ default: (cfg: KaraokeConfig) => AstroIntegration;
89
+ };
90
+ const themeIntegration = themeModule.default(config);
91
+
92
+ // ── Core routes — always present regardless of theme ─────────────
55
93
  injectRoute({ pattern: '/rss.xml', entrypoint: '@karaoke-cms/astro/pages/rss.xml.ts' });
56
94
 
95
+ // Handbook routes — dev only
96
+ if (_resolvedCollections['karaoke-cms']?.enabled) {
97
+ injectRoute({ pattern: '/karaoke-cms', entrypoint: '@karaoke-cms/astro/pages/karaoke-cms/index.astro' });
98
+ injectRoute({ pattern: '/karaoke-cms/[...slug]', entrypoint: '@karaoke-cms/astro/pages/karaoke-cms/[...slug].astro' });
99
+ }
100
+
57
101
  updateConfig({
58
- integrations: [sitemap()],
102
+ integrations: [sitemap(), themeIntegration],
59
103
  vite: {
60
- resolve: {
61
- alias: {
62
- // @theme → packages/astro/src/themes/<active-theme>/
63
- '@theme': join(themesDir, theme),
64
- },
65
- },
66
- plugins: [virtualConfigPlugin(config, resolved, layout)],
104
+ plugins: [virtualConfigPlugin(config, resolved, layout, _resolvedCollections, _resolvedMenus)],
67
105
  },
68
106
  markdown: {
69
107
  remarkPlugins: [
70
108
  [wikiLinkPlugin, {
71
- // [[blog/hello-world]] → /blog/hello-world/
72
- // [[docs/getting-started]] → /docs/getting-started/
73
- // [[blog/hello-world|Hello World]] → link text "Hello World"
74
109
  pageResolver: (name: string) => [name.toLowerCase().replace(/ /g, '-')],
75
110
  hrefTemplate: (permalink: string) => `/${permalink}/`,
76
111
  aliasDivider: '|',
@@ -79,6 +114,19 @@ export default function karaoke(config: KaraokeConfig = {}): AstroIntegration {
79
114
  },
80
115
  });
81
116
  },
117
+
118
+ 'astro:build:done'({ pages }) {
119
+ if (!_resolvedCollections?.docs?.enabled) return;
120
+ // pages is { pathname: string }[] — check if any /docs page was rendered
121
+ const hasDocsRoute = (pages ?? []).some(p => p.pathname?.startsWith('docs'));
122
+ if (!hasDocsRoute) {
123
+ console.warn(
124
+ '[karaoke-cms] Your vault has a docs collection enabled but the active theme ' +
125
+ 'has no /docs route. Published docs entries will not be accessible. ' +
126
+ 'Consider using @karaoke-cms/theme-default which includes /docs routes.',
127
+ );
128
+ }
129
+ },
82
130
  },
83
131
  };
84
132
  }
@@ -87,7 +135,13 @@ export default function karaoke(config: KaraokeConfig = {}): AstroIntegration {
87
135
  * Vite plugin providing `virtual:karaoke-cms/config`.
88
136
  * Base.astro and ModuleLoader.astro import from it at build time.
89
137
  */
90
- function virtualConfigPlugin(config: KaraokeConfig, resolved: ResolvedModules, layout: ResolvedLayout) {
138
+ function virtualConfigPlugin(
139
+ config: KaraokeConfig,
140
+ resolved: ResolvedModules,
141
+ layout: ResolvedLayout,
142
+ resolvedCollections: ResolvedCollections,
143
+ resolvedMenus: ResolvedMenus,
144
+ ) {
91
145
  const VIRTUAL_ID = 'virtual:karaoke-cms/config';
92
146
  const RESOLVED_ID = '\0' + VIRTUAL_ID;
93
147
 
@@ -103,10 +157,12 @@ export const siteTitle = ${JSON.stringify(config.title ?? 'Karaoke')};
103
157
  export const siteDescription = ${JSON.stringify(config.description ?? '')};
104
158
  export const resolvedModules = ${JSON.stringify(resolved)};
105
159
  export const resolvedLayout = ${JSON.stringify(layout)};
160
+ export const resolvedCollections = ${JSON.stringify(resolvedCollections)};
161
+ export const resolvedMenus = ${JSON.stringify(resolvedMenus)};
106
162
  `;
107
163
  }
108
164
  },
109
165
  };
110
166
  }
111
167
 
112
- export type { KaraokeConfig, ResolvedModules, ResolvedLayout, RegionComponent } from './types.js';
168
+ export type { KaraokeConfig, ResolvedModules, ResolvedLayout, ResolvedCollections, RegionComponent, ResolvedMenus, ResolvedMenu, ResolvedMenuEntry } from './types.js';
@@ -1,8 +1,7 @@
1
1
  ---
2
2
  import '@theme/styles.css';
3
3
  import RegionRenderer from '../components/RegionRenderer.astro';
4
- import { resolvedModules, resolvedLayout } from 'virtual:karaoke-cms/config';
5
- import { SITE_TITLE } from '../consts';
4
+ import { siteTitle, resolvedModules, resolvedLayout } from 'virtual:karaoke-cms/config';
6
5
 
7
6
  interface Props {
8
7
  title: string;
@@ -28,14 +27,14 @@ const hasRight = Astro.slots.has('right') || layout.regions.right.components.len
28
27
  <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
29
28
  <link rel="icon" href="/favicon.ico" />
30
29
  <meta name="generator" content={Astro.generator} />
31
- <title>{title === SITE_TITLE ? title : `${title} — ${SITE_TITLE}`}</title>
30
+ <title>{title === siteTitle ? title : `${title} — ${siteTitle}`}</title>
32
31
  {description && <meta name="description" content={description} />}
33
32
  <meta property="og:title" content={title} />
34
33
  <meta property="og:type" content={type} />
35
34
  <meta property="og:url" content={canonicalUrl} />
36
35
  {description && <meta property="og:description" content={description} />}
37
36
  <meta name="twitter:card" content="summary" />
38
- <link rel="alternate" type="application/rss+xml" title={SITE_TITLE} href="/rss.xml" />
37
+ <link rel="alternate" type="application/rss+xml" title={siteTitle} href="/rss.xml" />
39
38
  </head>
40
39
  <body>
41
40
  <header>
@@ -65,9 +64,11 @@ const hasRight = Astro.slots.has('right') || layout.regions.right.components.len
65
64
  )}
66
65
  </div>
67
66
  <footer>
68
- <slot name="bottom">
69
- <RegionRenderer components={layout.regions.bottom.components} {searchEnabled} />
70
- </slot>
67
+ <div class="footer-inner">
68
+ <slot name="bottom">
69
+ <RegionRenderer components={layout.regions.bottom.components} {searchEnabled} />
70
+ </slot>
71
+ </div>
71
72
  </footer>
72
73
  </body>
73
74
  </html>
@@ -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. */
@@ -49,3 +73,37 @@ export interface ResolvedLayout {
49
73
  bottom: { components: RegionComponent[] };
50
74
  };
51
75
  }
76
+
77
+ // ── Menu types ─────────────────────────────────────────────────────────────────
78
+
79
+ /** Raw shape of one entry in menus.yaml (used to type the parsed YAML). */
80
+ export interface MenuEntryConfig {
81
+ text: string;
82
+ href?: string;
83
+ weight?: number;
84
+ /** Visibility condition — only 'collection:name' supported in v1. */
85
+ when?: string;
86
+ entries?: MenuEntryConfig[];
87
+ }
88
+
89
+ /** Raw shape of one menu block in menus.yaml. */
90
+ export interface MenuConfig {
91
+ orientation?: 'horizontal' | 'vertical';
92
+ entries?: MenuEntryConfig[];
93
+ }
94
+
95
+ export interface ResolvedMenuEntry {
96
+ text: string;
97
+ href?: string;
98
+ weight: number;
99
+ when?: string;
100
+ entries: ResolvedMenuEntry[];
101
+ }
102
+
103
+ export interface ResolvedMenu {
104
+ name: string;
105
+ orientation: 'horizontal' | 'vertical';
106
+ entries: ResolvedMenuEntry[];
107
+ }
108
+
109
+ export type ResolvedMenus = Record<string, ResolvedMenu>;
@@ -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
+ }
@@ -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
+ }
@@ -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
- }
@@ -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>