@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.
- package/README.md +264 -0
- package/package.json +50 -0
- package/src/application/react/index.ts +294 -0
- package/src/application/react/templates/.storybook/decorators/index.ts +1 -0
- package/src/application/react/templates/.storybook/decorators/withRouter.tsx +44 -0
- package/src/application/react/templates/.storybook/main.ts +5 -0
- package/src/application/react/templates/.storybook/preview.ts +10 -0
- package/src/application/react/templates/README.md.ejs +82 -0
- package/src/application/react/templates/biome.json.ejs +6 -0
- package/src/application/react/templates/index.html.ejs +14 -0
- package/src/application/react/templates/package.json.ejs +72 -0
- package/src/application/react/templates/public/.gitkeep +0 -0
- package/src/application/react/templates/public/robots.txt +2 -0
- package/src/application/react/templates/src/assets/.gitkeep +0 -0
- package/src/application/react/templates/src/client/entry.tsx +25 -0
- package/src/application/react/templates/src/domains/account/AccountPage.tsx +13 -0
- package/src/application/react/templates/src/domains/account/LoginPage.tsx +27 -0
- package/src/application/react/templates/src/domains/account/routes.ts +44 -0
- package/src/application/react/templates/src/domains/contact/ContactPage.tsx +44 -0
- package/src/application/react/templates/src/domains/contact/routes.ts +11 -0
- package/src/application/react/templates/src/domains/marketing/GuidePage.tsx +17 -0
- package/src/application/react/templates/src/domains/marketing/HomePage.tsx +33 -0
- package/src/application/react/templates/src/domains/marketing/routes.ts +16 -0
- package/src/application/react/templates/src/lib/ExampleComponent/ExampleComponent.stories.tsx +59 -0
- package/src/application/react/templates/src/lib/ExampleComponent/ExampleComponent.tests.tsx +17 -0
- package/src/application/react/templates/src/lib/ExampleComponent/ExampleComponent.tsx +29 -0
- package/src/application/react/templates/src/lib/ExampleComponent/index.ts +3 -0
- package/src/application/react/templates/src/lib/ExampleComponent/styles.css +7 -0
- package/src/application/react/templates/src/lib/ExampleComponent/types.ts +13 -0
- package/src/application/react/templates/src/lib/LazyComponent/LazyComponent.stories.tsx +23 -0
- package/src/application/react/templates/src/lib/LazyComponent/LazyComponent.tsx +32 -0
- package/src/application/react/templates/src/lib/LazyComponent/index.ts +1 -0
- package/src/application/react/templates/src/lib/Navigation/Navigation.tsx.ejs +21 -0
- package/src/application/react/templates/src/lib/Navigation/index.ts +1 -0
- package/src/application/react/templates/src/lib/ThemeSelector/ThemeSelector.tsx +30 -0
- package/src/application/react/templates/src/lib/ThemeSelector/index.ts +1 -0
- package/src/application/react/templates/src/lib/index.ts +4 -0
- package/src/application/react/templates/src/routes.tsx.ejs +129 -0
- package/src/application/react/templates/src/server/entry.tsx +45 -0
- package/src/application/react/templates/src/server/preview.bun.ts +79 -0
- package/src/application/react/templates/src/server/preview.express.ts +69 -0
- package/src/application/react/templates/src/server/renderer.tsx +50 -0
- package/src/application/react/templates/src/server/server.bun.ts +105 -0
- package/src/application/react/templates/src/server/server.express.ts +102 -0
- package/src/application/react/templates/src/sitemap/getSitemapItems.ts.ejs +31 -0
- package/src/application/react/templates/src/sitemap/renderer.ts +40 -0
- package/src/application/react/templates/src/styles/app.css +16 -0
- package/src/application/react/templates/src/styles/index.css.ejs +5 -0
- package/src/application/react/templates/src/vite-env.d.ts +1 -0
- package/src/application/react/templates/test/e2e/serverHarness.ts +153 -0
- package/src/application/react/templates/test/e2e/servers.e2e.ts +99 -0
- package/src/application/react/templates/tsconfig.json +32 -0
- package/src/application/react/templates/vite.config.ts +45 -0
- package/src/application/react/templates/vitest.config.ts +31 -0
- package/src/application/react/templates/vitest.e2e.config.ts +17 -0
- package/src/application/react/templates/vitest.setup.ts +9 -0
- package/src/domain/index.ts +119 -0
- package/src/index.test.ts +398 -0
- package/src/index.ts +14 -0
- package/src/route/index.ts +154 -0
- package/src/route/insertRoute.test.ts +98 -0
- package/src/route/insertRoute.ts +236 -0
- package/src/shared/casing.ts +14 -0
- package/src/shared/versions.ts +48 -0
- 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,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
|
+
}
|
|
File without changes
|
|
File without changes
|
|
@@ -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,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,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";
|