@timber-js/app 0.2.0-alpha.139 → 0.2.0-alpha.140

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.
@@ -20,6 +20,13 @@
20
20
  * - `facade:app/layout.tsx` (root layout) → `"client-shared"`
21
21
  * - `shared:...` (shared across chunks) → `"client-shared"`
22
22
  *
23
+ * When a 'use client' module lives outside the app directory (e.g. in
24
+ * src/posts/ or src/components/), the function falls back to deriving
25
+ * the layout boundary from the serverChunk's facade path. This prevents
26
+ * lazily-loaded client dependencies (e.g. via import.meta.glob) from
27
+ * being hoisted into the shared chunk when they're only used by a
28
+ * specific route.
29
+ *
23
30
  * This balances route-scoped code splitting with HTTP request count:
24
31
  * fewer chunks than per-serverChunk, but still avoids downloading every
25
32
  * client component on every page.
@@ -28,5 +35,5 @@ export declare function clientChunkGroup(meta: {
28
35
  id: string;
29
36
  normalizedId: string;
30
37
  serverChunk: string;
31
- }, appDir: string): string;
38
+ }, appDir: string, root: string): string;
32
39
  //# sourceMappingURL=client-chunks.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"client-chunks.d.ts","sourceRoot":"","sources":["../../src/plugins/client-chunks.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,gBAAgB,CAC9B,IAAI,EAAE;IAAE,EAAE,EAAE,MAAM,CAAC;IAAC,YAAY,EAAE,MAAM,CAAC;IAAC,WAAW,EAAE,MAAM,CAAA;CAAE,EAC/D,MAAM,EAAE,MAAM,GACb,MAAM,CAkCR"}
1
+ {"version":3,"file":"client-chunks.d.ts","sourceRoot":"","sources":["../../src/plugins/client-chunks.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAoCH;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,wBAAgB,gBAAgB,CAC9B,IAAI,EAAE;IAAE,EAAE,EAAE,MAAM,CAAC;IAAC,YAAY,EAAE,MAAM,CAAC;IAAC,WAAW,EAAE,MAAM,CAAA;CAAE,EAC/D,MAAM,EAAE,MAAM,EACd,IAAI,EAAE,MAAM,GACX,MAAM,CAwCR"}
@@ -1 +1 @@
1
- {"version":3,"file":"fonts.d.ts","sourceRoot":"","sources":["../../src/plugins/fonts.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAEH,OAAO,KAAK,EAAE,MAAM,EAAiB,MAAM,MAAM,CAAC;AAClD,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,sBAAsB,CAAC;AA0B1D,OAAO,EAAE,YAAY,EAAE,oBAAoB,EAAE,KAAK,YAAY,EAAE,MAAM,sBAAsB,CAAC;AAC7F,OAAO,EACL,cAAc,EACd,iBAAiB,EACjB,qBAAqB,EACrB,sBAAsB,EACtB,uBAAuB,EACvB,wBAAwB,GACzB,MAAM,uBAAuB,CAAC;AAC/B,OAAO,EAAE,eAAe,EAAE,kBAAkB,EAAE,MAAM,6BAA6B,CAAC;AAElF;;GAEG;AACH,wBAAgB,WAAW,CAAC,GAAG,EAAE,aAAa,GAAG,MAAM,CAsLtD"}
1
+ {"version":3,"file":"fonts.d.ts","sourceRoot":"","sources":["../../src/plugins/fonts.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAEH,OAAO,KAAK,EAAE,MAAM,EAAiB,MAAM,MAAM,CAAC;AAClD,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,sBAAsB,CAAC;AA0B1D,OAAO,EAAE,YAAY,EAAE,oBAAoB,EAAE,KAAK,YAAY,EAAE,MAAM,sBAAsB,CAAC;AAC7F,OAAO,EACL,cAAc,EACd,iBAAiB,EACjB,qBAAqB,EACrB,sBAAsB,EACtB,uBAAuB,EACvB,wBAAwB,GACzB,MAAM,uBAAuB,CAAC;AAC/B,OAAO,EAAE,eAAe,EAAE,kBAAkB,EAAE,MAAM,6BAA6B,CAAC;AAElF;;GAEG;AACH,wBAAgB,WAAW,CAAC,GAAG,EAAE,aAAa,GAAG,MAAM,CAuLtD"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@timber-js/app",
3
- "version": "0.2.0-alpha.139",
3
+ "version": "0.2.0-alpha.140",
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",
@@ -114,11 +114,6 @@
114
114
  "publishConfig": {
115
115
  "access": "public"
116
116
  },
117
- "scripts": {
118
- "build": "vite build --config vite.lib.config.ts && tsc --emitDeclarationOnly --project tsconfig.json --outDir dist",
119
- "typecheck": "tsgo --noEmit",
120
- "prepublishOnly": "pnpm run build"
121
- },
122
117
  "dependencies": {
123
118
  "@opentelemetry/api": "^1.9.1",
124
119
  "@opentelemetry/context-async-hooks": "^2.7.1",
@@ -162,5 +157,9 @@
162
157
  },
163
158
  "engines": {
164
159
  "node": ">=22.12.0"
160
+ },
161
+ "scripts": {
162
+ "build": "vite build --config vite.lib.config.ts && tsc --emitDeclarationOnly --project tsconfig.json --outDir dist",
163
+ "typecheck": "tsgo --noEmit"
165
164
  }
166
- }
165
+ }
package/src/cli.ts CHANGED
File without changes
@@ -10,8 +10,9 @@
10
10
  */
11
11
 
12
12
  import { readFileSync, existsSync } from 'node:fs';
13
- import { resolve, normalize } from 'node:path';
13
+ import { resolve, normalize, extname } from 'node:path';
14
14
  import type { ManifestFontEntry, BuildManifest } from '../server/build-manifest.js';
15
+ import { contentHash } from './google.js';
15
16
 
16
17
  /**
17
18
  * Minimal shape of the asset descriptor accepted by Rollup's `emitFile`.
@@ -67,7 +68,7 @@ export function emitFontAssets(
67
68
  });
68
69
  }
69
70
 
70
- // Local font files (emitted by basename)
71
+ // Local font files (content-hashed for immutable caching)
71
72
  for (const font of pipeline.fonts()) {
72
73
  if (font.provider !== 'local' || !font.localSources) continue;
73
74
  for (const src of font.localSources) {
@@ -76,11 +77,11 @@ export function emitFontAssets(
76
77
  warn(`Local font file not found: ${absolutePath}`);
77
78
  continue;
78
79
  }
79
- const basename = src.path.split('/').pop() ?? src.path;
80
+ const filename = src.hashedBasename ?? src.path.split('/').pop() ?? src.path;
80
81
  const data = readFileSync(absolutePath);
81
82
  emitFile({
82
83
  type: 'asset',
83
- fileName: `_timber/fonts/${basename}`,
84
+ fileName: `_timber/fonts/${filename}`,
84
85
  source: data,
85
86
  });
86
87
  }
@@ -89,17 +90,13 @@ export function emitFontAssets(
89
90
 
90
91
  /**
91
92
  * 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).
93
+ * absolute importer path. This matches the runtime route manifest's
94
+ * `filePath` values (which are absolute) and is consistent with how
95
+ * `buildManifest.css` keys work after TIM-1014.
94
96
  *
95
- * Google fonts use the content-hashed filenames produced during
96
- * `buildStart`. Local fonts use the source basename.
97
+ * All font binaries use content-hashed filenames for immutable caching.
97
98
  */
98
- export function writeFontManifest(
99
- pipeline: FontPipeline,
100
- buildManifest: BuildManifest,
101
- rootDir: string
102
- ): void {
99
+ export function writeFontManifest(pipeline: FontPipeline, buildManifest: BuildManifest): void {
103
100
  const fontsByImporter = new Map<string, ManifestFontEntry[]>();
104
101
 
105
102
  for (const entry of pipeline.entries()) {
@@ -107,7 +104,7 @@ export function writeFontManifest(
107
104
 
108
105
  if (entry.provider === 'local' && entry.localSources) {
109
106
  for (const src of entry.localSources) {
110
- const filename = src.path.split('/').pop() ?? src.path;
107
+ const filename = src.hashedBasename ?? src.path.split('/').pop() ?? src.path;
111
108
  const format = inferFontFormat(src.path);
112
109
  manifestEntries.push({
113
110
  href: `/_timber/fonts/${filename}`,
@@ -116,9 +113,6 @@ export function writeFontManifest(
116
113
  });
117
114
  }
118
115
  } 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
116
  for (const cf of entry.cachedFiles) {
123
117
  manifestEntries.push({
124
118
  href: `/_timber/fonts/${cf.hashedFilename}`,
@@ -131,12 +125,7 @@ export function writeFontManifest(
131
125
  fontsByImporter.set(entry.importer, manifestEntries);
132
126
  }
133
127
 
134
- // Normalize importer paths to be relative to project root (matching how
135
- // Vite's manifest.json keys work for css/js).
136
128
  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;
129
+ buildManifest.fonts[importer] = entries;
141
130
  }
142
131
  }
@@ -112,10 +112,10 @@ export function generateLocalFontFaces(
112
112
  ): FontFaceDescriptor[] {
113
113
  return sources.map((entry) => {
114
114
  const format = inferFontFormat(entry.path);
115
- const basename = entry.path.split('/').pop() ?? entry.path;
115
+ const filename = entry.hashedBasename ?? entry.path.split('/').pop() ?? entry.path;
116
116
  return {
117
117
  family,
118
- src: `url('${urlPrefix}/${basename}') format('${format}')`,
118
+ src: `url('${urlPrefix}/${filename}') format('${format}')`,
119
119
  weight: entry.weight,
120
120
  style: entry.style,
121
121
  display,
@@ -26,8 +26,11 @@
26
26
  * Design doc: 24-fonts.md
27
27
  */
28
28
 
29
+ import { readFileSync, existsSync } from 'node:fs';
30
+ import { resolve, normalize, extname } from 'node:path';
29
31
  import type { ExtractedFont, FontEntry, FontFaceDescriptor } from './types.js';
30
32
  import type { CachedFont } from './google.js';
33
+ import { contentHash } from './google.js';
31
34
  import { generateVariableClass, generateFontFamilyClass, generateFontFaces } from './css.js';
32
35
  import { generateFallbackCss } from './fallbacks.js';
33
36
  import { generateLocalFontFaces } from './local.js';
@@ -56,6 +59,13 @@ export class FontPipeline {
56
59
  */
57
60
  private readonly _entries = new Map<string, FontEntry>();
58
61
 
62
+ /** When true, local font sources are content-hashed at registration time. */
63
+ private readonly _hashOnRegister: boolean;
64
+
65
+ constructor(opts?: { hashOnRegister?: boolean }) {
66
+ this._hashOnRegister = opts?.hashOnRegister ?? false;
67
+ }
68
+
59
69
  /**
60
70
  * Per-importer virtual CSS module id registry. Populated by the
61
71
  * transform hook when it inlines a font function call, read by the
@@ -140,7 +150,20 @@ export class FontPipeline {
140
150
  // Wholesale replace — never merge faces/cachedFiles from a previous
141
151
  // registration. Re-registration with the same ID always represents the
142
152
  // newest authoritative config and must start from a clean slate.
143
- this._entries.set(extracted.id, { ...extracted });
153
+ const entry: FontEntry = { ...extracted };
154
+ if (this._hashOnRegister && entry.provider === 'local' && entry.localSources) {
155
+ for (const src of entry.localSources) {
156
+ const absolutePath = normalize(resolve(src.path));
157
+ if (!existsSync(absolutePath)) continue;
158
+ const data = readFileSync(absolutePath);
159
+ const hash = contentHash(data);
160
+ const basename = src.path.split('/').pop() ?? src.path;
161
+ const ext = extname(basename);
162
+ const stem = ext ? basename.slice(0, -ext.length) : basename;
163
+ src.hashedBasename = `${stem}-${hash}${ext}`;
164
+ }
165
+ }
166
+ this._entries.set(entry.id, entry);
144
167
  }
145
168
 
146
169
  /** Attach pre-resolved `@font-face` descriptors to a font entry. */
@@ -26,6 +26,8 @@ export interface LocalFontSrc {
26
26
  path: string;
27
27
  weight?: string;
28
28
  style?: string;
29
+ /** Content-hashed basename for production builds (e.g. `MyFont-Bold-a1b2c3d4.woff2`). */
30
+ hashedBasename?: string;
29
31
  }
30
32
 
31
33
  /** Configuration passed to `localFont()`. */
package/src/index.ts CHANGED
@@ -157,7 +157,7 @@ function createRscOptions(
157
157
  // Layout-boundary grouping is the middle ground: components under the same
158
158
  // layout segment share a chunk. See design/27-chunking-strategy.md, TIM-440, TIM-499.
159
159
  clientChunks: (meta: { id: string; normalizedId: string; serverChunk: string }) =>
160
- clientChunkGroup(meta, ctx.appDir),
160
+ clientChunkGroup(meta, ctx.appDir, ctx.root),
161
161
  };
162
162
 
163
163
  // Bound args encryption — AES-256-GCM authenticated encryption for
@@ -10,6 +10,40 @@
10
10
  * See design/27-chunking-strategy.md, TIM-440, TIM-499.
11
11
  */
12
12
 
13
+ import { relative } from 'node:path';
14
+
15
+ /**
16
+ * Check whether a root-relative path lives under the app directory,
17
+ * and if so return the first non-group route segment.
18
+ *
19
+ * `appPrefix` is the root-relative app directory with a trailing slash
20
+ * (e.g. "app/" or "src/app/"), computed once from the resolved appDir
21
+ * and root. Matching uses `startsWith` — no ambiguity.
22
+ *
23
+ * Returns:
24
+ * { inApp: false } — path is not under app/
25
+ * { inApp: true, segment: null } — root of app/ (e.g. app/layout.tsx)
26
+ * { inApp: true, segment: "dashboard" } — nested route boundary
27
+ */
28
+ function findAppBoundary(
29
+ path: string,
30
+ appPrefix: string
31
+ ): { inApp: false } | { inApp: true; segment: string | null } {
32
+ const relPath = path.replace(/\\/g, '/');
33
+ if (!relPath.startsWith(appPrefix)) return { inApp: false };
34
+
35
+ const withinApp = relPath.slice(appPrefix.length);
36
+ const segments = withinApp.split('/');
37
+
38
+ for (const seg of segments) {
39
+ if (seg.includes('.')) break;
40
+ if (seg.startsWith('(') && seg.endsWith(')')) continue;
41
+ return { inApp: true, segment: seg };
42
+ }
43
+
44
+ return { inApp: true, segment: null };
45
+ }
46
+
13
47
  /**
14
48
  * Derive a chunk group name for a client reference module.
15
49
  *
@@ -21,42 +55,56 @@
21
55
  * - `facade:app/layout.tsx` (root layout) → `"client-shared"`
22
56
  * - `shared:...` (shared across chunks) → `"client-shared"`
23
57
  *
58
+ * When a 'use client' module lives outside the app directory (e.g. in
59
+ * src/posts/ or src/components/), the function falls back to deriving
60
+ * the layout boundary from the serverChunk's facade path. This prevents
61
+ * lazily-loaded client dependencies (e.g. via import.meta.glob) from
62
+ * being hoisted into the shared chunk when they're only used by a
63
+ * specific route.
64
+ *
24
65
  * This balances route-scoped code splitting with HTTP request count:
25
66
  * fewer chunks than per-serverChunk, but still avoids downloading every
26
67
  * client component on every page.
27
68
  */
28
69
  export function clientChunkGroup(
29
70
  meta: { id: string; normalizedId: string; serverChunk: string },
30
- appDir: string
71
+ appDir: string,
72
+ root: string
31
73
  ): string {
32
74
  const { normalizedId, serverChunk } = meta;
33
75
 
34
76
  // Shared chunks (not associated with a single route entry) get one group.
35
77
  if (serverChunk.startsWith('shared:')) return 'client-shared';
36
78
 
37
- // Derive the layout boundary from the file's location relative to the
38
- // app directory. normalizedId is root-relative (e.g. "app/dashboard/shell.tsx"
39
- // or "src/app/dashboard/shell.tsx"). We find the "app/" prefix and walk
40
- // segments after it to find the first non-group directory.
41
- const relPath = normalizedId.replace(/\\/g, '/');
79
+ // Compute the root-relative app directory prefix once. Both normalizedId
80
+ // and serverChunk paths from the RSC plugin are root-relative (via
81
+ // path.relative(root, absoluteId)), so this prefix matches exactly —
82
+ // no ambiguity with content directories that happen to share a name.
83
+ const appPrefix = relative(root, appDir).replace(/\\/g, '/') + '/';
42
84
 
43
- // Find the app directory boundary in the path. The last segment of appDir
44
- // is the app folder name (usually "app").
45
- const appDirName = appDir.replace(/\\/g, '/').split('/').pop() || 'app';
46
- const appIdx = relPath.indexOf(appDirName + '/');
47
- if (appIdx === -1) return 'client-shared';
85
+ // Try the client module's own path first.
86
+ const result = findAppBoundary(normalizedId, appPrefix);
87
+ if (result.inApp && result.segment) return `client-${result.segment}`;
88
+ if (result.inApp) return 'client-shared';
48
89
 
49
- const withinApp = relPath.slice(appIdx + appDirName.length + 1);
50
- const segments = withinApp.split('/');
90
+ // The client module lives outside the app directory (e.g. src/posts/,
91
+ // src/components/). Fall back to the serverChunk's facade path — the
92
+ // RSC entry point that imported this module — to derive the route's
93
+ // layout boundary. This keeps dependencies of lazily-loaded 'use client'
94
+ // components (e.g. from import.meta.glob) in per-route async chunks
95
+ // instead of hoisting them into client-shared.
96
+ if (serverChunk.startsWith('facade:')) {
97
+ const facadePath = serverChunk.slice('facade:'.length).replace(/\\/g, '/');
98
+ const facadeResult = findAppBoundary(facadePath, appPrefix);
99
+ if (facadeResult.inApp && facadeResult.segment) return `client-${facadeResult.segment}`;
100
+ if (facadeResult.inApp) return 'client-shared';
51
101
 
52
- // Find first directory segment that isn't a route group like (group-a)
53
- for (const seg of segments) {
54
- // Skip the filename itself
55
- if (seg.includes('.')) break;
56
- // Skip route groups — parenthesized segments like (group-a)
57
- if (seg.startsWith('(') && seg.endsWith(')')) continue;
58
- // Found a real route segment
59
- return `client-${seg}`;
102
+ // Both module and facade are outside app/. Derive a chunk name from
103
+ // the full facade path to keep these isolated from client-shared.
104
+ // Uses the full path (minus extension) to avoid collisions between
105
+ // facades with the same basename (e.g. foo/index.mdx vs bar/index.mdx).
106
+ const facadeName = facadePath.replace(/\.[^.]+$/, '').replace(/\//g, '-');
107
+ if (facadeName) return `client-${facadeName}`;
60
108
  }
61
109
 
62
110
  // Root-level files (layout.tsx, page.tsx directly in app/) go into the
@@ -60,7 +60,7 @@ export { generateFontCss, generateAllFontCss } from '../fonts/virtual-modules.js
60
60
  * Create the timber-fonts Vite plugin.
61
61
  */
62
62
  export function timberFonts(ctx: PluginContext): Plugin {
63
- const pipeline = new FontPipeline();
63
+ const pipeline = new FontPipeline({ hashOnRegister: !ctx.dev });
64
64
 
65
65
  return {
66
66
  name: 'timber-fonts',
@@ -189,8 +189,10 @@ export function timberFonts(ctx: PluginContext): Plugin {
189
189
  *
190
190
  * In dev mode this is a no-op — fonts point to the Google CDN and are
191
191
  * resolved lazily inside `load()` when the side-effect module runs.
192
- * The registry is populated by the transform hook which runs before
193
- * `buildStart` in the build pipeline, so all fonts are known here.
192
+ *
193
+ * Local font hashing is handled at registration time by the pipeline
194
+ * (when `hashOnRegister` is set) so hashed filenames are available
195
+ * before the virtual CSS `load()` hook runs.
194
196
  */
195
197
  async buildStart() {
196
198
  if (ctx.dev) return;
@@ -226,10 +228,9 @@ export function timberFonts(ctx: PluginContext): Plugin {
226
228
  /**
227
229
  * Emit font binaries and build-manifest entries.
228
230
  *
229
- * Google fonts use the content-hashed filenames produced during
230
- * `buildStart`. Local fonts are emitted by basename. Font CSS is
231
- * handled by Vite's CSS pipeline via virtual modules — we only emit
232
- * binaries here.
231
+ * All font binaries (Google and local) use content-hashed filenames
232
+ * for immutable caching. Font CSS is handled by Vite's CSS pipeline
233
+ * via virtual modules — we only emit binaries here.
233
234
  */
234
235
  generateBundle() {
235
236
  emitFontAssets(
@@ -238,7 +239,7 @@ export function timberFonts(ctx: PluginContext): Plugin {
238
239
  (msg) => this.warn(msg)
239
240
  );
240
241
  if (!ctx.buildManifest) return;
241
- writeFontManifest(pipeline, ctx.buildManifest, ctx.root);
242
+ writeFontManifest(pipeline, ctx.buildManifest);
242
243
  },
243
244
  };
244
245
  }