@monospaced/create-set 0.21.0 → 0.23.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 CHANGED
@@ -6,17 +6,30 @@ Project scaffolding for Set. Bootstraps a site or app with Set's components, too
6
6
  pnpm create @monospaced/set my-site
7
7
  ```
8
8
 
9
- Prompts for a package name, site title and description, each showing its default, which Enter accepts. Each has a flag (`--name`, `--title`, `--description`), and `--defaults` takes every default without prompting, so it runs non-interactively too; without a terminal, anything unanswered is refused rather than guessed. Refuses a directory that isn't empty.
9
+ Prompts for a package name, site title, description, variant (`html`, `prerender` or `react`) and brand (`mnsp` or `wrfr`), each showing its default, which Enter accepts. Each has a flag (`--name`, `--title`, `--description`, `--variant`, `--brand`), and `--defaults` takes every default without prompting, so it runs non-interactively too; without a terminal, anything unanswered is refused rather than guessed. Refuses a directory that isn't empty.
10
+
11
+ ## Variants
12
+
13
+ Three ways to author, sharing everything else.
14
+
15
+ - **`html`** (default): pages are HTML you write against Set's CSS variables and custom elements. The simplest possible Set site; the file you write is the file that ships.
16
+ - **`prerender`**: pages are render functions in `src/pages`, prerendered to static HTML at build time by a small Vite plugin and hydrated by `defineSetComponents()`. One shared head in `src/document.ts`, per-page title and description, and nothing of the renderer ships to the browser.
17
+ - **`react`**: an app rendered on the client from `@monospaced/set-react` components, with `@vitejs/plugin-react`, the React ESLint preset, and jsdom for component tests. `Root` takes its initial theme from the stored lightswitch choice.
18
+
19
+ In `html` and `prerender`, a page is a folder with an `index.html` and its path is its URL; Vite discovers them, so adding a page is adding a folder.
20
+
21
+ The brand picks which of core's stylesheets the project imports (`styles/mnsp.css` or `styles/wrfr.css`, one brand each) and the `brand` on `Root`.
10
22
 
11
23
  ## What you get
12
24
 
13
25
  A Vite and TypeScript project with a starter page built from Set components, a `src/styles.css` for your own styles, Set's ESLint and Stylelint presets, Prettier, Husky and lint-staged, VS Code settings, and Lightning CSS targets from Set's Baseline query.
14
26
 
15
- - Dependencies install live, so nothing in the template goes stale. Tools the config package declares as peers install at its declared ranges, so preset and tool agree.
27
+ - Every version has an owner. Set packages install at latest; tools the config package declares as peers install at its declared ranges; the rest of the tooling installs at the monorepo's own ranges, so a scaffold gets the versions Set develops and tests with.
16
28
  - `scripts/sync-set.mjs` copies the token catalog, agent skills and favicons into place on every install.
17
29
  - Git is initialised first, with an initial commit at the end.
30
+ - A GitHub Actions workflow runs the project's checks on every pull request, and Dependabot proposes grouped updates weekly with a three-day cooldown.
18
31
 
19
- The Node and pnpm versions written to `engines`, `packageManager` and `.node-version` are generated into `src/toolchain.mjs` from the monorepo's toolchain by `pnpm create:sync`; `pnpm create:verify` fails CI when they drift.
32
+ The Node and pnpm versions written to `engines`, `packageManager` and `.node-version`, and the tooling ranges, are generated into `src/toolchain.mjs` from the monorepo's toolchain and pnpm catalog by `pnpm create:sync`; `pnpm create:verify` fails CI when they drift.
20
33
 
21
34
  ## Developing
22
35
 
@@ -27,11 +40,11 @@ pnpm create:test
27
40
  pnpm create:dev ../scratch/my-site
28
41
  ```
29
42
 
30
- `create:dev` builds core first and runs the CLI from source. The generated project installs the published Set packages, which is what a user gets. To scaffold against the workspace instead, pack the Set packages and point the CLI at them:
43
+ `create:dev` builds core first and runs the CLI from source. The `scaffold` workflow (`.github/workflows/scaffold.yml`) does the end-to-end run in CI: both variants, against this checkout's packed packages on pull requests and pushes, and against the registry on demand. The generated project installs the published Set packages, which is what a user gets. To scaffold against the workspace instead, pack the Set packages and point the CLI at them:
31
44
 
32
45
  ```sh
33
- pnpm --filter "@monospaced/set-{assets,config,core,skills,tokens}" pack --pack-destination ../scratch/pack
46
+ pnpm --filter @monospaced/set-assets --filter @monospaced/set-config --filter @monospaced/set-core --filter @monospaced/set-icons --filter @monospaced/set-react --filter @monospaced/set-skills --filter @monospaced/set-tokens pack --pack-destination ../scratch/pack
34
47
  CREATE_SET_PACK_DIR=../scratch/pack pnpm create:dev ../scratch/my-site
35
48
  ```
36
49
 
37
- `plan()` in `src/plan.mjs` is pure and returns every file for a set of answers; the tests snapshot it. `execute()` does the I/O: git, files, `pnpm add`, the sync script, the commit.
50
+ `plan()` in `src/plan.mjs` is pure and returns every file for a set of answers; the tests snapshot both variants, and one test executes the generated prerender modules to check they render the same document as the `html` starter. `execute()` does the I/O: git, files, `pnpm add`, the sync script, the commit.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@monospaced/create-set",
3
- "version": "0.21.0",
3
+ "version": "0.23.0",
4
4
  "description": "Project scaffolding for Set. Bootstraps a site or app with Set's components, tooling and defaults.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -18,10 +18,11 @@
18
18
  ],
19
19
  "dependencies": {
20
20
  "@clack/prompts": "^1.8.1",
21
- "@monospaced/set-core": "0.21.0"
21
+ "@monospaced/set-core": "0.23.0"
22
22
  },
23
23
  "devDependencies": {
24
- "vitest": "^4.1.6"
24
+ "prettier": "^3.9.10",
25
+ "vitest": "^5.1.0"
25
26
  },
26
27
  "scripts": {
27
28
  "sync": "node scripts/sync.mjs",
package/src/cli.mjs CHANGED
@@ -9,6 +9,7 @@ import {
9
9
  toTitle,
10
10
  validatePackageName,
11
11
  } from "./answers.mjs";
12
+ import { BRANDS, VARIANTS } from "./constants.mjs";
12
13
  import { execute, isEmptyDirectory } from "./execute.mjs";
13
14
 
14
15
  const { version } = createRequire(import.meta.url)("../package.json");
@@ -22,20 +23,41 @@ Options:
22
23
  --name <name> Package name (default: the directory name)
23
24
  --title <title> Site title (default: from the package name)
24
25
  --description <text> Site description (default: "<title>, built with Set.")
26
+ --variant <variant> html: pages are HTML you write (default)
27
+ prerender: pages are render functions, built to HTML
28
+ react: an app rendered by React
29
+ --brand <brand> mnsp: Monospaced (default); wrfr: wireframe
25
30
  --defaults Take every default without prompting
26
31
  -h, --help Show this help
27
32
  -v, --version Show the version
28
33
  `;
29
34
 
30
35
  const OPTIONS = {
36
+ brand: { type: "string" },
31
37
  defaults: { type: "boolean" },
32
38
  description: { type: "string" },
33
39
  help: { short: "h", type: "boolean" },
34
40
  name: { type: "string" },
35
41
  title: { type: "string" },
42
+ variant: { type: "string" },
36
43
  version: { short: "v", type: "boolean" },
37
44
  };
38
45
 
46
+ const VARIANT_OPTIONS = [
47
+ { hint: "pages are HTML you write", label: "HTML", value: "html" },
48
+ {
49
+ hint: "pages are render functions, built to HTML",
50
+ label: "Prerender",
51
+ value: "prerender",
52
+ },
53
+ { hint: "an app rendered by React", label: "React", value: "react" },
54
+ ];
55
+
56
+ const BRAND_OPTIONS = [
57
+ { label: "Monospaced", value: "mnsp" },
58
+ { hint: "greyscale, for prototypes", label: "Wireframe", value: "wrfr" },
59
+ ];
60
+
39
61
  /** Prompts, or returns `undefined` when the user cancels. */
40
62
  async function prompt(promise) {
41
63
  const value = await promise;
@@ -44,9 +66,10 @@ async function prompt(promise) {
44
66
 
45
67
  /**
46
68
  * Answers a question from its flag, its default (with `--defaults`), or a
47
- * prompt whose hint is the default.
69
+ * prompt: a text field whose hint is the default, or a select when `options`
70
+ * are given.
48
71
  */
49
- async function answer(values, key, fallback, { missing, ...options }) {
72
+ async function answer(values, key, fallback, { missing, options, ...rest }) {
50
73
  if (values[key] !== undefined) return values[key];
51
74
  if (values.defaults) return fallback;
52
75
  if (!process.stdin.isTTY) {
@@ -56,7 +79,9 @@ async function answer(values, key, fallback, { missing, ...options }) {
56
79
  }
57
80
 
58
81
  return prompt(
59
- p.text({ defaultValue: fallback, placeholder: fallback, ...options }),
82
+ options
83
+ ? p.select({ initialValue: fallback, options, ...rest })
84
+ : p.text({ defaultValue: fallback, placeholder: fallback, ...rest }),
60
85
  );
61
86
  }
62
87
 
@@ -100,7 +125,29 @@ async function ask(values, positional) {
100
125
  );
101
126
  if (description === undefined) return;
102
127
 
103
- return { description, dir, name, title };
128
+ const variant = await answer(values, "variant", VARIANTS[0], {
129
+ message: "How are pages authored?",
130
+ options: VARIANT_OPTIONS,
131
+ });
132
+ if (variant === undefined) return;
133
+
134
+ if (!VARIANTS.includes(variant)) {
135
+ p.cancel(`${variant} is not a variant. Use ${VARIANTS.join(" or ")}.`);
136
+ return;
137
+ }
138
+
139
+ const brand = await answer(values, "brand", BRANDS[0], {
140
+ message: "Brand",
141
+ options: BRAND_OPTIONS,
142
+ });
143
+ if (brand === undefined) return;
144
+
145
+ if (!BRANDS.includes(brand)) {
146
+ p.cancel(`${brand} is not a brand. Use ${BRANDS.join(" or ")}.`);
147
+ return;
148
+ }
149
+
150
+ return { brand, description, dir, name, title, variant };
104
151
  }
105
152
 
106
153
  export async function run(argv) {
@@ -146,17 +193,13 @@ export async function run(argv) {
146
193
  return;
147
194
  }
148
195
 
149
- const spinner = p.spinner();
150
- spinner.start("Creating project");
151
-
152
196
  try {
153
- const root = execute(answers, {
154
- step: (message) => spinner.message(message),
197
+ const root = await execute(answers, {
198
+ step: (message) => p.log.step(message),
155
199
  warn: (message) => p.log.warn(message),
156
200
  });
157
- spinner.stop(`Created ${root}`);
201
+ p.log.success(`Created ${root}`);
158
202
  } catch (error) {
159
- spinner.stop("Failed", 1);
160
203
  p.log.error(error.message);
161
204
  process.exitCode = 1;
162
205
  return;
package/src/constants.mjs CHANGED
@@ -2,19 +2,27 @@
2
2
  export const OG_IMAGE =
3
3
  "https://res.cloudinary.com/monospaced/image/upload/v1789307953/2018-04-20_15.28.26--cyan--og--mid.png";
4
4
 
5
+ /** How pages are authored. The first is the default. */
6
+ export const VARIANTS = ["html", "prerender", "react"];
7
+
8
+ /** Set brands. The first is the default. */
9
+ export const BRANDS = ["mnsp", "wrfr"];
10
+
5
11
  /** Installed live at generation time, so nothing in the template goes stale. */
6
12
  export const RUNTIME_DEPENDENCIES = [
7
13
  "@monospaced/set-assets",
8
14
  "@monospaced/set-core",
9
15
  ];
10
16
 
11
- export const DEV_DEPENDENCIES = [
17
+ /** The React variant's extra Set package. */
18
+ export const REACT_RUNTIME_DEPENDENCIES = ["@monospaced/set-react"];
19
+
20
+ /**
21
+ * Set's own dev packages, at latest like the runtime ones. The rest of the
22
+ * tooling installs at the monorepo's ranges, from `toolchain.mjs`.
23
+ */
24
+ export const SET_DEV_DEPENDENCIES = [
12
25
  "@monospaced/set-config",
13
26
  "@monospaced/set-skills",
14
27
  "@monospaced/set-tokens",
15
- "husky",
16
- "lint-staged",
17
- "prettier",
18
- "vite",
19
- "vitest",
20
28
  ];
package/src/execute.mjs CHANGED
@@ -1,9 +1,20 @@
1
- import { spawnSync } from "node:child_process";
2
- import { mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
1
+ import { spawn } from "node:child_process";
2
+ import {
3
+ appendFileSync,
4
+ mkdirSync,
5
+ readdirSync,
6
+ readFileSync,
7
+ writeFileSync,
8
+ } from "node:fs";
3
9
  import { dirname, join, resolve } from "node:path";
4
10
 
5
- import { DEV_DEPENDENCIES, RUNTIME_DEPENDENCIES } from "./constants.mjs";
11
+ import {
12
+ REACT_RUNTIME_DEPENDENCIES,
13
+ RUNTIME_DEPENDENCIES,
14
+ SET_DEV_DEPENDENCIES,
15
+ } from "./constants.mjs";
6
16
  import { plan } from "./plan.mjs";
17
+ import { REACT, TOOLING } from "./toolchain.mjs";
7
18
 
8
19
  /** True when `dir` does not exist or holds nothing. */
9
20
  export function isEmptyDirectory(dir) {
@@ -15,21 +26,29 @@ export function isEmptyDirectory(dir) {
15
26
  }
16
27
  }
17
28
 
29
+ /** Runs a command to completion, off the event loop so Ctrl-C still works. */
18
30
  function run(command, args, cwd) {
19
- const result = spawnSync(command, args, {
20
- cwd,
21
- encoding: "utf8",
22
- shell: process.platform === "win32",
23
- stdio: "pipe",
31
+ return new Promise((resolve, reject) => {
32
+ const child = spawn(command, args, {
33
+ cwd,
34
+ shell: process.platform === "win32",
35
+ stdio: ["ignore", "pipe", "pipe"],
36
+ });
37
+ let output = "";
38
+ child.stdout.on("data", (chunk) => (output += chunk));
39
+ child.stderr.on("data", (chunk) => (output += chunk));
40
+ child.on("error", reject);
41
+ child.on("close", (status) => {
42
+ if (status === 0) resolve(output);
43
+ else {
44
+ reject(
45
+ new Error(
46
+ `\`${command} ${args.join(" ")}\` failed:\n${output.trim()}`,
47
+ ),
48
+ );
49
+ }
50
+ });
24
51
  });
25
-
26
- if (result.error) throw result.error;
27
- if (result.status !== 0) {
28
- const output = `${result.stdout}\n${result.stderr}`.trim();
29
- throw new Error(`\`${command} ${args.join(" ")}\` failed:\n${output}`);
30
- }
31
-
32
- return result.stdout;
33
52
  }
34
53
 
35
54
  const pnpm = (args, cwd) => run("pnpm", args, cwd);
@@ -39,19 +58,46 @@ const pnpm = (args, cwd) => run("pnpm", args, cwd);
39
58
  * `pnpm pack` tarballs in that directory instead of the registry, so the CLI
40
59
  * can be exercised against unreleased packages. Users always get the registry.
41
60
  */
42
- function setSpec(name) {
43
- const packDir = process.env.CREATE_SET_PACK_DIR;
44
- if (!packDir || !name.startsWith("@monospaced/")) return name;
45
-
46
- const stem = `${name.slice(1).replace("/", "-")}-`;
47
- const tarball = readdirSync(packDir).find(
48
- (file) => file.startsWith(stem) && file.endsWith(".tgz"),
61
+ const packDir = process.env.CREATE_SET_PACK_DIR;
62
+
63
+ /** Packed Set packages, by name, from the tarball filenames. */
64
+ function packed() {
65
+ if (!packDir) return {};
66
+
67
+ return Object.fromEntries(
68
+ readdirSync(packDir)
69
+ .filter((file) => file.endsWith(".tgz"))
70
+ .map((file) => [
71
+ `@${file.replace(/-\d+\.\d+\.\d+.*\.tgz$/, "").replace("-", "/")}`,
72
+ resolve(packDir, file),
73
+ ]),
49
74
  );
75
+ }
50
76
 
51
- return tarball ? resolve(packDir, tarball) : name;
77
+ function setSpec(name) {
78
+ return packed()[name] ?? name;
79
+ }
80
+
81
+ /**
82
+ * The packed packages also as pnpm overrides, so a packed core resolves the
83
+ * packed icons it depends on rather than a registry version that may not be
84
+ * published yet. Appended to the generated workspace file in harness mode.
85
+ */
86
+ function packOverrides() {
87
+ const entries = Object.entries(packed());
88
+ if (entries.length === 0) return "";
89
+
90
+ return `
91
+ # Dev harness: Set packages from CREATE_SET_PACK_DIR, transitively too.
92
+ overrides:
93
+ ${entries.map(([name, tarball]) => ` "${name}": "file:${tarball}"`).join("\n")}
94
+ `;
52
95
  }
53
96
  const git = (args, cwd) => run("git", args, cwd);
54
97
 
98
+ const specs = (dependencies) =>
99
+ Object.entries(dependencies).map(([name, range]) => `${name}@${range}`);
100
+
55
101
  /**
56
102
  * The tools the config package drives, as `name@range` specs. Its presets
57
103
  * declare them as peers, so they install at the range it supports rather
@@ -65,9 +111,7 @@ function configPeerSpecs(root) {
65
111
  ),
66
112
  );
67
113
 
68
- return Object.entries(manifest.peerDependencies).map(
69
- ([name, range]) => `${name}@${range}`,
70
- );
114
+ return specs(manifest.peerDependencies);
71
115
  }
72
116
 
73
117
  /**
@@ -77,13 +121,14 @@ function configPeerSpecs(root) {
77
121
  * @param {{ dir: string } & import("./plan.mjs").Answers} answers
78
122
  * @param {{ step(message: string): void, warn(message: string): void }} report
79
123
  */
80
- export function execute({ dir, ...answers }, report) {
124
+ export async function execute({ dir, ...answers }, report) {
81
125
  const root = resolve(dir);
82
126
  const files = plan(answers);
127
+ const react = answers.variant === "react";
83
128
 
84
129
  report.step("Initialising git");
85
130
  mkdirSync(root, { recursive: true });
86
- git(["init", "--initial-branch=main"], root);
131
+ await git(["init", "--initial-branch=main"], root);
87
132
 
88
133
  report.step("Writing files");
89
134
  for (const [path, content] of Object.entries(files)) {
@@ -91,32 +136,49 @@ export function execute({ dir, ...answers }, report) {
91
136
  mkdirSync(dirname(target), { recursive: true });
92
137
  writeFileSync(target, content);
93
138
  }
139
+ appendFileSync(join(root, "pnpm-workspace.yaml"), packOverrides());
94
140
 
95
141
  // Lifecycle scripts wait until everything `prepare` needs is installed.
96
142
  report.step("Installing Set");
97
- pnpm(["add", "--ignore-scripts", ...RUNTIME_DEPENDENCIES.map(setSpec)], root);
143
+ await pnpm(
144
+ [
145
+ "add",
146
+ "--ignore-scripts",
147
+ ...RUNTIME_DEPENDENCIES.map(setSpec),
148
+ ...(react ? REACT_RUNTIME_DEPENDENCIES.map(setSpec) : []),
149
+ ...(react ? specs(REACT.dependencies) : []),
150
+ ],
151
+ root,
152
+ );
98
153
 
99
154
  report.step("Installing tooling");
100
- pnpm(
101
- ["add", "--ignore-scripts", "--save-dev", ...DEV_DEPENDENCIES.map(setSpec)],
155
+ await pnpm(
156
+ [
157
+ "add",
158
+ "--ignore-scripts",
159
+ "--save-dev",
160
+ ...SET_DEV_DEPENDENCIES.map(setSpec),
161
+ ...specs(TOOLING),
162
+ ...(react ? specs(REACT.devDependencies) : []),
163
+ ],
102
164
  root,
103
165
  );
104
- pnpm(
166
+ await pnpm(
105
167
  ["add", "--ignore-scripts", "--save-dev", ...configPeerSpecs(root)],
106
168
  root,
107
169
  );
108
170
 
109
171
  // Explicit: `pnpm install` skips lifecycle scripts when nothing changed.
110
172
  report.step("Syncing Set artifacts and git hooks");
111
- pnpm(["run", "prepare"], root);
173
+ await pnpm(["run", "prepare"], root);
112
174
 
113
175
  report.step("Formatting");
114
- pnpm(["exec", "prettier", "--write", "."], root);
176
+ await pnpm(["exec", "prettier", "--write", "."], root);
115
177
 
116
178
  report.step("Committing");
117
- git(["add", "--all"], root);
179
+ await git(["add", "--all"], root);
118
180
  try {
119
- git(
181
+ await git(
120
182
  ["commit", "--message", "chore: bootstrap with @monospaced/create-set"],
121
183
  root,
122
184
  );
package/src/page.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  import {
2
+ escapeHtml,
2
3
  renderSetBox,
3
4
  renderSetContainer,
4
- renderSetDivider,
5
5
  renderSetHeading,
6
6
  renderSetInline,
7
7
  renderSetLightswitch,
@@ -12,33 +12,24 @@ import {
12
12
  SET_LIGHTSWITCH_STORAGE_KEY,
13
13
  } from "@monospaced/set-core";
14
14
 
15
- /** Escapes a value for an HTML attribute or text node. */
16
- export const escapeHtml = (value) =>
17
- value
18
- .replaceAll("&", "&amp;")
19
- .replaceAll("<", "&lt;")
20
- .replaceAll(">", "&gt;")
21
- .replaceAll('"', "&quot;");
22
-
23
15
  /*
24
16
  * Stamps a stored lightswitch override onto the root before its content
25
17
  * parses, so an overridden theme paints correctly on first load. Must be the
26
18
  * root's first child: earlier and the root doesn't exist yet, later and
27
19
  * content above it may already have painted in the wrong theme.
28
20
  */
29
- const themeBootstrap = `<script>try{var t=localStorage.getItem(${JSON.stringify(
21
+ export const themeBootstrap = `<script>try{var t=localStorage.getItem(${JSON.stringify(
30
22
  SET_LIGHTSWITCH_STORAGE_KEY,
31
23
  )});if(t==="light"||t==="dark")document.currentScript.closest(".set").setAttribute("data-set-theme",t)}catch(e){}</script>`;
32
24
 
33
25
  /**
34
- * The hello-world page: Root → Page with a header, a heading, a paragraph and
35
- * a Lightswitch, built from Set components only. Rendered with core at
36
- * generation time, so the markup is always what the installed core expects.
26
+ * The starter page, built from Set components only and rendered with core at
27
+ * generation time. The `prerender` variant writes the same composition as
28
+ * `src/document.ts` and `src/pages/index.ts`.
37
29
  */
38
- export function renderHelloPage({ title }) {
39
- const safeTitle = escapeHtml(title);
40
-
30
+ export function renderHelloPage({ brand, editHint, title }) {
41
31
  const header = renderSetContainer({
32
+ maxInlineSize: "none",
42
33
  children: renderSetBox({
43
34
  background: "transparent",
44
35
  paddingBlock: "2xs",
@@ -47,7 +38,7 @@ export function renderHelloPage({ title }) {
47
38
  gap: "sm",
48
39
  justify: "between",
49
40
  children:
50
- renderSetHeading({ children: safeTitle, size: "md" }) +
41
+ renderSetHeading({ children: escapeHtml(title), size: "md" }) +
51
42
  renderSetLightswitch({ size: "sm" }),
52
43
  }),
53
44
  }),
@@ -69,31 +60,18 @@ export function renderHelloPage({ title }) {
69
60
  }) +
70
61
  renderSetText({
71
62
  as: "p",
72
- children: `${safeTitle} is built with <a href="https://set.monospaced.com">Set</a>. Edit <code>index.html</code> to begin.`,
63
+ children: `Edit <code>${editHint}</code> to begin.`,
73
64
  responsive: true,
65
+ size: "sm",
74
66
  }),
75
67
  }),
76
68
  }),
77
69
  });
78
70
 
79
- const footer =
80
- renderSetDivider({ tone: "subtle" }) +
81
- renderSetContainer({
82
- children: renderSetBox({
83
- paddingBlock: "xs",
84
- paddingInline: "none",
85
- children: renderSetText({
86
- as: "p",
87
- children: "Built with Set.",
88
- size: "sm",
89
- tone: "muted",
90
- }),
91
- }),
92
- });
93
-
71
+ // Page always renders its footer region; the starter leaves it empty.
94
72
  const page = renderSetPage({
95
73
  children: main,
96
- footer,
74
+ footer: "",
97
75
  header,
98
76
  headerBorder: "always",
99
77
  headerSize: "sm",
@@ -102,6 +80,7 @@ export function renderHelloPage({ title }) {
102
80
  return renderSetRoot({
103
81
  appOverscrollBehavior: "none",
104
82
  appRoot: true,
83
+ brand,
105
84
  children: themeBootstrap + page,
106
85
  });
107
86
  }
package/src/plan.mjs CHANGED
@@ -6,6 +6,8 @@ import { NODE_MAJOR, PNPM_VERSION } from "./toolchain.mjs";
6
6
  * @property {string} name Package name.
7
7
  * @property {string} title Site title.
8
8
  * @property {string} description Site description.
9
+ * @property {"html" | "prerender" | "react"} variant How pages are authored.
10
+ * @property {"mnsp" | "wrfr"} brand Set brand.
9
11
  */
10
12
 
11
13
  /**
@@ -23,8 +25,10 @@ export function plan(answers) {
23
25
  pnpmVersion: PNPM_VERSION,
24
26
  };
25
27
 
26
- return {
28
+ const shared = {
27
29
  ".editorconfig": templates.editorconfig(),
30
+ ".github/dependabot.yml": templates.dependabotYml(),
31
+ ".github/workflows/ci.yml": templates.ciYml(),
28
32
  ".gitignore": templates.gitignore(),
29
33
  ".husky/pre-commit": "pnpm exec lint-staged\n",
30
34
  ".node-version": `${NODE_MAJOR}\n`,
@@ -33,15 +37,40 @@ export function plan(answers) {
33
37
  ".vscode/extensions.json": templates.vscodeExtensions(),
34
38
  ".vscode/settings.json": templates.vscodeSettings(),
35
39
  "README.md": templates.readme(context),
36
- "eslint.config.mjs": templates.eslintConfig(),
37
- "index.html": templates.indexHtml(context),
40
+ "eslint.config.mjs": templates.eslintConfig(context),
38
41
  "package.json": templates.packageJson(context),
39
42
  "pnpm-workspace.yaml": templates.pnpmWorkspace(),
40
43
  "public/manifest.webmanifest": templates.manifest(context),
41
44
  "scripts/sync-set.mjs": templates.syncSet(),
42
- "src/main.ts": templates.mainTs(),
43
45
  "src/styles.css": templates.stylesCss(),
44
- "tsconfig.json": templates.tsconfig(),
45
- "vite.config.ts": templates.viteConfig(),
46
+ "tsconfig.json": templates.tsconfig(context),
47
+ "vite.config.ts": templates.viteConfig(context),
46
48
  };
49
+
50
+ const variants = {
51
+ html: {
52
+ "index.html": templates.indexHtml(context),
53
+ "src/main.ts": templates.mainTs(context),
54
+ },
55
+ prerender: {
56
+ "index.html": templates.routeStub(),
57
+ "src/document.ts": templates.documentTs(),
58
+ "src/main.ts": templates.mainTs(context),
59
+ "src/pages.ts": templates.pagesTs(),
60
+ "src/pages/index.ts": templates.indexPageTs(),
61
+ "src/site.ts": templates.siteTs(context),
62
+ },
63
+ react: {
64
+ "index.html": templates.indexHtml(context),
65
+ "src/App.tsx": templates.appTsx(context),
66
+ "src/main.tsx": templates.mainTsx(context),
67
+ },
68
+ };
69
+ const variant = variants[answers.variant];
70
+
71
+ return Object.fromEntries(
72
+ Object.entries({ ...shared, ...variant }).sort(([a], [b]) =>
73
+ a.localeCompare(b),
74
+ ),
75
+ );
47
76
  }
package/src/templates.mjs CHANGED
@@ -1,5 +1,7 @@
1
+ import { escapeHtml } from "@monospaced/set-core";
2
+
1
3
  import { OG_IMAGE } from "./constants.mjs";
2
- import { escapeHtml, renderHelloPage } from "./page.mjs";
4
+ import { renderHelloPage, themeBootstrap } from "./page.mjs";
3
5
 
4
6
  const json = (value) => `${JSON.stringify(value, null, 2)}\n`;
5
7
 
@@ -34,12 +36,17 @@ export const packageJson = ({ description, name, nodeMajor, pnpmVersion }) => {
34
36
  "lint-staged": {
35
37
  "*": "prettier --ignore-unknown --write",
36
38
  "*.css": "stylelint --fix",
37
- "*.{js,mjs,ts}": "eslint --fix",
39
+ "*.{js,jsx,mjs,ts,tsx}": "eslint --fix",
38
40
  },
39
41
  });
40
42
  };
41
43
 
42
- export const indexHtml = ({ description, title }) => `<!doctype html>
44
+ export const indexHtml = ({
45
+ brand,
46
+ description,
47
+ title,
48
+ variant,
49
+ }) => `<!doctype html>
43
50
  <html lang="en">
44
51
  <head>
45
52
  <meta charset="utf-8" />
@@ -67,8 +74,13 @@ export const indexHtml = ({ description, title }) => `<!doctype html>
67
74
  <link rel="manifest" href="/manifest.webmanifest" />
68
75
  </head>
69
76
  <body>
70
- ${renderHelloPage({ title })}
71
- <script type="module" src="/src/main.ts"></script>
77
+ ${
78
+ variant === "react"
79
+ ? ` <div id="root"></div>
80
+ <script type="module" src="/src/main.tsx"></script>`
81
+ : ` ${renderHelloPage({ brand, editHint: "index.html", title })}
82
+ <script type="module" src="/src/main.ts"></script>`
83
+ }
72
84
  </body>
73
85
  </html>
74
86
  `;
@@ -93,8 +105,8 @@ export const manifest = ({ description, title }) =>
93
105
  ),
94
106
  });
95
107
 
96
- export const mainTs = () => `import "@monospaced/set-assets/fonts.css";
97
- import "@monospaced/set-core/styles.css";
108
+ export const mainTs = ({ brand }) => `import "@monospaced/set-assets/fonts.css";
109
+ import "@monospaced/set-core/styles/${brand}.css";
98
110
  import "./styles.css";
99
111
 
100
112
  import { defineSetComponents } from "@monospaced/set-core";
@@ -107,20 +119,327 @@ export const stylesCss =
107
119
  its own CSS, write it here with Set tokens. */
108
120
  `;
109
121
 
110
- export const viteConfig =
111
- () => `import target from "@monospaced/set-config/browserslist/esbuild";
112
- import targets from "@monospaced/set-config/browserslist/lightningcss";
113
- import { defineConfig } from "vite";
122
+ export const viteConfig = ({ variant }) => {
123
+ const prerender = variant === "prerender";
124
+ const react = variant === "react";
114
125
 
126
+ return `import { globSync } from "node:fs";
127
+
128
+ import target from "@monospaced/set-config/browserslist/esbuild";
129
+ import targets from "@monospaced/set-config/browserslist/lightningcss";
130
+ ${react ? 'import react from "@vitejs/plugin-react";\n' : ""}import { defineConfig${prerender ? ", type Plugin" : ""} } from "${react ? "vitest/config" : "vite"}";
131
+ ${
132
+ prerender
133
+ ? `
134
+ import { renderDocument } from "./src/document.ts";
135
+ import { pages } from "./src/pages.ts";
136
+ `
137
+ : ""
138
+ }
139
+ // Every index.html is a page; its folder is its route.
140
+ const input = globSync("**/index.html", {
141
+ exclude: ["dist/**", "node_modules/**"],
142
+ });
143
+ ${
144
+ prerender
145
+ ? `
146
+ // Renders each route's document from src/pages at dev and build time, so the
147
+ // render functions never ship to the browser. The page modules are imported
148
+ // above, so the dev server restarts when one changes.
149
+ const prerender: Plugin = {
150
+ name: "set-prerender",
151
+ transformIndexHtml: {
152
+ order: "pre",
153
+ handler: (_html, { path }) => {
154
+ const route = path.replace(/index\\.html$/, "");
155
+ const page = pages[route];
156
+ if (!page) throw new Error(\`No page for \${route}. Add it to src/pages.ts.\`);
157
+ return renderDocument(page, route);
158
+ },
159
+ },
160
+ };
161
+ `
162
+ : ""
163
+ }
115
164
  export default defineConfig({
116
- build: { cssMinify: "lightningcss", target },
117
- css: { lightningcss: { targets }, transformer: "lightningcss" },
165
+ build: { cssMinify: "lightningcss", rollupOptions: { input }, target },
166
+ css: { lightningcss: { targets }, transformer: "lightningcss" },${
167
+ prerender ? "\n plugins: [prerender]," : ""
168
+ }${react ? '\n plugins: [react()],\n test: { environment: "jsdom" },' : ""}
118
169
  });
119
170
  `;
171
+ };
172
+
173
+ export const mainTsx = ({
174
+ brand,
175
+ }) => `import "@monospaced/set-assets/fonts.css";
176
+ import "@monospaced/set-core/styles/${brand}.css";
177
+ import "./styles.css";
178
+
179
+ import { SET_LIGHTSWITCH_STORAGE_KEY } from "@monospaced/set-core";
180
+ import { defineSetAll } from "@monospaced/set-react";
181
+ import { StrictMode } from "react";
182
+ import { createRoot } from "react-dom/client";
183
+
184
+ import { App } from "./App.tsx";
185
+
186
+ defineSetAll();
187
+
188
+ // A stored lightswitch override paints from the first render; Lightswitch
189
+ // keeps it in sync after mount.
190
+ function storedTheme(): "light" | "dark" | undefined {
191
+ try {
192
+ const theme = localStorage.getItem(SET_LIGHTSWITCH_STORAGE_KEY);
193
+ return theme === "light" || theme === "dark" ? theme : undefined;
194
+ } catch {
195
+ return undefined;
196
+ }
197
+ }
198
+
199
+ const container = document.getElementById("root");
200
+ if (!container) throw new Error("#root not found");
201
+
202
+ createRoot(container).render(
203
+ <StrictMode>
204
+ <App theme={storedTheme()} />
205
+ </StrictMode>,
206
+ );
207
+ `;
208
+
209
+ export const appTsx = ({ brand, title }) => `import {
210
+ Box,
211
+ Container,
212
+ Heading,
213
+ Inline,
214
+ Lightswitch,
215
+ Page,
216
+ Root,
217
+ Stack,
218
+ Text,
219
+ } from "@monospaced/set-react";
220
+
221
+ export function App({ theme }: { theme?: "light" | "dark" }) {
222
+ return (
223
+ <Root appOverscrollBehavior="none" appRoot brand="${brand}" theme={theme}>
224
+ <Page
225
+ footer={null}
226
+ header={
227
+ <Container maxInlineSize="none">
228
+ <Box background="transparent" paddingBlock="2xs" paddingInline="none">
229
+ <Inline gap="sm" justify="between">
230
+ <Heading size="md">${escapeHtml(title)}</Heading>
231
+ <Lightswitch size="sm" />
232
+ </Inline>
233
+ </Box>
234
+ </Container>
235
+ }
236
+ headerBorder="always"
237
+ headerSize="sm"
238
+ >
239
+ <Container>
240
+ <Box paddingBlock="lg" paddingInline="none" responsive>
241
+ <Stack gap="md">
242
+ <Heading level={1} responsive size="xl">
243
+ Hello, world
244
+ </Heading>
245
+ <Text as="p" responsive size="sm">
246
+ Edit <code>src/App.tsx</code> to begin.
247
+ </Text>
248
+ </Stack>
249
+ </Box>
250
+ </Container>
251
+ </Page>
252
+ </Root>
253
+ );
254
+ }
255
+ `;
256
+
257
+ export const routeStub = () => `<!doctype html>
258
+ <!-- Route marker. This page is rendered from src/pages.ts by the plugin in vite.config.ts. -->
259
+ `;
260
+
261
+ export const siteTs = ({
262
+ brand,
263
+ description,
264
+ title,
265
+ }) => `import type { SetBrand } from "@monospaced/set-core";
266
+
267
+ interface Site {
268
+ brand: SetBrand;
269
+ description: string;
270
+ name: string;
271
+ ogImage: string;
272
+ url: string;
273
+ }
274
+
275
+ export const site: Site = {
276
+ brand: ${JSON.stringify(brand)},
277
+ name: ${JSON.stringify(title)},
278
+ description: ${JSON.stringify(description)},
279
+ // Set once the site has a domain; canonical and og:url derive from it.
280
+ url: "https://example.com",
281
+ ogImage: ${JSON.stringify(OG_IMAGE)},
282
+ };
283
+ `;
284
+
285
+ export const documentTs = () => `import {
286
+ escapeHtml,
287
+ renderSetBox,
288
+ renderSetContainer,
289
+ renderSetHeading,
290
+ renderSetInline,
291
+ renderSetLightswitch,
292
+ renderSetPage,
293
+ renderSetRoot,
294
+ } from "@monospaced/set-core";
295
+
296
+ import { site } from "./site.ts";
297
+
298
+ export interface Page {
299
+ /** Page title. The site name alone when omitted. */
300
+ title?: string;
301
+ /** Meta description. The site description when omitted. */
302
+ description?: string;
303
+ /** The main region, from Set's render functions. */
304
+ render: () => string | Promise<string>;
305
+ }
306
+
307
+ /*
308
+ * Stamps a stored lightswitch override onto the root before its content
309
+ * parses, so an overridden theme paints correctly on first load. Must be the
310
+ * root's first child: earlier and the root doesn't exist yet, later and
311
+ * content above it may already have painted in the wrong theme.
312
+ */
313
+ const themeBootstrap = ${JSON.stringify(themeBootstrap)};
314
+
315
+ const header = (): string =>
316
+ renderSetContainer({
317
+ maxInlineSize: "none",
318
+ children: renderSetBox({
319
+ background: "transparent",
320
+ paddingBlock: "2xs",
321
+ paddingInline: "none",
322
+ children: renderSetInline({
323
+ gap: "sm",
324
+ justify: "between",
325
+ children:
326
+ renderSetHeading({ children: escapeHtml(site.name), size: "md" }) +
327
+ renderSetLightswitch({ size: "sm" }),
328
+ }),
329
+ }),
330
+ });
331
+
332
+ /** The full HTML document for \`page\` at \`route\`. */
333
+ export async function renderDocument(page: Page, route: string): Promise<string> {
334
+ const title = page.title ? \`\${page.title} | \${site.name}\` : site.name;
335
+ const description = page.description ?? site.description;
336
+ const url = \`\${site.url}\${route}\`;
337
+
338
+ // Page always renders its footer region; the starter leaves it empty.
339
+ const body = renderSetRoot({
340
+ appOverscrollBehavior: "none",
341
+ appRoot: true,
342
+ brand: site.brand,
343
+ children:
344
+ themeBootstrap +
345
+ renderSetPage({
346
+ children: await page.render(),
347
+ footer: "",
348
+ header: header(),
349
+ headerBorder: "always",
350
+ headerSize: "sm",
351
+ }),
352
+ });
353
+
354
+ return \`<!doctype html>
355
+ <html lang="en">
356
+ <head>
357
+ <meta charset="utf-8" />
358
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
359
+ <meta name="color-scheme" content="light dark" />
360
+ <meta name="theme-color" content="#f8fbfb" media="(prefers-color-scheme: light)" />
361
+ <meta name="theme-color" content="#0e0f0f" media="(prefers-color-scheme: dark)" />
362
+ <title>\${escapeHtml(title)}</title>
363
+ <meta name="description" content="\${escapeHtml(description)}" />
364
+ <link rel="canonical" href="\${escapeHtml(url)}" />
365
+ <meta property="og:type" content="website" />
366
+ <meta property="og:site_name" content="\${escapeHtml(site.name)}" />
367
+ <meta property="og:title" content="\${escapeHtml(title)}" />
368
+ <meta property="og:description" content="\${escapeHtml(description)}" />
369
+ <meta property="og:url" content="\${escapeHtml(url)}" />
370
+ <meta property="og:image" content="\${site.ogImage}" />
371
+ <meta name="twitter:card" content="summary_large_image" />
372
+ <meta name="twitter:title" content="\${escapeHtml(title)}" />
373
+ <meta name="twitter:description" content="\${escapeHtml(description)}" />
374
+ <meta name="twitter:image" content="\${site.ogImage}" />
375
+ <link rel="icon" href="/favicons/favicon.ico" sizes="32x32" />
376
+ <link rel="icon" href="/favicons/favicon.svg" type="image/svg+xml" />
377
+ <link rel="apple-touch-icon" href="/favicons/apple-touch-icon.png" />
378
+ <link rel="manifest" href="/manifest.webmanifest" />
379
+ </head>
380
+ <body>
381
+ \${body}
382
+ <script type="module" src="/src/main.ts"></script>
383
+ </body>
384
+ </html>
385
+ \`;
386
+ }
387
+ `;
388
+
389
+ export const pagesTs = () => `import type { Page } from "./document.ts";
390
+ import index from "./pages/index.ts";
391
+
392
+ /** Routes to pages. A route is a folder holding an index.html; add both. */
393
+ export const pages: Record<string, Page> = {
394
+ "/": index,
395
+ };
396
+ `;
120
397
 
121
- export const tsconfig = () =>
398
+ export const indexPageTs = () => `import {
399
+ renderSetBox,
400
+ renderSetContainer,
401
+ renderSetHeading,
402
+ renderSetStack,
403
+ renderSetText,
404
+ } from "@monospaced/set-core";
405
+
406
+ import type { Page } from "../document.ts";
407
+
408
+ const page: Page = {
409
+ render: () =>
410
+ renderSetContainer({
411
+ children: renderSetBox({
412
+ paddingBlock: "lg",
413
+ paddingInline: "none",
414
+ responsive: true,
415
+ children: renderSetStack({
416
+ gap: "md",
417
+ children:
418
+ renderSetHeading({
419
+ children: "Hello, world",
420
+ level: 1,
421
+ responsive: true,
422
+ size: "xl",
423
+ }) +
424
+ renderSetText({
425
+ as: "p",
426
+ children: "Edit <code>src/pages/index.ts</code> to begin.",
427
+ responsive: true,
428
+ size: "sm",
429
+ }),
430
+ }),
431
+ }),
432
+ }),
433
+ };
434
+
435
+ export default page;
436
+ `;
437
+
438
+ export const tsconfig = ({ variant }) =>
122
439
  json({
123
440
  compilerOptions: {
441
+ allowImportingTsExtensions: true,
442
+ ...(variant === "react" ? { jsx: "react-jsx" } : {}),
124
443
  lib: ["ES2022", "DOM", "DOM.Iterable"],
125
444
  module: "ESNext",
126
445
  moduleResolution: "Bundler",
@@ -128,13 +447,14 @@ export const tsconfig = () =>
128
447
  skipLibCheck: true,
129
448
  strict: true,
130
449
  target: "ES2022",
131
- types: ["vite/client"],
450
+ types: ["node", "vite/client"],
132
451
  },
133
452
  include: ["src", "*.config.ts"],
134
453
  });
135
454
 
136
- export const eslintConfig =
137
- () => `import setEslint from "@monospaced/set-config/eslint";
455
+ export const eslintConfig = ({
456
+ variant,
457
+ }) => `import setEslint from "@monospaced/set-config/eslint${variant === "react" ? "/react" : ""}";
138
458
 
139
459
  export default [...setEslint];
140
460
  `;
@@ -195,6 +515,9 @@ export const vscodeExtensions = () =>
195
515
  export const pnpmWorkspace =
196
516
  () => `# Minimum age of a release before it installs, in minutes (3 days).
197
517
  minimumReleaseAge: 4320
518
+ # Set's own packages install as soon as they are released.
519
+ minimumReleaseAgeExclude:
520
+ - "@monospaced/*"
198
521
  `;
199
522
 
200
523
  export const syncSet = () => `/*
@@ -232,7 +555,122 @@ if (existsSync(new URL(".git", \`file://\${root}\`))) {
232
555
  }
233
556
  `;
234
557
 
235
- export const readme = ({ description, title }) => `# ${title}
558
+ export const ciYml = () => `name: ci
559
+
560
+ on:
561
+ pull_request:
562
+ push:
563
+ branches:
564
+ - main
565
+
566
+ permissions:
567
+ contents: read
568
+
569
+ jobs:
570
+ check:
571
+ runs-on: ubuntu-latest
572
+
573
+ steps:
574
+ - name: Checkout
575
+ uses: actions/checkout@v6
576
+
577
+ - name: Setup pnpm
578
+ uses: pnpm/action-setup@v6
579
+
580
+ - name: Setup Node
581
+ uses: actions/setup-node@v6
582
+ with:
583
+ node-version-file: .node-version
584
+ cache: pnpm
585
+
586
+ - name: Install
587
+ run: pnpm install --frozen-lockfile
588
+
589
+ - name: Lint
590
+ run: pnpm run lint
591
+
592
+ - name: Stylelint
593
+ run: pnpm run stylelint
594
+
595
+ - name: Format check
596
+ run: pnpm run format:check
597
+
598
+ - name: Typecheck
599
+ run: pnpm run typecheck
600
+
601
+ - name: Test
602
+ run: pnpm run test
603
+
604
+ - name: Build
605
+ run: pnpm run build
606
+ `;
607
+
608
+ export const dependabotYml = () => `version: 2
609
+ updates:
610
+ - package-ecosystem: npm
611
+ directory: /
612
+ schedule:
613
+ interval: weekly
614
+ # Mirrors minimumReleaseAge in pnpm-workspace.yaml, so proposals stay
615
+ # installable under it.
616
+ cooldown:
617
+ default-days: 3
618
+ groups:
619
+ minor-and-patch:
620
+ update-types:
621
+ - minor
622
+ - patch
623
+
624
+ - package-ecosystem: github-actions
625
+ directory: /
626
+ schedule:
627
+ interval: weekly
628
+ `;
629
+
630
+ export const readme = ({ description, title, variant }) => {
631
+ const pages =
632
+ variant === "react"
633
+ ? `## Pages
634
+
635
+ This variant is an app: one \`index.html\`, rendered on the client by React from \`src/main.tsx\`. \`src/App.tsx\` composes the page from \`@monospaced/set-react\` components, and \`Root\` takes its initial \`theme\` from the stored lightswitch choice so an overridden theme paints from the first render. Routes live in React, not in folders; for a site with pages as files, the \`prerender\` variant of \`@monospaced/create-set\` renders them from code.
636
+
637
+ Component tests run in jsdom (\`test.environment\` in \`vite.config.ts\`).
638
+ `
639
+ : variant === "prerender"
640
+ ? `## Pages
641
+
642
+ A page is a folder with an \`index.html\` route marker and a module in \`src/pages\` that exports its title, description and a \`render()\` built from Set's render functions. \`src/pages.ts\` maps routes to modules, and \`src/document.ts\` wraps each page in the shared head and shell at build time. To add \`/about/\`:
643
+
644
+ 1. Copy \`index.html\` to \`about/index.html\`.
645
+ 2. Add \`src/pages/about.ts\` (or \`src/pages/about/index.ts\`, to keep content beside it).
646
+ 3. Register it in \`src/pages.ts\`.
647
+
648
+ \`renderDocument\` in \`src/document.ts\` owns the shell around every page and receives the route, so a nav in its header can mark the current link (\`current: "page"\` on \`renderSetLink\`) by comparing hrefs to it.
649
+
650
+ Page modules are imported by \`vite.config.ts\`, so the dev server restarts when one changes rather than hot-replacing. Keep them to render functions and data: no CSS imports and no DOM at the top level, so \`main.ts\` can import one to re-render on the client.
651
+
652
+ For Markdown content, read the file beside the page and render it in \`Prose\` with \`@monospaced/set-markdown\` (\`pnpm add @monospaced/set-markdown\`):
653
+
654
+ \`\`\`ts
655
+ import { readFileSync } from "node:fs";
656
+
657
+ import { renderSetProse } from "@monospaced/set-core";
658
+ import { processMarkdown } from "@monospaced/set-markdown";
659
+
660
+ const content = readFileSync(new URL("./content.md", import.meta.url), "utf8");
661
+
662
+ export default {
663
+ title: "About",
664
+ render: () => renderSetProse({ children: processMarkdown(content) }),
665
+ };
666
+ \`\`\`
667
+ `
668
+ : `## Pages
669
+
670
+ A page is a folder with an \`index.html\`; its path is its URL (\`about/index.html\` is \`/about/\`). Vite discovers them, so adding a page is adding the folder. Each page carries its own head and its own \`<script type="module" src="/src/main.ts">\`; copy \`index.html\` to start one. When keeping the heads in step by hand becomes a chore, the \`prerender\` variant of \`@monospaced/create-set\` renders pages from code with one shared head.
671
+ `;
672
+
673
+ return `# ${title}
236
674
 
237
675
  ${description}
238
676
 
@@ -247,11 +685,20 @@ Built with [Set](https://set.monospaced.com), scaffolded by \`@monospaced/create
247
685
  - \`pnpm typecheck\`
248
686
  - \`pnpm test\`
249
687
 
250
- Prettier, Stylelint and ESLint fixes run on staged files before every commit via Husky and lint-staged.
688
+ Prettier, Stylelint and ESLint fixes run on staged files before every commit via Husky and lint-staged. CI runs the same checks plus typecheck, test and build on every pull request (\`.github/workflows/ci.yml\`), and Dependabot proposes grouped updates weekly.
251
689
 
690
+ ${pages}
252
691
  ## After bootstrapping
253
692
 
254
- - Set the canonical URL and \`og:url\` in \`index.html\` once the site has a domain.
693
+ - ${
694
+ variant === "prerender"
695
+ ? "Set `url` in `src/site.ts` once the site has a domain; canonical URLs derive from it."
696
+ : "Set the canonical URL and `og:url` in `index.html` once the site has a domain."
697
+ }
255
698
  - \`.vscode/set.catalog.css\`, \`.claude/skills/\`, \`.agents/skills/\` and \`public/favicons/\` are copied from Set on every install by \`scripts/sync-set.mjs\`. Upgrade Set to refresh them rather than editing them by hand.
256
- - Markdown content? \`pnpm add @monospaced/set-markdown\` renders GFM to safe HTML that pairs with core's \`prose\` component.
257
- `;
699
+ ${
700
+ variant === "prerender"
701
+ ? ""
702
+ : "- Markdown content? `pnpm add @monospaced/set-markdown` renders GFM to safe HTML that pairs with core's `prose` component.\n"
703
+ }`;
704
+ };
package/src/toolchain.mjs CHANGED
@@ -5,3 +5,24 @@ export const NODE_MAJOR = 26;
5
5
 
6
6
  /** pnpm version written to `packageManager`; `engines` takes its major. */
7
7
  export const PNPM_VERSION = "12.17.0";
8
+
9
+ /** Tooling installed into every project, at the monorepo's ranges. */
10
+ export const TOOLING = {
11
+ "@types/node": "^26.6.6",
12
+ husky: "^9.1.7",
13
+ "lint-staged": "^17.6.0",
14
+ prettier: "^3.9.10",
15
+ vite: "^8.3.6",
16
+ vitest: "^5.1.0",
17
+ };
18
+
19
+ /** What the React variant adds, at the monorepo's ranges. */
20
+ export const REACT = {
21
+ dependencies: { react: "^19.3.1", "react-dom": "^19.3.1" },
22
+ devDependencies: {
23
+ "@types/react": "^19.3.0",
24
+ "@types/react-dom": "^19.3.0",
25
+ "@vitejs/plugin-react": "^6.2.1",
26
+ jsdom: "^31.0.0",
27
+ },
28
+ };