@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 +19 -6
- package/package.json +4 -3
- package/src/cli.mjs +54 -11
- package/src/constants.mjs +14 -6
- package/src/execute.mjs +99 -37
- package/src/page.mjs +13 -34
- package/src/plan.mjs +35 -6
- package/src/templates.mjs +469 -22
- package/src/toolchain.mjs +21 -0
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
|
|
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
|
-
-
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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
|
+
"@monospaced/set-core": "0.23.0"
|
|
22
22
|
},
|
|
23
23
|
"devDependencies": {
|
|
24
|
-
"
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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) =>
|
|
197
|
+
const root = await execute(answers, {
|
|
198
|
+
step: (message) => p.log.step(message),
|
|
155
199
|
warn: (message) => p.log.warn(message),
|
|
156
200
|
});
|
|
157
|
-
|
|
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
|
-
|
|
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 {
|
|
2
|
-
import {
|
|
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 {
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
-
[
|
|
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("&", "&")
|
|
19
|
-
.replaceAll("<", "<")
|
|
20
|
-
.replaceAll(">", ">")
|
|
21
|
-
.replaceAll('"', """);
|
|
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
|
|
35
|
-
*
|
|
36
|
-
*
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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 = ({
|
|
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
|
-
|
|
71
|
-
|
|
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
|
-
|
|
112
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
-
|
|
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
|
-
|
|
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
|
+
};
|