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
@@ -2,12 +2,19 @@
2
2
  * Route data loaders. A route file may export a `loader` alongside its default component; the
3
3
  * loader runs on navigation, in parallel with the route's chunk load, and the page suspends until
4
4
  * it resolves (so `loading.tsx` shows and `useNavigationPending` is true). The page reads the
5
- * result with `useLoaderData()`. Results are cached per navigation+URL (revalidated on each
6
- * navigation); `clearLoaderData()` (router.refresh) busts the cache.
5
+ * result with `useLoaderData<typeof loader>()`.
6
+ *
7
+ * Caching: results are cached by URL (`pathname + search`). Re-renders reuse the cached result (the
8
+ * loader does not re-run). Across navigations, the cache policy comes from the route's optional
9
+ * `export const revalidate` ({@link Revalidate}): by default the loader re-runs on every navigation;
10
+ * a number keeps data fresh for that many seconds; `false` caches until manual invalidation.
11
+ * `revalidate()` / `router.refresh()` bust the cache to force a refetch.
7
12
  */
8
13
  import { createContext, useContext, type ComponentType } from 'react';
9
14
 
10
- import { navigationEpoch } from '../navigation/navigation.js';
15
+ import type { HeadSpec } from '../head/head.js';
16
+ import { resolveMetadata, type GenerateMetadata, type Metadata } from '../head/metadata.js';
17
+ import { refresh as rerender } from '../navigation/navigation.js';
11
18
  import type { RouteDef } from '../types.js';
12
19
  import type { RouteParams } from './match.js';
13
20
 
@@ -20,72 +27,140 @@ export interface LoaderArgs {
20
27
  /** A route `loader`: `export const loader = ({ params }) => …` (sync or async). */
21
28
  export type LoaderFunction<T = unknown> = (args: LoaderArgs) => T | Promise<T>;
22
29
 
30
+ /**
31
+ * Per-route cache policy, set with `export const revalidate` in a route file:
32
+ * - `0` (default): re-run the loader on every navigation to the route.
33
+ * - a positive number: reuse cached data across navigations for that many **seconds**, then refetch.
34
+ * - `false`: cache indefinitely until manually invalidated (`revalidate()` / `router.refresh()`).
35
+ */
36
+ export type Revalidate = number | false;
37
+
38
+ /** Resolves the data type for {@link useLoaderData}: `typeof loader` → its (awaited) return, else `T`. */
39
+ export type LoaderData<T> = T extends (...args: never[]) => infer R ? Awaited<R> : T;
40
+
23
41
  interface RouteModule {
24
42
  default: ComponentType;
25
43
  loader?: LoaderFunction;
44
+ revalidate?: Revalidate;
45
+ metadata?: Metadata;
46
+ generateMetadata?: GenerateMetadata;
26
47
  }
27
48
  interface RouteData {
28
49
  Component: ComponentType;
29
50
  data: unknown;
51
+ /** Resolved baseline head from the route's `metadata` / `generateMetadata`, if any. */
52
+ head?: HeadSpec;
30
53
  }
31
54
  interface Entry {
32
55
  status: 'pending' | 'done' | 'error';
33
56
  promise: Promise<void>;
34
57
  value?: RouteData;
35
58
  error?: unknown;
59
+ /** `Date.now()` when the fetch settled (`0` while pending). */
60
+ loadedAt: number;
61
+ /** Cache policy captured from the route module once loaded. */
62
+ revalidate: Revalidate;
63
+ /** Navigation epoch at which this entry was (re)fetched. */
64
+ epoch: number;
65
+ /** Whether the route exports a `loader`, a route without one has no data that can change. */
66
+ hasLoader: boolean;
36
67
  }
37
68
 
38
69
  const cache = new Map<string, Entry>();
39
- const MAX_ENTRIES = 16;
70
+ const MAX_ENTRIES = 32;
71
+
72
+ /** Cache key for a URL: path + query (hash is ignored, it never changes loader data). */
73
+ export function loaderKey(pathname: string, search: string): string {
74
+ return `${pathname}${search}`;
75
+ }
40
76
 
41
77
  /** Loads the route module and runs its loader (if any), in parallel where possible. */
42
- async function loadRoute(route: RouteDef, params: RouteParams): Promise<RouteData> {
78
+ async function loadRoute(
79
+ route: RouteDef,
80
+ params: RouteParams,
81
+ ): Promise<{ data: RouteData; revalidate: Revalidate; hasLoader: boolean }> {
43
82
  const mod: RouteModule = await route.load();
44
83
  const searchParams = new URLSearchParams(
45
84
  typeof window === 'undefined' ? '' : window.location.search,
46
85
  );
47
86
  const data = mod.loader ? await mod.loader({ params, searchParams }) : undefined;
48
- return { Component: mod.default, data };
87
+ let head: HeadSpec | undefined;
88
+ if (mod.generateMetadata) {
89
+ head = resolveMetadata(await mod.generateMetadata({ params, searchParams, data }));
90
+ } else if (mod.metadata) {
91
+ head = resolveMetadata(mod.metadata);
92
+ }
93
+ return {
94
+ data: { Component: mod.default, data, head },
95
+ revalidate: mod.revalidate ?? 0,
96
+ hasLoader: mod.loader != null,
97
+ };
49
98
  }
50
99
 
51
- /** Drops entries from previous navigations (and caps the cache as a backstop). */
52
- function prune(): void {
53
- const current = `${String(navigationEpoch())}:`;
54
- for (const key of cache.keys()) {
55
- if (!key.startsWith(current)) cache.delete(key);
56
- }
100
+ /** Whether a settled entry must be refetched for the current navigation. */
101
+ function isStale(entry: Entry, epoch: number): boolean {
102
+ if (entry.status === 'error') return true; // always retry a failed load
103
+ // A route with no loader has no data that can change, keep it cached so repeat navigations
104
+ // render synchronously (instant) instead of re-suspending and remounting on every switch.
105
+ if (!entry.hasLoader) return false;
106
+ if (entry.revalidate === false) return false; // cache forever
107
+ if (entry.revalidate === 0) return entry.epoch !== epoch; // refetch once per navigation
108
+ return Date.now() - entry.loadedAt >= entry.revalidate * 1000; // time-based staleness
109
+ }
110
+
111
+ /** Starts (and caches) a fresh fetch for `key`. */
112
+ function startFetch(route: RouteDef, params: RouteParams, key: string, epoch: number): Entry {
113
+ const created: Entry = {
114
+ status: 'pending',
115
+ promise: Promise.resolve(),
116
+ loadedAt: 0,
117
+ revalidate: 0,
118
+ epoch,
119
+ hasLoader: false,
120
+ };
121
+ created.promise = loadRoute(route, params).then(
122
+ (result) => {
123
+ created.value = result.data;
124
+ created.revalidate = result.revalidate;
125
+ created.hasLoader = result.hasLoader;
126
+ created.loadedAt = Date.now();
127
+ created.status = 'done';
128
+ },
129
+ (error: unknown) => {
130
+ created.error = error;
131
+ created.loadedAt = Date.now();
132
+ created.status = 'error';
133
+ },
134
+ );
135
+ cache.set(key, created);
57
136
  while (cache.size > MAX_ENTRIES) {
58
137
  const oldest = cache.keys().next().value;
59
- if (oldest === undefined) break;
138
+ if (oldest === undefined || oldest === key) break;
60
139
  cache.delete(oldest);
61
140
  }
141
+ return created;
62
142
  }
63
143
 
64
144
  /**
65
- * Reads (or starts) the route's module + loader data for `key`, suspending until ready. Throws the
66
- * loader's error so the route's `error.tsx` boundary can catch it.
145
+ * Reads (or starts) the route's module + loader data for the URL `key`, suspending until ready.
146
+ * Throws the loader's error so the route's `error.tsx` boundary can catch it. `epoch` is the current
147
+ * navigation epoch, used to refetch once-per-navigation under the default cache policy.
67
148
  */
68
- export function readRouteData(route: RouteDef, params: RouteParams, key: string): RouteData {
149
+ export function readRouteData(
150
+ route: RouteDef,
151
+ params: RouteParams,
152
+ key: string,
153
+ epoch: number,
154
+ ): RouteData {
69
155
  let entry = cache.get(key);
70
- if (!entry) {
71
- const created: Entry = { status: 'pending', promise: Promise.resolve() };
72
- created.promise = loadRoute(route, params).then(
73
- (value) => {
74
- created.value = value;
75
- created.status = 'done';
76
- },
77
- (error: unknown) => {
78
- created.error = error;
79
- created.status = 'error';
80
- },
81
- );
82
- cache.set(key, created);
83
- prune();
84
- entry = created;
156
+ if (entry && entry.status !== 'pending' && isStale(entry, epoch)) {
157
+ entry = undefined; // stale drop and refetch below
85
158
  }
159
+ entry ??= startFetch(route, params, key, epoch);
86
160
  if (entry.status === 'pending') throw entry.promise;
87
161
  if (entry.status === 'error') throw entry.error;
88
- return entry.value as RouteData;
162
+ if (!entry.value) throw entry.promise;
163
+ return entry.value;
89
164
  }
90
165
 
91
166
  /** Clears all cached loader data, so the next render re-runs loaders (used by router.refresh). */
@@ -93,10 +168,58 @@ export function clearLoaderData(): void {
93
168
  cache.clear();
94
169
  }
95
170
 
171
+ /** Cache key for an href (relative or absolute), matching {@link loaderKey}. */
172
+ function keyForHref(href: string): string | undefined {
173
+ if (typeof window === 'undefined') return undefined;
174
+ try {
175
+ const url = new URL(href, window.location.href);
176
+ return loaderKey(url.pathname, url.search);
177
+ } catch {
178
+ return undefined;
179
+ }
180
+ }
181
+
182
+ /**
183
+ * Invalidates cached loader data so it refetches on the next render. With no argument, clears every
184
+ * route; with an `href`, clears just that route's entry. Pair with a re-render (the active route
185
+ * refetches and suspends), see {@link revalidate}.
186
+ */
187
+ export function invalidateLoaderData(href?: string): void {
188
+ if (href === undefined) {
189
+ cache.clear();
190
+ return;
191
+ }
192
+ const key = keyForHref(href);
193
+ if (key !== undefined) cache.delete(key);
194
+ }
195
+
196
+ /**
197
+ * Invalidates loader data and re-renders so the active route refetches. Call after a mutation to
198
+ * refresh the current route (`revalidate()`), or target another route by href
199
+ * (`revalidate('/posts')`). Usable outside React (e.g. in an event handler after a `fetch`).
200
+ */
201
+ export function revalidate(href?: string): void {
202
+ invalidateLoaderData(href);
203
+ rerender();
204
+ }
205
+
96
206
  /** Holds the active route's loader data; provided by the Router, read by {@link useLoaderData}. */
97
207
  export const LoaderDataContext = createContext<unknown>(undefined);
98
208
 
99
- /** The data returned by the active route's `loader` (`undefined` if it has none). */
100
- export function useLoaderData<T = unknown>(): T {
101
- return useContext(LoaderDataContext) as T;
209
+ /**
210
+ * The data returned by the active route's `loader`. Three ways to type it, easiest first:
211
+ *
212
+ * 1. **Pass the loader**, zero generics, fully inferred from your loader's return:
213
+ * `const data = useLoaderData(loader);`
214
+ * 2. Pass `typeof loader` as a type argument: `useLoaderData<typeof loader>();`
215
+ * 3. Pass an explicit shape: `useLoaderData<Post>();`
216
+ *
217
+ * With no argument and no type, it returns `unknown` (never `any`), so the data is there at runtime,
218
+ * but you must annotate or narrow before using it. There's no way to infer the type from a bare call:
219
+ * TypeScript can't tell which file (and so which `loader`) the call belongs to, hence option 1.
220
+ */
221
+ export function useLoaderData<L extends LoaderFunction>(loader: L): Awaited<ReturnType<L>>;
222
+ export function useLoaderData<T = unknown>(): LoaderData<T>;
223
+ export function useLoaderData(_loader?: LoaderFunction): unknown {
224
+ return useContext(LoaderDataContext);
102
225
  }
@@ -1,5 +1,11 @@
1
1
  import { createRoot } from 'react-dom/client';
2
2
 
3
+ import {
4
+ DevErrorBoundary,
5
+ DevErrorOverlay,
6
+ initDevErrorOverlay,
7
+ isDevMode,
8
+ } from '../dev/error-overlay.js';
3
9
  import { initNavigation } from '../navigation/navigation.js';
4
10
  import { startPrefetcher } from '../navigation/prefetch.js';
5
11
  import { Router } from './Router.js';
@@ -14,17 +20,33 @@ export function mount(
14
20
  layout: LayoutLoader = null,
15
21
  notFound: NotFoundLoader = null,
16
22
  globalError: ErrorComponentLoader = null,
23
+ slots: Record<string, RouteDef[]> = {},
17
24
  ): void {
18
25
  const el = document.getElementById('root');
19
26
  if (!el) throw new Error('toil: #root element not found');
20
27
  initNavigation();
21
- createRoot(el).render(
28
+ const app = (
22
29
  <Router
23
30
  routes={routes}
24
31
  layout={layout}
25
32
  notFound={notFound}
26
33
  globalError={globalError}
27
- />,
34
+ slots={slots}
35
+ />
28
36
  );
29
- startPrefetcher(routes);
37
+ // In dev, wrap the app in the error overlay so uncaught render/async errors surface on screen
38
+ // (not a blank page). In production it's omitted entirely.
39
+ if (isDevMode()) {
40
+ initDevErrorOverlay();
41
+ createRoot(el).render(
42
+ <>
43
+ <DevErrorBoundary>{app}</DevErrorBoundary>
44
+ <DevErrorOverlay />
45
+ </>,
46
+ );
47
+ } else {
48
+ createRoot(el).render(app);
49
+ }
50
+ // Prefetch across the main tree and every slot tree (one prefetcher owns the whole table).
51
+ startPrefetcher([...routes, ...Object.values(slots).flat()]);
30
52
  }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Context carrying the rendered element for each parallel-route slot (`@slot`), keyed by name.
3
+ * Provided by the Router for the current URL, consumed by {@link Slot}.
4
+ */
5
+ import { createContext, type ReactNode } from 'react';
6
+
7
+ export const SlotContext = createContext<Record<string, ReactNode>>({});
@@ -12,7 +12,7 @@ import type { ComponentType, ReactNode } from 'react';
12
12
  export interface Register {}
13
13
 
14
14
  /**
15
- * Union of the project's route paths static routes as literals, dynamic/catch-all as
15
+ * Union of the project's route paths, static routes as literals, dynamic/catch-all as
16
16
  * `` `…/${string}` `` templates. Falls back to `string` before the types are generated.
17
17
  */
18
18
  export type RoutePath = Register extends { routePath: infer P }
@@ -56,12 +56,14 @@ export interface RouteDef {
56
56
  readonly pattern: string;
57
57
  readonly load: () => Promise<{ default: ComponentType }>;
58
58
  readonly layouts?: readonly LayoutComponentLoader[];
59
- /** `template.tsx` chain (root → nested) like layouts, but re-mounted on each navigation. */
59
+ /** `template.tsx` chain (root → nested), like layouts, but re-mounted on each navigation. */
60
60
  readonly templates?: readonly LayoutComponentLoader[];
61
- /** Nearest `loading.tsx` shown as the Suspense fallback while this route loads. */
61
+ /** Nearest `loading.tsx`, shown as the Suspense fallback while this route loads. */
62
62
  readonly loading?: () => Promise<{ default: ComponentType }>;
63
- /** Nearest `error.tsx` rendered by an error boundary around this route. */
63
+ /** Nearest `error.tsx`, rendered by an error boundary around this route. */
64
64
  readonly errorComponent?: () => Promise<{ default: ComponentType<RouteErrorProps> }>;
65
+ /** Intercepting route (`(.)`/`(..)`/`(...)`), matched in its slot only on soft navigation. */
66
+ readonly intercept?: boolean;
65
67
  }
66
68
 
67
69
  /** Optional root layout loader (wraps every page). `null` when the project defines no layout. */
@@ -4,6 +4,10 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
4
4
 
5
5
  import { type InlineConfig } from 'vite';
6
6
 
7
+ import { type SeoConfig } from './seo.js';
8
+
9
+ export type { SeoConfig } from './seo.js';
10
+
7
11
  /**
8
12
  * Client-side (TSX/React/Vite) configuration. All fields optional; sensible defaults applied.
9
13
  */
@@ -24,9 +28,31 @@ export interface ClientConfig {
24
28
  readonly base?: string;
25
29
  /** Dev server port. Default `3000`. */
26
30
  readonly port?: number;
31
+ /**
32
+ * Optimize imported images at build time (resize/convert via `vite-imagetools` + sharp): an
33
+ * import like `logo.png?w=400;800&format=webp&as=srcset` emits resized, compressed variants.
34
+ * Default `true`. Set `false` to disable the pipeline (images are then served as-is).
35
+ */
36
+ readonly images?: boolean;
37
+ /**
38
+ * Preload bundled fonts at build time: injects `<link rel="preload" as="font">` for each
39
+ * `@font-face` font so it loads in parallel with the CSS (faster text paint). Default `true`.
40
+ */
41
+ readonly fonts?: boolean;
42
+ /**
43
+ * Animate cross-page navigations with the browser View Transitions API (a crossfade by default;
44
+ * add `view-transition-name` in CSS for shared-element transitions). Respects
45
+ * `prefers-reduced-motion`. Default `false`.
46
+ */
47
+ readonly viewTransitions?: boolean;
48
+ /**
49
+ * Build-time SEO: bakes site-level metadata into the HTML `<head>` (so JS-less crawlers and AI
50
+ * bots see real tags) and generates `robots.txt`, `sitemap.xml`, and `llms.txt`. Omit to skip.
51
+ */
52
+ readonly seo?: SeoConfig;
27
53
  /**
28
54
  * Raw Vite escape hatch, deep-merged over the framework's opinionated config.
29
- * This is NOT the client config itself toil owns the Vite setup; use this only
55
+ * This is NOT the client config itself, toil owns the Vite setup; use this only
30
56
  * to override specific Vite options.
31
57
  */
32
58
  readonly vite?: InlineConfig;
@@ -68,6 +94,14 @@ export interface ResolvedToilConfig {
68
94
  readonly outDir: string;
69
95
  readonly base: string;
70
96
  readonly port: number;
97
+ /** Whether build-time image optimization (`vite-imagetools`) is enabled. */
98
+ readonly images: boolean;
99
+ /** Whether build-time font preloading is enabled. */
100
+ readonly fonts: boolean;
101
+ /** Whether animated View Transitions are enabled for navigation. */
102
+ readonly viewTransitions: boolean;
103
+ /** Build-time SEO config, or `null` when not configured. */
104
+ readonly seo: SeoConfig | null;
71
105
  /** Absolute path to the framework client runtime (`toiljs/client`). */
72
106
  readonly runtimePath: string;
73
107
  readonly vite: InlineConfig;
@@ -104,8 +138,7 @@ export async function loadConfig(
104
138
  for (const name of CONFIG_NAMES) {
105
139
  const candidate = path.join(root, name);
106
140
  if (fs.existsSync(candidate)) {
107
- // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- dynamic import() is typed `any`
108
- const loaded: { default?: ToilConfig } = await import(pathToFileURL(candidate).href);
141
+ const loaded = (await import(pathToFileURL(candidate).href)) as { default?: ToilConfig };
109
142
  if (loaded.default) user = loaded.default;
110
143
  break;
111
144
  }
@@ -128,6 +161,10 @@ export async function loadConfig(
128
161
  outDir: client.outDir ?? 'build/client',
129
162
  base: client.base ?? '/',
130
163
  port: opts.port ?? client.port ?? 3000,
164
+ images: client.images ?? true,
165
+ fonts: client.fonts ?? true,
166
+ viewTransitions: client.viewTransitions ?? false,
167
+ seo: client.seo ?? null,
131
168
  runtimePath: resolveRuntimePath(),
132
169
  vite: client.vite ?? {},
133
170
  };
@@ -10,18 +10,18 @@ import path from 'node:path';
10
10
  /** Shared body for the per-tool pointer files. */
11
11
  const POINTER_BODY = `# toiljs · AI assistant guide
12
12
 
13
- This is a **toiljs** project a full-stack React framework (React + Vite client, file-based
13
+ This is a **toiljs** project, a full-stack React framework (React + Vite client, file-based
14
14
  routing, and a toilscript→WebAssembly server).
15
15
 
16
16
  **Before editing this project, read the generated documentation in \`.toil/docs/\`.** It describes
17
17
  the conventions you must follow:
18
18
 
19
- - \`.toil/docs/index.md\` overview and project layout
20
- - \`.toil/docs/routing.md\` file-based routing, nested layouts, loading / error files
21
- - \`.toil/docs/client.md\` the \`Toil\` global, Link / NavLink, router hooks
22
- - \`.toil/docs/styling.md\` CSS / Sass / Less / Stylus / Tailwind (via \`toiljs configure\`)
23
- - \`.toil/docs/server.md\` the toilscript server target
24
- - \`.toil/docs/cli.md\` toiljs CLI commands
19
+ - \`.toil/docs/index.md\`, overview and project layout
20
+ - \`.toil/docs/routing.md\`, file-based routing, nested layouts, loading / error files
21
+ - \`.toil/docs/client.md\`, the \`Toil\` global, Link / NavLink, router hooks
22
+ - \`.toil/docs/styling.md\`, CSS / Sass / Less / Stylus / Tailwind (via \`toiljs configure\`)
23
+ - \`.toil/docs/server.md\`, the toilscript server target
24
+ - \`.toil/docs/cli.md\`, toiljs CLI commands
25
25
 
26
26
  \`.toil/docs/\` is regenerated by toiljs; do not edit it by hand. This pointer file is yours to edit.
27
27
  `;
@@ -82,10 +82,10 @@ export const TOIL_DOCS: Record<string, string> = {
82
82
  '',
83
83
  '## Project layout',
84
84
  '',
85
- '- `client/` the app: `routes/` (file-based), `layout.tsx`, `components/`, `styles/`,',
85
+ '- `client/`, the app: `routes/` (file-based), `layout.tsx`, `components/`, `styles/`,',
86
86
  ' `public/`, and `toil.tsx` (the entry that calls `Toil.mount`).',
87
- '- `server/` the toilscript → WASM target (`@main` entry), compiled by `toilscript`.',
88
- '- `toil.config.ts` configuration via `defineConfig` (`toiljs.config.ts` also works).',
87
+ '- `server/`, the toilscript → WASM target (`@main` entry), compiled by `toilscript`.',
88
+ '- `toil.config.ts`, configuration via `defineConfig` (`toiljs.config.ts` also works).',
89
89
  '- Generated, gitignored, do not edit: `.toil/` (working dir), `toil-env.d.ts` (ambient',
90
90
  ' globals), `toil-routes.d.ts` (typed routes).',
91
91
  '',
@@ -106,17 +106,17 @@ export const TOIL_DOCS: Record<string, string> = {
106
106
  '## Route files',
107
107
  '',
108
108
  '- `index.tsx` → `/`, `about.tsx` → `/about`, `blog/index.tsx` → `/blog`',
109
- '- `[id].tsx` → dynamic `/:id` read with `Toil.useParams<{ id: string }>()`',
109
+ '- `[id].tsx` → dynamic `/:id`, read with `Toil.useParams<{ id: string }>()`',
110
110
  '- `[...slug].tsx` → catch-all (1+ segments); `[[...slug]].tsx` → optional catch-all (0+)',
111
111
  '- `(group)/` → route group: groups files / scopes a layout, adds no URL segment',
112
112
  '',
113
113
  '## Special files',
114
114
  '',
115
- '- `layout.tsx` wraps the routes beneath it. Root `client/layout.tsx` wraps everything;',
115
+ '- `layout.tsx`, wraps the routes beneath it. Root `client/layout.tsx` wraps everything;',
116
116
  ' nested `routes/**/layout.tsx` compose inside it.',
117
- '- `loading.tsx` Suspense fallback shown while a route (chunk + loader) loads',
118
- '- `error.tsx` error boundary; receives `{ error, reset }` (`Toil.RouteErrorProps`)',
119
- '- `client/404.tsx` shown when no route matches',
117
+ '- `loading.tsx`, Suspense fallback shown while a route (chunk + loader) loads',
118
+ '- `error.tsx`, error boundary; receives `{ error, reset }` (`Toil.RouteErrorProps`)',
119
+ '- `client/404.tsx`, shown when no route matches',
120
120
  '',
121
121
  '## Data loaders',
122
122
  '',
@@ -138,12 +138,12 @@ export const TOIL_DOCS: Record<string, string> = {
138
138
  '- `Toil.Link` / `Toil.NavLink` (adds an active class) / `Toil.useRouter()`',
139
139
  ' (push / replace / back / forward / refresh / prefetch)',
140
140
  '- `Toil.useParams`, `usePathname`, `useSearchParams`, `useNavigationPending`',
141
- '- Hrefs are type-checked against your routes a typo is a compile error.',
141
+ '- Hrefs are type-checked against your routes, a typo is a compile error.',
142
142
  ]),
143
143
  'client.md': doc([
144
144
  '# Client runtime',
145
145
  '',
146
- 'Everything is on the `Toil` global no imports needed in route files.',
146
+ 'Everything is on the `Toil` global, no imports needed in route files.',
147
147
  '',
148
148
  '## Entry',
149
149
  '',
@@ -159,7 +159,7 @@ export const TOIL_DOCS: Record<string, string> = {
159
159
  '- Navigation: `navigate`, `useRouter`, `useNavigate`',
160
160
  '- Location: `usePathname`, `useSearchParams`, `useParams`, `useNavigationPending`',
161
161
  '- Data: `useLoaderData` (see `routing.md`)',
162
- '- Head: `useHead`, `useTitle`, `<Head>` set the `<title>` / meta per route',
162
+ '- Head: `useHead`, `useTitle`, `<Head>`, set the `<title>` / meta per route',
163
163
  '- Realtime: `useChannel`, `connectChannel` (WebSocket to the backend at `/_toil`)',
164
164
  '- IO globals (no `Toil.` prefix): `BinaryWriter`, `BinaryReader`, `FastMap`, `FastSet`',
165
165
  '',
@@ -198,9 +198,9 @@ export const TOIL_DOCS: Record<string, string> = {
198
198
  '',
199
199
  '`server/` is the toilscript source, compiled to WebAssembly by `toilscript`.',
200
200
  '',
201
- '- `server/main.ts` the `@main` entry, exported as the WASM `main`.',
202
- '- `server/index.ts` your functions.',
203
- '- `server/tsconfig.json` extends `toilscript/std/assembly.json` (AssemblyScript/toilscript',
201
+ '- `server/main.ts`, the `@main` entry, exported as the WASM `main`.',
202
+ '- `server/index.ts`, your functions.',
203
+ '- `server/tsconfig.json`, extends `toilscript/std/assembly.json` (AssemblyScript/toilscript',
204
204
  ' globals like `i32`, not the DOM), so editors resolve server types correctly.',
205
205
  '- `npm run build:server` (or `npm run build`) emits `build/server/release.wasm`.',
206
206
  '',
@@ -209,12 +209,12 @@ export const TOIL_DOCS: Record<string, string> = {
209
209
  'cli.md': doc([
210
210
  '# CLI',
211
211
  '',
212
- '- `toiljs create [name]` scaffold a project. Flags: `--template app|minimal`,',
212
+ '- `toiljs create [name]`, scaffold a project. Flags: `--template app|minimal`,',
213
213
  ' `--style css|sass|less|stylus`, `--tailwind`, `--no-ai`, `-y`/`--yes`.',
214
- '- `toiljs dev` dev server with HMR (`--port`, `--root`).',
215
- '- `toiljs build` production build → `build/client` (chain `toilscript` for the server).',
216
- '- `toiljs start` self-host the built app (hyper-express) with a `/_toil` WebSocket channel.',
217
- '- `toiljs configure` toggle styling features on an existing project (see `styling.md`).',
214
+ '- `toiljs dev`, dev server with HMR (`--port`, `--root`).',
215
+ '- `toiljs build`, production build → `build/client` (chain `toilscript` for the server).',
216
+ '- `toiljs start`, self-host the built app (hyper-express) with a `/_toil` WebSocket channel.',
217
+ '- `toiljs configure`, toggle styling features on an existing project (see `styling.md`).',
218
218
  ]),
219
219
  };
220
220
 
@@ -0,0 +1,87 @@
1
+ import type { HtmlTagDescriptor, Logger, Plugin } from 'vite';
2
+
3
+ import { type ResolvedToilConfig } from './config.js';
4
+
5
+ /**
6
+ * Build-time font optimization. Bundled font files (`@font-face` `url(...)` imports) are emitted +
7
+ * hashed by Vite, but without a hint the browser only discovers them after parsing CSS, delaying
8
+ * text paint. This injects a `<link rel="preload" as="font" crossorigin>` for each bundled font so
9
+ * it loads in parallel with the CSS, and logs what it preloaded (mirrors the image-optimization log).
10
+ */
11
+ const FONT_RE = /\.(woff2|woff|ttf|otf)$/i;
12
+ const FONT_TYPE: Record<string, string> = {
13
+ woff2: 'font/woff2',
14
+ woff: 'font/woff',
15
+ ttf: 'font/ttf',
16
+ otf: 'font/otf',
17
+ };
18
+
19
+ function kb(bytes: number): string {
20
+ return `${(bytes / 1000).toFixed(2)} kB`;
21
+ }
22
+
23
+ /** Builds the `<link rel="preload">` head tags for a set of bundled font file names. */
24
+ export function fontPreloadTags(fileNames: readonly string[], base: string): HtmlTagDescriptor[] {
25
+ const prefix = base.endsWith('/') ? base : `${base}/`;
26
+ return fileNames
27
+ .filter((name) => FONT_RE.test(name))
28
+ .map((name) => {
29
+ const ext = name.split('.').pop()?.toLowerCase() ?? '';
30
+ return {
31
+ tag: 'link',
32
+ attrs: {
33
+ rel: 'preload',
34
+ as: 'font',
35
+ type: FONT_TYPE[ext] ?? `font/${ext}`,
36
+ href: `${prefix}${name}`,
37
+ crossorigin: '',
38
+ },
39
+ injectTo: 'head',
40
+ };
41
+ });
42
+ }
43
+
44
+ /** Build-only plugin that preloads bundled fonts and logs them. Disabled by `client.fonts: false`. */
45
+ export function fontPreloadPlugin(cfg: ResolvedToilConfig): Plugin {
46
+ let logger: Logger | undefined;
47
+ let logged = false;
48
+ return {
49
+ name: 'toil:font-preload',
50
+ apply: 'build',
51
+ configResolved(config) {
52
+ logger = config.logger;
53
+ },
54
+ transformIndexHtml: {
55
+ order: 'post',
56
+ handler(html, ctx) {
57
+ const bundle = ctx.bundle ?? {};
58
+ const fonts = Object.values(bundle).filter(
59
+ (file) => file.type === 'asset' && FONT_RE.test(file.fileName),
60
+ );
61
+ if (fonts.length === 0) return html;
62
+
63
+ // Log once (the same template's HTML is transformed per emitted page).
64
+ if (!logged && logger) {
65
+ logged = true;
66
+ logger.info('');
67
+ logger.info(` ✓ preloaded ${String(fonts.length)} font${fonts.length === 1 ? '' : 's'}`);
68
+ for (const file of fonts) {
69
+ const size =
70
+ file.type === 'asset' && typeof file.source !== 'string'
71
+ ? kb(file.source.byteLength)
72
+ : '';
73
+ logger.info(` → ${file.fileName} ${size}`);
74
+ }
75
+ }
76
+
77
+ return {
78
+ html,
79
+ tags: fontPreloadTags(
80
+ fonts.map((f) => f.fileName),
81
+ cfg.base,
82
+ ),
83
+ };
84
+ },
85
+ },
86
+ };
87
+ }