@timber-js/app 0.1.23 → 0.1.25

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 (61) hide show
  1. package/dist/_chunks/{ssr-data-B2yikEEB.js → ssr-data-DLnbYpj1.js} +2 -4
  2. package/dist/_chunks/{ssr-data-B2yikEEB.js.map → ssr-data-DLnbYpj1.js.map} +1 -1
  3. package/dist/_chunks/{use-cookie-D5aS4slY.js → use-cookie-dDbpCTx-.js} +2 -2
  4. package/dist/_chunks/{use-cookie-D5aS4slY.js.map → use-cookie-dDbpCTx-.js.map} +1 -1
  5. package/dist/adapters/nitro.d.ts.map +1 -1
  6. package/dist/adapters/nitro.js +4 -3
  7. package/dist/adapters/nitro.js.map +1 -1
  8. package/dist/cli.js +1 -1
  9. package/dist/cli.js.map +1 -1
  10. package/dist/client/browser-dev.d.ts +29 -0
  11. package/dist/client/browser-dev.d.ts.map +1 -0
  12. package/dist/client/browser-links.d.ts +32 -0
  13. package/dist/client/browser-links.d.ts.map +1 -0
  14. package/dist/client/error-boundary.js +1 -1
  15. package/dist/client/index.d.ts +2 -0
  16. package/dist/client/index.d.ts.map +1 -1
  17. package/dist/client/index.js +150 -122
  18. package/dist/client/index.js.map +1 -1
  19. package/dist/client/navigation-context.d.ts +52 -0
  20. package/dist/client/navigation-context.d.ts.map +1 -0
  21. package/dist/client/router.d.ts.map +1 -1
  22. package/dist/client/transition-root.d.ts +54 -0
  23. package/dist/client/transition-root.d.ts.map +1 -0
  24. package/dist/client/use-params.d.ts +35 -25
  25. package/dist/client/use-params.d.ts.map +1 -1
  26. package/dist/client/use-pathname.d.ts +11 -4
  27. package/dist/client/use-pathname.d.ts.map +1 -1
  28. package/dist/client/use-router.d.ts +14 -0
  29. package/dist/client/use-router.d.ts.map +1 -1
  30. package/dist/cookies/index.js +2 -2
  31. package/dist/server/index.js +264 -218
  32. package/dist/server/index.js.map +1 -1
  33. package/dist/server/metadata-platform.d.ts +34 -0
  34. package/dist/server/metadata-platform.d.ts.map +1 -0
  35. package/dist/server/metadata-render.d.ts.map +1 -1
  36. package/dist/server/metadata-social.d.ts +24 -0
  37. package/dist/server/metadata-social.d.ts.map +1 -0
  38. package/dist/server/pipeline-interception.d.ts +32 -0
  39. package/dist/server/pipeline-interception.d.ts.map +1 -0
  40. package/dist/server/pipeline-metadata.d.ts +31 -0
  41. package/dist/server/pipeline-metadata.d.ts.map +1 -0
  42. package/dist/server/pipeline.d.ts.map +1 -1
  43. package/package.json +1 -1
  44. package/src/adapters/nitro.ts +9 -7
  45. package/src/cli.ts +9 -2
  46. package/src/client/browser-dev.ts +142 -0
  47. package/src/client/browser-entry.ts +73 -223
  48. package/src/client/browser-links.ts +90 -0
  49. package/src/client/index.ts +4 -0
  50. package/src/client/navigation-context.ts +118 -0
  51. package/src/client/router.ts +37 -33
  52. package/src/client/transition-root.tsx +86 -0
  53. package/src/client/use-params.ts +50 -54
  54. package/src/client/use-pathname.ts +31 -24
  55. package/src/client/use-router.ts +17 -15
  56. package/src/server/metadata-platform.ts +229 -0
  57. package/src/server/metadata-render.ts +9 -363
  58. package/src/server/metadata-social.ts +184 -0
  59. package/src/server/pipeline-interception.ts +76 -0
  60. package/src/server/pipeline-metadata.ts +90 -0
  61. package/src/server/pipeline.ts +2 -148
@@ -0,0 +1,184 @@
1
+ /**
2
+ * Social metadata rendering — Open Graph and Twitter Card meta tags.
3
+ *
4
+ * Extracted from metadata-render.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
+ /**
13
+ * Render Open Graph metadata into head element descriptors.
14
+ *
15
+ * Handles og:title, og:description, og:image (with dimensions/alt),
16
+ * og:video, og:audio, og:article:author, and other OG properties.
17
+ */
18
+ export function renderOpenGraph(og: NonNullable<Metadata['openGraph']>, elements: HeadElement[]): void {
19
+ const simpleProps: Array<[string, string | undefined]> = [
20
+ ['og:title', og.title],
21
+ ['og:description', og.description],
22
+ ['og:url', og.url],
23
+ ['og:site_name', og.siteName],
24
+ ['og:locale', og.locale],
25
+ ['og:type', og.type],
26
+ ['og:article:published_time', og.publishedTime],
27
+ ['og:article:modified_time', og.modifiedTime],
28
+ ];
29
+
30
+ for (const [property, content] of simpleProps) {
31
+ if (content) {
32
+ elements.push({ tag: 'meta', attrs: { property, content } });
33
+ }
34
+ }
35
+
36
+ // Images — normalize single object to array for uniform handling
37
+ if (og.images) {
38
+ if (typeof og.images === 'string') {
39
+ elements.push({ tag: 'meta', attrs: { property: 'og:image', content: og.images } });
40
+ } else {
41
+ const imgList = Array.isArray(og.images) ? og.images : [og.images];
42
+ for (const img of imgList) {
43
+ elements.push({ tag: 'meta', attrs: { property: 'og:image', content: img.url } });
44
+ if (img.width) {
45
+ elements.push({
46
+ tag: 'meta',
47
+ attrs: { property: 'og:image:width', content: String(img.width) },
48
+ });
49
+ }
50
+ if (img.height) {
51
+ elements.push({
52
+ tag: 'meta',
53
+ attrs: { property: 'og:image:height', content: String(img.height) },
54
+ });
55
+ }
56
+ if (img.alt) {
57
+ elements.push({ tag: 'meta', attrs: { property: 'og:image:alt', content: img.alt } });
58
+ }
59
+ }
60
+ }
61
+ }
62
+
63
+ // Videos
64
+ if (og.videos) {
65
+ for (const video of og.videos) {
66
+ elements.push({ tag: 'meta', attrs: { property: 'og:video', content: video.url } });
67
+ }
68
+ }
69
+
70
+ // Audio
71
+ if (og.audio) {
72
+ for (const audio of og.audio) {
73
+ elements.push({ tag: 'meta', attrs: { property: 'og:audio', content: audio.url } });
74
+ }
75
+ }
76
+
77
+ // Authors
78
+ if (og.authors) {
79
+ for (const author of og.authors) {
80
+ elements.push({
81
+ tag: 'meta',
82
+ attrs: { property: 'og:article:author', content: author },
83
+ });
84
+ }
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Render Twitter Card metadata into head element descriptors.
90
+ *
91
+ * Handles twitter:card, twitter:site, twitter:title, twitter:image,
92
+ * twitter:player, and twitter:app (per-platform name/id/url).
93
+ */
94
+ export function renderTwitter(tw: NonNullable<Metadata['twitter']>, elements: HeadElement[]): void {
95
+ const simpleProps: Array<[string, string | undefined]> = [
96
+ ['twitter:card', tw.card],
97
+ ['twitter:site', tw.site],
98
+ ['twitter:site:id', tw.siteId],
99
+ ['twitter:title', tw.title],
100
+ ['twitter:description', tw.description],
101
+ ['twitter:creator', tw.creator],
102
+ ['twitter:creator:id', tw.creatorId],
103
+ ];
104
+
105
+ for (const [name, content] of simpleProps) {
106
+ if (content) {
107
+ elements.push({ tag: 'meta', attrs: { name, content } });
108
+ }
109
+ }
110
+
111
+ // Images — normalize single object to array for uniform handling
112
+ if (tw.images) {
113
+ if (typeof tw.images === 'string') {
114
+ elements.push({ tag: 'meta', attrs: { name: 'twitter:image', content: tw.images } });
115
+ } else {
116
+ const imgList = Array.isArray(tw.images) ? tw.images : [tw.images];
117
+ for (const img of imgList) {
118
+ const url = typeof img === 'string' ? img : img.url;
119
+ elements.push({ tag: 'meta', attrs: { name: 'twitter:image', content: url } });
120
+ }
121
+ }
122
+ }
123
+
124
+ // Player card fields
125
+ if (tw.players) {
126
+ for (const player of tw.players) {
127
+ elements.push({ tag: 'meta', attrs: { name: 'twitter:player', content: player.playerUrl } });
128
+ if (player.width) {
129
+ elements.push({
130
+ tag: 'meta',
131
+ attrs: { name: 'twitter:player:width', content: String(player.width) },
132
+ });
133
+ }
134
+ if (player.height) {
135
+ elements.push({
136
+ tag: 'meta',
137
+ attrs: { name: 'twitter:player:height', content: String(player.height) },
138
+ });
139
+ }
140
+ if (player.streamUrl) {
141
+ elements.push({
142
+ tag: 'meta',
143
+ attrs: { name: 'twitter:player:stream', content: player.streamUrl },
144
+ });
145
+ }
146
+ }
147
+ }
148
+
149
+ // App card fields — 3 platforms × 3 attributes (name, id, url)
150
+ if (tw.app) {
151
+ const platforms: Array<[keyof NonNullable<typeof tw.app.id>, string]> = [
152
+ ['iPhone', 'iphone'],
153
+ ['iPad', 'ipad'],
154
+ ['googlePlay', 'googleplay'],
155
+ ];
156
+
157
+ // App name is shared across platforms but the spec uses per-platform names.
158
+ // Emit for each platform that has an ID.
159
+ if (tw.app.name) {
160
+ for (const [key, tag] of platforms) {
161
+ if (tw.app.id?.[key]) {
162
+ elements.push({
163
+ tag: 'meta',
164
+ attrs: { name: `twitter:app:name:${tag}`, content: tw.app.name },
165
+ });
166
+ }
167
+ }
168
+ }
169
+
170
+ for (const [key, tag] of platforms) {
171
+ const id = tw.app.id?.[key];
172
+ if (id) {
173
+ elements.push({ tag: 'meta', attrs: { name: `twitter:app:id:${tag}`, content: id } });
174
+ }
175
+ }
176
+
177
+ for (const [key, tag] of platforms) {
178
+ const url = tw.app.url?.[key];
179
+ if (url) {
180
+ elements.push({ tag: 'meta', attrs: { name: `twitter:app:url:${tag}`, content: url } });
181
+ }
182
+ }
183
+ }
184
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Interception route matching for the request pipeline.
3
+ *
4
+ * Matches target URLs against interception rewrites to support the
5
+ * modal route pattern (soft navigation intercepts).
6
+ *
7
+ * Extracted from pipeline.ts to keep files under 500 lines.
8
+ *
9
+ * See design/07-routing.md §"Intercepting Routes"
10
+ */
11
+
12
+ /** Result of a successful interception match. */
13
+ export interface InterceptionMatchResult {
14
+ /** The pathname to re-match (the source/intercepting route's parent). */
15
+ sourcePathname: string;
16
+ }
17
+
18
+ /**
19
+ * Check if an intercepting route applies for this soft navigation.
20
+ *
21
+ * Matches the target pathname against interception rewrites, constrained
22
+ * by the source URL (X-Timber-URL header — where the user navigates FROM).
23
+ *
24
+ * Returns the source pathname to re-match if interception applies, or null.
25
+ */
26
+ export function findInterceptionMatch(
27
+ targetPathname: string,
28
+ sourceUrl: string,
29
+ rewrites: import('#/routing/interception.js').InterceptionRewrite[]
30
+ ): InterceptionMatchResult | null {
31
+ for (const rewrite of rewrites) {
32
+ // Check if the source URL starts with the intercepting prefix
33
+ if (!sourceUrl.startsWith(rewrite.interceptingPrefix)) continue;
34
+
35
+ // Check if the target URL matches the intercepted pattern.
36
+ // Dynamic segments in the pattern match any single URL segment.
37
+ if (pathnameMatchesPattern(targetPathname, rewrite.interceptedPattern)) {
38
+ return { sourcePathname: rewrite.interceptingPrefix };
39
+ }
40
+ }
41
+ return null;
42
+ }
43
+
44
+ /**
45
+ * Check if a pathname matches a URL pattern with dynamic segments.
46
+ *
47
+ * Supports [param] (single segment) and [...param] (one or more segments).
48
+ * Static segments must match exactly.
49
+ */
50
+ export function pathnameMatchesPattern(pathname: string, pattern: string): boolean {
51
+ const pathParts = pathname === '/' ? [] : pathname.slice(1).split('/');
52
+ const patternParts = pattern === '/' ? [] : pattern.slice(1).split('/');
53
+
54
+ let pi = 0;
55
+ for (let i = 0; i < patternParts.length; i++) {
56
+ const segment = patternParts[i];
57
+
58
+ // Catch-all: [...param] or [[...param]] — matches rest of URL
59
+ if (segment.startsWith('[...') || segment.startsWith('[[...')) {
60
+ return pi < pathParts.length || segment.startsWith('[[...');
61
+ }
62
+
63
+ // Dynamic: [param] — matches any single segment
64
+ if (segment.startsWith('[') && segment.endsWith(']')) {
65
+ if (pi >= pathParts.length) return false;
66
+ pi++;
67
+ continue;
68
+ }
69
+
70
+ // Static — must match exactly
71
+ if (pi >= pathParts.length || pathParts[pi] !== segment) return false;
72
+ pi++;
73
+ }
74
+
75
+ return pi === pathParts.length;
76
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Metadata route helpers for the request pipeline.
3
+ *
4
+ * Handles serving static metadata files and serializing sitemap responses.
5
+ * Extracted from pipeline.ts to keep files under 500 lines.
6
+ *
7
+ * See design/16-metadata.md §"Metadata Routes"
8
+ */
9
+
10
+ import { readFile } from 'node:fs/promises';
11
+
12
+ /**
13
+ * Content types that are text-based and should include charset=utf-8.
14
+ * Binary formats (images) should not include charset.
15
+ */
16
+ const TEXT_CONTENT_TYPES = new Set([
17
+ 'application/xml',
18
+ 'text/plain',
19
+ 'application/json',
20
+ 'application/manifest+json',
21
+ 'image/svg+xml',
22
+ ]);
23
+
24
+ /**
25
+ * Serve a static metadata file by reading it from disk.
26
+ *
27
+ * Static metadata route files (.xml, .txt, .json, .png, .ico, .svg, etc.)
28
+ * are served as-is with the appropriate Content-Type header.
29
+ * Text files include charset=utf-8; binary files do not.
30
+ *
31
+ * See design/16-metadata.md §"Metadata Routes"
32
+ */
33
+ export async function serveStaticMetadataFile(
34
+ metaMatch: import('./route-matcher.js').MetadataRouteMatch
35
+ ): Promise<Response> {
36
+ const { contentType, file } = metaMatch;
37
+ const isText = TEXT_CONTENT_TYPES.has(contentType);
38
+
39
+ const body = await readFile(file.filePath);
40
+
41
+ const headers: Record<string, string> = {
42
+ 'Content-Type': isText ? `${contentType}; charset=utf-8` : contentType,
43
+ 'Content-Length': String(body.byteLength),
44
+ };
45
+
46
+ return new Response(body, { status: 200, headers });
47
+ }
48
+
49
+ /**
50
+ * Serialize a sitemap array to XML.
51
+ * Follows the sitemap.org protocol: https://www.sitemaps.org/protocol.html
52
+ */
53
+ export function serializeSitemap(
54
+ entries: Array<{
55
+ url: string;
56
+ lastModified?: string | Date;
57
+ changeFrequency?: string;
58
+ priority?: number;
59
+ }>
60
+ ): string {
61
+ const urls = entries
62
+ .map((e) => {
63
+ let xml = ` <url>\n <loc>${escapeXml(e.url)}</loc>`;
64
+ if (e.lastModified) {
65
+ const date = e.lastModified instanceof Date ? e.lastModified.toISOString() : e.lastModified;
66
+ xml += `\n <lastmod>${escapeXml(date)}</lastmod>`;
67
+ }
68
+ if (e.changeFrequency) {
69
+ xml += `\n <changefreq>${escapeXml(e.changeFrequency)}</changefreq>`;
70
+ }
71
+ if (e.priority !== undefined) {
72
+ xml += `\n <priority>${e.priority}</priority>`;
73
+ }
74
+ xml += '\n </url>';
75
+ return xml;
76
+ })
77
+ .join('\n');
78
+
79
+ return `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${urls}\n</urlset>`;
80
+ }
81
+
82
+ /** Escape special XML characters. */
83
+ export function escapeXml(str: string): string {
84
+ return str
85
+ .replace(/&/g, '&amp;')
86
+ .replace(/</g, '&lt;')
87
+ .replace(/>/g, '&gt;')
88
+ .replace(/"/g, '&quot;')
89
+ .replace(/'/g, '&apos;');
90
+ }
@@ -11,7 +11,6 @@
11
11
  * and design/17-logging.md §"Production Logging"
12
12
  */
13
13
 
14
- import { readFile } from 'node:fs/promises';
15
14
  import { canonicalize } from './canonicalize.js';
16
15
  import { runProxy, type ProxyExport } from './proxy.js';
17
16
  import { runMiddleware, type MiddlewareFn } from './middleware-runner.js';
@@ -47,6 +46,8 @@ import {
47
46
  } from './logger.js';
48
47
  import { callOnRequestError } from './instrumentation.js';
49
48
  import { RedirectSignal, DenySignal } from './primitives.js';
49
+ import { serveStaticMetadataFile, serializeSitemap } from './pipeline-metadata.js';
50
+ import { findInterceptionMatch } from './pipeline-interception.js';
50
51
  import type { MiddlewareContext } from './types.js';
51
52
  import type { SegmentNode } from '#/routing/types.js';
52
53
 
@@ -511,72 +512,7 @@ async function fireOnRequestError(
511
512
  );
512
513
  }
513
514
 
514
- // ─── Interception Matching ────────────────────────────────────────────────
515
515
 
516
- interface InterceptionMatchResult {
517
- /** The pathname to re-match (the source/intercepting route's parent). */
518
- sourcePathname: string;
519
- }
520
-
521
- /**
522
- * Check if an intercepting route applies for this soft navigation.
523
- *
524
- * Matches the target pathname against interception rewrites, constrained
525
- * by the source URL (X-Timber-URL header — where the user navigates FROM).
526
- *
527
- * Returns the source pathname to re-match if interception applies, or null.
528
- */
529
- function findInterceptionMatch(
530
- targetPathname: string,
531
- sourceUrl: string,
532
- rewrites: import('#/routing/interception.js').InterceptionRewrite[]
533
- ): InterceptionMatchResult | null {
534
- for (const rewrite of rewrites) {
535
- // Check if the source URL starts with the intercepting prefix
536
- if (!sourceUrl.startsWith(rewrite.interceptingPrefix)) continue;
537
-
538
- // Check if the target URL matches the intercepted pattern.
539
- // Dynamic segments in the pattern match any single URL segment.
540
- if (pathnameMatchesPattern(targetPathname, rewrite.interceptedPattern)) {
541
- return { sourcePathname: rewrite.interceptingPrefix };
542
- }
543
- }
544
- return null;
545
- }
546
-
547
- /**
548
- * Check if a pathname matches a URL pattern with dynamic segments.
549
- *
550
- * Supports [param] (single segment) and [...param] (one or more segments).
551
- * Static segments must match exactly.
552
- */
553
- function pathnameMatchesPattern(pathname: string, pattern: string): boolean {
554
- const pathParts = pathname === '/' ? [] : pathname.slice(1).split('/');
555
- const patternParts = pattern === '/' ? [] : pattern.slice(1).split('/');
556
-
557
- let pi = 0;
558
- for (let i = 0; i < patternParts.length; i++) {
559
- const segment = patternParts[i];
560
-
561
- // Catch-all: [...param] or [[...param]] — matches rest of URL
562
- if (segment.startsWith('[...') || segment.startsWith('[[...')) {
563
- return pi < pathParts.length || segment.startsWith('[[...');
564
- }
565
-
566
- // Dynamic: [param] — matches any single segment
567
- if (segment.startsWith('[') && segment.endsWith(']')) {
568
- if (pi >= pathParts.length) return false;
569
- pi++;
570
- continue;
571
- }
572
-
573
- // Static — must match exactly
574
- if (pi >= pathParts.length || pathParts[pi] !== segment) return false;
575
- pi++;
576
- }
577
-
578
- return pi === pathParts.length;
579
- }
580
516
 
581
517
  // ─── Cookie Helpers ──────────────────────────────────────────────────────
582
518
 
@@ -620,86 +556,4 @@ function ensureMutableResponse(response: Response): Response {
620
556
  }
621
557
  }
622
558
 
623
- // ─── Metadata Route Helpers ──────────────────────────────────────────────
624
559
 
625
- /**
626
- * Serialize a sitemap array to XML.
627
- * Follows the sitemap.org protocol: https://www.sitemaps.org/protocol.html
628
- */
629
- function serializeSitemap(
630
- entries: Array<{
631
- url: string;
632
- lastModified?: string | Date;
633
- changeFrequency?: string;
634
- priority?: number;
635
- }>
636
- ): string {
637
- const urls = entries
638
- .map((e) => {
639
- let xml = ` <url>\n <loc>${escapeXml(e.url)}</loc>`;
640
- if (e.lastModified) {
641
- const date = e.lastModified instanceof Date ? e.lastModified.toISOString() : e.lastModified;
642
- xml += `\n <lastmod>${escapeXml(date)}</lastmod>`;
643
- }
644
- if (e.changeFrequency) {
645
- xml += `\n <changefreq>${escapeXml(e.changeFrequency)}</changefreq>`;
646
- }
647
- if (e.priority !== undefined) {
648
- xml += `\n <priority>${e.priority}</priority>`;
649
- }
650
- xml += '\n </url>';
651
- return xml;
652
- })
653
- .join('\n');
654
-
655
- return `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${urls}\n</urlset>`;
656
- }
657
-
658
- /** Escape special XML characters. */
659
- function escapeXml(str: string): string {
660
- return str
661
- .replace(/&/g, '&amp;')
662
- .replace(/</g, '&lt;')
663
- .replace(/>/g, '&gt;')
664
- .replace(/"/g, '&quot;')
665
- .replace(/'/g, '&apos;');
666
- }
667
-
668
- // ─── Static Metadata File Serving ────────────────────────────────────────
669
-
670
- /**
671
- * Content types that are text-based and should include charset=utf-8.
672
- * Binary formats (images) should not include charset.
673
- */
674
- const TEXT_CONTENT_TYPES = new Set([
675
- 'application/xml',
676
- 'text/plain',
677
- 'application/json',
678
- 'application/manifest+json',
679
- 'image/svg+xml',
680
- ]);
681
-
682
- /**
683
- * Serve a static metadata file by reading it from disk.
684
- *
685
- * Static metadata route files (.xml, .txt, .json, .png, .ico, .svg, etc.)
686
- * are served as-is with the appropriate Content-Type header.
687
- * Text files include charset=utf-8; binary files do not.
688
- *
689
- * See design/16-metadata.md §"Metadata Routes"
690
- */
691
- async function serveStaticMetadataFile(
692
- metaMatch: import('./route-matcher.js').MetadataRouteMatch
693
- ): Promise<Response> {
694
- const { contentType, file } = metaMatch;
695
- const isText = TEXT_CONTENT_TYPES.has(contentType);
696
-
697
- const body = await readFile(file.filePath);
698
-
699
- const headers: Record<string, string> = {
700
- 'Content-Type': isText ? `${contentType}; charset=utf-8` : contentType,
701
- 'Content-Length': String(body.byteLength),
702
- };
703
-
704
- return new Response(body, { status: 200, headers });
705
- }