@timber-js/app 0.2.0-alpha.169 → 0.2.0-alpha.170

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 (59) hide show
  1. package/LICENSE +8 -0
  2. package/dist/_chunks/{canonicalize-P41GR6tY.js → canonicalize-Du3o_ptW.js} +30 -12
  3. package/dist/_chunks/canonicalize-Du3o_ptW.js.map +1 -0
  4. package/dist/_chunks/{cli-schema-sync-B73L6pMq.js → cli-schema-sync-B5FDplGI.js} +55 -7
  5. package/dist/_chunks/cli-schema-sync-B5FDplGI.js.map +1 -0
  6. package/dist/_chunks/{segment-classify-CDDRVKs7.js → segment-classify-Byy425ng.js} +57 -18
  7. package/dist/_chunks/segment-classify-Byy425ng.js.map +1 -0
  8. package/dist/_chunks/{walkers-CoOC8Hga.js → walkers-DCoE-LJf.js} +23 -9
  9. package/dist/_chunks/walkers-DCoE-LJf.js.map +1 -0
  10. package/dist/cli-schema-sync.d.ts.map +1 -1
  11. package/dist/cli.js +1 -1
  12. package/dist/client/index.js +5 -2
  13. package/dist/client/index.js.map +1 -1
  14. package/dist/client/link.d.ts.map +1 -1
  15. package/dist/index.js +26 -4
  16. package/dist/index.js.map +1 -1
  17. package/dist/routing/index.js +3 -3
  18. package/dist/routing/link-codegen.d.ts.map +1 -1
  19. package/dist/routing/manifest-codegen.d.ts.map +1 -1
  20. package/dist/routing/scanner.d.ts.map +1 -1
  21. package/dist/routing/schema-validation.d.ts.map +1 -1
  22. package/dist/routing/segment-classify.d.ts +8 -2
  23. package/dist/routing/segment-classify.d.ts.map +1 -1
  24. package/dist/routing/types.d.ts +4 -0
  25. package/dist/routing/types.d.ts.map +1 -1
  26. package/dist/server/internal.js +13 -4
  27. package/dist/server/internal.js.map +1 -1
  28. package/dist/server/metadata-routes.d.ts +24 -4
  29. package/dist/server/metadata-routes.d.ts.map +1 -1
  30. package/dist/server/pipeline-interception.d.ts.map +1 -1
  31. package/dist/server/pipeline-phases.d.ts.map +1 -1
  32. package/dist/server/route-element-builder.d.ts.map +1 -1
  33. package/dist/server/route-matcher.d.ts.map +1 -1
  34. package/dist/server/sitemap-generator.d.ts.map +1 -1
  35. package/docs/more/01-advanced-routing.mdx +35 -1
  36. package/docs/more/04-metadata-and-fonts.mdx +2 -2
  37. package/package.json +7 -8
  38. package/src/cli-schema-sync.ts +15 -2
  39. package/src/cli.ts +0 -0
  40. package/src/client/link.tsx +4 -1
  41. package/src/routing/codegen.ts +13 -1
  42. package/src/routing/link-codegen.ts +15 -12
  43. package/src/routing/manifest-codegen.ts +6 -0
  44. package/src/routing/scanner.ts +85 -3
  45. package/src/routing/schema-validation.ts +7 -1
  46. package/src/routing/segment-classify.ts +49 -14
  47. package/src/routing/types.ts +4 -0
  48. package/src/server/metadata-routes.ts +71 -10
  49. package/src/server/pipeline-interception.ts +14 -1
  50. package/src/server/pipeline-phases.ts +7 -1
  51. package/src/server/route-element-builder.ts +12 -3
  52. package/src/server/route-matcher.ts +16 -2
  53. package/src/server/sitemap-generator.ts +5 -4
  54. package/src/server/slot-resolver.ts +7 -1
  55. package/src/server/tree-match.ts +46 -4
  56. package/dist/_chunks/canonicalize-P41GR6tY.js.map +0 -1
  57. package/dist/_chunks/cli-schema-sync-B73L6pMq.js.map +0 -1
  58. package/dist/_chunks/segment-classify-CDDRVKs7.js.map +0 -1
  59. package/dist/_chunks/walkers-CoOC8Hga.js.map +0 -1
@@ -34,8 +34,18 @@ export declare const METADATA_ROUTE_CONVENTIONS: Record<string, {
34
34
  nestable: boolean;
35
35
  staticExtensions: string[];
36
36
  dynamicExtensions: string[];
37
- /** The URL path this file serves at (relative to segment). */
37
+ /**
38
+ * The URL path basename this file serves at (relative to segment).
39
+ * For image routes, the full serve path includes an extension via
40
+ * `resolveServePathForFile()`.
41
+ */
38
42
  servePath: string;
43
+ /**
44
+ * When set, image routes append `.{serveExtension}` to the serve path.
45
+ * Dynamic handlers (`.ts`/`.tsx`) use this as the default. Static files
46
+ * use their own extension instead. Non-image routes leave this undefined.
47
+ */
48
+ serveExtension?: string;
39
49
  }>;
40
50
  /**
41
51
  * Resolve the concrete MIME type for a static metadata route file.
@@ -72,10 +82,20 @@ export declare function isDynamicMetadataExtension(baseName: string, extension:
72
82
  */
73
83
  export declare function classifyMetadataRoute(fileName: string): MetadataRouteInfo | null;
74
84
  /**
75
- * Get the serve path for a metadata route type.
85
+ * Resolve the serve path for a metadata route file.
76
86
  *
77
- * @param type - The metadata route type
78
- * @returns The URL path fragment this route serves at
87
+ * For image routes (icon, opengraph-image, apple-icon), the serve path includes
88
+ * a file extension so CDNs cache correctly:
89
+ * - Dynamic handlers (.ts/.tsx) use the convention's `serveExtension` (default: .png)
90
+ * - Static files use their own extension (e.g., icon.svg → icon.svg)
91
+ *
92
+ * Non-image routes return the convention's `servePath` as-is (already includes
93
+ * extension: sitemap.xml, robots.txt, etc.).
94
+ */
95
+ export declare function resolveServePathForFile(baseName: string, filePath: string): string;
96
+ /**
97
+ * Get the default serve path for a metadata route type (using default extension
98
+ * for image routes). Used for auto-link generation when only the type is known.
79
99
  */
80
100
  export declare function getMetadataRouteServePath(type: MetadataRouteType): string;
81
101
  export declare function isMetadataRouteServePath(pathname: string): boolean;
@@ -1 +1 @@
1
- {"version":3,"file":"metadata-routes.d.ts","sourceRoot":"","sources":["../../src/server/metadata-routes.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAIH,+CAA+C;AAC/C,MAAM,WAAW,iBAAiB;IAChC,+BAA+B;IAC/B,IAAI,EAAE,iBAAiB,CAAC;IACxB,iDAAiD;IACjD,WAAW,EAAE,MAAM,CAAC;IACpB,4EAA4E;IAC5E,QAAQ,EAAE,OAAO,CAAC;CACnB;AAED,MAAM,MAAM,iBAAiB,GACzB,SAAS,GACT,QAAQ,GACR,UAAU,GACV,SAAS,GACT,MAAM,GACN,iBAAiB,GACjB,YAAY,CAAC;AAIjB;;;;;;;;GAQG;AACH,eAAO,MAAM,0BAA0B,EAAE,MAAM,CAC7C,MAAM,EACN;IACE,IAAI,EAAE,iBAAiB,CAAC;IACxB,WAAW,EAAE,MAAM,CAAC;IACpB,QAAQ,EAAE,OAAO,CAAC;IAClB,gBAAgB,EAAE,MAAM,EAAE,CAAC;IAC3B,iBAAiB,EAAE,MAAM,EAAE,CAAC;IAC5B,8DAA8D;IAC9D,SAAS,EAAE,MAAM,CAAC;CACnB,CA2DF,CAAC;AAoBF;;;;;;;;;GASG;AACH,wBAAgB,wBAAwB,CAAC,qBAAqB,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,MAAM,CAKjG;AAED;;;;;;GAMG;AACH,wBAAgB,yBAAyB,CAAC,QAAQ,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAItF;AAED;;;;;;GAMG;AACH,wBAAgB,0BAA0B,CAAC,QAAQ,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAIvF;AAID;;;;;GAKG;AACH,wBAAgB,qBAAqB,CAAC,QAAQ,EAAE,MAAM,GAAG,iBAAiB,GAAG,IAAI,CAoBhF;AAED;;;;;GAKG;AACH,wBAAgB,yBAAyB,CAAC,IAAI,EAAE,iBAAiB,GAAG,MAAM,CAKzE;AAMD,wBAAgB,wBAAwB,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAGlE;AAED,8BAA8B;AAC9B,MAAM,WAAW,YAAY;IAC3B,GAAG,EAAE,MAAM,CAAC;IACZ,GAAG,EAAE,MAAM,CAAC;IACZ,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAED,8BAA8B;AAC9B,MAAM,WAAW,YAAY;IAC3B,GAAG,EAAE,MAAM,CAAC;IACZ,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,MAAM,WAAW,GAAG,YAAY,GAAG,YAAY,CAAC;AAEtD;;;;;;;;;;;GAWG;AACH,wBAAgB,wBAAwB,CAAC,IAAI,EAAE,iBAAiB,EAAE,IAAI,EAAE,MAAM,GAAG,WAAW,EAAE,CAgB7F"}
1
+ {"version":3,"file":"metadata-routes.d.ts","sourceRoot":"","sources":["../../src/server/metadata-routes.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAIH,+CAA+C;AAC/C,MAAM,WAAW,iBAAiB;IAChC,+BAA+B;IAC/B,IAAI,EAAE,iBAAiB,CAAC;IACxB,iDAAiD;IACjD,WAAW,EAAE,MAAM,CAAC;IACpB,4EAA4E;IAC5E,QAAQ,EAAE,OAAO,CAAC;CACnB;AAED,MAAM,MAAM,iBAAiB,GACzB,SAAS,GACT,QAAQ,GACR,UAAU,GACV,SAAS,GACT,MAAM,GACN,iBAAiB,GACjB,YAAY,CAAC;AAIjB;;;;;;;;GAQG;AACH,eAAO,MAAM,0BAA0B,EAAE,MAAM,CAC7C,MAAM,EACN;IACE,IAAI,EAAE,iBAAiB,CAAC;IACxB,WAAW,EAAE,MAAM,CAAC;IACpB,QAAQ,EAAE,OAAO,CAAC;IAClB,gBAAgB,EAAE,MAAM,EAAE,CAAC;IAC3B,iBAAiB,EAAE,MAAM,EAAE,CAAC;IAC5B;;;;OAIG;IACH,SAAS,EAAE,MAAM,CAAC;IAClB;;;;OAIG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC;CACzB,CA8DF,CAAC;AAoBF;;;;;;;;;GASG;AACH,wBAAgB,wBAAwB,CAAC,qBAAqB,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,MAAM,CAKjG;AAED;;;;;;GAMG;AACH,wBAAgB,yBAAyB,CAAC,QAAQ,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAItF;AAED;;;;;;GAMG;AACH,wBAAgB,0BAA0B,CAAC,QAAQ,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAIvF;AAID;;;;;GAKG;AACH,wBAAgB,qBAAqB,CAAC,QAAQ,EAAE,MAAM,GAAG,iBAAiB,GAAG,IAAI,CAoBhF;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,uBAAuB,CAAC,QAAQ,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,MAAM,CAWlF;AAED;;;GAGG;AACH,wBAAgB,yBAAyB,CAAC,IAAI,EAAE,iBAAiB,GAAG,MAAM,CAUzE;AAiBD,wBAAgB,wBAAwB,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAalE;AAED,8BAA8B;AAC9B,MAAM,WAAW,YAAY;IAC3B,GAAG,EAAE,MAAM,CAAC;IACZ,GAAG,EAAE,MAAM,CAAC;IACZ,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAED,8BAA8B;AAC9B,MAAM,WAAW,YAAY;IAC3B,GAAG,EAAE,MAAM,CAAC;IACZ,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,MAAM,WAAW,GAAG,YAAY,GAAG,YAAY,CAAC;AAEtD;;;;;;;;;;;GAWG;AACH,wBAAgB,wBAAwB,CAAC,IAAI,EAAE,iBAAiB,EAAE,IAAI,EAAE,MAAM,GAAG,WAAW,EAAE,CAgB7F"}
@@ -1 +1 @@
1
- {"version":3,"file":"pipeline-interception.d.ts","sourceRoot":"","sources":["../../src/server/pipeline-interception.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAIH,iDAAiD;AACjD,MAAM,WAAW,uBAAuB;IACtC,yEAAyE;IACzE,cAAc,EAAE,MAAM,CAAC;CACxB;AAcD;;;;;;;GAOG;AACH,wBAAgB,qBAAqB,CACnC,cAAc,EAAE,MAAM,EACtB,SAAS,EAAE,MAAM,EACjB,QAAQ,EAAE,OAAO,4BAA4B,EAAE,mBAAmB,EAAE,GACnE,uBAAuB,GAAG,IAAI,CAahC;AAED;;;;;GAKG;AACH,wBAAgB,sBAAsB,CAAC,QAAQ,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAyBjF"}
1
+ {"version":3,"file":"pipeline-interception.d.ts","sourceRoot":"","sources":["../../src/server/pipeline-interception.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAIH,iDAAiD;AACjD,MAAM,WAAW,uBAAuB;IACtC,yEAAyE;IACzE,cAAc,EAAE,MAAM,CAAC;CACxB;AAcD;;;;;;;GAOG;AACH,wBAAgB,qBAAqB,CACnC,cAAc,EAAE,MAAM,EACtB,SAAS,EAAE,MAAM,EACjB,QAAQ,EAAE,OAAO,4BAA4B,EAAE,mBAAmB,EAAE,GACnE,uBAAuB,GAAG,IAAI,CAahC;AAED;;;;;GAKG;AACH,wBAAgB,sBAAsB,CAAC,QAAQ,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAsCjF"}
@@ -1 +1 @@
1
- {"version":3,"file":"pipeline-phases.d.ts","sourceRoot":"","sources":["../../src/server/pipeline-phases.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAoBH,OAAO,EAA2C,KAAK,aAAa,EAAE,MAAM,uBAAuB,CAAC;AAEpG,OAAO,EAAqB,KAAK,YAAY,EAAE,MAAM,uBAAuB,CAAC;AAC7E,OAAO,KAAK,EAAE,mBAAmB,EAAE,cAAc,EAAE,UAAU,EAAE,MAAM,eAAe,CAAC;AAKrF,UAAU,aAAa;IACrB,iBAAiB,EAAE,MAAM,CAAC;IAC1B,YAAY,CAAC,EAAE,mBAAmB,CAAC;CACpC;AAwBD;;;;;;;;GAQG;AACH,wBAAsB,aAAa,CACjC,MAAM,EAAE,cAAc,EACtB,QAAQ,EAAE,aAAa,EACvB,GAAG,EAAE,OAAO,EACZ,MAAM,EAAE,MAAM,EACd,IAAI,EAAE,MAAM,EACZ,IAAI,CAAC,EAAE,MAAM,OAAO,CAAC,QAAQ,CAAC,GAC7B,OAAO,CAAC,YAAY,CAAC,CAmBvB;AAID;;;;GAIG;AACH,wBAAsB,kBAAkB,CACtC,MAAM,EAAE,cAAc,EACtB,GAAG,EAAE,OAAO,EACZ,KAAK,EAAE,UAAU,EACjB,eAAe,EAAE,OAAO,EACxB,oBAAoB,EAAE,OAAO,EAC7B,aAAa,EAAE,aAAa,GAC3B,OAAO,CAAC,YAAY,CAAC,CA6DvB;AAID;;;GAGG;AACH,wBAAsB,cAAc,CAClC,MAAM,EAAE,cAAc,EACtB,GAAG,EAAE,OAAO,EACZ,KAAK,EAAE,UAAU,EACjB,eAAe,EAAE,OAAO,EACxB,oBAAoB,EAAE,OAAO,EAC7B,EAAE,iBAAiB,EAAE,YAAY,EAAE,EAAE,aAAa,GACjD,OAAO,CAAC,YAAY,CAAC,CAkBvB;AAID;;;;;;;;;;;;;;GAcG;AACH,wBAAsB,aAAa,CACjC,MAAM,EAAE,cAAc,EACtB,GAAG,EAAE,OAAO,EACZ,MAAM,EAAE,MAAM,EACd,IAAI,EAAE,MAAM,EACZ,eAAe,CAAC,EAAE,OAAO,GACxB,OAAO,CAAC,QAAQ,CAAC,CA6SnB"}
1
+ {"version":3,"file":"pipeline-phases.d.ts","sourceRoot":"","sources":["../../src/server/pipeline-phases.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAoBH,OAAO,EAA2C,KAAK,aAAa,EAAE,MAAM,uBAAuB,CAAC;AAEpG,OAAO,EAAqB,KAAK,YAAY,EAAE,MAAM,uBAAuB,CAAC;AAC7E,OAAO,KAAK,EAAE,mBAAmB,EAAE,cAAc,EAAE,UAAU,EAAE,MAAM,eAAe,CAAC;AAKrF,UAAU,aAAa;IACrB,iBAAiB,EAAE,MAAM,CAAC;IAC1B,YAAY,CAAC,EAAE,mBAAmB,CAAC;CACpC;AAwBD;;;;;;;;GAQG;AACH,wBAAsB,aAAa,CACjC,MAAM,EAAE,cAAc,EACtB,QAAQ,EAAE,aAAa,EACvB,GAAG,EAAE,OAAO,EACZ,MAAM,EAAE,MAAM,EACd,IAAI,EAAE,MAAM,EACZ,IAAI,CAAC,EAAE,MAAM,OAAO,CAAC,QAAQ,CAAC,GAC7B,OAAO,CAAC,YAAY,CAAC,CAmBvB;AAID;;;;GAIG;AACH,wBAAsB,kBAAkB,CACtC,MAAM,EAAE,cAAc,EACtB,GAAG,EAAE,OAAO,EACZ,KAAK,EAAE,UAAU,EACjB,eAAe,EAAE,OAAO,EACxB,oBAAoB,EAAE,OAAO,EAC7B,aAAa,EAAE,aAAa,GAC3B,OAAO,CAAC,YAAY,CAAC,CA6DvB;AAID;;;GAGG;AACH,wBAAsB,cAAc,CAClC,MAAM,EAAE,cAAc,EACtB,GAAG,EAAE,OAAO,EACZ,KAAK,EAAE,UAAU,EACjB,eAAe,EAAE,OAAO,EACxB,oBAAoB,EAAE,OAAO,EAC7B,EAAE,iBAAiB,EAAE,YAAY,EAAE,EAAE,aAAa,GACjD,OAAO,CAAC,YAAY,CAAC,CAkBvB;AAID;;;;;;;;;;;;;;GAcG;AACH,wBAAsB,aAAa,CACjC,MAAM,EAAE,cAAc,EACtB,GAAG,EAAE,OAAO,EACZ,MAAM,EAAE,MAAM,EACd,IAAI,EAAE,MAAM,EACZ,eAAe,CAAC,EAAE,OAAO,GACxB,OAAO,CAAC,QAAQ,CAAC,CAmTnB"}
@@ -1 +1 @@
1
- {"version":3,"file":"route-element-builder.d.ts","sourceRoot":"","sources":["../../src/server/route-element-builder.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAMH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,eAAe,CAAC;AAChD,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,oBAAoB,CAAC;AA6B9D,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,eAAe,CAAC;AAEzD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,eAAe,CAAC;AAEjD;;;;;;;GAOG;AACH,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,WAAW,EAAE,GAAG,KAAK,CAAC,YAAY,CAW/E;AAwCD;;;;;;;;GAQG;AACH,wBAAgB,iBAAiB,CAAC,SAAS,EAAE,OAAO,GAAG,OAAO,CAM7D;AAID;;;GAGG;AACH,qBAAa,kBAAmB,SAAQ,KAAK;IAC3C,YAAY,OAAO,EAAE,MAAM,EAG1B;CACF;AAED;;;GAGG;AACH,qBAAa,oBAAqB,SAAQ,KAAK;IAC7C,YAAY,OAAO,EAAE,MAAM,EAG1B;CACF;AAID,+CAA+C;AAC/C,MAAM,WAAW,oBAAoB;IACnC,SAAS,EAAE,CAAC,GAAG,IAAI,EAAE,OAAO,EAAE,KAAK,OAAO,CAAC;IAC3C,OAAO,EAAE,mBAAmB,CAAC;CAC9B;AAED,+CAA+C;AAC/C,MAAM,WAAW,kBAAkB;IACjC,wFAAwF;IACxF,OAAO,EAAE,KAAK,CAAC,YAAY,CAAC;IAC5B,wDAAwD;IACxD,gBAAgB,EAAE,oBAAoB,EAAE,CAAC;IACzC,qCAAqC;IACrC,QAAQ,EAAE,mBAAmB,EAAE,CAAC;IAChC,4DAA4D;IAC5D,gBAAgB,EAAE,MAAM,CAAC;IACzB;;;;;OAKG;IACH,eAAe,EAAE,MAAM,EAAE,CAAC;IAC1B;;;;;;;OAOG;IACH,YAAY,CAAC,EAAE,OAAO,CAAC,IAAI,CAAC,CAAC;CAC9B;AA8DD;;;;;;;;;;;;;;;GAeG;AACH,wBAAsB,iBAAiB,CACrC,GAAG,EAAE,OAAO,EACZ,KAAK,EAAE,UAAU,EACjB,YAAY,CAAC,EAAE,mBAAmB,EAClC,eAAe,CAAC,EAAE,GAAG,CAAC,MAAM,CAAC,GAAG,IAAI,EACpC,mBAAmB,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAC3C,OAAO,CAAC,kBAAkB,CAAC,CAkjB7B"}
1
+ {"version":3,"file":"route-element-builder.d.ts","sourceRoot":"","sources":["../../src/server/route-element-builder.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAMH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,eAAe,CAAC;AAChD,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,oBAAoB,CAAC;AAiC9D,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,eAAe,CAAC;AAEzD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,eAAe,CAAC;AAEjD;;;;;;;GAOG;AACH,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,WAAW,EAAE,GAAG,KAAK,CAAC,YAAY,CAW/E;AAwCD;;;;;;;;GAQG;AACH,wBAAgB,iBAAiB,CAAC,SAAS,EAAE,OAAO,GAAG,OAAO,CAM7D;AAID;;;GAGG;AACH,qBAAa,kBAAmB,SAAQ,KAAK;IAC3C,YAAY,OAAO,EAAE,MAAM,EAG1B;CACF;AAED;;;GAGG;AACH,qBAAa,oBAAqB,SAAQ,KAAK;IAC7C,YAAY,OAAO,EAAE,MAAM,EAG1B;CACF;AAID,+CAA+C;AAC/C,MAAM,WAAW,oBAAoB;IACnC,SAAS,EAAE,CAAC,GAAG,IAAI,EAAE,OAAO,EAAE,KAAK,OAAO,CAAC;IAC3C,OAAO,EAAE,mBAAmB,CAAC;CAC9B;AAED,+CAA+C;AAC/C,MAAM,WAAW,kBAAkB;IACjC,wFAAwF;IACxF,OAAO,EAAE,KAAK,CAAC,YAAY,CAAC;IAC5B,wDAAwD;IACxD,gBAAgB,EAAE,oBAAoB,EAAE,CAAC;IACzC,qCAAqC;IACrC,QAAQ,EAAE,mBAAmB,EAAE,CAAC;IAChC,4DAA4D;IAC5D,gBAAgB,EAAE,MAAM,CAAC;IACzB;;;;;OAKG;IACH,eAAe,EAAE,MAAM,EAAE,CAAC;IAC1B;;;;;;;OAOG;IACH,YAAY,CAAC,EAAE,OAAO,CAAC,IAAI,CAAC,CAAC;CAC9B;AA8DD;;;;;;;;;;;;;;;GAeG;AACH,wBAAsB,iBAAiB,CACrC,GAAG,EAAE,OAAO,EACZ,KAAK,EAAE,UAAU,EACjB,YAAY,CAAC,EAAE,mBAAmB,EAClC,eAAe,CAAC,EAAE,GAAG,CAAC,MAAM,CAAC,GAAG,IAAI,EACpC,mBAAmB,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAC3C,OAAO,CAAC,kBAAkB,CAAC,CAujB7B"}
@@ -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,KAAK,EAAE,WAAW,EAAE,SAAS,EAAE,MAAM,qBAAqB,CAAC;AAClE,OAAO,EAIL,KAAK,iBAAiB,EACvB,MAAM,sBAAsB,CAAC;AAY9B,6DAA6D;AAC7D,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,MAAM,OAAO,CAAC,OAAO,CAAC,CAAC;IAC7B,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED;;;;;;GAMG;AACH,MAAM,MAAM,mBAAmB,GAAG,WAAW,CAAC,YAAY,CAAC,CAAC;AAE5D;;;;;;;;GAQG;AACH,MAAM,WAAW,YAAa,SAAQ,SAAS,CAAC,YAAY,CAAC;IAC3D,QAAQ,EAAE,MAAM,CAAC;CAClB;AAID;;;;;GAKG;AACH,wBAAgB,kBAAkB,CAChC,QAAQ,EAAE,YAAY,GACrB,CAAC,QAAQ,EAAE,MAAM,KAAK,UAAU,GAAG,IAAI,CAEzC;AAsCD,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;IAClB,wEAAwE;IACxE,aAAa,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,EAAE,CAAC,CAAC;CAClD;AAQD;;;;;;;GAOG;AACH,wBAAgB,0BAA0B,CACxC,QAAQ,EAAE,YAAY,GACrB,CAAC,QAAQ,EAAE,MAAM,KAAK,kBAAkB,GAAG,IAAI,CAEjD"}
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,KAAK,EAAE,WAAW,EAAE,SAAS,EAAE,MAAM,qBAAqB,CAAC;AAClE,OAAO,EAKL,KAAK,iBAAiB,EACvB,MAAM,sBAAsB,CAAC;AAY9B,6DAA6D;AAC7D,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,MAAM,OAAO,CAAC,OAAO,CAAC,CAAC;IAC7B,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED;;;;;;GAMG;AACH,MAAM,MAAM,mBAAmB,GAAG,WAAW,CAAC,YAAY,CAAC,CAAC;AAE5D;;;;;;;;GAQG;AACH,MAAM,WAAW,YAAa,SAAQ,SAAS,CAAC,YAAY,CAAC;IAC3D,QAAQ,EAAE,MAAM,CAAC;CAClB;AAID;;;;;GAKG;AACH,wBAAgB,kBAAkB,CAChC,QAAQ,EAAE,YAAY,GACrB,CAAC,QAAQ,EAAE,MAAM,KAAK,UAAU,GAAG,IAAI,CAEzC;AAsCD,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;IAClB,wEAAwE;IACxE,aAAa,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,EAAE,CAAC,CAAC;CAClD;AAcD;;;;;;;GAOG;AACH,wBAAgB,0BAA0B,CACxC,QAAQ,EAAE,YAAY,GACrB,CAAC,QAAQ,EAAE,MAAM,KAAK,kBAAkB,GAAG,IAAI,CAEjD"}
@@ -1 +1 @@
1
- {"version":3,"file":"sitemap-generator.d.ts","sourceRoot":"","sources":["../../src/server/sitemap-generator.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAGH,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAC;AAIvD,kEAAkE;AAClE,MAAM,WAAW,aAAa;IAC5B,yDAAyD;IACzD,OAAO,EAAE,OAAO,CAAC;IACjB,uFAAuF;IACvF,OAAO,EAAE,MAAM,CAAC;IAChB,oDAAoD;IACpD,sBAAsB,CAAC,EAAE,MAAM,CAAC;IAChC,4DAA4D;IAC5D,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,6DAA6D;IAC7D,gBAAgB,CAAC,EAAE,OAAO,CAAC;CAC5B;AAED,sDAAsD;AACtD,MAAM,WAAW,YAAY;IAC3B,GAAG,EAAE,MAAM,CAAC;IACZ,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAED;;;GAGG;AACH,MAAM,WAAW,YAAY;IAC3B,qDAAqD;IACrD,OAAO,EAAE,MAAM,CAAC;IAChB,qFAAqF;IACrF,SAAS,EAAE,OAAO,CAAC;IACnB,wDAAwD;IACxD,UAAU,CAAC,EAAE;QAAE,IAAI,EAAE,MAAM,OAAO,CAAC,OAAO,CAAC,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAA;KAAE,CAAC;CACjE;AAED;;;GAGG;AACH,MAAM,MAAM,YAAY,GAAG,CAAC,MAAM,EAAE;IAClC,IAAI,EAAE,MAAM,OAAO,CAAC,OAAO,CAAC,CAAC;IAC7B,QAAQ,EAAE,MAAM,CAAC;CAClB,KAAK,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;AAEvC,oCAAoC;AACpC,MAAM,MAAM,aAAa,GACrB;IAAE,IAAI,EAAE,QAAQ,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,GAC/B;IAAE,IAAI,EAAE,OAAO,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;CAAE,CAAC;AAIpE,8DAA8D;AAC9D,eAAO,MAAM,gBAAgB,QAAS,CAAC;AAIvC;;;;;;;;;GASG;AACH,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,WAAW,EAAE,MAAM,EAAE,aAAa,GAAG,YAAY,EAAE,CAI7F;AAwDD;;;;GAIG;AACH,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAE3D;AAID;;;;;;;;;;GAUG;AACH,wBAAsB,kBAAkB,CACtC,MAAM,EAAE,YAAY,EAAE,EACtB,MAAM,EAAE,aAAa,EACrB,UAAU,CAAC,EAAE,YAAY,GACxB,OAAO,CAAC,YAAY,EAAE,CAAC,CAqDzB;AAED;;;;;;;;;GASG;AACH,wBAAgB,qBAAqB,CACnC,OAAO,EAAE,MAAM,EACf,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,EAAE,CAAC,GACxC,MAAM,GAAG,IAAI,CA0Cf;AAID;;;;;;;;;;GAUG;AACH,wBAAsB,eAAe,CACnC,IAAI,EAAE,WAAW,EACjB,MAAM,EAAE,aAAa,EACrB,UAAU,CAAC,EAAE,YAAY,GACxB,OAAO,CAAC,aAAa,CAAC,CA+BxB;AAED;;;;;GAKG;AACH,wBAAgB,cAAc,CAAC,IAAI,EAAE,WAAW,GAAG,OAAO,CAGzD"}
1
+ {"version":3,"file":"sitemap-generator.d.ts","sourceRoot":"","sources":["../../src/server/sitemap-generator.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAGH,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAC;AAIvD,kEAAkE;AAClE,MAAM,WAAW,aAAa;IAC5B,yDAAyD;IACzD,OAAO,EAAE,OAAO,CAAC;IACjB,uFAAuF;IACvF,OAAO,EAAE,MAAM,CAAC;IAChB,oDAAoD;IACpD,sBAAsB,CAAC,EAAE,MAAM,CAAC;IAChC,4DAA4D;IAC5D,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,6DAA6D;IAC7D,gBAAgB,CAAC,EAAE,OAAO,CAAC;CAC5B;AAED,sDAAsD;AACtD,MAAM,WAAW,YAAY;IAC3B,GAAG,EAAE,MAAM,CAAC;IACZ,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAED;;;GAGG;AACH,MAAM,WAAW,YAAY;IAC3B,qDAAqD;IACrD,OAAO,EAAE,MAAM,CAAC;IAChB,qFAAqF;IACrF,SAAS,EAAE,OAAO,CAAC;IACnB,wDAAwD;IACxD,UAAU,CAAC,EAAE;QAAE,IAAI,EAAE,MAAM,OAAO,CAAC,OAAO,CAAC,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAA;KAAE,CAAC;CACjE;AAED;;;GAGG;AACH,MAAM,MAAM,YAAY,GAAG,CAAC,MAAM,EAAE;IAClC,IAAI,EAAE,MAAM,OAAO,CAAC,OAAO,CAAC,CAAC;IAC7B,QAAQ,EAAE,MAAM,CAAC;CAClB,KAAK,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;AAEvC,oCAAoC;AACpC,MAAM,MAAM,aAAa,GACrB;IAAE,IAAI,EAAE,QAAQ,CAAC;IAAC,GAAG,EAAE,MAAM,CAAA;CAAE,GAC/B;IAAE,IAAI,EAAE,OAAO,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;CAAE,CAAC;AAIpE,8DAA8D;AAC9D,eAAO,MAAM,gBAAgB,QAAS,CAAC;AAIvC;;;;;;;;;GASG;AACH,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,WAAW,EAAE,MAAM,EAAE,aAAa,GAAG,YAAY,EAAE,CAI7F;AAwDD;;;;GAIG;AACH,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAE3D;AAID;;;;;;;;;;GAUG;AACH,wBAAsB,kBAAkB,CACtC,MAAM,EAAE,YAAY,EAAE,EACtB,MAAM,EAAE,aAAa,EACrB,UAAU,CAAC,EAAE,YAAY,GACxB,OAAO,CAAC,YAAY,EAAE,CAAC,CAqDzB;AAED;;;;;;;;;GASG;AACH,wBAAgB,qBAAqB,CACnC,OAAO,EAAE,MAAM,EACf,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,EAAE,CAAC,GACxC,MAAM,GAAG,IAAI,CA2Cf;AAID;;;;;;;;;;GAUG;AACH,wBAAsB,eAAe,CACnC,IAAI,EAAE,WAAW,EACjB,MAAM,EAAE,aAAa,EACrB,UAAU,CAAC,EAAE,YAAY,GACxB,OAAO,CAAC,aAAa,CAAC,CA+BxB;AAED;;;;;GAKG;AACH,wBAAgB,cAAc,CAAC,IAAI,EAAE,WAAW,GAAG,OAAO,CAGzD"}
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  title: 'Advanced Routing'
3
- description: 'Route groups, parallel routes (slots), intercepting routes, catch-all segments, and API routes.'
3
+ description: 'Route groups, parallel routes (slots), intercepting routes, catch-all segments, affixed dynamic segments, and API routes.'
4
4
  slug: 'advanced-routing'
5
5
  ---
6
6
 
@@ -45,6 +45,40 @@ app/blog/page.tsx # /blog (index)
45
45
  app/blog/[year]/page.tsx # /blog/2024 (filtered)
46
46
  ```
47
47
 
48
+ ## Affixed Dynamic Segments
49
+
50
+ Dynamic segments can have literal prefixes and/or suffixes:
51
+
52
+ ```
53
+ app/
54
+ images/
55
+ [id].png/
56
+ route.ts # /images/123.png → id = "123"
57
+ user-[name]/
58
+ page.tsx # /user-alice → name = "alice"
59
+ img-[id].webp/
60
+ page.tsx # /img-photo.webp → id = "photo"
61
+ ```
62
+
63
+ This is useful for CDN-cacheable file-extension routes (e.g. image generation endpoints) and prefixed slugs. The literal parts must match exactly; the dynamic middle is extracted as the param value.
64
+
65
+ **Rules:**
66
+ - The param must be non-empty — `img-[id].png` does NOT match `img-.png`
67
+ - One param per segment — `[foo]-[bar]` is not supported
68
+ - Affixes cannot be used with catch-all (`[...slug].png`) or optional catch-all segments
69
+ - Matching is greedy: `[id].png` matching `a.png.png` yields `id = "a.png"`
70
+ - `params.ts` codecs use the bare param name (`id`), not the full segment pattern
71
+
72
+ **Priority:** static → affixed dynamic → bare dynamic → catch-all → optional catch-all. A static `foo.png` always wins over `[id].png`.
73
+
74
+ `<Link>` interpolates affixed segments automatically:
75
+
76
+ ```tsx
77
+ <Link href="/images/[id].png" segmentParams={{ id: 'photo' }}>
78
+ {/* renders href="/images/photo.png" */}
79
+ </Link>
80
+ ```
81
+
48
82
  ## Parallel Routes (Slots)
49
83
 
50
84
  Directories prefixed with `@` are slots — they render simultaneously within a parent layout:
@@ -81,9 +81,9 @@ Special files in your route tree generate metadata endpoints:
81
81
  | `sitemap.ts` | `/sitemap.xml` |
82
82
  | `robots.ts` | `/robots.txt` |
83
83
  | `manifest.ts` | `/manifest.json` |
84
- | `opengraph-image.tsx` | `/opengraph-image` + auto `og:image` and `twitter:image` meta tags |
84
+ | `opengraph-image.tsx` | `/opengraph-image.png` + auto `og:image` and `twitter:image` meta tags |
85
85
  | `favicon.ico` / `favicon.tsx` | `/favicon.ico` |
86
- | `icon.tsx` | `/icon` + auto `<link rel="icon">` |
86
+ | `icon.tsx` | `/icon.png` + auto `<link rel="icon">` |
87
87
 
88
88
  When `opengraph-image.tsx` exists in a segment, timber automatically emits both `<meta property="og:image">` and `<meta name="twitter:image">` in `<head>` — no need to declare them in your `metadata` export. The auto-linked URL includes a cache-busting hash so social platforms pick up changes on redeploy.
89
89
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@timber-js/app",
3
- "version": "0.2.0-alpha.169",
3
+ "version": "0.2.0-alpha.170",
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",
@@ -150,12 +150,6 @@
150
150
  "publishConfig": {
151
151
  "access": "public"
152
152
  },
153
- "scripts": {
154
- "copy-docs": "node scripts/copy-docs.js",
155
- "build": "vite build --config vite.lib.config.ts && tsc --emitDeclarationOnly --project tsconfig.json --outDir dist && pnpm run copy-docs",
156
- "typecheck": "tsgo --noEmit",
157
- "prepublishOnly": "pnpm run build"
158
- },
159
153
  "dependencies": {
160
154
  "@opentelemetry/api": "^1.9.1",
161
155
  "@opentelemetry/context-async-hooks": "^2.9.0",
@@ -202,5 +196,10 @@
202
196
  },
203
197
  "engines": {
204
198
  "node": ">=22.18.0"
199
+ },
200
+ "scripts": {
201
+ "copy-docs": "node scripts/copy-docs.js",
202
+ "build": "vite build --config vite.lib.config.ts && tsc --emitDeclarationOnly --project tsconfig.json --outDir dist && pnpm run copy-docs",
203
+ "typecheck": "tsgo --noEmit"
205
204
  }
206
- }
205
+ }
@@ -12,6 +12,18 @@ import { scanRoutes } from './routing/scanner.js';
12
12
  import type { SegmentNode } from './routing/types.js';
13
13
  import { resolveAppDir } from './plugin-context.js';
14
14
 
15
+ function toBracketKey(node: SegmentNode): string {
16
+ if (node.paramPrefix || node.paramSuffix) return `[${node.paramName}]`;
17
+ switch (node.segmentType) {
18
+ case 'catch-all':
19
+ return `[...${node.paramName}]`;
20
+ case 'optional-catch-all':
21
+ return `[[...${node.paramName}]]`;
22
+ default:
23
+ return `[${node.paramName}]`;
24
+ }
25
+ }
26
+
15
27
  /**
16
28
  * Collect all unique bracket-keyed dynamic segment names from the route tree.
17
29
  * Returns entries like '[artistSlug]', '[...slug]', '[[...topic]]'.
@@ -25,8 +37,9 @@ export function collectDynamicSegments(appDir: string): string[] {
25
37
 
26
38
  function walkSegments(node: SegmentNode, segments: Set<string>): void {
27
39
  if (node.paramName) {
28
- // Reconstruct bracket key from segmentName (already has brackets)
29
- segments.add(node.segmentName);
40
+ // Use bare bracket form for schema keys — affixed segments like
41
+ // `img-[id].png` register as `[id]`, matching runtime codec lookup.
42
+ segments.add(toBracketKey(node));
30
43
  }
31
44
  for (const child of node.children) {
32
45
  walkSegments(child, segments);
package/src/cli.ts CHANGED
File without changes
@@ -264,7 +264,10 @@ function resolveSegment(
264
264
  }
265
265
  const codec = getLinkCodec(`[${seg.name}]`);
266
266
  const str = codec ? (codec.serialize(value) ?? String(value)) : String(value);
267
- return encodeURIComponent(str);
267
+ const encoded = encodeURIComponent(str);
268
+ const prefix = seg.prefix ?? '';
269
+ const suffix = seg.suffix ?? '';
270
+ return prefix + encoded + suffix;
268
271
  }
269
272
  }
270
273
  }
@@ -111,7 +111,7 @@ function collectRoutes(
111
111
  const legacyFallback = ownParamsFile ? undefined : findLegacyParamsExport(node);
112
112
  params.push({
113
113
  name: node.paramName,
114
- bracketName: node.segmentName,
114
+ bracketName: bracketNameForSegment(node),
115
115
  type: paramTypeForSegment(node.segmentType),
116
116
  // Codec chain populated at leaf time. We carry the per-segment
117
117
  // legacy fallback (if any) so leaf-time resolution can fall back
@@ -238,6 +238,18 @@ function dedupeRoutesByUrlPath(routes: RouteEntry[]): RouteEntry[] {
238
238
  /**
239
239
  * Determine the TypeScript type for a segment's param.
240
240
  */
241
+ /**
242
+ * Derive the bracket key for schema/codegen lookup.
243
+ * For bare segments like `[id]` this is the segmentName itself.
244
+ * For affixed segments like `img-[id].png` this is `[id]` — the bare bracket form.
245
+ */
246
+ function bracketNameForSegment(node: SegmentNode): string {
247
+ if (node.paramPrefix || node.paramSuffix) {
248
+ return `[${node.paramName}]`;
249
+ }
250
+ return node.segmentName;
251
+ }
252
+
241
253
  function paramTypeForSegment(segmentType: string): ParamEntry['type'] {
242
254
  switch (segmentType) {
243
255
  case 'catch-all':
@@ -29,6 +29,7 @@ import {
29
29
  buildSchemaParamType,
30
30
  formatSearchParamsType,
31
31
  } from './codegen-shared.js';
32
+ import { classifyUrlSegment } from './segment-classify.js';
32
33
 
33
34
  /** Shared Link base-props type literal used in every emitted call signature. */
34
35
  export const LINK_BASE_PROPS_TYPE =
@@ -44,19 +45,21 @@ export const LINK_BASE_PROPS_TYPE =
44
45
  export function buildResolvedPattern(route: RouteEntry): string | null {
45
46
  const parts = route.urlPath.split('/');
46
47
  const templateParts = parts.map((part) => {
47
- if (part.startsWith('[[...') && part.endsWith(']]')) {
48
- // eslint-disable-next-line no-template-curly-in-string -- codegen output
49
- return '${string}';
50
- }
51
- if (part.startsWith('[...') && part.endsWith(']')) {
52
- // eslint-disable-next-line no-template-curly-in-string -- codegen output
53
- return '${string}';
54
- }
55
- if (part.startsWith('[') && part.endsWith(']')) {
56
- // eslint-disable-next-line no-template-curly-in-string -- codegen output
57
- return '${string}';
48
+ const seg = classifyUrlSegment(part);
49
+ switch (seg.kind) {
50
+ case 'catch-all':
51
+ case 'optional-catch-all':
52
+ // eslint-disable-next-line no-template-curly-in-string -- codegen output
53
+ return '${string}';
54
+ case 'dynamic': {
55
+ const prefix = seg.prefix ?? '';
56
+ const suffix = seg.suffix ?? '';
57
+ // eslint-disable-next-line no-template-curly-in-string -- codegen output
58
+ return `${prefix}\${string}${suffix}`;
59
+ }
60
+ default:
61
+ return part;
58
62
  }
59
- return part;
60
63
  });
61
64
  return templateParts.join('/');
62
65
  }
@@ -88,6 +88,12 @@ export function generateManifestModule(tree: RouteTree, viteRoot: string): strin
88
88
  if (node.paramName) {
89
89
  parts.push(`${nextIndent}paramName: ${JSON.stringify(node.paramName)},`);
90
90
  }
91
+ if (node.paramPrefix) {
92
+ parts.push(`${nextIndent}paramPrefix: ${JSON.stringify(node.paramPrefix)},`);
93
+ }
94
+ if (node.paramSuffix) {
95
+ parts.push(`${nextIndent}paramSuffix: ${JSON.stringify(node.paramSuffix)},`);
96
+ }
91
97
 
92
98
  if (node.interceptionMarker) {
93
99
  parts.push(`${nextIndent}interceptionMarker: ${JSON.stringify(node.interceptionMarker)},`);
@@ -98,6 +98,10 @@ export function scanRoutes(appDir: string, config: ScannerConfig = {}): RouteTre
98
98
  // e.g., [id] in one route and [...id] in another
99
99
  validateUniqueBracketForms(tree.root);
100
100
 
101
+ // Validate: detect affixed dynamic siblings with equal specificity
102
+ // e.g., a-[id] and [slug]-b both have prefix+suffix length = 2
103
+ validateAffixedSiblingConflicts(tree.root);
104
+
101
105
  return tree;
102
106
  }
103
107
 
@@ -110,13 +114,17 @@ function createSegmentNode(
110
114
  urlPath: string,
111
115
  paramName?: string,
112
116
  interceptionMarker?: InterceptionMarker,
113
- interceptedSegmentName?: string
117
+ interceptedSegmentName?: string,
118
+ paramPrefix?: string,
119
+ paramSuffix?: string
114
120
  ): SegmentNode {
115
121
  return {
116
122
  segmentName,
117
123
  segmentType,
118
124
  urlPath,
119
125
  paramName,
126
+ paramPrefix,
127
+ paramSuffix,
120
128
  interceptionMarker,
121
129
  interceptedSegmentName,
122
130
  children: [],
@@ -331,7 +339,14 @@ function scanChildren(dirPath: string, parentNode: SegmentNode, extSet: Set<stri
331
339
  );
332
340
  }
333
341
 
334
- const { type, paramName, interceptionMarker, interceptedSegmentName } = classifySegment(entry);
342
+ const {
343
+ type,
344
+ paramName,
345
+ paramPrefix,
346
+ paramSuffix,
347
+ interceptionMarker,
348
+ interceptedSegmentName,
349
+ } = classifySegment(entry);
335
350
 
336
351
  // Skip private folders — underscore-prefixed dirs are excluded from routing
337
352
  if (type === 'private') continue;
@@ -343,7 +358,9 @@ function scanChildren(dirPath: string, parentNode: SegmentNode, extSet: Set<stri
343
358
  urlPath,
344
359
  paramName,
345
360
  interceptionMarker,
346
- interceptedSegmentName
361
+ interceptedSegmentName,
362
+ paramPrefix,
363
+ paramSuffix
347
364
  );
348
365
 
349
366
  // Scan this segment's files
@@ -527,6 +544,71 @@ function collectBracketForms(
527
544
  }
528
545
  }
529
546
 
547
+ /**
548
+ * Validate that no sibling affixed dynamic segments have equal specificity.
549
+ *
550
+ * Among affixed siblings, longer combined prefix+suffix length wins. When two
551
+ * siblings tie (same total affix length), the router would fall back to
552
+ * filesystem scan order — which is platform-dependent. This is a build error.
553
+ *
554
+ * Route groups are URL-transparent, so affixed segments inside different groups
555
+ * at the same level still conflict with each other.
556
+ *
557
+ * See design/07-routing.md §"Priority"
558
+ */
559
+ function validateAffixedSiblingConflicts(node: SegmentNode): void {
560
+ const affixed = collectEffectiveAffixed(node);
561
+
562
+ if (affixed.length >= 2) {
563
+ const byLength = new Map<number, SegmentNode[]>();
564
+ for (const child of affixed) {
565
+ const len = (child.paramPrefix?.length ?? 0) + (child.paramSuffix?.length ?? 0);
566
+ const group = byLength.get(len);
567
+ if (group) {
568
+ group.push(child);
569
+ } else {
570
+ byLength.set(len, [child]);
571
+ }
572
+ }
573
+
574
+ for (const [, group] of byLength) {
575
+ if (group.length >= 2) {
576
+ throw new Error(
577
+ `Build error: ambiguous affixed dynamic siblings with equal specificity.\n` +
578
+ ` Parent: ${node.urlPath || '/'}\n` +
579
+ ` Segments: ${group.map((s) => s.segmentName).join(', ')}\n` +
580
+ ` Combined prefix+suffix length: ${(group[0]!.paramPrefix?.length ?? 0) + (group[0]!.paramSuffix?.length ?? 0)}\n` +
581
+ `The router cannot deterministically choose between these — filesystem scan order is platform-dependent.\n` +
582
+ `Rename one segment to give it a longer or shorter combined affix.`
583
+ );
584
+ }
585
+ }
586
+ }
587
+
588
+ for (const child of node.children) {
589
+ validateAffixedSiblingConflicts(child);
590
+ }
591
+ for (const slotNode of Object.values(node.slots)) {
592
+ validateAffixedSiblingConflicts(slotNode);
593
+ }
594
+ }
595
+
596
+ /**
597
+ * Collect affixed dynamic segments that are effective children of a node.
598
+ * Route groups are URL-transparent, so we look through them recursively.
599
+ */
600
+ function collectEffectiveAffixed(node: SegmentNode): SegmentNode[] {
601
+ const result: SegmentNode[] = [];
602
+ for (const child of node.children) {
603
+ if (child.segmentType === 'dynamic' && (child.paramPrefix || child.paramSuffix)) {
604
+ result.push(child);
605
+ } else if (child.segmentType === 'group') {
606
+ result.push(...collectEffectiveAffixed(child));
607
+ }
608
+ }
609
+ return result;
610
+ }
611
+
530
612
  /**
531
613
  * Find a fixed-extension file (proxy.ts) in a directory.
532
614
  */
@@ -33,7 +33,13 @@ function walkTree(node: SegmentNode, segments: Set<string>): void {
33
33
  if (node.paramName && node.segmentName) {
34
34
  const classified = classifyUrlSegment(node.segmentName);
35
35
  if (classified.kind !== 'static') {
36
- segments.add(node.segmentName);
36
+ // Use bare bracket form for schema keys — affixed segments like
37
+ // `img-[id].png` register as `[id]`, matching runtime codec lookup.
38
+ if (classified.kind === 'dynamic' && (classified.prefix || classified.suffix)) {
39
+ segments.add(`[${classified.name}]`);
40
+ } else {
41
+ segments.add(node.segmentName);
42
+ }
37
43
  }
38
44
  }
39
45
  for (const child of node.children) {
@@ -27,7 +27,7 @@ import { INTERCEPTION_MARKERS } from './types.js';
27
27
 
28
28
  export type UrlSegment =
29
29
  | { kind: 'static'; value: string }
30
- | { kind: 'dynamic'; name: string }
30
+ | { kind: 'dynamic'; name: string; prefix?: string; suffix?: string }
31
31
  | { kind: 'catch-all'; name: string }
32
32
  | { kind: 'optional-catch-all'; name: string };
33
33
 
@@ -35,27 +35,31 @@ export type UrlSegment =
35
35
  * Classify a URL path segment token.
36
36
  *
37
37
  * Walks the string left-to-right in one pass:
38
- * 1. If it doesn't start with '[', it's static.
38
+ * 1. Find the first '[' — characters before it are the prefix.
39
39
  * 2. Count opening brackets (1 or 2) to detect optional.
40
40
  * 3. Check for '...' to detect catch-all.
41
41
  * 4. Read the param name up to the closing bracket.
42
42
  * 5. Validate the expected closing sequence (']' or ']]').
43
- * 6. Reject if there are leftover characters after the close.
43
+ * 6. Characters after the close are the suffix.
44
+ * 7. Reject affixes on catch-all/optional-catch-all.
45
+ * 8. Reject affixes that contain '[' or ']' (no multi-param segments).
44
46
  *
45
47
  * Any structural violation → static (safe default).
46
48
  */
47
49
  export function classifyUrlSegment(token: string): UrlSegment {
48
50
  const len = token.length;
51
+ if (len === 0) return { kind: 'static', value: token };
49
52
 
50
- // Must start with '[' to be dynamic
51
- if (len === 0 || token[0] !== '[') {
52
- return { kind: 'static', value: token };
53
- }
53
+ // Find first '[' — characters before it are the prefix
54
+ const bracketStart = token.indexOf('[');
55
+ if (bracketStart === -1) return { kind: 'static', value: token };
54
56
 
55
- let i = 1;
57
+ const prefix = token.slice(0, bracketStart);
58
+
59
+ let i = bracketStart + 1;
56
60
 
57
61
  // Check for optional: '[[...'
58
- const optional = token[i] === '[';
62
+ const optional = i < len && token[i] === '[';
59
63
  if (optional) i++;
60
64
 
61
65
  // Check for catch-all: '...'
@@ -82,17 +86,41 @@ export function classifyUrlSegment(token: string): UrlSegment {
82
86
  i++;
83
87
  }
84
88
 
85
- // Must be at end of string — no trailing characters
86
- if (i !== len) {
89
+ const suffix = token.slice(i);
90
+
91
+ // Reject affixes containing brackets (no multi-param segments like [foo]-[bar])
92
+ if (suffix.includes('[') || suffix.includes(']')) {
87
93
  return { kind: 'static', value: token };
88
94
  }
95
+ if (prefix.includes(']')) {
96
+ return { kind: 'static', value: token };
97
+ }
98
+
99
+ const hasAffixes = prefix.length > 0 || suffix.length > 0;
89
100
 
90
- if (optional && catchAll) return { kind: 'optional-catch-all', name };
91
- if (catchAll) return { kind: 'catch-all', name };
101
+ if (optional && catchAll) {
102
+ // No affixes on optional catch-all
103
+ if (hasAffixes) return { kind: 'static', value: token };
104
+ return { kind: 'optional-catch-all', name };
105
+ }
106
+ if (catchAll) {
107
+ // No affixes on catch-all
108
+ if (hasAffixes) return { kind: 'static', value: token };
109
+ return { kind: 'catch-all', name };
110
+ }
92
111
  if (optional) {
93
112
  // '[[name]]' without '...' is malformed — not a valid segment syntax
94
113
  return { kind: 'static', value: token };
95
114
  }
115
+
116
+ if (hasAffixes) {
117
+ return {
118
+ kind: 'dynamic',
119
+ name,
120
+ prefix: prefix || undefined,
121
+ suffix: suffix || undefined,
122
+ };
123
+ }
96
124
  return { kind: 'dynamic', name };
97
125
  }
98
126
 
@@ -102,6 +130,8 @@ export function classifyUrlSegment(token: string): UrlSegment {
102
130
  export interface SegmentClassification {
103
131
  type: SegmentType;
104
132
  paramName?: string;
133
+ paramPrefix?: string;
134
+ paramSuffix?: string;
105
135
  interceptionMarker?: InterceptionMarker;
106
136
  interceptedSegmentName?: string;
107
137
  }
@@ -151,7 +181,12 @@ export function classifySegment(dirName: string): SegmentClassification {
151
181
  // Bracket-syntax segments: [param], [...param], [[...param]]
152
182
  const urlSeg = classifyUrlSegment(dirName);
153
183
  if (urlSeg.kind !== 'static') {
154
- return { type: urlSeg.kind, paramName: urlSeg.name };
184
+ const result: SegmentClassification = { type: urlSeg.kind, paramName: urlSeg.name };
185
+ if (urlSeg.kind === 'dynamic') {
186
+ if (urlSeg.prefix) result.paramPrefix = urlSeg.prefix;
187
+ if (urlSeg.suffix) result.paramSuffix = urlSeg.suffix;
188
+ }
189
+ return result;
155
190
  }
156
191
 
157
192
  return { type: 'static' };
@@ -76,6 +76,10 @@ export interface SegmentNode<TFile = RouteFile> {
76
76
  segmentType: SegmentType;
77
77
  /** The dynamic param name, if dynamic (e.g. "id" for "[id]", "slug" for "[...slug]") */
78
78
  paramName?: string;
79
+ /** Literal prefix before the dynamic bracket (e.g. "img-" for "img-[id].png") */
80
+ paramPrefix?: string;
81
+ /** Literal suffix after the dynamic bracket (e.g. ".png" for "img-[id].png") */
82
+ paramSuffix?: string;
79
83
  /** The URL path prefix at this segment level (e.g. "/dashboard") */
80
84
  urlPath: string;
81
85
  /** For intercepting segments: the marker used, e.g. "(.)". */