@karaoke-cms/astro 0.9.3 → 0.9.6

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 CHANGED
@@ -1,10 +1,14 @@
1
1
  # @karaoke-cms/astro
2
2
 
3
- Core Astro integration for karaoke-cms. Every karaoke-cms project depends on this package.
3
+ Core Astro integration for karaoke-cms. Wires up themes, modules, collections, menus, and the virtual config module at build time. Every karaoke-cms project depends on this package.
4
4
 
5
- ## Where it belongs
5
+ ## Installation
6
6
 
7
- `packages/astro/` in the monorepo. Users install it as a dependency and register it in `astro.config.mjs`:
7
+ ```bash
8
+ npm install @karaoke-cms/astro
9
+ ```
10
+
11
+ ## Usage
8
12
 
9
13
  ```js
10
14
  // astro.config.mjs
@@ -18,77 +22,102 @@ export default defineConfig({
18
22
  });
19
23
  ```
20
24
 
21
- ## What it does
25
+ ```ts
26
+ // karaoke.config.ts
27
+ import { defineConfig } from '@karaoke-cms/astro';
28
+ import { loadEnv } from '@karaoke-cms/astro/env';
29
+ import { blog } from '@karaoke-cms/module-blog';
30
+ import { docs } from '@karaoke-cms/module-docs';
31
+ import { themeDefault } from '@karaoke-cms/theme-default';
22
32
 
23
- `karaoke(config)` is an Astro integration that wires up the full karaoke-cms runtime at build time:
33
+ const env = loadEnv(new URL('.', import.meta.url));
24
34
 
25
- - **Loads the active theme** as a nested Astro integration (resolves by package name from `config.theme`)
26
- - **Injects core routes**: `/rss.xml` always; `/karaoke-cms` and `/karaoke-cms/[...slug]` in dev mode only
27
- - **Exposes a virtual module** (`virtual:karaoke-cms/config`) with all resolved config — title, description, collections, menus, layout, modules
28
- - **Registers wikilinks** via `remark-wiki-link` (`[[note]]` → `/note/`, `[[note|text]]` with alias)
29
- - **Registers sitemap** via `@astrojs/sitemap`
35
+ export default defineConfig({
36
+ vault: env.KARAOKE_VAULT,
37
+ title: 'My Site',
38
+ description: 'What this site is about.',
39
+ theme: themeDefault({
40
+ implements: [blog({ mount: '/blog' }), docs({ mount: '/docs' })],
41
+ }),
42
+ });
43
+ ```
30
44
 
31
- ### Virtual module
45
+ ## Configuration
32
46
 
33
- Every page and component in the framework reads config from `virtual:karaoke-cms/config`:
47
+ All fields are optional. Defaults produce a working site with no content.
34
48
 
35
- ```ts
36
- import {
37
- siteTitle, // string
38
- siteDescription, // string
39
- resolvedCollections, // Record<string, ResolvedCollection>
40
- resolvedMenus, // Record<string, ResolvedMenu>
41
- resolvedLayout, // { regions: { top, left, right, bottom } }
42
- resolvedModules, // { search: { enabled }, comments: { enabled, ... } }
43
- } from 'virtual:karaoke-cms/config';
44
- ```
49
+ | Field | Type | Default | Description |
50
+ |-------|------|---------|-------------|
51
+ | `vault` | `string` | project root | Path to the Obsidian vault directory (absolute or relative) |
52
+ | `title` | `string` | `'Karaoke'` | Site title for browser tab and nav |
53
+ | `description` | `string` | `''` | Site description for RSS and OG tags |
54
+ | `theme` | `ThemeInstance` | — | Theme from `themeDefault()` or similar |
55
+ | `modules` | `ModuleInstance[]` | `[]` | Additional modules not covered by the theme |
56
+ | `comments` | `CommentsConfig` | | Giscus comments config |
57
+ | `collections` | `Record<string, CollectionConfig>` | — | Per-collection mode overrides |
58
+ | `layout.regions` | `{ top, left, right, bottom }` | — | Region component lists |
45
59
 
46
- Add to your `src/env.d.ts` to get TypeScript types:
60
+ ## Exports
47
61
 
48
- ```ts
49
- /// <reference types="@karaoke-cms/astro/client" />
50
- ```
62
+ ### `@karaoke-cms/astro` — main integration
51
63
 
52
- ### Collections
64
+ - `karaoke(config)` — Astro integration (default export)
65
+ - `defineConfig(config)` — identity wrapper for type inference
66
+ - `defineModule(def)` — create a module factory
67
+ - `defineTheme(def)` — create a theme factory
53
68
 
54
- `makeCollections()` creates Astro content collections from the vault:
69
+ ### `@karaoke-cms/astro/env`
55
70
 
56
- ```ts
57
- // src/content.config.ts
58
- import { makeCollections } from '@karaoke-cms/astro/collections';
59
- import { loadEnv } from '@karaoke-cms/astro/env';
71
+ - `loadEnv(dir)` — reads `.env.default` and `.env` from `dir`, returns merged record
72
+
73
+ ### `@karaoke-cms/astro/collections`
74
+
75
+ - `makeCollections(root, vault?, collections?)` — creates Astro content collections from the vault
76
+
77
+ ### `virtual:karaoke-cms/config`
78
+
79
+ Available in any page or component at build time:
60
80
 
61
- const { KARAOKE_VAULT } = loadEnv(new URL('..', import.meta.url));
62
- export const collections = makeCollections(import.meta.url, KARAOKE_VAULT);
81
+ ```ts
82
+ import {
83
+ siteTitle, // string
84
+ siteDescription, // string
85
+ resolvedCollections, // Record<string, { modes, label, enabled }>
86
+ resolvedMenus, // Record<string, { name, orientation, entries }>
87
+ resolvedLayout, // { regions: { top, left, right, bottom } }
88
+ resolvedModules, // { comments: { enabled, repo, repoId, category, categoryId } }
89
+ blogMount, // string — the blog module's mount path
90
+ } from 'virtual:karaoke-cms/config';
63
91
  ```
64
92
 
65
- Collections are glob-loaded from `{vault}/blog/`, `{vault}/docs/`, and `{vault}/karaoke-cms/`. Only files with `publish: true` are included in production builds.
93
+ Add to `src/env.d.ts` for TypeScript types:
66
94
 
67
- ### Menus
95
+ ```ts
96
+ /// <reference types="@karaoke-cms/astro/client" />
97
+ ```
68
98
 
69
- `resolveMenus(vaultDir)` reads `{vault}/karaoke-cms/config/menus.yaml` and returns `ResolvedMenus`. When the file is absent, defaults are generated: a `main` menu with Blog/Docs/Tags (each hidden when the collection is empty) and a `footer` menu with the RSS link.
99
+ ## Menus
70
100
 
71
- The `Menu.astro` and `MenuItems.astro` components render named menus with `when: collection:name` visibility guards evaluated at render time via `getCollection()`.
101
+ Define menus in `{vault}/karaoke-cms/config/menus.yaml`. When absent, defaults are generated: a `main` menu with Blog/Docs/Tags and a `footer` menu with the RSS link. Entries support `when: collection:name` to hide automatically when a collection is empty.
72
102
 
73
- ### Layout regions
103
+ ## Layout regions
74
104
 
75
- The `Base.astro` layout renders four configurable regions (top, left, right, bottom) using `RegionRenderer.astro`. Each region's component list is set in `karaoke.config.ts`:
105
+ Four regions (top, left, right, bottom) accept component lists from `karaoke.config.ts`:
76
106
 
77
107
  ```ts
78
108
  layout: {
79
109
  regions: {
80
- top: { components: ['header', 'main-menu'] },
110
+ top: { components: ['header', 'main-menu'] },
111
+ right: { components: ['recent-posts'] },
81
112
  bottom: { components: ['footer'] },
82
113
  }
83
114
  }
84
115
  ```
85
116
 
86
- Available region components: `'header'`, `'main-menu'`, `'search'`, `'recent-posts'`, `'footer'`.
117
+ Available components: `'header'`, `'main-menu'`, `'search'`, `'recent-posts'`, `'footer'`.
87
118
 
88
- ## How it changes the behavior of the system
119
+ ## What's new in 0.9.5
89
120
 
90
- - Without this package there is no karaoke-cms site it is the root of the dependency graph.
91
- - It is the only place where vault config (`.env`, `collections.yaml`, `menus.yaml`) is read and compiled into the virtual module. Everything else reads from that module at build time.
92
- - It controls which pages exist: core routes (RSS, handbook) are always present; all other routes come from the active theme.
93
- - Privacy enforcement flows through `makeCollections()`: the `publish: true` filter is applied at the collection level, so unpublished content is never passed to Astro's static generator.
94
- - Theme loading uses a native `import()` bypass to avoid Vite interception, which is why theme packages ship `.astro` source files instead of pre-compiled output.
121
+ - **`isThemeInstance` type-guard** added fixes a runtime crash in the `astro:config:setup` hook when a `ThemeInstance` was passed
122
+ - **Module array API** `modules` in config is now `ModuleInstance[]` (was a legacy object shape in some docs)
123
+ - **Tighter `astro.config.mjs` error handling** missing `karaoke.config.ts` is now distinguished from missing dependencies of that file; the latter now surfaces the real error instead of silently falling back to empty config
package/client.d.ts CHANGED
@@ -20,4 +20,6 @@ declare module 'virtual:karaoke-cms/config' {
20
20
  export const resolvedCollections: ResolvedCollections;
21
21
  /** Resolved menus from menus.yaml (defaults to main + footer when absent) */
22
22
  export const resolvedMenus: ResolvedMenus;
23
+ /** Mount path of the blog module (e.g. '/blog' or '/news'). Defaults to '/blog'. */
24
+ export const blogMount: string;
23
25
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@karaoke-cms/astro",
3
3
  "type": "module",
4
- "version": "0.9.3",
4
+ "version": "0.9.6",
5
5
  "description": "Core Astro integration for karaoke-cms — virtual config, wikilinks, handbook routes",
6
6
  "main": "./src/index.ts",
7
7
  "exports": {
@@ -43,6 +43,6 @@
43
43
  "astro": "^6.0.8"
44
44
  },
45
45
  "scripts": {
46
- "test": "vitest run test/validate-config.test.js test/theme-loading.test.js test/resolve-modules.test.js test/resolve-layout.test.js test/resolve-collections.test.js test/collections.test.js test/load-env.test.js test/resolve-menus.test.js test/define-module.test.js test/define-theme.test.js"
46
+ "test": "vitest run test/validate-config.test.js test/theme-loading.test.js test/resolve-modules.test.js test/resolve-layout.test.js test/resolve-collections.test.js test/collections.test.js test/load-env.test.js test/resolve-menus.test.js test/define-module.test.js test/define-theme.test.js test/theme-default-styles.test.js test/module-injection.test.js"
47
47
  }
48
48
  }
@@ -16,6 +16,7 @@ const baseSchema = z.object({
16
16
  description: z.string().optional(),
17
17
  reading_time: z.number().optional(),
18
18
  related: z.array(z.string()).optional(),
19
+ featured_image: z.string().optional(),
19
20
  });
20
21
 
21
22
  // Relaxed schema for handbook / custom collections — title is optional
@@ -89,7 +90,10 @@ export function makeCollections(
89
90
  if (resolved.docs?.enabled) {
90
91
  collections.docs = defineCollection({
91
92
  loader: glob({ pattern: '**/*.md', base: join(vaultDir, 'docs') }),
92
- schema: baseSchema.extend({ comments: z.boolean().optional().default(false) }),
93
+ schema: baseSchema.extend({
94
+ featured_image: z.string().optional(),
95
+ comments: z.boolean().optional().default(false),
96
+ }),
93
97
  });
94
98
  }
95
99
 
@@ -11,10 +11,10 @@ import type { RegionComponent } from '../types.js';
11
11
 
12
12
  interface Props {
13
13
  components: RegionComponent[];
14
- searchEnabled: boolean;
14
+ searchEnabled?: boolean;
15
15
  }
16
16
 
17
- const { components, searchEnabled } = Astro.props;
17
+ const { components, searchEnabled = false } = Astro.props;
18
18
  ---
19
19
 
20
20
  {components.includes('header') && <SiteHeader />}
@@ -3,10 +3,16 @@ import type { ModuleInstance, ModuleMenuEntry } from './types.js';
3
3
  export interface ModuleDefinition {
4
4
  id: string;
5
5
  cssContract: readonly string[];
6
- defaultCss?: () => Promise<unknown>;
6
+ /** Absolute path to a default CSS file. On first dev run, copied to src/styles/{id}.css. */
7
+ defaultCssPath?: string;
7
8
  routes: (mount: string) => Array<{ pattern: string; entrypoint: string }>;
8
9
  menuEntries: (mount: string, id: string) => ModuleMenuEntry[];
9
10
  collection?: () => unknown;
11
+ /**
12
+ * Pages to scaffold into the user's src/pages/ on first dev run.
13
+ * src: absolute path inside the npm package. dest: relative to src/pages/.
14
+ */
15
+ scaffoldPages?: (mount: string) => Array<{ src: string; dest: string }>;
10
16
  }
11
17
 
12
18
  /**
@@ -22,17 +28,21 @@ export interface ModuleDefinition {
22
28
  * modules: [blog({ mount: '/blog' })]
23
29
  */
24
30
  export function defineModule(def: ModuleDefinition) {
25
- return function moduleFactory(config: { id?: string; mount: string }): ModuleInstance {
31
+ return function moduleFactory(config: { id?: string; mount: string; enabled?: boolean }): ModuleInstance {
26
32
  const id = config.id ?? def.id;
27
33
  const mount = config.mount.replace(/\/$/, '');
34
+ const enabled = config.enabled ?? true;
28
35
  return {
29
36
  _type: 'module-instance',
30
37
  id,
31
38
  mount,
39
+ enabled,
32
40
  routes: def.routes(mount),
33
41
  menuEntries: def.menuEntries(mount, id),
34
42
  cssContract: def.cssContract,
35
- hasDefaultCss: !!def.defaultCss,
43
+ hasDefaultCss: !!def.defaultCssPath,
44
+ scaffoldPages: def.scaffoldPages?.(mount),
45
+ defaultCssPath: def.defaultCssPath,
36
46
  };
37
47
  };
38
48
  }
@@ -1,37 +1,34 @@
1
1
  import type { AstroIntegration } from 'astro';
2
2
  import type { ModuleInstance, ThemeInstance } from './types.js';
3
3
 
4
- export interface ThemeFactoryConfig {
5
- implements?: ModuleInstance[];
6
- }
4
+ export interface ThemeFactoryConfig {}
7
5
 
8
6
  export interface ThemeDefinition {
9
7
  id: string;
10
- toAstroIntegration: (config: ThemeFactoryConfig) => AstroIntegration;
8
+ toAstroIntegration: (config: ThemeFactoryConfig, modules: ModuleInstance[]) => AstroIntegration;
11
9
  }
12
10
 
13
11
  /**
14
12
  * Define a karaoke-cms theme.
15
13
  *
16
- * Returns a factory function. Call the factory with `{ implements: [...] }` to
17
- * declare which module instances this theme provides CSS for. The result is a
18
- * ThemeInstance that can be passed to `defineConfig({ theme: ... })`.
14
+ * Returns a factory function. Call the factory (optionally with config) to get a
15
+ * ThemeInstance that can be passed to `defineConfig({ theme: ... })`. Modules are
16
+ * passed by the karaoke() integration at build time via `toAstroIntegration(modules)`.
19
17
  *
20
18
  * @example
21
19
  * export const themeDefault = defineTheme({
22
20
  * id: 'theme-default',
23
- * toAstroIntegration: (config) => ({ name: '...', hooks: { ... } }),
21
+ * toAstroIntegration: (config, modules) => ({ name: '...', hooks: { ... } }),
24
22
  * })
25
23
  * // In karaoke.config.ts:
26
- * theme: themeDefault({ implements: [blog({ mount: '/blog' })] })
24
+ * theme: themeDefault()
27
25
  */
28
26
  export function defineTheme(def: ThemeDefinition) {
29
27
  return function themeFactory(config: ThemeFactoryConfig = {}): ThemeInstance {
30
28
  return {
31
29
  _type: 'theme-instance',
32
30
  id: def.id,
33
- implementedModuleIds: (config.implements ?? []).map(m => m.id),
34
- toAstroIntegration: () => def.toAstroIntegration(config),
31
+ toAstroIntegration: (modules: ModuleInstance[]) => def.toAstroIntegration(config, modules),
35
32
  };
36
33
  };
37
34
  }
package/src/index.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import type { AstroIntegration } from 'astro';
2
- import { fileURLToPath, pathToFileURL } from 'url';
3
- import { resolve, isAbsolute } from 'path';
4
- import { createRequire } from 'module';
2
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
3
+ import { fileURLToPath } from 'url';
4
+ import { resolve, isAbsolute, dirname } from 'path';
5
5
  import { wikiLinkPlugin } from 'remark-wiki-link';
6
6
  import sitemap from '@astrojs/sitemap';
7
7
  import { validateModules } from './validate-config.js';
@@ -9,34 +9,15 @@ import { resolveModules } from './utils/resolve-modules.js';
9
9
  import { resolveLayout } from './utils/resolve-layout.js';
10
10
  import { resolveCollections } from './utils/resolve-collections.js';
11
11
  import { resolveMenus } from './utils/resolve-menus.js';
12
- import type { KaraokeConfig, ResolvedModules, ResolvedLayout, ResolvedCollections, ResolvedMenus, ThemeInstance } from './types.js';
13
-
14
- function isThemeInstance(theme: unknown): theme is ThemeInstance {
15
- return typeof theme === 'object' && theme !== null &&
16
- (theme as ThemeInstance)._type === 'theme-instance';
17
- }
18
-
19
- /**
20
- * Resolve a theme config value to an npm package name.
21
- * Bare strings ('default', 'minimal') map to @karaoke-cms/theme-* with a deprecation warning.
22
- */
23
- function resolveThemePkg(theme: string | undefined): string {
24
- const raw = theme ?? '@karaoke-cms/theme-default';
25
- if (raw === 'default' || raw === 'minimal') {
26
- console.warn(
27
- `[karaoke-cms] theme: '${raw}' is deprecated. Use theme: '@karaoke-cms/theme-${raw}' instead.`,
28
- );
29
- return `@karaoke-cms/theme-${raw}`;
30
- }
31
- return raw;
32
- }
12
+ import type { KaraokeConfig, ResolvedModules, ResolvedLayout, ResolvedCollections, ResolvedMenus } from './types.js';
33
13
 
34
14
  /**
35
15
  * karaoke() — the main Astro integration for karaoke-cms.
36
16
  *
37
- * Loads the active theme package (an Astro integration) and registers it
38
- * as a nested integration. The theme owns all content routes (/, /blog, /docs,
39
- * etc.), the @theme CSS alias, and its own 404 page.
17
+ * Loads the active theme (a ThemeInstance from defineTheme()) and registers it
18
+ * as a nested integration. The theme owns content routes (/, /docs, /tags, etc.)
19
+ * and the @theme CSS alias. Modules declared in config.modules[] have their
20
+ * routes injected by this integration.
40
21
  *
41
22
  * Core always registers: /rss.xml, /karaoke-cms/[...slug] (dev only), wikilinks,
42
23
  * sitemap, and the virtual:karaoke-cms/config module.
@@ -57,11 +38,23 @@ export function defineConfig(config: KaraokeConfig): KaraokeConfig {
57
38
  return config;
58
39
  }
59
40
 
41
+ function isThemeInstance(theme: unknown): theme is ThemeInstance {
42
+ return typeof theme === 'object' && theme !== null && (theme as ThemeInstance)._type === 'theme-instance';
43
+ }
44
+
60
45
  export default function karaoke(config: KaraokeConfig = {}): AstroIntegration {
61
46
  validateModules(config);
62
47
  const resolved = resolveModules(config);
63
48
  const layout = resolveLayout(config);
64
49
 
50
+ if (!config.theme) {
51
+ throw new Error(
52
+ '[karaoke-cms] No theme configured. Add theme: themeDefault() to your karaoke.config.ts.\n' +
53
+ 'Install: pnpm add @karaoke-cms/theme-default',
54
+ );
55
+ }
56
+
57
+ const theme = config.theme;
65
58
  let _resolvedCollections: ResolvedCollections | undefined;
66
59
 
67
60
  return {
@@ -74,36 +67,64 @@ export default function karaoke(config: KaraokeConfig = {}): AstroIntegration {
74
67
  : rootDir;
75
68
  const isProd = command === 'build';
76
69
  _resolvedCollections = resolveCollections(vaultDir, isProd, config.collections);
77
- const _resolvedMenus = resolveMenus(vaultDir);
70
+ const modules = (config.modules ?? []).filter(m => m.enabled !== false);
71
+ const _resolvedMenus = resolveMenus(vaultDir, modules);
72
+
73
+ // ── Scaffold module pages and CSS on first dev run ────────────────
74
+ if (command === 'dev') {
75
+ for (const mod of modules) {
76
+ if (mod.scaffoldPages) {
77
+ for (const { src, dest } of mod.scaffoldPages) {
78
+ if (!existsSync(src)) continue;
79
+ const destAbs = resolve(rootDir, 'src/pages', dest);
80
+ if (!existsSync(destAbs)) {
81
+ mkdirSync(dirname(destAbs), { recursive: true });
82
+ copyFileSync(src, destAbs);
83
+ console.log(`[karaoke-cms] Scaffolded src/pages/${dest}`);
84
+ }
85
+ }
86
+ }
87
+ if (mod.defaultCssPath && existsSync(mod.defaultCssPath)) {
88
+ const stylesDir = resolve(rootDir, 'src/styles');
89
+ const cssDest = resolve(stylesDir, `${mod.id}.css`);
90
+ if (!existsSync(cssDest)) {
91
+ mkdirSync(stylesDir, { recursive: true });
92
+ copyFileSync(mod.defaultCssPath, cssDest);
93
+ console.log(`[karaoke-cms] Scaffolded src/styles/${mod.id}.css`);
94
+ }
95
+ const globalCss = resolve(stylesDir, 'global.css');
96
+ const importLine = `@import './${mod.id}.css';`;
97
+ if (existsSync(globalCss)) {
98
+ const content = readFileSync(globalCss, 'utf8');
99
+ if (!content.includes(importLine)) {
100
+ writeFileSync(globalCss, importLine + '\n' + content);
101
+ console.log(`[karaoke-cms] Added @import '${mod.id}.css' to global.css`);
102
+ }
103
+ } else {
104
+ mkdirSync(stylesDir, { recursive: true });
105
+ writeFileSync(globalCss, importLine + '\n');
106
+ console.log(`[karaoke-cms] Created src/styles/global.css with @import '${mod.id}.css'`);
107
+ }
108
+ }
109
+ }
110
+ }
111
+
112
+ // ── Load theme and inject module routes ───────────────────────────
113
+ const themeIntegration = theme.toAstroIntegration(modules);
78
114
 
79
- // ── Load active theme as a nested Astro integration ───────────────
80
- let themeIntegration: AstroIntegration;
115
+ for (const mod of modules) {
116
+ for (const route of mod.routes) {
117
+ injectRoute(route);
118
+ }
119
+ }
81
120
 
121
+ // ── Module routes — injected from implements[] on the ThemeInstance ──
82
122
  if (isThemeInstance(config.theme)) {
83
- // New API: theme is a ThemeInstance from defineTheme()
84
- themeIntegration = config.theme.toAstroIntegration();
85
- } else {
86
- // Legacy API: theme is a package name string.
87
- // Resolve from the user's project root so their node_modules is searched.
88
- const themePkg = resolveThemePkg(config.theme as string | undefined);
89
- const projectRequire = createRequire(new URL('package.json', astroConfig.root));
90
- let resolvedThemePath: string;
91
- try {
92
- resolvedThemePath = projectRequire.resolve(themePkg);
93
- } catch {
94
- throw new Error(
95
- `[karaoke-cms] Theme package "${themePkg}" is not installed.\n` +
96
- `Run: pnpm add ${themePkg}`,
97
- );
123
+ for (const mod of config.theme.implementedModules ?? []) {
124
+ for (const route of mod.routes) {
125
+ injectRoute({ pattern: route.pattern, entrypoint: route.entrypoint });
126
+ }
98
127
  }
99
- // Use new Function to bypass Vite's import() interception —
100
- // this runs the native Node.js ESM loader, not Vite's module runner.
101
- const nativeImport: (specifier: string) => Promise<unknown> =
102
- new Function('specifier', 'return import(specifier)');
103
- const themeModule = await nativeImport(pathToFileURL(resolvedThemePath).href) as {
104
- default: (cfg: KaraokeConfig) => AstroIntegration;
105
- };
106
- themeIntegration = themeModule.default(config);
107
128
  }
108
129
 
109
130
  // ── Core routes — always present regardless of theme ─────────────
@@ -176,6 +197,9 @@ export const resolvedModules = ${JSON.stringify(resolved)};
176
197
  export const resolvedLayout = ${JSON.stringify(layout)};
177
198
  export const resolvedCollections = ${JSON.stringify(resolvedCollections)};
178
199
  export const resolvedMenus = ${JSON.stringify(resolvedMenus)};
200
+ export const blogMount = ${JSON.stringify(
201
+ (config.modules ?? []).find((m: { id: string; mount: string }) => m.id === 'blog')?.mount ?? '/blog'
202
+ )};
179
203
  `;
180
204
  }
181
205
  },
@@ -2,7 +2,7 @@
2
2
  import '@theme/styles.css';
3
3
  import Base from './Base.astro';
4
4
  import RegionRenderer from '../components/RegionRenderer.astro';
5
- import { resolvedModules, resolvedLayout } from 'virtual:karaoke-cms/config';
5
+ import { resolvedLayout } from 'virtual:karaoke-cms/config';
6
6
 
7
7
  interface Props {
8
8
  title: string;
@@ -13,9 +13,7 @@ interface Props {
13
13
  }
14
14
 
15
15
  const { title, description, type = 'website', variant = 'default' } = Astro.props;
16
- const layout = resolvedLayout;
17
- const modules = resolvedModules;
18
- const searchEnabled = modules.search.enabled;
16
+ const layout = resolvedLayout;
19
17
 
20
18
  const isLanding = variant === 'landing';
21
19
 
@@ -25,10 +23,10 @@ const hasRight = !isLanding && (Astro.slots.has('right') || layout.regions.right
25
23
  ---
26
24
 
27
25
  <Base {title} {description} {type}>
28
- <header>
26
+ <header class:list={{ 'landing-header': isLanding }}>
29
27
  <div class="header-inner">
30
28
  <slot name="top">
31
- <RegionRenderer components={layout.regions.top.components} {searchEnabled} />
29
+ <RegionRenderer components={layout.regions.top.components} searchEnabled={false} />
32
30
  </slot>
33
31
  <slot name="header-cta" />
34
32
  </div>
@@ -49,7 +47,7 @@ const hasRight = !isLanding && (Astro.slots.has('right') || layout.regions.right
49
47
  {hasLeft && (
50
48
  <aside class="region-left">
51
49
  <slot name="left">
52
- <RegionRenderer components={layout.regions.left.components} {searchEnabled} />
50
+ <RegionRenderer components={layout.regions.left.components} searchEnabled={false} />
53
51
  </slot>
54
52
  </aside>
55
53
  )}
@@ -59,7 +57,7 @@ const hasRight = !isLanding && (Astro.slots.has('right') || layout.regions.right
59
57
  {hasRight && (
60
58
  <aside class="region-right">
61
59
  <slot name="right">
62
- <RegionRenderer components={layout.regions.right.components} {searchEnabled} />
60
+ <RegionRenderer components={layout.regions.right.components} searchEnabled={false} />
63
61
  </slot>
64
62
  </aside>
65
63
  )}
@@ -69,7 +67,7 @@ const hasRight = !isLanding && (Astro.slots.has('right') || layout.regions.right
69
67
  <footer>
70
68
  <div class="footer-inner">
71
69
  <slot name="bottom">
72
- <RegionRenderer components={layout.regions.bottom.components} {searchEnabled} />
70
+ <RegionRenderer components={layout.regions.bottom.components} searchEnabled={false} />
73
71
  </slot>
74
72
  </div>
75
73
  </footer>
package/src/types.ts CHANGED
@@ -15,6 +15,15 @@ export interface ResolvedCollection {
15
15
 
16
16
  export type ResolvedCollections = Record<string, ResolvedCollection>;
17
17
 
18
+ export interface CommentsConfig {
19
+ enabled?: boolean;
20
+ /** GitHub repo in "owner/repo" format */
21
+ repo?: string;
22
+ repoId?: string;
23
+ category?: string;
24
+ categoryId?: string;
25
+ }
26
+
18
27
  export interface KaraokeConfig {
19
28
  /**
20
29
  * Path to the Obsidian vault root (where content/ lives).
@@ -31,17 +40,10 @@ export interface KaraokeConfig {
31
40
  description?: string;
32
41
  /** Theme — package name string (legacy) or a ThemeInstance from defineTheme(). */
33
42
  theme?: string | ThemeInstance;
34
- modules?: {
35
- search?: { enabled?: boolean };
36
- comments?: {
37
- enabled?: boolean;
38
- /** GitHub repo in "owner/repo" format */
39
- repo?: string;
40
- repoId?: string;
41
- category?: string;
42
- categoryId?: string;
43
- };
44
- };
43
+ /** Modules to activate. Each is a ModuleInstance from defineModule(). */
44
+ modules?: ModuleInstance[];
45
+ /** Giscus comments configuration. */
46
+ comments?: CommentsConfig;
45
47
  layout?: {
46
48
  regions?: {
47
49
  top?: { components?: RegionComponent[] };
@@ -54,7 +56,6 @@ export interface KaraokeConfig {
54
56
 
55
57
  /** Resolved (defaults filled in) modules config — available at build time via virtual module. */
56
58
  export interface ResolvedModules {
57
- search: { enabled: boolean };
58
59
  comments: {
59
60
  enabled: boolean;
60
61
  repo: string;
@@ -124,10 +125,24 @@ export interface ModuleInstance {
124
125
  _type: 'module-instance';
125
126
  id: string;
126
127
  mount: string;
128
+ /** When false, karaoke() skips route injection and menu entries for this module. Defaults to true. */
129
+ enabled: boolean;
127
130
  routes: Array<{ pattern: string; entrypoint: string }>;
128
131
  menuEntries: ModuleMenuEntry[];
129
132
  cssContract: readonly string[];
130
133
  hasDefaultCss: boolean;
134
+ /**
135
+ * Page files to copy into the user's src/pages/ on first dev run.
136
+ * Only copied if the destination does not already exist.
137
+ * src: absolute path to the source .astro file in the npm package.
138
+ * dest: path relative to src/pages/ (e.g. "blog/index.astro").
139
+ */
140
+ scaffoldPages?: Array<{ src: string; dest: string }>;
141
+ /**
142
+ * Absolute path to the module's default CSS file.
143
+ * On first dev run, copied to src/styles/{id}.css and imported into global.css.
144
+ */
145
+ defaultCssPath?: string;
131
146
  }
132
147
 
133
148
  /** A resolved theme instance — returned by a defineTheme() factory. */
@@ -135,5 +150,6 @@ export interface ThemeInstance {
135
150
  _type: 'theme-instance';
136
151
  id: string;
137
152
  implementedModuleIds: string[];
153
+ implementedModules: ModuleInstance[];
138
154
  toAstroIntegration: () => import('astro').AstroIntegration;
139
155
  }
@@ -1,7 +1,7 @@
1
1
  import { readFileSync } from 'fs';
2
2
  import { join } from 'path';
3
3
  import { parse } from 'yaml';
4
- import type { MenuConfig, MenuEntryConfig, ResolvedMenuEntry, ResolvedMenu, ResolvedMenus } from '../types.js';
4
+ import type { MenuConfig, MenuEntryConfig, ResolvedMenuEntry, ResolvedMenu, ResolvedMenus, ModuleInstance } from '../types.js';
5
5
 
6
6
  /** Reject javascript: and data: hrefs to prevent stored XSS via menus.yaml. */
7
7
  function sanitizeHref(href: string): string | undefined {
@@ -24,25 +24,66 @@ function normalizeEntries(raw: MenuEntryConfig[], depth = 0): ResolvedMenuEntry[
24
24
  .sort((a, b) => a.weight - b.weight);
25
25
  }
26
26
 
27
- function defaultMain(): ResolvedMenu {
27
+ function defaultMain(modules: ModuleInstance[]): ResolvedMenu {
28
+ // Resolve href for module entries: path '/' means the module's mount root.
29
+ const moduleMainEntries: ResolvedMenuEntry[] = modules
30
+ .flatMap(m => m.menuEntries
31
+ .filter(e => e.section === 'main')
32
+ .map(e => ({
33
+ text: e.name,
34
+ href: e.path === '/' ? m.mount : e.path,
35
+ weight: e.weight,
36
+ when: `collection:${m.id}`,
37
+ entries: [] as ResolvedMenuEntry[],
38
+ }))
39
+ );
40
+
41
+ // Theme-owned entries (Docs, Tags) are always present.
42
+ const themeEntries: ResolvedMenuEntry[] = [
43
+ { text: 'Docs', href: '/docs', weight: 20, when: 'collection:docs', entries: [] },
44
+ { text: 'Tags', href: '/tags', weight: 30, entries: [] },
45
+ ];
46
+
47
+ // If no modules provide main entries, fall back to hardcoded Blog default.
48
+ const mainEntries =
49
+ moduleMainEntries.length > 0
50
+ ? [...moduleMainEntries, ...themeEntries]
51
+ : [
52
+ { text: 'Blog', href: '/blog', weight: 10, when: 'collection:blog', entries: [] },
53
+ ...themeEntries,
54
+ ];
55
+
28
56
  return {
29
57
  name: 'main',
30
58
  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
- ],
59
+ entries: mainEntries.sort((a, b) => a.weight - b.weight),
36
60
  };
37
61
  }
38
62
 
39
- function defaultFooter(): ResolvedMenu {
63
+ function defaultFooter(modules: ModuleInstance[]): ResolvedMenu {
64
+ // Derive RSS-like entries from modules' footer-section menuEntries.
65
+ const moduleFooterEntries: ResolvedMenuEntry[] = modules
66
+ .flatMap(m => m.menuEntries
67
+ .filter(e => e.section === 'footer')
68
+ .map(e => ({
69
+ text: e.name,
70
+ href: e.path === '/' ? m.mount : e.path,
71
+ weight: e.weight,
72
+ when: undefined,
73
+ entries: [] as ResolvedMenuEntry[],
74
+ }))
75
+ );
76
+
77
+ // Fall back to hardcoded /rss.xml if no module provides a footer entry.
78
+ const footerEntries =
79
+ moduleFooterEntries.length > 0
80
+ ? moduleFooterEntries
81
+ : [{ text: 'RSS', href: '/rss.xml', weight: 10, entries: [] }];
82
+
40
83
  return {
41
84
  name: 'footer',
42
85
  orientation: 'horizontal',
43
- entries: [
44
- { text: 'RSS', href: '/rss.xml', weight: 10, entries: [] },
45
- ],
86
+ entries: footerEntries,
46
87
  };
47
88
  }
48
89
 
@@ -54,14 +95,14 @@ function defaultFooter(): ResolvedMenu {
54
95
  * to render. ENOENT (no menus.yaml) is the normal zero-config case; all other
55
96
  * errors warn and fall back to defaults.
56
97
  */
57
- export function resolveMenus(vaultDir: string): ResolvedMenus {
98
+ export function resolveMenus(vaultDir: string, modules: ModuleInstance[] = []): ResolvedMenus {
58
99
  try {
59
100
  const path = join(vaultDir, 'karaoke-cms/config/menus.yaml');
60
101
  const raw = readFileSync(path, 'utf8');
61
102
  const parsed = parse(raw) as { menus?: Record<string, MenuConfig> };
62
103
 
63
104
  if (!parsed?.menus || typeof parsed.menus !== 'object') {
64
- return { main: defaultMain(), footer: defaultFooter() };
105
+ return { main: defaultMain(modules), footer: defaultFooter(modules) };
65
106
  }
66
107
 
67
108
  const result: ResolvedMenus = {};
@@ -81,8 +122,8 @@ export function resolveMenus(vaultDir: string): ResolvedMenus {
81
122
  entries: normalizeEntries(cfg?.entries ?? []),
82
123
  };
83
124
  }
84
- if (!result.main) result.main = defaultMain();
85
- if (!result.footer) result.footer = defaultFooter();
125
+ if (!result.main) result.main = defaultMain(modules);
126
+ if (!result.footer) result.footer = defaultFooter(modules);
86
127
  return result;
87
128
  } catch (err: unknown) {
88
129
  if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
@@ -90,6 +131,6 @@ export function resolveMenus(vaultDir: string): ResolvedMenus {
90
131
  `[karaoke-cms] Failed to parse menus.yaml: ${(err as Error).message}. Using default menus.`,
91
132
  );
92
133
  }
93
- return { main: defaultMain(), footer: defaultFooter() };
134
+ return { main: defaultMain(modules), footer: defaultFooter(modules) };
94
135
  }
95
136
  }
@@ -1,20 +1,16 @@
1
- // Minimal config shape — mirrors KaraokeConfig.modules without importing from root
1
+ // Minimal config shape — mirrors KaraokeConfig.comments without importing from root
2
2
  // so this utility stays self-contained and testable.
3
3
  export interface ModuleConfig {
4
- modules?: {
5
- search?: { enabled?: boolean };
6
- comments?: {
7
- enabled?: boolean;
8
- repo?: string;
9
- repoId?: string;
10
- category?: string;
11
- categoryId?: string;
12
- };
4
+ comments?: {
5
+ enabled?: boolean;
6
+ repo?: string;
7
+ repoId?: string;
8
+ category?: string;
9
+ categoryId?: string;
13
10
  };
14
11
  }
15
12
 
16
13
  export interface ResolvedModules {
17
- search: { enabled: boolean };
18
14
  comments: {
19
15
  enabled: boolean;
20
16
  repo: string;
@@ -26,15 +22,12 @@ export interface ResolvedModules {
26
22
 
27
23
  export function resolveModules(config: ModuleConfig | null | undefined): ResolvedModules {
28
24
  return {
29
- search: {
30
- enabled: config?.modules?.search?.enabled ?? false,
31
- },
32
25
  comments: {
33
- enabled: config?.modules?.comments?.enabled ?? false,
34
- repo: config?.modules?.comments?.repo ?? '',
35
- repoId: config?.modules?.comments?.repoId ?? '',
36
- category: config?.modules?.comments?.category ?? '',
37
- categoryId: config?.modules?.comments?.categoryId ?? '',
26
+ enabled: config?.comments?.enabled ?? false,
27
+ repo: config?.comments?.repo ?? '',
28
+ repoId: config?.comments?.repoId ?? '',
29
+ category: config?.comments?.category ?? '',
30
+ categoryId: config?.comments?.categoryId ?? '',
38
31
  },
39
32
  };
40
33
  }
@@ -11,14 +11,14 @@
11
11
  * @param {import('../karaoke.config').KaraokeConfig | null | undefined} config
12
12
  */
13
13
  export function validateModules(config) {
14
- const comments = config?.modules?.comments
14
+ const comments = config?.comments
15
15
  if (!comments?.enabled) return
16
16
 
17
17
  const required = ['repo', 'repoId', 'category', 'categoryId']
18
18
  const missing = required.filter(k => !comments[k])
19
19
  if (missing.length > 0) {
20
20
  throw new Error(
21
- `karaoke-cms: modules.comments.enabled is true but the following required fields are missing: ` +
21
+ `karaoke-cms: comments.enabled is true but the following required fields are missing: ` +
22
22
  `${missing.join(', ')}. Get these values from https://giscus.app`
23
23
  )
24
24
  }