toiljs 0.0.10 → 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 (128) hide show
  1. package/README.md +315 -1
  2. package/assets/logo.svg +37 -0
  3. package/build/cli/.tsbuildinfo +1 -1
  4. package/build/cli/configure.js +10 -4
  5. package/build/cli/create.js +60 -32
  6. package/build/cli/diagnostics.d.ts +55 -0
  7. package/build/cli/diagnostics.js +333 -0
  8. package/build/cli/doctor.d.ts +6 -0
  9. package/build/cli/doctor.js +249 -0
  10. package/build/cli/index.js +26 -0
  11. package/build/cli/proc.d.ts +5 -0
  12. package/build/cli/proc.js +20 -0
  13. package/build/cli/ui.d.ts +1 -0
  14. package/build/cli/ui.js +1 -0
  15. package/build/cli/update.d.ts +7 -0
  16. package/build/cli/update.js +117 -0
  17. package/build/cli/updates.d.ts +10 -0
  18. package/build/cli/updates.js +45 -0
  19. package/build/client/.tsbuildinfo +1 -1
  20. package/build/client/dev/error-overlay.js +1 -1
  21. package/build/client/head/metadata.js +3 -1
  22. package/build/client/index.d.ts +5 -1
  23. package/build/client/index.js +2 -0
  24. package/build/client/navigation/navigation.js +1 -1
  25. package/build/client/routing/Router.js +2 -2
  26. package/build/client/search/search.d.ts +26 -0
  27. package/build/client/search/search.js +101 -0
  28. package/build/client/search/use-page-search.d.ts +8 -0
  29. package/build/client/search/use-page-search.js +21 -0
  30. package/build/compiler/.tsbuildinfo +1 -1
  31. package/build/compiler/generate.js +35 -26
  32. package/build/compiler/index.d.ts +2 -0
  33. package/build/compiler/index.js +1 -0
  34. package/build/compiler/pages.d.ts +8 -0
  35. package/build/compiler/pages.js +37 -0
  36. package/build/compiler/plugin.js +3 -1
  37. package/build/compiler/prerender.d.ts +1 -0
  38. package/build/compiler/prerender.js +11 -5
  39. package/build/compiler/seo.js +10 -3
  40. package/build/compiler/vite.js +7 -0
  41. package/build/io/.tsbuildinfo +1 -1
  42. package/examples/basic/client/components/Header.tsx +43 -38
  43. package/examples/basic/client/components/HoneycombBackground.tsx +223 -230
  44. package/examples/basic/client/layout.tsx +4 -1
  45. package/examples/basic/client/public/index.html +18 -16
  46. package/examples/basic/client/routes/(legal)/privacy.tsx +18 -0
  47. package/examples/basic/client/routes/(legal)/terms.tsx +15 -0
  48. package/examples/basic/client/routes/about.tsx +21 -19
  49. package/examples/basic/client/routes/blog/[id].tsx +26 -12
  50. package/examples/basic/client/routes/features/actions.tsx +67 -0
  51. package/examples/basic/client/routes/features/error/error.tsx +16 -0
  52. package/examples/basic/client/routes/features/error/index.tsx +27 -0
  53. package/examples/basic/client/routes/features/head.tsx +38 -0
  54. package/examples/basic/client/routes/features/index.tsx +83 -0
  55. package/examples/basic/client/routes/features/realtime.tsx +34 -0
  56. package/examples/basic/client/routes/features/script.tsx +31 -0
  57. package/examples/basic/client/routes/features/seo.tsx +39 -0
  58. package/examples/basic/client/routes/features/template/b.tsx +14 -0
  59. package/examples/basic/client/routes/features/template/index.tsx +20 -0
  60. package/examples/basic/client/routes/features/template/template.tsx +16 -0
  61. package/examples/basic/client/routes/files/[[...slug]].tsx +21 -0
  62. package/examples/basic/client/routes/gallery/@modal/(.)photo/[id].tsx +23 -0
  63. package/examples/basic/client/routes/gallery/index.tsx +42 -0
  64. package/examples/basic/client/routes/gallery/layout.tsx +13 -0
  65. package/examples/basic/client/routes/gallery/photo/[id].tsx +18 -0
  66. package/examples/basic/client/routes/get-started.tsx +157 -84
  67. package/examples/basic/client/routes/index.tsx +137 -87
  68. package/examples/basic/client/routes/loader-demo/index.tsx +59 -50
  69. package/examples/basic/client/routes/search.tsx +61 -0
  70. package/examples/basic/client/routes/test.tsx +7 -8
  71. package/examples/basic/client/styles/main.css +624 -552
  72. package/examples/basic/client/toil.tsx +2 -4
  73. package/package.json +3 -2
  74. package/presets/eslint.js +10 -3
  75. package/src/cli/configure.ts +363 -353
  76. package/src/cli/create.ts +563 -530
  77. package/src/cli/diagnostics.ts +421 -0
  78. package/src/cli/doctor.ts +318 -0
  79. package/src/cli/features.ts +166 -160
  80. package/src/cli/index.ts +242 -211
  81. package/src/cli/proc.ts +30 -0
  82. package/src/cli/ui.ts +111 -103
  83. package/src/cli/update.ts +150 -0
  84. package/src/cli/updates.ts +69 -0
  85. package/src/client/components/Image.tsx +91 -89
  86. package/src/client/dev/error-overlay.tsx +193 -197
  87. package/src/client/head/metadata.ts +94 -92
  88. package/src/client/index.ts +79 -64
  89. package/src/client/navigation/Link.tsx +94 -100
  90. package/src/client/navigation/navigation.ts +215 -218
  91. package/src/client/routing/Router.tsx +210 -193
  92. package/src/client/routing/hooks.ts +110 -114
  93. package/src/client/routing/lazy.ts +77 -81
  94. package/src/client/search/search.ts +189 -0
  95. package/src/client/search/use-page-search.ts +73 -0
  96. package/src/compiler/config.ts +173 -171
  97. package/src/compiler/fonts.ts +89 -87
  98. package/src/compiler/generate.ts +378 -364
  99. package/src/compiler/image-report.ts +88 -85
  100. package/src/compiler/index.ts +2 -0
  101. package/src/compiler/pages.ts +70 -0
  102. package/src/compiler/plugin.ts +51 -47
  103. package/src/compiler/prerender.ts +152 -130
  104. package/src/compiler/routes.ts +132 -131
  105. package/src/compiler/seo.ts +381 -356
  106. package/src/compiler/vite.ts +155 -130
  107. package/src/io/FastSet.ts +99 -96
  108. package/test/configure.test.ts +94 -90
  109. package/test/doctor.test.ts +140 -0
  110. package/test/dom/Image.test.tsx +73 -46
  111. package/test/dom/Script.test.tsx +48 -45
  112. package/test/dom/action.test.tsx +146 -129
  113. package/test/dom/error-overlay.test.tsx +44 -44
  114. package/test/dom/loader.test.tsx +2 -2
  115. package/test/dom/revalidate.test.tsx +1 -1
  116. package/test/dom/route-head.test.tsx +35 -2
  117. package/test/dom/slot.test.tsx +131 -109
  118. package/test/dom/view-transitions.test.tsx +53 -51
  119. package/test/features.test.ts +149 -142
  120. package/test/fonts.test.ts +28 -26
  121. package/test/head.test.ts +45 -35
  122. package/test/metadata.test.ts +42 -41
  123. package/test/pages.test.ts +105 -0
  124. package/test/prerender.test.ts +54 -46
  125. package/test/search.test.ts +114 -0
  126. package/test/seo.test.ts +164 -142
  127. package/test/slot-layouts.test.ts +69 -0
  128. package/test/update.test.ts +44 -0
@@ -0,0 +1,333 @@
1
+ function parseVersion(v) {
2
+ const m = /(\d+)(?:\.(\d+))?(?:\.(\d+))?/.exec(v);
3
+ if (!m)
4
+ return [0, 0, 0];
5
+ return [Number(m[1]), Number(m[2] ?? 0), Number(m[3] ?? 0)];
6
+ }
7
+ export function satisfiesMin(version, range) {
8
+ const [a, b, c] = parseVersion(version);
9
+ const [x, y, z] = parseVersion(range);
10
+ if (a !== x)
11
+ return a > x;
12
+ if (b !== y)
13
+ return b > y;
14
+ return c >= z;
15
+ }
16
+ export function checkNode(current, requiredRange) {
17
+ const ok = satisfiesMin(current, requiredRange);
18
+ return {
19
+ id: 'node',
20
+ label: 'Node.js',
21
+ status: ok ? 'pass' : 'fail',
22
+ detail: `${current} (requires ${requiredRange})`,
23
+ fix: ok ? undefined : `Upgrade Node to ${requiredRange}.`,
24
+ };
25
+ }
26
+ export function checkPeer(name, installed, range) {
27
+ if (installed === null) {
28
+ return {
29
+ id: `peer:${name}`,
30
+ label: name,
31
+ status: 'fail',
32
+ detail: `not installed (requires ${range})`,
33
+ fix: `Install ${name}@"${range}".`,
34
+ };
35
+ }
36
+ const ok = satisfiesMin(installed, range);
37
+ return {
38
+ id: `peer:${name}`,
39
+ label: name,
40
+ status: ok ? 'pass' : 'warn',
41
+ detail: `${installed} (requires ${range})`,
42
+ fix: ok ? undefined : `Update ${name} to ${range}.`,
43
+ };
44
+ }
45
+ export function checkPackageManager(lockfiles) {
46
+ if (lockfiles.length === 0) {
47
+ return {
48
+ id: 'pm',
49
+ label: 'Package manager',
50
+ status: 'warn',
51
+ detail: 'no lockfile found',
52
+ fix: 'Run an install (npm/pnpm/yarn/bun) to create a lockfile.',
53
+ };
54
+ }
55
+ return { id: 'pm', label: 'Package manager', status: 'pass', detail: lockfiles.join(', ') };
56
+ }
57
+ export function checkToiljsInstalled(version) {
58
+ return version
59
+ ? { id: 'toiljs', label: 'toiljs', status: 'pass', detail: version }
60
+ : {
61
+ id: 'toiljs',
62
+ label: 'toiljs',
63
+ status: 'fail',
64
+ detail: 'not a dependency of this project',
65
+ fix: 'Add toiljs to dependencies, or run from the project root with --root.',
66
+ };
67
+ }
68
+ export function checkDir(id, label, exists, fix) {
69
+ return exists
70
+ ? { id, label, status: 'pass' }
71
+ : { id, label, status: 'fail', detail: 'missing', fix };
72
+ }
73
+ export function checkMountSlots(entrySource) {
74
+ const label = 'App entry mount()';
75
+ if (entrySource === null) {
76
+ return {
77
+ id: 'mount',
78
+ label,
79
+ status: 'warn',
80
+ detail: 'entry file not found',
81
+ fix: 'Ensure client/toil.tsx calls Toil.mount(...).',
82
+ };
83
+ }
84
+ const call = /\bmount\s*\(([^)]*)\)/.exec(entrySource);
85
+ if (!call) {
86
+ return {
87
+ id: 'mount',
88
+ label,
89
+ status: 'warn',
90
+ detail: 'no mount() call found',
91
+ fix: 'Call Toil.mount(routes, layout, notFound, globalError, slots).',
92
+ };
93
+ }
94
+ const hasSlots = /\bslots\b/.test(call[1]);
95
+ return hasSlots
96
+ ? { id: 'mount', label, status: 'pass', detail: 'passes slots' }
97
+ : {
98
+ id: 'mount',
99
+ label,
100
+ status: 'warn',
101
+ detail: 'mount() is missing the slots argument',
102
+ fix: 'Pass slots last: mount(routes, layout, notFound, globalError, slots). Without it, parallel and intercepting routes are ignored.',
103
+ };
104
+ }
105
+ export function checkRootElement(indexHtml) {
106
+ const label = 'index.html mount target';
107
+ if (indexHtml === null) {
108
+ return {
109
+ id: 'root-el',
110
+ label,
111
+ status: 'fail',
112
+ detail: 'index.html not found',
113
+ fix: 'Add public/index.html with <div id="root"></div>.',
114
+ };
115
+ }
116
+ const ok = /id\s*=\s*["']root["']/.test(indexHtml);
117
+ return ok
118
+ ? { id: 'root-el', label, status: 'pass' }
119
+ : {
120
+ id: 'root-el',
121
+ label,
122
+ status: 'fail',
123
+ detail: 'no element with id="root"',
124
+ fix: 'Add <div id="root"></div> to public/index.html.',
125
+ };
126
+ }
127
+ export function checkRoutesPresent(routeCount) {
128
+ return routeCount > 0
129
+ ? {
130
+ id: 'routes',
131
+ label: 'Routes',
132
+ status: 'pass',
133
+ detail: `${routeCount} route${routeCount === 1 ? '' : 's'}`,
134
+ }
135
+ : {
136
+ id: 'routes',
137
+ label: 'Routes',
138
+ status: 'fail',
139
+ detail: 'no routes found',
140
+ fix: 'Add a page, e.g. client/routes/index.tsx.',
141
+ };
142
+ }
143
+ export function checkDuplicatePatterns(patterns) {
144
+ const seen = new Set();
145
+ const dupes = new Set();
146
+ for (const p of patterns) {
147
+ if (seen.has(p))
148
+ dupes.add(p);
149
+ else
150
+ seen.add(p);
151
+ }
152
+ return dupes.size === 0
153
+ ? { id: 'route-dupes', label: 'Unique route patterns', status: 'pass' }
154
+ : {
155
+ id: 'route-dupes',
156
+ label: 'Unique route patterns',
157
+ status: 'warn',
158
+ detail: `duplicate: ${[...dupes].join(', ')}`,
159
+ fix: 'Two route files map to the same URL; rename or remove one.',
160
+ };
161
+ }
162
+ function isBrokenRelativeAsset(value) {
163
+ if (value === '')
164
+ return false;
165
+ if (value.startsWith('/'))
166
+ return false;
167
+ if (/^[a-z][a-z0-9+.-]*:/i.test(value))
168
+ return false;
169
+ if (value.startsWith('#') || value.startsWith('?'))
170
+ return false;
171
+ return /\.(svgz?|png|jpe?g|gif|webp|avif|ico|css|m?js|woff2?|ttf|otf|eot|mp4|webm|json)$/i.test(value);
172
+ }
173
+ export function findRelativeAssets(files) {
174
+ const issues = [];
175
+ const attr = /\b(?:src|href)\s*=\s*(?:"([^"]*)"|'([^']*)')/g;
176
+ for (const file of files) {
177
+ const lines = file.source.split('\n');
178
+ for (let i = 0; i < lines.length; i++) {
179
+ attr.lastIndex = 0;
180
+ let m;
181
+ while ((m = attr.exec(lines[i])) !== null) {
182
+ const value = m[1] ?? m[2] ?? '';
183
+ if (isBrokenRelativeAsset(value)) {
184
+ issues.push({ file: file.path, line: i + 1, value });
185
+ }
186
+ }
187
+ }
188
+ }
189
+ return issues;
190
+ }
191
+ export function checkRelativeAssets(issues) {
192
+ if (issues.length === 0)
193
+ return { id: 'rel-assets', label: 'Asset paths', status: 'pass' };
194
+ const shown = issues
195
+ .slice(0, 5)
196
+ .map((i) => `${i.file}:${String(i.line)} "${i.value}"`)
197
+ .join('; ');
198
+ const more = issues.length > 5 ? `, and ${String(issues.length - 5)} more` : '';
199
+ return {
200
+ id: 'rel-assets',
201
+ label: 'Asset paths',
202
+ status: 'warn',
203
+ detail: `${String(issues.length)} relative reference(s): ${shown}${more}`,
204
+ fix: 'Use a root-absolute path (e.g. "/images/logo.svg") or import the asset; relative paths 404 on nested routes.',
205
+ };
206
+ }
207
+ export function checkConfigLoads(loaded, error) {
208
+ return loaded
209
+ ? { id: 'config', label: 'toil.config loads', status: 'pass' }
210
+ : {
211
+ id: 'config',
212
+ label: 'toil.config loads',
213
+ status: 'fail',
214
+ detail: error ?? 'failed to load',
215
+ fix: 'Fix the error in your toil.config.* so dev/build can read it.',
216
+ };
217
+ }
218
+ export function checkBasePath(base) {
219
+ const ok = base === '/' || (base.startsWith('/') && base.endsWith('/'));
220
+ return ok
221
+ ? { id: 'base', label: 'Base path', status: 'pass', detail: base }
222
+ : {
223
+ id: 'base',
224
+ label: 'Base path',
225
+ status: 'warn',
226
+ detail: `"${base}"`,
227
+ fix: 'A non-root base should start and end with "/" (e.g. "/app/").',
228
+ };
229
+ }
230
+ export function checkSeoUrl(seoConfigured, hasUrl) {
231
+ if (!seoConfigured) {
232
+ return {
233
+ id: 'seo-url',
234
+ label: 'SEO site url',
235
+ status: 'pass',
236
+ detail: 'SEO not configured',
237
+ };
238
+ }
239
+ return hasUrl
240
+ ? { id: 'seo-url', label: 'SEO site url', status: 'pass' }
241
+ : {
242
+ id: 'seo-url',
243
+ label: 'SEO site url',
244
+ status: 'warn',
245
+ detail: 'seo is set without a url',
246
+ fix: 'Set client.seo.url so sitemap.xml and canonical links are absolute.',
247
+ };
248
+ }
249
+ export function checkStyling(f) {
250
+ const label = 'Styling';
251
+ if (f.preprocessorImported && f.preprocessorImported !== 'css' && !f.preprocessorInstalled) {
252
+ return {
253
+ id: 'styling',
254
+ label,
255
+ status: 'fail',
256
+ detail: `${f.preprocessorImported} stylesheet imported but ${f.preprocessorImported} is not installed`,
257
+ fix: `Install ${f.preprocessorImported}, or run toiljs configure.`,
258
+ };
259
+ }
260
+ if (f.tailwindImported && !f.tailwindInstalled) {
261
+ return {
262
+ id: 'styling',
263
+ label,
264
+ status: 'fail',
265
+ detail: 'Tailwind entry imported but @tailwindcss/vite is not installed',
266
+ fix: 'Run toiljs configure --tailwind, or install @tailwindcss/vite.',
267
+ };
268
+ }
269
+ return { id: 'styling', label, status: 'pass' };
270
+ }
271
+ export function checkToilconfig(present) {
272
+ return present
273
+ ? { id: 'toilconfig', label: 'Server target (toilconfig.json)', status: 'pass' }
274
+ : {
275
+ id: 'toilconfig',
276
+ label: 'Server target (toilconfig.json)',
277
+ status: 'warn',
278
+ detail: 'no toilconfig.json (no WebAssembly server)',
279
+ fix: 'Add toilconfig.json + a server/ entry if you want a WASM backend.',
280
+ };
281
+ }
282
+ export function checkServerEntry(missing) {
283
+ return missing.length === 0
284
+ ? { id: 'server-entry', label: 'Server entries', status: 'pass' }
285
+ : {
286
+ id: 'server-entry',
287
+ label: 'Server entries',
288
+ status: 'fail',
289
+ detail: `missing: ${missing.join(', ')}`,
290
+ fix: 'Create the entry file(s) listed in toilconfig.json "entries", or update them.',
291
+ };
292
+ }
293
+ export function checkToilscriptInstalled(installed) {
294
+ return installed
295
+ ? { id: 'toilscript', label: 'toilscript compiler', status: 'pass' }
296
+ : {
297
+ id: 'toilscript',
298
+ label: 'toilscript compiler',
299
+ status: 'warn',
300
+ detail: 'toilconfig.json present but toilscript is not installed',
301
+ fix: 'Install toilscript to compile the server to WebAssembly.',
302
+ };
303
+ }
304
+ export function checkWasmBuilt(exists) {
305
+ return exists
306
+ ? { id: 'wasm', label: 'Server build', status: 'pass' }
307
+ : {
308
+ id: 'wasm',
309
+ label: 'Server build',
310
+ status: 'warn',
311
+ detail: 'no compiled .wasm found',
312
+ fix: 'Run your server build (toilscript) before toiljs start.',
313
+ };
314
+ }
315
+ export function summarize(groups) {
316
+ let pass = 0;
317
+ let warn = 0;
318
+ let fail = 0;
319
+ for (const group of groups) {
320
+ for (const check of group.checks) {
321
+ if (check.status === 'pass')
322
+ pass++;
323
+ else if (check.status === 'warn')
324
+ warn++;
325
+ else
326
+ fail++;
327
+ }
328
+ }
329
+ return { pass, warn, fail };
330
+ }
331
+ export function hasFailures(summary) {
332
+ return summary.fail > 0;
333
+ }
@@ -0,0 +1,6 @@
1
+ export interface DoctorOptions {
2
+ readonly root?: string;
3
+ readonly cwd: string;
4
+ readonly json?: boolean;
5
+ }
6
+ export declare function runDoctor(opts: DoctorOptions): Promise<void>;
@@ -0,0 +1,249 @@
1
+ import fs from 'node:fs';
2
+ import { createRequire } from 'node:module';
3
+ import path from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { loadConfig, scanRoutes } from 'toiljs/compiler';
6
+ import { checkBasePath, checkConfigLoads, checkDir, checkDuplicatePatterns, checkMountSlots, checkNode, checkPackageManager, checkPeer, checkRelativeAssets, checkRootElement, checkRoutesPresent, checkSeoUrl, checkServerEntry, checkStyling, checkToilconfig, checkToiljsInstalled, checkToilscriptInstalled, checkWasmBuilt, findRelativeAssets, hasFailures, summarize, } from './diagnostics.js';
7
+ import { detectTailwind, PREPROCESSOR_PKG, preprocessorForExt, TAILWIND_ENTRY, } from './features.js';
8
+ import { accent, bold, danger, dim, success, version, warn } from './ui.js';
9
+ function readJsonObject(file) {
10
+ try {
11
+ const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
12
+ return typeof parsed === 'object' && parsed !== null
13
+ ? parsed
14
+ : null;
15
+ }
16
+ catch {
17
+ return null;
18
+ }
19
+ }
20
+ function stringRecord(value) {
21
+ if (typeof value !== 'object' || value === null)
22
+ return {};
23
+ const out = {};
24
+ for (const [k, v] of Object.entries(value))
25
+ if (typeof v === 'string')
26
+ out[k] = v;
27
+ return out;
28
+ }
29
+ function readFile(file) {
30
+ try {
31
+ return fs.readFileSync(file, 'utf8');
32
+ }
33
+ catch {
34
+ return null;
35
+ }
36
+ }
37
+ function frameworkMeta() {
38
+ const pkgPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'package.json');
39
+ const pkg = readJsonObject(pkgPath);
40
+ const engines = pkg ? stringRecord(pkg.engines) : {};
41
+ const peers = pkg ? stringRecord(pkg.peerDependencies) : {};
42
+ return { node: engines.node ?? '>=24.0.0', peers };
43
+ }
44
+ const LOCKFILES = ['package-lock.json', 'pnpm-lock.yaml', 'yarn.lock', 'bun.lockb'];
45
+ function readEntry(clientAbsDir) {
46
+ for (const name of ['toil.tsx', 'toil.jsx', 'main.tsx', 'main.jsx']) {
47
+ const source = readFile(path.join(clientAbsDir, name));
48
+ if (source !== null && /toiljs\/routes|\bmount\s*\(/.test(source))
49
+ return source;
50
+ }
51
+ return null;
52
+ }
53
+ function collectSources(root, dir, cap) {
54
+ const out = [];
55
+ const visit = (current) => {
56
+ if (out.length >= cap)
57
+ return;
58
+ let entries;
59
+ try {
60
+ entries = fs.readdirSync(current, { withFileTypes: true });
61
+ }
62
+ catch {
63
+ return;
64
+ }
65
+ for (const entry of entries) {
66
+ if (out.length >= cap)
67
+ break;
68
+ const full = path.join(current, entry.name);
69
+ if (entry.isDirectory()) {
70
+ if (entry.name !== 'node_modules')
71
+ visit(full);
72
+ }
73
+ else if (/\.(tsx|jsx)$/.test(entry.name)) {
74
+ const source = readFile(full);
75
+ if (source !== null)
76
+ out.push({ path: path.relative(root, full), source });
77
+ }
78
+ }
79
+ };
80
+ visit(dir);
81
+ return out;
82
+ }
83
+ function glyph(status) {
84
+ if (status === 'pass')
85
+ return success('✓');
86
+ if (status === 'warn')
87
+ return warn('⚠');
88
+ return danger('✗');
89
+ }
90
+ function renderHuman(groups) {
91
+ const summary = summarize(groups);
92
+ const out = [];
93
+ for (const group of groups) {
94
+ out.push(' ' + bold(group.title));
95
+ for (const check of group.checks) {
96
+ let line = ` ${glyph(check.status)} ${check.label}`;
97
+ if (check.detail)
98
+ line += dim(` ${check.detail}`);
99
+ out.push(line);
100
+ if (check.fix && check.status !== 'pass')
101
+ out.push(' ' + dim(`fix: ${check.fix}`));
102
+ }
103
+ out.push('');
104
+ }
105
+ const parts = [success(`${String(summary.pass)} passed`)];
106
+ if (summary.warn > 0) {
107
+ parts.push(warn(`${String(summary.warn)} warning${summary.warn === 1 ? '' : 's'}`));
108
+ }
109
+ if (summary.fail > 0)
110
+ parts.push(danger(`${String(summary.fail)} failed`));
111
+ out.push(' ' + parts.join(dim(', ')));
112
+ out.push('');
113
+ process.stdout.write(out.join('\n') + '\n');
114
+ }
115
+ export async function runDoctor(opts) {
116
+ const root = path.resolve(opts.root ?? opts.cwd);
117
+ const meta = frameworkMeta();
118
+ const projectPkg = readJsonObject(path.join(root, 'package.json'));
119
+ const deps = {
120
+ ...(projectPkg ? stringRecord(projectPkg.dependencies) : {}),
121
+ ...(projectPkg ? stringRecord(projectPkg.devDependencies) : {}),
122
+ };
123
+ let cfg = null;
124
+ let configError;
125
+ try {
126
+ cfg = await loadConfig({ root });
127
+ }
128
+ catch (err) {
129
+ configError = err instanceof Error ? err.message : String(err);
130
+ }
131
+ const clientAbsDir = cfg ? cfg.clientAbsDir : path.join(root, 'client');
132
+ const routesAbsDir = cfg ? cfg.routesAbsDir : path.join(clientAbsDir, 'routes');
133
+ const publicDir = cfg ? cfg.publicDir : path.join(clientAbsDir, 'public');
134
+ const entrySource = readEntry(clientAbsDir);
135
+ const indexHtml = readFile(path.join(publicDir, 'index.html'));
136
+ const routes = scanRoutes(routesAbsDir);
137
+ const mainPatterns = routes.filter((r) => r.slot === undefined).map((r) => r.pattern);
138
+ const assetIssues = findRelativeAssets(collectSources(root, clientAbsDir, 200));
139
+ let preprocessorImported = null;
140
+ let tailwindImported = false;
141
+ if (entrySource) {
142
+ const styleImport = /import\s+['"]\.\/styles\/main\.([a-z]+)['"]/.exec(entrySource);
143
+ if (styleImport)
144
+ preprocessorImported = preprocessorForExt(styleImport[1]);
145
+ tailwindImported = entrySource.includes(TAILWIND_ENTRY);
146
+ }
147
+ const ppPkg = preprocessorImported ? PREPROCESSOR_PKG[preprocessorImported] : null;
148
+ const preprocessorInstalled = ppPkg === null || ppPkg in deps;
149
+ const toilconfig = readJsonObject(path.join(root, 'toilconfig.json'));
150
+ const serverPresent = toilconfig !== null;
151
+ let missingEntries = [];
152
+ let toilscriptInstalled = false;
153
+ let wasmExists = false;
154
+ if (toilconfig) {
155
+ const entries = Array.isArray(toilconfig.entries)
156
+ ? toilconfig.entries.filter((e) => typeof e === 'string')
157
+ : [];
158
+ missingEntries = entries.filter((e) => !fs.existsSync(path.join(root, e)));
159
+ try {
160
+ createRequire(path.join(root, 'package.json')).resolve('toilscript');
161
+ toilscriptInstalled = true;
162
+ }
163
+ catch {
164
+ toilscriptInstalled = false;
165
+ }
166
+ const targets = typeof toilconfig.targets === 'object' && toilconfig.targets !== null
167
+ ? toilconfig.targets
168
+ : {};
169
+ const outFiles = [];
170
+ for (const target of Object.values(targets)) {
171
+ if (typeof target === 'object' && target !== null) {
172
+ const outFile = target.outFile;
173
+ if (typeof outFile === 'string')
174
+ outFiles.push(outFile);
175
+ }
176
+ }
177
+ wasmExists = outFiles.some((f) => fs.existsSync(path.join(root, f)));
178
+ if (!wasmExists && outFiles.length === 0) {
179
+ try {
180
+ wasmExists = fs
181
+ .readdirSync(path.join(root, 'build', 'server'))
182
+ .some((f) => f.endsWith('.wasm'));
183
+ }
184
+ catch {
185
+ wasmExists = false;
186
+ }
187
+ }
188
+ }
189
+ const peerName = (n) => checkPeer(n, deps[n] ?? null, meta.peers[n] ?? '*');
190
+ const peerChecks = Object.keys(meta.peers).map(peerName);
191
+ const groups = [
192
+ {
193
+ title: 'Environment',
194
+ checks: [
195
+ checkNode(process.versions.node, meta.node),
196
+ checkToiljsInstalled('toiljs' in deps ? version() : null),
197
+ ...peerChecks,
198
+ checkPackageManager(LOCKFILES.filter((f) => fs.existsSync(path.join(root, f)))),
199
+ ],
200
+ },
201
+ {
202
+ title: 'Project + routing',
203
+ checks: [
204
+ checkDir('client-dir', 'client/ directory', fs.existsSync(clientAbsDir), 'Create a client/ directory for your app.'),
205
+ checkDir('routes-dir', 'routes/ directory', fs.existsSync(routesAbsDir), 'Create client/routes/ and add an index.tsx.'),
206
+ checkRootElement(indexHtml),
207
+ checkMountSlots(entrySource),
208
+ checkRoutesPresent(routes.length),
209
+ checkDuplicatePatterns(mainPatterns),
210
+ checkRelativeAssets(assetIssues),
211
+ ],
212
+ },
213
+ {
214
+ title: 'Config + assets',
215
+ checks: [
216
+ checkConfigLoads(cfg !== null, configError),
217
+ checkBasePath(cfg ? cfg.base : '/'),
218
+ checkSeoUrl(cfg?.seo != null, cfg?.seo?.url != null),
219
+ checkStyling({
220
+ preprocessorImported,
221
+ preprocessorInstalled,
222
+ tailwindImported,
223
+ tailwindInstalled: detectTailwind(deps),
224
+ }),
225
+ ],
226
+ },
227
+ {
228
+ title: 'Server / WASM',
229
+ checks: serverPresent
230
+ ? [
231
+ checkToilconfig(true),
232
+ checkServerEntry(missingEntries),
233
+ checkToilscriptInstalled(toilscriptInstalled),
234
+ checkWasmBuilt(wasmExists),
235
+ ]
236
+ : [checkToilconfig(false)],
237
+ },
238
+ ];
239
+ const summary = summarize(groups);
240
+ if (opts.json) {
241
+ process.stdout.write(JSON.stringify({ groups, summary }, null, 2) + '\n');
242
+ }
243
+ else {
244
+ process.stdout.write('\n' + accent(' Doctor') + dim(` ${root}`) + '\n\n');
245
+ renderHuman(groups);
246
+ }
247
+ if (hasFailures(summary))
248
+ process.exitCode = 1;
249
+ }
@@ -2,6 +2,8 @@
2
2
  import { build, dev, start } from 'toiljs/compiler';
3
3
  import { runConfigure } from './configure.js';
4
4
  import { runCreate } from './create.js';
5
+ import { runDoctor } from './doctor.js';
6
+ import { runUpdate } from './update.js';
5
7
  import { PREPROCESSORS } from './features.js';
6
8
  import { accent, banner, bold, danger, dim, success, version } from './ui.js';
7
9
  function parseArgs(argv) {
@@ -68,6 +70,12 @@ function parseArgs(argv) {
68
70
  case '--yes':
69
71
  flags.yes = true;
70
72
  break;
73
+ case '--json':
74
+ flags.json = true;
75
+ break;
76
+ case '--target':
77
+ flags.target = argv[++i];
78
+ break;
71
79
  default:
72
80
  if (!arg.startsWith('-') && flags.name === undefined)
73
81
  flags.name = arg;
@@ -86,6 +94,8 @@ function printHelp() {
86
94
  cmd('dev', 'start the dev server with HMR'),
87
95
  cmd('build', 'build the optimized production bundle'),
88
96
  cmd('start', 'self-host the built app (hyper-express / uWS)'),
97
+ cmd('doctor', 'diagnose project setup and dependencies'),
98
+ cmd('update', 'check for and apply dependency updates'),
89
99
  '',
90
100
  bold('Options'),
91
101
  cmd('--root <dir>', 'project root (default: current directory)'),
@@ -96,6 +106,8 @@ function printHelp() {
96
106
  cmd('--no-ai', 'create: skip AI assistant files (CLAUDE.md, etc.)'),
97
107
  cmd('-y, --yes', 'create: accept defaults (non-interactive)'),
98
108
  cmd('--no-install', "create: don't install dependencies"),
109
+ cmd('--json', 'doctor: machine-readable output'),
110
+ cmd('--target <t>', 'update: latest | minor | patch | newest | greatest'),
99
111
  cmd('-v, --version', 'print the toiljs version'),
100
112
  '',
101
113
  ].join('\n') + '\n');
@@ -156,6 +168,20 @@ async function main() {
156
168
  '\n\n');
157
169
  break;
158
170
  }
171
+ case 'doctor':
172
+ if (!flags.json)
173
+ banner();
174
+ await runDoctor({ root: flags.root, cwd: process.cwd(), json: flags.json });
175
+ break;
176
+ case 'update':
177
+ banner();
178
+ await runUpdate({
179
+ root: flags.root,
180
+ cwd: process.cwd(),
181
+ yes: flags.yes,
182
+ target: flags.target,
183
+ });
184
+ break;
159
185
  case 'help':
160
186
  case '--help':
161
187
  case '-h':
@@ -1 +1,6 @@
1
1
  export declare function run(cmd: string, args: string[], cwd: string): Promise<void>;
2
+ export declare function capture(cmd: string, args: string[], cwd: string): Promise<{
3
+ stdout: string;
4
+ stderr: string;
5
+ code: number;
6
+ }>;
package/build/cli/proc.js CHANGED
@@ -9,3 +9,23 @@ export function run(cmd, args, cwd) {
9
9
  child.on('close', (code) => code === 0 ? resolve() : reject(new Error(`${cmd} exited with code ${String(code)}`)));
10
10
  });
11
11
  }
12
+ export function capture(cmd, args, cwd) {
13
+ return new Promise((resolve, reject) => {
14
+ const onWindows = process.platform === 'win32';
15
+ const child = onWindows
16
+ ? spawn([cmd, ...args].join(' '), { cwd, shell: true })
17
+ : spawn(cmd, args, { cwd });
18
+ let stdout = '';
19
+ let stderr = '';
20
+ child.stdout?.on('data', (d) => {
21
+ stdout += d.toString();
22
+ });
23
+ child.stderr?.on('data', (d) => {
24
+ stderr += d.toString();
25
+ });
26
+ child.on('error', reject);
27
+ child.on('close', (code) => {
28
+ resolve({ stdout, stderr, code: code ?? 1 });
29
+ });
30
+ });
31
+ }
package/build/cli/ui.d.ts CHANGED
@@ -4,5 +4,6 @@ export declare function brand(s: string): string;
4
4
  export declare const accent: typeof brand;
5
5
  export declare function success(s: string): string;
6
6
  export declare const danger: import("picocolors/types").Formatter;
7
+ export declare const warn: import("picocolors/types").Formatter;
7
8
  export declare function version(): string;
8
9
  export declare function banner(): void;