@karaoke-cms/astro 0.6.3 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,94 @@
1
+ # @karaoke-cms/astro
2
+
3
+ Core Astro integration for karaoke-cms. Every karaoke-cms project depends on this package.
4
+
5
+ ## Where it belongs
6
+
7
+ `packages/astro/` in the monorepo. Users install it as a dependency and register it in `astro.config.mjs`:
8
+
9
+ ```js
10
+ // astro.config.mjs
11
+ import { defineConfig } from 'astro/config';
12
+ import karaoke from '@karaoke-cms/astro';
13
+ import karaokeConfig from './karaoke.config.ts';
14
+
15
+ export default defineConfig({
16
+ site: 'https://your-site.pages.dev',
17
+ integrations: [karaoke(karaokeConfig)],
18
+ });
19
+ ```
20
+
21
+ ## What it does
22
+
23
+ `karaoke(config)` is an Astro integration that wires up the full karaoke-cms runtime at build time:
24
+
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`
30
+
31
+ ### Virtual module
32
+
33
+ Every page and component in the framework reads config from `virtual:karaoke-cms/config`:
34
+
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
+ ```
45
+
46
+ Add to your `src/env.d.ts` to get TypeScript types:
47
+
48
+ ```ts
49
+ /// <reference types="@karaoke-cms/astro/client" />
50
+ ```
51
+
52
+ ### Collections
53
+
54
+ `makeCollections()` creates Astro content collections from the vault:
55
+
56
+ ```ts
57
+ // src/content.config.ts
58
+ import { makeCollections } from '@karaoke-cms/astro/collections';
59
+ import { loadEnv } from '@karaoke-cms/astro/env';
60
+
61
+ const { KARAOKE_VAULT } = loadEnv(new URL('..', import.meta.url));
62
+ export const collections = makeCollections(import.meta.url, KARAOKE_VAULT);
63
+ ```
64
+
65
+ Collections are glob-loaded from `{vault}/blog/`, `{vault}/docs/`, and `{vault}/karaoke-cms/`. Only files with `publish: true` are included in production builds.
66
+
67
+ ### Menus
68
+
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.
70
+
71
+ The `Menu.astro` and `MenuItems.astro` components render named menus with `when: collection:name` visibility guards evaluated at render time via `getCollection()`.
72
+
73
+ ### Layout regions
74
+
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`:
76
+
77
+ ```ts
78
+ layout: {
79
+ regions: {
80
+ top: { components: ['header', 'main-menu'] },
81
+ bottom: { components: ['footer'] },
82
+ }
83
+ }
84
+ ```
85
+
86
+ Available region components: `'header'`, `'main-menu'`, `'search'`, `'recent-posts'`, `'footer'`.
87
+
88
+ ## How it changes the behavior of the system
89
+
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.
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, ResolvedCollections } from '@karaoke-cms/astro';
9
+ import type { ResolvedModules, ResolvedLayout, ResolvedCollections, ResolvedMenus } from '@karaoke-cms/astro';
10
10
 
11
11
  /** Site title from karaoke.config.ts */
12
12
  export const siteTitle: string;
@@ -18,4 +18,6 @@ declare module 'virtual:karaoke-cms/config' {
18
18
  export const resolvedLayout: ResolvedLayout;
19
19
  /** Resolved collections with enabled/disabled status for current build mode */
20
20
  export const resolvedCollections: ResolvedCollections;
21
+ /** Resolved menus from menus.yaml (defaults to main + footer when absent) */
22
+ export const resolvedMenus: ResolvedMenus;
21
23
  }
package/package.json CHANGED
@@ -1,22 +1,17 @@
1
1
  {
2
2
  "name": "@karaoke-cms/astro",
3
3
  "type": "module",
4
- "version": "0.6.3",
5
- "description": "Astro integration for karaoke-cms — ships all routes, themes, and modules",
4
+ "version": "0.9.0",
5
+ "description": "Core Astro integration for karaoke-cms — virtual config, wikilinks, handbook routes",
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
11
  "./env": "./src/utils/load-env.ts",
12
- "./pages/index.astro": "./src/pages/index.astro",
13
- "./pages/blog/index.astro": "./src/pages/blog/index.astro",
14
- "./pages/blog/[slug].astro": "./src/pages/blog/[slug].astro",
15
- "./pages/docs/index.astro": "./src/pages/docs/index.astro",
16
- "./pages/docs/[slug].astro": "./src/pages/docs/[slug].astro",
17
- "./pages/tags/index.astro": "./src/pages/tags/index.astro",
18
- "./pages/tags/[tag].astro": "./src/pages/tags/[tag].astro",
19
- "./pages/404.astro": "./src/pages/404.astro",
12
+ "./layouts/Base.astro": "./src/layouts/Base.astro",
13
+ "./consts": "./src/consts.ts",
14
+ "./components/ModuleLoader.astro": "./src/components/ModuleLoader.astro",
20
15
  "./pages/rss.xml.ts": "./src/pages/rss.xml.ts",
21
16
  "./pages/karaoke-cms/index.astro": "./src/pages/karaoke-cms/index.astro",
22
17
  "./pages/karaoke-cms/[...slug].astro": "./src/pages/karaoke-cms/[...slug].astro"
@@ -38,13 +33,14 @@
38
33
  "@astrojs/rss": "^4.0.17",
39
34
  "@astrojs/sitemap": "^3.7.1",
40
35
  "remark-wiki-link": "^2.0.1",
41
- "yaml": "^2.7.0"
36
+ "yaml": "^2.7.0",
37
+ "zod": "^4.0.0"
42
38
  },
43
39
  "devDependencies": {
44
40
  "vitest": "^4.1.1",
45
41
  "astro": "^6.0.8"
46
42
  },
47
43
  "scripts": {
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"
44
+ "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"
49
45
  }
50
46
  }
@@ -0,0 +1,13 @@
1
+ import { z } from 'zod';
2
+
3
+ /**
4
+ * Optional frontmatter schema extension for blog-theme-compatible posts.
5
+ * Pass as `opts.blogSchema` to `makeCollections()` when using @karaoke-cms/theme-blog.
6
+ *
7
+ * All fields are optional — posts without them validate fine against the base schema.
8
+ */
9
+ export const blogThemeExtension = z.object({
10
+ cover_image: z.string().optional(),
11
+ featured: z.boolean().optional().default(false),
12
+ abstract: z.string().optional(),
13
+ });
@@ -4,6 +4,7 @@ import { fileURLToPath } from 'url';
4
4
  import { join, resolve, isAbsolute } from 'path';
5
5
  import { resolveCollections } from './utils/resolve-collections.js';
6
6
  import type { CollectionConfig } from './types.js';
7
+ export { blogThemeExtension } from './blog-schema.js';
7
8
 
8
9
  const baseSchema = z.object({
9
10
  title: z.string(),
@@ -29,6 +30,13 @@ const relaxedSchema = z.object({
29
30
  related: z.array(z.string()).optional(),
30
31
  });
31
32
 
33
+ export interface MakeCollectionsOptions {
34
+ /** Per-collection overrides from karaoke.config.ts. */
35
+ collections?: Record<string, CollectionConfig>;
36
+ /** Zod schema extension merged into the blog collection schema. Use with @karaoke-cms/theme-blog. */
37
+ blogSchema?: z.ZodObject<z.ZodRawShape>;
38
+ }
39
+
32
40
  /**
33
41
  * Create Astro content collections for karaoke-cms.
34
42
  *
@@ -41,7 +49,7 @@ const relaxedSchema = z.object({
41
49
  * @param vault - Path to the Obsidian vault root (where content/ lives).
42
50
  * Absolute, or relative to `root`. Defaults to `root` when omitted (vault = project).
43
51
  * Typically sourced from `loadEnv(new URL('..', import.meta.url)).KARAOKE_VAULT`.
44
- * @param configCollections - Optional per-collection overrides from karaoke.config.ts.
52
+ * @param opts - Optional config: collection overrides and blog schema extension.
45
53
  *
46
54
  * @example
47
55
  * // src/content.config.ts
@@ -49,25 +57,32 @@ const relaxedSchema = z.object({
49
57
  * import { loadEnv } from '@karaoke-cms/astro/env';
50
58
  * const env = loadEnv(new URL('..', import.meta.url));
51
59
  * export const collections = makeCollections(new URL('..', import.meta.url), env.KARAOKE_VAULT);
60
+ *
61
+ * @example With blog theme schema extension:
62
+ * import { makeCollections, blogThemeExtension } from '@karaoke-cms/astro/collections';
63
+ * export const collections = makeCollections(root, vault, { blogSchema: blogThemeExtension });
52
64
  */
53
65
  export function makeCollections(
54
66
  root: URL,
55
67
  vault?: string,
56
- configCollections?: Record<string, CollectionConfig>,
68
+ opts?: MakeCollectionsOptions,
57
69
  ) {
58
70
  const rootDir = fileURLToPath(root);
59
71
  const vaultDir = vault
60
72
  ? (isAbsolute(vault) ? vault : resolve(rootDir, vault))
61
73
  : rootDir;
62
74
  const isProd = import.meta.env.PROD;
63
- const resolved = resolveCollections(vaultDir, isProd, configCollections);
75
+ const resolved = resolveCollections(vaultDir, isProd, opts?.collections);
64
76
 
65
77
  const collections: Record<string, ReturnType<typeof defineCollection>> = {};
66
78
 
67
79
  if (resolved.blog?.enabled) {
80
+ const blogExt = opts?.blogSchema
81
+ ? baseSchema.merge(opts.blogSchema).extend({ comments: z.boolean().optional().default(true) })
82
+ : baseSchema.extend({ comments: z.boolean().optional().default(true) });
68
83
  collections.blog = defineCollection({
69
84
  loader: glob({ pattern: '**/*.md', base: join(vaultDir, 'blog') }),
70
- schema: baseSchema.extend({ comments: z.boolean().optional().default(true) }),
85
+ schema: blogExt,
71
86
  });
72
87
  }
73
88
 
@@ -0,0 +1,71 @@
1
+ ---
2
+ import { resolvedCollections, resolvedMenus } from 'virtual:karaoke-cms/config';
3
+ import { getCollection } from 'astro:content';
4
+ import MenuItems from './MenuItems.astro';
5
+ import type { ResolvedMenuEntry } from '../types.js';
6
+
7
+ interface Props {
8
+ name: string;
9
+ class?: string;
10
+ }
11
+
12
+ const { name, class: className } = Astro.props;
13
+ const pathname = Astro.url.pathname;
14
+ const menu = resolvedMenus[name];
15
+ if (!menu) return;
16
+
17
+ // Evaluate `when: collection:name` visibility at render time.
18
+ // getCollection() is Astro's per-build cached function — no extra filesystem reads.
19
+ const collectionEmpty: Record<string, boolean> = {};
20
+
21
+ async function isVisible(entry: ResolvedMenuEntry): Promise<boolean> {
22
+ if (!entry.when) return true;
23
+ if (!entry.when.startsWith('collection:')) {
24
+ if (import.meta.env.DEV) {
25
+ console.warn(
26
+ `[karaoke-cms] Unknown when: condition "${entry.when}" on menu entry "${entry.text}". ` +
27
+ `Only "collection:name" is supported in v1. Entry will always show.`,
28
+ );
29
+ }
30
+ return true;
31
+ }
32
+ const colName = entry.when.slice('collection:'.length);
33
+ if (!resolvedCollections[colName]?.enabled) return false;
34
+ if (!(colName in collectionEmpty)) {
35
+ try {
36
+ // colName is a runtime string from YAML, not a literal type.
37
+ // resolvedCollections[colName]?.enabled check above ensures it's a known collection.
38
+ // getCollection() throws if the collection is unknown — caught below.
39
+ const entries = await getCollection(colName as any, ({ data }: any) => data.publish === true);
40
+ collectionEmpty[colName] = entries.length === 0;
41
+ } catch {
42
+ collectionEmpty[colName] = true;
43
+ }
44
+ }
45
+ return !collectionEmpty[colName];
46
+ }
47
+
48
+ // Apply when: guards recursively so nested submenu entries also respect visibility.
49
+ async function filterVisible(entries: ResolvedMenuEntry[]): Promise<ResolvedMenuEntry[]> {
50
+ const results = await Promise.all(
51
+ entries.map(async e => {
52
+ if (!(await isVisible(e))) return null;
53
+ const visibleChildren = await filterVisible(e.entries);
54
+ return { ...e, entries: visibleChildren };
55
+ })
56
+ );
57
+ return results.filter((e): e is ResolvedMenuEntry => e !== null);
58
+ }
59
+
60
+ const visibleEntries = await filterVisible(menu.entries);
61
+ ---
62
+
63
+ {visibleEntries.length > 0 && (
64
+ <nav
65
+ class:list={['karaoke-menu', className]}
66
+ data-orientation={menu.orientation}
67
+ aria-label={name}
68
+ >
69
+ <MenuItems entries={visibleEntries} pathname={pathname} />
70
+ </nav>
71
+ )}
@@ -0,0 +1,28 @@
1
+ ---
2
+ import type { ResolvedMenuEntry } from '../types.js';
3
+
4
+ interface Props {
5
+ entries: ResolvedMenuEntry[];
6
+ pathname: string;
7
+ }
8
+
9
+ const { entries, pathname } = Astro.props;
10
+ ---
11
+
12
+ <ul>
13
+ {entries.map(entry => (
14
+ <li class:list={{ 'has-submenu': entry.entries.length > 0 }}>
15
+ {entry.href ? (
16
+ <a
17
+ href={entry.href}
18
+ aria-current={pathname === entry.href || pathname.startsWith(entry.href + '/') ? 'page' : undefined}
19
+ >{entry.text}</a>
20
+ ) : (
21
+ <span>{entry.text}</span>
22
+ )}
23
+ {entry.entries.length > 0 && (
24
+ <Astro.self entries={entry.entries} pathname={pathname} />
25
+ )}
26
+ </li>
27
+ ))}
28
+ </ul>
@@ -1,22 +1,4 @@
1
1
  ---
2
- const pathname = Astro.url.pathname;
2
+ import Menu from '../Menu.astro';
3
3
  ---
4
- <nav>
5
- <ul>
6
- <li>
7
- <a href="/blog" aria-current={pathname.startsWith('/blog') ? 'page' : undefined}>
8
- Blog
9
- </a>
10
- </li>
11
- <li>
12
- <a href="/docs" aria-current={pathname.startsWith('/docs') ? 'page' : undefined}>
13
- Docs
14
- </a>
15
- </li>
16
- <li>
17
- <a href="/tags" aria-current={pathname.startsWith('/tags') ? 'page' : undefined}>
18
- Tags
19
- </a>
20
- </li>
21
- </ul>
22
- </nav>
4
+ <Menu name="main" />
@@ -1,7 +1,7 @@
1
1
  ---
2
+ import Menu from '../Menu.astro';
2
3
  ---
3
- <div>
4
- <a href="/rss.xml">RSS</a> · Built with
5
- <a href="https://obsidian.md" rel="noopener">Obsidian</a> +
6
- <a href="https://astro.build" rel="noopener">Astro</a>
4
+ <Menu name="footer" />
5
+ <div class="footer-attr">
6
+ Built with <a href="https://karaoke-cms.org">karaoke-cms</a>
7
7
  </div>
package/src/index.ts CHANGED
@@ -1,24 +1,40 @@
1
1
  import type { AstroIntegration } from 'astro';
2
- import { fileURLToPath } from 'url';
3
- import { join, resolve, isAbsolute } 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
10
  import { resolveCollections } from './utils/resolve-collections.js';
10
- import type { KaraokeConfig, ResolvedModules, ResolvedLayout, ResolvedCollections } from './types.js';
11
+ import { resolveMenus } from './utils/resolve-menus.js';
12
+ import type { KaraokeConfig, ResolvedModules, ResolvedLayout, ResolvedCollections, ResolvedMenus } from './types.js';
11
13
 
12
- 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
+ }
13
28
 
14
29
  /**
15
30
  * karaoke() — the main Astro integration for karaoke-cms.
16
31
  *
17
- * Injects all routes (/, /blog, /blog/[slug], /docs, /docs/[slug],
18
- * /tags, /tags/[tag], /rss.xml, /404), sets up the @theme CSS alias,
19
- * 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.
20
35
  *
21
- * The /karaoke-cms and /karaoke-cms/[...slug] routes are injected in dev only.
36
+ * Core always registers: /rss.xml, /karaoke-cms/[...slug] (dev only), wikilinks,
37
+ * sitemap, and the virtual:karaoke-cms/config module.
22
38
  *
23
39
  * @example
24
40
  * // astro.config.mjs
@@ -30,63 +46,66 @@ const __dirname = fileURLToPath(new URL('.', import.meta.url));
30
46
  * site: 'https://your-site.pages.dev',
31
47
  * integrations: [karaoke(karaokeConfig)],
32
48
  * });
33
- *
34
- * Module slots (contract for Base.astro — extensible in v0.7+):
35
- * 'nav' — rendered in the nav bar (e.g., Search)
36
- * 'post-footer' — rendered after post content (e.g., Comments)
37
49
  */
38
50
  export default function karaoke(config: KaraokeConfig = {}): AstroIntegration {
39
- const themesDir = join(__dirname, 'themes');
40
- const theme = getTheme(config, themesDir);
41
51
  validateModules(config);
42
52
  const resolved = resolveModules(config);
43
53
  const layout = resolveLayout(config);
44
54
 
55
+ let _resolvedCollections: ResolvedCollections | undefined;
56
+
45
57
  return {
46
58
  name: '@karaoke-cms/astro',
47
59
  hooks: {
48
- 'astro:config:setup'({ injectRoute, updateConfig, command, config: astroConfig }) {
60
+ async 'astro:config:setup'({ injectRoute, updateConfig, command, config: astroConfig }) {
49
61
  const rootDir = fileURLToPath(astroConfig.root);
50
62
  const vaultDir = config.vault
51
63
  ? (isAbsolute(config.vault) ? config.vault : resolve(rootDir, config.vault))
52
64
  : rootDir;
53
65
  const isProd = command === 'build';
54
- const resolvedCollections = resolveCollections(vaultDir, isProd, config.collections);
66
+ _resolvedCollections = resolveCollections(vaultDir, isProd, config.collections);
67
+ const _resolvedMenus = resolveMenus(vaultDir);
55
68
 
56
- // ── Inject all framework routes ──────────────────────────────────
57
- injectRoute({ pattern: '/', entrypoint: '@karaoke-cms/astro/pages/index.astro' });
58
- injectRoute({ pattern: '/blog', entrypoint: '@karaoke-cms/astro/pages/blog/index.astro' });
59
- injectRoute({ pattern: '/blog/[slug]', entrypoint: '@karaoke-cms/astro/pages/blog/[slug].astro' });
60
- injectRoute({ pattern: '/docs', entrypoint: '@karaoke-cms/astro/pages/docs/index.astro' });
61
- injectRoute({ pattern: '/docs/[slug]', entrypoint: '@karaoke-cms/astro/pages/docs/[slug].astro' });
62
- injectRoute({ pattern: '/tags', entrypoint: '@karaoke-cms/astro/pages/tags/index.astro' });
63
- injectRoute({ pattern: '/tags/[tag]', entrypoint: '@karaoke-cms/astro/pages/tags/[tag].astro' });
64
- injectRoute({ pattern: '/404', entrypoint: '@karaoke-cms/astro/pages/404.astro' });
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 ─────────────
65
93
  injectRoute({ pattern: '/rss.xml', entrypoint: '@karaoke-cms/astro/pages/rss.xml.ts' });
66
94
 
67
- // ── Handbook routes — dev only ───────────────────────────────────
68
- if (resolvedCollections['karaoke-cms']?.enabled) {
95
+ // Handbook routes — dev only
96
+ if (_resolvedCollections['karaoke-cms']?.enabled) {
69
97
  injectRoute({ pattern: '/karaoke-cms', entrypoint: '@karaoke-cms/astro/pages/karaoke-cms/index.astro' });
70
98
  injectRoute({ pattern: '/karaoke-cms/[...slug]', entrypoint: '@karaoke-cms/astro/pages/karaoke-cms/[...slug].astro' });
71
99
  }
72
100
 
73
101
  updateConfig({
74
- integrations: [sitemap()],
102
+ integrations: [sitemap(), themeIntegration],
75
103
  vite: {
76
- resolve: {
77
- alias: {
78
- // @theme → packages/astro/src/themes/<active-theme>/
79
- '@theme': join(themesDir, theme),
80
- },
81
- },
82
- plugins: [virtualConfigPlugin(config, resolved, layout, resolvedCollections)],
104
+ plugins: [virtualConfigPlugin(config, resolved, layout, _resolvedCollections, _resolvedMenus)],
83
105
  },
84
106
  markdown: {
85
107
  remarkPlugins: [
86
108
  [wikiLinkPlugin, {
87
- // [[blog/hello-world]] → /blog/hello-world/
88
- // [[docs/getting-started]] → /docs/getting-started/
89
- // [[blog/hello-world|Hello World]] → link text "Hello World"
90
109
  pageResolver: (name: string) => [name.toLowerCase().replace(/ /g, '-')],
91
110
  hrefTemplate: (permalink: string) => `/${permalink}/`,
92
111
  aliasDivider: '|',
@@ -95,6 +114,19 @@ export default function karaoke(config: KaraokeConfig = {}): AstroIntegration {
95
114
  },
96
115
  });
97
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
+ },
98
130
  },
99
131
  };
100
132
  }
@@ -108,6 +140,7 @@ function virtualConfigPlugin(
108
140
  resolved: ResolvedModules,
109
141
  layout: ResolvedLayout,
110
142
  resolvedCollections: ResolvedCollections,
143
+ resolvedMenus: ResolvedMenus,
111
144
  ) {
112
145
  const VIRTUAL_ID = 'virtual:karaoke-cms/config';
113
146
  const RESOLVED_ID = '\0' + VIRTUAL_ID;
@@ -125,10 +158,11 @@ export const siteDescription = ${JSON.stringify(config.description ?? '')};
125
158
  export const resolvedModules = ${JSON.stringify(resolved)};
126
159
  export const resolvedLayout = ${JSON.stringify(layout)};
127
160
  export const resolvedCollections = ${JSON.stringify(resolvedCollections)};
161
+ export const resolvedMenus = ${JSON.stringify(resolvedMenus)};
128
162
  `;
129
163
  }
130
164
  },
131
165
  };
132
166
  }
133
167
 
134
- export type { KaraokeConfig, ResolvedModules, ResolvedLayout, ResolvedCollections, 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>
package/src/types.ts CHANGED
@@ -73,3 +73,37 @@ export interface ResolvedLayout {
73
73
  bottom: { components: RegionComponent[] };
74
74
  };
75
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>;