@canonical/summon-application
v0.44.0
Published
Summon generators for application scaffolding: application, domain, page, and wrapper
Downloads
2,014
Maintainers
Keywords
Readme
@canonical/summon-application
Summon generators for scaffolding application structure: full applications, domains, pages, and wrappers. Produces code aligned with the boilerplate reference app.
Generators
summon application/react <name>
Scaffolds a complete React application with routing, Storybook, and two starter domains — server-rendered by default, or client-only with --rendering spa.
summon application/react my-app
summon application/react --no-forms my-app
summon application/react --intl my-app
summon application/react --relay my-app
summon application/react --rendering spa my-appProduces:
my-app/
├── .storybook/
│ ├── main.ts
│ ├── preview.ts
│ └── decorators/
│ ├── withRouter.tsx # Hash-based router decorator
│ └── index.ts
├── patches/ # bun dependency patches (--relay only)
├── src/
│ ├── client/entry.tsx # Client hydration
│ ├── server/ # (omitted with --rendering spa)
│ │ ├── entry.tsx # SSR render
│ │ ├── server.express.ts # Express dev server
│ │ ├── server.bun.ts # Bun dev server
│ │ └── sitemap.ts
│ ├── domains/
│ │ ├── marketing/ # HomePage, GuidePage, routes
│ │ ├── account/ # AccountPage, LoginPage, routes
│ │ ├── contact/ # ContactPage, routes (omitted with --no-forms)
│ │ └── catalog/ # CatalogPage, ProductList, ProductCard (--relay only)
│ ├── lib/
│ │ ├── Navigation/
│ │ ├── ThemeSelector/
│ │ ├── ExampleComponent/
│ │ ├── LazyComponent/
│ │ └── ClientOnly/ # SSR query guard (--relay only; omitted with --rendering spa)
│ ├── i18n/ # Locale config, message catalogs (--intl only)
│ ├── relay/ # Environment factory, mock schema, __generated__ (--relay only)
│ ├── styles/
│ ├── routes.tsx
│ └── vite-env.d.ts
├── biome.json
├── index.html
├── package.json
├── relay.config.json # (--relay only)
├── tsconfig.json
└── vite.config.tsForm components are ON by default: the contact domain and its contactRoutes wiring in routes.tsx ship unless --no-forms is passed.
--intl: internationalisation
Off by default. --intl adds locale negotiation (cookie > Accept-Language >
default), per-locale message catalogs for en/fr/ar under src/i18n/, a
LocaleSelector in the chrome, the Storybook locale toolbar and its withI18n
decorator, and translated copy across every page. Under --rendering spa the same
negotiation runs in the browser (cookie, then navigator.languages) because no
server saw the request.
--rendering spa: client-only, no SSR layer
Off by default — a scaffolded app server-renders. --rendering spa is subtractive: it
emits a strict subset of the default tree, and nothing that is SPA-only.
Omitted:
src/server/— the SSR entry, the compiled renderer, and the Express and Bun dev/preview servers.src/sitemap/—/sitemap.xmlis a server-rendered route, and its renderer is the getters' only consumer.src/lib/ClientOnly/— an SSR-safety wrapper, so a no-op here.- The
dev:*,preview:*andbuild:serverscripts,--ssrManifestonbuild:client, and theexpress,@canonical/react-ssr,@types/express,tsxandbun-typesdependencies.
Kept: the e2e suite. Its harness spawns a script and polls HTTP — vite and
vite preview are servers too — so the matrix keeps its two non-SSR cells and
bun run test:e2e still covers dev and preview.
What a SPA gives up. The client router still performs the auth redirect,
the /home redirect and the not-found route. What is lost is the HTTP status
on a cold request — no 404, 301 or 302 from a server — plus /sitemap.xml
and the server-painted first paint (<html lang dir> and the cookie-derived
theme class), so expect a brief default theme/locale flash. With --relay the
catalog page always suspends and fetches rather than arriving with prefetched
data. Static hosting needs a history-API fallback the scaffold does not
configure.
--relay: Relay (GraphQL) data layer
The --relay flag (off by default) mirrors the boilerplate's Relay data layer:
src/relay/— acreateEnvironmentfactory built onrelay-runtime-network's middleware pipeline: by default a local executor resolves every operation in-process against the executable mock catalog schema (schema.graphql+schema.ts), so the app runs with zero backend; settingVITE_GRAPHQL_URL(or passinggraphqlUrl) switches to posting operations to a real endpoint. The relay-compiler artifacts insrc/relay/__generated__/are committed — deterministic outputs of the committed schema — and regenerated withbun run relay(orrelay:watch) after any schema orgraphqltag edit.src/domains/catalog/— an example domain wiringuseLazyLoadQuery(ProductList) and a colocateduseFragment(ProductCard) behind the canonical Suspense +ErrorBoundarypairing, with Storybook stories mocked via@canonical/storybook-addon-relay(parameters.relaymock resolvers + play tests) and component tests against both the local schema andrelay-test-utils.src/lib/ClientOnly/— keeps queries off the server render path until SSR data serialization/hydration is supported: the server streams the fallback and the browser fetches after hydration. It is emitted only with--relaybecause the catalog page is its only consumer today.patches/+"patchedDependencies"— emitted only for STANDALONE apps. bun resolvespatchedDependenciespaths from the workspace root, so when the target path is inside a bun workspace (detected by walking up for apackage.jsonwhoseworkspacesglobs cover the app) the workspace root owns patching and the scaffold emits neither thepatches/directory nor thepatchedDependenciesblock — an app-local block there would abortbun installwith "Couldn't find patch file". A standalone app cannot inherit anyone'spatches/, so three bun patches ship with the scaffold:[email protected](cjs-module-lexer export hints so named imports survive Node SSR externalisation),[email protected](real runtime bindings for type-only names the compiler artifacts import as values —ConcreteRequest,ReaderFragment,FragmentRefs), and[email protected](fixes its broken packageimportsmap — temporary until the fixed upstream release lands, advl/lit-relay#32, after which the patch and itspatchedDependenciesentry can be dropped).- Wiring —
RelayEnvironmentProviderin both entries (one environment per browser session on the client, a fresh one per request on the server),vite-plugin-relay-liteinvite.config.ts(codegen: false; artifacts are committed), therelay/relay:watchscripts, catalog route, nav link, and sitemap entries, and the relay addon viaextraAddonsin.storybook/main.ts.
Known caveats — not yet on npm. @canonical/storybook-addon-relay and
the fixed relay-runtime-network release have not been published yet. A
scaffolded app's install currently fails to resolve
@canonical/storybook-addon-relay (404 from the registry), and the
relay-runtime-network patch remains necessary until the fixed upstream
release lands. The scaffold encodes the intended end state; a --relay app's
install will only fully resolve once those packages publish.
summon domain <name>
Creates a domain folder under src/domains/ with a MainPage and a routes.ts barrel.
summon domain billingProduces:
src/domains/billing/
├── MainPage.tsx # Page component rendering <Head>
└── routes.ts # Route barrel with example route entryAfter generating, import the domain routes in src/routes.tsx and wire them with group():
import billingRoutes from "#domains/billing/routes.js";
const [billing] = group(publicLayout, [billingRoutes.billing] as const);summon page <domain>/<name>
Adds a page component to an existing domain. It writes one new file and edits nothing else: routing the page stays a hand edit, so the domain's routes.ts and src/routes.tsx only ever contain what you wrote.
summon page invoices/detailProduces:
src/domains/invoices/
└── DetailPage.tsx # New page componentThe argument is exactly two segments, the domain and then the page name in kebab-case (order-lines makes OrderLinesPage.tsx); the domain folder already carries the domain's name, so the file is DetailPage.tsx, not InvoicesDetailPage.tsx. The generator refuses to run when the domain does not exist (create it first with summon domain <name>) or when the page file already exists.
After writing the page, it prints what to add by hand. For invoices/detail, that starts with the import and a route entry for src/domains/invoices/routes.ts (the url is an example to adapt):
import DetailPage from "./DetailPage.js";
detail: route({ url: "/invoices/detail", content: DetailPage }),and ends with the wiring for src/routes.tsx, where the comments stand for what the app names itself:
import invoicesRoutes from "#domains/invoices/routes.js";
const [detail] = group(/* your wrapper() */, [invoicesRoutes.detail] as const);
const appRoutes = { /* …the routes already listed */ detail } as const;The full guide printed at run time also shows a url with a :param and the page signature that reads params, and says where typed search parameters go (a Standard Schema as the route's search). Its route examples are type-checked against @canonical/router-core in this package's tests. The page and the guide are rendered from the generator's .ejs templates.
--undo deletes the page file. It touches nothing else, because the generator changed nothing else; remove any lines you pasted by hand yourself.
summon wrapper <name>
Creates a layout wrapper component under src/lib/.
summon wrapper sidebarProduces:
src/lib/SidebarLayout/
├── SidebarLayout.tsx # Layout component with children prop
└── index.ts # Barrel exportUse the layout component in a wrapper() call in routes.tsx:
import SidebarLayout from "#lib/SidebarLayout/index.js";
const sidebarWrapper = wrapper({
id: "sidebar",
component: ({ children }) => <SidebarLayout>{children}</SidebarLayout>,
});Conventions
The generators enforce the conventions established by the boilerplate:
File structure
src/
├── client/ # Client entry point (hydration; createRoot with --rendering spa)
├── server/ # Server entry points (Express, Bun) — omitted with --rendering spa
├── domains/ # Feature domains
│ ├── marketing/
│ │ ├── HomePage.tsx
│ │ ├── GuidePage.tsx
│ │ └── routes.ts
│ ├── account/
│ │ ├── AccountPage.tsx
│ │ ├── LoginPage.tsx
│ │ └── routes.ts
│ ├── contact/ # Omitted with --no-forms
│ │ ├── ContactPage.tsx
│ │ └── routes.ts
│ └── catalog/ # When --relay is enabled
│ ├── CatalogPage.tsx
│ ├── ProductList.tsx
│ ├── ProductCard.tsx
│ ├── ErrorBoundary.tsx
│ └── routes.ts
├── lib/ # Shared components
│ ├── Navigation/
│ └── SidebarLayout/
├── relay/ # When --relay is enabled
│ ├── environment.ts # Environment factory (local executor / HTTP)
│ ├── schema.graphql # Mock SDL (relay-compiler validates against it)
│ ├── schema.ts # Executable mock schema (graphql-js)
│ └── __generated__/ # Committed relay-compiler artifacts
├── styles/ # CSS
├── formatDocumentTitle.ts # Appends the app name to every page's title
└── routes.tsx # Root route map, middleware, type registrationNaming
| Concept | Pattern | Example |
|---------|---------|---------|
| Page component | {Name}Page.tsx | SettingsPage.tsx |
| Layout component | {Name}Layout.tsx | SidebarLayout.tsx |
| Domain routes | routes.ts (not .tsx) | src/domains/billing/routes.ts |
| Root routes | routes.tsx | src/routes.tsx |
| Component folders | PascalCase | src/lib/SidebarLayout/ |
Route definitions
Routes are flat objects using route() from @canonical/router-core:
const routes = {
invoices: route({
url: "/billing/invoices",
content: InvoicesPage,
}),
} as const;
export default routes;contentreceives the component directly (not a wrapper function)- Pages render
<Head>from@canonical/react-headfor title and meta — one owner per document, so a layout or an entry never renders it - No
fetch()/warm()unless needed for cache warming - No
.error— use React error boundaries - Route files are
.ts(no JSX in route definitions)
Storybook decorators
The generated application includes a withRouter decorator for stories that need a router context:
import withRouter from "../decorators/withRouter.js";
const meta = {
decorators: [withRouter()],
} satisfies Meta;Pass custom routes when the story depends on specific route shapes:
import { appRoutes } from "../../routes.js";
const meta = {
decorators: [withRouter({ routes: appRoutes })],
} satisfies Meta;The decorator uses createHashRouter from @canonical/router-core, which stores routes in window.location.hash — suitable for Storybook and static environments where no server handles URL paths.
Wrappers
Wrapper components are standard React components with a children prop. They become routable wrappers via wrapper() + group() in routes.tsx:
const layout = wrapper({
id: "sidebar",
component: ({ children }) => <SidebarLayout>{children}</SidebarLayout>,
});
const [invoices, payments] = group(layout, [
billingRoutes.invoices,
billingRoutes.payments,
] as const);Type registration
Register routes once in routes.tsx for global type inference:
declare module "@canonical/router-react" {
interface RouterRegister {
routes: typeof appRoutes;
}
}This enables typed <Link to="invoices">, router.navigate("invoices"), and router.buildPath("invoices").
Open questions
Design decisions about the generated output that are not yet settled:
A
--rendering spaapp has no sitemap./sitemap.xmlis a server-rendered route, so--rendering spadropssrc/sitemap/entirely. The getters themselves are portable — a build step could write a staticpublic/sitemap.xmland a static host would serve it fine — but that is new tooling nothing else needs, and keeping the renderer would hold@canonical/react-ssras a dependency for otherwise-dead code. Open: whether SEO-facing SPAs should get a build-time sitemap emitter.Pinned pragma version is fetched at summon time. Generated apps pin the pragma workspace packages (
react-ds-global,router-core,styles, …) at^<latest>, where<latest>is queried from the npm registry when the app is scaffolded (resolvePragmaVersioninsrc/shared/versions.ts, vianpm view). If the registry is unreachable it falls back to^<installed generator version>— safe because@canonical/summon-applicationis released in lockstep with those packages. Note@canonical/design-tokensis versioned separately and is not covered here (it arrives transitively via@canonical/styles). Open: whether to cache the lookup or let users pin explicitly for reproducible offline scaffolding.Two static-asset folders (
src/assets/+public/). The generated app ships both, and.storybook/main.tslistsstaticDirs: ["../src/assets", "../public"]to match the@canonical/react-ds-globalconvention. The two serve different roles under Vite —src/assets/for assets imported in code (hashed/optimized, dropped if unused),public/for files referenced by fixed URL (favicon,robots.txt, served as-is, unhashed). Open: whether a scaffolded app genuinely needs both by default, or whether one (likely justpublic/for a favicon) is enough andsrc/assets/should be added only when the app actually imports an asset. Currently both ship with.gitkeepplaceholders.
