@timber-js/app 0.2.0-alpha.190 → 0.2.0-alpha.192

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 (123) hide show
  1. package/dist/_chunks/{actions-35jnMdeJ.js → actions-BjbbNRFN.js} +3 -3
  2. package/dist/_chunks/{actions-35jnMdeJ.js.map → actions-BjbbNRFN.js.map} +1 -1
  3. package/dist/_chunks/als-registry-C6kcfprT.js.map +1 -1
  4. package/dist/_chunks/als-slots-BEEIPKYm.js.map +1 -1
  5. package/dist/_chunks/{cache-api-DjNrIWRR.js → cache-api-DllJ-Lyw.js} +2 -2
  6. package/dist/_chunks/{cache-api-DjNrIWRR.js.map → cache-api-DllJ-Lyw.js.map} +1 -1
  7. package/dist/_chunks/{cli-check-CpmN7Nh-.js → cli-check-DJZDc22E.js} +3 -3
  8. package/dist/_chunks/{cli-check-CpmN7Nh-.js.map → cli-check-DJZDc22E.js.map} +1 -1
  9. package/dist/_chunks/{cli-schema-sync-CGMp_Psg.js → cli-schema-sync-D4_AZVUb.js} +2 -2
  10. package/dist/_chunks/{cli-schema-sync-CGMp_Psg.js.map → cli-schema-sync-D4_AZVUb.js.map} +1 -1
  11. package/dist/_chunks/{convention-lint-kXsgc_-7.js → convention-lint-CpteIpTm.js} +2 -2
  12. package/dist/_chunks/{convention-lint-kXsgc_-7.js.map → convention-lint-CpteIpTm.js.map} +1 -1
  13. package/dist/_chunks/{logger-D8xJZXIN.js → logger-CH7IcMmg.js} +1 -16
  14. package/dist/_chunks/{logger-D8xJZXIN.js.map → logger-CH7IcMmg.js.map} +1 -1
  15. package/dist/_chunks/{scanner-C8b0Gcw3.js → scanner-CAietmj4.js} +2 -2
  16. package/dist/_chunks/{scanner-C8b0Gcw3.js.map → scanner-CAietmj4.js.map} +1 -1
  17. package/dist/_chunks/segment-context-CjOlyB8Y.js.map +1 -1
  18. package/dist/_chunks/segment-keys-BawYuNFO.js.map +1 -1
  19. package/dist/_chunks/slot-params-BCTmZkQB.js.map +1 -1
  20. package/dist/_chunks/ssr-data-14MXm7Pj.js.map +1 -1
  21. package/dist/_chunks/use-segment-params-C4r4BD9T.js.map +1 -1
  22. package/dist/_chunks/{walkers-RzN6AFjr.js → walkers-BsVLmD1S.js} +2 -2
  23. package/dist/_chunks/{walkers-RzN6AFjr.js.map → walkers-BsVLmD1S.js.map} +1 -1
  24. package/dist/cache/index.js +1 -1
  25. package/dist/cli.js +2 -2
  26. package/dist/client/internal.js.map +1 -1
  27. package/dist/client/params-context.d.ts +2 -1
  28. package/dist/client/params-context.d.ts.map +1 -1
  29. package/dist/client/ssr-data.d.ts +2 -1
  30. package/dist/client/ssr-data.d.ts.map +1 -1
  31. package/dist/client/state.d.ts +3 -2
  32. package/dist/client/state.d.ts.map +1 -1
  33. package/dist/client/use-segment-params.d.ts +5 -4
  34. package/dist/client/use-segment-params.d.ts.map +1 -1
  35. package/dist/config-types.d.ts +24 -6
  36. package/dist/config-types.d.ts.map +1 -1
  37. package/dist/index.js +35 -32
  38. package/dist/index.js.map +1 -1
  39. package/dist/plugins/mdx.d.ts +17 -4
  40. package/dist/plugins/mdx.d.ts.map +1 -1
  41. package/dist/plugins/server-bundle.d.ts.map +1 -1
  42. package/dist/routing/index.js +2 -2
  43. package/dist/routing/segment-keys.d.ts +2 -1
  44. package/dist/routing/segment-keys.d.ts.map +1 -1
  45. package/dist/server/action-handler.d.ts.map +1 -1
  46. package/dist/server/als-registry.d.ts +10 -5
  47. package/dist/server/als-registry.d.ts.map +1 -1
  48. package/dist/server/chain-url-parts.d.ts +2 -1
  49. package/dist/server/chain-url-parts.d.ts.map +1 -1
  50. package/dist/server/index.d.ts +1 -0
  51. package/dist/server/index.d.ts.map +1 -1
  52. package/dist/server/index.js +2 -2
  53. package/dist/server/internal.js +3 -3
  54. package/dist/server/internal.js.map +1 -1
  55. package/dist/server/metadata-collector.d.ts +40 -18
  56. package/dist/server/metadata-collector.d.ts.map +1 -1
  57. package/dist/server/param-coercion.d.ts +2 -1
  58. package/dist/server/param-coercion.d.ts.map +1 -1
  59. package/dist/server/pipeline-phases.d.ts.map +1 -1
  60. package/dist/server/pipeline.d.ts +3 -2
  61. package/dist/server/pipeline.d.ts.map +1 -1
  62. package/dist/server/prebuilt/cache-key.d.ts +2 -1
  63. package/dist/server/prebuilt/cache-key.d.ts.map +1 -1
  64. package/dist/server/prebuilt/payload-source.d.ts +2 -1
  65. package/dist/server/prebuilt/payload-source.d.ts.map +1 -1
  66. package/dist/server/prebuilt/synthetic-store.d.ts +2 -1
  67. package/dist/server/prebuilt/synthetic-store.d.ts.map +1 -1
  68. package/dist/server/prebuilt-builder.d.ts +4 -3
  69. package/dist/server/prebuilt-builder.d.ts.map +1 -1
  70. package/dist/server/prebuilt-runtime.d.ts.map +1 -1
  71. package/dist/server/request-context.d.ts +6 -5
  72. package/dist/server/request-context.d.ts.map +1 -1
  73. package/dist/server/route-element-builder.d.ts.map +1 -1
  74. package/dist/server/sitemap-generator.d.ts +2 -1
  75. package/dist/server/sitemap-generator.d.ts.map +1 -1
  76. package/dist/server/slot-resolver.d.ts +1 -1
  77. package/dist/server/slot-resolver.d.ts.map +1 -1
  78. package/dist/server/ssr-bridge-types.d.ts +5 -3
  79. package/dist/server/ssr-bridge-types.d.ts.map +1 -1
  80. package/dist/server/types.d.ts +2 -1
  81. package/dist/server/types.d.ts.map +1 -1
  82. package/dist/shared/als-slots.d.ts +3 -2
  83. package/dist/shared/als-slots.d.ts.map +1 -1
  84. package/dist/shared/param-value.d.ts +25 -1
  85. package/dist/shared/param-value.d.ts.map +1 -1
  86. package/dist/shared/payload-root.d.ts +2 -1
  87. package/dist/shared/payload-root.d.ts.map +1 -1
  88. package/dist/shared/slot-params.d.ts +4 -3
  89. package/dist/shared/slot-params.d.ts.map +1 -1
  90. package/docs/api/34-api-config.mdx +9 -8
  91. package/docs/more/04b-mdx.mdx +38 -18
  92. package/package.json +7 -3
  93. package/src/client/params-context.ts +2 -2
  94. package/src/client/ssr-data.ts +2 -1
  95. package/src/client/state.ts +3 -2
  96. package/src/client/use-segment-params.ts +6 -5
  97. package/src/config-types.ts +27 -7
  98. package/src/plugins/mdx.ts +69 -35
  99. package/src/plugins/server-bundle.ts +2 -1
  100. package/src/routing/segment-keys.ts +5 -2
  101. package/src/server/action-handler.ts +106 -8
  102. package/src/server/als-registry.ts +10 -5
  103. package/src/server/chain-url-parts.ts +2 -1
  104. package/src/server/index.ts +4 -0
  105. package/src/server/metadata-collector.ts +114 -40
  106. package/src/server/param-coercion.ts +7 -7
  107. package/src/server/pipeline-phases.ts +3 -1
  108. package/src/server/pipeline.ts +3 -2
  109. package/src/server/prebuilt/cache-key.ts +2 -1
  110. package/src/server/prebuilt/payload-source.ts +2 -1
  111. package/src/server/prebuilt/synthetic-store.ts +2 -1
  112. package/src/server/prebuilt-builder.ts +6 -5
  113. package/src/server/prebuilt-runtime.ts +5 -3
  114. package/src/server/request-context.ts +6 -8
  115. package/src/server/route-element-builder.ts +18 -40
  116. package/src/server/sitemap-generator.ts +7 -9
  117. package/src/server/slot-resolver.ts +2 -1
  118. package/src/server/ssr-bridge-types.ts +6 -3
  119. package/src/server/types.ts +2 -1
  120. package/src/shared/als-slots.ts +3 -1
  121. package/src/shared/param-value.ts +44 -7
  122. package/src/shared/payload-root.ts +2 -1
  123. package/src/shared/slot-params.ts +11 -12
@@ -17,8 +17,9 @@
17
17
  *
18
18
  * See design/41-global-params.md §"Params in a parallel slot".
19
19
  */
20
+ import type { CoercedParams } from './param-value.js';
20
21
  /** Slot tree path → that slot's own coerced params. */
21
- export type SlotParamsRecord = Record<string, Record<string, string | string[]>>;
22
+ export type SlotParamsRecord = Record<string, CoercedParams>;
22
23
  /**
23
24
  * The params a slot sees: the main route's as a base, the slot's own on top.
24
25
  *
@@ -34,7 +35,7 @@ export type SlotParamsRecord = Record<string, Record<string, string | string[]>>
34
35
  * a function instead of `undefined` — in the one param record that was built
35
36
  * by merging. See design/13-security.md #36c.
36
37
  */
37
- export declare function mergeSlotParams(mainParams: Record<string, string | string[]>, slotParams: Record<string, string | string[]>): Record<string, string | string[]>;
38
+ export declare function mergeSlotParams(mainParams: CoercedParams, slotParams: CoercedParams): CoercedParams;
38
39
  /**
39
40
  * Resolve the params for a segment path against a published slot map.
40
41
  *
@@ -47,5 +48,5 @@ export declare function mergeSlotParams(mainParams: Record<string, string | stri
47
48
  * JSON, so a segment path of `constructor` or `toString` would otherwise
48
49
  * resolve to an inherited member of `Object.prototype`.
49
50
  */
50
- export declare function resolveSegmentParams(mainParams: Record<string, string | string[]>, slotParams: SlotParamsRecord | null | undefined, segmentPath: string | undefined): Record<string, string | string[]>;
51
+ export declare function resolveSegmentParams(mainParams: CoercedParams, slotParams: SlotParamsRecord | null | undefined, segmentPath: string | undefined): CoercedParams;
51
52
  //# sourceMappingURL=slot-params.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"slot-params.d.ts","sourceRoot":"","sources":["../../src/shared/slot-params.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAEH,uDAAuD;AACvD,MAAM,MAAM,gBAAgB,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,EAAE,CAAC,CAAC,CAAC;AAEjF;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,eAAe,CAC7B,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,EAAE,CAAC,EAC7C,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,EAAE,CAAC,GAC5C,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,EAAE,CAAC,CAEnC;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,oBAAoB,CAClC,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,EAAE,CAAC,EAC7C,UAAU,EAAE,gBAAgB,GAAG,IAAI,GAAG,SAAS,EAC/C,WAAW,EAAE,MAAM,GAAG,SAAS,GAC9B,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,EAAE,CAAC,CAGnC"}
1
+ {"version":3,"file":"slot-params.d.ts","sourceRoot":"","sources":["../../src/shared/slot-params.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAEH,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAC;AAEtD,uDAAuD;AACvD,MAAM,MAAM,gBAAgB,GAAG,MAAM,CAAC,MAAM,EAAE,aAAa,CAAC,CAAC;AAE7D;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,eAAe,CAC7B,UAAU,EAAE,aAAa,EACzB,UAAU,EAAE,aAAa,GACxB,aAAa,CAEf;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,oBAAoB,CAClC,UAAU,EAAE,aAAa,EACzB,UAAU,EAAE,gBAAgB,GAAG,IAAI,GAAG,SAAS,EAC/C,WAAW,EAAE,MAAM,GAAG,SAAS,GAC9B,aAAa,CAGf"}
@@ -39,11 +39,10 @@ interface TimberUserConfig {
39
39
  firstLoadJs?: number;
40
40
  };
41
41
  appDir?: string;
42
- mdx?: {
43
- remarkPlugins?: any[];
44
- rehypePlugins?: any[];
45
- recmaPlugins?: any[];
46
- remarkRehypeOptions?: any;
42
+ mdx?: false | {
43
+ mdastPlugins?: any[];
44
+ hastPlugins?: any[];
45
+ features?: Record<string, any>;
47
46
  };
48
47
  actionEncryption?: {
49
48
  disableInDev?: boolean;
@@ -226,15 +225,17 @@ Override the app directory location. Set to a relative path from the project roo
226
225
 
227
226
  ### `mdx`
228
227
 
229
- MDX compilation options — remark, rehype, and recma plugins:
228
+ MDX compilation options — Satteri visitor plugins and parser feature toggles (GFM and frontmatter are on by default). Set `mdx: false` to disable timber's MDX support and bring your own compiler:
230
229
 
231
230
  ```ts
232
231
  mdx: {
233
- remarkPlugins: [remarkGfm],
234
- rehypePlugins: [rehypePrism],
232
+ hastPlugins: [myCodeHighlighter],
233
+ features: { math: true },
235
234
  }
236
235
  ```
237
236
 
237
+ See [MDX](/docs/mdx) for the plugin model and how it differs from remark/rehype.
238
+
238
239
  ### `actionEncryption`
239
240
 
240
241
  Server action bound args encryption configuration. The RSC plugin encrypts closure variables captured by `'use server'` functions using AES-256-GCM so they are opaque and tamper-proof in the Flight payload. Encryption is always enabled in production.
@@ -17,10 +17,10 @@ export default {
17
17
  };
18
18
  ```
19
19
 
20
- Install the MDX compiler and frontmatter plugins:
20
+ Install the MDX compiler — timber.js uses [Satteri](https://satteri.bruits.org), a Rust-based Markdown/MDX compiler with a native Vite plugin:
21
21
 
22
22
  ```bash title="Terminal"
23
- pnpm add -D @mdx-js/rollup remark-frontmatter remark-mdx-frontmatter
23
+ pnpm add -D vite-plugin-satteri satteri
24
24
  ```
25
25
 
26
26
  That's it. Any `page.mdx` file in your `app/` directory is now a route.
@@ -57,7 +57,7 @@ This page is a **server component** — no JavaScript shipped to the browser.
57
57
 
58
58
  ## Frontmatter
59
59
 
60
- timber.js automatically registers `remark-frontmatter` and `remark-mdx-frontmatter` when installed. Frontmatter is exported as a single `frontmatter` object:
60
+ Frontmatter parsing is built in — YAML between `---` fences or TOML between `+++` fences. Frontmatter is exported as a single `frontmatter` object:
61
61
 
62
62
  ````mdx title="app/blog/hello/page.mdx"
63
63
  ---
@@ -127,33 +127,52 @@ pnpm add @timber-js/app
127
127
 
128
128
  Only the `CopyButton` ships JavaScript. The rest of the page renders as static HTML.
129
129
 
130
- ## Remark and Rehype Plugins
130
+ ## Plugins and Features
131
131
 
132
- Configure remark and rehype plugins through the `mdx` key in your config:
132
+ GFM (tables, footnotes, strikethrough, task lists) and frontmatter are on by default. Additional syntax is enabled through `features`, and custom transforms are written as Satteri visitor plugins through the `mdx` key in your config:
133
133
 
134
134
  ```ts title="timber.config.ts"
135
- import remarkGfm from 'remark-gfm';
136
- import rehypeShiki from '@shikijs/rehype';
135
+ import { defineHastPlugin } from 'satteri';
136
+
137
+ const externalLinks = defineHastPlugin({
138
+ name: 'external-links',
139
+ element: {
140
+ filter: ['a'],
141
+ visit(node, ctx) {
142
+ const href = node.properties.href;
143
+ if (typeof href === 'string' && href.startsWith('http')) {
144
+ ctx.setProperty(node, 'target', '_blank');
145
+ }
146
+ },
147
+ },
148
+ });
137
149
 
138
150
  export default {
139
151
  pageExtensions: ['tsx', 'ts', 'jsx', 'js', 'mdx'],
140
152
  mdx: {
141
- remarkPlugins: [remarkGfm],
142
- rehypePlugins: [[rehypeShiki, { theme: 'monokai' }]],
153
+ hastPlugins: [externalLinks],
154
+ features: { math: true },
143
155
  },
144
156
  };
145
157
  ```
146
158
 
147
- The `mdx` config maps directly to `@mdx-js/rollup` options. Available fields:
159
+ The `mdx` config maps directly to `vite-plugin-satteri` options. Available fields:
160
+
161
+ | Option | Type | Description |
162
+ | -------------- | -------------------- | ------------------------------------------------------------------------------- |
163
+ | `mdastPlugins` | `MdastPluginInput[]` | Markdown AST visitors (created with `defineMdastPlugin`) |
164
+ | `hastPlugins` | `HastPluginInput[]` | HTML AST visitors (created with `defineHastPlugin`) |
165
+ | `features` | `Features` | Parser toggles — `gfm`, `frontmatter`, `math`, `directive`, `wikilinks`, … |
148
166
 
149
- | Option | Type | Description |
150
- | --------------------- | --------------- | ------------------------------------------ |
151
- | `remarkPlugins` | `PluggableList` | remark plugins for Markdown AST transforms |
152
- | `rehypePlugins` | `PluggableList` | rehype plugins for HTML AST transforms |
153
- | `recmaPlugins` | `PluggableList` | recma plugins for ESTree transforms |
154
- | `remarkRehypeOptions` | `object` | Options passed to `remark-rehype` |
167
+ Satteri plugins are filtered visitors, not unified plugins — **remark/rehype plugins do not run** on Satteri's Rust-side AST. Visitors can be async and can replace nodes, which is enough to build things like shiki-based syntax highlighting (shiki transformers such as `@shikijs/twoslash` still work, since they run inside shiki).
155
168
 
156
- Plugins like `remark-gfm` and `rehype-shiki` are your choice to install — timber.js doesn't bundle them.
169
+ If you need the unified MDX pipeline (for example, CodeHike), bypass timber's MDX support entirely: set `mdx: false` in your config and register `@mdx-js/rollup` yourself in `vite.config.ts` with `enforce: 'pre'`:
170
+
171
+ ```ts title="timber.config.ts"
172
+ export default {
173
+ mdx: false, // disable timber's built-in MDX — bring your own compiler
174
+ };
175
+ ```
157
176
 
158
177
  ## Dynamic MDX Loading
159
178
 
@@ -205,5 +224,6 @@ For structured content outside the route tree (blog posts, docs, changelogs), us
205
224
  | `@next/mdx` wrapper package | Built-in — just add `'mdx'` to pageExtensions |
206
225
  | `next.config.mjs` `withMDX()` wrapper | `timber.config.ts` `mdx` key |
207
226
  | MDX pages are client components by default | MDX pages are server components by default |
208
- | Custom loader for `.md` files | `@mdx-js/rollup` handles both `.mdx` and `.md` |
227
+ | unified (remark/rehype) plugins | Satteri visitor plugins + built-in features |
228
+ | Custom loader for `.md` files | Built in — `.md` imports export an HTML string |
209
229
  | `mdx-components.tsx` at project root | Same convention |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@timber-js/app",
3
- "version": "0.2.0-alpha.190",
3
+ "version": "0.2.0-alpha.192",
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",
@@ -160,14 +160,15 @@
160
160
  "@content-collections/core": "^0.14.0 || ^0.15.0",
161
161
  "@content-collections/mdx": "^0.2.0",
162
162
  "@content-collections/vite": "^0.2.0 || ^0.3.0",
163
- "@mdx-js/rollup": "^3.0.0",
164
163
  "@typescript/native-preview": "^7.0.0-dev.0",
165
164
  "@vitejs/plugin-react": "^6.0.0",
166
165
  "@vitejs/plugin-rsc": ">=0.5.28",
167
166
  "nuqs": "^2.0.0",
168
167
  "react": "19.2.7",
169
168
  "react-dom": "19.2.7",
169
+ "satteri": "^0.9.5",
170
170
  "vite": "^8.1.0",
171
+ "vite-plugin-satteri": "^0.2.15",
171
172
  "wrangler": "^4.0.0",
172
173
  "zod": "^3.22.0 || ^4.0.0"
173
174
  },
@@ -181,7 +182,10 @@
181
182
  "@content-collections/vite": {
182
183
  "optional": true
183
184
  },
184
- "@mdx-js/rollup": {
185
+ "satteri": {
186
+ "optional": true
187
+ },
188
+ "vite-plugin-satteri": {
185
189
  "optional": true
186
190
  },
187
191
  "@typescript/native-preview": {
@@ -32,7 +32,7 @@
32
32
 
33
33
  import React, { createElement, useMemo, use } from 'react';
34
34
  import { _setCurrentParams, _setCurrentSlotParams } from './state.js';
35
- import { toNullProtoRecord } from '../shared/param-value.js';
35
+ import { toNullProtoRecord, type CoercedParams } from '../shared/param-value.js';
36
36
  import { readPublishedParams, type PublishedParams } from '../shared/payload-root.js';
37
37
  import type { SlotParamsRecord } from '../shared/slot-params.js';
38
38
 
@@ -103,7 +103,7 @@ export function useParamsContext(): ParamsContextValue | null {
103
103
  // ─── Provider ────────────────────────────────────────────────────
104
104
 
105
105
  interface ParamsProviderProps {
106
- params: Record<string, string | string[]>;
106
+ params: CoercedParams;
107
107
  slotParams: SlotParamsRecord | null;
108
108
  children?: React.ReactNode;
109
109
  }
@@ -28,6 +28,7 @@ import {
28
28
  _setSsrDataProvider,
29
29
  _setCurrentSsrData,
30
30
  } from './state.js';
31
+ import type { CoercedParams } from '../shared/param-value.js';
31
32
  import type { SlotParamsRecord } from '../shared/slot-params.js';
32
33
 
33
34
  // ─── Types ────────────────────────────────────────────────────────
@@ -40,7 +41,7 @@ export interface SsrData {
40
41
  /** The request's cookies as name→value pairs */
41
42
  cookies: Map<string, string>;
42
43
  /** The request's route params (e.g. { id: '123' }) */
43
- params: Record<string, string | string[]>;
44
+ params: CoercedParams;
44
45
  /**
45
46
  * Per-slot params keyed by slot tree path, absent when the request rendered
46
47
  * no slot with params of its own (TIM-1285).
@@ -19,6 +19,7 @@
19
19
  * §"Singleton State Registry".
20
20
  */
21
21
 
22
+ import type { CoercedParams } from '../shared/param-value.js';
22
23
  import type { SlotParamsRecord } from '../shared/slot-params.js';
23
24
  import type { RouterInstance } from './router-types.js';
24
25
  import type { SsrData } from './ssr-data.js';
@@ -54,9 +55,9 @@ export function _setCurrentSsrData(data: SsrData | undefined): void {
54
55
  // ─── Route Params (from use-segment-params.ts) ──────────────────────────────────────
55
56
 
56
57
  /** Current route params snapshot — replaced (not mutated) on each navigation. */
57
- export let currentParams: Record<string, string | string[]> = {};
58
+ export let currentParams: CoercedParams = {};
58
59
 
59
- export function _setCurrentParams(params: Record<string, string | string[]>): void {
60
+ export function _setCurrentParams(params: CoercedParams): void {
60
61
  currentParams = params;
61
62
  }
62
63
 
@@ -30,6 +30,7 @@
30
30
  * Design doc: design/09-typescript.md §"Typed Routes"
31
31
  */
32
32
 
33
+ import type { CoercedParams } from '../shared/param-value.js';
33
34
  import type { Routes } from '../index.js';
34
35
  import { getSsrData } from './ssr-data.js';
35
36
  import {
@@ -61,7 +62,7 @@ export function subscribe(callback: () => void): () => void {
61
62
  * Get the current params snapshot (module-level fallback).
62
63
  * Used by tests and by the hook when called outside a React component.
63
64
  */
64
- export function getSnapshot(): Record<string, string | string[]> {
65
+ export function getSnapshot(): CoercedParams {
65
66
  return currentParams;
66
67
  }
67
68
 
@@ -84,7 +85,7 @@ export function getSnapshot(): Record<string, string | string[]> {
84
85
  * During SSR, params are also available via getSsrData().params
85
86
  * (ALS-backed).
86
87
  */
87
- export function setCurrentParams(params: Record<string, string | string[]>): void {
88
+ export function setCurrentParams(params: CoercedParams): void {
88
89
  _setCurrentParams(params);
89
90
  }
90
91
 
@@ -140,9 +141,9 @@ export function notifyParamsListeners(): void {
140
141
  */
141
142
  export function useSegmentParams<R extends keyof Routes>(
142
143
  segmentPath: R
143
- ): Routes[R] extends { segmentParams: infer P } ? P : Record<string, string | string[]>;
144
- export function useSegmentParams(segmentPath?: string): Record<string, string | string[]>;
145
- export function useSegmentParams(segmentPath?: string): Record<string, string | string[]> {
144
+ ): Routes[R] extends { segmentParams: infer P } ? P : CoercedParams;
145
+ export function useSegmentParams(segmentPath?: string): CoercedParams;
146
+ export function useSegmentParams(segmentPath?: string): CoercedParams {
146
147
  // Try the client-owned provider first. It sits above everything a navigation
147
148
  // renders, so any component on the page — initial document, full navigation,
148
149
  // or a layout the server skipped — has one above it. Absent during SSR,
@@ -159,13 +159,33 @@ export interface TimberUserConfig {
159
159
  * to use a custom location.
160
160
  */
161
161
  appDir?: string;
162
- /** MDX compilation options passed to @mdx-js/rollup. See design/20-content-collections.md. */
163
- mdx?: {
164
- remarkPlugins?: unknown[];
165
- rehypePlugins?: unknown[];
166
- recmaPlugins?: unknown[];
167
- remarkRehypeOptions?: Record<string, unknown>;
168
- };
162
+ /**
163
+ * MDX/Markdown compilation options passed to vite-plugin-satteri.
164
+ *
165
+ * Satteri's plugin API is visitor-based (`defineMdastPlugin` /
166
+ * `defineHastPlugin` from the `satteri` package) — unified
167
+ * remark/rehype plugins are not supported.
168
+ *
169
+ * Set to `false` to disable timber's MDX support entirely — even when a
170
+ * `content/` directory exists. Use this when registering your own MDX
171
+ * compiler (e.g. `@mdx-js/rollup` for the unified pipeline) directly in
172
+ * `vite.config.ts`.
173
+ *
174
+ * See design/20-content-collections.md.
175
+ */
176
+ mdx?:
177
+ | false
178
+ | {
179
+ /** MDAST-stage plugins (created with satteri's `defineMdastPlugin`). */
180
+ mdastPlugins?: unknown[];
181
+ /** HAST-stage plugins (created with satteri's `defineHastPlugin`). */
182
+ hastPlugins?: unknown[];
183
+ /**
184
+ * Parser feature toggles. Defaults: `gfm: true`, `frontmatter: true`,
185
+ * everything else (math, directive, wikilinks, …) off.
186
+ */
187
+ features?: Record<string, unknown>;
188
+ };
169
189
  /**
170
190
  * Server action bound args encryption configuration.
171
191
  *
@@ -1,10 +1,22 @@
1
1
  /**
2
- * timber-mdx — Vite sub-plugin for MDX page rendering.
2
+ * timber-mdx — Vite sub-plugin for MDX and Markdown rendering.
3
3
  *
4
- * Wires @mdx-js/rollup into the Vite pipeline when MDX is activated.
4
+ * Wires vite-plugin-satteri into the Vite pipeline when MDX is activated.
5
5
  * MDX is activated when pageExtensions includes 'mdx' or 'md', or
6
6
  * when a content/ directory exists at the project root.
7
7
  *
8
+ * Satteri is a Rust-based Markdown/MDX compiler with a native Vite plugin:
9
+ * - `.mdx` files compile to React components (ES modules with real import
10
+ * statements, so @vitejs/plugin-rsc client-boundary detection works).
11
+ * - `.md` files compile to modules exporting a rendered HTML string.
12
+ * - Frontmatter (YAML/TOML) parses natively and is emitted as a
13
+ * `frontmatter` named export — no remark plugins needed.
14
+ * - GFM (tables, footnotes, strikethrough, task lists) is on by default.
15
+ *
16
+ * Extensibility is satteri's visitor-based plugin API (defineMdastPlugin /
17
+ * defineHastPlugin), not unified — the AST lives on the Rust side, so
18
+ * remark/rehype plugins cannot run. See design/20-content-collections.md.
19
+ *
8
20
  * Design doc: 20-content-collections.md §"The timber-mdx Plugin"
9
21
  */
10
22
 
@@ -43,6 +55,12 @@ export function findMdxComponents(root: string): string | undefined {
43
55
  * Determine if MDX should be activated based on config and project structure.
44
56
  */
45
57
  function shouldActivate(ctx: PluginContext): boolean {
58
+ // Explicit opt-out — the escape hatch for apps that register their own
59
+ // MDX compiler (e.g. @mdx-js/rollup for the unified pipeline) in
60
+ // vite.config.ts. Without this, a content/ directory would force
61
+ // activation no matter what pageExtensions says.
62
+ if (ctx.config.mdx === false) return false;
63
+
46
64
  const exts = ctx.config.pageExtensions;
47
65
  if (exts && exts.some((ext) => MDX_EXTENSIONS.includes(ext))) {
48
66
  return true;
@@ -61,13 +79,11 @@ function shouldActivate(ctx: PluginContext): boolean {
61
79
  *
62
80
  * Why this matters: pnpm only hoists declared (peer) dependencies into a
63
81
  * package's resolution scope. The MDX integration's optional companions
64
- * — `remark-frontmatter`, `remark-mdx-frontmatter`, `@mdx-js/rollup` — are
65
- * installed as direct deps of the consumer (e.g. `packages/website`), not
66
- * `@timber-js/app`. A bare `import('remark-frontmatter')` from inside this
67
- * file resolves against `packages/timber-app/node_modules` and silently
68
- * fails with `ERR_MODULE_NOT_FOUND`, which made `tryImport` return
69
- * `undefined` and skipped frontmatter parsing entirely — leaving MDX to
70
- * choke on YAML as JS expressions (TIM-840).
82
+ * — `vite-plugin-satteri` and `satteri` — are installed as direct deps of
83
+ * the consumer (e.g. `packages/website`), not `@timber-js/app`. A bare
84
+ * `import('vite-plugin-satteri')` from inside this file resolves against
85
+ * `packages/timber-app/node_modules` and silently fails with
86
+ * `ERR_MODULE_NOT_FOUND` (TIM-840).
71
87
  *
72
88
  * Resolving relative to `projectRoot` (via `createRequire`) makes the
73
89
  * lookup walk the consumer's `node_modules` tree first, which is where
@@ -106,9 +122,10 @@ async function tryImport(name: string, projectRoot: string): Promise<unknown | u
106
122
  * Create the timber-mdx Vite plugin.
107
123
  *
108
124
  * Uses the transform and resolveId hooks to delegate MDX compilation
109
- * to @mdx-js/rollup. The inner plugin is loaded lazily on first activation.
125
+ * to vite-plugin-satteri. The inner plugin is loaded lazily on first
126
+ * activation.
110
127
  *
111
- * Hooks: buildStart (loads @mdx-js/rollup), resolveId, load, transform
128
+ * Hooks: buildStart (loads vite-plugin-satteri), resolveId, load, transform
112
129
  */
113
130
  export function timberMdx(ctx: PluginContext): Plugin {
114
131
  let innerPlugin: Plugin | null = null;
@@ -116,50 +133,60 @@ export function timberMdx(ctx: PluginContext): Plugin {
116
133
  async function activate(): Promise<void> {
117
134
  if (innerPlugin !== null || !shouldActivate(ctx)) return;
118
135
 
119
- const createMdxPlugin = (await tryImport('@mdx-js/rollup', ctx.root)) as
136
+ // Satteri compiles .md to an HTML string, not a component — a page.md
137
+ // route would make the renderer invoke a string as a component and
138
+ // TypeError at request time. Fail at startup instead. Imported .md
139
+ // files work without listing 'md' in pageExtensions.
140
+ if (ctx.config.pageExtensions?.includes('md')) {
141
+ throw new Error(
142
+ [
143
+ "[timber] pageExtensions includes 'md', but .md route pages are not supported:",
144
+ 'Markdown files compile to HTML strings, not components.',
145
+ '',
146
+ "Use .mdx for pages (pageExtensions: ['tsx', 'ts', 'mdx']).",
147
+ '.md files can still be imported as HTML strings — no pageExtensions entry needed.',
148
+ ].join('\n')
149
+ );
150
+ }
151
+
152
+ const createSatteriPlugin = (await tryImport('vite-plugin-satteri', ctx.root)) as
120
153
  | ((options?: Record<string, unknown>) => Plugin)
121
154
  | undefined;
122
155
 
123
- if (!createMdxPlugin) {
156
+ if (!createSatteriPlugin) {
124
157
  throw new Error(
125
158
  [
126
- '[timber] MDX is enabled but @mdx-js/rollup is not installed.',
159
+ '[timber] MDX is enabled but vite-plugin-satteri is not installed.',
127
160
  '',
128
161
  'Install it:',
129
- ' pnpm add -D @mdx-js/rollup remark-frontmatter remark-mdx-frontmatter',
162
+ ' pnpm add -D vite-plugin-satteri satteri',
130
163
  '',
131
164
  'MDX is activated because pageExtensions includes "mdx"/"md" or a content/ directory exists.',
132
165
  ].join('\n')
133
166
  );
134
167
  }
135
168
 
136
- const mdxConfig = ctx.config.mdx ?? {};
137
-
138
- // Auto-register frontmatter plugins. Resolve from the consumer's root
139
- // so pnpm finds packages declared in the user's package.json (TIM-840).
140
- const remarkPlugins: unknown[] = [];
141
- const remarkFrontmatter = await tryImport('remark-frontmatter', ctx.root);
142
- const remarkMdxFrontmatter = await tryImport('remark-mdx-frontmatter', ctx.root);
143
- if (remarkFrontmatter) remarkPlugins.push(remarkFrontmatter);
144
- if (remarkMdxFrontmatter) remarkPlugins.push(remarkMdxFrontmatter);
145
-
146
- if (mdxConfig.remarkPlugins) {
147
- remarkPlugins.push(...mdxConfig.remarkPlugins);
148
- }
169
+ // `|| {}` (not `??`): mdx can be `false`, though shouldActivate() has
170
+ // already bailed in that case.
171
+ const mdxConfig = ctx.config.mdx || {};
149
172
 
150
- const mdxOptions: Record<string, unknown> = {
151
- remarkPlugins,
152
- rehypePlugins: mdxConfig.rehypePlugins ?? [],
153
- recmaPlugins: mdxConfig.recmaPlugins ?? [],
154
- remarkRehypeOptions: mdxConfig.remarkRehypeOptions,
155
- };
173
+ // We pass `development` explicitly (satteri otherwise infers it from
174
+ // Vite's command via its own configResolved hook, which we don't
175
+ // delegate) and control providerImportSource — the mdx-components.tsx
176
+ // convention belongs to the framework, not the user config.
177
+ const mdxOptions: Record<string, unknown> = { development: ctx.dev };
156
178
 
157
179
  const mdxComponentsPath = findMdxComponents(ctx.root);
158
180
  if (mdxComponentsPath) {
159
181
  mdxOptions.providerImportSource = mdxComponentsPath;
160
182
  }
161
183
 
162
- innerPlugin = createMdxPlugin(mdxOptions);
184
+ innerPlugin = createSatteriPlugin({
185
+ mdx: mdxOptions,
186
+ ...(mdxConfig.mdastPlugins ? { mdastPlugins: mdxConfig.mdastPlugins } : {}),
187
+ ...(mdxConfig.hastPlugins ? { hastPlugins: mdxConfig.hastPlugins } : {}),
188
+ ...(mdxConfig.features ? { features: mdxConfig.features } : {}),
189
+ });
163
190
  }
164
191
 
165
192
  return {
@@ -208,6 +235,13 @@ export function timberMdx(ctx: PluginContext): Plugin {
208
235
 
209
236
  async transform(code, id) {
210
237
  if (!innerPlugin) return null;
238
+ // Ids with a Vite query (?raw, ?url, ?import) are asset requests, not
239
+ // modules to compile. vite-plugin-satteri's id regexes match them, but
240
+ // by the time this transform runs the code is already the asset module
241
+ // (e.g. `export default "<file text>"` for ?raw) — compiling that as
242
+ // markdown corrupts it. @mdx-js/rollup matched by extname and skipped
243
+ // queried ids; preserve that behavior.
244
+ if (id.includes('?')) return null;
211
245
  const envName = (this as unknown as { environment?: { name?: string } }).environment?.name;
212
246
  if (envName && envName !== 'rsc') return null;
213
247
  if (typeof innerPlugin.transform === 'function') {
@@ -43,7 +43,8 @@ const BUILD_ONLY_EXTERNALS: string[] = [
43
43
  'vite',
44
44
  'nitro',
45
45
  'rollup',
46
- '@mdx-js/rollup',
46
+ 'vite-plugin-satteri',
47
+ 'satteri',
47
48
  'fsevents',
48
49
  'chokidar',
49
50
  ];
@@ -22,6 +22,9 @@
22
22
  * `ManifestSegmentNode` (request time) — keys depend only on the
23
23
  * URL path and the segment classification, never on file payloads.
24
24
  */
25
+
26
+ import type { CoercedParams } from '../shared/param-value.js';
27
+
25
28
  export interface SegmentKeyInput {
26
29
  urlPath: string;
27
30
  segmentName?: string;
@@ -170,14 +173,14 @@ export function computeSlotContentKey(
170
173
  slotKey: string,
171
174
  ownerParts: string[],
172
175
  entryFile: string | null,
173
- slotParams: Record<string, string | string[]>
176
+ slotParams: CoercedParams
174
177
  ): string {
175
178
  const owner = ownerParts.join('/');
176
179
  const entry = entryFile ?? '\x01';
177
180
  const paramKeys = Object.keys(slotParams).sort();
178
181
  const paramParts = paramKeys.map((k) => {
179
182
  const v = slotParams[k];
180
- return Array.isArray(v) ? `${k}=${v.join('\x02')}` : `${k}=${v}`;
183
+ return Array.isArray(v) ? `${k}=${v.map(String).join('\x02')}` : `${k}=${String(v)}`;
181
184
  });
182
185
  return [slotKey, owner, entry, ...paramParts].join('\0');
183
186
  }