@canonical/summon-application 0.29.0-experimental.0

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.
Files changed (65) hide show
  1. package/README.md +264 -0
  2. package/package.json +50 -0
  3. package/src/application/react/index.ts +294 -0
  4. package/src/application/react/templates/.storybook/decorators/index.ts +1 -0
  5. package/src/application/react/templates/.storybook/decorators/withRouter.tsx +44 -0
  6. package/src/application/react/templates/.storybook/main.ts +5 -0
  7. package/src/application/react/templates/.storybook/preview.ts +10 -0
  8. package/src/application/react/templates/README.md.ejs +82 -0
  9. package/src/application/react/templates/biome.json.ejs +6 -0
  10. package/src/application/react/templates/index.html.ejs +14 -0
  11. package/src/application/react/templates/package.json.ejs +72 -0
  12. package/src/application/react/templates/public/.gitkeep +0 -0
  13. package/src/application/react/templates/public/robots.txt +2 -0
  14. package/src/application/react/templates/src/assets/.gitkeep +0 -0
  15. package/src/application/react/templates/src/client/entry.tsx +25 -0
  16. package/src/application/react/templates/src/domains/account/AccountPage.tsx +13 -0
  17. package/src/application/react/templates/src/domains/account/LoginPage.tsx +27 -0
  18. package/src/application/react/templates/src/domains/account/routes.ts +44 -0
  19. package/src/application/react/templates/src/domains/contact/ContactPage.tsx +44 -0
  20. package/src/application/react/templates/src/domains/contact/routes.ts +11 -0
  21. package/src/application/react/templates/src/domains/marketing/GuidePage.tsx +17 -0
  22. package/src/application/react/templates/src/domains/marketing/HomePage.tsx +33 -0
  23. package/src/application/react/templates/src/domains/marketing/routes.ts +16 -0
  24. package/src/application/react/templates/src/lib/ExampleComponent/ExampleComponent.stories.tsx +59 -0
  25. package/src/application/react/templates/src/lib/ExampleComponent/ExampleComponent.tests.tsx +17 -0
  26. package/src/application/react/templates/src/lib/ExampleComponent/ExampleComponent.tsx +29 -0
  27. package/src/application/react/templates/src/lib/ExampleComponent/index.ts +3 -0
  28. package/src/application/react/templates/src/lib/ExampleComponent/styles.css +7 -0
  29. package/src/application/react/templates/src/lib/ExampleComponent/types.ts +13 -0
  30. package/src/application/react/templates/src/lib/LazyComponent/LazyComponent.stories.tsx +23 -0
  31. package/src/application/react/templates/src/lib/LazyComponent/LazyComponent.tsx +32 -0
  32. package/src/application/react/templates/src/lib/LazyComponent/index.ts +1 -0
  33. package/src/application/react/templates/src/lib/Navigation/Navigation.tsx.ejs +21 -0
  34. package/src/application/react/templates/src/lib/Navigation/index.ts +1 -0
  35. package/src/application/react/templates/src/lib/ThemeSelector/ThemeSelector.tsx +30 -0
  36. package/src/application/react/templates/src/lib/ThemeSelector/index.ts +1 -0
  37. package/src/application/react/templates/src/lib/index.ts +4 -0
  38. package/src/application/react/templates/src/routes.tsx.ejs +129 -0
  39. package/src/application/react/templates/src/server/entry.tsx +45 -0
  40. package/src/application/react/templates/src/server/preview.bun.ts +79 -0
  41. package/src/application/react/templates/src/server/preview.express.ts +69 -0
  42. package/src/application/react/templates/src/server/renderer.tsx +50 -0
  43. package/src/application/react/templates/src/server/server.bun.ts +105 -0
  44. package/src/application/react/templates/src/server/server.express.ts +102 -0
  45. package/src/application/react/templates/src/sitemap/getSitemapItems.ts.ejs +31 -0
  46. package/src/application/react/templates/src/sitemap/renderer.ts +40 -0
  47. package/src/application/react/templates/src/styles/app.css +16 -0
  48. package/src/application/react/templates/src/styles/index.css.ejs +5 -0
  49. package/src/application/react/templates/src/vite-env.d.ts +1 -0
  50. package/src/application/react/templates/test/e2e/serverHarness.ts +153 -0
  51. package/src/application/react/templates/test/e2e/servers.e2e.ts +99 -0
  52. package/src/application/react/templates/tsconfig.json +32 -0
  53. package/src/application/react/templates/vite.config.ts +45 -0
  54. package/src/application/react/templates/vitest.config.ts +31 -0
  55. package/src/application/react/templates/vitest.e2e.config.ts +17 -0
  56. package/src/application/react/templates/vitest.setup.ts +9 -0
  57. package/src/domain/index.ts +119 -0
  58. package/src/index.test.ts +398 -0
  59. package/src/index.ts +14 -0
  60. package/src/route/index.ts +154 -0
  61. package/src/route/insertRoute.test.ts +98 -0
  62. package/src/route/insertRoute.ts +236 -0
  63. package/src/shared/casing.ts +14 -0
  64. package/src/shared/versions.ts +48 -0
  65. package/src/wrapper/index.ts +100 -0
@@ -0,0 +1,82 @@
1
+ # <%= name %>
2
+
3
+ A React application with server-side rendering and routing, generated by
4
+ `@canonical/summon`. It is built on Canonical's shared design system, router, and
5
+ SSR packages, and ships SSR on two runtimes (Bun and Node), file-based routing, a
6
+ component library wired to design tokens, and Storybook.
7
+
8
+ ## Quick start
9
+
10
+ ```bash
11
+ bun install
12
+ bun run dev:bun # SSR dev server with HMR, on http://localhost:5174
13
+ ```
14
+
15
+ Edit anything under `src/` and the page hot-reloads. To check the production
16
+ build before shipping, run `bun run preview:bun` instead.
17
+
18
+ ## Running the app
19
+
20
+ The server scripts form a **2×3 matrix** — two modes (development and preview)
21
+ across three targets (a client-only SPA, SSR on Bun, SSR on Node/Express):
22
+
23
+ | | `dev` — transform, HMR | `preview` — compiled, production-faithful |
24
+ | ---------------- | ---------------------- | ----------------------------------------- |
25
+ | **SPA** (no SSR) | `bun run dev` | `bun run preview` |
26
+ | **SSR · Bun** | `bun run dev:bun` | `bun run preview:bun` |
27
+ | **SSR · Node** | `bun run dev:express` | `bun run preview:express` |
28
+
29
+ The naming is systematic: the bare name is the **SPA**, the `:bun` / `:express`
30
+ suffix selects the **SSR runtime**, and the `dev` / `preview` prefix selects the
31
+ **mode**. Reach for `dev:*` while building — it is the fast inner loop, with HMR
32
+ and no build step — and for `preview:*` when verifying that the production bundle
33
+ behaves before a deploy: `preview:*` builds the client and runs a compiled
34
+ renderer over the built assets, the same artifact that deploys.
35
+
36
+ ```bash
37
+ bun run storybook # component workshop
38
+ ```
39
+
40
+ ## Adding routes
41
+
42
+ `summon` scaffolds the building blocks of the app so new features follow the
43
+ established structure rather than diverging from it.
44
+
45
+ ```bash
46
+ summon domain billing # a new feature domain (routes + pages)
47
+ summon route billing/invoices # a route within a domain
48
+ summon wrapper sidebar # a layout wrapper
49
+ ```
50
+
51
+ ## Testing
52
+
53
+ ```bash
54
+ bun run test # unit + component tests (Vitest, jsdom)
55
+ bun run test:coverage # the same, with a coverage report
56
+ bun run test:e2e # boots all six matrix servers and asserts each serves
57
+ ```
58
+
59
+ `test:e2e` is an end-to-end test *of the build*: it runs each of the six server
60
+ scripts — including the production builds that `preview:*` performs — and asserts
61
+ that every server returns a rendered document and serves its client assets with
62
+ the correct content type.
63
+
64
+ ## Project structure
65
+
66
+ ```
67
+ src/
68
+ ├── client/ Client entry (hydration)
69
+ ├── server/ Server entry, compiled renderer, dev servers, sitemap
70
+ ├── domains/ Feature domains (each owns its routes and pages)
71
+ ├── lib/ Shared components
72
+ ├── styles/ Application CSS
73
+ ├── assets/ Assets imported in code (bundled and hashed by Vite)
74
+ └── routes.tsx Root route map
75
+ public/ Files served verbatim at a fixed URL (favicon, robots.txt)
76
+ test/e2e/ The 2×3 server-matrix end-to-end suite
77
+ ```
78
+
79
+ Domains are the unit of feature organisation: each owns its routes and pages, so
80
+ adding a feature is adding a domain rather than threading changes through shared
81
+ files. This app ships both `src/assets/` (assets imported in code) and `public/`
82
+ (files served by fixed URL); either may be removed if the app uses only one.
@@ -0,0 +1,6 @@
1
+ {
2
+ "extends": ["@canonical/biome-config"],
3
+ "files": {
4
+ "includes": ["src", "*.json", "vite.config.ts"]
5
+ }
6
+ }
@@ -0,0 +1,14 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <meta name="description" content="<%= name %> — a React application built with the Canonical design system, router, and SSR packages." />
7
+ <link rel="icon" type="image/png" sizes="32x32" href="https://assets.ubuntu.com/v1/be7e4cc6-COF-favicon-32x32.png">
8
+ <title><%= name %></title>
9
+ </head>
10
+ <body>
11
+ <div id="root"></div>
12
+ <script type="module" src="/src/client/entry.tsx"></script>
13
+ </body>
14
+ </html>
@@ -0,0 +1,72 @@
1
+ {
2
+ "name": "<%= name %>",
3
+ "private": true,
4
+ "version": "0.0.0",
5
+ "type": "module",
6
+ "license": "GPL-3.0-only",
7
+ "imports": {
8
+ "#lib/*": "./src/lib/*",
9
+ "#domains/*": "./src/domains/*",
10
+ "#styles/*": "./src/styles/*"
11
+ },
12
+ "scripts": {
13
+ "dev": "vite",
14
+ "dev:express": "node --import tsx src/server/server.express.ts",
15
+ "dev:bun": "bun src/server/server.bun.ts",
16
+ "preview": "bun run build:client && vite preview --outDir dist/client",
17
+ "preview:bun": "bun run build:client && bun run build:server && bun src/server/preview.bun.ts",
18
+ "preview:express": "bun run build:client && bun run build:server && node --import tsx src/server/preview.express.ts",
19
+ "build": "bun run build:client",
20
+ "build:all": "bun run build:client && bun run build:storybook",
21
+ "build:client": "vite build --ssrManifest --outDir dist/client",
22
+ "build:server": "vite build --mode server",
23
+ "build:storybook": "storybook build",
24
+ "check": "bun run check:biome && bun run check:ts",
25
+ "check:fix": "bun run check:biome:fix && bun run check:ts",
26
+ "check:biome": "biome check",
27
+ "check:biome:fix": "biome check --write",
28
+ "check:ts": "tsc --noEmit",
29
+ "test": "vitest run",
30
+ "test:watch": "vitest",
31
+ "test:coverage": "vitest run --coverage",
32
+ "test:e2e": "vitest run --config vitest.e2e.config.ts",
33
+ "storybook": "storybook dev -p 6010 --no-open --host 0.0.0.0"
34
+ },
35
+ "dependencies": {
36
+ "@canonical/react-ds-global": "<%= pragmaVersion %>",
37
+ <% if (forms) { -%>
38
+ "@canonical/react-ds-global-form": "<%= pragmaVersion %>",
39
+ <% } -%>
40
+ "@canonical/react-head": "<%= pragmaVersion %>",
41
+ "@canonical/react-hooks": "<%= pragmaVersion %>",
42
+ "@canonical/react-ssr": "<%= pragmaVersion %>",
43
+ "@canonical/router-core": "<%= pragmaVersion %>",
44
+ "@canonical/router-react": "<%= pragmaVersion %>",
45
+ "@canonical/storybook-config": "<%= pragmaVersion %>",
46
+ "@canonical/styles": "<%= pragmaVersion %>",
47
+ "express": "^5.2.1",
48
+ "react": "^19.2.4",
49
+ "react-dom": "^19.2.4"
50
+ },
51
+ "devDependencies": {
52
+ "@biomejs/biome": "2.4.9",
53
+ "@canonical/biome-config": "<%= pragmaVersion %>",
54
+ "@canonical/typescript-config-react": "<%= pragmaVersion %>",
55
+ "@storybook/react-vite": "^10.3.1",
56
+ "@testing-library/jest-dom": "^6.9.1",
57
+ "@testing-library/react": "^16.3.2",
58
+ "@types/express": "^5.0.6",
59
+ "@types/node": "^24.12.0",
60
+ "@types/react": "^19.2.14",
61
+ "@types/react-dom": "^19.2.3",
62
+ "@vitejs/plugin-react": "^6.0.0",
63
+ "@vitest/coverage-v8": "^4.0.18",
64
+ "bun-types": "^1.3.9",
65
+ "jsdom": "^28.1.0",
66
+ "storybook": "^10.3.1",
67
+ "tsx": "^4.19.0",
68
+ "typescript": "^5.9.3",
69
+ "vite": "^8.0.1",
70
+ "vitest": "^4.0.18"
71
+ }
72
+ }
@@ -0,0 +1,2 @@
1
+ User-agent: *
2
+ Allow: /
@@ -0,0 +1,25 @@
1
+ import { HeadProvider } from "@canonical/react-head";
2
+ import { createBrowserRouter } from "@canonical/router-core";
3
+ import { Outlet, RouterProvider } from "@canonical/router-react";
4
+ import { hydrateRoot } from "react-dom/client";
5
+ import { appRoutes, middleware, notFoundRoute } from "../routes.js";
6
+ import "#styles/index.css";
7
+
8
+ const router = createBrowserRouter(appRoutes, {
9
+ middleware: [...middleware],
10
+ notFound: notFoundRoute,
11
+ });
12
+
13
+ const root = document.getElementById("root");
14
+ if (!root) {
15
+ throw new Error('Root element "#root" not found');
16
+ }
17
+
18
+ hydrateRoot(
19
+ root,
20
+ <HeadProvider>
21
+ <RouterProvider router={router}>
22
+ <Outlet fallback={<p>Loading…</p>} />
23
+ </RouterProvider>
24
+ </HeadProvider>,
25
+ );
@@ -0,0 +1,13 @@
1
+ import { useHead } from "@canonical/react-head";
2
+ import type { ReactElement } from "react";
3
+
4
+ export default function AccountPage(): ReactElement {
5
+ useHead({ title: "Account — Boilerplate" });
6
+
7
+ return (
8
+ <section aria-labelledby="account-title">
9
+ <h1 id="account-title">Account</h1>
10
+ <p>Protected account page. You are signed in.</p>
11
+ </section>
12
+ );
13
+ }
@@ -0,0 +1,27 @@
1
+ import { useHead } from "@canonical/react-head";
2
+ import type { ReactElement } from "react";
3
+
4
+ interface LoginSearch {
5
+ readonly from?: string;
6
+ }
7
+
8
+ export default function LoginPage({
9
+ search,
10
+ }: {
11
+ search: LoginSearch;
12
+ }): ReactElement {
13
+ useHead({ title: "Login — Boilerplate" });
14
+
15
+ return (
16
+ <section aria-labelledby="login-title">
17
+ <h1 id="login-title">Login</h1>
18
+ <p>
19
+ Demo login. Add <code>?auth=1</code> to any protected URL to simulate
20
+ authentication.
21
+ </p>
22
+ {search.from && (
23
+ <p>You will be redirected to {search.from} after login.</p>
24
+ )}
25
+ </section>
26
+ );
27
+ }
@@ -0,0 +1,44 @@
1
+ import { route } from "@canonical/router-core";
2
+ import AccountPage from "./AccountPage.js";
3
+ import LoginPage from "./LoginPage.js";
4
+
5
+ function readString(value: unknown): string | undefined {
6
+ return typeof value === "string" ? value : undefined;
7
+ }
8
+
9
+ const accountSearchSchema = {
10
+ "~standard": {
11
+ output: {} as { readonly auth?: string },
12
+ validate(value: unknown): { readonly auth?: string } {
13
+ const record = value as Record<string, unknown>;
14
+
15
+ return { auth: readString(record.auth) };
16
+ },
17
+ },
18
+ };
19
+
20
+ const loginSearchSchema = {
21
+ "~standard": {
22
+ output: {} as { readonly from?: string },
23
+ validate(value: unknown): { readonly from?: string } {
24
+ const record = value as Record<string, unknown>;
25
+
26
+ return { from: readString(record.from) };
27
+ },
28
+ },
29
+ };
30
+
31
+ const routes = {
32
+ account: route({
33
+ url: "/account",
34
+ search: accountSearchSchema,
35
+ content: AccountPage,
36
+ }),
37
+ login: route({
38
+ url: "/login",
39
+ search: loginSearchSchema,
40
+ content: LoginPage,
41
+ }),
42
+ } as const;
43
+
44
+ export default routes;
@@ -0,0 +1,44 @@
1
+ import { Button } from "@canonical/react-ds-global";
2
+ import { Field, Form } from "@canonical/react-ds-global-form";
3
+ import { useHead } from "@canonical/react-head";
4
+ import type { ReactElement } from "react";
5
+
6
+ function handleSubmit(data: Record<string, unknown>) {
7
+ console.log("Form submitted:", data);
8
+ }
9
+
10
+ export default function ContactPage(): ReactElement {
11
+ useHead({ title: "Contact" });
12
+
13
+ return (
14
+ <section aria-labelledby="contact-title">
15
+ <h1 id="contact-title">Contact</h1>
16
+ <Form onSubmit={handleSubmit}>
17
+ <Field name="name" inputType="text" label="Full name" />
18
+ <Field
19
+ name="email"
20
+ inputType="text"
21
+ label="Email address"
22
+ registerProps={{ required: "Email is required" }}
23
+ />
24
+ <Field
25
+ name="subject"
26
+ inputType="select"
27
+ label="Subject"
28
+ options={[
29
+ { value: "general", label: "General enquiry" },
30
+ { value: "support", label: "Support" },
31
+ { value: "feedback", label: "Feedback" },
32
+ ]}
33
+ />
34
+ <Field
35
+ name="message"
36
+ inputType="textarea"
37
+ label="Message"
38
+ description="Maximum 500 characters"
39
+ />
40
+ <Button type="submit">Send message</Button>
41
+ </Form>
42
+ </section>
43
+ );
44
+ }
@@ -0,0 +1,11 @@
1
+ import { route } from "@canonical/router-core";
2
+ import ContactPage from "./ContactPage.js";
3
+
4
+ const routes = {
5
+ contact: route({
6
+ url: "/contact",
7
+ content: ContactPage,
8
+ }),
9
+ } as const;
10
+
11
+ export default routes;
@@ -0,0 +1,17 @@
1
+ import { useHead } from "@canonical/react-head";
2
+ import type { ReactElement } from "react";
3
+
4
+ export default function GuidePage({
5
+ params,
6
+ }: {
7
+ params: { slug: string };
8
+ }): ReactElement {
9
+ useHead({ title: `${params.slug} — Guides` }, [params.slug]);
10
+
11
+ return (
12
+ <section aria-labelledby="guide-title">
13
+ <h1 id="guide-title">{params.slug}</h1>
14
+ <p>Guide content for {params.slug}.</p>
15
+ </section>
16
+ );
17
+ }
@@ -0,0 +1,33 @@
1
+ import { useHead } from "@canonical/react-head";
2
+ import { type ReactElement, Suspense } from "react";
3
+ import { ExampleComponent, LazyComponent } from "#lib/index.js";
4
+
5
+ export default function HomePage(): ReactElement {
6
+ useHead({ title: "Home — Boilerplate" });
7
+
8
+ return (
9
+ <section aria-labelledby="home-title">
10
+ <h1 id="home-title">Home</h1>
11
+ <p>Welcome to the pragma router boilerplate.</p>
12
+
13
+ <h2>Example component</h2>
14
+ <p>
15
+ A plain component scaffolded alongside the app. It ships with a
16
+ Storybook story (<code>ExampleComponent.stories.tsx</code>) and a test —
17
+ the starting point for your own components.
18
+ </p>
19
+ <ExampleComponent>Hello from ExampleComponent</ExampleComponent>
20
+
21
+ <h2>Streaming with Suspense</h2>
22
+ <p>
23
+ <code>LazyComponent</code> reads a synthetic delayed fetch with React 19{" "}
24
+ <code>use()</code>, so it suspends. Inside this <code>Suspense</code>{" "}
25
+ boundary the server streams the page shell first, shows the fallback,
26
+ then flushes the resolved content in — no full client round-trip.
27
+ </p>
28
+ <Suspense fallback={<p>Loading streamed content…</p>}>
29
+ <LazyComponent />
30
+ </Suspense>
31
+ </section>
32
+ );
33
+ }
@@ -0,0 +1,16 @@
1
+ import { route } from "@canonical/router-core";
2
+ import GuidePage from "./GuidePage.js";
3
+ import HomePage from "./HomePage.js";
4
+
5
+ const routes = {
6
+ home: route({
7
+ url: "/",
8
+ content: HomePage,
9
+ }),
10
+ guide: route({
11
+ url: "/guides/:slug",
12
+ content: GuidePage,
13
+ }),
14
+ } as const;
15
+
16
+ export default routes;
@@ -0,0 +1,59 @@
1
+ /* @canonical/generator-ds 0.9.0-experimental.9 */
2
+
3
+ // Needed for function-based story, safe to remove otherwise
4
+ // import type { ExampleComponentProps } from './types.js'
5
+ import type { Meta, StoryObj } from "@storybook/react-vite";
6
+ import Component from "./ExampleComponent.js";
7
+
8
+ // Needed for template-based story, safe to remove otherwise
9
+ // import type { StoryFn } from '@storybook/react-vite'
10
+
11
+ const meta = {
12
+ title: "ExampleComponent",
13
+ component: Component,
14
+ } satisfies Meta<typeof Component>;
15
+
16
+ export default meta;
17
+
18
+ /*
19
+ CSF3 story
20
+ Uses object-based story declarations with strong TS support (`Meta` and `StoryObj`).
21
+ Uses the latest storybook format.
22
+ */
23
+ type Story = StoryObj<typeof meta>;
24
+
25
+ export const Default: Story = {
26
+ args: {
27
+ children: <span>Hello world!</span>,
28
+ },
29
+ };
30
+
31
+ /*
32
+ Function-based story
33
+ Direct arguments passed to the component
34
+ Simple, but can lead to repetition if used across multiple stories with similar configurations
35
+
36
+ export const Default = (args: ExampleComponentProps) => <Component {...args} />;
37
+ Default.args = { children: <span>Hello world!</span> };
38
+ */
39
+
40
+ /*
41
+ Template-Based story
42
+ Uses a template function to bind story variations, making it more reusable
43
+ Slightly more boilerplate but more flexible for creating multiple stories with different configurations
44
+
45
+ const Template: StoryFn<typeof Component> = (args) => <Component {...args} />;
46
+ export const Default: StoryFn<typeof Component> = Template.bind({});
47
+ Default.args = {
48
+ children: <span>Hello world!</span>
49
+ };
50
+ */
51
+
52
+ /*
53
+ Static story
54
+ Simple and straightforward, but offers the least flexibility and reusability
55
+
56
+ export const Default: StoryFn<typeof Component> = () => (
57
+ <Component><span>Hello world!</span></Component>
58
+ );
59
+ */
@@ -0,0 +1,17 @@
1
+ /* @canonical/generator-ds 0.9.0-experimental.9 */
2
+
3
+ import { render, screen } from "@testing-library/react";
4
+ import { describe, expect, it } from "vitest";
5
+ import Component from "./ExampleComponent.js";
6
+
7
+ describe("ExampleComponent component", () => {
8
+ it("renders", () => {
9
+ render(<Component>ExampleComponent</Component>);
10
+ expect(screen.getByText("ExampleComponent")).toBeInTheDocument();
11
+ });
12
+
13
+ it("applies className", () => {
14
+ render(<Component className={"test-class"}>ExampleComponent</Component>);
15
+ expect(screen.getByText("ExampleComponent")).toHaveClass("test-class");
16
+ });
17
+ });
@@ -0,0 +1,29 @@
1
+ /* @canonical/generator-ds 0.9.0-experimental.9 */
2
+ import type React from "react";
3
+ import type { ExampleComponentProps } from "./types.js";
4
+ import "./styles.css";
5
+
6
+ const componentCssClassName = "ds example-component";
7
+
8
+ /**
9
+ * description of the ExampleComponent component
10
+ * @returns {React.ReactElement} - Rendered ExampleComponent
11
+ */
12
+ const ExampleComponent = ({
13
+ id,
14
+ children,
15
+ className,
16
+ style,
17
+ }: ExampleComponentProps): React.ReactElement => {
18
+ return (
19
+ <div
20
+ id={id}
21
+ style={style}
22
+ className={[componentCssClassName, className].filter(Boolean).join(" ")}
23
+ >
24
+ {children}
25
+ </div>
26
+ );
27
+ };
28
+
29
+ export default ExampleComponent;
@@ -0,0 +1,3 @@
1
+ /* @canonical/generator-ds 0.9.0-experimental.9 */
2
+ export { default as ExampleComponent } from "./ExampleComponent.js";
3
+ export * from "./types.js";
@@ -0,0 +1,7 @@
1
+ /* @canonical/generator-ds 0.9.0-experimental.9 */
2
+
3
+ /*
4
+ .ds.example-component {
5
+
6
+ }
7
+ */
@@ -0,0 +1,13 @@
1
+ /* @canonical/generator-ds 0.9.0-experimental.9 */
2
+ import type React from "react";
3
+
4
+ export interface ExampleComponentProps {
5
+ /* A unique identifier for the ExampleComponent */
6
+ id?: string;
7
+ /* Additional CSS classes */
8
+ className?: string;
9
+ /* Child elements */
10
+ children?: React.ReactNode;
11
+ /* Inline styles */
12
+ style?: React.CSSProperties;
13
+ }
@@ -0,0 +1,23 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import { Suspense } from "react";
3
+ import Component from "./LazyComponent.js";
4
+
5
+ const meta = {
6
+ title: "LazyComponent",
7
+ component: Component,
8
+ // LazyComponent suspends, so every story renders inside a Suspense boundary —
9
+ // the same pattern the homepage uses to stream it during SSR.
10
+ decorators: [
11
+ (Story) => (
12
+ <Suspense fallback={<p>Loading…</p>}>
13
+ <Story />
14
+ </Suspense>
15
+ ),
16
+ ],
17
+ } satisfies Meta<typeof Component>;
18
+
19
+ export default meta;
20
+
21
+ type Story = StoryObj<typeof meta>;
22
+
23
+ export const Default: Story = {};
@@ -0,0 +1,32 @@
1
+ import { type ReactElement, use } from "react";
2
+
3
+ /**
4
+ * Demonstrates streaming SSR with Suspense.
5
+ *
6
+ * The component reads a promise with React 19's `use()`, which suspends
7
+ * rendering until the promise resolves. When rendered inside a `<Suspense>`
8
+ * boundary on a streaming server (`renderToPipeableStream` /
9
+ * `renderToReadableStream`), the server flushes the surrounding shell
10
+ * immediately and streams this content in once the data is ready — so the
11
+ * fallback is shown first, then replaced without a full client round-trip.
12
+ *
13
+ * Here the "fetch" is synthetic: a promise that resolves after a short delay.
14
+ * Replace `createDelayedData` with a real data source in your own routes.
15
+ */
16
+
17
+ /** Synthetic data source — resolves after `delayMs` to simulate a slow fetch. */
18
+ function createDelayedData(delayMs = 1000): Promise<string> {
19
+ return new Promise((resolve) => {
20
+ setTimeout(() => resolve("Streamed in after a delay"), delayMs);
21
+ });
22
+ }
23
+
24
+ // Created once at module load so re-renders reuse the same promise rather
25
+ // than restarting the "fetch" (and re-suspending) on every render.
26
+ const dataPromise = createDelayedData();
27
+
28
+ export default function LazyComponent(): ReactElement {
29
+ const message = use(dataPromise);
30
+
31
+ return <p>{message}</p>;
32
+ }
@@ -0,0 +1 @@
1
+ export { default } from "./LazyComponent.js";
@@ -0,0 +1,21 @@
1
+ import { Link } from "@canonical/router-react";
2
+ import type { ReactElement } from "react";
3
+ import ThemeSelector from "../ThemeSelector/index.js";
4
+
5
+ export default function Navigation(): ReactElement {
6
+ return (
7
+ <nav aria-label="Main">
8
+ <Link to="home">Home</Link>
9
+ <Link params={{ slug: "router-core" }} to="guide">
10
+ Guide
11
+ </Link>
12
+ <% if (forms) { -%>
13
+ <Link to="contact">Contact</Link>
14
+ <% } -%>
15
+ <Link search={{ auth: "1" }} to="account">
16
+ Demo sign-in
17
+ </Link>
18
+ <ThemeSelector />
19
+ </nav>
20
+ );
21
+ }
@@ -0,0 +1 @@
1
+ export { default } from "./Navigation.js";