@canonical/summon-component
v0.33.0
Published
Component generators for Summon - React, Svelte and Lit component scaffolding
Maintainers
Keywords
Readme
@canonical/summon-component
Component scaffolding for React and Svelte projects. Generates production-ready component structures with TypeScript, tests, stories, and styles — all following consistent conventions.
Why Use This?
Creating a new component by hand means creating 5-7 files with boilerplate that's almost identical every time. This generator handles that in one command, ensuring:
- Consistent file structure across your codebase
- Proper TypeScript types from the start
- Test files that actually test something
- Storybook stories ready to customize
- Auto-registration in your component barrel
Installation
Install this package alongside the @canonical/summon CLI — summon
auto-discovers it and exposes summon component react|svelte|lit:
npm install -g @canonical/summon @canonical/summon-component
# or: bun add -g @canonical/summon @canonical/summon-componentThe CLI runs under plain Node (no Bun required). Install both into the same location (both global, or both as project dependencies) so discovery can find the generators.
Quick Start
React Component
# Interactive — prompts guide you
summon component react
# Direct — specify path
summon component react --component-path=src/components/Button
# Preview — see what would be created
summon component react --component-path=src/components/Button --dry-runSvelte Component
# Interactive
summon component svelte
# Direct
summon component svelte --component-path=src/lib/components/Card
# With TypeScript stories instead of Svelte CSF
summon component svelte --component-path=src/lib/components/Card --use-ts-storiesWeb Component (Lit)
# Interactive — prompts guide you
summon component lit
# Direct — specify path
summon component lit --component-path=src/lib/Button
# Preview — see what would be created
summon component lit --component-path=src/lib/Button --dry-runWhat Gets Generated
React
For summon component react --component-path=src/components/Button:
src/components/Button/
├── Button.tsx # Component implementation
├── types.ts # Props interface
├── index.ts # Barrel export
├── Button.test.tsx # Unit tests (Testing Library)
├── Button.ssr.test.tsx # SSR tests (optional)
├── Button.stories.tsx # Storybook stories (optional)
└── styles.css # Component styles (optional)And appends to src/components/index.ts:
export * from "./Button";Generated Component
// Button.tsx
import type { ButtonProps } from "./types";
import "./styles.css";
export const Button = ({
className,
children,
...props
}: ButtonProps): JSX.Element => {
return (
<div className={`button${className ? ` ${className}` : ""}`} {...props}>
{children}
</div>
);
};Generated Types
// types.ts
import type { HTMLAttributes, PropsWithChildren } from "react";
export interface ButtonProps
extends PropsWithChildren<HTMLAttributes<HTMLDivElement>> {}Generated Test
// Button.test.tsx
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { Button } from "./Button";
describe("Button", () => {
it("renders children", () => {
render(<Button>Hello</Button>);
expect(screen.getByText("Hello")).toBeInTheDocument();
});
it("applies custom className", () => {
render(<Button className="custom">Content</Button>);
expect(screen.getByText("Content")).toHaveClass("custom");
});
});Svelte
For summon component svelte --component-path=src/lib/components/Card:
src/lib/components/Card/
├── Card.svelte # Svelte 5 component with runes
├── types.ts # Props interface
├── index.ts # Barrel export
├── Card.svelte.test.ts # Unit tests
├── Card.ssr.test.ts # SSR tests (optional)
├── Card.stories.svelte # Storybook CSF (optional)
└── styles.css # External styles (optional, or inline <style>)Generated Component
<!-- Card.svelte -->
<script lang="ts">
import type { CardProps } from "./types";
let {
class: className = "",
children,
...props
}: CardProps = $props();
</script>
<div class="card{className ? ` ${className}` : ''}" {...props}>
{@render children?.()}
</div>
<style>
.card {
/* Component styles */
}
</style>Uses Svelte 5 runes ($props()) and render tags (@render).
Web Components (Lit)
For summon component lit --component-path=src/lib/Button:
src/lib/Button/
├── Button.ts # Lit element implementation
├── types.ts # Props interface
├── index.ts # Barrel export
├── Button.tests.ts # Unit tests (Vitest + shadow DOM)
├── Button.stories.ts # Storybook stories (optional)
└── styles.css # Component styles as CSSResult (optional)And appends to src/lib/index.ts:
export * from "./Button/index.js";Generated Component
// Button.ts
import { html, LitElement } from "lit";
import { customElement } from "lit/decorators.js";
import styles from "./styles.css";
import type { ButtonProps } from "./types.js";
const componentCssClassName = "ds button";
/**
* Button web component.
*
* @implements ds:global.component.button
*/
@customElement("ds-button")
export default class Button extends LitElement implements ButtonProps {
static styles = styles;
render() {
return html`
<div class="${componentCssClassName}">
<slot></slot>
</div>
`;
}
}Generated Types
// types.ts
export interface ButtonProps {
// Define props specific to Button here
}Generated Styles
/* styles.css */
:host {
/*
* Declare component-scoped CSS variables here.
* Variables must be on :host to be accessible inside the shadow tree.
*
* Example:
* --button-color-background: var(--color-background-default);
* --button-color-text: var(--color-text-default);
*/
}
.ds.button {
/* Component styles */
}Generated Test
// Button.tests.ts
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import "./Button.js";
import type Button from "./Button.js";
describe("Button component", () => {
let elem: Button;
beforeEach(() => {
elem = document.createElement("ds-button") as Button;
document.body.appendChild(elem);
});
afterEach(() => {
elem.remove();
});
it("should render component", async () => {
await customElements.whenDefined("ds-button");
const container = elem.shadowRoot?.querySelector(".ds.button");
expect(container).toBeTruthy();
});
it("should have correct tag name", () => {
expect(elem.tagName.toLowerCase()).toBe("ds-button");
});
});Generated Stories
// Button.stories.ts
import type { Meta, StoryObj } from "@storybook/web-components-vite";
import { html } from "lit";
import "./Button.js";
import type Button from "./Button.js";
const meta = {
title: "Web Components/Button",
tags: ["autodocs"],
} satisfies Meta<Button>;
export default meta;
type Story = StoryObj<Button>;
export const Default: Story = {
render: () => html`<ds-button></ds-button>`,
};Options Reference
React Options
| Flag | Description | Default |
|------|-------------|---------|
| --component-path | Full path for the component (e.g., src/components/Button) | Interactive prompt |
| --with-styles | Include styles.css file | true |
| --no-with-styles | Skip styles file | — |
| --with-stories | Include Storybook stories | true |
| --no-with-stories | Skip stories file | — |
| --with-ssr-tests | Include SSR test file | true |
| --no-with-ssr-tests | Skip SSR tests | — |
Svelte Options
| Flag | Description | Default |
|------|-------------|---------|
| --component-path | Full path for the component (e.g., src/lib/components/Card) | Interactive prompt |
| --with-styles | Include inline <style> block | true |
| --no-with-styles | Skip styles | — |
| --with-stories | Include Storybook stories | true |
| --no-with-stories | Skip stories | — |
| --use-ts-stories | Use .stories.ts instead of .stories.svelte | false |
| --with-ssr-tests | Include SSR test file | true |
| --no-with-ssr-tests | Skip SSR tests | — |
Global Options
| Flag | Description |
|------|-------------|
| --dry-run, -d | Preview without writing files |
| --yes, -y | Skip confirmation prompts |
| --no-preview | Skip the file preview step |
| --help | Show all options |
Web Components Options
| Flag | Description | Default |
|------|-------------|---------|
| --component-path | Full path for the component (e.g., src/lib/Button) | Interactive prompt |
| --with-styles | Include styles.css file | true |
| --no-with-styles | Skip styles file | — |
| --with-stories | Include Storybook stories | true |
| --no-with-stories | Skip stories file | — |
Component Path Convention
The --component-path determines:
- Directory location — where files are created
- Component name — derived from the last path segment
--component-path=src/components/UserProfile
# └──────────────┘└─────────┘
# directory name: UserProfileThe component name is used as-is for PascalCase (class names) and converted for other cases:
UserProfile.tsx(filename)UserProfileProps(types)user-profile(CSS class in styles)
Barrel Export Auto-Update
The generator automatically appends an export to the parent directory's index.ts:
summon component react --component-path=src/components/ButtonAppends to src/components/index.ts:
export * from "./Button";If the barrel file doesn't exist, it's created.
Customization
Override with Local Generators
Create a local generator to override the installed one. Local generators take precedence:
your-project/
└── generators/
└── component/
└── react/
└── index.ts # Your custom React component generatorNow summon component react uses your version.
Extend the Base Generators
Import and compose with the existing generators:
// generators/component/react/index.ts
import { generators } from "@canonical/summon-component";
import { sequence_, writeFile } from "@canonical/summon";
const baseGenerator = generators["component/react"];
export const generator = {
...baseGenerator,
// Add custom prompts
prompts: [
...baseGenerator.prompts,
{
name: "withI18n",
type: "confirm",
message: "Include i18n hooks?",
default: false,
},
],
// Extend generation
generate: (answers) => sequence_([
// Run base generator
baseGenerator.generate(answers),
// Add your files
answers.withI18n && writeFile(
`${answers.componentPath}/useTranslations.ts`,
`export const useTranslations = () => ({ t: (k: string) => k });\n`
),
].filter(Boolean)),
};
export const generators = {
"component/react": generator,
};Modify Templates
Fork the package and edit templates in src/react/templates/ or src/svelte/templates/. Templates use EJS syntax.
Testing Your Components
The generated test files use Vitest and Testing Library. Run them with:
# If you have vitest configured
bun test
# Or specifically
bun vitest src/components/Button/Button.test.tsxSSR Tests
SSR tests verify the component renders without errors on the server:
// Button.ssr.test.tsx
import { renderToString } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { Button } from "./Button";
describe("Button SSR", () => {
it("renders without hydration errors", () => {
const html = renderToString(<Button>Click me</Button>);
expect(html).toContain("Click me");
});
});Storybook Integration
Generated stories work with Storybook 7+:
// Button.stories.tsx
import type { Meta, StoryObj } from "@storybook/react";
import { Button } from "./Button";
const meta: Meta<typeof Button> = {
component: Button,
title: "Components/Button",
};
export default meta;
type Story = StoryObj<typeof Button>;
export const Default: Story = {
args: {
children: "Click me",
},
};Svelte CSF vs TypeScript Stories
By default, Svelte components get .stories.svelte files using Svelte CSF. Use --use-ts-stories for traditional TypeScript stories if you prefer.
Programmatic Usage
Use the generators in your own code:
import { generators } from "@canonical/summon-component";
import { dryRun, getAffectedFiles } from "@canonical/summon";
const reactGenerator = generators["component/react"];
// Preview what would be created
const task = reactGenerator.generate({
componentPath: "src/components/Button",
withStyles: true,
withStories: true,
withSsrTests: false,
});
const { effects } = dryRun(task);
console.log("Would create:", getAffectedFiles(effects));Troubleshooting
"Generator not found"
Ensure the package is installed and discoverable:
# Check if summon sees it
summon
# Should show:
# component [pkg] (has subtopics)
# └─ react, svelte, litIf not, verify both the CLI and this generator package are installed into the same location (both global, or both project dependencies) — summon only discovers generators it can resolve:
npm install -g @canonical/summon @canonical/summon-componentBarrel file not updated
The generator appends to {parentDir}/index.ts. If your project uses a different convention (e.g., index.tsx or no barrel), manually add the export.
Related
- @canonical/summon-core — The generator framework (required peer dependency)
- @canonical/summon-package — Package scaffolding for the monorepo
Known Limitations
Compound Components
The current generator creates single-level components. For compound components with subcomponents (e.g., Accordion.Item, Card.Header), you'll need to:
- Generate the parent component
- Manually create the
common/directory structure for subcomponents - Attach subcomponents using dot notation
Expected structure for compound components:
Accordion/
├── Accordion.tsx # Parent with Accordion.Item = Item
├── types.ts
├── index.ts
└── common/
└── Item/
├── Item.tsx
├── types.ts
├── index.ts
└── styles.cssAlternative: For ontology-driven component generation that handles compound components, see the component-from-ontology skill at /skills/component-from-ontology/SKILL.md.
Code Standards
Generated templates use generic patterns. For project-specific standards (CSS namespacing, className construction patterns, etc.), consider:
- Customizing templates in a local generator
- Post-generation linting/formatting
- Using the
component-from-ontologyskill which integrates with semantic code standards
Non-Interactive Environments
See Summon How-To: Non-Interactive Usage for CI/LLM usage patterns.
License
GPL-3.0
