@timber-js/app 0.2.0-alpha.84 → 0.2.0-alpha.85

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 (58) hide show
  1. package/LICENSE +8 -0
  2. package/dist/_chunks/{actions-YHRCboUO.js → actions-DLnUaR65.js} +2 -2
  3. package/dist/_chunks/{actions-YHRCboUO.js.map → actions-DLnUaR65.js.map} +1 -1
  4. package/dist/_chunks/{chunk-DYhsFzuS.js → chunk-BYIpzuS7.js} +7 -1
  5. package/dist/_chunks/{define-cookie-C9pquwOg.js → define-cookie-BowvzoP0.js} +4 -4
  6. package/dist/_chunks/{define-cookie-C9pquwOg.js.map → define-cookie-BowvzoP0.js.map} +1 -1
  7. package/dist/_chunks/{request-context-Dl0hXED3.js → request-context-CK5tZqIP.js} +2 -2
  8. package/dist/_chunks/{request-context-Dl0hXED3.js.map → request-context-CK5tZqIP.js.map} +1 -1
  9. package/dist/client/form.d.ts +4 -1
  10. package/dist/client/form.d.ts.map +1 -1
  11. package/dist/client/index.js +2 -2
  12. package/dist/client/index.js.map +1 -1
  13. package/dist/config-validation.d.ts +51 -0
  14. package/dist/config-validation.d.ts.map +1 -0
  15. package/dist/cookies/index.js +1 -1
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/index.js +1168 -51
  18. package/dist/index.js.map +1 -1
  19. package/dist/plugins/dev-404-page.d.ts +56 -0
  20. package/dist/plugins/dev-404-page.d.ts.map +1 -0
  21. package/dist/plugins/dev-error-overlay.d.ts +14 -11
  22. package/dist/plugins/dev-error-overlay.d.ts.map +1 -1
  23. package/dist/plugins/dev-error-page.d.ts +58 -0
  24. package/dist/plugins/dev-error-page.d.ts.map +1 -0
  25. package/dist/plugins/dev-server.d.ts.map +1 -1
  26. package/dist/plugins/dev-terminal-error.d.ts +28 -0
  27. package/dist/plugins/dev-terminal-error.d.ts.map +1 -0
  28. package/dist/plugins/entries.d.ts.map +1 -1
  29. package/dist/plugins/fonts.d.ts +4 -0
  30. package/dist/plugins/fonts.d.ts.map +1 -1
  31. package/dist/plugins/routing.d.ts.map +1 -1
  32. package/dist/routing/convention-lint.d.ts +41 -0
  33. package/dist/routing/convention-lint.d.ts.map +1 -0
  34. package/dist/server/action-client.d.ts +13 -5
  35. package/dist/server/action-client.d.ts.map +1 -1
  36. package/dist/server/fallback-error.d.ts +9 -5
  37. package/dist/server/fallback-error.d.ts.map +1 -1
  38. package/dist/server/index.js +2 -2
  39. package/dist/server/index.js.map +1 -1
  40. package/dist/server/internal.js +2 -2
  41. package/dist/server/rsc-entry/index.d.ts.map +1 -1
  42. package/package.json +6 -7
  43. package/src/cli.ts +0 -0
  44. package/src/client/form.tsx +10 -5
  45. package/src/config-validation.ts +299 -0
  46. package/src/index.ts +17 -0
  47. package/src/plugins/dev-404-page.ts +418 -0
  48. package/src/plugins/dev-error-overlay.ts +165 -54
  49. package/src/plugins/dev-error-page.ts +536 -0
  50. package/src/plugins/dev-server.ts +63 -10
  51. package/src/plugins/dev-terminal-error.ts +217 -0
  52. package/src/plugins/entries.ts +3 -0
  53. package/src/plugins/fonts.ts +3 -2
  54. package/src/plugins/routing.ts +37 -5
  55. package/src/routing/convention-lint.ts +356 -0
  56. package/src/server/action-client.ts +17 -9
  57. package/src/server/fallback-error.ts +39 -88
  58. package/src/server/rsc-entry/index.ts +34 -2
@@ -0,0 +1,217 @@
1
+ /**
2
+ * Terminal error formatting — boxed, color-coded error output for dev mode.
3
+ *
4
+ * Produces a visually scannable error block with:
5
+ * - Unicode box-drawing border around the error
6
+ * - Phase badge and error message
7
+ * - First app frame highlighted as the primary action item
8
+ * - OSC 8 clickable file:line links (VSCode terminal, iTerm2, etc.)
9
+ * - Internal/framework frames collapsed with a count
10
+ * - Component stack (for React render errors)
11
+ *
12
+ * Dev-only: this module is only imported by dev-error-overlay.ts.
13
+ *
14
+ * Design doc: 21-dev-server.md §"Error Overlay"
15
+ */
16
+
17
+ import { pathToFileURL } from 'node:url';
18
+ import {
19
+ classifyFrame,
20
+ extractComponentStack,
21
+ parseFirstAppFrame,
22
+ PHASE_LABELS,
23
+ type ErrorPhase,
24
+ type FrameType,
25
+ } from './dev-error-overlay.js';
26
+
27
+ // ─── ANSI Codes ─────────────────────────────────────────────────────────────
28
+
29
+ const RED = '\x1b[31m';
30
+ const CYAN = '\x1b[36m';
31
+ const DIM = '\x1b[2m';
32
+ const RESET = '\x1b[0m';
33
+ const BOLD = '\x1b[1m';
34
+ const UNDERLINE = '\x1b[4m';
35
+
36
+ // ─── OSC 8 Hyperlinks ──────────────────────────────────────────────────────
37
+
38
+ /**
39
+ * Wrap text in an OSC 8 hyperlink escape sequence.
40
+ *
41
+ * Terminals that support OSC 8 (VSCode, iTerm2, Windows Terminal, etc.)
42
+ * render this as a clickable link. Others ignore the escape sequences
43
+ * and show the text normally.
44
+ *
45
+ * Format: \x1b]8;;URL\x07TEXT\x1b]8;;\x07
46
+ */
47
+ function hyperlink(text: string, url: string): string {
48
+ return `\x1b]8;;${url}\x07${text}\x1b]8;;\x07`;
49
+ }
50
+
51
+ /**
52
+ * Format a file:line:col reference as a clickable terminal link.
53
+ *
54
+ * Uses file:// URLs so terminals open the file in the configured editor.
55
+ * The link text is styled with cyan + underline for visibility.
56
+ */
57
+ function fileLink(filePath: string, line?: number, col?: number): string {
58
+ const display = line ? `${filePath}:${line}${col ? `:${col}` : ''}` : filePath;
59
+ // Use pathToFileURL for proper encoding of spaces, #, ?, and Windows
60
+ // drive letters (TIM-792). Append :line:col after the URL.
61
+ const base = pathToFileURL(filePath).href;
62
+ const url = line ? `${base}:${line}${col ? `:${col}` : ''}` : base;
63
+ return `${CYAN}${UNDERLINE}${hyperlink(display, url)}${RESET}`;
64
+ }
65
+
66
+ // ─── Box Drawing ────────────────────────────────────────────────────────────
67
+
68
+ const BOX = {
69
+ topLeft: '╭',
70
+ topRight: '╮',
71
+ bottomLeft: '╰',
72
+ bottomRight: '╯',
73
+ horizontal: '─',
74
+ vertical: '│',
75
+ };
76
+
77
+ /**
78
+ * Wrap lines of text in a Unicode box with a colored left border.
79
+ *
80
+ * @param lines - Content lines (no ANSI length calculation — keeps it simple)
81
+ * @param width - Box width (characters). Lines longer than this are not truncated.
82
+ */
83
+ function box(lines: string[], borderColor: string, width = 80): string {
84
+ const bar = BOX.horizontal.repeat(width - 2);
85
+ const output: string[] = [];
86
+
87
+ output.push(`${borderColor}${BOX.topLeft}${bar}${BOX.topRight}${RESET}`);
88
+ for (const line of lines) {
89
+ output.push(`${borderColor}${BOX.vertical}${RESET} ${line}`);
90
+ }
91
+ output.push(`${borderColor}${BOX.bottomLeft}${bar}${BOX.bottomRight}${RESET}`);
92
+
93
+ return output.join('\n');
94
+ }
95
+
96
+ // ─── Frame Extraction ───────────────────────────────────────────────────────
97
+
98
+ interface ClassifiedFrame {
99
+ raw: string;
100
+ type: FrameType;
101
+ file?: string;
102
+ line?: number;
103
+ col?: number;
104
+ }
105
+
106
+ /** Parse file/line/col from a stack frame line. */
107
+ function parseFrame(frameLine: string): { file?: string; line?: number; col?: number } {
108
+ const parenMatch = /\(([^)]+):(\d+):(\d+)\)/.exec(frameLine);
109
+ if (parenMatch) {
110
+ return { file: parenMatch[1], line: Number(parenMatch[2]), col: Number(parenMatch[3]) };
111
+ }
112
+ const bareMatch = /at (\/[^:]+):(\d+):(\d+)/.exec(frameLine);
113
+ if (bareMatch) {
114
+ return { file: bareMatch[1], line: Number(bareMatch[2]), col: Number(bareMatch[3]) };
115
+ }
116
+ return {};
117
+ }
118
+
119
+ function classifyFrames(stack: string, projectRoot: string): ClassifiedFrame[] {
120
+ return stack
121
+ .split('\n')
122
+ .slice(1)
123
+ .filter((l) => l.trim().startsWith('at '))
124
+ .map((raw) => {
125
+ const type = classifyFrame(raw, projectRoot);
126
+ const { file, line, col } = parseFrame(raw);
127
+ return { raw, type, file, line, col };
128
+ });
129
+ }
130
+
131
+ // ─── Public API ─────────────────────────────────────────────────────────────
132
+
133
+ /**
134
+ * Format an error for terminal output with a boxed layout.
135
+ *
136
+ * The output is designed to be scannable at a glance:
137
+ * 1. Red box with phase badge and error message
138
+ * 2. First app frame as a clickable link (the primary action item)
139
+ * 3. App frames listed normally
140
+ * 4. Internal/framework frames collapsed with count
141
+ * 5. Component stack (if present)
142
+ */
143
+ export function formatTerminalError(error: Error, phase: ErrorPhase, projectRoot: string): string {
144
+ const sections: string[] = [];
145
+ const componentStack = extractComponentStack(error);
146
+ const loc = parseFirstAppFrame(error.stack ?? '', projectRoot);
147
+ const frames = error.stack ? classifyFrames(error.stack, projectRoot) : [];
148
+ const appFrames = frames.filter((f) => f.type === 'app');
149
+ const internalCount = frames.filter((f) => f.type !== 'app').length;
150
+
151
+ // ── Box: Phase + Message ──────────────────────────────────────────
152
+ const boxLines: string[] = [];
153
+ boxLines.push(`${RED}${BOLD}${PHASE_LABELS[phase]} Error${RESET}`);
154
+ boxLines.push('');
155
+
156
+ // Error message — may be multi-line
157
+ for (const msgLine of error.message.split('\n')) {
158
+ boxLines.push(`${RED}${msgLine}${RESET}`);
159
+ }
160
+
161
+ // Primary file location (clickable)
162
+ if (loc) {
163
+ boxLines.push('');
164
+ const relPath = loc.file.startsWith(projectRoot)
165
+ ? loc.file.slice(projectRoot.length + 1)
166
+ : loc.file;
167
+ boxLines.push(
168
+ `${BOLD}→${RESET} ${fileLink(loc.file, loc.line, loc.column)} ${DIM}(${relPath})${RESET}`
169
+ );
170
+ }
171
+
172
+ sections.push(box(boxLines, RED));
173
+
174
+ // ── Component Stack ───────────────────────────────────────────────
175
+ if (componentStack) {
176
+ sections.push('');
177
+ sections.push(` ${BOLD}Component Stack:${RESET}`);
178
+ for (const csLine of componentStack.trim().split('\n')) {
179
+ sections.push(` ${DIM}${csLine.trim()}${RESET}`);
180
+ }
181
+ }
182
+
183
+ // ── App Frames ────────────────────────────────────────────────────
184
+ if (appFrames.length > 0) {
185
+ sections.push('');
186
+ sections.push(` ${BOLD}Application Frames:${RESET}`);
187
+ for (let i = 0; i < appFrames.length; i++) {
188
+ const f = appFrames[i]!;
189
+ if (f.file && f.line) {
190
+ const prefix = i === 0 ? `${BOLD}▸${RESET}` : ' ';
191
+ sections.push(
192
+ ` ${prefix} ${fileLink(f.file, f.line, f.col)} ${DIM}${extractFnName(f.raw)}${RESET}`
193
+ );
194
+ } else {
195
+ sections.push(` ${f.raw}`);
196
+ }
197
+ }
198
+ }
199
+
200
+ // ── Internal Frames (collapsed) ───────────────────────────────────
201
+ if (internalCount > 0) {
202
+ sections.push(
203
+ ` ${DIM}… ${internalCount} internal frame${internalCount !== 1 ? 's' : ''} hidden${RESET}`
204
+ );
205
+ }
206
+
207
+ sections.push('');
208
+ return sections.join('\n');
209
+ }
210
+
211
+ // ─── Helpers ────────────────────────────────────────────────────────────────
212
+
213
+ /** Extract the function name from a stack frame line like " at fnName (/path:1:2)". */
214
+ function extractFnName(frameLine: string): string {
215
+ const match = /at\s+(\S+)\s+\(/.exec(frameLine.trim());
216
+ return match ? match[1]! : '';
217
+ }
@@ -117,6 +117,9 @@ function generateConfigModule(ctx: PluginContext): string {
117
117
  // Per-build deployment ID for version skew detection (TIM-446).
118
118
  // Null in dev mode — HMR handles code updates without full reloads.
119
119
  deploymentId: ctx.deploymentId ?? null,
120
+ // Note: project root is NOT included here — it was leaking server
121
+ // filesystem paths to client bundles (TIM-787). Dev error pages get
122
+ // the root through the dev server, not the runtime config.
120
123
  };
121
124
 
122
125
  return [
@@ -84,11 +84,12 @@ function familyToClassName(family: string): string {
84
84
  /**
85
85
  * Generate a unique font ID from family + config hash.
86
86
  */
87
- function generateFontId(family: string, config: GoogleFontConfig): string {
87
+ export function generateFontId(family: string, config: GoogleFontConfig): string {
88
88
  const weights = normalizeToArray(config.weight);
89
89
  const styles = normalizeToArray(config.style);
90
90
  const subsets = config.subsets ?? ['latin'];
91
- return `${family.toLowerCase()}-${weights.join(',')}-${styles.join(',')}-${subsets.join(',')}`;
91
+ const display = config.display ?? 'swap';
92
+ return `${family.toLowerCase()}-${weights.join(',')}-${styles.join(',')}-${subsets.join(',')}-${display}`;
92
93
  }
93
94
 
94
95
  /**
@@ -18,6 +18,11 @@ import {
18
18
  lintStatusFileDirectives,
19
19
  formatStatusFileLintWarnings,
20
20
  } from '../routing/status-file-lint.js';
21
+ import {
22
+ lintConventions,
23
+ checkAppDirExists,
24
+ formatConventionWarnings,
25
+ } from '../routing/convention-lint.js';
21
26
  import type { RouteTree, SegmentNode, RouteFile } from '../routing/types.js';
22
27
  import type { PluginContext } from '../plugin-context.js';
23
28
 
@@ -83,6 +88,24 @@ export function timberRouting(ctx: PluginContext): Plugin {
83
88
  const warnedFiles = new Set<string>();
84
89
 
85
90
  function rescan(): void {
91
+ // Check app/ directory exists before scanning
92
+ const appDirWarning = checkAppDirExists(ctx.appDir);
93
+ if (appDirWarning) {
94
+ const formatted = formatConventionWarnings([appDirWarning]);
95
+ if (formatted) process.stderr.write(`${formatted}\n`);
96
+ // Still create an empty tree so the rest of the pipeline doesn't crash
97
+ ctx.routeTree = {
98
+ root: {
99
+ segmentName: '',
100
+ segmentType: 'static',
101
+ urlPath: '/',
102
+ children: [],
103
+ slots: new Map(),
104
+ },
105
+ };
106
+ return;
107
+ }
108
+
86
109
  ctx.timer.start('route-scan');
87
110
  ctx.routeTree = scanRoutes(ctx.appDir, {
88
111
  pageExtensions: ctx.config.pageExtensions,
@@ -91,11 +114,20 @@ export function timberRouting(ctx: PluginContext): Plugin {
91
114
  writeCodegen(ctx);
92
115
 
93
116
  // Lint status files for missing 'use client' directive
94
- const warnings = lintStatusFileDirectives(ctx.routeTree);
95
- const newWarnings = warnings.filter((w) => !warnedFiles.has(w.filePath));
96
- if (newWarnings.length > 0) {
97
- for (const w of newWarnings) warnedFiles.add(w.filePath);
98
- console.warn(formatStatusFileLintWarnings(newWarnings));
117
+ const statusWarnings = lintStatusFileDirectives(ctx.routeTree);
118
+ const newStatusWarnings = statusWarnings.filter((w) => !warnedFiles.has(w.filePath));
119
+ if (newStatusWarnings.length > 0) {
120
+ for (const w of newStatusWarnings) warnedFiles.add(w.filePath);
121
+ console.warn(formatStatusFileLintWarnings(newStatusWarnings));
122
+ }
123
+
124
+ // Lint conventions (empty app, missing methods, missing root layout)
125
+ const conventionWarnings = lintConventions(ctx.routeTree, ctx.appDir);
126
+ const newConventionWarnings = conventionWarnings.filter((w) => !warnedFiles.has(w.id));
127
+ if (newConventionWarnings.length > 0) {
128
+ for (const w of newConventionWarnings) warnedFiles.add(w.id);
129
+ const formatted = formatConventionWarnings(newConventionWarnings);
130
+ if (formatted) process.stderr.write(`${formatted}\n`);
99
131
  }
100
132
  }
101
133
 
@@ -0,0 +1,356 @@
1
+ /**
2
+ * Convention linter — validates common misconfigurations in the route tree.
3
+ *
4
+ * Runs at scan time (build and dev startup). Each check produces a warning
5
+ * with the file path, what's wrong, and what to do about it.
6
+ *
7
+ * These are warnings, not errors — they don't block the build. The goal is
8
+ * to catch issues that would otherwise produce cryptic runtime behavior
9
+ * (silent 404s, empty pages, confusing React errors).
10
+ *
11
+ * Design doc: 07-routing.md, 10-error-handling.md
12
+ */
13
+
14
+ import { readFileSync, existsSync } from 'node:fs';
15
+ import type { RouteTree, SegmentNode } from './types.js';
16
+
17
+ // ─── Types ──────────────────────────────────────────────────────────────────
18
+
19
+ export interface ConventionWarning {
20
+ /** Warning ID for deduplication and filtering. */
21
+ id: string;
22
+ /** Human-readable single-line summary. */
23
+ summary: string;
24
+ /** Multi-line details with file path and fix suggestion. */
25
+ details: string;
26
+ /** Severity: 'warn' for potential issues, 'error' for definite misconfigurations. */
27
+ level: 'warn' | 'error';
28
+ }
29
+
30
+ // ─── Lint Rules ─────────────────────────────────────────────────────────────
31
+
32
+ /**
33
+ * Run all convention lint checks on a route tree.
34
+ *
35
+ * Returns an array of warnings. Empty array means everything looks good.
36
+ */
37
+ export function lintConventions(tree: RouteTree, appDir: string): ConventionWarning[] {
38
+ const warnings: ConventionWarning[] = [];
39
+
40
+ // Check 1: app/ directory exists (caller should check before scanning,
41
+ // but we validate the root has at least one routable file)
42
+ checkEmptyApp(tree.root, appDir, warnings);
43
+
44
+ // Check 2: route.ts without recognized HTTP method exports
45
+ checkRouteExports(tree.root, warnings);
46
+
47
+ // Check 3: Segments with layout but no page/route anywhere in subtree
48
+ // (not a misconfiguration per se — layouts without pages are valid for
49
+ // route groups. Skip this check.)
50
+
51
+ // Check 4: Root segment has no layout.tsx (no HTML shell)
52
+ checkRootLayout(tree.root, warnings);
53
+
54
+ // Check 5: page.tsx / layout.tsx without default export
55
+ checkDefaultExports(tree.root, warnings);
56
+
57
+ return warnings;
58
+ }
59
+
60
+ // ─── Check: Empty App ───────────────────────────────────────────────────────
61
+
62
+ /**
63
+ * Warn when the app/ directory has no routable files at all.
64
+ *
65
+ * This catches the "I created app/ but didn't add any pages" case where
66
+ * every request silently 404s with no guidance.
67
+ */
68
+ function checkEmptyApp(root: SegmentNode, appDir: string, warnings: ConventionWarning[]): void {
69
+ if (hasAnyRoutable(root)) return;
70
+
71
+ warnings.push({
72
+ id: 'EMPTY_APP',
73
+ summary: 'No pages or route handlers found in app/',
74
+ details:
75
+ ` Directory: ${appDir}\n\n` +
76
+ ' Your app/ directory has no page.tsx or route.ts files.\n' +
77
+ ' Every request will return 404.\n\n' +
78
+ ' To fix: Create app/page.tsx with a default export:\n\n' +
79
+ ' export default function Home() {\n return <h1>Hello</h1>;\n }\n',
80
+ level: 'warn',
81
+ });
82
+ }
83
+
84
+ /**
85
+ * Check if a segment tree has any routable files (page or route) anywhere.
86
+ */
87
+ function hasAnyRoutable(node: SegmentNode): boolean {
88
+ if (node.page || node.route) return true;
89
+ for (const child of node.children) {
90
+ if (hasAnyRoutable(child)) return true;
91
+ }
92
+ for (const [, slot] of node.slots) {
93
+ if (hasAnyRoutable(slot)) return true;
94
+ }
95
+ return false;
96
+ }
97
+
98
+ /**
99
+ * Check if a segment tree has any page files (not just route handlers).
100
+ * Used by checkRootLayout to avoid false positives for API-only apps (TIM-794).
101
+ * API-only apps (just route.ts handlers) don't need a root layout.
102
+ */
103
+ function hasAnyPage(node: SegmentNode): boolean {
104
+ if (node.page) return true;
105
+ for (const child of node.children) {
106
+ if (hasAnyPage(child)) return true;
107
+ }
108
+ for (const [, slot] of node.slots) {
109
+ if (hasAnyPage(slot)) return true;
110
+ }
111
+ return false;
112
+ }
113
+
114
+ // ─── Check: Route Exports ───────────────────────────────────────────────────
115
+
116
+ /** HTTP methods that route.ts can export. */
117
+ const HTTP_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'];
118
+
119
+ /**
120
+ * Pattern to detect named exports that look like HTTP method handlers.
121
+ * Matches: export function GET, export const GET, export async function POST, etc.
122
+ * Also matches: export { GET } or re-exports like export { GET } from './handler'.
123
+ *
124
+ * The re-export branch uses word boundaries (\b) around method names to avoid
125
+ * matching substrings (e.g. TARGET should not match GET). See TIM-795.
126
+ */
127
+ const EXPORT_PATTERN = new RegExp(
128
+ `export\\s+(?:async\\s+)?(?:function|const|let|var)\\s+(${HTTP_METHODS.join('|')})\\b` +
129
+ `|export\\s*\\{[^}]*\\b(${HTTP_METHODS.join('|')})\\b[^}]*\\}`
130
+ );
131
+
132
+ /**
133
+ * Warn when a route.ts file doesn't appear to export any recognized HTTP methods.
134
+ *
135
+ * Uses static analysis (regex on source text) — not module loading.
136
+ * This is intentionally conservative: it may miss complex re-exports but
137
+ * catches the common case of an empty route.ts or one with wrong export names.
138
+ */
139
+ function checkRouteExports(node: SegmentNode, warnings: ConventionWarning[]): void {
140
+ if (node.route) {
141
+ const filePath = node.route.filePath;
142
+ try {
143
+ const source = readFileSync(filePath, 'utf-8');
144
+ if (!EXPORT_PATTERN.test(source)) {
145
+ // Check if there's a default export (common mistake)
146
+ const hasDefaultExport = /export\s+default\b/.test(source);
147
+ const hint = hasDefaultExport
148
+ ? ' It looks like you have a default export. route.ts uses named exports\n' +
149
+ ' for HTTP methods (GET, POST, etc.), not a default export.\n'
150
+ : '';
151
+
152
+ warnings.push({
153
+ id: 'ROUTE_NO_METHODS',
154
+ summary: `route.ts has no HTTP method exports: ${filePath}`,
155
+ details:
156
+ ` File: ${filePath}\n\n` +
157
+ ' route.ts files must export named HTTP method handlers.\n' +
158
+ ' Without them, the route matches but returns 405 Method Not Allowed.\n\n' +
159
+ hint +
160
+ ' Example:\n\n' +
161
+ ' export function GET(ctx: RouteContext) {\n' +
162
+ " return Response.json({ hello: 'world' });\n" +
163
+ ' }\n',
164
+ level: 'warn',
165
+ });
166
+ }
167
+ } catch {
168
+ // Can't read the file — skip silently
169
+ }
170
+ }
171
+
172
+ // Recurse
173
+ for (const child of node.children) {
174
+ checkRouteExports(child, warnings);
175
+ }
176
+ for (const [, slot] of node.slots) {
177
+ checkRouteExports(slot, warnings);
178
+ }
179
+ }
180
+
181
+ // ─── Check: Root Layout ─────────────────────────────────────────────────────
182
+
183
+ /**
184
+ * Warn when the root segment has no layout.tsx.
185
+ *
186
+ * Without a root layout, there's no HTML shell (<html>, <body>).
187
+ * The page will render but may have hydration issues or no proper document structure.
188
+ */
189
+ function checkRootLayout(root: SegmentNode, warnings: ConventionWarning[]): void {
190
+ if (root.layout) return;
191
+
192
+ // Only warn if there are pages — API-only apps (just route.ts handlers)
193
+ // don't need a root layout (TIM-794).
194
+ if (!hasAnyPage(root)) return;
195
+
196
+ warnings.push({
197
+ id: 'NO_ROOT_LAYOUT',
198
+ summary: 'No root layout.tsx found',
199
+ details:
200
+ ' Your app has pages but no root layout.\n' +
201
+ ' Without app/layout.tsx, pages have no <html> or <body> wrapper.\n\n' +
202
+ ' To fix: Create app/layout.tsx:\n\n' +
203
+ ' export default function RootLayout({ children }: { children: React.ReactNode }) {\n' +
204
+ ' return (\n' +
205
+ ' <html lang="en">\n' +
206
+ ' <body>{children}</body>\n' +
207
+ ' </html>\n' +
208
+ ' );\n' +
209
+ ' }\n',
210
+ level: 'warn',
211
+ });
212
+ }
213
+
214
+ // ─── Check: Default Exports ───────────────────────────────────────────
215
+
216
+ /**
217
+ * Pattern to detect a default export in source code.
218
+ * Matches: export default function, export default class, export default
219
+ * Also matches: export { X as default }, export { default } from './Impl'
220
+ *
221
+ * The third alternative catches bare `default` re-exports (TIM-790).
222
+ * The negative lookahead (?!\s+as\b) prevents matching `export { default as X }`
223
+ * which is a named export, not a default export (TIM-806).
224
+ */
225
+ const DEFAULT_EXPORT_PATTERN =
226
+ /export\s+default\b|export\s*\{[^}]*\bas\s+default\b|export\s*\{[^}]*\bdefault\b(?!\s+as\b)[^}]*\}/;
227
+
228
+ /**
229
+ * Warn when page.tsx or layout.tsx files don't have a default export.
230
+ *
231
+ * Without a default export:
232
+ * - page.tsx: the page renders nothing (empty content)
233
+ * - layout.tsx: the layout module loads but has no component to render
234
+ *
235
+ * Uses static analysis (regex on source text) — intentionally conservative.
236
+ */
237
+ function checkDefaultExports(node: SegmentNode, warnings: ConventionWarning[]): void {
238
+ // Check page files (tsx/ts/jsx/js only — not mdx, which is default-exported by the mdx compiler)
239
+ if (node.page && isScriptExtension(node.page.extension)) {
240
+ checkFileDefaultExport(node.page.filePath, 'page', warnings);
241
+ }
242
+
243
+ // Check layout files
244
+ if (node.layout && isScriptExtension(node.layout.extension)) {
245
+ checkFileDefaultExport(node.layout.filePath, 'layout', warnings);
246
+ }
247
+
248
+ // Recurse
249
+ for (const child of node.children) {
250
+ checkDefaultExports(child, warnings);
251
+ }
252
+ for (const [, slot] of node.slots) {
253
+ checkDefaultExports(slot, warnings);
254
+ }
255
+ }
256
+
257
+ function isScriptExtension(ext: string): boolean {
258
+ return ext === 'tsx' || ext === 'ts' || ext === 'jsx' || ext === 'js';
259
+ }
260
+
261
+ function checkFileDefaultExport(
262
+ filePath: string,
263
+ fileType: string,
264
+ warnings: ConventionWarning[]
265
+ ): void {
266
+ try {
267
+ const source = readFileSync(filePath, 'utf-8');
268
+ if (!DEFAULT_EXPORT_PATTERN.test(source)) {
269
+ warnings.push({
270
+ id: `NO_DEFAULT_EXPORT:${filePath}`,
271
+ summary: `${fileType}.tsx has no default export: ${filePath}`,
272
+ details:
273
+ ` File: ${filePath}\n\n` +
274
+ ` ${fileType}.tsx files must export a default React component.\n` +
275
+ ` Without a default export, the ${fileType === 'page' ? 'page renders nothing' : 'layout has no component to wrap children'}.\n\n` +
276
+ ` To fix: Add a default export:\n\n` +
277
+ ` export default function My${fileType === 'page' ? 'Page' : 'Layout'}(${fileType === 'layout' ? '{ children }' : ''}) {\n` +
278
+ ` return ${fileType === 'layout' ? '<div>{children}</div>' : '<h1>Hello</h1>'};\n` +
279
+ ` }\n`,
280
+ level: 'warn',
281
+ });
282
+ }
283
+ } catch {
284
+ // Can't read file — skip silently
285
+ }
286
+ }
287
+
288
+ // ─── Check: App Directory Exists ────────────────────────────────────────────
289
+
290
+ /**
291
+ * Check if the app/ directory exists. Called before scanning.
292
+ * Returns a warning if missing, or null if the directory exists.
293
+ */
294
+ export function checkAppDirExists(appDir: string): ConventionWarning | null {
295
+ if (existsSync(appDir)) return null;
296
+
297
+ return {
298
+ id: 'NO_APP_DIR',
299
+ summary: 'No app/ directory found',
300
+ details:
301
+ ` Expected: ${appDir}\n\n` +
302
+ ' timber.js requires an app/ directory for file-system routing.\n' +
303
+ ' Every request will return 404 until you create it.\n\n' +
304
+ ' To fix: Create the app/ directory with a page:\n\n' +
305
+ ' mkdir -p app\n' +
306
+ ' # Create app/layout.tsx and app/page.tsx\n',
307
+ level: 'error',
308
+ };
309
+ }
310
+
311
+ // ─── Formatting ─────────────────────────────────────────────────────────────
312
+
313
+ const YELLOW = '\x1b[33m';
314
+ const RED = '\x1b[31m';
315
+ const BOLD = '\x1b[1m';
316
+ const DIM = '\x1b[2m';
317
+ const RESET = '\x1b[0m';
318
+
319
+ /**
320
+ * Format warnings for terminal output.
321
+ *
322
+ * Groups by severity, uses colors, and includes fix suggestions.
323
+ */
324
+ export function formatConventionWarnings(warnings: ConventionWarning[]): string {
325
+ if (warnings.length === 0) return '';
326
+
327
+ const errors = warnings.filter((w) => w.level === 'error');
328
+ const warns = warnings.filter((w) => w.level === 'warn');
329
+
330
+ const lines: string[] = [];
331
+
332
+ if (errors.length > 0) {
333
+ lines.push(
334
+ `${RED}${BOLD}[timber]${RESET} ${RED}${errors.length} configuration error${errors.length !== 1 ? 's' : ''}:${RESET}`
335
+ );
336
+ for (const e of errors) {
337
+ lines.push('');
338
+ lines.push(` ${RED}✗${RESET} ${e.summary}`);
339
+ lines.push(`${DIM}${e.details}${RESET}`);
340
+ }
341
+ }
342
+
343
+ if (warns.length > 0) {
344
+ if (errors.length > 0) lines.push('');
345
+ lines.push(
346
+ `${YELLOW}${BOLD}[timber]${RESET} ${YELLOW}${warns.length} configuration warning${warns.length !== 1 ? 's' : ''}:${RESET}`
347
+ );
348
+ for (const w of warns) {
349
+ lines.push('');
350
+ lines.push(` ${YELLOW}⚠${RESET} ${w.summary}`);
351
+ lines.push(`${DIM}${w.details}${RESET}`);
352
+ }
353
+ }
354
+
355
+ return lines.join('\n');
356
+ }