@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 +94 -0
- package/client.d.ts +3 -1
- package/package.json +8 -12
- package/src/blog-schema.ts +13 -0
- package/src/collections.ts +19 -4
- package/src/components/Menu.astro +71 -0
- package/src/components/MenuItems.astro +28 -0
- package/src/components/regions/MainMenu.astro +2 -20
- package/src/components/regions/SiteFooter.astro +4 -4
- package/src/index.ts +74 -40
- package/src/layouts/Base.astro +8 -7
- package/src/types.ts +34 -0
- package/src/utils/resolve-menus.ts +95 -0
- package/src/validate-config.js +0 -33
- package/src/pages/404.astro +0 -14
- package/src/pages/blog/[slug].astro +0 -66
- package/src/pages/blog/index.astro +0 -31
- package/src/pages/docs/[slug].astro +0 -66
- package/src/pages/docs/index.astro +0 -31
- package/src/pages/index.astro +0 -65
- package/src/pages/tags/[tag].astro +0 -53
- package/src/pages/tags/index.astro +0 -41
- package/src/themes/default/styles.css +0 -446
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.
|
|
5
|
-
"description": "Astro integration for karaoke-cms —
|
|
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
|
-
"./
|
|
13
|
-
"./
|
|
14
|
-
"./
|
|
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
|
+
});
|
package/src/collections.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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,
|
|
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:
|
|
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
|
-
|
|
2
|
+
import Menu from '../Menu.astro';
|
|
3
3
|
---
|
|
4
|
-
<
|
|
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
|
-
<
|
|
4
|
-
|
|
5
|
-
<a href="https://
|
|
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 {
|
|
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 {
|
|
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
|
|
11
|
+
import { resolveMenus } from './utils/resolve-menus.js';
|
|
12
|
+
import type { KaraokeConfig, ResolvedModules, ResolvedLayout, ResolvedCollections, ResolvedMenus } from './types.js';
|
|
11
13
|
|
|
12
|
-
|
|
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
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
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
|
-
*
|
|
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
|
-
|
|
66
|
+
_resolvedCollections = resolveCollections(vaultDir, isProd, config.collections);
|
|
67
|
+
const _resolvedMenus = resolveMenus(vaultDir);
|
|
55
68
|
|
|
56
|
-
// ──
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
//
|
|
68
|
-
if (
|
|
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
|
-
|
|
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';
|
package/src/layouts/Base.astro
CHANGED
|
@@ -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 ===
|
|
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={
|
|
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
|
-
<
|
|
69
|
-
<
|
|
70
|
-
|
|
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>;
|