toiljs 0.0.7 → 0.0.9

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 (135) hide show
  1. package/build/backend/.tsbuildinfo +1 -1
  2. package/build/cli/.tsbuildinfo +1 -1
  3. package/build/cli/configure.d.ts +1 -0
  4. package/build/cli/configure.js +85 -20
  5. package/build/cli/create.d.ts +1 -0
  6. package/build/cli/create.js +18 -7
  7. package/build/cli/features.d.ts +2 -0
  8. package/build/cli/features.js +22 -0
  9. package/build/cli/index.js +8 -0
  10. package/build/client/.tsbuildinfo +1 -1
  11. package/build/client/components/Form.d.ts +12 -0
  12. package/build/client/components/Form.js +23 -0
  13. package/build/client/components/Image.d.ts +13 -0
  14. package/build/client/components/Image.js +22 -0
  15. package/build/client/components/Script.d.ts +13 -0
  16. package/build/client/components/Script.js +68 -0
  17. package/build/client/components/Slot.d.ts +6 -0
  18. package/build/client/components/Slot.js +6 -0
  19. package/build/client/dev/error-overlay.d.ts +20 -0
  20. package/build/client/dev/error-overlay.js +123 -0
  21. package/build/client/head/head.d.ts +2 -0
  22. package/build/client/head/head.js +17 -2
  23. package/build/client/head/metadata.d.ts +29 -0
  24. package/build/client/head/metadata.js +38 -0
  25. package/build/client/index.d.ts +15 -3
  26. package/build/client/index.js +8 -2
  27. package/build/client/navigation/navigation.d.ts +3 -0
  28. package/build/client/navigation/navigation.js +42 -1
  29. package/build/client/routing/Router.d.ts +1 -0
  30. package/build/client/routing/Router.js +56 -34
  31. package/build/client/routing/action.d.ts +17 -0
  32. package/build/client/routing/action.js +55 -0
  33. package/build/client/routing/hooks.d.ts +1 -0
  34. package/build/client/routing/hooks.js +6 -7
  35. package/build/client/routing/loader.d.ts +10 -2
  36. package/build/client/routing/loader.js +83 -24
  37. package/build/client/routing/mount.d.ts +1 -1
  38. package/build/client/routing/mount.js +12 -4
  39. package/build/client/routing/slot-context.d.ts +2 -0
  40. package/build/client/routing/slot-context.js +2 -0
  41. package/build/client/types.d.ts +1 -0
  42. package/build/compiler/.tsbuildinfo +1 -1
  43. package/build/compiler/config.d.ts +10 -0
  44. package/build/compiler/config.js +5 -1
  45. package/build/compiler/docs.js +26 -26
  46. package/build/compiler/fonts.d.ts +4 -0
  47. package/build/compiler/fonts.js +64 -0
  48. package/build/compiler/generate.js +67 -32
  49. package/build/compiler/image-report.d.ts +2 -0
  50. package/build/compiler/image-report.js +62 -0
  51. package/build/compiler/plugin.js +1 -1
  52. package/build/compiler/prerender.d.ts +7 -0
  53. package/build/compiler/prerender.js +111 -0
  54. package/build/compiler/routes.d.ts +3 -0
  55. package/build/compiler/routes.js +50 -5
  56. package/build/compiler/seo.d.ts +70 -0
  57. package/build/compiler/seo.js +221 -0
  58. package/build/compiler/vite.js +13 -1
  59. package/build/io/.tsbuildinfo +1 -1
  60. package/build/shared/.tsbuildinfo +1 -1
  61. package/examples/basic/client/404.tsx +1 -1
  62. package/examples/basic/client/components/Header.tsx +38 -0
  63. package/examples/basic/client/components/HoneycombBackground.tsx +86 -18
  64. package/examples/basic/client/global-error.tsx +3 -3
  65. package/examples/basic/client/layout.tsx +2 -33
  66. package/examples/basic/client/public/images/test_image.webp +0 -0
  67. package/examples/basic/client/routes/about.tsx +8 -0
  68. package/examples/basic/client/routes/get-started.tsx +1 -1
  69. package/examples/basic/client/routes/index.tsx +8 -1
  70. package/examples/basic/client/routes/io.tsx +1 -1
  71. package/examples/basic/client/routes/loader-demo/index.tsx +29 -1
  72. package/examples/basic/client/routes/test.tsx +8 -0
  73. package/examples/basic/client/styles/main.css +48 -1
  74. package/package.json +8 -6
  75. package/presets/eslint.js +7 -4
  76. package/presets/tsconfig.json +1 -1
  77. package/src/backend/index.ts +1 -1
  78. package/src/cli/configure.ts +102 -21
  79. package/src/cli/create.ts +25 -9
  80. package/src/cli/features.ts +33 -1
  81. package/src/cli/index.ts +10 -1
  82. package/src/cli/ui.ts +1 -1
  83. package/src/cli/validate.ts +1 -1
  84. package/src/client/components/Form.tsx +65 -0
  85. package/src/client/components/Image.tsx +89 -0
  86. package/src/client/components/Script.tsx +113 -0
  87. package/src/client/components/Slot.tsx +21 -0
  88. package/src/client/dev/error-overlay.tsx +197 -0
  89. package/src/client/head/head.ts +28 -3
  90. package/src/client/head/metadata.ts +92 -0
  91. package/src/client/index.ts +20 -3
  92. package/src/client/navigation/Link.tsx +1 -1
  93. package/src/client/navigation/navigation.ts +74 -4
  94. package/src/client/navigation/prefetch.ts +2 -2
  95. package/src/client/routing/Router.tsx +128 -62
  96. package/src/client/routing/action.ts +122 -0
  97. package/src/client/routing/error-boundary.tsx +1 -1
  98. package/src/client/routing/hooks.ts +17 -23
  99. package/src/client/routing/loader.ts +158 -35
  100. package/src/client/routing/mount.tsx +25 -3
  101. package/src/client/routing/slot-context.ts +7 -0
  102. package/src/client/types.ts +6 -4
  103. package/src/compiler/config.ts +40 -3
  104. package/src/compiler/docs.ts +26 -26
  105. package/src/compiler/fonts.ts +87 -0
  106. package/src/compiler/generate.ts +69 -31
  107. package/src/compiler/image-report.ts +85 -0
  108. package/src/compiler/plugin.ts +2 -2
  109. package/src/compiler/prerender.ts +130 -0
  110. package/src/compiler/routes.ts +62 -7
  111. package/src/compiler/seo.ts +356 -0
  112. package/src/compiler/vite.ts +21 -4
  113. package/src/io/FastSet.ts +1 -1
  114. package/src/io/index.ts +1 -1
  115. package/src/io/types.ts +1 -1
  116. package/src/server/index.ts +1 -1
  117. package/src/server/main.ts +1 -1
  118. package/src/shared/index.ts +1 -1
  119. package/test/dom/Image.test.tsx +46 -0
  120. package/test/dom/Script.test.tsx +45 -0
  121. package/test/dom/action.test.tsx +129 -0
  122. package/test/dom/error-overlay.test.tsx +44 -0
  123. package/test/dom/loader.test.tsx +121 -0
  124. package/test/dom/revalidate.test.tsx +38 -0
  125. package/test/dom/route-head.test.tsx +34 -0
  126. package/test/dom/router-loading.test.tsx +44 -0
  127. package/test/dom/slot.test.tsx +109 -0
  128. package/test/dom/view-transitions.test.tsx +51 -0
  129. package/test/features.test.ts +31 -0
  130. package/test/fonts.test.ts +26 -0
  131. package/test/metadata.test.ts +41 -0
  132. package/test/prerender.test.ts +46 -0
  133. package/test/routes.test.ts +20 -1
  134. package/test/seo.test.ts +142 -0
  135. package/examples/basic/client/template.tsx +0 -7
@@ -1,5 +1,5 @@
1
1
  /**
2
- * `toiljs configure` toggle a project's client styling features (CSS preprocessor + Tailwind) on
2
+ * `toiljs configure`, toggle a project's client styling features (CSS preprocessor + Tailwind) on
3
3
  * an existing app. Detects the current setup, prompts for the desired one, then rewrites the
4
4
  * stylesheet(s) + the `client/toil.tsx` imports, edits `package.json`, and syncs node_modules with
5
5
  * the project's package manager (so removed features are fully cleaned, not just disabled).
@@ -16,10 +16,12 @@ import {
16
16
  PREPROCESSORS,
17
17
  TAILWIND_CSS,
18
18
  TAILWIND_ENTRY,
19
+ defaultConfigSource,
19
20
  detectPreprocessor,
20
21
  detectTailwind,
21
22
  packageDiff,
22
23
  preprocessorForExt,
24
+ setConfigImages,
23
25
  setStyleImports,
24
26
  styleEntry,
25
27
  type Preprocessor,
@@ -34,10 +36,60 @@ export interface ConfigureOptions {
34
36
  /** When set, the corresponding prompt is skipped (non-interactive). */
35
37
  readonly preprocessor?: Preprocessor;
36
38
  readonly tailwind?: boolean;
39
+ /** Toggle build-time image optimization. When set, the prompt is skipped. */
40
+ readonly images?: boolean;
37
41
  /** Run the package manager to sync deps. Default `true`; `false` edits files only. */
38
42
  readonly install?: boolean;
39
43
  }
40
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
+
41
93
  /** Resolves the client source dir, falling back to `<root>/client` if the config can't be loaded. */
42
94
  async function resolveClientDir(root: string): Promise<string> {
43
95
  try {
@@ -121,7 +173,7 @@ async function applyStyleFiles(
121
173
  const newPath = path.join(clientDir, styleEntry(to.preprocessor));
122
174
  await fs.mkdir(path.dirname(newPath), { recursive: true });
123
175
  // Rename whatever main stylesheet actually exists (preserving its content), not an assumed
124
- // name so we never blow away the user's styles when the on-disk extension differs.
176
+ // name, so we never blow away the user's styles when the on-disk extension differs.
125
177
  const existing = await findMainStylesheet(clientDir);
126
178
  if (existing && path.resolve(existing) !== path.resolve(newPath)) {
127
179
  await fs.rename(existing, newPath);
@@ -213,7 +265,7 @@ export async function runConfigure(opts: ConfigureOptions): Promise<void> {
213
265
  try {
214
266
  pkg = JSON.parse(await fs.readFile(pkgPath, 'utf8')) as PackageJson;
215
267
  } catch {
216
- cancel(`No package.json in ${pc.cyan(root)} run this inside a toiljs project.`);
268
+ cancel(`No package.json in ${pc.cyan(root)}, run this inside a toiljs project.`);
217
269
  process.exit(1);
218
270
  }
219
271
 
@@ -224,13 +276,18 @@ export async function runConfigure(opts: ConfigureOptions): Promise<void> {
224
276
  tailwind: detectTailwind(deps),
225
277
  };
226
278
 
227
- const nonInteractive = opts.preprocessor !== undefined || opts.tailwind !== undefined;
279
+ const currentImages = await resolveImages(root);
280
+
281
+ const nonInteractive =
282
+ opts.preprocessor !== undefined || opts.tailwind !== undefined || opts.images !== undefined;
228
283
  let target: StyleFeatures;
284
+ let targetImages: boolean;
229
285
  if (nonInteractive) {
230
286
  target = {
231
287
  preprocessor: opts.preprocessor ?? current.preprocessor,
232
288
  tailwind: opts.tailwind ?? current.tailwind,
233
289
  };
290
+ targetImages = opts.images ?? currentImages;
234
291
  } else {
235
292
  const ppChoice = await select<Preprocessor>({
236
293
  message: 'CSS preprocessor',
@@ -240,33 +297,57 @@ export async function runConfigure(opts: ConfigureOptions): Promise<void> {
240
297
  bail(ppChoice);
241
298
  const twChoice = await confirm({ message: 'Use Tailwind CSS?', initialValue: current.tailwind });
242
299
  bail(twChoice);
300
+ const imChoice = await confirm({
301
+ message: 'Optimize images at build time?',
302
+ initialValue: currentImages,
303
+ });
304
+ bail(imChoice);
243
305
  target = { preprocessor: ppChoice, tailwind: twChoice };
306
+ targetImages = imChoice;
244
307
  }
245
308
 
246
- if (target.preprocessor === current.preprocessor && target.tailwind === current.tailwind) {
247
- outro('No changes your styling setup is already up to date.');
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.');
248
314
  return;
249
315
  }
250
316
 
251
317
  const s = spinner();
252
318
  s.start('Updating project files');
253
- await applyConfigure(clientAbsDir, pkgPath, pkg, current, target);
254
- s.stop('Updated stylesheets, entry imports, and package.json');
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');
255
327
 
256
- const pm = await detectPackageManager(root);
257
- if (opts.install === false) {
258
- note(`${pc.cyan(`${pm} install`)} to sync the dependency changes.`, 'Next step');
259
- } else {
260
- const i = spinner();
261
- i.start(`Syncing dependencies with ${pm}`);
262
- try {
263
- await run(pm, ['install'], root);
264
- i.stop('Dependencies synced');
265
- } catch {
266
- i.stop(pc.yellow(`Could not run \`${pm} install\` — run it yourself to finish`));
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
+ }
267
341
  }
268
342
  }
269
343
 
270
- note(describe(current, target), 'Styling updated');
271
- outro(`Reconfigured restart \`${accent('toiljs dev')}\` to pick up the changes.`);
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.`);
272
353
  }
package/src/cli/create.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * `toiljs create` an interactive project scaffolder (Clack-powered) that wires a new
2
+ * `toiljs create`, an interactive project scaffolder (Clack-powered) that wires a new
3
3
  * app to the enforced toiljs presets (tsconfig / eslint / prettier) and file-based routing.
4
4
  * Supports a non-interactive path via flags (`--yes`, `--template`, …) for scripting/CI.
5
5
  */
@@ -74,6 +74,8 @@ export interface CreateOptions {
74
74
  readonly tailwind?: boolean;
75
75
  /** AI assistant files to scaffold: `true` = all, `false` = none, omitted = ask. */
76
76
  readonly ai?: boolean;
77
+ /** Enable build-time image optimization. Default `true`; omitted = ask. */
78
+ readonly images?: boolean;
77
79
  readonly install?: boolean;
78
80
  readonly git?: boolean;
79
81
  readonly pm?: string;
@@ -104,6 +106,7 @@ function scaffold(
104
106
  template: Template,
105
107
  features: StyleFeatures,
106
108
  aiTools: readonly string[],
109
+ images: boolean,
107
110
  ): Record<string, string> {
108
111
  const toilVersion = version();
109
112
  const devDependencies: Record<string, string> = {
@@ -142,7 +145,12 @@ function scaffold(
142
145
  'package.json': JSON.stringify(pkg, null, 4) + '\n',
143
146
  'toil.config.ts':
144
147
  "import { defineConfig } from 'toiljs/compiler';\n\n" +
145
- 'export default defineConfig({});\n',
148
+ 'export default defineConfig({\n' +
149
+ ' client: {\n' +
150
+ ' // Optimize images at build time (resize/compress imported images).\n' +
151
+ ` images: ${String(images)},\n` +
152
+ ' },\n' +
153
+ '});\n',
146
154
  'tsconfig.json':
147
155
  '{\n "extends": "toiljs/tsconfig",\n "include": ["client", "toil-env.d.ts", "toil-routes.d.ts"]\n}\n',
148
156
  'eslint.config.js': "import toiljs from 'toiljs/eslint';\n\nexport default toiljs;\n",
@@ -153,7 +161,7 @@ function scaffold(
153
161
  JSON.stringify({ 'typescript.tsdk': 'node_modules/typescript/lib' }, null, 4) + '\n',
154
162
  'toil-env.d.ts': TOIL_ENV_DTS,
155
163
  // Stub typed-routes augmentation (RoutePath = string until the first dev/build regenerates it).
156
- 'toil-routes.d.ts': '// AUTO-GENERATED by toil do not edit.\nexport {};\n',
164
+ 'toil-routes.d.ts': '// AUTO-GENERATED by toil, do not edit.\nexport {};\n',
157
165
  'toilconfig.json':
158
166
  JSON.stringify(
159
167
  {
@@ -304,7 +312,7 @@ export default function Layout({ children }: { children?: ReactNode }) {
304
312
 
305
313
  /**
306
314
  * Absolute path to the `app` starter client UI. There is a single source: `examples/basic/client`
307
- * (shipped in the package) the runnable example IS the create template, so there's nothing to
315
+ * (shipped in the package), the runnable example IS the create template, so there's nothing to
308
316
  * keep in sync.
309
317
  */
310
318
  function appClientDir(): string {
@@ -323,7 +331,7 @@ function appClientDir(): string {
323
331
  * preprocessor's extension, adds the Tailwind entry, and rewrites `toil.tsx`'s style imports.
324
332
  */
325
333
  async function applyStyling(clientDir: string, features: StyleFeatures): Promise<void> {
326
- // Plain CSS without Tailwind is exactly what the template ships leave it byte-for-byte.
334
+ // Plain CSS without Tailwind is exactly what the template ships, leave it byte-for-byte.
327
335
  if (features.preprocessor === 'css' && !features.tailwind) return;
328
336
  const entry = styleEntry(features.preprocessor);
329
337
  if (entry !== 'styles/main.css') {
@@ -398,7 +406,7 @@ export async function runCreate(opts: CreateOptions): Promise<void> {
398
406
  let template: Template = opts.template ?? 'app';
399
407
  if (!opts.template && !opts.yes) {
400
408
  const templateOptions: TemplateOption[] = [
401
- { value: 'app', label: 'App', hint: 'the full ToilJS starter landing page, layout, styles, demo routes' },
409
+ { value: 'app', label: 'App', hint: 'the full ToilJS starter, landing page, layout, styles, demo routes' },
402
410
  { value: 'minimal', label: 'Minimal', hint: 'just a layout and a home route' },
403
411
  ];
404
412
  const choice = await select({ message: 'Which template?', options: templateOptions, initialValue: 'app' });
@@ -443,6 +451,14 @@ export async function runCreate(opts: CreateOptions): Promise<void> {
443
451
  aiTools = picked.includes('none') ? [] : picked;
444
452
  }
445
453
 
454
+ // Build-time image optimization: on by default (just press enter to keep it).
455
+ let images = opts.images ?? true;
456
+ if (opts.images === undefined && !opts.yes) {
457
+ const im = await confirm({ message: 'Optimize images at build time?', initialValue: true });
458
+ bail(im);
459
+ images = im;
460
+ }
461
+
446
462
  let initGit = opts.git ?? false;
447
463
  let install = opts.install ?? false;
448
464
  const pm = opts.pm ?? 'npm';
@@ -465,7 +481,7 @@ export async function runCreate(opts: CreateOptions): Promise<void> {
465
481
 
466
482
  const s = spinner();
467
483
  s.start('Scaffolding project');
468
- await writeFiles(targetDir, scaffold(name, template, features, aiTools));
484
+ await writeFiles(targetDir, scaffold(name, template, features, aiTools, images));
469
485
  if (template === 'app') {
470
486
  // Copy the example client (the single starter source), set its <title>, then apply styling.
471
487
  await fs.cp(appClientDir(), path.join(targetDir, 'client'), { recursive: true });
@@ -498,7 +514,7 @@ export async function runCreate(opts: CreateOptions): Promise<void> {
498
514
  await run(pm, ['install'], targetDir);
499
515
  i.stop('Installed dependencies');
500
516
  } catch {
501
- i.stop(pc.yellow(`Could not install with ${pm} run it yourself later`));
517
+ i.stop(pc.yellow(`Could not install with ${pm}, run it yourself later`));
502
518
  install = false;
503
519
  }
504
520
  }
@@ -510,5 +526,5 @@ export async function runCreate(opts: CreateOptions): Promise<void> {
510
526
  steps.push(`${accent('npm run build')} ${dim('build for production')}`);
511
527
  note(steps.map((l) => dim(' ') + l).join('\n'), 'Next steps');
512
528
 
513
- outro(`Created ${accent(path.basename(name))} happy building! ${dim('· v' + version())}`);
529
+ outro(`Created ${accent(path.basename(name))}, happy building! ${dim('· v' + version())}`);
514
530
  }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Pure description of toiljs's optional client styling features a CSS preprocessor and Tailwind
2
+ * Pure description of toiljs's optional client styling features, a CSS preprocessor and Tailwind ,
3
3
  * shared by `create` (scaffold) and `configure` (toggle on existing projects). Dependency-light
4
4
  * (no node IO) so it can be unit-tested; the file writes and package-manager calls live in the
5
5
  * commands. Preprocessor and Tailwind are independent: Tailwind lives in its own `.css` entry so
@@ -114,6 +114,38 @@ export function setStyleImports(source: string, f: StyleFeatures): string {
114
114
  return `${head}\n\n${block}\n${tail}`.replace(/\n{3,}/g, '\n\n');
115
115
  }
116
116
 
117
+ /** A `toil.config` source containing `client: { images: <bool> }` (for scaffolding when none exists). */
118
+ export function defaultConfigSource(images: boolean): string {
119
+ return (
120
+ "import { defineConfig } from 'toiljs/compiler';\n\n" +
121
+ 'export default defineConfig({\n' +
122
+ ' client: {\n' +
123
+ ' // Optimize images at build time (resize/compress imported images).\n' +
124
+ ` images: ${String(images)},\n` +
125
+ ' },\n' +
126
+ '});\n'
127
+ );
128
+ }
129
+
130
+ /**
131
+ * Sets the `client.images` flag in a `toil.config` source, returning the updated source, or `null`
132
+ * if the file's shape isn't recognized (the caller should then fall back to a manual note). Handles
133
+ * an existing `images:` value, an existing `client: {` block, or a bare `defineConfig({ … })`.
134
+ */
135
+ export function setConfigImages(source: string, enabled: boolean): string | null {
136
+ const value = String(enabled);
137
+ if (/\bimages\s*:\s*(?:true|false)/.test(source)) {
138
+ return source.replace(/\bimages\s*:\s*(?:true|false)/, `images: ${value}`);
139
+ }
140
+ if (/\bclient\s*:\s*\{/.test(source)) {
141
+ return source.replace(/\bclient\s*:\s*\{/, `client: {\n images: ${value},`);
142
+ }
143
+ if (/defineConfig\(\s*\{/.test(source)) {
144
+ return source.replace(/defineConfig\(\s*\{/, `defineConfig({\n client: { images: ${value} },`);
145
+ }
146
+ return null;
147
+ }
148
+
117
149
  /** Detects the active preprocessor from a project's combined dependency map. */
118
150
  export function detectPreprocessor(deps: Record<string, string>): Preprocessor {
119
151
  if ('sass' in deps) return 'sass';
package/src/cli/index.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  /**
3
3
  * toiljs CLI. Routes `create` / `dev` / `build` and wraps them in the toiljs brand banner.
4
4
  * The compiler stays presentation-free (imported via the package's own `toiljs/compiler`
5
- * export); the epic bits banner, the Clack scaffolding wizard live here.
5
+ * export); the epic bits, banner, the Clack scaffolding wizard, live here.
6
6
  */
7
7
  import { build, dev, start } from 'toiljs/compiler';
8
8
 
@@ -19,6 +19,7 @@ interface Flags {
19
19
  preprocessor?: Preprocessor;
20
20
  tailwind?: boolean;
21
21
  ai?: boolean;
22
+ images?: boolean;
22
23
  install?: boolean;
23
24
  git?: boolean;
24
25
  pm?: string;
@@ -64,6 +65,12 @@ function parseArgs(argv: string[]): Flags {
64
65
  case '--no-ai':
65
66
  flags.ai = false;
66
67
  break;
68
+ case '--images':
69
+ flags.images = true;
70
+ break;
71
+ case '--no-images':
72
+ flags.images = false;
73
+ break;
67
74
  case '--install':
68
75
  flags.install = true;
69
76
  break;
@@ -134,6 +141,7 @@ async function main(): Promise<void> {
134
141
  preprocessor: flags.preprocessor,
135
142
  tailwind: flags.tailwind,
136
143
  ai: flags.ai,
144
+ images: flags.images,
137
145
  install: flags.install,
138
146
  git: flags.git,
139
147
  pm: flags.pm,
@@ -148,6 +156,7 @@ async function main(): Promise<void> {
148
156
  root: flags.root,
149
157
  preprocessor: flags.preprocessor,
150
158
  tailwind: flags.tailwind,
159
+ images: flags.images,
151
160
  install: flags.install,
152
161
  cwd: process.cwd(),
153
162
  });
package/src/cli/ui.ts CHANGED
@@ -54,7 +54,7 @@ export function success(s: string): string {
54
54
  return rgb(ACCENT, s);
55
55
  }
56
56
 
57
- /** Error accent (red kept outside the brand palette since errors should read as errors). */
57
+ /** Error accent (red, kept outside the brand palette since errors should read as errors). */
58
58
  export const danger = pc.red;
59
59
 
60
60
  function lerp(a: number, b: number, t: number): number {
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Pure input validation for `toiljs create` kept dependency-light (only node:path) so it can be
2
+ * Pure input validation for `toiljs create`, kept dependency-light (only node:path) so it can be
3
3
  * unit-tested without pulling in the rest of the CLI.
4
4
  */
5
5
  import path from 'node:path';
@@ -0,0 +1,65 @@
1
+ import { useRef, type ReactNode, type SyntheticEvent } from 'react';
2
+
3
+ import { useAction, type ActionState, type RevalidateTarget } from '../routing/action.js';
4
+
5
+ /** Props for {@link Form}. */
6
+ export interface FormProps {
7
+ /** Handles the submission, receiving the form's `FormData`. May be async. */
8
+ action: (data: FormData) => void | Promise<void>;
9
+ /** Loader data to revalidate after a successful submit. Default `true` (the current route). */
10
+ revalidate?: RevalidateTarget;
11
+ /** Called after a successful submit. */
12
+ onSuccess?: () => void;
13
+ /** Called when the action throws. */
14
+ onError?: (error: unknown) => void;
15
+ /** Reset the form fields after a successful submit. Default `false`. */
16
+ resetOnSuccess?: boolean;
17
+ className?: string;
18
+ /**
19
+ * Form contents. Pass a render function to receive live submit state, e.g. to disable the
20
+ * button while pending: `{({ pending }) => <button disabled={pending}>Save</button>}`.
21
+ */
22
+ children?: ReactNode | ((state: ActionState<void>) => ReactNode);
23
+ }
24
+
25
+ /**
26
+ * A `<form>` that runs an {@link useAction} on submit (no page reload) and revalidates loader data
27
+ * on success, the write half of the loader/action data loop. Tracks pending/error state, which a
28
+ * render-function child can read.
29
+ */
30
+ export function Form({
31
+ action,
32
+ revalidate,
33
+ onSuccess,
34
+ onError,
35
+ resetOnSuccess = false,
36
+ className,
37
+ children,
38
+ }: FormProps): ReactNode {
39
+ const formRef = useRef<HTMLFormElement | null>(null);
40
+ const handle = useAction((data: FormData) => action(data), {
41
+ revalidate,
42
+ onError,
43
+ onSuccess: () => {
44
+ if (resetOnSuccess) formRef.current?.reset();
45
+ onSuccess?.();
46
+ },
47
+ });
48
+
49
+ const onSubmit = (event: SyntheticEvent<HTMLFormElement>): void => {
50
+ event.preventDefault();
51
+ formRef.current = event.currentTarget;
52
+ void handle.run(new FormData(event.currentTarget));
53
+ };
54
+
55
+ return (
56
+ <form
57
+ ref={formRef}
58
+ className={className}
59
+ onSubmit={onSubmit}>
60
+ {typeof children === 'function'
61
+ ? children({ pending: handle.pending, error: handle.error, data: handle.data })
62
+ : children}
63
+ </form>
64
+ );
65
+ }
@@ -0,0 +1,89 @@
1
+ import { useState, type CSSProperties, type ComponentPropsWithRef, type ReactNode } from 'react';
2
+
3
+ /**
4
+ * Props for {@link Image}: every standard `<img>` attribute, plus toil's layout/loading controls.
5
+ * `src` and `alt` are required (`alt` is enforced for accessibility, pass `alt=""` for decorative
6
+ * images). `width`/`height` (or `fill`) reserve space to prevent layout shift.
7
+ */
8
+ export interface ImageProps
9
+ extends Omit<ComponentPropsWithRef<'img'>, 'loading' | 'placeholder' | 'width' | 'height'> {
10
+ src: string;
11
+ alt: string;
12
+ /** Intrinsic width in px. Set together with `height` to reserve space (avoids layout shift). */
13
+ width?: number;
14
+ /** Intrinsic height in px. Set together with `width` to reserve space (avoids layout shift). */
15
+ height?: number;
16
+ /**
17
+ * Fill the nearest positioned ancestor (the parent must be `position: relative|absolute|fixed`).
18
+ * The image is absolutely positioned at 100% × 100%; `width`/`height` are ignored. Pair with
19
+ * `objectFit` to control cropping.
20
+ */
21
+ fill?: boolean;
22
+ /** `object-fit` for the rendered image (handy with `fill`). */
23
+ objectFit?: CSSProperties['objectFit'];
24
+ /**
25
+ * Mark this as a high-priority (LCP) image: eager load + `fetchpriority="high"` and no lazy
26
+ * loading. Use for above-the-fold hero images; everything else stays lazy. Default `false`.
27
+ */
28
+ priority?: boolean;
29
+ /** Placeholder shown until the image loads: `'empty'` (default) or `'blur'` (needs `blurDataURL`). */
30
+ placeholder?: 'empty' | 'blur';
31
+ /** A tiny (base64) image shown blurred behind the image while it loads, when `placeholder="blur"`. */
32
+ blurDataURL?: string;
33
+ }
34
+
35
+ /**
36
+ * A drop-in `<img>` replacement that prevents layout shift and lazy-loads by default. It reserves
37
+ * space from `width`/`height` (or fills its container with `fill`), decodes async, lazy-loads unless
38
+ * `priority`, and can fade in from a `blur` placeholder. This is a client-only component, there is
39
+ * no server-side resizing; pass an already-optimized `src` (Vite hashes imported assets for you).
40
+ */
41
+ export function Image(props: ImageProps): ReactNode {
42
+ const {
43
+ src,
44
+ alt,
45
+ width,
46
+ height,
47
+ fill = false,
48
+ objectFit,
49
+ priority = false,
50
+ placeholder = 'empty',
51
+ blurDataURL,
52
+ style,
53
+ onLoad,
54
+ ...rest
55
+ } = props;
56
+
57
+ const [loaded, setLoaded] = useState(false);
58
+ const showBlur = placeholder === 'blur' && blurDataURL !== undefined && !loaded;
59
+
60
+ const layoutStyle: CSSProperties = fill
61
+ ? { position: 'absolute', inset: 0, width: '100%', height: '100%' }
62
+ : {};
63
+ const blurStyle: CSSProperties = showBlur
64
+ ? {
65
+ backgroundImage: `url(${blurDataURL})`,
66
+ backgroundSize: 'cover',
67
+ backgroundPosition: 'center',
68
+ filter: 'blur(20px)',
69
+ }
70
+ : {};
71
+
72
+ return (
73
+ <img
74
+ {...rest}
75
+ src={src}
76
+ alt={alt}
77
+ width={fill ? undefined : width}
78
+ height={fill ? undefined : height}
79
+ loading={priority ? 'eager' : 'lazy'}
80
+ decoding="async"
81
+ fetchPriority={priority ? 'high' : 'auto'}
82
+ onLoad={(event) => {
83
+ setLoaded(true);
84
+ onLoad?.(event);
85
+ }}
86
+ style={{ ...layoutStyle, objectFit, ...blurStyle, ...style }}
87
+ />
88
+ );
89
+ }