toiljs 0.0.11 → 0.0.14

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 (120) hide show
  1. package/README.md +3 -1
  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 +33 -24
  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 +45 -27
  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 +1 -1
  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/router-loading.test.tsx +1 -1
  110. package/test/dom/slot.test.tsx +131 -109
  111. package/test/dom/view-transitions.test.tsx +53 -51
  112. package/test/features.test.ts +149 -142
  113. package/test/fonts.test.ts +28 -26
  114. package/test/head.test.ts +45 -35
  115. package/test/metadata.test.ts +42 -41
  116. package/test/pages.test.ts +105 -0
  117. package/test/prerender.test.ts +54 -46
  118. package/test/search.test.ts +114 -0
  119. package/test/seo.test.ts +30 -8
  120. package/test/update.test.ts +44 -0
@@ -0,0 +1,421 @@
1
+ /**
2
+ * Pure diagnostics for `toiljs doctor`. Every check is a pure function that takes already-gathered
3
+ * facts (versions, parsed package data, file contents, scanned routes) and returns a {@link Check}.
4
+ * Kept IO-free so it can be unit-tested in isolation; the file reads, config load, and rendering
5
+ * live in `doctor.ts`. Mirrors the pure/IO split of `validate.ts` and `features.ts`.
6
+ */
7
+ import { type Preprocessor } from './features.js';
8
+
9
+ export type CheckStatus = 'pass' | 'warn' | 'fail';
10
+
11
+ /** One diagnostic result: a labelled outcome with an optional detail and a fix hint. */
12
+ export interface Check {
13
+ readonly id: string;
14
+ readonly label: string;
15
+ readonly status: CheckStatus;
16
+ readonly detail?: string;
17
+ readonly fix?: string;
18
+ }
19
+
20
+ /** A titled group of related checks (Environment, Project, ...). */
21
+ export interface CheckGroup {
22
+ readonly title: string;
23
+ readonly checks: Check[];
24
+ }
25
+
26
+ /** Tallied counts across all groups. */
27
+ export interface DoctorSummary {
28
+ readonly pass: number;
29
+ readonly warn: number;
30
+ readonly fail: number;
31
+ }
32
+
33
+ /** Parses a version string's leading `x.y.z` into a numeric tuple (missing parts default to 0). */
34
+ function parseVersion(v: string): [number, number, number] {
35
+ const m = /(\d+)(?:\.(\d+))?(?:\.(\d+))?/.exec(v);
36
+ if (!m) return [0, 0, 0];
37
+ return [Number(m[1]), Number(m[2] ?? 0), Number(m[3] ?? 0)];
38
+ }
39
+
40
+ /**
41
+ * Whether `version` meets a simple minimum `range` (`>=x.y.z` or a bare `x.y.z`). toiljs's peer
42
+ * ranges are all `>=`, so a full semver resolver is unnecessary; a declared range like `^19.2.6` is
43
+ * compared by its floor.
44
+ */
45
+ export function satisfiesMin(version: string, range: string): boolean {
46
+ const [a, b, c] = parseVersion(version);
47
+ const [x, y, z] = parseVersion(range);
48
+ if (a !== x) return a > x;
49
+ if (b !== y) return b > y;
50
+ return c >= z;
51
+ }
52
+
53
+ // --- Environment ----------------------------------------------------------------------------------
54
+
55
+ export function checkNode(current: string, requiredRange: string): Check {
56
+ const ok = satisfiesMin(current, requiredRange);
57
+ return {
58
+ id: 'node',
59
+ label: 'Node.js',
60
+ status: ok ? 'pass' : 'fail',
61
+ detail: `${current} (requires ${requiredRange})`,
62
+ fix: ok ? undefined : `Upgrade Node to ${requiredRange}.`,
63
+ };
64
+ }
65
+
66
+ export function checkPeer(name: string, installed: string | null, range: string): Check {
67
+ if (installed === null) {
68
+ return {
69
+ id: `peer:${name}`,
70
+ label: name,
71
+ status: 'fail',
72
+ detail: `not installed (requires ${range})`,
73
+ fix: `Install ${name}@"${range}".`,
74
+ };
75
+ }
76
+ const ok = satisfiesMin(installed, range);
77
+ return {
78
+ id: `peer:${name}`,
79
+ label: name,
80
+ status: ok ? 'pass' : 'warn',
81
+ detail: `${installed} (requires ${range})`,
82
+ fix: ok ? undefined : `Update ${name} to ${range}.`,
83
+ };
84
+ }
85
+
86
+ export function checkPackageManager(lockfiles: readonly string[]): Check {
87
+ if (lockfiles.length === 0) {
88
+ return {
89
+ id: 'pm',
90
+ label: 'Package manager',
91
+ status: 'warn',
92
+ detail: 'no lockfile found',
93
+ fix: 'Run an install (npm/pnpm/yarn/bun) to create a lockfile.',
94
+ };
95
+ }
96
+ return { id: 'pm', label: 'Package manager', status: 'pass', detail: lockfiles.join(', ') };
97
+ }
98
+
99
+ export function checkToiljsInstalled(version: string | null): Check {
100
+ return version
101
+ ? { id: 'toiljs', label: 'toiljs', status: 'pass', detail: version }
102
+ : {
103
+ id: 'toiljs',
104
+ label: 'toiljs',
105
+ status: 'fail',
106
+ detail: 'not a dependency of this project',
107
+ fix: 'Add toiljs to dependencies, or run from the project root with --root.',
108
+ };
109
+ }
110
+
111
+ // --- Project + routing ----------------------------------------------------------------------------
112
+
113
+ export function checkDir(id: string, label: string, exists: boolean, fix: string): Check {
114
+ return exists
115
+ ? { id, label, status: 'pass' }
116
+ : { id, label, status: 'fail', detail: 'missing', fix };
117
+ }
118
+
119
+ /**
120
+ * Whether the app entry calls `mount(...)` with a `slots` argument. Without it, parallel and
121
+ * intercepting routes are silently dropped (a real bug we shipped a fix for). Heuristic, regex-based.
122
+ */
123
+ export function checkMountSlots(entrySource: string | null): Check {
124
+ const label = 'App entry mount()';
125
+ if (entrySource === null) {
126
+ return {
127
+ id: 'mount',
128
+ label,
129
+ status: 'warn',
130
+ detail: 'entry file not found',
131
+ fix: 'Ensure client/toil.tsx calls Toil.mount(...).',
132
+ };
133
+ }
134
+ const call = /\bmount\s*\(([^)]*)\)/.exec(entrySource);
135
+ if (!call) {
136
+ return {
137
+ id: 'mount',
138
+ label,
139
+ status: 'warn',
140
+ detail: 'no mount() call found',
141
+ fix: 'Call Toil.mount(routes, layout, notFound, globalError, slots).',
142
+ };
143
+ }
144
+ const hasSlots = /\bslots\b/.test(call[1]);
145
+ return hasSlots
146
+ ? { id: 'mount', label, status: 'pass', detail: 'passes slots' }
147
+ : {
148
+ id: 'mount',
149
+ label,
150
+ status: 'warn',
151
+ detail: 'mount() is missing the slots argument',
152
+ fix: 'Pass slots last: mount(routes, layout, notFound, globalError, slots). Without it, parallel and intercepting routes are ignored.',
153
+ };
154
+ }
155
+
156
+ export function checkRootElement(indexHtml: string | null): Check {
157
+ const label = 'index.html mount target';
158
+ if (indexHtml === null) {
159
+ return {
160
+ id: 'root-el',
161
+ label,
162
+ status: 'fail',
163
+ detail: 'index.html not found',
164
+ fix: 'Add public/index.html with <div id="root"></div>.',
165
+ };
166
+ }
167
+ const ok = /id\s*=\s*["']root["']/.test(indexHtml);
168
+ return ok
169
+ ? { id: 'root-el', label, status: 'pass' }
170
+ : {
171
+ id: 'root-el',
172
+ label,
173
+ status: 'fail',
174
+ detail: 'no element with id="root"',
175
+ fix: 'Add <div id="root"></div> to public/index.html.',
176
+ };
177
+ }
178
+
179
+ export function checkRoutesPresent(routeCount: number): Check {
180
+ return routeCount > 0
181
+ ? {
182
+ id: 'routes',
183
+ label: 'Routes',
184
+ status: 'pass',
185
+ detail: `${routeCount} route${routeCount === 1 ? '' : 's'}`,
186
+ }
187
+ : {
188
+ id: 'routes',
189
+ label: 'Routes',
190
+ status: 'fail',
191
+ detail: 'no routes found',
192
+ fix: 'Add a page, e.g. client/routes/index.tsx.',
193
+ };
194
+ }
195
+
196
+ export function checkDuplicatePatterns(patterns: readonly string[]): Check {
197
+ const seen = new Set<string>();
198
+ const dupes = new Set<string>();
199
+ for (const p of patterns) {
200
+ if (seen.has(p)) dupes.add(p);
201
+ else seen.add(p);
202
+ }
203
+ return dupes.size === 0
204
+ ? { id: 'route-dupes', label: 'Unique route patterns', status: 'pass' }
205
+ : {
206
+ id: 'route-dupes',
207
+ label: 'Unique route patterns',
208
+ status: 'warn',
209
+ detail: `duplicate: ${[...dupes].join(', ')}`,
210
+ fix: 'Two route files map to the same URL; rename or remove one.',
211
+ };
212
+ }
213
+
214
+ /** A source file scanned for broken asset references. */
215
+ export interface SourceFile {
216
+ readonly path: string;
217
+ readonly source: string;
218
+ }
219
+
220
+ /** A relative asset reference that will 404 on a nested route. */
221
+ export interface AssetIssue {
222
+ readonly file: string;
223
+ readonly line: number;
224
+ readonly value: string;
225
+ }
226
+
227
+ /** Whether a `src`/`href` string value is a root-relative asset path that breaks on nested routes. */
228
+ function isBrokenRelativeAsset(value: string): boolean {
229
+ if (value === '') return false;
230
+ if (value.startsWith('/')) return false; // root-absolute, resolves the same everywhere
231
+ if (/^[a-z][a-z0-9+.-]*:/i.test(value)) return false; // http:, https:, data:, mailto:, ...
232
+ if (value.startsWith('#') || value.startsWith('?')) return false;
233
+ // Only flag asset-looking values (a real file extension), to avoid false positives on app routes.
234
+ return /\.(svgz?|png|jpe?g|gif|webp|avif|ico|css|m?js|woff2?|ttf|otf|eot|mp4|webm|json)$/i.test(
235
+ value,
236
+ );
237
+ }
238
+
239
+ /** Finds string-literal `src=`/`href=` attributes pointing at broken relative asset paths. */
240
+ export function findRelativeAssets(files: readonly SourceFile[]): AssetIssue[] {
241
+ const issues: AssetIssue[] = [];
242
+ const attr = /\b(?:src|href)\s*=\s*(?:"([^"]*)"|'([^']*)')/g;
243
+ for (const file of files) {
244
+ const lines = file.source.split('\n');
245
+ for (let i = 0; i < lines.length; i++) {
246
+ attr.lastIndex = 0;
247
+ let m: RegExpExecArray | null;
248
+ while ((m = attr.exec(lines[i])) !== null) {
249
+ const value = m[1] ?? m[2] ?? '';
250
+ if (isBrokenRelativeAsset(value)) {
251
+ issues.push({ file: file.path, line: i + 1, value });
252
+ }
253
+ }
254
+ }
255
+ }
256
+ return issues;
257
+ }
258
+
259
+ export function checkRelativeAssets(issues: readonly AssetIssue[]): Check {
260
+ if (issues.length === 0) return { id: 'rel-assets', label: 'Asset paths', status: 'pass' };
261
+ const shown = issues
262
+ .slice(0, 5)
263
+ .map((i) => `${i.file}:${String(i.line)} "${i.value}"`)
264
+ .join('; ');
265
+ const more = issues.length > 5 ? `, and ${String(issues.length - 5)} more` : '';
266
+ return {
267
+ id: 'rel-assets',
268
+ label: 'Asset paths',
269
+ status: 'warn',
270
+ detail: `${String(issues.length)} relative reference(s): ${shown}${more}`,
271
+ fix: 'Use a root-absolute path (e.g. "/images/logo.svg") or import the asset; relative paths 404 on nested routes.',
272
+ };
273
+ }
274
+
275
+ // --- Config + assets ------------------------------------------------------------------------------
276
+
277
+ export function checkConfigLoads(loaded: boolean, error?: string): Check {
278
+ return loaded
279
+ ? { id: 'config', label: 'toil.config loads', status: 'pass' }
280
+ : {
281
+ id: 'config',
282
+ label: 'toil.config loads',
283
+ status: 'fail',
284
+ detail: error ?? 'failed to load',
285
+ fix: 'Fix the error in your toil.config.* so dev/build can read it.',
286
+ };
287
+ }
288
+
289
+ export function checkBasePath(base: string): Check {
290
+ const ok = base === '/' || (base.startsWith('/') && base.endsWith('/'));
291
+ return ok
292
+ ? { id: 'base', label: 'Base path', status: 'pass', detail: base }
293
+ : {
294
+ id: 'base',
295
+ label: 'Base path',
296
+ status: 'warn',
297
+ detail: `"${base}"`,
298
+ fix: 'A non-root base should start and end with "/" (e.g. "/app/").',
299
+ };
300
+ }
301
+
302
+ export function checkSeoUrl(seoConfigured: boolean, hasUrl: boolean): Check {
303
+ if (!seoConfigured) {
304
+ return {
305
+ id: 'seo-url',
306
+ label: 'SEO site url',
307
+ status: 'pass',
308
+ detail: 'SEO not configured',
309
+ };
310
+ }
311
+ return hasUrl
312
+ ? { id: 'seo-url', label: 'SEO site url', status: 'pass' }
313
+ : {
314
+ id: 'seo-url',
315
+ label: 'SEO site url',
316
+ status: 'warn',
317
+ detail: 'seo is set without a url',
318
+ fix: 'Set client.seo.url so sitemap.xml and canonical links are absolute.',
319
+ };
320
+ }
321
+
322
+ /** Facts about the project's styling setup, derived by the orchestrator. */
323
+ export interface StylingFacts {
324
+ readonly preprocessorImported: Preprocessor | null;
325
+ readonly preprocessorInstalled: boolean;
326
+ readonly tailwindImported: boolean;
327
+ readonly tailwindInstalled: boolean;
328
+ }
329
+
330
+ export function checkStyling(f: StylingFacts): Check {
331
+ const label = 'Styling';
332
+ if (f.preprocessorImported && f.preprocessorImported !== 'css' && !f.preprocessorInstalled) {
333
+ return {
334
+ id: 'styling',
335
+ label,
336
+ status: 'fail',
337
+ detail: `${f.preprocessorImported} stylesheet imported but ${f.preprocessorImported} is not installed`,
338
+ fix: `Install ${f.preprocessorImported}, or run toiljs configure.`,
339
+ };
340
+ }
341
+ if (f.tailwindImported && !f.tailwindInstalled) {
342
+ return {
343
+ id: 'styling',
344
+ label,
345
+ status: 'fail',
346
+ detail: 'Tailwind entry imported but @tailwindcss/vite is not installed',
347
+ fix: 'Run toiljs configure --tailwind, or install @tailwindcss/vite.',
348
+ };
349
+ }
350
+ return { id: 'styling', label, status: 'pass' };
351
+ }
352
+
353
+ // --- Server / WASM --------------------------------------------------------------------------------
354
+
355
+ export function checkToilconfig(present: boolean): Check {
356
+ return present
357
+ ? { id: 'toilconfig', label: 'Server target (toilconfig.json)', status: 'pass' }
358
+ : {
359
+ id: 'toilconfig',
360
+ label: 'Server target (toilconfig.json)',
361
+ status: 'warn',
362
+ detail: 'no toilconfig.json (no WebAssembly server)',
363
+ fix: 'Add toilconfig.json + a server/ entry if you want a WASM backend.',
364
+ };
365
+ }
366
+
367
+ export function checkServerEntry(missing: readonly string[]): Check {
368
+ return missing.length === 0
369
+ ? { id: 'server-entry', label: 'Server entries', status: 'pass' }
370
+ : {
371
+ id: 'server-entry',
372
+ label: 'Server entries',
373
+ status: 'fail',
374
+ detail: `missing: ${missing.join(', ')}`,
375
+ fix: 'Create the entry file(s) listed in toilconfig.json "entries", or update them.',
376
+ };
377
+ }
378
+
379
+ export function checkToilscriptInstalled(installed: boolean): Check {
380
+ return installed
381
+ ? { id: 'toilscript', label: 'toilscript compiler', status: 'pass' }
382
+ : {
383
+ id: 'toilscript',
384
+ label: 'toilscript compiler',
385
+ status: 'warn',
386
+ detail: 'toilconfig.json present but toilscript is not installed',
387
+ fix: 'Install toilscript to compile the server to WebAssembly.',
388
+ };
389
+ }
390
+
391
+ export function checkWasmBuilt(exists: boolean): Check {
392
+ return exists
393
+ ? { id: 'wasm', label: 'Server build', status: 'pass' }
394
+ : {
395
+ id: 'wasm',
396
+ label: 'Server build',
397
+ status: 'warn',
398
+ detail: 'no compiled .wasm found',
399
+ fix: 'Run your server build (toilscript) before toiljs start.',
400
+ };
401
+ }
402
+
403
+ // --- Summary --------------------------------------------------------------------------------------
404
+
405
+ export function summarize(groups: readonly CheckGroup[]): DoctorSummary {
406
+ let pass = 0;
407
+ let warn = 0;
408
+ let fail = 0;
409
+ for (const group of groups) {
410
+ for (const check of group.checks) {
411
+ if (check.status === 'pass') pass++;
412
+ else if (check.status === 'warn') warn++;
413
+ else fail++;
414
+ }
415
+ }
416
+ return { pass, warn, fail };
417
+ }
418
+
419
+ export function hasFailures(summary: DoctorSummary): boolean {
420
+ return summary.fail > 0;
421
+ }