@timber-js/app 0.2.0-alpha.87 → 0.2.0-alpha.89

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 (57) hide show
  1. package/LICENSE +8 -0
  2. package/dist/client/index.d.ts +44 -1
  3. package/dist/client/index.d.ts.map +1 -1
  4. package/dist/client/index.js.map +1 -1
  5. package/dist/client/link.d.ts +7 -44
  6. package/dist/client/link.d.ts.map +1 -1
  7. package/dist/config-types.d.ts +39 -0
  8. package/dist/config-types.d.ts.map +1 -1
  9. package/dist/config-validation.d.ts.map +1 -1
  10. package/dist/fonts/bundle.d.ts +48 -0
  11. package/dist/fonts/bundle.d.ts.map +1 -0
  12. package/dist/fonts/dev-middleware.d.ts +22 -0
  13. package/dist/fonts/dev-middleware.d.ts.map +1 -0
  14. package/dist/fonts/pipeline.d.ts +138 -0
  15. package/dist/fonts/pipeline.d.ts.map +1 -0
  16. package/dist/fonts/transform.d.ts +72 -0
  17. package/dist/fonts/transform.d.ts.map +1 -0
  18. package/dist/fonts/types.d.ts +45 -1
  19. package/dist/fonts/types.d.ts.map +1 -1
  20. package/dist/fonts/virtual-modules.d.ts +59 -0
  21. package/dist/fonts/virtual-modules.d.ts.map +1 -0
  22. package/dist/index.js +753 -575
  23. package/dist/index.js.map +1 -1
  24. package/dist/plugins/entries.d.ts.map +1 -1
  25. package/dist/plugins/fonts.d.ts +16 -83
  26. package/dist/plugins/fonts.d.ts.map +1 -1
  27. package/dist/server/action-client.d.ts +8 -0
  28. package/dist/server/action-client.d.ts.map +1 -1
  29. package/dist/server/action-handler.d.ts +7 -0
  30. package/dist/server/action-handler.d.ts.map +1 -1
  31. package/dist/server/index.js +158 -2
  32. package/dist/server/index.js.map +1 -1
  33. package/dist/server/route-matcher.d.ts +7 -0
  34. package/dist/server/route-matcher.d.ts.map +1 -1
  35. package/dist/server/rsc-entry/index.d.ts.map +1 -1
  36. package/dist/server/sensitive-fields.d.ts +74 -0
  37. package/dist/server/sensitive-fields.d.ts.map +1 -0
  38. package/package.json +6 -7
  39. package/src/cli.ts +0 -0
  40. package/src/client/index.ts +77 -1
  41. package/src/client/link.tsx +15 -65
  42. package/src/config-types.ts +39 -0
  43. package/src/config-validation.ts +7 -3
  44. package/src/fonts/bundle.ts +142 -0
  45. package/src/fonts/dev-middleware.ts +74 -0
  46. package/src/fonts/pipeline.ts +275 -0
  47. package/src/fonts/transform.ts +353 -0
  48. package/src/fonts/types.ts +50 -1
  49. package/src/fonts/virtual-modules.ts +159 -0
  50. package/src/plugins/entries.ts +37 -0
  51. package/src/plugins/fonts.ts +102 -704
  52. package/src/plugins/routing.ts +6 -5
  53. package/src/server/action-client.ts +34 -4
  54. package/src/server/action-handler.ts +32 -2
  55. package/src/server/route-matcher.ts +7 -0
  56. package/src/server/rsc-entry/index.ts +19 -3
  57. package/src/server/sensitive-fields.ts +230 -0
@@ -1 +1 @@
1
- {"version":3,"file":"route-matcher.d.ts","sourceRoot":"","sources":["../../src/server/route-matcher.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,eAAe,CAAC;AAEhD,OAAO,EAIL,KAAK,iBAAiB,EACvB,MAAM,sBAAsB,CAAC;AAM9B,6DAA6D;AAC7D,UAAU,YAAY;IACpB,IAAI,EAAE,MAAM,OAAO,CAAC,OAAO,CAAC,CAAC;IAC7B,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED,gFAAgF;AAChF,MAAM,WAAW,mBAAmB;IAClC,WAAW,EAAE,MAAM,CAAC;IACpB,WAAW,EACP,QAAQ,GACR,SAAS,GACT,WAAW,GACX,oBAAoB,GACpB,OAAO,GACP,MAAM,GACN,cAAc,CAAC;IACnB,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,8DAA8D;IAC9D,kBAAkB,CAAC,EAAE,KAAK,GAAG,MAAM,GAAG,OAAO,GAAG,UAAU,CAAC;IAC3D,8EAA8E;IAC9E,sBAAsB,CAAC,EAAE,MAAM,CAAC;IAEhC,IAAI,CAAC,EAAE,YAAY,CAAC;IACpB,MAAM,CAAC,EAAE,YAAY,CAAC;IACtB,UAAU,CAAC,EAAE,YAAY,CAAC;IAC1B,MAAM,CAAC,EAAE,YAAY,CAAC;IACtB,KAAK,CAAC,EAAE,YAAY,CAAC;IACrB,2FAA2F;IAC3F,MAAM,CAAC,EAAE,YAAY,CAAC;IACtB,KAAK,CAAC,EAAE,YAAY,CAAC;IACrB,OAAO,CAAC,EAAE,YAAY,CAAC;IACvB,MAAM,CAAC,EAAE,YAAY,CAAC;IACtB,WAAW,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,YAAY,CAAC,CAAC;IAC3C,eAAe,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,YAAY,CAAC,CAAC;IAC/C,iBAAiB,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,YAAY,CAAC,CAAC;IACjD,sFAAsF;IACtF,cAAc,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,YAAY,CAAC,CAAC;IAE9C,QAAQ,EAAE,mBAAmB,EAAE,CAAC;IAChC,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,mBAAmB,CAAC,CAAC;CAC5C;AAED,6DAA6D;AAC7D,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,mBAAmB,CAAC;IAC1B,KAAK,CAAC,EAAE,YAAY,CAAC;IACrB;;;;;OAKG;IACH,WAAW,CAAC,EAAE,YAAY,CAAC;CAC5B;AAID;;;;;GAKG;AACH,wBAAgB,kBAAkB,CAChC,QAAQ,EAAE,YAAY,GACrB,CAAC,QAAQ,EAAE,MAAM,KAAK,UAAU,GAAG,IAAI,CAEzC;AAyMD,2CAA2C;AAC3C,MAAM,WAAW,kBAAkB;IACjC,4DAA4D;IAC5D,IAAI,EAAE,iBAAiB,CAAC;IACxB,4CAA4C;IAC5C,WAAW,EAAE,MAAM,CAAC;IACpB,0DAA0D;IAC1D,IAAI,EAAE,YAAY,CAAC;IACnB,0DAA0D;IAC1D,OAAO,EAAE,mBAAmB,CAAC;IAC7B;;;OAGG;IACH,QAAQ,EAAE,OAAO,CAAC;CACnB;AAED;;;;;;;GAOG;AACH,wBAAgB,0BAA0B,CACxC,QAAQ,EAAE,YAAY,GACrB,CAAC,QAAQ,EAAE,MAAM,KAAK,kBAAkB,GAAG,IAAI,CAMjD"}
1
+ {"version":3,"file":"route-matcher.d.ts","sourceRoot":"","sources":["../../src/server/route-matcher.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,eAAe,CAAC;AAEhD,OAAO,EAIL,KAAK,iBAAiB,EACvB,MAAM,sBAAsB,CAAC;AAM9B,6DAA6D;AAC7D,UAAU,YAAY;IACpB,IAAI,EAAE,MAAM,OAAO,CAAC,OAAO,CAAC,CAAC;IAC7B,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED,gFAAgF;AAChF,MAAM,WAAW,mBAAmB;IAClC,WAAW,EAAE,MAAM,CAAC;IACpB,WAAW,EACP,QAAQ,GACR,SAAS,GACT,WAAW,GACX,oBAAoB,GACpB,OAAO,GACP,MAAM,GACN,cAAc,CAAC;IACnB,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,8DAA8D;IAC9D,kBAAkB,CAAC,EAAE,KAAK,GAAG,MAAM,GAAG,OAAO,GAAG,UAAU,CAAC;IAC3D,8EAA8E;IAC9E,sBAAsB,CAAC,EAAE,MAAM,CAAC;IAEhC,IAAI,CAAC,EAAE,YAAY,CAAC;IACpB,MAAM,CAAC,EAAE,YAAY,CAAC;IACtB,UAAU,CAAC,EAAE,YAAY,CAAC;IAC1B,MAAM,CAAC,EAAE,YAAY,CAAC;IACtB,KAAK,CAAC,EAAE,YAAY,CAAC;IACrB,2FAA2F;IAC3F,MAAM,CAAC,EAAE,YAAY,CAAC;IACtB,KAAK,CAAC,EAAE,YAAY,CAAC;IACrB,OAAO,CAAC,EAAE,YAAY,CAAC;IACvB,MAAM,CAAC,EAAE,YAAY,CAAC;IACtB,WAAW,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,YAAY,CAAC,CAAC;IAC3C,eAAe,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,YAAY,CAAC,CAAC;IAC/C,iBAAiB,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,YAAY,CAAC,CAAC;IACjD,sFAAsF;IACtF,cAAc,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,YAAY,CAAC,CAAC;IAE9C,QAAQ,EAAE,mBAAmB,EAAE,CAAC;IAChC,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,mBAAmB,CAAC,CAAC;CAC5C;AAED,6DAA6D;AAC7D,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,mBAAmB,CAAC;IAC1B;;;;;OAKG;IACH,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,CAAC,EAAE,YAAY,CAAC;IACrB;;;;;OAKG;IACH,WAAW,CAAC,EAAE,YAAY,CAAC;CAC5B;AAID;;;;;GAKG;AACH,wBAAgB,kBAAkB,CAChC,QAAQ,EAAE,YAAY,GACrB,CAAC,QAAQ,EAAE,MAAM,KAAK,UAAU,GAAG,IAAI,CAEzC;AAyMD,2CAA2C;AAC3C,MAAM,WAAW,kBAAkB;IACjC,4DAA4D;IAC5D,IAAI,EAAE,iBAAiB,CAAC;IACxB,4CAA4C;IAC5C,WAAW,EAAE,MAAM,CAAC;IACpB,0DAA0D;IAC1D,IAAI,EAAE,YAAY,CAAC;IACnB,0DAA0D;IAC1D,OAAO,EAAE,mBAAmB,CAAC;IAC7B;;;OAGG;IACH,QAAQ,EAAE,OAAO,CAAC;CACnB;AAED;;;;;;;GAOG;AACH,wBAAgB,0BAA0B,CACxC,QAAQ,EAAE,YAAY,GACrB,CAAC,QAAQ,EAAE,MAAM,KAAK,kBAAkB,GAAG,IAAI,CAMjD"}
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/server/rsc-entry/index.ts"],"names":[],"mappings":"AA4DA,OAAO,EAKL,KAAK,mBAAmB,EACzB,MAAM,cAAc,CAAC;AAoCtB;;;;;;;;;GASG;AACH,wBAAgB,0BAA0B,CACxC,OAAO,EAAE,CAAC,KAAK,EAAE,KAAK,EAAE,KAAK,EAAE,MAAM,EAAE,eAAe,CAAC,EAAE,mBAAmB,EAAE,KAAK,IAAI,GACtF,IAAI,CAEN;AAED;;;;;;;;;GASG;AACH,wBAAgB,sBAAsB,CAAC,OAAO,EAAE,CAAC,KAAK,EAAE,KAAK,KAAK,IAAI,GAAG,IAAI,CAE5E;AAqiBD,OAAO,EAAE,uBAAuB,EAAE,MAAM,0BAA0B,CAAC;AAInE,OAAO,EAAE,gBAAgB,EAAE,MAAM,wBAAwB,CAAC;8BA3SrC,OAAO,KAAG,OAAO,CAAC,QAAQ,CAAC;AA6ShD,wBAAiE"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/server/rsc-entry/index.ts"],"names":[],"mappings":"AA4DA,OAAO,EAKL,KAAK,mBAAmB,EACzB,MAAM,cAAc,CAAC;AAoCtB;;;;;;;;;GASG;AACH,wBAAgB,0BAA0B,CACxC,OAAO,EAAE,CAAC,KAAK,EAAE,KAAK,EAAE,KAAK,EAAE,MAAM,EAAE,eAAe,CAAC,EAAE,mBAAmB,EAAE,KAAK,IAAI,GACtF,IAAI,CAEN;AAED;;;;;;;;;GASG;AACH,wBAAgB,sBAAsB,CAAC,OAAO,EAAE,CAAC,KAAK,EAAE,KAAK,KAAK,IAAI,GAAG,IAAI,CAE5E;AAqjBD,OAAO,EAAE,uBAAuB,EAAE,MAAM,0BAA0B,CAAC;AAInE,OAAO,EAAE,gBAAgB,EAAE,MAAM,wBAAwB,CAAC;8BA5SrC,OAAO,KAAG,OAAO,CAAC,QAAQ,CAAC;AA8ShD,wBAAiE"}
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Sensitive field stripping — removes password/token/CVV-style fields
3
+ * from form values before they are echoed back to the client as
4
+ * `submittedValues` for form repopulation.
5
+ *
6
+ * Applied to both action paths:
7
+ * - With-JS action path: `createActionClient()` in `action-client.ts`
8
+ * - No-JS form POST path: `handleFormAction()` in `action-handler.ts`
9
+ *
10
+ * Why: on a validation failure, timber echoes submitted form values back so
11
+ * the user doesn't have to re-type everything. Without filtering, plaintext
12
+ * passwords / credit-card numbers / TOTP codes would travel through the RSC
13
+ * stream (with-JS) or land in the HTML as `defaultValue` attributes (no-JS)
14
+ * — ending up in browser history, proxy logs, disk caches, and the
15
+ * back-forward cache.
16
+ *
17
+ * Safe by default: the built-in deny-list is applied unconditionally unless
18
+ * the user explicitly opts out via `forms.stripSensitiveFields: false` in
19
+ * `timber.config.ts` or per-action via `createActionClient({ stripSensitiveFields: false })`.
20
+ *
21
+ * See design/08-forms-and-actions.md §"Validation errors"
22
+ * See design/13-security.md §"Sensitive field stripping"
23
+ * See TIM-816
24
+ */
25
+ /**
26
+ * How to strip sensitive fields from `submittedValues`.
27
+ *
28
+ * - `true` / `undefined` — use the built-in deny-list (default, safe).
29
+ * - `false` — do not strip anything (dev convenience; never do this in prod).
30
+ * - `string[]` — additional field names to strip, merged with the built-in list.
31
+ * - `(name) => boolean` — custom predicate, fully replaces the built-in list.
32
+ * Return `true` to strip, `false` to keep. The `name` argument is the raw
33
+ * (un-normalized) field name as it appeared in the submitted form.
34
+ */
35
+ export type SensitiveFieldsOption = boolean | readonly string[] | ((name: string) => boolean);
36
+ /**
37
+ * A resolved predicate: `null` means "don't strip anything" (the option was
38
+ * explicitly `false`). Otherwise a function from raw field name → boolean.
39
+ */
40
+ export type ResolvedSensitivePredicate = ((name: string) => boolean) | null;
41
+ /**
42
+ * Resolve a `SensitiveFieldsOption` into a concrete predicate.
43
+ * Precedence: per-action > global > built-in default.
44
+ *
45
+ * - Per-action `undefined` → fall back to global.
46
+ * - Global `undefined` → use built-in list.
47
+ * - Either level set to `false` → disable stripping entirely (returns `null`).
48
+ * - `true` → built-in list.
49
+ * - `string[]` → built-in ∪ extras.
50
+ * - function → custom, replaces the built-in list entirely.
51
+ */
52
+ export declare function resolveSensitivePredicate(perAction: SensitiveFieldsOption | undefined, global: SensitiveFieldsOption | undefined): ResolvedSensitivePredicate;
53
+ /**
54
+ * Set the global `forms.stripSensitiveFields` config from `timber.config.ts`.
55
+ * Called once at startup from `rsc-entry`.
56
+ */
57
+ export declare function setGlobalSensitiveFieldsConfig(option: SensitiveFieldsOption | undefined): void;
58
+ /** Read the global `forms.stripSensitiveFields` config. */
59
+ export declare function getGlobalSensitiveFieldsConfig(): SensitiveFieldsOption | undefined;
60
+ /**
61
+ * Walk an object (recursively) and return a copy with every key matching
62
+ * `predicate` removed. Nested objects like `{ user: { password: '...' } }`
63
+ * are handled — `user.password` is stripped while other `user.*` fields remain.
64
+ *
65
+ * - Arrays are walked element-wise (object entries inside arrays are cleaned).
66
+ * - Non-plain values (strings, numbers, Files, Dates, etc.) are returned as-is.
67
+ * - When a stripped key is encountered, it is omitted from the result entirely
68
+ * — we do NOT set it to an empty string, because that would overwrite a
69
+ * valid `defaultValue` the form author might have set.
70
+ */
71
+ export declare function stripSensitiveFields<T>(value: T, predicate: ResolvedSensitivePredicate): T;
72
+ /** Reset the "warned once" cache. Exposed for tests. */
73
+ export declare function __resetSensitiveFieldsWarnings(): void;
74
+ //# sourceMappingURL=sensitive-fields.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sensitive-fields.d.ts","sourceRoot":"","sources":["../../src/server/sensitive-fields.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AAMH;;;;;;;;;GASG;AACH,MAAM,MAAM,qBAAqB,GAAG,OAAO,GAAG,SAAS,MAAM,EAAE,GAAG,CAAC,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,CAAC;AAoF9F;;;GAGG;AACH,MAAM,MAAM,0BAA0B,GAAG,CAAC,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,GAAG,IAAI,CAAC;AAE5E;;;;;;;;;;GAUG;AACH,wBAAgB,yBAAyB,CACvC,SAAS,EAAE,qBAAqB,GAAG,SAAS,EAC5C,MAAM,EAAE,qBAAqB,GAAG,SAAS,GACxC,0BAA0B,CAa5B;AAMD;;;GAGG;AACH,wBAAgB,8BAA8B,CAAC,MAAM,EAAE,qBAAqB,GAAG,SAAS,GAAG,IAAI,CAE9F;AAED,2DAA2D;AAC3D,wBAAgB,8BAA8B,IAAI,qBAAqB,GAAG,SAAS,CAElF;AAkBD;;;;;;;;;;GAUG;AACH,wBAAgB,oBAAoB,CAAC,CAAC,EAAE,KAAK,EAAE,CAAC,EAAE,SAAS,EAAE,0BAA0B,GAAG,CAAC,CAoB1F;AAID,wDAAwD;AACxD,wBAAgB,8BAA8B,IAAI,IAAI,CAErD"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@timber-js/app",
3
- "version": "0.2.0-alpha.87",
3
+ "version": "0.2.0-alpha.89",
4
4
  "description": "Vite-native React framework built for Servers and Serverless Platforms — correct HTTP semantics, real status codes, pages that work without JavaScript",
5
5
  "keywords": [
6
6
  "cloudflare-workers",
@@ -110,11 +110,6 @@
110
110
  "publishConfig": {
111
111
  "access": "public"
112
112
  },
113
- "scripts": {
114
- "build": "vite build --config vite.lib.config.ts && tsc --emitDeclarationOnly --project tsconfig.json --outDir dist",
115
- "typecheck": "tsgo --noEmit",
116
- "prepublishOnly": "pnpm run build"
117
- },
118
113
  "dependencies": {
119
114
  "@opentelemetry/api": "^1.9.1",
120
115
  "@opentelemetry/context-async-hooks": "^2.6.1",
@@ -157,5 +152,9 @@
157
152
  },
158
153
  "engines": {
159
154
  "node": ">=22.12.0"
155
+ },
156
+ "scripts": {
157
+ "build": "vite build --config vite.lib.config.ts && tsc --emitDeclarationOnly --project tsconfig.json --outDir dist",
158
+ "typecheck": "tsgo --noEmit"
160
159
  }
161
- }
160
+ }
package/src/cli.ts CHANGED
File without changes
@@ -10,10 +10,86 @@
10
10
  export type { RenderErrorDigest } from './types';
11
11
 
12
12
  // Navigation
13
+ import type { JSX } from 'react';
14
+ import type { SearchParamsDefinition } from '../search-params/define.js';
15
+ import type { LinkBaseProps } from './link';
13
16
  export { Link, interpolateParams, resolveHref, validateLinkHref, buildLinkProps } from './link';
14
17
  export { mergePreservedSearchParams } from '../shared/merge-search-params.js';
15
- export type { LinkFunction, LinkProps, LinkPropsWithHref, LinkPropsWithParams } from './link';
18
+ export type { LinkProps, LinkPropsWithHref, LinkPropsWithParams, LinkBaseProps } from './link';
16
19
  export type { LinkSegmentParams, OnNavigateHandler, OnNavigateEvent } from './link';
20
+
21
+ // ─── LinkFunction (originally declared here for module augmentation) ───
22
+ //
23
+ // The codegen emits `declare module '@timber-js/app/client' { interface
24
+ // LinkFunction { ... } }` to add per-route call signatures. TypeScript
25
+ // interface merging via module augmentation only merges with interfaces
26
+ // that are ORIGINALLY DECLARED in the augmented module — a re-exported
27
+ // type binding (`export type { LinkFunction } from './link'`) creates a
28
+ // distinct, unmergeable name. So `LinkFunction` and its supporting
29
+ // `ExternalHref` type must live here, not in `./link.tsx`. The `Link`
30
+ // const in `./link.tsx` imports this interface as a type-only import.
31
+ // See TIM-624.
32
+
33
+ /**
34
+ * Href types accepted by the catch-all (non-route) call signature.
35
+ *
36
+ * - External protocols: https://, http://, mailto:, tel:, ftp://
37
+ * - Hash-only and query-only links: #section, ?param=value
38
+ * - Computed `string` variables (non-literal)
39
+ *
40
+ * Internal path literals like "/typo-route" do NOT match — they must
41
+ * be a known route (from codegen) or stored in a `string` variable.
42
+ * This catches wrong hrefs at the type level.
43
+ */
44
+ type ExternalHref =
45
+ | `http://${string}`
46
+ | `https://${string}`
47
+ | `mailto:${string}`
48
+ | `tel:${string}`
49
+ | `ftp://${string}`
50
+ | `//${string}`
51
+ | `#${string}`
52
+ | `?${string}`;
53
+
54
+ /**
55
+ * Callable interface for the Link component.
56
+ *
57
+ * Two kinds of call signatures:
58
+ * 1. Per-route (added by codegen via interface merging): DIRECT types
59
+ * for segmentParams — preserves TypeScript excess property checking.
60
+ * 2. Catch-all (below): accepts external hrefs and computed `string`
61
+ * variables. Does NOT accept internal path literals — those must
62
+ * match a known route from the codegen.
63
+ */
64
+ export interface LinkFunction {
65
+ // External links (literal protocol hrefs)
66
+ (
67
+ props: LinkBaseProps & {
68
+ href: ExternalHref;
69
+ segmentParams?: never;
70
+ searchParams?: {
71
+ definition: SearchParamsDefinition<Record<string, unknown>>;
72
+ values: Record<string, unknown>;
73
+ };
74
+ }
75
+ ): JSX.Element;
76
+ // Computed/variable href (non-literal string) — e.g. href={myVar}
77
+ // `string extends H` is true only when H is the wide `string` type,
78
+ // not a specific literal. Template literal hrefs like `/blog/${slug}`
79
+ // are handled by resolved-pattern signatures in the codegen.
80
+ <H extends string>(
81
+ props: string extends H
82
+ ? LinkBaseProps & {
83
+ href: H;
84
+ segmentParams?: Record<string, string | number | string[]>;
85
+ searchParams?: {
86
+ definition: SearchParamsDefinition<Record<string, unknown>>;
87
+ values: Record<string, unknown>;
88
+ };
89
+ }
90
+ : never
91
+ ): JSX.Element;
92
+ }
17
93
  export { usePendingNavigation } from './use-pending-navigation';
18
94
  export { useLinkStatus, LinkStatusContext } from './use-link-status';
19
95
  export type { LinkStatus } from './use-link-status';
@@ -23,11 +23,11 @@ import {
23
23
  useEffect,
24
24
  useRef,
25
25
  type AnchorHTMLAttributes,
26
- type JSX,
27
26
  type ReactNode,
28
27
  type MouseEvent as ReactMouseEvent,
29
28
  } from 'react';
30
29
  import type { SearchParamsDefinition } from '../search-params/define.js';
30
+ import type { LinkFunction } from './index.js';
31
31
  import { classifyUrlSegment, type UrlSegment } from '../routing/segment-classify.js';
32
32
  import { LinkStatusContext } from './use-link-status.js';
33
33
  import { getRouterOrNull } from './router-ref.js';
@@ -68,8 +68,12 @@ export type OnNavigateHandler = (e: OnNavigateEvent) => void;
68
68
 
69
69
  /**
70
70
  * Base props shared by all Link variants.
71
+ *
72
+ * Exported so the public `LinkFunction` interface (declared in
73
+ * `./index.ts`, where module augmentation can merge into it) can
74
+ * compose this without duplication.
71
75
  */
72
- interface LinkBaseProps extends Omit<AnchorHTMLAttributes<HTMLAnchorElement>, 'href'> {
76
+ export interface LinkBaseProps extends Omit<AnchorHTMLAttributes<HTMLAnchorElement>, 'href'> {
73
77
  /** Prefetch the RSC payload on hover */
74
78
  prefetch?: boolean;
75
79
  /**
@@ -114,69 +118,15 @@ export type LinkSegmentParams<T> = {
114
118
  };
115
119
 
116
120
  // ─── External Href Types ─────────────────────────────────────────
117
-
118
- /**
119
- * Href types accepted by the catch-all (non-route) call signature.
120
- *
121
- * - External protocols: https://, http://, mailto:, tel:, ftp://
122
- * - Hash-only and query-only links: #section, ?param=value
123
- * - Computed `string` variables (non-literal)
124
- *
125
- * Internal path literals like "/typo-route" do NOT match — they must
126
- * be a known route (from codegen) or stored in a `string` variable.
127
- * This catches wrong hrefs at the type level. See TIM-624.
128
- */
129
- type ExternalHref =
130
- | `http://${string}`
131
- | `https://${string}`
132
- | `mailto:${string}`
133
- | `tel:${string}`
134
- | `ftp://${string}`
135
- | `//${string}`
136
- | `#${string}`
137
- | `?${string}`;
138
-
139
- /**
140
- * Callable interface for the Link component.
141
- *
142
- * Two kinds of call signatures:
143
- * 1. Per-route (added by codegen via interface merging): DIRECT types
144
- * for segmentParams — preserves TypeScript excess property checking.
145
- * 2. Catch-all (below): accepts external hrefs and computed `string`
146
- * variables. Does NOT accept internal path literals — those must
147
- * match a known route from the codegen.
148
- *
149
- * See TIM-624.
150
- */
151
- export interface LinkFunction {
152
- // External links (literal protocol hrefs)
153
- (
154
- props: LinkBaseProps & {
155
- href: ExternalHref;
156
- segmentParams?: never;
157
- searchParams?: {
158
- definition: SearchParamsDefinition<Record<string, unknown>>;
159
- values: Record<string, unknown>;
160
- };
161
- }
162
- ): JSX.Element;
163
- // Computed/variable href (non-literal string) — e.g. href={myVar}
164
- // `string extends H` is true only when H is the wide `string` type,
165
- // not a specific literal. Template literal hrefs like `/blog/${slug}`
166
- // are handled by resolved-pattern signatures in the codegen.
167
- <H extends string>(
168
- props: string extends H
169
- ? LinkBaseProps & {
170
- href: H;
171
- segmentParams?: Record<string, string | number | string[]>;
172
- searchParams?: {
173
- definition: SearchParamsDefinition<Record<string, unknown>>;
174
- values: Record<string, unknown>;
175
- };
176
- }
177
- : never
178
- ): JSX.Element;
179
- }
121
+ //
122
+ // `ExternalHref` and the public `LinkFunction` interface live in
123
+ // `./index.ts` rather than this file. They MUST be originally declared
124
+ // in the same module that the codegen augments (`@timber-js/app/client`)
125
+ // so that codegen-generated per-route call signatures merge with the
126
+ // same interface that types the `Link` constant. Re-exporting an
127
+ // interface via `export type {}` does NOT participate in module
128
+ // augmentation merging — only originally-declared interfaces do.
129
+ // See TIM-624.
180
130
 
181
131
  /**
182
132
  * Runtime-only loose props used internally by the Link implementation.
@@ -54,6 +54,45 @@ export interface TimberUserConfig {
54
54
  uploadBodySize?: string;
55
55
  maxFields?: number;
56
56
  };
57
+ /**
58
+ * Server-action form handling.
59
+ *
60
+ * See design/08-forms-and-actions.md §"Validation errors" and
61
+ * design/13-security.md §"Sensitive field stripping".
62
+ */
63
+ forms?: {
64
+ /**
65
+ * Strip sensitive fields (passwords, tokens, CVV, SSN, etc.) from the
66
+ * `submittedValues` echoed back on validation failure.
67
+ *
68
+ * Applied to both the with-JS (`createActionClient`) and no-JS (form POST
69
+ * re-render) paths. Safe-by-default: the built-in deny-list is active
70
+ * unless you explicitly opt out.
71
+ *
72
+ * - `true` / omitted — use the built-in deny-list (default, recommended).
73
+ * - `false` — do not strip anything. Dev convenience only; never do this
74
+ * in production — plaintext passwords end up in `defaultValue` attributes,
75
+ * browser history, proxy logs, and the back-forward cache.
76
+ * - `string[]` — additional field names to strip, merged with the built-in list.
77
+ *
78
+ * The built-in list matches (case-insensitive, substring match on
79
+ * normalized names) the following patterns: password / passwd / pwd,
80
+ * secret, apiKey, accessToken, refreshToken, cvv, cvc, cardNumber,
81
+ * cardCvc, ssn, socialSecurityNumber, otp, totp, mfaCode, twoFactorCode,
82
+ * privateKey — plus exact match on `token` (exact-only to avoid
83
+ * false positives on `csrfToken`).
84
+ *
85
+ * **Function predicates are not supported at the global level** because
86
+ * `timber.config.ts` is JSON-serialized into the runtime config and
87
+ * functions cannot cross that boundary. For custom predicates, use
88
+ * the per-action override: `createActionClient({ stripSensitiveFields:
89
+ * (name) => ... })` (functions are supported at the per-action level
90
+ * since `createActionClient` runs in the same module graph as user code).
91
+ *
92
+ * See TIM-816.
93
+ */
94
+ stripSensitiveFields?: boolean | readonly string[];
95
+ };
57
96
  pageExtensions?: string[];
58
97
  /**
59
98
  * Slow request threshold in milliseconds. Requests exceeding this emit
@@ -8,6 +8,7 @@
8
8
  * Design doc: 18-build-system.md
9
9
  */
10
10
 
11
+ import { createRequire } from 'node:module';
11
12
  import type { TimberUserConfig } from './config-types.js';
12
13
 
13
14
  // ─── Types ──────────────────────────────────────────────────────────────────
@@ -262,11 +263,14 @@ const REQUIRED_PEERS = ['react', 'react-dom', '@vitejs/plugin-react', '@vitejs/p
262
263
  export function checkPeerDependencies(projectRoot: string): PeerDepResult[] {
263
264
  const results: PeerDepResult[] = [];
264
265
 
266
+ // Use createRequire from the project root to resolve as the user would.
267
+ // `createRequire` MUST be a top-level ESM import — inline `require('node:module')`
268
+ // throws "Dynamic require not supported" in ESM and the catch below would
269
+ // mark every peer as missing. Same class of bug as TIM-796.
270
+ const userRequire = createRequire(`${projectRoot}/package.json`);
271
+
265
272
  for (const name of REQUIRED_PEERS) {
266
273
  try {
267
- // Use createRequire from the project root to resolve as the user would
268
- const { createRequire } = require('node:module');
269
- const userRequire = createRequire(`${projectRoot}/package.json`);
270
274
  userRequire.resolve(name);
271
275
  results.push({ name, status: 'ok' });
272
276
  } catch {
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Build-output emission for the timber-fonts plugin.
3
+ *
4
+ * Splits the `generateBundle` hook into pure helpers so the plugin shell
5
+ * can stay focused on Vite wiring. The functions here are deliberately
6
+ * Rollup-agnostic — they take an `EmitFile` callback so the plugin can
7
+ * pass `this.emitFile` from the `generateBundle` context.
8
+ *
9
+ * Design doc: 24-fonts.md
10
+ */
11
+
12
+ import { readFileSync, existsSync } from 'node:fs';
13
+ import { resolve, normalize } from 'node:path';
14
+ import type { ManifestFontEntry, BuildManifest } from '../server/build-manifest.js';
15
+
16
+ /**
17
+ * Minimal shape of the asset descriptor accepted by Rollup's `emitFile`.
18
+ * Declared inline rather than importing from `rollup` to keep `bundle.ts`
19
+ * Rollup-agnostic and avoid pulling the type into the build graph.
20
+ */
21
+ interface EmittedAsset {
22
+ type: 'asset';
23
+ fileName: string;
24
+ source: string | Uint8Array;
25
+ }
26
+ import type { FontPipeline } from './pipeline.js';
27
+ import type { CachedFont } from './google.js';
28
+ import { inferFontFormat } from './local.js';
29
+
30
+ type EmitFile = (asset: EmittedAsset) => string;
31
+ type EmitWarning = (msg: string) => void;
32
+
33
+ /** Group cached Google Font binaries by lowercase family name. */
34
+ export function groupCachedFontsByFamily(
35
+ cachedFonts: readonly CachedFont[]
36
+ ): Map<string, CachedFont[]> {
37
+ const cachedByFamily = new Map<string, CachedFont[]>();
38
+ for (const cf of cachedFonts) {
39
+ const key = cf.face.family.toLowerCase();
40
+ const arr = cachedByFamily.get(key) ?? [];
41
+ arr.push(cf);
42
+ cachedByFamily.set(key, arr);
43
+ }
44
+ return cachedByFamily;
45
+ }
46
+
47
+ /**
48
+ * Emit cached Google Font binaries and local font files into the build
49
+ * output under `_timber/fonts/`. Local files missing on disk produce a
50
+ * warning rather than an error so the build still succeeds.
51
+ *
52
+ * Cached Google Font binaries are deduplicated by `hashedFilename` so
53
+ * each unique file is written exactly once even when multiple font
54
+ * entries share a family (and therefore share `CachedFont` references).
55
+ */
56
+ export function emitFontAssets(
57
+ pipeline: FontPipeline,
58
+ emitFile: EmitFile,
59
+ warn: EmitWarning
60
+ ): void {
61
+ // Cached Google Font binaries (content-hashed, deduped by filename)
62
+ for (const cf of pipeline.uniqueCachedFiles()) {
63
+ emitFile({
64
+ type: 'asset',
65
+ fileName: `_timber/fonts/${cf.hashedFilename}`,
66
+ source: cf.data,
67
+ });
68
+ }
69
+
70
+ // Local font files (emitted by basename)
71
+ for (const font of pipeline.fonts()) {
72
+ if (font.provider !== 'local' || !font.localSources) continue;
73
+ for (const src of font.localSources) {
74
+ const absolutePath = normalize(resolve(src.path));
75
+ if (!existsSync(absolutePath)) {
76
+ warn(`Local font file not found: ${absolutePath}`);
77
+ continue;
78
+ }
79
+ const basename = src.path.split('/').pop() ?? src.path;
80
+ const data = readFileSync(absolutePath);
81
+ emitFile({
82
+ type: 'asset',
83
+ fileName: `_timber/fonts/${basename}`,
84
+ source: data,
85
+ });
86
+ }
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Populate `buildManifest.fonts` with `ManifestFontEntry[]` keyed by the
92
+ * importer module path (relative to the project root, matching how Vite's
93
+ * `manifest.json` keys css/js).
94
+ *
95
+ * Google fonts use the content-hashed filenames produced during
96
+ * `buildStart`. Local fonts use the source basename.
97
+ */
98
+ export function writeFontManifest(
99
+ pipeline: FontPipeline,
100
+ buildManifest: BuildManifest,
101
+ rootDir: string
102
+ ): void {
103
+ const fontsByImporter = new Map<string, ManifestFontEntry[]>();
104
+
105
+ for (const entry of pipeline.entries()) {
106
+ const manifestEntries = fontsByImporter.get(entry.importer) ?? [];
107
+
108
+ if (entry.provider === 'local' && entry.localSources) {
109
+ for (const src of entry.localSources) {
110
+ const filename = src.path.split('/').pop() ?? src.path;
111
+ const format = inferFontFormat(src.path);
112
+ manifestEntries.push({
113
+ href: `/_timber/fonts/${filename}`,
114
+ format,
115
+ crossOrigin: 'anonymous',
116
+ });
117
+ }
118
+ } else if (entry.cachedFiles) {
119
+ // Google fonts: use the per-entry cached files attached during
120
+ // buildStart. Each entry owns its own cached binaries (TIM-829),
121
+ // so we no longer need a family-grouped lookup.
122
+ for (const cf of entry.cachedFiles) {
123
+ manifestEntries.push({
124
+ href: `/_timber/fonts/${cf.hashedFilename}`,
125
+ format: 'woff2',
126
+ crossOrigin: 'anonymous',
127
+ });
128
+ }
129
+ }
130
+
131
+ fontsByImporter.set(entry.importer, manifestEntries);
132
+ }
133
+
134
+ // Normalize importer paths to be relative to project root (matching how
135
+ // Vite's manifest.json keys work for css/js).
136
+ for (const [importer, entries] of fontsByImporter) {
137
+ const relativePath = importer.startsWith(rootDir)
138
+ ? importer.slice(rootDir.length + 1)
139
+ : importer;
140
+ buildManifest.fonts[relativePath] = entries;
141
+ }
142
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Dev-mode middleware that serves local font binaries under
3
+ * `/_timber/fonts/<basename>`.
4
+ *
5
+ * Only files registered in the FontPipeline are served, and only by
6
+ * basename — directory traversal and path separators in the requested
7
+ * filename are rejected up front. Font CSS is no longer served here; it
8
+ * goes through Vite's CSS pipeline via virtual modules.
9
+ *
10
+ * Design doc: 24-fonts.md
11
+ */
12
+
13
+ import type { ViteDevServer } from 'vite';
14
+ import { readFileSync, existsSync } from 'node:fs';
15
+ import { resolve, normalize } from 'node:path';
16
+ import type { FontPipeline } from './pipeline.js';
17
+
18
+ const FONT_MIME_TYPES: Record<string, string> = {
19
+ woff2: 'font/woff2',
20
+ woff: 'font/woff',
21
+ ttf: 'font/ttf',
22
+ otf: 'font/otf',
23
+ eot: 'application/vnd.ms-fontopen',
24
+ };
25
+
26
+ /**
27
+ * Wire the timber-fonts dev middleware onto a Vite dev server.
28
+ *
29
+ * Returns synchronously after registering the middleware. The pipeline is
30
+ * captured by reference so that fonts registered after `configureServer`
31
+ * runs (during transform) are still resolvable.
32
+ */
33
+ export function installFontDevMiddleware(server: ViteDevServer, pipeline: FontPipeline): void {
34
+ server.middlewares.use((req, res, next) => {
35
+ const url = req.url;
36
+ if (!url || !url.startsWith('/_timber/fonts/')) return next();
37
+
38
+ const requestedFilename = url.slice('/_timber/fonts/'.length);
39
+ // Reject path traversal attempts and any subdirectory access. We only
40
+ // serve flat basenames out of the registry.
41
+ if (requestedFilename.includes('..') || requestedFilename.includes('/')) {
42
+ res.statusCode = 400;
43
+ res.end('Bad request');
44
+ return;
45
+ }
46
+
47
+ // Find the matching font file in the registry. We iterate every local
48
+ // font's source list and match by basename. The set is small (one per
49
+ // localFont() call) so this is cheap.
50
+ for (const font of pipeline.fonts()) {
51
+ if (font.provider !== 'local' || !font.localSources) continue;
52
+ for (const src of font.localSources) {
53
+ const basename = src.path.split('/').pop() ?? '';
54
+ if (basename !== requestedFilename) continue;
55
+
56
+ const absolutePath = normalize(resolve(src.path));
57
+ if (!existsSync(absolutePath)) {
58
+ res.statusCode = 404;
59
+ res.end('Not found');
60
+ return;
61
+ }
62
+ const data = readFileSync(absolutePath);
63
+ const ext = absolutePath.split('.').pop()?.toLowerCase();
64
+ res.setHeader('Content-Type', FONT_MIME_TYPES[ext ?? ''] ?? 'application/octet-stream');
65
+ res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
66
+ res.setHeader('Access-Control-Allow-Origin', '*');
67
+ res.end(data);
68
+ return;
69
+ }
70
+ }
71
+
72
+ next();
73
+ });
74
+ }