Writing a Custom Module
A module is a self-contained feature package: it owns a set of routes, menu entries, and a list of CSS class names it will put on its DOM elements. Themes provide the styles for those classes. This separation lets you swap themes without touching module code.
The contract-first rule
Everything you need is in @karaoke-cms/contracts. Never import from @karaoke-cms/astro in a module or theme package. The contracts package has zero runtime dependencies beyond a peer reference to astro.
// package.json
{
"peerDependencies": {
"astro": ">=6.0.0",
"@karaoke-cms/contracts": "^0.18.0"
}
}
No @karaoke-cms/astro peer dep is needed.
The CSS contract
Before writing any UI, declare which CSS classes your module will use. This is the CssContract: a record mapping class names to metadata.
// src/css-contract.ts
import type { CssContract } from '@karaoke-cms/contracts';
export const cssContract: CssContract = {
'widget-wrapper': { description: 'Top-level page wrapper', required: true },
'widget-item': { description: 'Individual item card', required: true },
'widget-badge': { description: 'Optional status badge', required: false },
};
Each key is a CSS class name. The value has two fields:
description— human-readable explanation for tooling and theme authorsrequired— whentrue, every theme that implements this module must provide a CSS rule for this class. Whenfalse, the class is optional (progressive enhancement).
Class names must be prefixed with the module id followed by a hyphen (e.g. blog-card, docs-sidebar). The /module-contract skill validates this automatically.
Module styles: <id>-styles.css
Modules with visual UI (hasUx: true) must ship a default CSS file. This provides functional fallback styling for the module’s contract classes. Themes override these styles via the cascade.
/* src/styles/widget-styles.css */
/* Widget module default styles */
.widget-wrapper {
max-width: var(--width-content, 680px);
margin: 0 auto;
}
.widget-item {
padding: var(--spacing-md, 1rem) 0;
border-bottom: 1px solid var(--color-border, #e5e5e5);
}
.widget-badge {
font-size: var(--font-size-sm, 0.875rem);
color: var(--color-muted, #737373);
}
Conventions:
- Use CSS custom property fallbacks (
var(--token, fallback)) so the file works standalone - Only target class names from your own contract
- Keep it functional/minimal — themes add the visual polish
The CSS file must be exported from package.json:
{
"exports": {
".": "./src/index.ts",
"./widget-styles.css": "./src/styles/widget-styles.css",
"./pages/list": "./src/pages/list.astro"
}
}
Apps import it in their src/styles/imported.css:
@import '@my-org/module-widget/widget-styles.css';
defineModule()
defineModule() is exported from @karaoke-cms/contracts. It takes a ModuleDefinition and returns a factory function. Calling the factory with { mount } produces a ModuleInstance that you pass to karaoke.config.ts.
// src/index.ts
import { defineModule } from '@karaoke-cms/contracts';
import { fileURLToPath } from 'url';
import { join } from 'path';
import { cssContract } from './css-contract.js';
const _srcDir = fileURLToPath(new URL('.', import.meta.url));
export const widget = defineModule({
id: 'widget',
cssContract,
hasUx: true,
defaultCssPath: join(_srcDir, 'styles', 'widget-styles.css'),
routes: (mount) => [
{ pattern: mount, entrypoint: '@my-org/module-widget/pages/list' },
{ pattern: `${mount}/[slug]`, entrypoint: '@my-org/module-widget/pages/detail' },
],
menuEntries: (mount, id) => [
{ id, name: 'Widget', path: mount, section: 'main', weight: 35 },
],
});
ModuleDefinition fields
| Field | Type | Description |
|---|---|---|
id | string | Unique module identifier (e.g. 'blog', 'widget') |
cssContract | CssContract | CSS class declarations (see above) |
hasUx | boolean | Whether this module renders visual UI. Set true for modules with pages/components, false for metadata-only modules (e.g. SEO). |
defaultCssPath | string | Absolute path to the default CSS file. Required when hasUx: true. |
routes | (mount: string) => RouteDefinition[] | Returns routes to inject. Each has pattern and entrypoint. |
menuEntries | (mount: string, id: string) => ModuleMenuEntry[] | Returns menu entries to register. |
integration | AstroIntegration | Optional Astro integration for Vite plugins, build hooks, etc. |
RouteDefinition
interface RouteDefinition {
pattern: string; // URL pattern, e.g. '/widget' or '/widget/[slug]'
entrypoint: string; // Package export path to the .astro page file
}
The entrypoint must be listed in your package’s exports map. Unlisted files cannot be resolved by Vite.
ModuleMenuEntry
interface ModuleMenuEntry {
id: string; // Unique id for this entry (referenced by parent)
name: string; // Display text
path: string; // URL path
section: string; // Menu section: 'main' or 'footer'
weight: number; // Sort order. Lower = earlier. Blog = 10, Tags = 30.
parent?: string; // Id of a root entry to nest under (single-level only)
}
Optional: Astro integration
If your module needs Vite plugins, build hooks, or updateConfig calls, add an integration field:
import type { AstroIntegration } from 'astro';
const integration: AstroIntegration = {
name: '@my-org/module-widget',
hooks: {
'astro:config:setup': ({ updateConfig }) => {
updateConfig({
vite: {
plugins: [/* ... */],
},
});
},
},
};
export const widget = defineModule({
id: 'widget',
cssContract,
hasUx: true,
defaultCssPath: join(_srcDir, 'styles', 'widget-styles.css'),
routes: (mount) => [...],
menuEntries: (mount, id) => [...],
integration,
});
karaoke() collects integrations from all active modules and registers them alongside the theme integration.
Using the module in karaoke.config.ts
defineModule() returns a factory function. Call the factory with { mount } to get a ModuleInstance:
// karaoke.config.ts
import { defineConfig } from '@karaoke-cms/astro';
import { widget } from '@my-org/module-widget';
export default defineConfig({
modules: [
widget({ mount: '/widgets' }),
],
});
You can also pass enabled: false to suppress the module without removing it from config:
widget({ mount: '/widgets', enabled: false })
Minimal skeleton
Complete file structure for a new module package:
packages/module-widget/
src/
css-contract.ts <- CssContract declaration
index.ts <- defineModule() + export
styles/
widget-styles.css <- default CSS for contract classes
pages/
list.astro <- /widget page
detail.astro <- /widget/[slug] page
package.json <- exports map + peerDependencies
// package.json (relevant fields)
{
"name": "@my-org/module-widget",
"type": "module",
"main": "./src/index.ts",
"exports": {
".": "./src/index.ts",
"./pages/list": "./src/pages/list.astro",
"./pages/detail": "./src/pages/detail.astro",
"./widget-styles.css": "./src/styles/widget-styles.css"
},
"peerDependencies": {
"astro": ">=6.0.0",
"@karaoke-cms/contracts": "^0.18.0"
}
}
Modules without UX
Metadata-only modules (like module-seo) set hasUx: false and do not ship a CSS file:
export const seo = defineModule({
id: 'seo',
cssContract: {}, // empty — no visual output
hasUx: false,
routes: () => [],
menuEntries: () => [],
integration: { /* build hooks only */ },
});
Validating your module
Run the /module-contract skill to check that your module is complete:
- All class names follow the
{id}-*prefix convention - All required route entrypoints are exported in the
exportsmap - No imports from
@karaoke-cms/astro(contracts only) hasUx: truemodules have adefaultCssPathset
/module-contract