@timber-js/app 0.1.0 → 0.1.2

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 (141) hide show
  1. package/dist/index.js +5 -2
  2. package/dist/index.js.map +1 -1
  3. package/dist/plugins/entries.d.ts.map +1 -1
  4. package/package.json +43 -58
  5. package/src/adapters/cloudflare.ts +325 -0
  6. package/src/adapters/nitro.ts +366 -0
  7. package/src/adapters/types.ts +63 -0
  8. package/src/cache/index.ts +91 -0
  9. package/src/cache/redis-handler.ts +91 -0
  10. package/src/cache/register-cached-function.ts +99 -0
  11. package/src/cache/singleflight.ts +26 -0
  12. package/src/cache/stable-stringify.ts +21 -0
  13. package/src/cache/timber-cache.ts +116 -0
  14. package/src/cli.ts +201 -0
  15. package/src/client/browser-entry.ts +663 -0
  16. package/src/client/error-boundary.tsx +209 -0
  17. package/src/client/form.tsx +200 -0
  18. package/src/client/head.ts +61 -0
  19. package/src/client/history.ts +46 -0
  20. package/src/client/index.ts +60 -0
  21. package/src/client/link-navigate-interceptor.tsx +62 -0
  22. package/src/client/link-status-provider.tsx +40 -0
  23. package/src/client/link.tsx +310 -0
  24. package/src/client/nuqs-adapter.tsx +117 -0
  25. package/src/client/router-ref.ts +25 -0
  26. package/src/client/router.ts +563 -0
  27. package/src/client/segment-cache.ts +194 -0
  28. package/src/client/segment-context.ts +57 -0
  29. package/src/client/ssr-data.ts +95 -0
  30. package/src/client/types.ts +4 -0
  31. package/src/client/unload-guard.ts +34 -0
  32. package/src/client/use-cookie.ts +122 -0
  33. package/src/client/use-link-status.ts +46 -0
  34. package/src/client/use-navigation-pending.ts +47 -0
  35. package/src/client/use-params.ts +71 -0
  36. package/src/client/use-pathname.ts +43 -0
  37. package/src/client/use-query-states.ts +133 -0
  38. package/src/client/use-router.ts +77 -0
  39. package/src/client/use-search-params.ts +74 -0
  40. package/src/client/use-selected-layout-segment.ts +110 -0
  41. package/src/content/index.ts +13 -0
  42. package/src/cookies/define-cookie.ts +137 -0
  43. package/src/cookies/index.ts +9 -0
  44. package/src/fonts/ast.ts +359 -0
  45. package/src/fonts/css.ts +68 -0
  46. package/src/fonts/fallbacks.ts +248 -0
  47. package/src/fonts/google.ts +332 -0
  48. package/src/fonts/local.ts +177 -0
  49. package/src/fonts/types.ts +88 -0
  50. package/src/index.ts +413 -0
  51. package/src/plugins/adapter-build.ts +118 -0
  52. package/src/plugins/build-manifest.ts +323 -0
  53. package/src/plugins/build-report.ts +353 -0
  54. package/src/plugins/cache-transform.ts +199 -0
  55. package/src/plugins/chunks.ts +90 -0
  56. package/src/plugins/content.ts +136 -0
  57. package/src/plugins/dev-error-overlay.ts +230 -0
  58. package/src/plugins/dev-logs.ts +280 -0
  59. package/src/plugins/dev-server.ts +389 -0
  60. package/src/plugins/dynamic-transform.ts +161 -0
  61. package/src/plugins/entries.ts +207 -0
  62. package/src/plugins/fonts.ts +581 -0
  63. package/src/plugins/mdx.ts +179 -0
  64. package/src/plugins/react-prod.ts +56 -0
  65. package/src/plugins/routing.ts +419 -0
  66. package/src/plugins/server-action-exports.ts +220 -0
  67. package/src/plugins/server-bundle.ts +113 -0
  68. package/src/plugins/shims.ts +168 -0
  69. package/src/plugins/static-build.ts +207 -0
  70. package/src/routing/codegen.ts +396 -0
  71. package/src/routing/index.ts +14 -0
  72. package/src/routing/interception.ts +173 -0
  73. package/src/routing/scanner.ts +487 -0
  74. package/src/routing/status-file-lint.ts +114 -0
  75. package/src/routing/types.ts +100 -0
  76. package/src/search-params/analyze.ts +192 -0
  77. package/src/search-params/codecs.ts +153 -0
  78. package/src/search-params/create.ts +314 -0
  79. package/src/search-params/index.ts +23 -0
  80. package/src/search-params/registry.ts +31 -0
  81. package/src/server/access-gate.tsx +142 -0
  82. package/src/server/action-client.ts +473 -0
  83. package/src/server/action-handler.ts +325 -0
  84. package/src/server/actions.ts +236 -0
  85. package/src/server/asset-headers.ts +81 -0
  86. package/src/server/body-limits.ts +102 -0
  87. package/src/server/build-manifest.ts +234 -0
  88. package/src/server/canonicalize.ts +90 -0
  89. package/src/server/client-module-map.ts +58 -0
  90. package/src/server/csrf.ts +79 -0
  91. package/src/server/deny-renderer.ts +302 -0
  92. package/src/server/dev-logger.ts +419 -0
  93. package/src/server/dev-span-processor.ts +78 -0
  94. package/src/server/dev-warnings.ts +282 -0
  95. package/src/server/early-hints-sender.ts +55 -0
  96. package/src/server/early-hints.ts +142 -0
  97. package/src/server/error-boundary-wrapper.ts +69 -0
  98. package/src/server/error-formatter.ts +184 -0
  99. package/src/server/flush.ts +182 -0
  100. package/src/server/form-data.ts +176 -0
  101. package/src/server/form-flash.ts +93 -0
  102. package/src/server/html-injectors.ts +445 -0
  103. package/src/server/index.ts +222 -0
  104. package/src/server/instrumentation.ts +136 -0
  105. package/src/server/logger.ts +145 -0
  106. package/src/server/manifest-status-resolver.ts +215 -0
  107. package/src/server/metadata-render.ts +527 -0
  108. package/src/server/metadata-routes.ts +189 -0
  109. package/src/server/metadata.ts +263 -0
  110. package/src/server/middleware-runner.ts +32 -0
  111. package/src/server/nuqs-ssr-provider.tsx +63 -0
  112. package/src/server/pipeline.ts +555 -0
  113. package/src/server/prerender.ts +139 -0
  114. package/src/server/primitives.ts +264 -0
  115. package/src/server/proxy.ts +43 -0
  116. package/src/server/request-context.ts +554 -0
  117. package/src/server/route-element-builder.ts +395 -0
  118. package/src/server/route-handler.ts +153 -0
  119. package/src/server/route-matcher.ts +316 -0
  120. package/src/server/rsc-entry/api-handler.ts +112 -0
  121. package/src/server/rsc-entry/error-renderer.ts +177 -0
  122. package/src/server/rsc-entry/helpers.ts +147 -0
  123. package/src/server/rsc-entry/index.ts +688 -0
  124. package/src/server/rsc-entry/ssr-bridge.ts +18 -0
  125. package/src/server/slot-resolver.ts +359 -0
  126. package/src/server/ssr-entry.ts +161 -0
  127. package/src/server/ssr-render.ts +200 -0
  128. package/src/server/status-code-resolver.ts +282 -0
  129. package/src/server/tracing.ts +281 -0
  130. package/src/server/tree-builder.ts +354 -0
  131. package/src/server/types.ts +150 -0
  132. package/src/shims/font-google.ts +67 -0
  133. package/src/shims/headers.ts +11 -0
  134. package/src/shims/image.ts +48 -0
  135. package/src/shims/link.ts +9 -0
  136. package/src/shims/navigation-client.ts +52 -0
  137. package/src/shims/navigation.ts +31 -0
  138. package/src/shims/server-only-noop.js +5 -0
  139. package/src/utils/directive-parser.ts +529 -0
  140. package/src/utils/format.ts +10 -0
  141. package/src/utils/startup-timer.ts +102 -0
@@ -0,0 +1,527 @@
1
+ /**
2
+ * Metadata rendering — converts resolved Metadata into HeadElement descriptors.
3
+ *
4
+ * Extracted from metadata.ts to keep files under 500 lines.
5
+ *
6
+ * See design/16-metadata.md
7
+ */
8
+
9
+ import type { Metadata } from './types.js';
10
+ import type { HeadElement } from './metadata.js';
11
+
12
+ // ─── Render to Elements ──────────────────────────────────────────────────────
13
+
14
+ /**
15
+ * Convert resolved metadata into an array of head element descriptors.
16
+ *
17
+ * Each descriptor has a `tag` ('title', 'meta', 'link') and either
18
+ * `content` (for <title>) or `attrs` (for <meta>/<link>).
19
+ *
20
+ * The framework's MetadataResolver component consumes these descriptors
21
+ * and renders them into the <head>.
22
+ */
23
+ export function renderMetadataToElements(metadata: Metadata): HeadElement[] {
24
+ const elements: HeadElement[] = [];
25
+
26
+ // Title
27
+ if (typeof metadata.title === 'string') {
28
+ elements.push({ tag: 'title', content: metadata.title });
29
+ }
30
+
31
+ // Simple string meta tags
32
+ const simpleMetaProps: Array<[string, string | undefined]> = [
33
+ ['description', metadata.description],
34
+ ['generator', metadata.generator],
35
+ ['application-name', metadata.applicationName],
36
+ ['referrer', metadata.referrer],
37
+ ['category', metadata.category],
38
+ ['creator', metadata.creator],
39
+ ['publisher', metadata.publisher],
40
+ ];
41
+
42
+ for (const [name, content] of simpleMetaProps) {
43
+ if (content) {
44
+ elements.push({ tag: 'meta', attrs: { name, content } });
45
+ }
46
+ }
47
+
48
+ // Keywords (array or string)
49
+ if (metadata.keywords) {
50
+ const content = Array.isArray(metadata.keywords)
51
+ ? metadata.keywords.join(', ')
52
+ : metadata.keywords;
53
+ elements.push({ tag: 'meta', attrs: { name: 'keywords', content } });
54
+ }
55
+
56
+ // Robots
57
+ if (metadata.robots) {
58
+ const content =
59
+ typeof metadata.robots === 'string' ? metadata.robots : renderRobotsObject(metadata.robots);
60
+ elements.push({ tag: 'meta', attrs: { name: 'robots', content } });
61
+
62
+ // googleBot as separate tag
63
+ if (typeof metadata.robots === 'object' && metadata.robots.googleBot) {
64
+ const gbContent =
65
+ typeof metadata.robots.googleBot === 'string'
66
+ ? metadata.robots.googleBot
67
+ : renderRobotsObject(metadata.robots.googleBot);
68
+ elements.push({ tag: 'meta', attrs: { name: 'googlebot', content: gbContent } });
69
+ }
70
+ }
71
+
72
+ // Open Graph
73
+ if (metadata.openGraph) {
74
+ renderOpenGraph(metadata.openGraph, elements);
75
+ }
76
+
77
+ // Twitter
78
+ if (metadata.twitter) {
79
+ renderTwitter(metadata.twitter, elements);
80
+ }
81
+
82
+ // Icons
83
+ if (metadata.icons) {
84
+ renderIcons(metadata.icons, elements);
85
+ }
86
+
87
+ // Manifest
88
+ if (metadata.manifest) {
89
+ elements.push({ tag: 'link', attrs: { rel: 'manifest', href: metadata.manifest } });
90
+ }
91
+
92
+ // Alternates
93
+ if (metadata.alternates) {
94
+ renderAlternates(metadata.alternates, elements);
95
+ }
96
+
97
+ // Verification
98
+ if (metadata.verification) {
99
+ renderVerification(metadata.verification, elements);
100
+ }
101
+
102
+ // Format detection
103
+ if (metadata.formatDetection) {
104
+ const parts: string[] = [];
105
+ if (metadata.formatDetection.telephone === false) parts.push('telephone=no');
106
+ if (metadata.formatDetection.email === false) parts.push('email=no');
107
+ if (metadata.formatDetection.address === false) parts.push('address=no');
108
+ if (parts.length > 0) {
109
+ elements.push({
110
+ tag: 'meta',
111
+ attrs: { name: 'format-detection', content: parts.join(', ') },
112
+ });
113
+ }
114
+ }
115
+
116
+ // Authors
117
+ if (metadata.authors) {
118
+ const authorList = Array.isArray(metadata.authors) ? metadata.authors : [metadata.authors];
119
+ for (const author of authorList) {
120
+ if (author.name) {
121
+ elements.push({ tag: 'meta', attrs: { name: 'author', content: author.name } });
122
+ }
123
+ if (author.url) {
124
+ elements.push({ tag: 'link', attrs: { rel: 'author', href: author.url } });
125
+ }
126
+ }
127
+ }
128
+
129
+ // Apple Web App
130
+ if (metadata.appleWebApp) {
131
+ renderAppleWebApp(metadata.appleWebApp, elements);
132
+ }
133
+
134
+ // App Links (al:*)
135
+ if (metadata.appLinks) {
136
+ renderAppLinks(metadata.appLinks, elements);
137
+ }
138
+
139
+ // iTunes
140
+ if (metadata.itunes) {
141
+ renderItunes(metadata.itunes, elements);
142
+ }
143
+
144
+ // Other (custom meta tags)
145
+ if (metadata.other) {
146
+ for (const [name, value] of Object.entries(metadata.other)) {
147
+ const content = Array.isArray(value) ? value.join(', ') : value;
148
+ elements.push({ tag: 'meta', attrs: { name, content } });
149
+ }
150
+ }
151
+
152
+ return elements;
153
+ }
154
+
155
+ // ─── Rendering Helpers ───────────────────────────────────────────────────────
156
+
157
+ function renderRobotsObject(robots: Record<string, unknown>): string {
158
+ const parts: string[] = [];
159
+ if (robots.index === true) parts.push('index');
160
+ if (robots.index === false) parts.push('noindex');
161
+ if (robots.follow === true) parts.push('follow');
162
+ if (robots.follow === false) parts.push('nofollow');
163
+ return parts.join(', ');
164
+ }
165
+
166
+ function renderOpenGraph(og: NonNullable<Metadata['openGraph']>, elements: HeadElement[]): void {
167
+ const simpleProps: Array<[string, string | undefined]> = [
168
+ ['og:title', og.title],
169
+ ['og:description', og.description],
170
+ ['og:url', og.url],
171
+ ['og:site_name', og.siteName],
172
+ ['og:locale', og.locale],
173
+ ['og:type', og.type],
174
+ ['og:article:published_time', og.publishedTime],
175
+ ['og:article:modified_time', og.modifiedTime],
176
+ ];
177
+
178
+ for (const [property, content] of simpleProps) {
179
+ if (content) {
180
+ elements.push({ tag: 'meta', attrs: { property, content } });
181
+ }
182
+ }
183
+
184
+ // Images — normalize single object to array for uniform handling
185
+ if (og.images) {
186
+ if (typeof og.images === 'string') {
187
+ elements.push({ tag: 'meta', attrs: { property: 'og:image', content: og.images } });
188
+ } else {
189
+ const imgList = Array.isArray(og.images) ? og.images : [og.images];
190
+ for (const img of imgList) {
191
+ elements.push({ tag: 'meta', attrs: { property: 'og:image', content: img.url } });
192
+ if (img.width) {
193
+ elements.push({
194
+ tag: 'meta',
195
+ attrs: { property: 'og:image:width', content: String(img.width) },
196
+ });
197
+ }
198
+ if (img.height) {
199
+ elements.push({
200
+ tag: 'meta',
201
+ attrs: { property: 'og:image:height', content: String(img.height) },
202
+ });
203
+ }
204
+ if (img.alt) {
205
+ elements.push({ tag: 'meta', attrs: { property: 'og:image:alt', content: img.alt } });
206
+ }
207
+ }
208
+ }
209
+ }
210
+
211
+ // Videos
212
+ if (og.videos) {
213
+ for (const video of og.videos) {
214
+ elements.push({ tag: 'meta', attrs: { property: 'og:video', content: video.url } });
215
+ }
216
+ }
217
+
218
+ // Audio
219
+ if (og.audio) {
220
+ for (const audio of og.audio) {
221
+ elements.push({ tag: 'meta', attrs: { property: 'og:audio', content: audio.url } });
222
+ }
223
+ }
224
+
225
+ // Authors
226
+ if (og.authors) {
227
+ for (const author of og.authors) {
228
+ elements.push({
229
+ tag: 'meta',
230
+ attrs: { property: 'og:article:author', content: author },
231
+ });
232
+ }
233
+ }
234
+ }
235
+
236
+ function renderTwitter(tw: NonNullable<Metadata['twitter']>, elements: HeadElement[]): void {
237
+ const simpleProps: Array<[string, string | undefined]> = [
238
+ ['twitter:card', tw.card],
239
+ ['twitter:site', tw.site],
240
+ ['twitter:site:id', tw.siteId],
241
+ ['twitter:title', tw.title],
242
+ ['twitter:description', tw.description],
243
+ ['twitter:creator', tw.creator],
244
+ ['twitter:creator:id', tw.creatorId],
245
+ ];
246
+
247
+ for (const [name, content] of simpleProps) {
248
+ if (content) {
249
+ elements.push({ tag: 'meta', attrs: { name, content } });
250
+ }
251
+ }
252
+
253
+ // Images — normalize single object to array for uniform handling
254
+ if (tw.images) {
255
+ if (typeof tw.images === 'string') {
256
+ elements.push({ tag: 'meta', attrs: { name: 'twitter:image', content: tw.images } });
257
+ } else {
258
+ const imgList = Array.isArray(tw.images) ? tw.images : [tw.images];
259
+ for (const img of imgList) {
260
+ const url = typeof img === 'string' ? img : img.url;
261
+ elements.push({ tag: 'meta', attrs: { name: 'twitter:image', content: url } });
262
+ }
263
+ }
264
+ }
265
+
266
+ // Player card fields
267
+ if (tw.players) {
268
+ for (const player of tw.players) {
269
+ elements.push({ tag: 'meta', attrs: { name: 'twitter:player', content: player.playerUrl } });
270
+ if (player.width) {
271
+ elements.push({
272
+ tag: 'meta',
273
+ attrs: { name: 'twitter:player:width', content: String(player.width) },
274
+ });
275
+ }
276
+ if (player.height) {
277
+ elements.push({
278
+ tag: 'meta',
279
+ attrs: { name: 'twitter:player:height', content: String(player.height) },
280
+ });
281
+ }
282
+ if (player.streamUrl) {
283
+ elements.push({
284
+ tag: 'meta',
285
+ attrs: { name: 'twitter:player:stream', content: player.streamUrl },
286
+ });
287
+ }
288
+ }
289
+ }
290
+
291
+ // App card fields — 3 platforms × 3 attributes (name, id, url)
292
+ if (tw.app) {
293
+ const platforms: Array<[keyof NonNullable<typeof tw.app.id>, string]> = [
294
+ ['iPhone', 'iphone'],
295
+ ['iPad', 'ipad'],
296
+ ['googlePlay', 'googleplay'],
297
+ ];
298
+
299
+ // App name is shared across platforms but the spec uses per-platform names.
300
+ // Emit for each platform that has an ID.
301
+ if (tw.app.name) {
302
+ for (const [key, tag] of platforms) {
303
+ if (tw.app.id?.[key]) {
304
+ elements.push({
305
+ tag: 'meta',
306
+ attrs: { name: `twitter:app:name:${tag}`, content: tw.app.name },
307
+ });
308
+ }
309
+ }
310
+ }
311
+
312
+ for (const [key, tag] of platforms) {
313
+ const id = tw.app.id?.[key];
314
+ if (id) {
315
+ elements.push({ tag: 'meta', attrs: { name: `twitter:app:id:${tag}`, content: id } });
316
+ }
317
+ }
318
+
319
+ for (const [key, tag] of platforms) {
320
+ const url = tw.app.url?.[key];
321
+ if (url) {
322
+ elements.push({ tag: 'meta', attrs: { name: `twitter:app:url:${tag}`, content: url } });
323
+ }
324
+ }
325
+ }
326
+ }
327
+
328
+ function renderIcons(icons: NonNullable<Metadata['icons']>, elements: HeadElement[]): void {
329
+ // Icon
330
+ if (icons.icon) {
331
+ if (typeof icons.icon === 'string') {
332
+ elements.push({ tag: 'link', attrs: { rel: 'icon', href: icons.icon } });
333
+ } else if (Array.isArray(icons.icon)) {
334
+ for (const icon of icons.icon) {
335
+ const attrs: Record<string, string> = { rel: 'icon', href: icon.url };
336
+ if (icon.sizes) attrs.sizes = icon.sizes;
337
+ if (icon.type) attrs.type = icon.type;
338
+ elements.push({ tag: 'link', attrs });
339
+ }
340
+ }
341
+ }
342
+
343
+ // Shortcut
344
+ if (icons.shortcut) {
345
+ const urls = Array.isArray(icons.shortcut) ? icons.shortcut : [icons.shortcut];
346
+ for (const url of urls) {
347
+ elements.push({ tag: 'link', attrs: { rel: 'shortcut icon', href: url } });
348
+ }
349
+ }
350
+
351
+ // Apple
352
+ if (icons.apple) {
353
+ if (typeof icons.apple === 'string') {
354
+ elements.push({ tag: 'link', attrs: { rel: 'apple-touch-icon', href: icons.apple } });
355
+ } else if (Array.isArray(icons.apple)) {
356
+ for (const icon of icons.apple) {
357
+ const attrs: Record<string, string> = { rel: 'apple-touch-icon', href: icon.url };
358
+ if (icon.sizes) attrs.sizes = icon.sizes;
359
+ elements.push({ tag: 'link', attrs });
360
+ }
361
+ }
362
+ }
363
+
364
+ // Other
365
+ if (icons.other) {
366
+ for (const icon of icons.other) {
367
+ const attrs: Record<string, string> = { rel: icon.rel, href: icon.url };
368
+ if (icon.sizes) attrs.sizes = icon.sizes;
369
+ if (icon.type) attrs.type = icon.type;
370
+ elements.push({ tag: 'link', attrs });
371
+ }
372
+ }
373
+ }
374
+
375
+ function renderAlternates(
376
+ alternates: NonNullable<Metadata['alternates']>,
377
+ elements: HeadElement[]
378
+ ): void {
379
+ if (alternates.canonical) {
380
+ elements.push({ tag: 'link', attrs: { rel: 'canonical', href: alternates.canonical } });
381
+ }
382
+
383
+ if (alternates.languages) {
384
+ for (const [lang, href] of Object.entries(alternates.languages)) {
385
+ elements.push({
386
+ tag: 'link',
387
+ attrs: { rel: 'alternate', hreflang: lang, href },
388
+ });
389
+ }
390
+ }
391
+
392
+ if (alternates.media) {
393
+ for (const [media, href] of Object.entries(alternates.media)) {
394
+ elements.push({
395
+ tag: 'link',
396
+ attrs: { rel: 'alternate', media, href },
397
+ });
398
+ }
399
+ }
400
+
401
+ if (alternates.types) {
402
+ for (const [type, href] of Object.entries(alternates.types)) {
403
+ elements.push({
404
+ tag: 'link',
405
+ attrs: { rel: 'alternate', type, href },
406
+ });
407
+ }
408
+ }
409
+ }
410
+
411
+ function renderVerification(
412
+ verification: NonNullable<Metadata['verification']>,
413
+ elements: HeadElement[]
414
+ ): void {
415
+ const verificationProps: Array<[string, string | undefined]> = [
416
+ ['google-site-verification', verification.google],
417
+ ['y_key', verification.yahoo],
418
+ ['yandex-verification', verification.yandex],
419
+ ];
420
+
421
+ for (const [name, content] of verificationProps) {
422
+ if (content) {
423
+ elements.push({ tag: 'meta', attrs: { name, content } });
424
+ }
425
+ }
426
+ if (verification.other) {
427
+ for (const [name, value] of Object.entries(verification.other)) {
428
+ const content = Array.isArray(value) ? value.join(', ') : value;
429
+ elements.push({ tag: 'meta', attrs: { name, content } });
430
+ }
431
+ }
432
+ }
433
+
434
+ function renderAppleWebApp(
435
+ appleWebApp: NonNullable<Metadata['appleWebApp']>,
436
+ elements: HeadElement[]
437
+ ): void {
438
+ if (appleWebApp.capable) {
439
+ elements.push({
440
+ tag: 'meta',
441
+ attrs: { name: 'apple-mobile-web-app-capable', content: 'yes' },
442
+ });
443
+ }
444
+ if (appleWebApp.title) {
445
+ elements.push({
446
+ tag: 'meta',
447
+ attrs: { name: 'apple-mobile-web-app-title', content: appleWebApp.title },
448
+ });
449
+ }
450
+ if (appleWebApp.statusBarStyle) {
451
+ elements.push({
452
+ tag: 'meta',
453
+ attrs: {
454
+ name: 'apple-mobile-web-app-status-bar-style',
455
+ content: appleWebApp.statusBarStyle,
456
+ },
457
+ });
458
+ }
459
+ if (appleWebApp.startupImage) {
460
+ const images = Array.isArray(appleWebApp.startupImage)
461
+ ? appleWebApp.startupImage
462
+ : [{ url: appleWebApp.startupImage }];
463
+ for (const img of images) {
464
+ const url = typeof img === 'string' ? img : img.url;
465
+ const attrs: Record<string, string> = { rel: 'apple-touch-startup-image', href: url };
466
+ if (typeof img === 'object' && img.media) {
467
+ attrs.media = img.media;
468
+ }
469
+ elements.push({ tag: 'link', attrs });
470
+ }
471
+ }
472
+ }
473
+
474
+ function renderAppLinks(
475
+ appLinks: NonNullable<Metadata['appLinks']>,
476
+ elements: HeadElement[]
477
+ ): void {
478
+ const platformEntries: Array<[string, Array<Record<string, unknown>> | undefined]> = [
479
+ ['ios', appLinks.ios],
480
+ ['android', appLinks.android],
481
+ ['windows', appLinks.windows],
482
+ ['windows_phone', appLinks.windowsPhone],
483
+ ['windows_universal', appLinks.windowsUniversal],
484
+ ];
485
+
486
+ for (const [platform, entries] of platformEntries) {
487
+ if (!entries) continue;
488
+ for (const entry of entries) {
489
+ for (const [key, value] of Object.entries(entry)) {
490
+ if (value !== undefined && value !== null) {
491
+ elements.push({
492
+ tag: 'meta',
493
+ attrs: { property: `al:${platform}:${key}`, content: String(value) },
494
+ });
495
+ }
496
+ }
497
+ }
498
+ }
499
+
500
+ if (appLinks.web) {
501
+ if (appLinks.web.url) {
502
+ elements.push({
503
+ tag: 'meta',
504
+ attrs: { property: 'al:web:url', content: appLinks.web.url },
505
+ });
506
+ }
507
+ if (appLinks.web.shouldFallback !== undefined) {
508
+ elements.push({
509
+ tag: 'meta',
510
+ attrs: {
511
+ property: 'al:web:should_fallback',
512
+ content: appLinks.web.shouldFallback ? 'true' : 'false',
513
+ },
514
+ });
515
+ }
516
+ }
517
+ }
518
+
519
+ function renderItunes(itunes: NonNullable<Metadata['itunes']>, elements: HeadElement[]): void {
520
+ const parts = [`app-id=${itunes.appId}`];
521
+ if (itunes.affiliateData) parts.push(`affiliate-data=${itunes.affiliateData}`);
522
+ if (itunes.appArgument) parts.push(`app-argument=${itunes.appArgument}`);
523
+ elements.push({
524
+ tag: 'meta',
525
+ attrs: { name: 'apple-itunes-app', content: parts.join(', ') },
526
+ });
527
+ }
@@ -0,0 +1,189 @@
1
+ /**
2
+ * Metadata route classification for timber.js.
3
+ *
4
+ * Metadata routes are file-based endpoints that generate well-known URLs for
5
+ * crawlers and browsers (sitemap.xml, robots.txt, OG images, etc.).
6
+ *
7
+ * These routes run through proxy.ts but NOT through middleware.ts or access.ts —
8
+ * they are public endpoints by nature.
9
+ *
10
+ * See design/16-metadata.md §"Metadata Routes"
11
+ */
12
+
13
+ // ─── Types ───────────────────────────────────────────────────────────────────
14
+
15
+ /** Classification of a metadata route file. */
16
+ export interface MetadataRouteInfo {
17
+ /** The metadata route type. */
18
+ type: MetadataRouteType;
19
+ /** The content type to serve this route with. */
20
+ contentType: string;
21
+ /** Whether this route can appear in nested segments (not just app root). */
22
+ nestable: boolean;
23
+ }
24
+
25
+ export type MetadataRouteType =
26
+ | 'sitemap'
27
+ | 'robots'
28
+ | 'manifest'
29
+ | 'favicon'
30
+ | 'icon'
31
+ | 'opengraph-image'
32
+ | 'twitter-image'
33
+ | 'apple-icon';
34
+
35
+ // ─── Convention Table ────────────────────────────────────────────────────────
36
+
37
+ /**
38
+ * All recognized metadata route file conventions.
39
+ *
40
+ * Each entry maps a base file name (without extension) to its route info.
41
+ * The extensions determine whether the file is static or dynamic.
42
+ *
43
+ * Static extensions: .xml, .txt, .json, .png, .jpg, .ico, .svg
44
+ * Dynamic extensions: .ts, .tsx
45
+ */
46
+ export const METADATA_ROUTE_CONVENTIONS: Record<
47
+ string,
48
+ {
49
+ type: MetadataRouteType;
50
+ contentType: string;
51
+ nestable: boolean;
52
+ staticExtensions: string[];
53
+ dynamicExtensions: string[];
54
+ /** The URL path this file serves at (relative to segment). */
55
+ servePath: string;
56
+ }
57
+ > = {
58
+ 'sitemap': {
59
+ type: 'sitemap',
60
+ contentType: 'application/xml',
61
+ nestable: true,
62
+ staticExtensions: ['xml'],
63
+ dynamicExtensions: ['ts'],
64
+ servePath: 'sitemap.xml',
65
+ },
66
+ 'robots': {
67
+ type: 'robots',
68
+ contentType: 'text/plain',
69
+ nestable: false,
70
+ staticExtensions: ['txt'],
71
+ dynamicExtensions: ['ts'],
72
+ servePath: 'robots.txt',
73
+ },
74
+ 'manifest': {
75
+ type: 'manifest',
76
+ contentType: 'application/manifest+json',
77
+ nestable: false,
78
+ staticExtensions: ['json'],
79
+ dynamicExtensions: ['ts'],
80
+ servePath: 'manifest.webmanifest',
81
+ },
82
+ 'favicon': {
83
+ type: 'favicon',
84
+ contentType: 'image/x-icon',
85
+ nestable: false,
86
+ staticExtensions: ['ico'],
87
+ dynamicExtensions: [],
88
+ servePath: 'favicon.ico',
89
+ },
90
+ 'icon': {
91
+ type: 'icon',
92
+ contentType: 'image/*',
93
+ nestable: true,
94
+ staticExtensions: ['png', 'jpg', 'svg'],
95
+ dynamicExtensions: ['ts', 'tsx'],
96
+ servePath: 'icon',
97
+ },
98
+ 'opengraph-image': {
99
+ type: 'opengraph-image',
100
+ contentType: 'image/*',
101
+ nestable: true,
102
+ staticExtensions: ['png', 'jpg'],
103
+ dynamicExtensions: ['ts', 'tsx'],
104
+ servePath: 'opengraph-image',
105
+ },
106
+ 'twitter-image': {
107
+ type: 'twitter-image',
108
+ contentType: 'image/*',
109
+ nestable: true,
110
+ staticExtensions: ['png', 'jpg'],
111
+ dynamicExtensions: ['ts', 'tsx'],
112
+ servePath: 'twitter-image',
113
+ },
114
+ 'apple-icon': {
115
+ type: 'apple-icon',
116
+ contentType: 'image/*',
117
+ nestable: true,
118
+ staticExtensions: ['png'],
119
+ dynamicExtensions: ['ts', 'tsx'],
120
+ servePath: 'apple-icon',
121
+ },
122
+ };
123
+
124
+ // ─── Classification ──────────────────────────────────────────────────────────
125
+
126
+ /**
127
+ * Classify a file name as a metadata route, or return null if it's not one.
128
+ *
129
+ * @param fileName - The full file name including extension (e.g. "sitemap.xml", "icon.tsx")
130
+ * @returns Classification info, or null if not a metadata route
131
+ */
132
+ export function classifyMetadataRoute(fileName: string): MetadataRouteInfo | null {
133
+ const dotIndex = fileName.lastIndexOf('.');
134
+ if (dotIndex === -1) return null;
135
+
136
+ const baseName = fileName.slice(0, dotIndex);
137
+ const ext = fileName.slice(dotIndex + 1);
138
+
139
+ const convention = METADATA_ROUTE_CONVENTIONS[baseName];
140
+ if (!convention) return null;
141
+
142
+ const isStatic = convention.staticExtensions.includes(ext);
143
+ const isDynamic = convention.dynamicExtensions.includes(ext);
144
+
145
+ if (!isStatic && !isDynamic) return null;
146
+
147
+ return {
148
+ type: convention.type,
149
+ contentType: convention.contentType,
150
+ nestable: convention.nestable,
151
+ };
152
+ }
153
+
154
+ /**
155
+ * Get the serve path for a metadata route type.
156
+ *
157
+ * @param type - The metadata route type
158
+ * @returns The URL path fragment this route serves at
159
+ */
160
+ export function getMetadataRouteServePath(type: MetadataRouteType): string {
161
+ for (const convention of Object.values(METADATA_ROUTE_CONVENTIONS)) {
162
+ if (convention.type === type) return convention.servePath;
163
+ }
164
+ throw new Error(`[timber] Unknown metadata route type: ${type}`);
165
+ }
166
+
167
+ /**
168
+ * Get the auto-link tags to inject into <head> for metadata route files
169
+ * discovered in a segment.
170
+ *
171
+ * @param type - The metadata route type
172
+ * @param href - The resolved URL path to the metadata route
173
+ * @returns An object with tag/attrs for the <head>, or null if no auto-link
174
+ */
175
+ export function getMetadataRouteAutoLink(
176
+ type: MetadataRouteType,
177
+ href: string
178
+ ): { rel: string; href: string; type?: string } | null {
179
+ switch (type) {
180
+ case 'icon':
181
+ return { rel: 'icon', href };
182
+ case 'apple-icon':
183
+ return { rel: 'apple-touch-icon', href };
184
+ case 'manifest':
185
+ return { rel: 'manifest', href };
186
+ default:
187
+ return null;
188
+ }
189
+ }