@timber-js/app 0.2.0-alpha.120 → 0.2.0-alpha.121
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.
- package/dist/_chunks/{actions-Cw-7NfJJ.js → actions-Dy8vOjFt.js} +6 -6
- package/dist/_chunks/{actions-Cw-7NfJJ.js.map → actions-Dy8vOjFt.js.map} +1 -1
- package/dist/_chunks/{build-output-helper-DhxiT18k.js → build-output-helper-B1n0sUqj.js} +1 -1
- package/dist/_chunks/{build-output-helper-DhxiT18k.js.map → build-output-helper-B1n0sUqj.js.map} +1 -1
- package/dist/_chunks/cli-schema-sync-ByZPGGmS.js +724 -0
- package/dist/_chunks/cli-schema-sync-ByZPGGmS.js.map +1 -0
- package/dist/_chunks/cli-schema-sync-DHQLjFu3.js +2 -0
- package/dist/_chunks/{codec-registry-V7ARyeLN.js → codec-registry-G9hD5LOq.js} +1 -1
- package/dist/_chunks/{codec-registry-V7ARyeLN.js.map → codec-registry-G9hD5LOq.js.map} +1 -1
- package/dist/_chunks/{define-zQX6e1tN.js → define-CPVQaWbx.js} +3 -3
- package/dist/_chunks/{define-zQX6e1tN.js.map → define-CPVQaWbx.js.map} +1 -1
- package/dist/_chunks/{define-8zQ8Vm7x.js → define-jeHDXZGq.js} +3 -3
- package/dist/_chunks/{define-8zQ8Vm7x.js.map → define-jeHDXZGq.js.map} +1 -1
- package/dist/_chunks/{format-CR6v1pWE.js → format-BjN9HLu9.js} +1 -1
- package/dist/_chunks/{format-CR6v1pWE.js.map → format-BjN9HLu9.js.map} +1 -1
- package/dist/_chunks/{handler-store-iqnt2ghN.js → handler-store-CcwNX-gk.js} +1 -1
- package/dist/_chunks/{handler-store-iqnt2ghN.js.map → handler-store-CcwNX-gk.js.map} +1 -1
- package/dist/_chunks/{merge-search-params-CwxJzBLS.js → merge-search-params-az6ZiBeU.js} +1 -1
- package/dist/_chunks/{merge-search-params-CwxJzBLS.js.map → merge-search-params-az6ZiBeU.js.map} +1 -1
- package/dist/_chunks/{navigation-root-DKsJ5Di7.js → navigation-root-DoRTeBm5.js} +2 -2
- package/dist/_chunks/{navigation-root-DKsJ5Di7.js.map → navigation-root-DoRTeBm5.js.map} +1 -1
- package/dist/_chunks/{registry-Dp_hJL4s.js → registry-BI9cgCt7.js} +1 -1
- package/dist/_chunks/{registry-Dp_hJL4s.js.map → registry-BI9cgCt7.js.map} +1 -1
- package/dist/_chunks/{resolve-schema-23Mc8aLi.js → resolve-schema-Bf8eC6q6.js} +11 -3
- package/dist/_chunks/resolve-schema-Bf8eC6q6.js.map +1 -0
- package/dist/_chunks/{router-ref-sektkr-t.js → router-ref-CpvjBqdu.js} +2 -2
- package/dist/_chunks/{router-ref-sektkr-t.js.map → router-ref-CpvjBqdu.js.map} +1 -1
- package/dist/_chunks/{schema-bridge-eL5vQDSA.js → schema-bridge-o0SwFtXW.js} +1 -1
- package/dist/_chunks/{schema-bridge-eL5vQDSA.js.map → schema-bridge-o0SwFtXW.js.map} +1 -1
- package/dist/_chunks/{segment-context-Bg0pFbLT.js → segment-context-EsfQpS--.js} +1 -1
- package/dist/_chunks/{segment-context-Bg0pFbLT.js.map → segment-context-EsfQpS--.js.map} +1 -1
- package/dist/_chunks/{ssr-data-BO5OvEvg.js → ssr-data-m9-cNEIw.js} +1 -1
- package/dist/_chunks/{ssr-data-BO5OvEvg.js.map → ssr-data-m9-cNEIw.js.map} +1 -1
- package/dist/_chunks/{stale-reload-BCPRG0Cv.js → stale-reload-BN2kcE06.js} +1 -1
- package/dist/_chunks/{stale-reload-BCPRG0Cv.js.map → stale-reload-BN2kcE06.js.map} +1 -1
- package/dist/_chunks/{use-query-states-D9SBlAfp.js → use-query-states-C9G6LP0F.js} +2 -2
- package/dist/_chunks/{use-query-states-D9SBlAfp.js.map → use-query-states-C9G6LP0F.js.map} +1 -1
- package/dist/_chunks/{use-segment-params-CskiK-59.js → use-segment-params-CtIAjqK0.js} +2 -2
- package/dist/_chunks/{use-segment-params-CskiK-59.js.map → use-segment-params-CtIAjqK0.js.map} +1 -1
- package/dist/_chunks/{validation-Bm3Je-09.js → validation-BMU5MPzP.js} +1 -1
- package/dist/_chunks/{validation-Bm3Je-09.js.map → validation-BMU5MPzP.js.map} +1 -1
- package/dist/_chunks/{walkers-DgOXkTts.js → walkers-DNp9tHyV.js} +116 -6
- package/dist/_chunks/walkers-DNp9tHyV.js.map +1 -0
- package/dist/adapters/cloudflare.js +1 -1
- package/dist/adapters/nitro.js +1 -1
- package/dist/cache/index.js +1 -1
- package/dist/cli.js +1 -1
- package/dist/client/error-boundary.js +2 -2
- package/dist/client/index.js +9 -9
- package/dist/client/internal.js +7 -7
- package/dist/codec.d.ts +18 -0
- package/dist/codec.d.ts.map +1 -1
- package/dist/codec.js +30 -1
- package/dist/codec.js.map +1 -1
- package/dist/cookies/index.js +3 -3
- package/dist/index.js +30 -21
- package/dist/index.js.map +1 -1
- package/dist/params/define-schema.d.ts +32 -4
- package/dist/params/define-schema.d.ts.map +1 -1
- package/dist/params/index.d.ts +1 -1
- package/dist/params/index.d.ts.map +1 -1
- package/dist/params/index.js +2 -2
- package/dist/params/index.js.map +1 -1
- package/dist/params/resolve-schema.d.ts.map +1 -1
- package/dist/plugins/routing.d.ts.map +1 -1
- package/dist/routing/index.d.ts +2 -0
- package/dist/routing/index.d.ts.map +1 -1
- package/dist/routing/index.js +3 -3
- package/dist/routing/scanner.d.ts.map +1 -1
- package/dist/routing/schema-validation.d.ts +35 -0
- package/dist/routing/schema-validation.d.ts.map +1 -0
- package/dist/search-params/index.js +3 -3
- package/dist/segment-params/index.js +1 -1
- package/dist/server/index.js +2 -2
- package/dist/server/internal.js +3 -3
- package/package.json +7 -6
- package/src/cli.ts +0 -0
- package/src/codec.ts +51 -1
- package/src/params/define-schema.ts +46 -5
- package/src/params/index.ts +7 -1
- package/src/params/resolve-schema.ts +13 -0
- package/src/plugins/routing.ts +30 -33
- package/src/routing/codegen.ts +54 -5
- package/src/routing/index.ts +5 -0
- package/src/routing/scanner.ts +58 -0
- package/src/routing/schema-validation.ts +149 -0
- package/LICENSE +0 -8
- package/dist/_chunks/cli-schema-sync-XV0I47Gj.js +0 -2
- package/dist/_chunks/cli-schema-sync-q2wB0DyJ.js +0 -358
- package/dist/_chunks/cli-schema-sync-q2wB0DyJ.js.map +0 -1
- package/dist/_chunks/resolve-schema-23Mc8aLi.js.map +0 -1
- package/dist/_chunks/scanner-DmfoB7iK.js +0 -336
- package/dist/_chunks/scanner-DmfoB7iK.js.map +0 -1
- package/dist/_chunks/walkers-DgOXkTts.js.map +0 -1
package/src/routing/codegen.ts
CHANGED
|
@@ -321,10 +321,35 @@ function formatDeclarationFile(
|
|
|
321
321
|
? './' + relative(importBase, join(appDir, 'schema')).replace(/\\/g, '/')
|
|
322
322
|
: '../app/schema';
|
|
323
323
|
lines.push(`import type schema from '${schemaImport}';`);
|
|
324
|
-
lines.push("import type { InferCodecType } from '@timber-js/app/params';");
|
|
324
|
+
lines.push("import type { InferCodecType, SegmentParamCodec } from '@timber-js/app/params';");
|
|
325
325
|
lines.push('');
|
|
326
|
-
//
|
|
327
|
-
|
|
326
|
+
// Collect all unique bracket keys from routes for key validation
|
|
327
|
+
const allBracketKeys = new Set<string>();
|
|
328
|
+
for (const route of routes) {
|
|
329
|
+
for (const p of route.params) {
|
|
330
|
+
allBracketKeys.add(p.bracketName);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// Augment the ValidDynamicSegmentKeysRegistry so defineSchema constrains keys
|
|
335
|
+
// to known filesystem segments. Unknown keys produce inline TS errors.
|
|
336
|
+
if (allBracketKeys.size > 0) {
|
|
337
|
+
lines.push("declare module '@timber-js/app/params' {");
|
|
338
|
+
lines.push(' interface ValidDynamicSegmentKeysRegistry {');
|
|
339
|
+
for (const key of [...allBracketKeys].sort()) {
|
|
340
|
+
lines.push(` ${JSON.stringify(key)}: true;`);
|
|
341
|
+
}
|
|
342
|
+
lines.push(' }');
|
|
343
|
+
lines.push('}');
|
|
344
|
+
lines.push('');
|
|
345
|
+
}
|
|
346
|
+
// Type-level validation: each schema value must be a Codec or Standard Schema.
|
|
347
|
+
// Running `pnpm run typecheck` will surface errors for invalid values.
|
|
348
|
+
lines.push('type _AssertSchemaValues = {');
|
|
349
|
+
lines.push(
|
|
350
|
+
" [K in keyof typeof schema['segmentParams']]: typeof schema['segmentParams'][K] extends SegmentParamCodec ? true : ['Error: value for', K, 'is not a valid Codec or Standard Schema'];"
|
|
351
|
+
);
|
|
352
|
+
lines.push('};');
|
|
328
353
|
lines.push('');
|
|
329
354
|
}
|
|
330
355
|
|
|
@@ -350,6 +375,16 @@ function formatDeclarationFile(
|
|
|
350
375
|
}
|
|
351
376
|
|
|
352
377
|
lines.push(' }');
|
|
378
|
+
|
|
379
|
+
// When a global schema exists, emit segment param types inside the
|
|
380
|
+
// @timber-js/app module augmentation so that declare module blocks
|
|
381
|
+
// for @timber-js/app/server and @timber-js/app/client can reference
|
|
382
|
+
// AllSegmentParams via `import type { AllSegmentParams } from '@timber-js/app'`.
|
|
383
|
+
if (hasSchema) {
|
|
384
|
+
lines.push('');
|
|
385
|
+
lines.push(...formatSchemaTypes(routes).map((l) => ` ${l}`));
|
|
386
|
+
}
|
|
387
|
+
|
|
353
388
|
lines.push('}');
|
|
354
389
|
lines.push('');
|
|
355
390
|
|
|
@@ -359,13 +394,20 @@ function formatDeclarationFile(
|
|
|
359
394
|
// Generate @timber-js/app/server augmentation — typed getSegmentParams
|
|
360
395
|
if (dynamicRoutes.length > 0) {
|
|
361
396
|
lines.push("declare module '@timber-js/app/server' {");
|
|
397
|
+
if (hasSchema) {
|
|
398
|
+
lines.push(" import type { AllSegmentParams } from '@timber-js/app'");
|
|
399
|
+
}
|
|
362
400
|
for (const route of dynamicRoutes) {
|
|
363
401
|
const paramsType = formatParamsType(route.params, importBase, hasSchema);
|
|
364
402
|
lines.push(
|
|
365
403
|
` export function getSegmentParams(segmentPath: '${route.segmentPath}'): ${paramsType}`
|
|
366
404
|
);
|
|
367
405
|
}
|
|
368
|
-
lines.push(
|
|
406
|
+
lines.push(
|
|
407
|
+
hasSchema
|
|
408
|
+
? ' export function getSegmentParams(): Partial<AllSegmentParams>'
|
|
409
|
+
: ' export function getSegmentParams(): Record<string, string | string[]>'
|
|
410
|
+
);
|
|
369
411
|
lines.push('}');
|
|
370
412
|
lines.push('');
|
|
371
413
|
}
|
|
@@ -377,6 +419,9 @@ function formatDeclarationFile(
|
|
|
377
419
|
lines.push(
|
|
378
420
|
" import type { SearchParamsDefinition, SetParams, QueryStatesOptions, SearchParamCodec } from '@timber-js/app/search-params'"
|
|
379
421
|
);
|
|
422
|
+
if (hasSchema) {
|
|
423
|
+
lines.push(" import type { AllSegmentParams } from '@timber-js/app'");
|
|
424
|
+
}
|
|
380
425
|
lines.push('');
|
|
381
426
|
|
|
382
427
|
// useSegmentParams overloads
|
|
@@ -387,7 +432,11 @@ function formatDeclarationFile(
|
|
|
387
432
|
` export function useSegmentParams(segmentPath: '${route.segmentPath}'): ${paramsType}`
|
|
388
433
|
);
|
|
389
434
|
}
|
|
390
|
-
lines.push(
|
|
435
|
+
lines.push(
|
|
436
|
+
hasSchema
|
|
437
|
+
? ' export function useSegmentParams(): Partial<AllSegmentParams>'
|
|
438
|
+
: ' export function useSegmentParams(): Record<string, string | string[]>'
|
|
439
|
+
);
|
|
391
440
|
lines.push('');
|
|
392
441
|
}
|
|
393
442
|
|
package/src/routing/index.ts
CHANGED
|
@@ -16,3 +16,8 @@ export { classifyUrlSegment, classifySegment } from './segment-classify.js';
|
|
|
16
16
|
export type { UrlSegment, SegmentClassification } from './segment-classify.js';
|
|
17
17
|
export { collectLeafRoutes } from './walkers.js';
|
|
18
18
|
export type { LeafRoute, CollectLeafRoutesOptions } from './walkers.js';
|
|
19
|
+
export {
|
|
20
|
+
validateSchemaAgainstRoutes,
|
|
21
|
+
collectDynamicSegmentsFromTree,
|
|
22
|
+
} from './schema-validation.js';
|
|
23
|
+
export type { SchemaWarning } from './schema-validation.js';
|
package/src/routing/scanner.ts
CHANGED
|
@@ -106,6 +106,10 @@ export function scanRoutes(appDir: string, config: ScannerConfig = {}): RouteTre
|
|
|
106
106
|
// e.g., /[id]/items/[id] — same param name in ancestor and descendant
|
|
107
107
|
validateDuplicateParamNames(tree.root);
|
|
108
108
|
|
|
109
|
+
// Validate: detect same param name used with different bracket forms
|
|
110
|
+
// e.g., [id] in one route and [...id] in another
|
|
111
|
+
validateUniqueBracketForms(tree.root);
|
|
112
|
+
|
|
109
113
|
return tree;
|
|
110
114
|
}
|
|
111
115
|
|
|
@@ -457,6 +461,60 @@ function walkForDuplicateParams(node: SegmentNode, seen: Map<string, string>): v
|
|
|
457
461
|
}
|
|
458
462
|
}
|
|
459
463
|
|
|
464
|
+
/**
|
|
465
|
+
* Validate that each param name uses a single bracket form across the entire route tree.
|
|
466
|
+
*
|
|
467
|
+
* Different bracket forms produce different types:
|
|
468
|
+
* [id] → string
|
|
469
|
+
* [...id] → string[]
|
|
470
|
+
* [[...id]] → string[] | undefined
|
|
471
|
+
*
|
|
472
|
+
* If the same param name appears with multiple forms, the no-argument
|
|
473
|
+
* getSegmentParams() / useSegmentParams() return type becomes ambiguous.
|
|
474
|
+
*
|
|
475
|
+
* Same name + same form across different routes is fine and expected.
|
|
476
|
+
*/
|
|
477
|
+
function validateUniqueBracketForms(root: SegmentNode): void {
|
|
478
|
+
const forms = new Map<string, { segmentType: SegmentType; urlPath: string }>();
|
|
479
|
+
collectBracketForms(root, forms);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function collectBracketForms(
|
|
483
|
+
node: SegmentNode,
|
|
484
|
+
forms: Map<string, { segmentType: SegmentType; urlPath: string }>
|
|
485
|
+
): void {
|
|
486
|
+
if (node.paramName && node.segmentType !== 'static' && node.segmentType !== 'group') {
|
|
487
|
+
const existing = forms.get(node.paramName);
|
|
488
|
+
if (existing && existing.segmentType !== node.segmentType) {
|
|
489
|
+
const bracketExamples: Record<string, string> = {
|
|
490
|
+
'dynamic': `[${node.paramName}]`,
|
|
491
|
+
'catch-all': `[...${node.paramName}]`,
|
|
492
|
+
'optional-catch-all': `[[...${node.paramName}]]`,
|
|
493
|
+
};
|
|
494
|
+
throw new Error(
|
|
495
|
+
`[timber] Param name '${node.paramName}' uses multiple bracket forms.\n` +
|
|
496
|
+
` ${bracketExamples[existing.segmentType] ?? existing.segmentType} at: ${existing.urlPath}\n` +
|
|
497
|
+
` ${bracketExamples[node.segmentType] ?? node.segmentType} at: ${node.urlPath || '/'}\n` +
|
|
498
|
+
` Each param name must use a single bracket form across all routes.\n` +
|
|
499
|
+
` Rename one of the segments to fix this (e.g., [${node.paramName}] → [${node.paramName}Id]).`
|
|
500
|
+
);
|
|
501
|
+
}
|
|
502
|
+
if (!existing) {
|
|
503
|
+
forms.set(node.paramName, {
|
|
504
|
+
segmentType: node.segmentType,
|
|
505
|
+
urlPath: node.urlPath || '/',
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
for (const child of node.children) {
|
|
511
|
+
collectBracketForms(child, forms);
|
|
512
|
+
}
|
|
513
|
+
for (const slotNode of Object.values(node.slots)) {
|
|
514
|
+
collectBracketForms(slotNode, forms);
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
460
518
|
/**
|
|
461
519
|
* Find a fixed-extension file (proxy.ts) in a directory.
|
|
462
520
|
*/
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Schema key/value validation against the filesystem route tree.
|
|
3
|
+
*
|
|
4
|
+
* Runs at codegen time (build + dev) to catch mismatches between
|
|
5
|
+
* app/schema.ts keys and the actual dynamic segments on disk.
|
|
6
|
+
*
|
|
7
|
+
* Design doc: design/41-global-params.md
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { readFileSync } from 'node:fs';
|
|
11
|
+
import type { RouteTree, SegmentNode } from './types.js';
|
|
12
|
+
import { findSchemaFile, parseExistingSchemaKeys } from '../cli-schema-sync.js';
|
|
13
|
+
import { classifyUrlSegment } from './segment-classify.js';
|
|
14
|
+
|
|
15
|
+
export interface SchemaWarning {
|
|
16
|
+
type: 'stale-key' | 'missing-key' | 'bracket-mismatch';
|
|
17
|
+
key: string;
|
|
18
|
+
message: string;
|
|
19
|
+
filesystemKey?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Collect all unique bracket-keyed dynamic segment names from a route tree.
|
|
24
|
+
* Returns entries like '[artistSlug]', '[...slug]', '[[...topic]]'.
|
|
25
|
+
*/
|
|
26
|
+
export function collectDynamicSegmentsFromTree(tree: RouteTree): Set<string> {
|
|
27
|
+
const segments = new Set<string>();
|
|
28
|
+
walkTree(tree.root, segments);
|
|
29
|
+
return segments;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function walkTree(node: SegmentNode, segments: Set<string>): void {
|
|
33
|
+
if (node.paramName && node.segmentName) {
|
|
34
|
+
const classified = classifyUrlSegment(node.segmentName);
|
|
35
|
+
if (classified.kind !== 'static') {
|
|
36
|
+
segments.add(node.segmentName);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
for (const child of node.children) {
|
|
40
|
+
walkTree(child, segments);
|
|
41
|
+
}
|
|
42
|
+
for (const slot of Object.values(node.slots)) {
|
|
43
|
+
walkTree(slot, segments);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Extract the param name from a bracket key.
|
|
49
|
+
* '[id]' → 'id', '[...slug]' → 'slug', '[[...topic]]' → 'topic'
|
|
50
|
+
*/
|
|
51
|
+
function extractParamName(bracketKey: string): string | null {
|
|
52
|
+
const seg = classifyUrlSegment(bracketKey);
|
|
53
|
+
if (seg.kind === 'static') return null;
|
|
54
|
+
return seg.name;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Validate schema keys against the filesystem route tree.
|
|
59
|
+
*
|
|
60
|
+
* Returns warnings for:
|
|
61
|
+
* - Stale keys: schema keys that don't match any filesystem segment
|
|
62
|
+
* - Bracket form mismatches: same param name, different bracket form
|
|
63
|
+
* - Missing keys: filesystem segments not registered in schema (informational)
|
|
64
|
+
*/
|
|
65
|
+
export function validateSchemaAgainstRoutes(tree: RouteTree, appDir: string): SchemaWarning[] {
|
|
66
|
+
const schemaPath = findSchemaFile(appDir);
|
|
67
|
+
if (!schemaPath) return [];
|
|
68
|
+
|
|
69
|
+
const content = readFileSync(schemaPath, 'utf-8');
|
|
70
|
+
const schemaKeys = parseExistingSchemaKeys(content);
|
|
71
|
+
|
|
72
|
+
if (schemaKeys.length === 0) return [];
|
|
73
|
+
|
|
74
|
+
const filesystemSegments = collectDynamicSegmentsFromTree(tree);
|
|
75
|
+
|
|
76
|
+
return validateKeys(schemaKeys, filesystemSegments);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Core validation: compare schema keys against filesystem segments.
|
|
81
|
+
* Exported separately for testing without filesystem dependencies.
|
|
82
|
+
*/
|
|
83
|
+
export function validateKeys(
|
|
84
|
+
schemaKeys: string[],
|
|
85
|
+
filesystemSegments: Set<string>
|
|
86
|
+
): SchemaWarning[] {
|
|
87
|
+
const warnings: SchemaWarning[] = [];
|
|
88
|
+
|
|
89
|
+
// Build a map of param name → bracket form for filesystem segments
|
|
90
|
+
const fsParamToKey = new Map<string, string>();
|
|
91
|
+
for (const fsKey of filesystemSegments) {
|
|
92
|
+
const name = extractParamName(fsKey);
|
|
93
|
+
if (name) {
|
|
94
|
+
fsParamToKey.set(name, fsKey);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Build a set of schema param names for the missing-key check
|
|
99
|
+
const schemaParamNames = new Set<string>();
|
|
100
|
+
|
|
101
|
+
for (const schemaKey of schemaKeys) {
|
|
102
|
+
const schemaParamName = extractParamName(schemaKey);
|
|
103
|
+
if (!schemaParamName) continue;
|
|
104
|
+
|
|
105
|
+
schemaParamNames.add(schemaParamName);
|
|
106
|
+
|
|
107
|
+
if (filesystemSegments.has(schemaKey)) {
|
|
108
|
+
// Exact match — no issue
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Check if the param name exists but with a different bracket form
|
|
113
|
+
const fsKey = fsParamToKey.get(schemaParamName);
|
|
114
|
+
if (fsKey) {
|
|
115
|
+
warnings.push({
|
|
116
|
+
type: 'bracket-mismatch',
|
|
117
|
+
key: schemaKey,
|
|
118
|
+
filesystemKey: fsKey,
|
|
119
|
+
message:
|
|
120
|
+
`Schema key '${schemaKey}' does not match filesystem bracket form '${fsKey}'. ` +
|
|
121
|
+
`Update the schema key to '${fsKey}'.`,
|
|
122
|
+
});
|
|
123
|
+
} else {
|
|
124
|
+
warnings.push({
|
|
125
|
+
type: 'stale-key',
|
|
126
|
+
key: schemaKey,
|
|
127
|
+
message:
|
|
128
|
+
`Schema key '${schemaKey}' does not match any dynamic segment in the filesystem. ` +
|
|
129
|
+
`Remove it from app/schema.ts or create the corresponding route segment.`,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Check for filesystem segments not in schema (informational)
|
|
135
|
+
for (const fsKey of filesystemSegments) {
|
|
136
|
+
const name = extractParamName(fsKey);
|
|
137
|
+
if (name && !schemaParamNames.has(name)) {
|
|
138
|
+
warnings.push({
|
|
139
|
+
type: 'missing-key',
|
|
140
|
+
key: fsKey,
|
|
141
|
+
message:
|
|
142
|
+
`Dynamic segment '${fsKey}' has no codec in app/schema.ts (defaults to string). ` +
|
|
143
|
+
`Run: timber schema sync`,
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return warnings;
|
|
149
|
+
}
|
package/LICENSE
DELETED
|
@@ -1,8 +0,0 @@
|
|
|
1
|
-
DONTFUCKINGUSE LICENSE
|
|
2
|
-
|
|
3
|
-
Copyright (c) 2025 Daniel Saewitz
|
|
4
|
-
|
|
5
|
-
This software may not be used, copied, modified, merged, published,
|
|
6
|
-
distributed, sublicensed, or sold by anyone other than the copyright holder.
|
|
7
|
-
|
|
8
|
-
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND.
|
|
@@ -1,358 +0,0 @@
|
|
|
1
|
-
import { t as scanRoutes } from "./scanner-DmfoB7iK.js";
|
|
2
|
-
import { join } from "node:path";
|
|
3
|
-
import { createRequire } from "node:module";
|
|
4
|
-
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
5
|
-
import { performance } from "node:perf_hooks";
|
|
6
|
-
//#region src/utils/startup-timer.ts
|
|
7
|
-
/**
|
|
8
|
-
* Startup timer — records named phases with their durations.
|
|
9
|
-
*
|
|
10
|
-
* Used by the plugin system to instrument cold start and report a
|
|
11
|
-
* timing breakdown in dev mode. Zero overhead in production (disabled).
|
|
12
|
-
*
|
|
13
|
-
* See design/18-build-system.md, TIM-155.
|
|
14
|
-
*/
|
|
15
|
-
/**
|
|
16
|
-
* Create a startup timer that records phase durations.
|
|
17
|
-
*/
|
|
18
|
-
function createStartupTimer() {
|
|
19
|
-
const pending = /* @__PURE__ */ new Map();
|
|
20
|
-
const phases = [];
|
|
21
|
-
return {
|
|
22
|
-
start(phase) {
|
|
23
|
-
pending.set(phase, performance.now());
|
|
24
|
-
},
|
|
25
|
-
end(phase) {
|
|
26
|
-
const startMs = pending.get(phase);
|
|
27
|
-
if (startMs === void 0) return 0;
|
|
28
|
-
pending.delete(phase);
|
|
29
|
-
const durationMs = performance.now() - startMs;
|
|
30
|
-
phases.push({
|
|
31
|
-
name: phase,
|
|
32
|
-
startMs,
|
|
33
|
-
durationMs
|
|
34
|
-
});
|
|
35
|
-
return durationMs;
|
|
36
|
-
},
|
|
37
|
-
getPhases() {
|
|
38
|
-
return [...phases].sort((a, b) => a.startMs - b.startMs);
|
|
39
|
-
},
|
|
40
|
-
totalMs() {
|
|
41
|
-
if (phases.length === 0) return 0;
|
|
42
|
-
const sorted = this.getPhases();
|
|
43
|
-
const first = sorted[0];
|
|
44
|
-
const last = sorted[sorted.length - 1];
|
|
45
|
-
return last.startMs + last.durationMs - first.startMs;
|
|
46
|
-
},
|
|
47
|
-
formatSummary() {
|
|
48
|
-
const sorted = this.getPhases();
|
|
49
|
-
if (sorted.length === 0) return "No phases recorded.";
|
|
50
|
-
const lines = sorted.map((p) => {
|
|
51
|
-
const ms = p.durationMs.toFixed(1);
|
|
52
|
-
return ` ${p.name.padEnd(30)} ${ms.padStart(8)}ms`;
|
|
53
|
-
});
|
|
54
|
-
const total = this.totalMs().toFixed(1);
|
|
55
|
-
lines.push(` ${"total".padEnd(30)} ${total.padStart(8)}ms`);
|
|
56
|
-
return ["[timber] startup timing:", ...lines].join("\n");
|
|
57
|
-
}
|
|
58
|
-
};
|
|
59
|
-
}
|
|
60
|
-
/**
|
|
61
|
-
* No-op timer for production builds — all methods are empty.
|
|
62
|
-
*/
|
|
63
|
-
function createNoopTimer() {
|
|
64
|
-
return {
|
|
65
|
-
start() {},
|
|
66
|
-
end() {
|
|
67
|
-
return 0;
|
|
68
|
-
},
|
|
69
|
-
getPhases() {
|
|
70
|
-
return [];
|
|
71
|
-
},
|
|
72
|
-
totalMs() {
|
|
73
|
-
return 0;
|
|
74
|
-
},
|
|
75
|
-
formatSummary() {
|
|
76
|
-
return "";
|
|
77
|
-
}
|
|
78
|
-
};
|
|
79
|
-
}
|
|
80
|
-
//#endregion
|
|
81
|
-
//#region src/plugin-context.ts
|
|
82
|
-
/**
|
|
83
|
-
* Plugin context — internal types and helpers for timber sub-plugins.
|
|
84
|
-
*
|
|
85
|
-
* These are NOT part of the user-facing API. They are consumed by
|
|
86
|
-
* sub-plugins (routing, entries, shims, etc.) via relative imports.
|
|
87
|
-
* The root entry point (index.ts) does not re-export them.
|
|
88
|
-
*
|
|
89
|
-
* Design doc: 18-build-system.md §"Shared Plugin Context"
|
|
90
|
-
*/
|
|
91
|
-
/**
|
|
92
|
-
* Resolve `clientJavascript` into a fully resolved config.
|
|
93
|
-
*/
|
|
94
|
-
function resolveClientJavascript(config) {
|
|
95
|
-
if (config.clientJavascript !== void 0) {
|
|
96
|
-
if (typeof config.clientJavascript === "boolean") return {
|
|
97
|
-
disabled: !config.clientJavascript,
|
|
98
|
-
enableHMRInDev: !config.clientJavascript
|
|
99
|
-
};
|
|
100
|
-
return {
|
|
101
|
-
disabled: config.clientJavascript.disabled,
|
|
102
|
-
enableHMRInDev: config.clientJavascript.enableHMRInDev ?? config.clientJavascript.disabled
|
|
103
|
-
};
|
|
104
|
-
}
|
|
105
|
-
return {
|
|
106
|
-
disabled: false,
|
|
107
|
-
enableHMRInDev: false
|
|
108
|
-
};
|
|
109
|
-
}
|
|
110
|
-
/**
|
|
111
|
-
* Resolve the app directory. Checks (in order):
|
|
112
|
-
* 1. Explicit `configAppDir` from timber.config.ts
|
|
113
|
-
* 2. `<root>/app`
|
|
114
|
-
* 3. `<root>/src/app`
|
|
115
|
-
*
|
|
116
|
-
* Throws if none exist.
|
|
117
|
-
*/
|
|
118
|
-
function resolveAppDir(root, configAppDir) {
|
|
119
|
-
if (configAppDir) {
|
|
120
|
-
const explicit = join(root, configAppDir);
|
|
121
|
-
if (!existsSync(explicit)) throw new Error(`[timber] Configured appDir "${configAppDir}" does not exist at ${explicit}`);
|
|
122
|
-
return explicit;
|
|
123
|
-
}
|
|
124
|
-
const rootApp = join(root, "app");
|
|
125
|
-
if (existsSync(rootApp)) return rootApp;
|
|
126
|
-
const srcApp = join(root, "src", "app");
|
|
127
|
-
if (existsSync(srcApp)) return srcApp;
|
|
128
|
-
throw new Error(`[timber] Could not find app directory. Expected "app/" or "src/app/" in ${root}. You can set appDir in timber.config.ts to specify a custom location.`);
|
|
129
|
-
}
|
|
130
|
-
function createPluginContext(config, root) {
|
|
131
|
-
const projectRoot = root ?? process.cwd();
|
|
132
|
-
const resolvedConfig = { ...config };
|
|
133
|
-
return {
|
|
134
|
-
config: resolvedConfig,
|
|
135
|
-
clientJavascript: resolveClientJavascript(resolvedConfig),
|
|
136
|
-
routeTree: null,
|
|
137
|
-
appDir: join(projectRoot, "app"),
|
|
138
|
-
root: projectRoot,
|
|
139
|
-
dev: false,
|
|
140
|
-
buildManifest: null,
|
|
141
|
-
deploymentId: null,
|
|
142
|
-
timer: createStartupTimer(),
|
|
143
|
-
holdingServer: null,
|
|
144
|
-
buildDir: resolveBuildDir(projectRoot, resolvedConfig.buildDir)
|
|
145
|
-
};
|
|
146
|
-
}
|
|
147
|
-
/** Default build output directory (relative to root). */
|
|
148
|
-
var DEFAULT_BUILD_DIR = join(".timber", "dist");
|
|
149
|
-
/**
|
|
150
|
-
* Resolve the build output directory.
|
|
151
|
-
*
|
|
152
|
-
* Priority:
|
|
153
|
-
* 1. Explicit `timberBuildDir` from timber.config.ts
|
|
154
|
-
* 2. Explicit `viteBuildOutDir` from Vite's build.outDir (if not the default 'dist')
|
|
155
|
-
* 3. Default: `.timber/dist`
|
|
156
|
-
*
|
|
157
|
-
* Returns an absolute path.
|
|
158
|
-
*/
|
|
159
|
-
function resolveBuildDir(root, timberBuildDir, viteBuildOutDir) {
|
|
160
|
-
if (timberBuildDir) return join(root, timberBuildDir);
|
|
161
|
-
if (viteBuildOutDir && viteBuildOutDir !== "dist") return join(root, viteBuildOutDir);
|
|
162
|
-
return join(root, DEFAULT_BUILD_DIR);
|
|
163
|
-
}
|
|
164
|
-
/**
|
|
165
|
-
* Load timber.config.ts (or .js, .mjs) from the project root.
|
|
166
|
-
* Returns the config object or null if no config file is found.
|
|
167
|
-
*
|
|
168
|
-
* Uses require() which works for ESM modules on Node 22.12+.
|
|
169
|
-
* This keeps timber() synchronous — no async config loading needed.
|
|
170
|
-
*/
|
|
171
|
-
function loadTimberConfigFile(root) {
|
|
172
|
-
const configNames = [
|
|
173
|
-
"timber.config.ts",
|
|
174
|
-
"timber.config.js",
|
|
175
|
-
"timber.config.mjs"
|
|
176
|
-
];
|
|
177
|
-
const req = createRequire(join(root, "package.json"));
|
|
178
|
-
for (const name of configNames) {
|
|
179
|
-
const configPath = join(root, name);
|
|
180
|
-
if (existsSync(configPath)) {
|
|
181
|
-
const mod = req(configPath);
|
|
182
|
-
return mod.default ?? mod;
|
|
183
|
-
}
|
|
184
|
-
}
|
|
185
|
-
return null;
|
|
186
|
-
}
|
|
187
|
-
/**
|
|
188
|
-
* Detect config keys set in both inline (vite.config.ts) and file (timber.config.ts)
|
|
189
|
-
* and warn the user. The `output` key is excluded because it defaults to 'server'
|
|
190
|
-
* in createPluginContext and would always appear as an inline key.
|
|
191
|
-
*
|
|
192
|
-
* Returns the list of conflicting key names (for testing).
|
|
193
|
-
*/
|
|
194
|
-
function warnConfigConflicts(inline, fileConfig) {
|
|
195
|
-
const conflicts = [];
|
|
196
|
-
for (const key of Object.keys(fileConfig)) if (key in inline && inline[key] !== void 0) conflicts.push(key);
|
|
197
|
-
if (conflicts.length > 0) console.warn(`[timber] Config conflict: ${conflicts.map((k) => `"${k}"`).join(", ")} set in both vite.config.ts (inline) and timber.config.ts. Move all config to timber.config.ts to avoid confusion. The inline value from vite.config.ts will be used.`);
|
|
198
|
-
return conflicts;
|
|
199
|
-
}
|
|
200
|
-
/**
|
|
201
|
-
* Merge file-based config into ctx.config. Inline config (already in ctx.config)
|
|
202
|
-
* takes precedence — file config only fills in missing fields.
|
|
203
|
-
*/
|
|
204
|
-
function mergeFileConfig(ctx, fileConfig) {
|
|
205
|
-
const inline = ctx.config;
|
|
206
|
-
warnConfigConflicts(inline, fileConfig);
|
|
207
|
-
ctx.config = {
|
|
208
|
-
...fileConfig,
|
|
209
|
-
...inline,
|
|
210
|
-
...fileConfig.limits && inline.limits ? { limits: {
|
|
211
|
-
...fileConfig.limits,
|
|
212
|
-
...inline.limits
|
|
213
|
-
} } : {},
|
|
214
|
-
...fileConfig.dev && inline.dev ? { dev: {
|
|
215
|
-
...fileConfig.dev,
|
|
216
|
-
...inline.dev
|
|
217
|
-
} } : {},
|
|
218
|
-
...fileConfig.mdx && inline.mdx ? { mdx: {
|
|
219
|
-
...fileConfig.mdx,
|
|
220
|
-
...inline.mdx
|
|
221
|
-
} } : {}
|
|
222
|
-
};
|
|
223
|
-
}
|
|
224
|
-
//#endregion
|
|
225
|
-
//#region src/cli-schema-sync.ts
|
|
226
|
-
/**
|
|
227
|
-
* `timber schema sync` — scan filesystem for dynamic segments and append
|
|
228
|
-
* missing entries to app/schema.ts with `codec.string` defaults.
|
|
229
|
-
*
|
|
230
|
-
* Only adds, never removes or modifies existing entries.
|
|
231
|
-
*
|
|
232
|
-
* Design doc: design/41-global-params.md §Auto-Scaffolding
|
|
233
|
-
*/
|
|
234
|
-
/**
|
|
235
|
-
* Collect all unique bracket-keyed dynamic segment names from the route tree.
|
|
236
|
-
* Returns entries like '[artistSlug]', '[...slug]', '[[...topic]]'.
|
|
237
|
-
*/
|
|
238
|
-
function collectDynamicSegments(appDir) {
|
|
239
|
-
const tree = scanRoutes(appDir);
|
|
240
|
-
const segments = /* @__PURE__ */ new Set();
|
|
241
|
-
walkSegments(tree.root, segments);
|
|
242
|
-
return [...segments].sort();
|
|
243
|
-
}
|
|
244
|
-
function walkSegments(node, segments) {
|
|
245
|
-
if (node.paramName) segments.add(node.segmentName);
|
|
246
|
-
for (const child of node.children) walkSegments(child, segments);
|
|
247
|
-
for (const slot of Object.values(node.slots)) walkSegments(slot, segments);
|
|
248
|
-
}
|
|
249
|
-
/**
|
|
250
|
-
* Parse existing bracket keys from an app/schema.ts file.
|
|
251
|
-
* Uses regex to find keys in segmentParams: { '[key]': ... } blocks.
|
|
252
|
-
*/
|
|
253
|
-
function parseExistingSchemaKeys(content) {
|
|
254
|
-
const keys = [];
|
|
255
|
-
const re = /['"](\[{1,2}(?:\.{3})?\w+\]{1,2})['"]\s*:/g;
|
|
256
|
-
let m;
|
|
257
|
-
while ((m = re.exec(content)) !== null) keys.push(m[1]);
|
|
258
|
-
return keys;
|
|
259
|
-
}
|
|
260
|
-
/** Schema file extensions to check. */
|
|
261
|
-
var SCHEMA_EXTENSIONS = [
|
|
262
|
-
".ts",
|
|
263
|
-
".tsx",
|
|
264
|
-
".js",
|
|
265
|
-
".jsx"
|
|
266
|
-
];
|
|
267
|
-
/**
|
|
268
|
-
* Find the schema file path if it exists.
|
|
269
|
-
*/
|
|
270
|
-
function findSchemaFile(appDir) {
|
|
271
|
-
for (const ext of SCHEMA_EXTENSIONS) {
|
|
272
|
-
const p = join(appDir, `schema${ext}`);
|
|
273
|
-
if (existsSync(p)) return p;
|
|
274
|
-
}
|
|
275
|
-
}
|
|
276
|
-
/**
|
|
277
|
-
* Generate the content for a new app/schema.ts with the given segment keys.
|
|
278
|
-
*/
|
|
279
|
-
function generateNewSchema(segments) {
|
|
280
|
-
const lines = [];
|
|
281
|
-
lines.push("import { defineSchema } from '@timber-js/app/params';");
|
|
282
|
-
lines.push("import { codec } from '@timber-js/app/codec';");
|
|
283
|
-
lines.push("");
|
|
284
|
-
lines.push("export default defineSchema({");
|
|
285
|
-
lines.push(" segmentParams: {");
|
|
286
|
-
for (const seg of segments) lines.push(` '${seg}': codec.string,`);
|
|
287
|
-
lines.push(" },");
|
|
288
|
-
lines.push("});");
|
|
289
|
-
lines.push("");
|
|
290
|
-
return lines.join("\n");
|
|
291
|
-
}
|
|
292
|
-
/**
|
|
293
|
-
* Append missing segment entries to an existing schema file.
|
|
294
|
-
* Inserts new entries before the closing `}` of segmentParams.
|
|
295
|
-
*/
|
|
296
|
-
function appendToSchema(content, newSegments) {
|
|
297
|
-
const spIndex = content.indexOf("segmentParams");
|
|
298
|
-
if (spIndex === -1) throw new Error("[timber] Could not find segmentParams in schema file. Expected a defineSchema({ segmentParams: { ... } }) call.");
|
|
299
|
-
const openBrace = content.indexOf("{", spIndex + 13);
|
|
300
|
-
if (openBrace === -1) throw new Error("[timber] Could not find opening brace for segmentParams.");
|
|
301
|
-
let depth = 1;
|
|
302
|
-
let i = openBrace + 1;
|
|
303
|
-
while (i < content.length && depth > 0) {
|
|
304
|
-
if (content[i] === "{") depth++;
|
|
305
|
-
else if (content[i] === "}") depth--;
|
|
306
|
-
i++;
|
|
307
|
-
}
|
|
308
|
-
const closeBrace = i - 1;
|
|
309
|
-
const existingLine = content.slice(openBrace, closeBrace).match(/^(\s+)['"]\[/m);
|
|
310
|
-
const indent = existingLine ? existingLine[1] : " ";
|
|
311
|
-
const insertion = newSegments.map((seg) => `${indent}'${seg}': codec.string,`).join("\n");
|
|
312
|
-
const before = content.slice(0, closeBrace);
|
|
313
|
-
const after = content.slice(closeBrace);
|
|
314
|
-
return before + (before.trimEnd().endsWith(",") ? "\n" : "\n") + insertion + "\n" + after;
|
|
315
|
-
}
|
|
316
|
-
/**
|
|
317
|
-
* Run the schema sync: scan filesystem, diff against schema, append missing.
|
|
318
|
-
*/
|
|
319
|
-
function runSchemaSync(projectRoot) {
|
|
320
|
-
const appDir = resolveAppDir(projectRoot);
|
|
321
|
-
const filesystemSegments = collectDynamicSegments(appDir);
|
|
322
|
-
if (filesystemSegments.length === 0) return {
|
|
323
|
-
filesystemSegments: [],
|
|
324
|
-
existingSegments: [],
|
|
325
|
-
addedSegments: [],
|
|
326
|
-
created: false
|
|
327
|
-
};
|
|
328
|
-
const schemaPath = findSchemaFile(appDir);
|
|
329
|
-
if (!schemaPath) {
|
|
330
|
-
writeFileSync(join(appDir, "schema.ts"), generateNewSchema(filesystemSegments), "utf-8");
|
|
331
|
-
return {
|
|
332
|
-
filesystemSegments,
|
|
333
|
-
existingSegments: [],
|
|
334
|
-
addedSegments: filesystemSegments,
|
|
335
|
-
created: true
|
|
336
|
-
};
|
|
337
|
-
}
|
|
338
|
-
const content = readFileSync(schemaPath, "utf-8");
|
|
339
|
-
const existingSegments = parseExistingSchemaKeys(content);
|
|
340
|
-
const missing = filesystemSegments.filter((seg) => !existingSegments.includes(seg));
|
|
341
|
-
if (missing.length === 0) return {
|
|
342
|
-
filesystemSegments,
|
|
343
|
-
existingSegments,
|
|
344
|
-
addedSegments: [],
|
|
345
|
-
created: false
|
|
346
|
-
};
|
|
347
|
-
writeFileSync(schemaPath, appendToSchema(content, missing), "utf-8");
|
|
348
|
-
return {
|
|
349
|
-
filesystemSegments,
|
|
350
|
-
existingSegments,
|
|
351
|
-
addedSegments: missing,
|
|
352
|
-
created: false
|
|
353
|
-
};
|
|
354
|
-
}
|
|
355
|
-
//#endregion
|
|
356
|
-
export { DEFAULT_BUILD_DIR as a, mergeFileConfig as c, resolveClientJavascript as d, createNoopTimer as f, runSchemaSync as i, resolveAppDir as l, findSchemaFile as n, createPluginContext as o, parseExistingSchemaKeys as r, loadTimberConfigFile as s, collectDynamicSegments as t, resolveBuildDir as u };
|
|
357
|
-
|
|
358
|
-
//# sourceMappingURL=cli-schema-sync-q2wB0DyJ.js.map
|