toiljs 0.0.11 → 0.0.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. package/README.md +2 -0
  2. package/build/cli/.tsbuildinfo +1 -1
  3. package/build/cli/configure.js +10 -4
  4. package/build/cli/create.js +58 -30
  5. package/build/cli/diagnostics.d.ts +55 -0
  6. package/build/cli/diagnostics.js +333 -0
  7. package/build/cli/doctor.d.ts +6 -0
  8. package/build/cli/doctor.js +249 -0
  9. package/build/cli/index.js +26 -0
  10. package/build/cli/proc.d.ts +5 -0
  11. package/build/cli/proc.js +20 -0
  12. package/build/cli/ui.d.ts +1 -0
  13. package/build/cli/ui.js +1 -0
  14. package/build/cli/update.d.ts +7 -0
  15. package/build/cli/update.js +117 -0
  16. package/build/cli/updates.d.ts +10 -0
  17. package/build/cli/updates.js +45 -0
  18. package/build/client/.tsbuildinfo +1 -1
  19. package/build/client/dev/error-overlay.js +1 -1
  20. package/build/client/head/metadata.js +3 -1
  21. package/build/client/index.d.ts +5 -1
  22. package/build/client/index.js +2 -0
  23. package/build/client/navigation/navigation.js +1 -1
  24. package/build/client/routing/Router.js +2 -2
  25. package/build/client/search/search.d.ts +26 -0
  26. package/build/client/search/search.js +101 -0
  27. package/build/client/search/use-page-search.d.ts +8 -0
  28. package/build/client/search/use-page-search.js +21 -0
  29. package/build/compiler/.tsbuildinfo +1 -1
  30. package/build/compiler/generate.js +26 -23
  31. package/build/compiler/index.d.ts +2 -0
  32. package/build/compiler/index.js +1 -0
  33. package/build/compiler/pages.d.ts +8 -0
  34. package/build/compiler/pages.js +37 -0
  35. package/build/compiler/plugin.js +3 -1
  36. package/build/compiler/prerender.d.ts +1 -0
  37. package/build/compiler/prerender.js +11 -5
  38. package/build/compiler/seo.js +10 -3
  39. package/build/io/.tsbuildinfo +1 -1
  40. package/examples/basic/client/components/Header.tsx +43 -41
  41. package/examples/basic/client/components/HoneycombBackground.tsx +223 -230
  42. package/examples/basic/client/public/index.html +18 -16
  43. package/examples/basic/client/routes/(legal)/privacy.tsx +18 -19
  44. package/examples/basic/client/routes/(legal)/terms.tsx +15 -16
  45. package/examples/basic/client/routes/about.tsx +21 -22
  46. package/examples/basic/client/routes/blog/[id].tsx +26 -18
  47. package/examples/basic/client/routes/features/actions.tsx +67 -67
  48. package/examples/basic/client/routes/features/error/index.tsx +27 -27
  49. package/examples/basic/client/routes/features/head.tsx +38 -38
  50. package/examples/basic/client/routes/features/index.tsx +83 -75
  51. package/examples/basic/client/routes/features/realtime.tsx +34 -32
  52. package/examples/basic/client/routes/features/script.tsx +31 -31
  53. package/examples/basic/client/routes/features/seo.tsx +39 -39
  54. package/examples/basic/client/routes/features/template/index.tsx +20 -20
  55. package/examples/basic/client/routes/features/template/template.tsx +16 -18
  56. package/examples/basic/client/routes/gallery/@modal/(.)photo/[id].tsx +23 -23
  57. package/examples/basic/client/routes/gallery/index.tsx +42 -42
  58. package/examples/basic/client/routes/gallery/photo/[id].tsx +18 -18
  59. package/examples/basic/client/routes/get-started.tsx +157 -84
  60. package/examples/basic/client/routes/index.tsx +137 -96
  61. package/examples/basic/client/routes/loader-demo/index.tsx +59 -52
  62. package/examples/basic/client/routes/search.tsx +61 -0
  63. package/examples/basic/client/routes/test.tsx +7 -8
  64. package/examples/basic/client/styles/main.css +624 -552
  65. package/package.json +2 -2
  66. package/presets/eslint.js +10 -3
  67. package/src/cli/configure.ts +363 -353
  68. package/src/cli/create.ts +563 -530
  69. package/src/cli/diagnostics.ts +421 -0
  70. package/src/cli/doctor.ts +318 -0
  71. package/src/cli/features.ts +166 -160
  72. package/src/cli/index.ts +242 -211
  73. package/src/cli/proc.ts +30 -0
  74. package/src/cli/ui.ts +111 -103
  75. package/src/cli/update.ts +150 -0
  76. package/src/cli/updates.ts +69 -0
  77. package/src/client/components/Image.tsx +91 -89
  78. package/src/client/dev/error-overlay.tsx +193 -197
  79. package/src/client/head/metadata.ts +94 -92
  80. package/src/client/index.ts +79 -64
  81. package/src/client/navigation/Link.tsx +94 -100
  82. package/src/client/navigation/navigation.ts +215 -218
  83. package/src/client/routing/Router.tsx +210 -193
  84. package/src/client/routing/hooks.ts +110 -114
  85. package/src/client/routing/lazy.ts +77 -81
  86. package/src/client/search/search.ts +189 -0
  87. package/src/client/search/use-page-search.ts +73 -0
  88. package/src/compiler/config.ts +173 -171
  89. package/src/compiler/fonts.ts +89 -87
  90. package/src/compiler/generate.ts +378 -373
  91. package/src/compiler/image-report.ts +88 -85
  92. package/src/compiler/index.ts +2 -0
  93. package/src/compiler/pages.ts +70 -0
  94. package/src/compiler/plugin.ts +51 -47
  95. package/src/compiler/prerender.ts +152 -130
  96. package/src/compiler/routes.ts +132 -131
  97. package/src/compiler/seo.ts +381 -356
  98. package/src/compiler/vite.ts +155 -145
  99. package/src/io/FastSet.ts +99 -96
  100. package/test/configure.test.ts +94 -90
  101. package/test/doctor.test.ts +140 -0
  102. package/test/dom/Image.test.tsx +73 -46
  103. package/test/dom/Script.test.tsx +48 -45
  104. package/test/dom/action.test.tsx +146 -129
  105. package/test/dom/error-overlay.test.tsx +44 -44
  106. package/test/dom/loader.test.tsx +2 -2
  107. package/test/dom/revalidate.test.tsx +1 -1
  108. package/test/dom/route-head.test.tsx +1 -2
  109. package/test/dom/slot.test.tsx +131 -109
  110. package/test/dom/view-transitions.test.tsx +53 -51
  111. package/test/features.test.ts +149 -142
  112. package/test/fonts.test.ts +28 -26
  113. package/test/head.test.ts +45 -35
  114. package/test/metadata.test.ts +42 -41
  115. package/test/pages.test.ts +105 -0
  116. package/test/prerender.test.ts +54 -46
  117. package/test/search.test.ts +114 -0
  118. package/test/seo.test.ts +164 -142
  119. package/test/update.test.ts +44 -0
@@ -1,353 +1,363 @@
1
- /**
2
- * `toiljs configure`, toggle a project's client styling features (CSS preprocessor + Tailwind) on
3
- * an existing app. Detects the current setup, prompts for the desired one, then rewrites the
4
- * stylesheet(s) + the `client/toil.tsx` imports, edits `package.json`, and syncs node_modules with
5
- * the project's package manager (so removed features are fully cleaned, not just disabled).
6
- */
7
- import fs from 'node:fs/promises';
8
- import path from 'node:path';
9
-
10
- import { intro, outro, select, confirm, isCancel, cancel, spinner, note } from '@clack/prompts';
11
- import { loadConfig } from 'toiljs/compiler';
12
- import pc from 'picocolors';
13
-
14
- import {
15
- PKG_VERSION,
16
- PREPROCESSORS,
17
- TAILWIND_CSS,
18
- TAILWIND_ENTRY,
19
- defaultConfigSource,
20
- detectPreprocessor,
21
- detectTailwind,
22
- packageDiff,
23
- preprocessorForExt,
24
- setConfigImages,
25
- setStyleImports,
26
- styleEntry,
27
- type Preprocessor,
28
- type StyleFeatures,
29
- } from './features.js';
30
- import { run } from './proc.js';
31
- import { accent, dim } from './ui.js';
32
-
33
- export interface ConfigureOptions {
34
- readonly root?: string;
35
- readonly cwd: string;
36
- /** When set, the corresponding prompt is skipped (non-interactive). */
37
- readonly preprocessor?: Preprocessor;
38
- readonly tailwind?: boolean;
39
- /** Toggle build-time image optimization. When set, the prompt is skipped. */
40
- readonly images?: boolean;
41
- /** Run the package manager to sync deps. Default `true`; `false` edits files only. */
42
- readonly install?: boolean;
43
- }
44
-
45
- const CONFIG_FILES = [
46
- 'toil.config.ts',
47
- 'toil.config.mts',
48
- 'toil.config.js',
49
- 'toil.config.mjs',
50
- 'toiljs.config.ts',
51
- 'toiljs.config.mts',
52
- 'toiljs.config.js',
53
- 'toiljs.config.mjs',
54
- ];
55
-
56
- /** Reads the project's `toil.config.*` (path + source), or null if none exists. */
57
- async function readConfigFile(root: string): Promise<{ path: string; source: string } | null> {
58
- for (const name of CONFIG_FILES) {
59
- const p = path.join(root, name);
60
- try {
61
- return { path: p, source: await fs.readFile(p, 'utf8') };
62
- } catch {}
63
- }
64
- return null;
65
- }
66
-
67
- /**
68
- * Persists `client.images` to the project's `toil.config`. Edits an existing config in place (or
69
- * creates `toil.config.ts` if none); returns `false` if the existing file's shape couldn't be
70
- * edited, so the caller can tell the user to set it by hand.
71
- */
72
- async function writeImagesFlag(root: string, enabled: boolean): Promise<boolean> {
73
- const existing = await readConfigFile(root);
74
- if (!existing) {
75
- await fs.writeFile(path.join(root, 'toil.config.ts'), defaultConfigSource(enabled), 'utf8');
76
- return true;
77
- }
78
- const next = setConfigImages(existing.source, enabled);
79
- if (next === null) return false;
80
- await fs.writeFile(existing.path, next, 'utf8');
81
- return true;
82
- }
83
-
84
- /** Current `client.images` setting (defaults to `true` when the config can't be loaded). */
85
- async function resolveImages(root: string): Promise<boolean> {
86
- try {
87
- return (await loadConfig({ root })).images;
88
- } catch {
89
- return true;
90
- }
91
- }
92
-
93
- /** Resolves the client source dir, falling back to `<root>/client` if the config can't be loaded. */
94
- async function resolveClientDir(root: string): Promise<string> {
95
- try {
96
- const cfg = await loadConfig({ root });
97
- return cfg.clientAbsDir;
98
- } catch {
99
- return path.join(root, 'client');
100
- }
101
- }
102
-
103
- const PREPROCESSOR_LABEL: Record<Preprocessor, string> = {
104
- css: 'Plain CSS',
105
- sass: 'Sass (SCSS)',
106
- less: 'Less',
107
- stylus: 'Stylus',
108
- };
109
-
110
- interface PackageJson {
111
- dependencies?: Record<string, string>;
112
- devDependencies?: Record<string, string>;
113
- }
114
-
115
- function bail<T>(value: T | symbol): asserts value is T {
116
- if (isCancel(value)) {
117
- cancel('Configuration cancelled.');
118
- process.exit(0);
119
- }
120
- }
121
-
122
- /** Finds the existing main stylesheet's preprocessor by extension, or null if none is present. */
123
- async function detectStylesheet(clientDir: string): Promise<Preprocessor | null> {
124
- for (const p of PREPROCESSORS) {
125
- try {
126
- await fs.access(path.join(clientDir, styleEntry(p)));
127
- return p;
128
- } catch {}
129
- }
130
- try {
131
- await fs.access(path.join(clientDir, 'styles/main.sass'));
132
- return preprocessorForExt('sass');
133
- } catch {
134
- return null;
135
- }
136
- }
137
-
138
- /** Returns the path of the existing `styles/main.*` stylesheet, or null. */
139
- async function findMainStylesheet(clientDir: string): Promise<string | null> {
140
- for (const ext of ['css', 'scss', 'sass', 'less', 'styl']) {
141
- const p = path.join(clientDir, 'styles', `main.${ext}`);
142
- try {
143
- await fs.access(p);
144
- return p;
145
- } catch {}
146
- }
147
- return null;
148
- }
149
-
150
- /** Picks the project's package manager from its lockfile (defaults to npm). */
151
- async function detectPackageManager(root: string): Promise<string> {
152
- const lock: [string, string][] = [
153
- ['pnpm-lock.yaml', 'pnpm'],
154
- ['yarn.lock', 'yarn'],
155
- ['bun.lockb', 'bun'],
156
- ];
157
- for (const [file, pm] of lock) {
158
- try {
159
- await fs.access(path.join(root, file));
160
- return pm;
161
- } catch {}
162
- }
163
- return 'npm';
164
- }
165
-
166
- /** Applies the stylesheet renames, Tailwind entry, and entry imports for the new feature set. */
167
- async function applyStyleFiles(
168
- clientDir: string,
169
- from: StyleFeatures,
170
- to: StyleFeatures,
171
- ): Promise<void> {
172
- if (from.preprocessor !== to.preprocessor) {
173
- const newPath = path.join(clientDir, styleEntry(to.preprocessor));
174
- await fs.mkdir(path.dirname(newPath), { recursive: true });
175
- // Rename whatever main stylesheet actually exists (preserving its content), not an assumed
176
- // name, so we never blow away the user's styles when the on-disk extension differs.
177
- const existing = await findMainStylesheet(clientDir);
178
- if (existing && path.resolve(existing) !== path.resolve(newPath)) {
179
- await fs.rename(existing, newPath);
180
- } else if (!existing) {
181
- await fs.writeFile(newPath, '', 'utf8');
182
- }
183
- }
184
-
185
- const tailwindPath = path.join(clientDir, TAILWIND_ENTRY);
186
- if (to.tailwind && !from.tailwind) {
187
- await fs.mkdir(path.dirname(tailwindPath), { recursive: true });
188
- await fs.writeFile(tailwindPath, TAILWIND_CSS, 'utf8');
189
- } else if (!to.tailwind && from.tailwind) {
190
- await fs.rm(tailwindPath, { force: true });
191
- }
192
-
193
- for (const entry of ['toil.tsx', 'toil.jsx']) {
194
- const entryPath = path.join(clientDir, entry);
195
- try {
196
- const source = await fs.readFile(entryPath, 'utf8');
197
- await fs.writeFile(entryPath, setStyleImports(source, to), 'utf8');
198
- return;
199
- } catch {}
200
- }
201
- }
202
-
203
- /**
204
- * Applies a styling change to a project on disk (no prompts): rewrites stylesheets + the app
205
- * entry's imports and edits `package.json`. Exposed for testing and reuse; the package manager is
206
- * run separately by {@link runConfigure}.
207
- */
208
- export async function applyConfigure(
209
- clientDir: string,
210
- pkgPath: string,
211
- pkg: PackageJson,
212
- from: StyleFeatures,
213
- to: StyleFeatures,
214
- ): Promise<void> {
215
- await applyStyleFiles(clientDir, from, to);
216
- await applyPackages(pkgPath, pkg, from, to);
217
- }
218
-
219
- /** Adds/removes the managed styling packages in `package.json` (sorted devDependencies). */
220
- async function applyPackages(
221
- pkgPath: string,
222
- pkg: PackageJson,
223
- from: StyleFeatures,
224
- to: StyleFeatures,
225
- ): Promise<void> {
226
- const { add, remove } = packageDiff(from, to);
227
- const dev: Record<string, string> = { ...pkg.devDependencies };
228
- const deps: Record<string, string> = { ...pkg.dependencies };
229
- for (const name of add) dev[name] = PKG_VERSION[name] ?? 'latest';
230
- for (const name of remove) {
231
- delete dev[name];
232
- delete deps[name];
233
- }
234
- const sortedDev = Object.fromEntries(Object.entries(dev).sort(([a], [b]) => a.localeCompare(b)));
235
- const next: PackageJson = { ...pkg, devDependencies: sortedDev };
236
- // Always reflect the pruned dependencies (or drop the key entirely if now empty), so a removed
237
- // package can't survive via the original `...pkg` spread.
238
- if (Object.keys(deps).length) next.dependencies = deps;
239
- else delete next.dependencies;
240
- await fs.writeFile(pkgPath, JSON.stringify(next, null, 4) + '\n', 'utf8');
241
- }
242
-
243
- /** Human-readable summary of what changed. */
244
- function describe(from: StyleFeatures, to: StyleFeatures): string {
245
- const lines: string[] = [];
246
- if (from.preprocessor !== to.preprocessor) {
247
- lines.push(`preprocessor: ${PREPROCESSOR_LABEL[from.preprocessor]} ${PREPROCESSOR_LABEL[to.preprocessor]}`);
248
- }
249
- if (from.tailwind !== to.tailwind) {
250
- lines.push(`Tailwind: ${from.tailwind ? 'on' : 'off'} → ${to.tailwind ? 'on' : 'off'}`);
251
- }
252
- const { add, remove } = packageDiff(from, to);
253
- if (add.length) lines.push(`+ ${add.join(', ')}`);
254
- if (remove.length) lines.push(`- ${remove.join(', ')}`);
255
- return lines.map((l) => dim(' ') + l).join('\n');
256
- }
257
-
258
- /** Runs the interactive configure flow. */
259
- export async function runConfigure(opts: ConfigureOptions): Promise<void> {
260
- intro(accent(' toiljs configure '));
261
- const root = path.resolve(opts.root ?? opts.cwd);
262
-
263
- const pkgPath = path.join(root, 'package.json');
264
- let pkg: PackageJson;
265
- try {
266
- pkg = JSON.parse(await fs.readFile(pkgPath, 'utf8')) as PackageJson;
267
- } catch {
268
- cancel(`No package.json in ${pc.cyan(root)}, run this inside a toiljs project.`);
269
- process.exit(1);
270
- }
271
-
272
- const clientAbsDir = await resolveClientDir(root);
273
- const deps = { ...pkg.dependencies, ...pkg.devDependencies };
274
- const current: StyleFeatures = {
275
- preprocessor: (await detectStylesheet(clientAbsDir)) ?? detectPreprocessor(deps),
276
- tailwind: detectTailwind(deps),
277
- };
278
-
279
- const currentImages = await resolveImages(root);
280
-
281
- const nonInteractive =
282
- opts.preprocessor !== undefined || opts.tailwind !== undefined || opts.images !== undefined;
283
- let target: StyleFeatures;
284
- let targetImages: boolean;
285
- if (nonInteractive) {
286
- target = {
287
- preprocessor: opts.preprocessor ?? current.preprocessor,
288
- tailwind: opts.tailwind ?? current.tailwind,
289
- };
290
- targetImages = opts.images ?? currentImages;
291
- } else {
292
- const ppChoice = await select<Preprocessor>({
293
- message: 'CSS preprocessor',
294
- options: PREPROCESSORS.map((value) => ({ value, label: PREPROCESSOR_LABEL[value] })),
295
- initialValue: current.preprocessor,
296
- });
297
- bail(ppChoice);
298
- const twChoice = await confirm({ message: 'Use Tailwind CSS?', initialValue: current.tailwind });
299
- bail(twChoice);
300
- const imChoice = await confirm({
301
- message: 'Optimize images at build time?',
302
- initialValue: currentImages,
303
- });
304
- bail(imChoice);
305
- target = { preprocessor: ppChoice, tailwind: twChoice };
306
- targetImages = imChoice;
307
- }
308
-
309
- const styleChanged =
310
- target.preprocessor !== current.preprocessor || target.tailwind !== current.tailwind;
311
- const imagesChanged = targetImages !== currentImages;
312
- if (!styleChanged && !imagesChanged) {
313
- outro('No changes, your setup is already up to date.');
314
- return;
315
- }
316
-
317
- const s = spinner();
318
- s.start('Updating project files');
319
- if (styleChanged) await applyConfigure(clientAbsDir, pkgPath, pkg, current, target);
320
- let imagesWarning = '';
321
- if (imagesChanged && !(await writeImagesFlag(root, targetImages))) {
322
- imagesWarning = pc.yellow(
323
- ' Could not edit toil.config automatically, set `client.images` by hand.',
324
- );
325
- }
326
- s.stop('Updated project files');
327
-
328
- if (styleChanged) {
329
- const pm = await detectPackageManager(root);
330
- if (opts.install === false) {
331
- note(`${pc.cyan(`${pm} install`)} to sync the dependency changes.`, 'Next step');
332
- } else {
333
- const i = spinner();
334
- i.start(`Syncing dependencies with ${pm}`);
335
- try {
336
- await run(pm, ['install'], root);
337
- i.stop('Dependencies synced');
338
- } catch {
339
- i.stop(pc.yellow(`Could not run \`${pm} install\`, run it yourself to finish`));
340
- }
341
- }
342
- }
343
-
344
- const summary = [
345
- styleChanged ? describe(current, target) : '',
346
- imagesChanged ? dim(' ') + `image optimization: ${currentImages ? 'on' : 'off'} ${targetImages ? 'on' : 'off'}` : '',
347
- imagesWarning,
348
- ]
349
- .filter(Boolean)
350
- .join('\n');
351
- note(summary, 'Updated');
352
- outro(`Reconfigured, restart \`${accent('toiljs dev')}\` to pick up the changes.`);
353
- }
1
+ /**
2
+ * `toiljs configure`, toggle a project's client styling features (CSS preprocessor + Tailwind) on
3
+ * an existing app. Detects the current setup, prompts for the desired one, then rewrites the
4
+ * stylesheet(s) + the `client/toil.tsx` imports, edits `package.json`, and syncs node_modules with
5
+ * the project's package manager (so removed features are fully cleaned, not just disabled).
6
+ */
7
+ import fs from 'node:fs/promises';
8
+ import path from 'node:path';
9
+
10
+ import { cancel, confirm, intro, isCancel, note, outro, select, spinner } from '@clack/prompts';
11
+ import { loadConfig } from 'toiljs/compiler';
12
+ import pc from 'picocolors';
13
+
14
+ import {
15
+ defaultConfigSource,
16
+ detectPreprocessor,
17
+ detectTailwind,
18
+ packageDiff,
19
+ PKG_VERSION,
20
+ type Preprocessor,
21
+ preprocessorForExt,
22
+ PREPROCESSORS,
23
+ setConfigImages,
24
+ setStyleImports,
25
+ styleEntry,
26
+ type StyleFeatures,
27
+ TAILWIND_CSS,
28
+ TAILWIND_ENTRY,
29
+ } from './features.js';
30
+ import { run } from './proc.js';
31
+ import { accent, dim } from './ui.js';
32
+
33
+ export interface ConfigureOptions {
34
+ readonly root?: string;
35
+ readonly cwd: string;
36
+ /** When set, the corresponding prompt is skipped (non-interactive). */
37
+ readonly preprocessor?: Preprocessor;
38
+ readonly tailwind?: boolean;
39
+ /** Toggle build-time image optimization. When set, the prompt is skipped. */
40
+ readonly images?: boolean;
41
+ /** Run the package manager to sync deps. Default `true`; `false` edits files only. */
42
+ readonly install?: boolean;
43
+ }
44
+
45
+ const CONFIG_FILES = [
46
+ 'toil.config.ts',
47
+ 'toil.config.mts',
48
+ 'toil.config.js',
49
+ 'toil.config.mjs',
50
+ 'toiljs.config.ts',
51
+ 'toiljs.config.mts',
52
+ 'toiljs.config.js',
53
+ 'toiljs.config.mjs',
54
+ ];
55
+
56
+ /** Reads the project's `toil.config.*` (path + source), or null if none exists. */
57
+ async function readConfigFile(root: string): Promise<{ path: string; source: string } | null> {
58
+ for (const name of CONFIG_FILES) {
59
+ const p = path.join(root, name);
60
+ try {
61
+ return { path: p, source: await fs.readFile(p, 'utf8') };
62
+ } catch {}
63
+ }
64
+ return null;
65
+ }
66
+
67
+ /**
68
+ * Persists `client.images` to the project's `toil.config`. Edits an existing config in place (or
69
+ * creates `toil.config.ts` if none); returns `false` if the existing file's shape couldn't be
70
+ * edited, so the caller can tell the user to set it by hand.
71
+ */
72
+ async function writeImagesFlag(root: string, enabled: boolean): Promise<boolean> {
73
+ const existing = await readConfigFile(root);
74
+ if (!existing) {
75
+ await fs.writeFile(path.join(root, 'toil.config.ts'), defaultConfigSource(enabled), 'utf8');
76
+ return true;
77
+ }
78
+ const next = setConfigImages(existing.source, enabled);
79
+ if (next === null) return false;
80
+ await fs.writeFile(existing.path, next, 'utf8');
81
+ return true;
82
+ }
83
+
84
+ /** Current `client.images` setting (defaults to `true` when the config can't be loaded). */
85
+ async function resolveImages(root: string): Promise<boolean> {
86
+ try {
87
+ return (await loadConfig({ root })).images;
88
+ } catch {
89
+ return true;
90
+ }
91
+ }
92
+
93
+ /** Resolves the client source dir, falling back to `<root>/client` if the config can't be loaded. */
94
+ async function resolveClientDir(root: string): Promise<string> {
95
+ try {
96
+ const cfg = await loadConfig({ root });
97
+ return cfg.clientAbsDir;
98
+ } catch {
99
+ return path.join(root, 'client');
100
+ }
101
+ }
102
+
103
+ const PREPROCESSOR_LABEL: Record<Preprocessor, string> = {
104
+ css: 'Plain CSS',
105
+ sass: 'Sass (SCSS)',
106
+ less: 'Less',
107
+ stylus: 'Stylus',
108
+ };
109
+
110
+ interface PackageJson {
111
+ dependencies?: Record<string, string>;
112
+ devDependencies?: Record<string, string>;
113
+ }
114
+
115
+ function bail<T>(value: T | symbol): asserts value is T {
116
+ if (isCancel(value)) {
117
+ cancel('Configuration cancelled.');
118
+ process.exit(0);
119
+ }
120
+ }
121
+
122
+ /** Finds the existing main stylesheet's preprocessor by extension, or null if none is present. */
123
+ async function detectStylesheet(clientDir: string): Promise<Preprocessor | null> {
124
+ for (const p of PREPROCESSORS) {
125
+ try {
126
+ await fs.access(path.join(clientDir, styleEntry(p)));
127
+ return p;
128
+ } catch {}
129
+ }
130
+ try {
131
+ await fs.access(path.join(clientDir, 'styles/main.sass'));
132
+ return preprocessorForExt('sass');
133
+ } catch {
134
+ return null;
135
+ }
136
+ }
137
+
138
+ /** Returns the path of the existing `styles/main.*` stylesheet, or null. */
139
+ async function findMainStylesheet(clientDir: string): Promise<string | null> {
140
+ for (const ext of ['css', 'scss', 'sass', 'less', 'styl']) {
141
+ const p = path.join(clientDir, 'styles', `main.${ext}`);
142
+ try {
143
+ await fs.access(p);
144
+ return p;
145
+ } catch {}
146
+ }
147
+ return null;
148
+ }
149
+
150
+ /** Picks the project's package manager from its lockfile (defaults to npm). */
151
+ async function detectPackageManager(root: string): Promise<string> {
152
+ const lock: [string, string][] = [
153
+ ['pnpm-lock.yaml', 'pnpm'],
154
+ ['yarn.lock', 'yarn'],
155
+ ['bun.lockb', 'bun'],
156
+ ];
157
+ for (const [file, pm] of lock) {
158
+ try {
159
+ await fs.access(path.join(root, file));
160
+ return pm;
161
+ } catch {}
162
+ }
163
+ return 'npm';
164
+ }
165
+
166
+ /** Applies the stylesheet renames, Tailwind entry, and entry imports for the new feature set. */
167
+ async function applyStyleFiles(
168
+ clientDir: string,
169
+ from: StyleFeatures,
170
+ to: StyleFeatures,
171
+ ): Promise<void> {
172
+ if (from.preprocessor !== to.preprocessor) {
173
+ const newPath = path.join(clientDir, styleEntry(to.preprocessor));
174
+ await fs.mkdir(path.dirname(newPath), { recursive: true });
175
+ // Rename whatever main stylesheet actually exists (preserving its content), not an assumed
176
+ // name, so we never blow away the user's styles when the on-disk extension differs.
177
+ const existing = await findMainStylesheet(clientDir);
178
+ if (existing && path.resolve(existing) !== path.resolve(newPath)) {
179
+ await fs.rename(existing, newPath);
180
+ } else if (!existing) {
181
+ await fs.writeFile(newPath, '', 'utf8');
182
+ }
183
+ }
184
+
185
+ const tailwindPath = path.join(clientDir, TAILWIND_ENTRY);
186
+ if (to.tailwind && !from.tailwind) {
187
+ await fs.mkdir(path.dirname(tailwindPath), { recursive: true });
188
+ await fs.writeFile(tailwindPath, TAILWIND_CSS, 'utf8');
189
+ } else if (!to.tailwind && from.tailwind) {
190
+ await fs.rm(tailwindPath, { force: true });
191
+ }
192
+
193
+ for (const entry of ['toil.tsx', 'toil.jsx']) {
194
+ const entryPath = path.join(clientDir, entry);
195
+ try {
196
+ const source = await fs.readFile(entryPath, 'utf8');
197
+ await fs.writeFile(entryPath, setStyleImports(source, to), 'utf8');
198
+ return;
199
+ } catch {}
200
+ }
201
+ }
202
+
203
+ /**
204
+ * Applies a styling change to a project on disk (no prompts): rewrites stylesheets + the app
205
+ * entry's imports and edits `package.json`. Exposed for testing and reuse; the package manager is
206
+ * run separately by {@link runConfigure}.
207
+ */
208
+ export async function applyConfigure(
209
+ clientDir: string,
210
+ pkgPath: string,
211
+ pkg: PackageJson,
212
+ from: StyleFeatures,
213
+ to: StyleFeatures,
214
+ ): Promise<void> {
215
+ await applyStyleFiles(clientDir, from, to);
216
+ await applyPackages(pkgPath, pkg, from, to);
217
+ }
218
+
219
+ /** Adds/removes the managed styling packages in `package.json` (sorted devDependencies). */
220
+ async function applyPackages(
221
+ pkgPath: string,
222
+ pkg: PackageJson,
223
+ from: StyleFeatures,
224
+ to: StyleFeatures,
225
+ ): Promise<void> {
226
+ const { add, remove } = packageDiff(from, to);
227
+ const dev: Record<string, string> = { ...pkg.devDependencies };
228
+ const deps: Record<string, string> = { ...pkg.dependencies };
229
+ for (const name of add) dev[name] = PKG_VERSION[name] ?? 'latest';
230
+ for (const name of remove) {
231
+ delete dev[name];
232
+ delete deps[name];
233
+ }
234
+ const sortedDev = Object.fromEntries(
235
+ Object.entries(dev).sort(([a], [b]) => a.localeCompare(b)),
236
+ );
237
+ const next: PackageJson = { ...pkg, devDependencies: sortedDev };
238
+ // Always reflect the pruned dependencies (or drop the key entirely if now empty), so a removed
239
+ // package can't survive via the original `...pkg` spread.
240
+ if (Object.keys(deps).length) next.dependencies = deps;
241
+ else delete next.dependencies;
242
+ await fs.writeFile(pkgPath, JSON.stringify(next, null, 4) + '\n', 'utf8');
243
+ }
244
+
245
+ /** Human-readable summary of what changed. */
246
+ function describe(from: StyleFeatures, to: StyleFeatures): string {
247
+ const lines: string[] = [];
248
+ if (from.preprocessor !== to.preprocessor) {
249
+ lines.push(
250
+ `preprocessor: ${PREPROCESSOR_LABEL[from.preprocessor]} → ${PREPROCESSOR_LABEL[to.preprocessor]}`,
251
+ );
252
+ }
253
+ if (from.tailwind !== to.tailwind) {
254
+ lines.push(`Tailwind: ${from.tailwind ? 'on' : 'off'} → ${to.tailwind ? 'on' : 'off'}`);
255
+ }
256
+ const { add, remove } = packageDiff(from, to);
257
+ if (add.length) lines.push(`+ ${add.join(', ')}`);
258
+ if (remove.length) lines.push(`- ${remove.join(', ')}`);
259
+ return lines.map((l) => dim(' ') + l).join('\n');
260
+ }
261
+
262
+ /** Runs the interactive configure flow. */
263
+ export async function runConfigure(opts: ConfigureOptions): Promise<void> {
264
+ intro(accent(' toiljs configure '));
265
+ const root = path.resolve(opts.root ?? opts.cwd);
266
+
267
+ const pkgPath = path.join(root, 'package.json');
268
+ let pkg: PackageJson;
269
+ try {
270
+ pkg = JSON.parse(await fs.readFile(pkgPath, 'utf8')) as PackageJson;
271
+ } catch {
272
+ cancel(`No package.json in ${pc.cyan(root)}, run this inside a toiljs project.`);
273
+ process.exit(1);
274
+ }
275
+
276
+ const clientAbsDir = await resolveClientDir(root);
277
+ const deps = { ...pkg.dependencies, ...pkg.devDependencies };
278
+ const current: StyleFeatures = {
279
+ preprocessor: (await detectStylesheet(clientAbsDir)) ?? detectPreprocessor(deps),
280
+ tailwind: detectTailwind(deps),
281
+ };
282
+
283
+ const currentImages = await resolveImages(root);
284
+
285
+ const nonInteractive =
286
+ opts.preprocessor !== undefined || opts.tailwind !== undefined || opts.images !== undefined;
287
+ let target: StyleFeatures;
288
+ let targetImages: boolean;
289
+ if (nonInteractive) {
290
+ target = {
291
+ preprocessor: opts.preprocessor ?? current.preprocessor,
292
+ tailwind: opts.tailwind ?? current.tailwind,
293
+ };
294
+ targetImages = opts.images ?? currentImages;
295
+ } else {
296
+ const ppChoice = await select<Preprocessor>({
297
+ message: 'CSS preprocessor',
298
+ options: PREPROCESSORS.map((value) => ({ value, label: PREPROCESSOR_LABEL[value] })),
299
+ initialValue: current.preprocessor,
300
+ });
301
+ bail(ppChoice);
302
+ const twChoice = await confirm({
303
+ message: 'Use Tailwind CSS?',
304
+ initialValue: current.tailwind,
305
+ });
306
+ bail(twChoice);
307
+ const imChoice = await confirm({
308
+ message: 'Optimize images at build time?',
309
+ initialValue: currentImages,
310
+ });
311
+ bail(imChoice);
312
+ target = { preprocessor: ppChoice, tailwind: twChoice };
313
+ targetImages = imChoice;
314
+ }
315
+
316
+ const styleChanged =
317
+ target.preprocessor !== current.preprocessor || target.tailwind !== current.tailwind;
318
+ const imagesChanged = targetImages !== currentImages;
319
+ if (!styleChanged && !imagesChanged) {
320
+ outro('No changes, your setup is already up to date.');
321
+ return;
322
+ }
323
+
324
+ const s = spinner();
325
+ s.start('Updating project files');
326
+ if (styleChanged) await applyConfigure(clientAbsDir, pkgPath, pkg, current, target);
327
+ let imagesWarning = '';
328
+ if (imagesChanged && !(await writeImagesFlag(root, targetImages))) {
329
+ imagesWarning = pc.yellow(
330
+ ' Could not edit toil.config automatically, set `client.images` by hand.',
331
+ );
332
+ }
333
+ s.stop('Updated project files');
334
+
335
+ if (styleChanged) {
336
+ const pm = await detectPackageManager(root);
337
+ if (opts.install === false) {
338
+ note(`${pc.cyan(`${pm} install`)} to sync the dependency changes.`, 'Next step');
339
+ } else {
340
+ const i = spinner();
341
+ i.start(`Syncing dependencies with ${pm}`);
342
+ try {
343
+ await run(pm, ['install'], root);
344
+ i.stop('Dependencies synced');
345
+ } catch {
346
+ i.stop(pc.yellow(`Could not run \`${pm} install\`, run it yourself to finish`));
347
+ }
348
+ }
349
+ }
350
+
351
+ const summary = [
352
+ styleChanged ? describe(current, target) : '',
353
+ imagesChanged
354
+ ? dim(' ') +
355
+ `image optimization: ${currentImages ? 'on' : 'off'} → ${targetImages ? 'on' : 'off'}`
356
+ : '',
357
+ imagesWarning,
358
+ ]
359
+ .filter(Boolean)
360
+ .join('\n');
361
+ note(summary, 'Updated');
362
+ outro(`Reconfigured, restart \`${accent('toiljs dev')}\` to pick up the changes.`);
363
+ }