@karaoke-cms/astro 0.9.8 → 0.10.3

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # @karaoke-cms/astro
2
2
 
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.
3
+ Core Astro integration for karaoke-cms. Wires up themes, modules, collections, menus, Obsidian embed handling, and the virtual config module at build time. Every karaoke-cms project depends on this package.
4
4
 
5
5
  ## Installation
6
6
 
@@ -57,6 +57,56 @@ All fields are optional. Defaults produce a working site with no content.
57
57
  | `collections` | `Record<string, CollectionConfig>` | — | Per-collection mode overrides |
58
58
  | `layout.regions` | `{ top, left, right, bottom }` | — | Region component lists |
59
59
 
60
+ ## Obsidian embed syntax
61
+
62
+ Markdown files can use Obsidian's `![[file]]` embed syntax. The integration handles it automatically:
63
+
64
+ ### Image embeds
65
+
66
+ ```markdown
67
+ ![[photo.jpg]] — embedded image (Astro-optimized)
68
+ ![[photo.jpg|300]] — image with width hint → <img width="300">
69
+ ![[photo.jpg|300x200]] — image with width and height → <img width="300" height="200">
70
+ ![[photo.jpg|alt text]] — image with alt text
71
+ ```
72
+
73
+ Image files are resolved in this order:
74
+ 1. Same folder as the note
75
+ 2. Vault `attachmentFolderPath` from `.obsidian/app.json`
76
+ 3. Vault root
77
+
78
+ Resolved images go through Astro's image optimisation pipeline.
79
+
80
+ ### Audio and video embeds
81
+
82
+ ```markdown
83
+ ![[recording.mp3]] — → <audio controls>
84
+ ![[clip.mp4]] — → <video controls>
85
+ ```
86
+
87
+ Audio and video files are served from `/media/` in dev and copied to `dist/media/` at build time.
88
+
89
+ ### Wiki links in `featured_image` frontmatter
90
+
91
+ ```yaml
92
+ featured_image: "[[hero.jpg]]"
93
+ ```
94
+
95
+ The wiki link is resolved using the same vault path lookup as inline embeds and rewritten to `/media/hero.jpg`. Use `resolveWikiImage()` in your templates — see below.
96
+
97
+ ## `resolveWikiImage(value)`
98
+
99
+ A utility for templates that render `featured_image`. Call it on the raw frontmatter value — it rewrites `[[file.jpg]]` to `/media/file.jpg` and passes regular paths and URLs through unchanged.
100
+
101
+ ```ts
102
+ import { resolveWikiImage } from '@karaoke-cms/astro';
103
+
104
+ // In an Astro template:
105
+ <img src={resolveWikiImage(entry.data.featured_image)} alt={entry.data.title} />
106
+ ```
107
+
108
+ This is necessary because Astro sets `entry.data` from validated frontmatter at collection-load time, before the remark plugin runs. Templates must call `resolveWikiImage()` to get the resolved URL.
109
+
60
110
  ## Exports
61
111
 
62
112
  ### `@karaoke-cms/astro` — main integration
@@ -65,6 +115,7 @@ All fields are optional. Defaults produce a working site with no content.
65
115
  - `defineConfig(config)` — identity wrapper for type inference
66
116
  - `defineModule(def)` — create a module factory
67
117
  - `defineTheme(def)` — create a theme factory
118
+ - `resolveWikiImage(value)` — resolve `[[wiki link]]` to `/media/` URL for use in templates
68
119
 
69
120
  ### `@karaoke-cms/astro/env`
70
121
 
@@ -116,6 +167,29 @@ layout: {
116
167
 
117
168
  Available components: `'header'`, `'main-menu'`, `'search'`, `'recent-posts'`, `'footer'`.
118
169
 
170
+ ## What's new in 0.10.3
171
+
172
+ - **Vault path warning** — if `KARAOKE_VAULT` points to a directory that doesn't exist, a clear warning is printed at startup instead of silently building a site with empty collections. The message shows the resolved path and points to `.env` / `.env.default`.
173
+ - **`menus.yaml` live reload** — editing `{vault}/karaoke-cms/config/menus.yaml` while the dev server is running now triggers an immediate full-page reload. No more restarting `npm run dev` after menu changes.
174
+ - **Security: path traversal guard hardened** — the docs codegen path-traversal check now uses `path.normalize()` on both sides of the comparison, so the guard holds correctly on Windows and with symlinked paths.
175
+
176
+ ## What's new in 0.10.2
177
+
178
+ - **Multiple named docs sections** — run `docs({ mount: '/api-docs', folder: 'api-reference', label: 'API Reference' })` alongside `docs({ mount: '/docs' })`. Each section gets its own sidebar, URL space, and Astro content collection. Routes are code-generated per instance at build time.
179
+ - **Auto-registered docs collections** — pass your full config to `makeCollections({ config })` in `content.config.ts` and all docs instances register automatically. No manual sync required when adding a second section.
180
+ - **`virtual:karaoke-cms/docs-sections`** — new Vite virtual module exporting all active docs section specs. Import it in any page to build section switchers or cross-section links.
181
+ - **Section switcher in doc pages** — when two or more docs sections are active, a nav row lists them with the current one highlighted.
182
+ - **Tags and RSS are section-aware** — `/tags`, `/tags/[tag]`, and `/rss.xml` now aggregate entries from every active docs section.
183
+ - **Fix: dev-only collection menu entries** — entries with `when: collection:name` now correctly show in dev mode when the collection exists but has no published entries.
184
+
185
+ ## What's new in 0.10.0
186
+
187
+ - **Obsidian `![[image]]` embed support** — `![[photo.jpg]]` in any markdown note is resolved via vault path lookup and goes through Astro's image optimisation pipeline
188
+ - **Size hints** — `![[photo.jpg|300]]` sets `width="300"`; `![[photo.jpg|300x200]]` sets both width and height
189
+ - **Audio and video embeds** — `![[recording.mp3]]` → `<audio controls>`, `![[clip.mp4]]` → `<video controls>`; files served from `/media/` in dev, emitted to `dist/media/` at build
190
+ - **`featured_image` wiki link support** — `featured_image: "[[hero.jpg]]"` in frontmatter is resolved to `/media/hero.jpg` via vault path lookup
191
+ - **`resolveWikiImage(value)` export** — utility for templates to rewrite `[[file.jpg]]` frontmatter values to the correct `/media/` URL at render time
192
+
119
193
  ## What's new in 0.9.5
120
194
 
121
195
  - **`isThemeInstance` type-guard** added — fixes a runtime crash in the `astro:config:setup` hook when a `ThemeInstance` was passed
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, ResolvedMenus } from '@karaoke-cms/astro';
9
+ import type { ResolvedModules, ResolvedLayout, ResolvedCollections, ResolvedMenus } from '@karaoke-cms/contracts';
10
10
 
11
11
  /** Site title from karaoke.config.ts */
12
12
  export const siteTitle: string;
@@ -23,3 +23,16 @@ declare module 'virtual:karaoke-cms/config' {
23
23
  /** Mount path of the blog module (e.g. '/blog' or '/news'). Defaults to '/blog'. */
24
24
  export const blogMount: string;
25
25
  }
26
+
27
+ declare module 'virtual:karaoke-cms/docs-sections' {
28
+ /** All active docs module instances, in declaration order. */
29
+ export const docsSections: Array<{
30
+ id: string;
31
+ collection: string;
32
+ mount: string;
33
+ folder: string;
34
+ label: string;
35
+ layout: string;
36
+ sidebarStyle: string;
37
+ }>;
38
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@karaoke-cms/astro",
3
3
  "type": "module",
4
- "version": "0.9.8",
4
+ "version": "0.10.3",
5
5
  "description": "Core Astro integration for karaoke-cms — virtual config, wikilinks, handbook routes",
6
6
  "main": "./src/index.ts",
7
7
  "exports": {
@@ -36,13 +36,14 @@
36
36
  "@astrojs/sitemap": "^3.7.1",
37
37
  "remark-wiki-link": "^2.0.1",
38
38
  "yaml": "^2.7.0",
39
- "zod": "^4.0.0"
39
+ "zod": "^4.0.0",
40
+ "@karaoke-cms/contracts": "0.10.3"
40
41
  },
41
42
  "devDependencies": {
42
43
  "vitest": "^4.1.1",
43
44
  "astro": "^6.0.8"
44
45
  },
45
46
  "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 test/theme-default-styles.test.js test/module-injection.test.js"
47
+ "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 test/codegen-docs.test.js test/codegen-snapshot.test.js"
47
48
  }
48
- }
49
+ }
@@ -0,0 +1,210 @@
1
+ import { mkdirSync, writeFileSync, rmSync, existsSync } from 'fs';
2
+ import { join, normalize, sep } from 'path';
3
+ import { pathToFileURL } from 'url';
4
+
5
+ export interface DocsInstanceSpec {
6
+ id: string;
7
+ collection: string;
8
+ mount: string;
9
+ folder: string;
10
+ label: string;
11
+ layout: string;
12
+ sidebarStyle: string;
13
+ }
14
+
15
+ /**
16
+ * Generate per-instance .astro page files for all active docs instances.
17
+ *
18
+ * Each instance gets three files written to:
19
+ * {rootDir}/.astro/generated/karaoke-cms/{id}/doc.astro
20
+ * {rootDir}/.astro/generated/karaoke-cms/{id}/home.astro
21
+ * {rootDir}/.astro/generated/karaoke-cms/{id}/list.astro
22
+ *
23
+ * The generated root is wiped on every call so stale files from removed
24
+ * instances are automatically cleaned up.
25
+ *
26
+ * @returns Array of { pattern, entrypoint } ready to pass to injectRoute().
27
+ */
28
+ export function generateDocsInstancePages(
29
+ rootDir: string,
30
+ instances: DocsInstanceSpec[],
31
+ ): Array<{ pattern: string; entrypoint: string }> {
32
+ const generatedRoot = join(rootDir, '.astro', 'generated', 'karaoke-cms');
33
+
34
+ // Always wipe the generated root so stale files from removed instances are cleaned up.
35
+ if (existsSync(generatedRoot)) {
36
+ rmSync(generatedRoot, { recursive: true, force: true });
37
+ }
38
+
39
+ if (instances.length === 0) return [];
40
+
41
+ mkdirSync(generatedRoot, { recursive: true });
42
+
43
+ const routes: Array<{ pattern: string; entrypoint: string }> = [];
44
+
45
+ for (const inst of instances) {
46
+ const dir = join(generatedRoot, inst.id);
47
+ // Guard against path traversal via a malicious id (e.g., '../../evil').
48
+ // normalize() resolves '..' segments before comparison so the check holds on
49
+ // all platforms and with symlinks in the path.
50
+ const normalizedDir = normalize(dir);
51
+ const normalizedRoot = normalize(generatedRoot);
52
+ if (!normalizedDir.startsWith(normalizedRoot + sep) && normalizedDir !== normalizedRoot) {
53
+ throw new Error(`[karaoke-cms] docs() id "${inst.id}" resolves outside the generated root. Use a simple identifier without path separators.`);
54
+ }
55
+ mkdirSync(dir, { recursive: true });
56
+
57
+ writeFileSync(join(dir, 'doc.astro'), generateDocPage(inst), 'utf8');
58
+ writeFileSync(join(dir, 'home.astro'), generateHomePage(inst), 'utf8');
59
+ writeFileSync(join(dir, 'list.astro'), generateListPage(inst), 'utf8');
60
+
61
+ routes.push(
62
+ { pattern: `${inst.mount}/[slug]`, entrypoint: pathToFileURL(join(dir, 'doc.astro')).href },
63
+ { pattern: inst.mount || '/', entrypoint: pathToFileURL(join(dir, 'home.astro')).href },
64
+ { pattern: `${inst.mount}/list`, entrypoint: pathToFileURL(join(dir, 'list.astro')).href },
65
+ );
66
+ }
67
+
68
+ return routes;
69
+ }
70
+
71
+ export function generateDocPage(inst: DocsInstanceSpec): string {
72
+ return `---
73
+ // AUTO-GENERATED by karaoke-cms — do not edit directly.
74
+ // Re-generated on each dev/build start. Source: packages/astro/src/codegen/generate-docs-instance.ts
75
+ const section = ${JSON.stringify(inst)};
76
+ import { getCollection, render } from 'astro:content';
77
+ import DefaultPage from '@karaoke-cms/astro/layouts/DefaultPage.astro';
78
+ import ModuleLoader from '@karaoke-cms/astro/components/ModuleLoader.astro';
79
+ import { siteTitle } from 'virtual:karaoke-cms/config';
80
+ import { docsSections } from 'virtual:karaoke-cms/docs-sections';
81
+ import { resolveWikiImage } from '@karaoke-cms/astro';
82
+
83
+ export async function getStaticPaths() {
84
+ // Use a string literal here — module-scope const is not accessible inside
85
+ // getStaticPaths after Astro/Vite extraction in production builds.
86
+ const docs = await getCollection(${JSON.stringify(inst.collection)}, ({ data }) => data.publish === true);
87
+ return docs.map(entry => ({
88
+ params: { slug: entry.id },
89
+ props: { entry },
90
+ }));
91
+ }
92
+
93
+ const { entry } = Astro.props;
94
+ const { Content } = await render(entry);
95
+
96
+ const allDocs = (await getCollection(${JSON.stringify(inst.collection)}, ({ data }) => data.publish === true))
97
+ .sort((a, b) => a.data.title.localeCompare(b.data.title));
98
+ ---
99
+
100
+ <DefaultPage title={\`\${entry.data.title} — \${siteTitle}\`} description={entry.data.description} type="article">
101
+ {docsSections.length > 1 && (
102
+ <nav class="docs-section-switcher" slot="left">
103
+ {docsSections.map(s => (
104
+ <a href={s.mount} class={\`docs-section-link\${s.id === section.id ? ' active' : ''}\`}>
105
+ {s.label}
106
+ </a>
107
+ ))}
108
+ </nav>
109
+ )}
110
+ <nav class="docs-sidebar" slot="left">
111
+ <ul class="docs-sidebar-list">
112
+ {allDocs.map(doc => (
113
+ <li class="docs-sidebar-item">
114
+ <a href={\`\${section.mount}/\${doc.id}\`} aria-current={doc.id === entry.id ? 'page' : undefined}>
115
+ {doc.data.title}
116
+ </a>
117
+ </li>
118
+ ))}
119
+ </ul>
120
+ </nav>
121
+ <article class="docs-article">
122
+ {entry.data.featured_image && (
123
+ <img src={resolveWikiImage(entry.data.featured_image)} alt="" class="docs-article-image" />
124
+ )}
125
+ <h1 class="docs-article-title">{entry.data.title}</h1>
126
+ {entry.data.tags && entry.data.tags.length > 0 && (
127
+ <div class="docs-tag-list docs-article-meta">
128
+ {entry.data.tags.map(tag => (
129
+ <a href={\`/tags/\${tag}\`} class="docs-tag">#{tag}</a>
130
+ ))}
131
+ </div>
132
+ )}
133
+ <div class="prose">
134
+ <Content />
135
+ </div>
136
+ </article>
137
+ <ModuleLoader comments={entry.data.comments} />
138
+ </DefaultPage>
139
+ `;
140
+ }
141
+
142
+ export function generateHomePage(inst: DocsInstanceSpec): string {
143
+ return `---
144
+ // AUTO-GENERATED by karaoke-cms — do not edit directly.
145
+ // Re-generated on each dev/build start. Source: packages/astro/src/codegen/generate-docs-instance.ts
146
+ const section = ${JSON.stringify(inst)};
147
+ import { getCollection } from 'astro:content';
148
+ import DefaultPage from '@karaoke-cms/astro/layouts/DefaultPage.astro';
149
+ import { siteTitle } from 'virtual:karaoke-cms/config';
150
+
151
+ const docs = (await getCollection(${JSON.stringify(inst.collection)}, ({ data }) => data.publish === true))
152
+ .sort((a, b) => a.data.title.localeCompare(b.data.title));
153
+ ---
154
+
155
+ <DefaultPage title={\`\${section.label} — \${siteTitle}\`}>
156
+ <div class="docs-home">
157
+ <h1>{section.label}</h1>
158
+ {docs.length > 0 ? (
159
+ <ul class="docs-home-list">
160
+ {docs.map(doc => (
161
+ <li class="docs-home-item">
162
+ <a href={\`\${section.mount}/\${doc.id}\`}>{doc.data.title}</a>
163
+ </li>
164
+ ))}
165
+ </ul>
166
+ ) : (
167
+ <div class="empty-state">
168
+ <p>No docs published yet.</p>
169
+ <p>Create a Markdown file in your vault's <code>{section.folder}/</code> folder and set <code>publish: true</code> in the frontmatter to make it appear here.</p>
170
+ </div>
171
+ )}
172
+ </div>
173
+ </DefaultPage>
174
+ `;
175
+ }
176
+
177
+ export function generateListPage(inst: DocsInstanceSpec): string {
178
+ return `---
179
+ // AUTO-GENERATED by karaoke-cms — do not edit directly.
180
+ // Re-generated on each dev/build start. Source: packages/astro/src/codegen/generate-docs-instance.ts
181
+ const section = ${JSON.stringify(inst)};
182
+ import { getCollection } from 'astro:content';
183
+ import DefaultPage from '@karaoke-cms/astro/layouts/DefaultPage.astro';
184
+ import { siteTitle } from 'virtual:karaoke-cms/config';
185
+
186
+ const docs = (await getCollection(${JSON.stringify(inst.collection)}, ({ data }) => data.publish === true))
187
+ .sort((a, b) => a.data.title.localeCompare(b.data.title));
188
+ ---
189
+
190
+ <DefaultPage title={\`All \${section.label} — \${siteTitle}\`}>
191
+ <div class="docs-list">
192
+ <h1>All {section.label}</h1>
193
+ {docs.length > 0 ? (
194
+ <ul>
195
+ {docs.map(doc => (
196
+ <li class="docs-list-item">
197
+ <a href={\`\${section.mount}/\${doc.id}\`}>{doc.data.title}</a>
198
+ </li>
199
+ ))}
200
+ </ul>
201
+ ) : (
202
+ <div class="empty-state">
203
+ <p>No docs published yet.</p>
204
+ <p>Create a Markdown file in your vault's <code>{section.folder}/</code> folder and set <code>publish: true</code> in the frontmatter to make it appear here.</p>
205
+ </div>
206
+ )}
207
+ </div>
208
+ </DefaultPage>
209
+ `;
210
+ }
@@ -3,7 +3,11 @@ import { glob } from 'astro/loaders';
3
3
  import { fileURLToPath } from 'url';
4
4
  import { join, resolve, isAbsolute } from 'path';
5
5
  import { resolveCollections } from './utils/resolve-collections.js';
6
- import type { CollectionConfig } from './types.js';
6
+ import type { CollectionConfig, KaraokeConfig, ModuleInstance, ThemeInstance } from './types.js';
7
+
8
+ function isThemeInstance(theme: unknown): theme is ThemeInstance {
9
+ return typeof theme === 'object' && theme !== null && (theme as ThemeInstance)._type === 'theme-instance';
10
+ }
7
11
  export { blogThemeExtension } from './blog-schema.js';
8
12
 
9
13
  const baseSchema = z.object({
@@ -36,6 +40,11 @@ export interface MakeCollectionsOptions {
36
40
  collections?: Record<string, CollectionConfig>;
37
41
  /** Zod schema extension merged into the blog collection schema. Use with @karaoke-cms/theme-blog. */
38
42
  blogSchema?: z.ZodObject<z.ZodRawShape>;
43
+ /**
44
+ * Full karaoke config. When provided, Astro collections are auto-registered
45
+ * for every docs() instance declared in config.modules and theme.implementedModules.
46
+ */
47
+ config?: KaraokeConfig;
39
48
  }
40
49
 
41
50
  /**
@@ -114,5 +123,28 @@ export function makeCollections(
114
123
  }
115
124
  }
116
125
 
126
+ // Register collections for docs() instances from config.modules and theme.implementedModules.
127
+ // Skip if already registered by the hardcoded blocks above (hardcoded schema wins for 'docs').
128
+ if (opts?.config) {
129
+ const configMods: ModuleInstance[] = opts.config.modules ?? [];
130
+ const themeMods: ModuleInstance[] = isThemeInstance(opts.config.theme)
131
+ ? (opts.config.theme.implementedModules ?? [])
132
+ : [];
133
+ const seenCollections = new Set(Object.keys(collections));
134
+ for (const mod of [...configMods, ...themeMods]) {
135
+ if (!mod.collection || mod.enabled === false) continue;
136
+ const name = mod.collection.name;
137
+ if (seenCollections.has(name)) continue;
138
+ seenCollections.add(name);
139
+ collections[name] = defineCollection({
140
+ loader: glob({ pattern: '**/*.md', base: join(vaultDir, mod.collection.folder) }),
141
+ schema: baseSchema.extend({
142
+ featured_image: z.string().optional(),
143
+ comments: z.boolean().optional().default(false),
144
+ }),
145
+ });
146
+ }
147
+ }
148
+
117
149
  return collections;
118
150
  }
@@ -36,7 +36,7 @@ async function isVisible(entry: ResolvedMenuEntry): Promise<boolean> {
36
36
  // colName is a runtime string from YAML, not a literal type.
37
37
  // resolvedCollections[colName]?.enabled check above ensures it's a known collection.
38
38
  // getCollection() throws if the collection is unknown — caught below.
39
- const entries = await getCollection(colName as any, ({ data }: any) => data.publish === true);
39
+ const entries = await getCollection(colName as any, import.meta.env.PROD ? ({ data }: any) => data.publish === true : undefined);
40
40
  collectionEmpty[colName] = entries.length === 0;
41
41
  } catch {
42
42
  collectionEmpty[colName] = true;
@@ -1,14 +1,17 @@
1
1
  ---
2
2
  import { getCollection } from 'astro:content';
3
+ import { docsSections } from 'virtual:karaoke-cms/docs-sections';
3
4
 
4
5
  const LIMIT = 5;
5
6
 
6
- // Detect which collection we're in and exclude the current page
7
+ // Detect which section we're in and exclude the current page
7
8
  const pathname = Astro.url.pathname.replace(/\/$/, '');
8
- const collection = pathname.startsWith('/docs/') ? 'docs' : 'blog';
9
+ const matchedSection = docsSections.find(s => pathname === s.mount || pathname.startsWith(s.mount + '/'));
10
+ const collection = matchedSection ? matchedSection.collection : 'blog';
11
+ const mount = matchedSection ? matchedSection.mount : '/blog';
9
12
  const currentSlug = pathname.split('/').at(-1) ?? '';
10
13
 
11
- const posts = (await getCollection(collection, ({ data }) => data.publish === true))
14
+ const posts = (await getCollection(collection as any, ({ data }) => data.publish === true))
12
15
  .sort((a, b) => (b.data.date?.valueOf() ?? 0) - (a.data.date?.valueOf() ?? 0))
13
16
  .filter(post => post.id !== currentSlug)
14
17
  .slice(0, LIMIT);
@@ -20,7 +23,7 @@ const posts = (await getCollection(collection, ({ data }) => data.publish === tr
20
23
  <ul class="sidebar-list">
21
24
  {posts.map(post => (
22
25
  <li>
23
- <a href={`/${collection}/${post.id}`}>{post.data.title}</a>
26
+ <a href={`${mount}/${post.id}`}>{post.data.title}</a>
24
27
  {post.data.date && (
25
28
  <span class="post-date">{post.data.date.toISOString().slice(0, 10)}</span>
26
29
  )}
@@ -11,7 +11,9 @@ import pkg from '../../../package.json';
11
11
  <div class="footer-col">
12
12
  <Menu name="footer-2" />
13
13
  </div>
14
- <div class="footer-col"></div>
14
+ <div class="footer-col">
15
+ <Menu name="footer-3" />
16
+ </div>
15
17
  <div class="footer-col">
16
18
  <Menu name="footer" />
17
19
  </div>
@@ -1,48 +1,3 @@
1
- import type { ModuleInstance, ModuleMenuEntry } from './types.js';
2
-
3
- export interface ModuleDefinition {
4
- id: string;
5
- cssContract: readonly string[];
6
- /** Absolute path to a default CSS file. On first dev run, copied to src/styles/{id}.css. */
7
- defaultCssPath?: string;
8
- routes: (mount: string) => Array<{ pattern: string; entrypoint: string }>;
9
- menuEntries: (mount: string, id: string) => ModuleMenuEntry[];
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 }>;
16
- }
17
-
18
- /**
19
- * Define a karaoke-cms module.
20
- *
21
- * Returns a factory function. Call the factory with `{ mount }` (and optionally
22
- * `{ id }` for multi-instance support) to get a ModuleInstance that can be
23
- * passed to `defineConfig({ modules: [...] })`.
24
- *
25
- * @example
26
- * export const blog = defineModule({ id: 'blog', routes: (mount) => [...], ... })
27
- * // In karaoke.config.ts:
28
- * modules: [blog({ mount: '/blog' })]
29
- */
30
- export function defineModule(def: ModuleDefinition) {
31
- return function moduleFactory(config: { id?: string; mount: string; enabled?: boolean }): ModuleInstance {
32
- const id = config.id ?? def.id;
33
- const mount = config.mount.replace(/\/$/, '');
34
- const enabled = config.enabled ?? true;
35
- return {
36
- _type: 'module-instance',
37
- id,
38
- mount,
39
- enabled,
40
- routes: def.routes(mount),
41
- menuEntries: def.menuEntries(mount, id),
42
- cssContract: def.cssContract,
43
- hasDefaultCss: !!def.defaultCssPath,
44
- scaffoldPages: def.scaffoldPages?.(mount),
45
- defaultCssPath: def.defaultCssPath,
46
- };
47
- };
48
- }
1
+ // defineModule() and ModuleDefinition now live in @karaoke-cms/contracts.
2
+ // Re-exported here so internal callers (index.ts) are unaffected.
3
+ export { defineModule, type ModuleDefinition } from '@karaoke-cms/contracts';
@@ -1,34 +1,3 @@
1
- import type { AstroIntegration } from 'astro';
2
- import type { ModuleInstance, ThemeInstance } from './types.js';
3
-
4
- export interface ThemeFactoryConfig {}
5
-
6
- export interface ThemeDefinition {
7
- id: string;
8
- toAstroIntegration: (config: ThemeFactoryConfig, modules: ModuleInstance[]) => AstroIntegration;
9
- }
10
-
11
- /**
12
- * Define a karaoke-cms theme.
13
- *
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)`.
17
- *
18
- * @example
19
- * export const themeDefault = defineTheme({
20
- * id: 'theme-default',
21
- * toAstroIntegration: (config, modules) => ({ name: '...', hooks: { ... } }),
22
- * })
23
- * // In karaoke.config.ts:
24
- * theme: themeDefault()
25
- */
26
- export function defineTheme(def: ThemeDefinition) {
27
- return function themeFactory(config: ThemeFactoryConfig = {}): ThemeInstance {
28
- return {
29
- _type: 'theme-instance',
30
- id: def.id,
31
- toAstroIntegration: (modules: ModuleInstance[]) => def.toAstroIntegration(config, modules),
32
- };
33
- };
34
- }
1
+ // defineTheme(), ThemeDefinition, and ThemeFactoryConfig now live in @karaoke-cms/contracts.
2
+ // Re-exported here so internal callers (index.ts) are unaffected.
3
+ export { defineTheme, type ThemeDefinition, type ThemeFactoryConfig } from '@karaoke-cms/contracts';