@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/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 } 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;
@@ -16,4 +16,8 @@ declare module 'virtual:karaoke-cms/config' {
16
16
  export const resolvedModules: ResolvedModules;
17
17
  /** Resolved layout config (defaults filled in) */
18
18
  export const resolvedLayout: ResolvedLayout;
19
+ /** Resolved collections with enabled/disabled status for current build mode */
20
+ export const resolvedCollections: ResolvedCollections;
21
+ /** Resolved menus from menus.yaml (defaults to main + footer when absent) */
22
+ export const resolvedMenus: ResolvedMenus;
19
23
  }
package/package.json CHANGED
@@ -1,22 +1,20 @@
1
1
  {
2
2
  "name": "@karaoke-cms/astro",
3
3
  "type": "module",
4
- "version": "0.6.2",
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
- "./pages/index.astro": "./src/pages/index.astro",
12
- "./pages/blog/index.astro": "./src/pages/blog/index.astro",
13
- "./pages/blog/[slug].astro": "./src/pages/blog/[slug].astro",
14
- "./pages/docs/index.astro": "./src/pages/docs/index.astro",
15
- "./pages/docs/[slug].astro": "./src/pages/docs/[slug].astro",
16
- "./pages/tags/index.astro": "./src/pages/tags/index.astro",
17
- "./pages/tags/[tag].astro": "./src/pages/tags/[tag].astro",
18
- "./pages/404.astro": "./src/pages/404.astro",
19
- "./pages/rss.xml.ts": "./src/pages/rss.xml.ts"
11
+ "./env": "./src/utils/load-env.ts",
12
+ "./layouts/Base.astro": "./src/layouts/Base.astro",
13
+ "./consts": "./src/consts.ts",
14
+ "./components/ModuleLoader.astro": "./src/components/ModuleLoader.astro",
15
+ "./pages/rss.xml.ts": "./src/pages/rss.xml.ts",
16
+ "./pages/karaoke-cms/index.astro": "./src/pages/karaoke-cms/index.astro",
17
+ "./pages/karaoke-cms/[...slug].astro": "./src/pages/karaoke-cms/[...slug].astro"
20
18
  },
21
19
  "files": [
22
20
  "src/",
@@ -34,13 +32,15 @@
34
32
  "dependencies": {
35
33
  "@astrojs/rss": "^4.0.17",
36
34
  "@astrojs/sitemap": "^3.7.1",
37
- "remark-wiki-link": "^2.0.1"
35
+ "remark-wiki-link": "^2.0.1",
36
+ "yaml": "^2.7.0",
37
+ "zod": "^4.0.0"
38
38
  },
39
39
  "devDependencies": {
40
40
  "vitest": "^4.1.1",
41
41
  "astro": "^6.0.8"
42
42
  },
43
43
  "scripts": {
44
- "test": "vitest run test/validate-config.test.js test/resolve-modules.test.js test/resolve-layout.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"
45
45
  }
46
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
+ });
@@ -1,7 +1,10 @@
1
1
  import { defineCollection, z } from 'astro:content';
2
2
  import { glob } from 'astro/loaders';
3
3
  import { fileURLToPath } from 'url';
4
- import { join } from 'path';
4
+ import { join, resolve, isAbsolute } from 'path';
5
+ import { resolveCollections } from './utils/resolve-collections.js';
6
+ import type { CollectionConfig } from './types.js';
7
+ export { blogThemeExtension } from './blog-schema.js';
5
8
 
6
9
  const baseSchema = z.object({
7
10
  title: z.string(),
@@ -15,28 +18,97 @@ const baseSchema = z.object({
15
18
  related: z.array(z.string()).optional(),
16
19
  });
17
20
 
21
+ // Relaxed schema for handbook / custom collections — title is optional
22
+ const relaxedSchema = z.object({
23
+ title: z.string().optional().default('Untitled'),
24
+ publish: z.boolean().optional().default(false),
25
+ date: z.coerce.date().optional(),
26
+ author: z.union([z.string(), z.array(z.string())]).optional(),
27
+ tags: z.array(z.string()).optional(),
28
+ description: z.string().optional(),
29
+ reading_time: z.number().optional(),
30
+ related: z.array(z.string()).optional(),
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
+
18
40
  /**
19
41
  * Create Astro content collections for karaoke-cms.
20
42
  *
21
- * @param root - URL pointing to the project root (where content/ lives).
22
- * Typically: `new URL('../..', import.meta.url)` from src/content.config.ts
43
+ * Reads collections.yaml from the vault to determine which collections are
44
+ * active for the current build mode. The `karaoke-cms` collection (handbook)
45
+ * is dev-only and never enters the production build graph.
46
+ *
47
+ * @param root - URL pointing to the Astro project root (where astro.config.mjs lives).
48
+ * Typically: `new URL('..', import.meta.url)` from src/content.config.ts
49
+ * @param vault - Path to the Obsidian vault root (where content/ lives).
50
+ * Absolute, or relative to `root`. Defaults to `root` when omitted (vault = project).
51
+ * Typically sourced from `loadEnv(new URL('..', import.meta.url)).KARAOKE_VAULT`.
52
+ * @param opts - Optional config: collection overrides and blog schema extension.
23
53
  *
24
54
  * @example
25
55
  * // src/content.config.ts
26
56
  * import { makeCollections } from '@karaoke-cms/astro/collections';
27
- * export const collections = makeCollections(new URL('../..', import.meta.url));
57
+ * import { loadEnv } from '@karaoke-cms/astro/env';
58
+ * const env = loadEnv(new URL('..', import.meta.url));
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 });
28
64
  */
29
- export function makeCollections(root: URL) {
65
+ export function makeCollections(
66
+ root: URL,
67
+ vault?: string,
68
+ opts?: MakeCollectionsOptions,
69
+ ) {
30
70
  const rootDir = fileURLToPath(root);
31
- return {
32
- // base paths are absolute, resolving content/ relative to the project root
33
- blog: defineCollection({
34
- loader: glob({ pattern: '**/*.md', base: join(rootDir, 'content/blog') }),
35
- schema: baseSchema.extend({ comments: z.boolean().optional().default(true) }),
36
- }),
37
- docs: defineCollection({
38
- loader: glob({ pattern: '**/*.md', base: join(rootDir, 'content/docs') }),
71
+ const vaultDir = vault
72
+ ? (isAbsolute(vault) ? vault : resolve(rootDir, vault))
73
+ : rootDir;
74
+ const isProd = import.meta.env.PROD;
75
+ const resolved = resolveCollections(vaultDir, isProd, opts?.collections);
76
+
77
+ const collections: Record<string, ReturnType<typeof defineCollection>> = {};
78
+
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) });
83
+ collections.blog = defineCollection({
84
+ loader: glob({ pattern: '**/*.md', base: join(vaultDir, 'blog') }),
85
+ schema: blogExt,
86
+ });
87
+ }
88
+
89
+ if (resolved.docs?.enabled) {
90
+ collections.docs = defineCollection({
91
+ loader: glob({ pattern: '**/*.md', base: join(vaultDir, 'docs') }),
39
92
  schema: baseSchema.extend({ comments: z.boolean().optional().default(false) }),
40
- }),
41
- };
93
+ });
94
+ }
95
+
96
+ if (resolved['karaoke-cms']?.enabled) {
97
+ collections['karaoke-cms'] = defineCollection({
98
+ loader: glob({ pattern: '**/*.md', base: join(vaultDir, 'karaoke-cms') }),
99
+ schema: relaxedSchema,
100
+ });
101
+ }
102
+
103
+ // Custom collections defined in yaml or configCollections
104
+ for (const [name, col] of Object.entries(resolved)) {
105
+ if (!collections[name] && col.enabled) {
106
+ collections[name] = defineCollection({
107
+ loader: glob({ pattern: '**/*.md', base: join(vaultDir, name) }),
108
+ schema: relaxedSchema,
109
+ });
110
+ }
111
+ }
112
+
113
+ return collections;
42
114
  }
@@ -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,9 +1,17 @@
1
1
  ---
2
2
  import { getCollection } from 'astro:content';
3
3
 
4
- const posts = (await getCollection('blog', ({ data }) => data.publish === true))
4
+ const LIMIT = 5;
5
+
6
+ // Detect which collection we're in and exclude the current page
7
+ const pathname = Astro.url.pathname.replace(/\/$/, '');
8
+ const collection = pathname.startsWith('/docs/') ? 'docs' : 'blog';
9
+ const currentSlug = pathname.split('/').at(-1) ?? '';
10
+
11
+ const posts = (await getCollection(collection, ({ data }) => data.publish === true))
5
12
  .sort((a, b) => (b.data.date?.valueOf() ?? 0) - (a.data.date?.valueOf() ?? 0))
6
- .slice(0, 5);
13
+ .filter(post => post.id !== currentSlug)
14
+ .slice(0, LIMIT);
7
15
  ---
8
16
 
9
17
  {posts.length > 0 && (
@@ -12,7 +20,7 @@ const posts = (await getCollection('blog', ({ data }) => data.publish === true))
12
20
  <ul class="sidebar-list">
13
21
  {posts.map(post => (
14
22
  <li>
15
- <a href={`/blog/${post.id}`}>{post.data.title}</a>
23
+ <a href={`/${collection}/${post.id}`}>{post.data.title}</a>
16
24
  {post.data.date && (
17
25
  <span class="post-date">{post.data.date.toISOString().slice(0, 10)}</span>
18
26
  )}
@@ -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>