@karaoke-cms/astro 0.6.3 → 0.9.1

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.1",
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 test/define-module.test.js test/define-theme.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,21 @@
1
1
  ---
2
+ import Menu from '../Menu.astro';
3
+ import { siteTitle } from 'virtual:karaoke-cms/config';
2
4
  ---
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>
5
+ <div class="footer-grid">
6
+ <div class="footer-col">
7
+ <p class="footer-brand">karaoke-cms</p>
8
+ <p class="footer-tagline">An Astro framework for publishing Obsidian vaults as private-by-default static sites.</p>
9
+ </div>
10
+ <div class="footer-col">
11
+ <Menu name="footer-2" />
12
+ </div>
13
+ <div class="footer-col"></div>
14
+ <div class="footer-col">
15
+ <Menu name="footer" />
16
+ </div>
17
+ </div>
18
+ <div class="footer-below">
19
+ <span>&copy; {new Date().getFullYear()} {siteTitle}</span>
20
+ <span class="footer-attr">Built with <a href="https://karaoke-cms.org">karaoke-cms</a></span>
7
21
  </div>
@@ -0,0 +1,38 @@
1
+ import type { ModuleInstance, ModuleMenuEntry } from './types.js';
2
+
3
+ export interface ModuleDefinition {
4
+ id: string;
5
+ cssContract: readonly string[];
6
+ defaultCss?: () => Promise<unknown>;
7
+ routes: (mount: string) => Array<{ pattern: string; entrypoint: string }>;
8
+ menuEntries: (mount: string, id: string) => ModuleMenuEntry[];
9
+ collection?: () => unknown;
10
+ }
11
+
12
+ /**
13
+ * Define a karaoke-cms module.
14
+ *
15
+ * Returns a factory function. Call the factory with `{ mount }` (and optionally
16
+ * `{ id }` for multi-instance support) to get a ModuleInstance that can be
17
+ * passed to `defineConfig({ modules: [...] })`.
18
+ *
19
+ * @example
20
+ * export const blog = defineModule({ id: 'blog', routes: (mount) => [...], ... })
21
+ * // In karaoke.config.ts:
22
+ * modules: [blog({ mount: '/blog' })]
23
+ */
24
+ export function defineModule(def: ModuleDefinition) {
25
+ return function moduleFactory(config: { id?: string; mount: string }): ModuleInstance {
26
+ const id = config.id ?? def.id;
27
+ const mount = config.mount.replace(/\/$/, '');
28
+ return {
29
+ _type: 'module-instance',
30
+ id,
31
+ mount,
32
+ routes: def.routes(mount),
33
+ menuEntries: def.menuEntries(mount, id),
34
+ cssContract: def.cssContract,
35
+ hasDefaultCss: !!def.defaultCss,
36
+ };
37
+ };
38
+ }
@@ -0,0 +1,37 @@
1
+ import type { AstroIntegration } from 'astro';
2
+ import type { ModuleInstance, ThemeInstance } from './types.js';
3
+
4
+ export interface ThemeFactoryConfig {
5
+ implements?: ModuleInstance[];
6
+ }
7
+
8
+ export interface ThemeDefinition {
9
+ id: string;
10
+ toAstroIntegration: (config: ThemeFactoryConfig) => AstroIntegration;
11
+ }
12
+
13
+ /**
14
+ * Define a karaoke-cms theme.
15
+ *
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: ... })`.
19
+ *
20
+ * @example
21
+ * export const themeDefault = defineTheme({
22
+ * id: 'theme-default',
23
+ * toAstroIntegration: (config) => ({ name: '...', hooks: { ... } }),
24
+ * })
25
+ * // In karaoke.config.ts:
26
+ * theme: themeDefault({ implements: [blog({ mount: '/blog' })] })
27
+ */
28
+ export function defineTheme(def: ThemeDefinition) {
29
+ return function themeFactory(config: ThemeFactoryConfig = {}): ThemeInstance {
30
+ return {
31
+ _type: 'theme-instance',
32
+ id: def.id,
33
+ implementedModuleIds: (config.implements ?? []).map(m => m.id),
34
+ toAstroIntegration: () => def.toAstroIntegration(config),
35
+ };
36
+ };
37
+ }