@monospaced/create-set 0.20.2 → 0.22.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,28 @@ 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 and variant (`html`, `prerender` or `react`), each showing its default, which Enter accepts. Each has a flag (`--name`, `--title`, `--description`, `--variant`), 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.
10
20
 
11
21
  ## What you get
12
22
 
13
23
  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
24
 
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.
25
+ - 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
26
  - `scripts/sync-set.mjs` copies the token catalog, agent skills and favicons into place on every install.
17
27
  - Git is initialised first, with an initial commit at the end.
28
+ - 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
29
 
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.
30
+ 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
31
 
21
32
  ## Developing
22
33
 
@@ -27,11 +38,11 @@ pnpm create:test
27
38
  pnpm create:dev ../scratch/my-site
28
39
  ```
29
40
 
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:
41
+ `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
42
 
32
43
  ```sh
33
- pnpm --filter "@monospaced/set-{assets,config,core,skills,tokens}" pack --pack-destination ../scratch/pack
44
+ 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
45
  CREATE_SET_PACK_DIR=../scratch/pack pnpm create:dev ../scratch/my-site
35
46
  ```
36
47
 
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.
48
+ `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.20.2",
3
+ "version": "0.22.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,9 +18,10 @@
18
18
  ],
19
19
  "dependencies": {
20
20
  "@clack/prompts": "^1.8.1",
21
- "@monospaced/set-core": "0.20.2"
21
+ "@monospaced/set-core": "0.22.0"
22
22
  },
23
23
  "devDependencies": {
24
+ "prettier": "^3.8.3",
24
25
  "vitest": "^4.1.6"
25
26
  },
26
27
  "scripts": {
package/src/cli.mjs CHANGED
@@ -9,6 +9,7 @@ import {
9
9
  toTitle,
10
10
  validatePackageName,
11
11
  } from "./answers.mjs";
12
+ import { 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,6 +23,9 @@ 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
25
29
  --defaults Take every default without prompting
26
30
  -h, --help Show this help
27
31
  -v, --version Show the version
@@ -33,9 +37,20 @@ const OPTIONS = {
33
37
  help: { short: "h", type: "boolean" },
34
38
  name: { type: "string" },
35
39
  title: { type: "string" },
40
+ variant: { type: "string" },
36
41
  version: { short: "v", type: "boolean" },
37
42
  };
38
43
 
44
+ const VARIANT_OPTIONS = [
45
+ { hint: "pages are HTML you write", label: "HTML", value: "html" },
46
+ {
47
+ hint: "pages are render functions, built to HTML",
48
+ label: "Prerender",
49
+ value: "prerender",
50
+ },
51
+ { hint: "an app rendered by React", label: "React", value: "react" },
52
+ ];
53
+
39
54
  /** Prompts, or returns `undefined` when the user cancels. */
40
55
  async function prompt(promise) {
41
56
  const value = await promise;
@@ -44,9 +59,10 @@ async function prompt(promise) {
44
59
 
45
60
  /**
46
61
  * Answers a question from its flag, its default (with `--defaults`), or a
47
- * prompt whose hint is the default.
62
+ * prompt: a text field whose hint is the default, or a select when `options`
63
+ * are given.
48
64
  */
49
- async function answer(values, key, fallback, { missing, ...options }) {
65
+ async function answer(values, key, fallback, { missing, options, ...rest }) {
50
66
  if (values[key] !== undefined) return values[key];
51
67
  if (values.defaults) return fallback;
52
68
  if (!process.stdin.isTTY) {
@@ -56,7 +72,9 @@ async function answer(values, key, fallback, { missing, ...options }) {
56
72
  }
57
73
 
58
74
  return prompt(
59
- p.text({ defaultValue: fallback, placeholder: fallback, ...options }),
75
+ options
76
+ ? p.select({ initialValue: fallback, options, ...rest })
77
+ : p.text({ defaultValue: fallback, placeholder: fallback, ...rest }),
60
78
  );
61
79
  }
62
80
 
@@ -100,7 +118,18 @@ async function ask(values, positional) {
100
118
  );
101
119
  if (description === undefined) return;
102
120
 
103
- return { description, dir, name, title };
121
+ const variant = await answer(values, "variant", VARIANTS[0], {
122
+ message: "How are pages authored?",
123
+ options: VARIANT_OPTIONS,
124
+ });
125
+ if (variant === undefined) return;
126
+
127
+ if (!VARIANTS.includes(variant)) {
128
+ p.cancel(`${variant} is not a variant. Use ${VARIANTS.join(" or ")}.`);
129
+ return;
130
+ }
131
+
132
+ return { description, dir, name, title, variant };
104
133
  }
105
134
 
106
135
  export async function run(argv) {
@@ -146,17 +175,13 @@ export async function run(argv) {
146
175
  return;
147
176
  }
148
177
 
149
- const spinner = p.spinner();
150
- spinner.start("Creating project");
151
-
152
178
  try {
153
- const root = execute(answers, {
154
- step: (message) => spinner.message(message),
179
+ const root = await execute(answers, {
180
+ step: (message) => p.log.step(message),
155
181
  warn: (message) => p.log.warn(message),
156
182
  });
157
- spinner.stop(`Created ${root}`);
183
+ p.log.success(`Created ${root}`);
158
184
  } catch (error) {
159
- spinner.stop("Failed", 1);
160
185
  p.log.error(error.message);
161
186
  process.exitCode = 1;
162
187
  return;
package/src/constants.mjs CHANGED
@@ -2,19 +2,24 @@
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
+
5
8
  /** Installed live at generation time, so nothing in the template goes stale. */
6
9
  export const RUNTIME_DEPENDENCIES = [
7
10
  "@monospaced/set-assets",
8
11
  "@monospaced/set-core",
9
12
  ];
10
13
 
11
- export const DEV_DEPENDENCIES = [
14
+ /** The React variant's extra Set package. */
15
+ export const REACT_RUNTIME_DEPENDENCIES = ["@monospaced/set-react"];
16
+
17
+ /**
18
+ * Set's own dev packages, at latest like the runtime ones. The rest of the
19
+ * tooling installs at the monorepo's ranges, from `toolchain.mjs`.
20
+ */
21
+ export const SET_DEV_DEPENDENCIES = [
12
22
  "@monospaced/set-config",
13
23
  "@monospaced/set-skills",
14
24
  "@monospaced/set-tokens",
15
- "husky",
16
- "lint-staged",
17
- "prettier",
18
- "vite",
19
- "vitest",
20
25
  ];
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,6 @@
1
1
  import {
2
2
  renderSetBox,
3
3
  renderSetContainer,
4
- renderSetDivider,
5
4
  renderSetHeading,
6
5
  renderSetInline,
7
6
  renderSetLightswitch,
@@ -26,19 +25,18 @@ export const escapeHtml = (value) =>
26
25
  * root's first child: earlier and the root doesn't exist yet, later and
27
26
  * content above it may already have painted in the wrong theme.
28
27
  */
29
- const themeBootstrap = `<script>try{var t=localStorage.getItem(${JSON.stringify(
28
+ export const themeBootstrap = `<script>try{var t=localStorage.getItem(${JSON.stringify(
30
29
  SET_LIGHTSWITCH_STORAGE_KEY,
31
30
  )});if(t==="light"||t==="dark")document.currentScript.closest(".set").setAttribute("data-set-theme",t)}catch(e){}</script>`;
32
31
 
33
32
  /**
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.
33
+ * The starter page, built from Set components only and rendered with core at
34
+ * generation time. The `prerender` variant writes the same composition as
35
+ * `src/document.ts` and `src/pages/index.ts`.
37
36
  */
38
- export function renderHelloPage({ title }) {
39
- const safeTitle = escapeHtml(title);
40
-
37
+ export function renderHelloPage({ editHint, title }) {
41
38
  const header = renderSetContainer({
39
+ maxInlineSize: "none",
42
40
  children: renderSetBox({
43
41
  background: "transparent",
44
42
  paddingBlock: "2xs",
@@ -47,7 +45,7 @@ export function renderHelloPage({ title }) {
47
45
  gap: "sm",
48
46
  justify: "between",
49
47
  children:
50
- renderSetHeading({ children: safeTitle, size: "md" }) +
48
+ renderSetHeading({ children: escapeHtml(title), size: "md" }) +
51
49
  renderSetLightswitch({ size: "sm" }),
52
50
  }),
53
51
  }),
@@ -69,31 +67,18 @@ export function renderHelloPage({ title }) {
69
67
  }) +
70
68
  renderSetText({
71
69
  as: "p",
72
- children: `${safeTitle} is built with <a href="https://set.monospaced.com">Set</a>. Edit <code>index.html</code> to begin.`,
70
+ children: `Edit <code>${editHint}</code> to begin.`,
73
71
  responsive: true,
72
+ size: "sm",
74
73
  }),
75
74
  }),
76
75
  }),
77
76
  });
78
77
 
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
-
78
+ // Page always renders its footer region; the starter leaves it empty.
94
79
  const page = renderSetPage({
95
80
  children: main,
96
- footer,
81
+ footer: "",
97
82
  header,
98
83
  headerBorder: "always",
99
84
  headerSize: "sm",
package/src/plan.mjs CHANGED
@@ -6,6 +6,7 @@ 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"} variant How pages are authored.
9
10
  */
10
11
 
11
12
  /**
@@ -23,8 +24,10 @@ export function plan(answers) {
23
24
  pnpmVersion: PNPM_VERSION,
24
25
  };
25
26
 
26
- return {
27
+ const shared = {
27
28
  ".editorconfig": templates.editorconfig(),
29
+ ".github/dependabot.yml": templates.dependabotYml(),
30
+ ".github/workflows/ci.yml": templates.ciYml(),
28
31
  ".gitignore": templates.gitignore(),
29
32
  ".husky/pre-commit": "pnpm exec lint-staged\n",
30
33
  ".node-version": `${NODE_MAJOR}\n`,
@@ -33,15 +36,40 @@ export function plan(answers) {
33
36
  ".vscode/extensions.json": templates.vscodeExtensions(),
34
37
  ".vscode/settings.json": templates.vscodeSettings(),
35
38
  "README.md": templates.readme(context),
36
- "eslint.config.mjs": templates.eslintConfig(),
37
- "index.html": templates.indexHtml(context),
39
+ "eslint.config.mjs": templates.eslintConfig(context),
38
40
  "package.json": templates.packageJson(context),
39
41
  "pnpm-workspace.yaml": templates.pnpmWorkspace(),
40
42
  "public/manifest.webmanifest": templates.manifest(context),
41
43
  "scripts/sync-set.mjs": templates.syncSet(),
42
- "src/main.ts": templates.mainTs(),
43
44
  "src/styles.css": templates.stylesCss(),
44
- "tsconfig.json": templates.tsconfig(),
45
- "vite.config.ts": templates.viteConfig(),
45
+ "tsconfig.json": templates.tsconfig(context),
46
+ "vite.config.ts": templates.viteConfig(context),
46
47
  };
48
+
49
+ const variants = {
50
+ html: {
51
+ "index.html": templates.indexHtml(context),
52
+ "src/main.ts": templates.mainTs(),
53
+ },
54
+ prerender: {
55
+ "index.html": templates.routeStub(),
56
+ "src/document.ts": templates.documentTs(),
57
+ "src/main.ts": templates.mainTs(),
58
+ "src/pages.ts": templates.pagesTs(),
59
+ "src/pages/index.ts": templates.indexPageTs(),
60
+ "src/site.ts": templates.siteTs(context),
61
+ },
62
+ react: {
63
+ "index.html": templates.indexHtml(context),
64
+ "src/App.tsx": templates.appTsx(context),
65
+ "src/main.tsx": templates.mainTsx(),
66
+ },
67
+ };
68
+ const variant = variants[answers.variant];
69
+
70
+ return Object.fromEntries(
71
+ Object.entries({ ...shared, ...variant }).sort(([a], [b]) =>
72
+ a.localeCompare(b),
73
+ ),
74
+ );
47
75
  }
package/src/templates.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  import { OG_IMAGE } from "./constants.mjs";
2
- import { escapeHtml, renderHelloPage } from "./page.mjs";
2
+ import { escapeHtml, renderHelloPage, themeBootstrap } from "./page.mjs";
3
3
 
4
4
  const json = (value) => `${JSON.stringify(value, null, 2)}\n`;
5
5
 
@@ -34,12 +34,12 @@ export const packageJson = ({ description, name, nodeMajor, pnpmVersion }) => {
34
34
  "lint-staged": {
35
35
  "*": "prettier --ignore-unknown --write",
36
36
  "*.css": "stylelint --fix",
37
- "*.{js,mjs,ts}": "eslint --fix",
37
+ "*.{js,jsx,mjs,ts,tsx}": "eslint --fix",
38
38
  },
39
39
  });
40
40
  };
41
41
 
42
- export const indexHtml = ({ description, title }) => `<!doctype html>
42
+ export const indexHtml = ({ description, title, variant }) => `<!doctype html>
43
43
  <html lang="en">
44
44
  <head>
45
45
  <meta charset="utf-8" />
@@ -67,8 +67,13 @@ export const indexHtml = ({ description, title }) => `<!doctype html>
67
67
  <link rel="manifest" href="/manifest.webmanifest" />
68
68
  </head>
69
69
  <body>
70
- ${renderHelloPage({ title })}
71
- <script type="module" src="/src/main.ts"></script>
70
+ ${
71
+ variant === "react"
72
+ ? ` <div id="root"></div>
73
+ <script type="module" src="/src/main.tsx"></script>`
74
+ : ` ${renderHelloPage({ editHint: "index.html", title })}
75
+ <script type="module" src="/src/main.ts"></script>`
76
+ }
72
77
  </body>
73
78
  </html>
74
79
  `;
@@ -107,20 +112,315 @@ export const stylesCss =
107
112
  its own CSS, write it here with Set tokens. */
108
113
  `;
109
114
 
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";
115
+ export const viteConfig = ({ variant }) => {
116
+ const prerender = variant === "prerender";
117
+ const react = variant === "react";
118
+
119
+ return `import { globSync } from "node:fs";
114
120
 
121
+ import target from "@monospaced/set-config/browserslist/esbuild";
122
+ import targets from "@monospaced/set-config/browserslist/lightningcss";
123
+ ${react ? 'import react from "@vitejs/plugin-react";\n' : ""}import { defineConfig${prerender ? ", type Plugin" : ""} } from "${react ? "vitest/config" : "vite"}";
124
+ ${
125
+ prerender
126
+ ? `
127
+ import { renderDocument } from "./src/document.ts";
128
+ import { pages } from "./src/pages.ts";
129
+ `
130
+ : ""
131
+ }
132
+ // Every index.html is a page; its folder is its route.
133
+ const input = globSync("**/index.html", {
134
+ exclude: ["dist/**", "node_modules/**"],
135
+ });
136
+ ${
137
+ prerender
138
+ ? `
139
+ // Renders each route's document from src/pages at dev and build time, so the
140
+ // render functions never ship to the browser. The page modules are imported
141
+ // above, so the dev server restarts when one changes.
142
+ const prerender: Plugin = {
143
+ name: "set-prerender",
144
+ transformIndexHtml: {
145
+ order: "pre",
146
+ handler: (_html, { path }) => {
147
+ const route = path.replace(/index\\.html$/, "");
148
+ const page = pages[route];
149
+ if (!page) throw new Error(\`No page for \${route}. Add it to src/pages.ts.\`);
150
+ return renderDocument(page, route);
151
+ },
152
+ },
153
+ };
154
+ `
155
+ : ""
156
+ }
115
157
  export default defineConfig({
116
- build: { cssMinify: "lightningcss", target },
117
- css: { lightningcss: { targets }, transformer: "lightningcss" },
158
+ build: { cssMinify: "lightningcss", rollupOptions: { input }, target },
159
+ css: { lightningcss: { targets }, transformer: "lightningcss" },${
160
+ prerender ? "\n plugins: [prerender]," : ""
161
+ }${react ? '\n plugins: [react()],\n test: { environment: "jsdom" },' : ""}
118
162
  });
119
163
  `;
164
+ };
165
+
166
+ export const mainTsx = () => `import "@monospaced/set-assets/fonts.css";
167
+ import "@monospaced/set-core/styles.css";
168
+ import "./styles.css";
169
+
170
+ import { SET_LIGHTSWITCH_STORAGE_KEY } from "@monospaced/set-core";
171
+ import { defineSetAll } from "@monospaced/set-react";
172
+ import { StrictMode } from "react";
173
+ import { createRoot } from "react-dom/client";
174
+
175
+ import { App } from "./App.tsx";
176
+
177
+ defineSetAll();
178
+
179
+ // A stored lightswitch override paints from the first render; Lightswitch
180
+ // keeps it in sync after mount.
181
+ function storedTheme(): "light" | "dark" | undefined {
182
+ try {
183
+ const theme = localStorage.getItem(SET_LIGHTSWITCH_STORAGE_KEY);
184
+ return theme === "light" || theme === "dark" ? theme : undefined;
185
+ } catch {
186
+ return undefined;
187
+ }
188
+ }
189
+
190
+ const container = document.getElementById("root");
191
+ if (!container) throw new Error("#root not found");
192
+
193
+ createRoot(container).render(
194
+ <StrictMode>
195
+ <App theme={storedTheme()} />
196
+ </StrictMode>,
197
+ );
198
+ `;
199
+
200
+ export const appTsx = ({ title }) => `import {
201
+ Box,
202
+ Container,
203
+ Heading,
204
+ Inline,
205
+ Lightswitch,
206
+ Page,
207
+ Root,
208
+ Stack,
209
+ Text,
210
+ } from "@monospaced/set-react";
211
+
212
+ export function App({ theme }: { theme?: "light" | "dark" }) {
213
+ return (
214
+ <Root appOverscrollBehavior="none" appRoot theme={theme}>
215
+ <Page
216
+ footer={null}
217
+ header={
218
+ <Container maxInlineSize="none">
219
+ <Box background="transparent" paddingBlock="2xs" paddingInline="none">
220
+ <Inline gap="sm" justify="between">
221
+ <Heading size="md">${escapeHtml(title)}</Heading>
222
+ <Lightswitch size="sm" />
223
+ </Inline>
224
+ </Box>
225
+ </Container>
226
+ }
227
+ headerBorder="always"
228
+ headerSize="sm"
229
+ >
230
+ <Container>
231
+ <Box paddingBlock="lg" paddingInline="none" responsive>
232
+ <Stack gap="md">
233
+ <Heading level={1} responsive size="xl">
234
+ Hello, world
235
+ </Heading>
236
+ <Text as="p" responsive size="sm">
237
+ Edit <code>src/App.tsx</code> to begin.
238
+ </Text>
239
+ </Stack>
240
+ </Box>
241
+ </Container>
242
+ </Page>
243
+ </Root>
244
+ );
245
+ }
246
+ `;
247
+
248
+ export const routeStub = () => `<!doctype html>
249
+ <!-- Route marker. This page is rendered from src/pages.ts by the plugin in vite.config.ts. -->
250
+ `;
251
+
252
+ export const siteTs = ({ description, title }) => `export const site = {
253
+ name: ${JSON.stringify(title)},
254
+ description: ${JSON.stringify(description)},
255
+ // Set once the site has a domain; canonical and og:url derive from it.
256
+ url: "https://example.com",
257
+ ogImage: ${JSON.stringify(OG_IMAGE)},
258
+ };
259
+ `;
260
+
261
+ export const documentTs = () => `import {
262
+ renderSetBox,
263
+ renderSetContainer,
264
+ renderSetHeading,
265
+ renderSetInline,
266
+ renderSetLightswitch,
267
+ renderSetPage,
268
+ renderSetRoot,
269
+ } from "@monospaced/set-core";
270
+
271
+ import { site } from "./site.ts";
272
+
273
+ export interface Page {
274
+ /** Page title. The site name alone when omitted. */
275
+ title?: string;
276
+ /** Meta description. The site description when omitted. */
277
+ description?: string;
278
+ /** The main region, from Set's render functions. */
279
+ render: () => string | Promise<string>;
280
+ }
281
+
282
+ const escapeHtml = (value: string): string =>
283
+ value
284
+ .replaceAll("&", "&amp;")
285
+ .replaceAll("<", "&lt;")
286
+ .replaceAll(">", "&gt;")
287
+ .replaceAll('"', "&quot;");
288
+
289
+ /*
290
+ * Stamps a stored lightswitch override onto the root before its content
291
+ * parses, so an overridden theme paints correctly on first load. Must be the
292
+ * root's first child: earlier and the root doesn't exist yet, later and
293
+ * content above it may already have painted in the wrong theme.
294
+ */
295
+ const themeBootstrap = ${JSON.stringify(themeBootstrap)};
296
+
297
+ const header = (): string =>
298
+ renderSetContainer({
299
+ maxInlineSize: "none",
300
+ children: renderSetBox({
301
+ background: "transparent",
302
+ paddingBlock: "2xs",
303
+ paddingInline: "none",
304
+ children: renderSetInline({
305
+ gap: "sm",
306
+ justify: "between",
307
+ children:
308
+ renderSetHeading({ children: escapeHtml(site.name), size: "md" }) +
309
+ renderSetLightswitch({ size: "sm" }),
310
+ }),
311
+ }),
312
+ });
313
+
314
+ /** The full HTML document for \`page\` at \`route\`. */
315
+ export async function renderDocument(page: Page, route: string): Promise<string> {
316
+ const title = page.title ? \`\${page.title} | \${site.name}\` : site.name;
317
+ const description = page.description ?? site.description;
318
+ const url = \`\${site.url}\${route}\`;
319
+
320
+ // Page always renders its footer region; the starter leaves it empty.
321
+ const body = renderSetRoot({
322
+ appOverscrollBehavior: "none",
323
+ appRoot: true,
324
+ children:
325
+ themeBootstrap +
326
+ renderSetPage({
327
+ children: await page.render(),
328
+ footer: "",
329
+ header: header(),
330
+ headerBorder: "always",
331
+ headerSize: "sm",
332
+ }),
333
+ });
334
+
335
+ return \`<!doctype html>
336
+ <html lang="en">
337
+ <head>
338
+ <meta charset="utf-8" />
339
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
340
+ <meta name="color-scheme" content="light dark" />
341
+ <meta name="theme-color" content="#f8fbfb" media="(prefers-color-scheme: light)" />
342
+ <meta name="theme-color" content="#0e0f0f" media="(prefers-color-scheme: dark)" />
343
+ <title>\${escapeHtml(title)}</title>
344
+ <meta name="description" content="\${escapeHtml(description)}" />
345
+ <link rel="canonical" href="\${escapeHtml(url)}" />
346
+ <meta property="og:type" content="website" />
347
+ <meta property="og:site_name" content="\${escapeHtml(site.name)}" />
348
+ <meta property="og:title" content="\${escapeHtml(title)}" />
349
+ <meta property="og:description" content="\${escapeHtml(description)}" />
350
+ <meta property="og:url" content="\${escapeHtml(url)}" />
351
+ <meta property="og:image" content="\${site.ogImage}" />
352
+ <meta name="twitter:card" content="summary_large_image" />
353
+ <meta name="twitter:title" content="\${escapeHtml(title)}" />
354
+ <meta name="twitter:description" content="\${escapeHtml(description)}" />
355
+ <meta name="twitter:image" content="\${site.ogImage}" />
356
+ <link rel="icon" href="/favicons/favicon.ico" sizes="32x32" />
357
+ <link rel="icon" href="/favicons/favicon.svg" type="image/svg+xml" />
358
+ <link rel="apple-touch-icon" href="/favicons/apple-touch-icon.png" />
359
+ <link rel="manifest" href="/manifest.webmanifest" />
360
+ </head>
361
+ <body>
362
+ \${body}
363
+ <script type="module" src="/src/main.ts"></script>
364
+ </body>
365
+ </html>
366
+ \`;
367
+ }
368
+ `;
369
+
370
+ export const pagesTs = () => `import type { Page } from "./document.ts";
371
+ import index from "./pages/index.ts";
372
+
373
+ /** Routes to pages. A route is a folder holding an index.html; add both. */
374
+ export const pages: Record<string, Page> = {
375
+ "/": index,
376
+ };
377
+ `;
120
378
 
121
- export const tsconfig = () =>
379
+ export const indexPageTs = () => `import {
380
+ renderSetBox,
381
+ renderSetContainer,
382
+ renderSetHeading,
383
+ renderSetStack,
384
+ renderSetText,
385
+ } from "@monospaced/set-core";
386
+
387
+ import type { Page } from "../document.ts";
388
+
389
+ const page: Page = {
390
+ render: () =>
391
+ renderSetContainer({
392
+ children: renderSetBox({
393
+ paddingBlock: "lg",
394
+ paddingInline: "none",
395
+ responsive: true,
396
+ children: renderSetStack({
397
+ gap: "md",
398
+ children:
399
+ renderSetHeading({
400
+ children: "Hello, world",
401
+ level: 1,
402
+ responsive: true,
403
+ size: "xl",
404
+ }) +
405
+ renderSetText({
406
+ as: "p",
407
+ children: "Edit <code>src/pages/index.ts</code> to begin.",
408
+ responsive: true,
409
+ size: "sm",
410
+ }),
411
+ }),
412
+ }),
413
+ }),
414
+ };
415
+
416
+ export default page;
417
+ `;
418
+
419
+ export const tsconfig = ({ variant }) =>
122
420
  json({
123
421
  compilerOptions: {
422
+ allowImportingTsExtensions: true,
423
+ ...(variant === "react" ? { jsx: "react-jsx" } : {}),
124
424
  lib: ["ES2022", "DOM", "DOM.Iterable"],
125
425
  module: "ESNext",
126
426
  moduleResolution: "Bundler",
@@ -128,13 +428,14 @@ export const tsconfig = () =>
128
428
  skipLibCheck: true,
129
429
  strict: true,
130
430
  target: "ES2022",
131
- types: ["vite/client"],
431
+ types: ["node", "vite/client"],
132
432
  },
133
433
  include: ["src", "*.config.ts"],
134
434
  });
135
435
 
136
- export const eslintConfig =
137
- () => `import setEslint from "@monospaced/set-config/eslint";
436
+ export const eslintConfig = ({
437
+ variant,
438
+ }) => `import setEslint from "@monospaced/set-config/eslint${variant === "react" ? "/react" : ""}";
138
439
 
139
440
  export default [...setEslint];
140
441
  `;
@@ -195,6 +496,9 @@ export const vscodeExtensions = () =>
195
496
  export const pnpmWorkspace =
196
497
  () => `# Minimum age of a release before it installs, in minutes (3 days).
197
498
  minimumReleaseAge: 4320
499
+ # Set's own packages install as soon as they are released.
500
+ minimumReleaseAgeExclude:
501
+ - "@monospaced/*"
198
502
  `;
199
503
 
200
504
  export const syncSet = () => `/*
@@ -232,7 +536,122 @@ if (existsSync(new URL(".git", \`file://\${root}\`))) {
232
536
  }
233
537
  `;
234
538
 
235
- export const readme = ({ description, title }) => `# ${title}
539
+ export const ciYml = () => `name: ci
540
+
541
+ on:
542
+ pull_request:
543
+ push:
544
+ branches:
545
+ - main
546
+
547
+ permissions:
548
+ contents: read
549
+
550
+ jobs:
551
+ check:
552
+ runs-on: ubuntu-latest
553
+
554
+ steps:
555
+ - name: Checkout
556
+ uses: actions/checkout@v6
557
+
558
+ - name: Setup pnpm
559
+ uses: pnpm/action-setup@v6
560
+
561
+ - name: Setup Node
562
+ uses: actions/setup-node@v6
563
+ with:
564
+ node-version-file: .node-version
565
+ cache: pnpm
566
+
567
+ - name: Install
568
+ run: pnpm install --frozen-lockfile
569
+
570
+ - name: Lint
571
+ run: pnpm run lint
572
+
573
+ - name: Stylelint
574
+ run: pnpm run stylelint
575
+
576
+ - name: Format check
577
+ run: pnpm run format:check
578
+
579
+ - name: Typecheck
580
+ run: pnpm run typecheck
581
+
582
+ - name: Test
583
+ run: pnpm run test
584
+
585
+ - name: Build
586
+ run: pnpm run build
587
+ `;
588
+
589
+ export const dependabotYml = () => `version: 2
590
+ updates:
591
+ - package-ecosystem: npm
592
+ directory: /
593
+ schedule:
594
+ interval: weekly
595
+ # Mirrors minimumReleaseAge in pnpm-workspace.yaml, so proposals stay
596
+ # installable under it.
597
+ cooldown:
598
+ default-days: 3
599
+ groups:
600
+ minor-and-patch:
601
+ update-types:
602
+ - minor
603
+ - patch
604
+
605
+ - package-ecosystem: github-actions
606
+ directory: /
607
+ schedule:
608
+ interval: weekly
609
+ `;
610
+
611
+ export const readme = ({ description, title, variant }) => {
612
+ const pages =
613
+ variant === "react"
614
+ ? `## Pages
615
+
616
+ 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.
617
+
618
+ Component tests run in jsdom (\`test.environment\` in \`vite.config.ts\`).
619
+ `
620
+ : variant === "prerender"
621
+ ? `## Pages
622
+
623
+ 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/\`:
624
+
625
+ 1. Copy \`index.html\` to \`about/index.html\`.
626
+ 2. Add \`src/pages/about.ts\` (or \`src/pages/about/index.ts\`, to keep content beside it).
627
+ 3. Register it in \`src/pages.ts\`.
628
+
629
+ \`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.
630
+
631
+ 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.
632
+
633
+ For Markdown content, read the file beside the page and render it in \`Prose\` with \`@monospaced/set-markdown\` (\`pnpm add @monospaced/set-markdown\`):
634
+
635
+ \`\`\`ts
636
+ import { readFileSync } from "node:fs";
637
+
638
+ import { renderSetProse } from "@monospaced/set-core";
639
+ import { processMarkdown } from "@monospaced/set-markdown";
640
+
641
+ const content = readFileSync(new URL("./content.md", import.meta.url), "utf8");
642
+
643
+ export default {
644
+ title: "About",
645
+ render: () => renderSetProse({ children: processMarkdown(content) }),
646
+ };
647
+ \`\`\`
648
+ `
649
+ : `## Pages
650
+
651
+ 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.
652
+ `;
653
+
654
+ return `# ${title}
236
655
 
237
656
  ${description}
238
657
 
@@ -247,11 +666,20 @@ Built with [Set](https://set.monospaced.com), scaffolded by \`@monospaced/create
247
666
  - \`pnpm typecheck\`
248
667
  - \`pnpm test\`
249
668
 
250
- Prettier, Stylelint and ESLint fixes run on staged files before every commit via Husky and lint-staged.
669
+ 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
670
 
671
+ ${pages}
252
672
  ## After bootstrapping
253
673
 
254
- - Set the canonical URL and \`og:url\` in \`index.html\` once the site has a domain.
674
+ - ${
675
+ variant === "prerender"
676
+ ? "Set `url` in `src/site.ts` once the site has a domain; canonical URLs derive from it."
677
+ : "Set the canonical URL and `og:url` in `index.html` once the site has a domain."
678
+ }
255
679
  - \`.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
- `;
680
+ ${
681
+ variant === "prerender"
682
+ ? ""
683
+ : "- Markdown content? `pnpm add @monospaced/set-markdown` renders GFM to safe HTML that pairs with core's `prose` component.\n"
684
+ }`;
685
+ };
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.0.4",
14
+ prettier: "^3.8.3",
15
+ vite: "^7.3.2",
16
+ vitest: "^4.1.6",
17
+ };
18
+
19
+ /** What the React variant adds, at the monorepo's ranges. */
20
+ export const REACT = {
21
+ dependencies: { react: "^19.2.6", "react-dom": "^19.2.6" },
22
+ devDependencies: {
23
+ "@types/react": "^19.2.14",
24
+ "@types/react-dom": "^19.2.3",
25
+ "@vitejs/plugin-react": "^5.2.0",
26
+ jsdom: "^29.1.1",
27
+ },
28
+ };