@karaoke-cms/astro 0.9.3 → 0.9.5
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 +77 -48
- package/client.d.ts +2 -0
- package/package.json +2 -2
- package/src/collections.ts +5 -1
- package/src/components/RegionRenderer.astro +2 -2
- package/src/define-module.ts +13 -3
- package/src/define-theme.ts +8 -11
- package/src/index.ts +77 -53
- package/src/layouts/DefaultPage.astro +7 -9
- package/src/types.ts +28 -12
- package/src/utils/resolve-menus.ts +57 -16
- package/src/utils/resolve-modules.ts +12 -19
- package/src/validate-config.js +2 -2
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
|
-
##
|
|
5
|
+
## Installation
|
|
6
6
|
|
|
7
|
-
|
|
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
|
-
|
|
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
|
-
|
|
33
|
+
const env = loadEnv(new URL('.', import.meta.url));
|
|
24
34
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
45
|
+
## Configuration
|
|
32
46
|
|
|
33
|
-
|
|
47
|
+
All fields are optional. Defaults produce a working site with no content.
|
|
34
48
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
60
|
+
## Exports
|
|
47
61
|
|
|
48
|
-
|
|
49
|
-
/// <reference types="@karaoke-cms/astro/client" />
|
|
50
|
-
```
|
|
62
|
+
### `@karaoke-cms/astro` — main integration
|
|
51
63
|
|
|
52
|
-
|
|
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
|
-
`
|
|
69
|
+
### `@karaoke-cms/astro/env`
|
|
55
70
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
62
|
-
|
|
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
|
-
|
|
93
|
+
Add to `src/env.d.ts` for TypeScript types:
|
|
66
94
|
|
|
67
|
-
|
|
95
|
+
```ts
|
|
96
|
+
/// <reference types="@karaoke-cms/astro/client" />
|
|
97
|
+
```
|
|
68
98
|
|
|
69
|
-
|
|
99
|
+
## Menus
|
|
70
100
|
|
|
71
|
-
|
|
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
|
-
|
|
103
|
+
## Layout regions
|
|
74
104
|
|
|
75
|
-
|
|
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:
|
|
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
|
|
117
|
+
Available components: `'header'`, `'main-menu'`, `'search'`, `'recent-posts'`, `'footer'`.
|
|
87
118
|
|
|
88
|
-
##
|
|
119
|
+
## What's new in 0.9.5
|
|
89
120
|
|
|
90
|
-
-
|
|
91
|
-
-
|
|
92
|
-
-
|
|
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.
|
|
4
|
+
"version": "0.9.5",
|
|
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
|
}
|
package/src/collections.ts
CHANGED
|
@@ -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({
|
|
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
|
|
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 />}
|
package/src/define-module.ts
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
43
|
+
hasDefaultCss: !!def.defaultCssPath,
|
|
44
|
+
scaffoldPages: def.scaffoldPages?.(mount),
|
|
45
|
+
defaultCssPath: def.defaultCssPath,
|
|
36
46
|
};
|
|
37
47
|
};
|
|
38
48
|
}
|
package/src/define-theme.ts
CHANGED
|
@@ -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
|
|
17
|
-
*
|
|
18
|
-
*
|
|
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(
|
|
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
|
-
|
|
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 {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
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
|
|
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
|
|
38
|
-
* as a nested integration. The theme owns
|
|
39
|
-
*
|
|
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
|
|
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
|
-
|
|
80
|
-
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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 {
|
|
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
|
|
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} {
|
|
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} {
|
|
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} {
|
|
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} {
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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.
|
|
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
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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?.
|
|
34
|
-
repo: config?.
|
|
35
|
-
repoId: config?.
|
|
36
|
-
category: config?.
|
|
37
|
-
categoryId: config?.
|
|
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
|
}
|
package/src/validate-config.js
CHANGED
|
@@ -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?.
|
|
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:
|
|
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
|
}
|