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 authors
  • required — when true, every theme that implements this module must provide a CSS rule for this class. When false, 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

FieldTypeDescription
idstringUnique module identifier (e.g. 'blog', 'widget')
cssContractCssContractCSS class declarations (see above)
hasUxbooleanWhether this module renders visual UI. Set true for modules with pages/components, false for metadata-only modules (e.g. SEO).
defaultCssPathstringAbsolute 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.
integrationAstroIntegrationOptional 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 exports map
  • No imports from @karaoke-cms/astro (contracts only)
  • hasUx: true modules have a defaultCssPath set
/module-contract