@timber-js/app 0.1.1 → 0.1.3
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/index.d.ts.map +1 -1
- package/dist/index.js +11 -7
- package/dist/index.js.map +1 -1
- package/dist/plugins/dev-server.d.ts.map +1 -1
- package/dist/plugins/entries.d.ts.map +1 -1
- package/package.json +5 -4
- package/src/adapters/cloudflare.ts +325 -0
- package/src/adapters/nitro.ts +366 -0
- package/src/adapters/types.ts +63 -0
- package/src/cache/index.ts +91 -0
- package/src/cache/redis-handler.ts +91 -0
- package/src/cache/register-cached-function.ts +99 -0
- package/src/cache/singleflight.ts +26 -0
- package/src/cache/stable-stringify.ts +21 -0
- package/src/cache/timber-cache.ts +116 -0
- package/src/cli.ts +201 -0
- package/src/client/browser-entry.ts +663 -0
- package/src/client/error-boundary.tsx +209 -0
- package/src/client/form.tsx +200 -0
- package/src/client/head.ts +61 -0
- package/src/client/history.ts +46 -0
- package/src/client/index.ts +60 -0
- package/src/client/link-navigate-interceptor.tsx +62 -0
- package/src/client/link-status-provider.tsx +40 -0
- package/src/client/link.tsx +310 -0
- package/src/client/nuqs-adapter.tsx +117 -0
- package/src/client/router-ref.ts +25 -0
- package/src/client/router.ts +563 -0
- package/src/client/segment-cache.ts +194 -0
- package/src/client/segment-context.ts +57 -0
- package/src/client/ssr-data.ts +95 -0
- package/src/client/types.ts +4 -0
- package/src/client/unload-guard.ts +34 -0
- package/src/client/use-cookie.ts +122 -0
- package/src/client/use-link-status.ts +46 -0
- package/src/client/use-navigation-pending.ts +47 -0
- package/src/client/use-params.ts +71 -0
- package/src/client/use-pathname.ts +43 -0
- package/src/client/use-query-states.ts +133 -0
- package/src/client/use-router.ts +77 -0
- package/src/client/use-search-params.ts +74 -0
- package/src/client/use-selected-layout-segment.ts +110 -0
- package/src/content/index.ts +13 -0
- package/src/cookies/define-cookie.ts +137 -0
- package/src/cookies/index.ts +9 -0
- package/src/fonts/ast.ts +359 -0
- package/src/fonts/css.ts +68 -0
- package/src/fonts/fallbacks.ts +248 -0
- package/src/fonts/google.ts +332 -0
- package/src/fonts/local.ts +177 -0
- package/src/fonts/types.ts +88 -0
- package/src/index.ts +420 -0
- package/src/plugins/adapter-build.ts +118 -0
- package/src/plugins/build-manifest.ts +323 -0
- package/src/plugins/build-report.ts +353 -0
- package/src/plugins/cache-transform.ts +199 -0
- package/src/plugins/chunks.ts +90 -0
- package/src/plugins/content.ts +136 -0
- package/src/plugins/dev-error-overlay.ts +230 -0
- package/src/plugins/dev-logs.ts +280 -0
- package/src/plugins/dev-server.ts +391 -0
- package/src/plugins/dynamic-transform.ts +161 -0
- package/src/plugins/entries.ts +214 -0
- package/src/plugins/fonts.ts +581 -0
- package/src/plugins/mdx.ts +179 -0
- package/src/plugins/react-prod.ts +56 -0
- package/src/plugins/routing.ts +419 -0
- package/src/plugins/server-action-exports.ts +220 -0
- package/src/plugins/server-bundle.ts +113 -0
- package/src/plugins/shims.ts +168 -0
- package/src/plugins/static-build.ts +207 -0
- package/src/routing/codegen.ts +396 -0
- package/src/routing/index.ts +14 -0
- package/src/routing/interception.ts +173 -0
- package/src/routing/scanner.ts +487 -0
- package/src/routing/status-file-lint.ts +114 -0
- package/src/routing/types.ts +100 -0
- package/src/search-params/analyze.ts +192 -0
- package/src/search-params/codecs.ts +153 -0
- package/src/search-params/create.ts +314 -0
- package/src/search-params/index.ts +23 -0
- package/src/search-params/registry.ts +31 -0
- package/src/server/access-gate.tsx +142 -0
- package/src/server/action-client.ts +473 -0
- package/src/server/action-handler.ts +325 -0
- package/src/server/actions.ts +236 -0
- package/src/server/asset-headers.ts +81 -0
- package/src/server/body-limits.ts +102 -0
- package/src/server/build-manifest.ts +234 -0
- package/src/server/canonicalize.ts +90 -0
- package/src/server/client-module-map.ts +58 -0
- package/src/server/csrf.ts +79 -0
- package/src/server/deny-renderer.ts +302 -0
- package/src/server/dev-logger.ts +419 -0
- package/src/server/dev-span-processor.ts +78 -0
- package/src/server/dev-warnings.ts +282 -0
- package/src/server/early-hints-sender.ts +55 -0
- package/src/server/early-hints.ts +142 -0
- package/src/server/error-boundary-wrapper.ts +69 -0
- package/src/server/error-formatter.ts +184 -0
- package/src/server/flush.ts +182 -0
- package/src/server/form-data.ts +176 -0
- package/src/server/form-flash.ts +93 -0
- package/src/server/html-injectors.ts +445 -0
- package/src/server/index.ts +222 -0
- package/src/server/instrumentation.ts +136 -0
- package/src/server/logger.ts +145 -0
- package/src/server/manifest-status-resolver.ts +215 -0
- package/src/server/metadata-render.ts +527 -0
- package/src/server/metadata-routes.ts +189 -0
- package/src/server/metadata.ts +263 -0
- package/src/server/middleware-runner.ts +32 -0
- package/src/server/nuqs-ssr-provider.tsx +63 -0
- package/src/server/pipeline.ts +555 -0
- package/src/server/prerender.ts +139 -0
- package/src/server/primitives.ts +264 -0
- package/src/server/proxy.ts +43 -0
- package/src/server/request-context.ts +554 -0
- package/src/server/route-element-builder.ts +395 -0
- package/src/server/route-handler.ts +153 -0
- package/src/server/route-matcher.ts +316 -0
- package/src/server/rsc-entry/api-handler.ts +112 -0
- package/src/server/rsc-entry/error-renderer.ts +177 -0
- package/src/server/rsc-entry/helpers.ts +147 -0
- package/src/server/rsc-entry/index.ts +688 -0
- package/src/server/rsc-entry/ssr-bridge.ts +18 -0
- package/src/server/slot-resolver.ts +359 -0
- package/src/server/ssr-entry.ts +161 -0
- package/src/server/ssr-render.ts +200 -0
- package/src/server/status-code-resolver.ts +282 -0
- package/src/server/tracing.ts +281 -0
- package/src/server/tree-builder.ts +354 -0
- package/src/server/types.ts +150 -0
- package/src/shims/font-google.ts +67 -0
- package/src/shims/headers.ts +11 -0
- package/src/shims/image.ts +48 -0
- package/src/shims/link.ts +9 -0
- package/src/shims/navigation-client.ts +52 -0
- package/src/shims/navigation.ts +31 -0
- package/src/shims/server-only-noop.js +5 -0
- package/src/utils/directive-parser.ts +529 -0
- package/src/utils/format.ts +10 -0
- 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
|
+
}
|