@pylonsync/functions 0.3.239 → 0.3.241

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pylonsync/functions",
3
- "version": "0.3.239",
3
+ "version": "0.3.241",
4
4
  "description": "TypeScript function runtime for pylon — defines server-side queries, mutations, and actions.",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -0,0 +1,182 @@
1
+ // Tests for the Next-style `opengraph-image.png` / `twitter-image.png`
2
+ // file convention wired up in `applyAutoSocialImages`.
3
+ import { afterEach, describe, expect, test } from "bun:test";
4
+ import * as fs from "node:fs";
5
+ import * as os from "node:os";
6
+ import * as path from "node:path";
7
+ import { applyAutoIcons, applyAutoSocialImages } from "./ssr-runtime";
8
+
9
+ // A minimal PNG: 8-byte signature + an IHDR chunk carrying width/height.
10
+ // `readSocialImageMeta` only reads the first 32 bytes, so a full valid
11
+ // PNG isn't required to exercise the dimension reader.
12
+ function pngHeader(w: number, h: number): Buffer {
13
+ const b = Buffer.alloc(24);
14
+ b.write("\x89PNG\r\n\x1a\n", 0, "latin1");
15
+ b.writeUInt32BE(13, 8); // IHDR length
16
+ b.write("IHDR", 12, "latin1");
17
+ b.writeUInt32BE(w, 16);
18
+ b.writeUInt32BE(h, 20);
19
+ return b;
20
+ }
21
+
22
+ let prevCwd: string | null = null;
23
+ const tmpDirs: string[] = [];
24
+
25
+ function makeApp(): string {
26
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pylon-og-"));
27
+ tmpDirs.push(dir);
28
+ prevCwd = process.cwd();
29
+ process.chdir(dir);
30
+ return dir;
31
+ }
32
+
33
+ afterEach(() => {
34
+ if (prevCwd) {
35
+ process.chdir(prevCwd);
36
+ prevCwd = null;
37
+ }
38
+ for (const d of tmpDirs.splice(0)) {
39
+ fs.rmSync(d, { recursive: true, force: true });
40
+ }
41
+ });
42
+
43
+ describe("opengraph-image file convention", () => {
44
+ test("auto-injects og:image + twitter:image with dims from a colocated png", () => {
45
+ const dir = makeApp();
46
+ fs.mkdirSync(path.join(dir, "app", "blog"), { recursive: true });
47
+ fs.writeFileSync(
48
+ path.join(dir, "app", "blog", "opengraph-image.png"),
49
+ pngHeader(1200, 630),
50
+ );
51
+
52
+ const md = applyAutoSocialImages(
53
+ "app/blog/page",
54
+ { host: "example.com" },
55
+ undefined,
56
+ );
57
+
58
+ expect(md?.openGraph?.image).toContain(
59
+ "https://example.com/_pylon/og?src=app%2Fblog%2Fopengraph-image.png",
60
+ );
61
+ expect(md?.openGraph?.imageType).toBe("image/png");
62
+ expect(md?.openGraph?.imageWidth).toBe(1200);
63
+ expect(md?.openGraph?.imageHeight).toBe(630);
64
+ expect(md?.openGraph?.imageSecureUrl).toBe(md?.openGraph?.image);
65
+ // Twitter falls back to the og image + a large-summary card.
66
+ expect(md?.twitter?.card).toBe("summary_large_image");
67
+ expect(md?.twitter?.image).toContain(
68
+ "/_pylon/og?src=app%2Fblog%2Fopengraph-image.png",
69
+ );
70
+ });
71
+
72
+ test("inherits the nearest ancestor image (root app/opengraph-image.png)", () => {
73
+ const dir = makeApp();
74
+ fs.mkdirSync(path.join(dir, "app", "deep", "nested"), { recursive: true });
75
+ fs.writeFileSync(
76
+ path.join(dir, "app", "opengraph-image.png"),
77
+ pngHeader(800, 418),
78
+ );
79
+
80
+ const md = applyAutoSocialImages(
81
+ "app/deep/nested/page",
82
+ { host: "x.test" },
83
+ undefined,
84
+ );
85
+ expect(md?.openGraph?.image).toContain(
86
+ "/_pylon/og?src=app%2Fopengraph-image.png",
87
+ );
88
+ expect(md?.openGraph?.imageWidth).toBe(800);
89
+ });
90
+
91
+ test("a closer image overrides an ancestor", () => {
92
+ const dir = makeApp();
93
+ fs.mkdirSync(path.join(dir, "app", "blog"), { recursive: true });
94
+ fs.writeFileSync(path.join(dir, "app", "opengraph-image.png"), pngHeader(1, 1));
95
+ fs.writeFileSync(
96
+ path.join(dir, "app", "blog", "opengraph-image.png"),
97
+ pngHeader(1200, 630),
98
+ );
99
+ const md = applyAutoSocialImages("app/blog/page", { host: "x.test" }, undefined);
100
+ expect(md?.openGraph?.image).toContain(
101
+ "/_pylon/og?src=app%2Fblog%2Fopengraph-image.png",
102
+ );
103
+ expect(md?.openGraph?.imageWidth).toBe(1200);
104
+ });
105
+
106
+ test("explicit metadata.openGraph.image always wins", () => {
107
+ const dir = makeApp();
108
+ fs.mkdirSync(path.join(dir, "app"), { recursive: true });
109
+ fs.writeFileSync(path.join(dir, "app", "opengraph-image.png"), pngHeader(1, 1));
110
+ const md = applyAutoSocialImages(
111
+ "app/page",
112
+ { host: "x.test" },
113
+ { openGraph: { image: "https://cdn.example/custom.png" } },
114
+ );
115
+ expect(md?.openGraph?.image).toBe("https://cdn.example/custom.png");
116
+ });
117
+
118
+ test("no colocated image leaves metadata untouched", () => {
119
+ makeApp(); // empty cwd, no app/ image
120
+ const input = { title: "Hello" };
121
+ const md = applyAutoSocialImages("app/page", { host: "x.test" }, input);
122
+ expect(md).toEqual(input);
123
+ });
124
+
125
+ test("localhost host yields an http (non-secure) absolute URL", () => {
126
+ const dir = makeApp();
127
+ fs.mkdirSync(path.join(dir, "app"), { recursive: true });
128
+ fs.writeFileSync(path.join(dir, "app", "opengraph-image.png"), pngHeader(10, 10));
129
+ const md = applyAutoSocialImages("app/page", { host: "localhost:4321" }, undefined);
130
+ expect(md?.openGraph?.image).toContain("http://localhost:4321/_pylon/og?src=");
131
+ expect(md?.openGraph?.imageSecureUrl).toBeUndefined();
132
+ });
133
+ });
134
+
135
+ describe("icon / apple-icon / favicon file convention", () => {
136
+ test("auto-wires <link rel=icon> + apple-touch-icon (relative URL + sizes)", () => {
137
+ const dir = makeApp();
138
+ fs.mkdirSync(path.join(dir, "app"), { recursive: true });
139
+ fs.writeFileSync(path.join(dir, "app", "icon.png"), pngHeader(512, 512));
140
+ fs.writeFileSync(path.join(dir, "app", "apple-icon.png"), pngHeader(180, 180));
141
+
142
+ const md = applyAutoIcons("app/page", undefined);
143
+ expect(md?.icons?.icon?.url).toContain("/_pylon/og?src=app%2Ficon.png");
144
+ expect(md?.icons?.icon?.url.startsWith("/")).toBe(true); // relative
145
+ expect(md?.icons?.icon?.type).toBe("image/png");
146
+ expect(md?.icons?.icon?.sizes).toBe("512x512");
147
+ expect(md?.icons?.apple?.url).toContain("/_pylon/og?src=app%2Fapple-icon.png");
148
+ expect(md?.icons?.apple?.sizes).toBe("180x180");
149
+ });
150
+
151
+ test("svg icon gets sizes=any; inherits from a parent folder", () => {
152
+ const dir = makeApp();
153
+ fs.mkdirSync(path.join(dir, "app", "blog"), { recursive: true });
154
+ fs.writeFileSync(path.join(dir, "app", "icon.svg"), "<svg/>");
155
+ const md = applyAutoIcons("app/blog/page", undefined);
156
+ expect(md?.icons?.icon?.url).toContain("/_pylon/og?src=app%2Ficon.svg");
157
+ expect(md?.icons?.icon?.type).toBe("image/svg+xml");
158
+ expect(md?.icons?.icon?.sizes).toBe("any");
159
+ });
160
+
161
+ test("favicon.ico is the icon fallback", () => {
162
+ const dir = makeApp();
163
+ fs.mkdirSync(path.join(dir, "app"), { recursive: true });
164
+ fs.writeFileSync(path.join(dir, "app", "favicon.ico"), Buffer.alloc(8));
165
+ const md = applyAutoIcons("app/page", undefined);
166
+ expect(md?.icons?.icon?.url).toContain("/_pylon/og?src=app%2Ffavicon.ico");
167
+ expect(md?.icons?.icon?.type).toBe("image/x-icon");
168
+ expect(md?.icons?.icon?.sizes).toBeUndefined(); // .ico is multi-size
169
+ });
170
+
171
+ test("explicit metadata.icons wins; no file → untouched", () => {
172
+ const dir = makeApp();
173
+ fs.mkdirSync(path.join(dir, "app"), { recursive: true });
174
+ fs.writeFileSync(path.join(dir, "app", "icon.png"), pngHeader(1, 1));
175
+ const explicit = { icons: { icon: { url: "/custom.ico" } } };
176
+ expect(applyAutoIcons("app/page", explicit)?.icons?.icon?.url).toBe("/custom.ico");
177
+
178
+ makeApp(); // fresh empty cwd
179
+ const input = { title: "T" };
180
+ expect(applyAutoIcons("app/page", input)).toEqual(input);
181
+ });
182
+ });
@@ -274,6 +274,14 @@ export interface SsrMetadata {
274
274
  title?: string;
275
275
  description?: string;
276
276
  image?: string;
277
+ /** `og:image:secure_url` — set automatically to the https image URL. */
278
+ imageSecureUrl?: string;
279
+ /** `og:image:type` (e.g. "image/png"). */
280
+ imageType?: string;
281
+ /** `og:image:width` / `og:image:height` in pixels. */
282
+ imageWidth?: number;
283
+ imageHeight?: number;
284
+ imageAlt?: string;
277
285
  url?: string;
278
286
  type?: string;
279
287
  };
@@ -283,6 +291,13 @@ export interface SsrMetadata {
283
291
  description?: string;
284
292
  image?: string;
285
293
  };
294
+ /** `<link rel="icon">` / `<link rel="apple-touch-icon">`. Auto-wired
295
+ * from the app/icon.* + app/apple-icon.* + app/favicon.ico file
296
+ * conventions, or set explicitly. */
297
+ icons?: {
298
+ icon?: { url: string; type?: string; sizes?: string };
299
+ apple?: { url: string; type?: string; sizes?: string };
300
+ };
286
301
  }
287
302
 
288
303
  /**
@@ -309,7 +324,14 @@ function renderMetadata(React: any, m: SsrMetadata | undefined): any {
309
324
  if (og) {
310
325
  if (og.title != null) kids.push(el("meta", { key: "ogt", property: "og:title", content: og.title }));
311
326
  if (og.description != null) kids.push(el("meta", { key: "ogd", property: "og:description", content: og.description }));
312
- if (og.image) kids.push(el("meta", { key: "ogi", property: "og:image", content: og.image }));
327
+ if (og.image) {
328
+ kids.push(el("meta", { key: "ogi", property: "og:image", content: og.image }));
329
+ if (og.imageSecureUrl) kids.push(el("meta", { key: "ogis", property: "og:image:secure_url", content: og.imageSecureUrl }));
330
+ if (og.imageType) kids.push(el("meta", { key: "ogit", property: "og:image:type", content: og.imageType }));
331
+ if (og.imageWidth != null) kids.push(el("meta", { key: "ogiw", property: "og:image:width", content: String(og.imageWidth) }));
332
+ if (og.imageHeight != null) kids.push(el("meta", { key: "ogih", property: "og:image:height", content: String(og.imageHeight) }));
333
+ if (og.imageAlt) kids.push(el("meta", { key: "ogia", property: "og:image:alt", content: og.imageAlt }));
334
+ }
313
335
  if (og.url) kids.push(el("meta", { key: "ogu", property: "og:url", content: og.url }));
314
336
  if (og.type) kids.push(el("meta", { key: "ogy", property: "og:type", content: og.type }));
315
337
  }
@@ -320,6 +342,21 @@ function renderMetadata(React: any, m: SsrMetadata | undefined): any {
320
342
  if (tw.description != null) kids.push(el("meta", { key: "twd", name: "twitter:description", content: tw.description }));
321
343
  if (tw.image) kids.push(el("meta", { key: "twi", name: "twitter:image", content: tw.image }));
322
344
  }
345
+ const ic = m.icons;
346
+ if (ic) {
347
+ if (ic.icon) {
348
+ const a: Record<string, string> = { key: "icn", rel: "icon", href: ic.icon.url };
349
+ if (ic.icon.type) a.type = ic.icon.type;
350
+ if (ic.icon.sizes) a.sizes = ic.icon.sizes;
351
+ kids.push(el("link", a));
352
+ }
353
+ if (ic.apple) {
354
+ const a: Record<string, string> = { key: "aicn", rel: "apple-touch-icon", href: ic.apple.url };
355
+ if (ic.apple.type) a.type = ic.apple.type;
356
+ if (ic.apple.sizes) a.sizes = ic.apple.sizes;
357
+ kids.push(el("link", a));
358
+ }
359
+ }
323
360
  return kids.length > 0 ? el(React.Fragment, null, ...kids) : null;
324
361
  }
325
362
 
@@ -402,6 +439,253 @@ function findBoundary(componentPath: string, fileName: string): string | null {
402
439
  return null;
403
440
  }
404
441
 
442
+ // ---------------------------------------------------------------------------
443
+ // Social-card image file convention (Next-style `opengraph-image.png` /
444
+ // `twitter-image.png` colocated with a `page.tsx`). Drop the file in a
445
+ // route folder and Pylon auto-emits the `<meta og:image>` (absolute URL,
446
+ // dimensions, type) pointing at the `/_pylon/og` asset endpoint — no
447
+ // metadata wiring required. An explicit `metadata.openGraph.image` always
448
+ // wins. Resolved fresh per render off the filesystem (same model as
449
+ // layouts / boundaries) so dropping a new image is picked up without a
450
+ // restart.
451
+ // ---------------------------------------------------------------------------
452
+
453
+ const SOCIAL_IMAGE_EXTS = [".png", ".jpg", ".jpeg", ".webp", ".gif", ".avif"];
454
+
455
+ /** Walk up from a page's directory to the nearest colocated
456
+ * `<base>.<imgext>` (Next inheritance: a closer file overrides an
457
+ * ancestor's). Returns the cwd-relative path WITH extension, or null. */
458
+ function findColocatedImage(
459
+ componentPath: string,
460
+ base: string,
461
+ exts: string[] = SOCIAL_IMAGE_EXTS,
462
+ ): string | null {
463
+ const fs = require("node:fs");
464
+ const path = require("node:path");
465
+ const cwd = process.cwd();
466
+ let dir = componentPath.replace(/\\/g, "/");
467
+ dir = dir.includes("/") ? dir.slice(0, dir.lastIndexOf("/")) : "";
468
+ while (dir && dir !== "." && dir !== "/") {
469
+ for (const ext of exts) {
470
+ if (fs.existsSync(path.join(cwd, dir, `${base}${ext}`))) {
471
+ return `${dir}/${base}${ext}`;
472
+ }
473
+ }
474
+ const slash = dir.lastIndexOf("/");
475
+ dir = slash >= 0 ? dir.slice(0, slash) : "";
476
+ }
477
+ return null;
478
+ }
479
+
480
+ /** Best-effort JPEG dimensions: scan SOF markers in the first 128KB. */
481
+ function readJpegSize(fs: any, fd: number): { w: number; h: number } | null {
482
+ const CAP = 128 * 1024;
483
+ const buf = Buffer.alloc(CAP);
484
+ const n = fs.readSync(fd, buf, 0, CAP, 0);
485
+ if (n < 4 || buf[0] !== 0xff || buf[1] !== 0xd8) return null;
486
+ let i = 2;
487
+ while (i + 9 < n) {
488
+ if (buf[i] !== 0xff) {
489
+ i++;
490
+ continue;
491
+ }
492
+ let marker = buf[i + 1];
493
+ while (marker === 0xff && i + 1 < n) {
494
+ i++;
495
+ marker = buf[i + 1];
496
+ }
497
+ const seg = i + 2;
498
+ if (seg + 2 > n) break;
499
+ const len = buf.readUInt16BE(seg);
500
+ const isSOF =
501
+ marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc;
502
+ if (isSOF) {
503
+ if (seg + 7 <= n) {
504
+ return { h: buf.readUInt16BE(seg + 3), w: buf.readUInt16BE(seg + 5) };
505
+ }
506
+ return null;
507
+ }
508
+ if (marker === 0xd9 || marker === 0xda) break; // EOI / SOS
509
+ i = seg + len;
510
+ }
511
+ return null;
512
+ }
513
+
514
+ /** Content-type + pixel dimensions + mtime for a colocated image. Dims
515
+ * are best-effort (PNG/GIF from the header, JPEG via SOF scan). */
516
+ function readSocialImageMeta(relPath: string): {
517
+ type: string;
518
+ width?: number;
519
+ height?: number;
520
+ v: number;
521
+ } {
522
+ const fs = require("node:fs");
523
+ const path = require("node:path");
524
+ const ext = relPath.slice(relPath.lastIndexOf(".")).toLowerCase();
525
+ const type =
526
+ ext === ".png" ? "image/png"
527
+ : ext === ".jpg" || ext === ".jpeg" ? "image/jpeg"
528
+ : ext === ".webp" ? "image/webp"
529
+ : ext === ".gif" ? "image/gif"
530
+ : ext === ".avif" ? "image/avif"
531
+ : ext === ".svg" ? "image/svg+xml"
532
+ : ext === ".ico" ? "image/x-icon"
533
+ : "application/octet-stream";
534
+ let width: number | undefined;
535
+ let height: number | undefined;
536
+ let v = 0;
537
+ try {
538
+ const abs = path.join(process.cwd(), relPath);
539
+ v = Math.floor(fs.statSync(abs).mtimeMs);
540
+ const fd = fs.openSync(abs, "r");
541
+ try {
542
+ const head = Buffer.alloc(32);
543
+ fs.readSync(fd, head, 0, 32, 0);
544
+ if (ext === ".png" && head.toString("latin1", 1, 4) === "PNG") {
545
+ width = head.readUInt32BE(16); // IHDR: 8 sig + 4 len + 4 "IHDR"
546
+ height = head.readUInt32BE(20);
547
+ } else if (ext === ".gif" && head.toString("latin1", 0, 3) === "GIF") {
548
+ width = head.readUInt16LE(6);
549
+ height = head.readUInt16LE(8);
550
+ } else if (ext === ".jpg" || ext === ".jpeg") {
551
+ const d = readJpegSize(fs, fd);
552
+ if (d) {
553
+ width = d.w;
554
+ height = d.h;
555
+ }
556
+ }
557
+ } finally {
558
+ fs.closeSync(fd);
559
+ }
560
+ } catch {
561
+ /* dims/mtime are best-effort */
562
+ }
563
+ return { type, width, height, v };
564
+ }
565
+
566
+ /** Absolute origin for OG URLs (crawlers require absolute). Prefers the
567
+ * request Host (works for any custom domain the app serves), falling
568
+ * back to PYLON_PUBLIC_URL. Empty string → relative (dev last resort). */
569
+ function resolveRequestOrigin(headers: Record<string, string> | undefined): string {
570
+ const host = headers?.["host"];
571
+ if (host) {
572
+ const proto =
573
+ headers?.["x-forwarded-proto"] ||
574
+ (/^(localhost|127\.|\[?::1|0\.0\.0\.0)/.test(host) ? "http" : "https");
575
+ return `${proto}://${host}`;
576
+ }
577
+ const env = ((globalThis as any).process?.env?.PYLON_PUBLIC_URL || "")
578
+ .trim()
579
+ .replace(/\/+$/, "");
580
+ return env;
581
+ }
582
+
583
+ /** Merge auto-discovered social-card images into a page's metadata. An
584
+ * explicit `openGraph.image` / `twitter.image` always wins; otherwise a
585
+ * colocated `opengraph-image.*` (and `twitter-image.*`, falling back to
586
+ * the og file) is wired in with absolute URL + dimensions. */
587
+ // Icon file conventions. `icon.*` → <link rel="icon">; `apple-icon.*` →
588
+ // <link rel="apple-touch-icon">; `favicon.ico` is the legacy fallback for
589
+ // the icon link. Unlike og:image, icon links use a RELATIVE URL (resolved
590
+ // same-origin by the browser) so no request origin is needed.
591
+ const ICON_EXTS = [".png", ".svg", ".ico", ".jpg", ".jpeg"];
592
+ const APPLE_ICON_EXTS = [".png", ".jpg", ".jpeg"];
593
+
594
+ /** `sizes` attribute for an icon link: "any" for vector SVG, "WxH" for a
595
+ * raster with known dimensions, omitted for .ico (multi-size). */
596
+ function iconSizes(rel: string, m: { width?: number; height?: number }): string | undefined {
597
+ if (rel.toLowerCase().endsWith(".svg")) return "any";
598
+ if (m.width && m.height) return `${m.width}x${m.height}`;
599
+ return undefined;
600
+ }
601
+
602
+ /** Merge auto-discovered favicons (icon.* / apple-icon.* / favicon.ico)
603
+ * into a page's metadata. Explicit `metadata.icons.*` wins. */
604
+ export function applyAutoIcons(
605
+ component: string,
606
+ metadata: SsrMetadata | undefined,
607
+ ): SsrMetadata | undefined {
608
+ const hasIcon = !!metadata?.icons?.icon;
609
+ const hasApple = !!metadata?.icons?.apple;
610
+ if (hasIcon && hasApple) return metadata;
611
+
612
+ const iconFile = hasIcon
613
+ ? null
614
+ : findColocatedImage(component, "icon", ICON_EXTS) ??
615
+ findColocatedImage(component, "favicon", [".ico"]);
616
+ const appleFile = hasApple
617
+ ? null
618
+ : findColocatedImage(component, "apple-icon", APPLE_ICON_EXTS);
619
+ if (!iconFile && !appleFile) return metadata;
620
+
621
+ const linkFor = (rel: string, v: number): string =>
622
+ `/_pylon/og?src=${encodeURIComponent(rel)}${v ? `&v=${v}` : ""}`;
623
+ const out: SsrMetadata = { ...(metadata ?? {}) };
624
+ out.icons = { ...(out.icons ?? {}) };
625
+
626
+ if (iconFile && !hasIcon) {
627
+ const m = readSocialImageMeta(iconFile);
628
+ const sizes = iconSizes(iconFile, m);
629
+ out.icons.icon = {
630
+ url: linkFor(iconFile, m.v),
631
+ type: m.type,
632
+ ...(sizes ? { sizes } : {}),
633
+ };
634
+ }
635
+ if (appleFile && !hasApple) {
636
+ const m = readSocialImageMeta(appleFile);
637
+ out.icons.apple = {
638
+ url: linkFor(appleFile, m.v),
639
+ type: m.type,
640
+ ...(m.width && m.height ? { sizes: `${m.width}x${m.height}` } : {}),
641
+ };
642
+ }
643
+ return out;
644
+ }
645
+
646
+ export function applyAutoSocialImages(
647
+ component: string,
648
+ headers: Record<string, string> | undefined,
649
+ metadata: SsrMetadata | undefined,
650
+ ): SsrMetadata | undefined {
651
+ const hasOg = !!metadata?.openGraph?.image;
652
+ const hasTw = !!metadata?.twitter?.image;
653
+ if (hasOg && hasTw) return metadata;
654
+
655
+ const ogFile = hasOg ? null : findColocatedImage(component, "opengraph-image");
656
+ const twFile = hasTw
657
+ ? null
658
+ : findColocatedImage(component, "twitter-image") ?? ogFile;
659
+ if (!ogFile && !twFile) return metadata;
660
+
661
+ const origin = resolveRequestOrigin(headers);
662
+ const urlFor = (rel: string, v: number): string =>
663
+ `${origin}/_pylon/og?src=${encodeURIComponent(rel)}${v ? `&v=${v}` : ""}`;
664
+ const out: SsrMetadata = { ...(metadata ?? {}) };
665
+
666
+ if (ogFile && !hasOg) {
667
+ const m = readSocialImageMeta(ogFile);
668
+ const url = urlFor(ogFile, m.v);
669
+ out.openGraph = {
670
+ ...(out.openGraph ?? {}),
671
+ image: url,
672
+ imageType: m.type,
673
+ ...(m.width ? { imageWidth: m.width } : {}),
674
+ ...(m.height ? { imageHeight: m.height } : {}),
675
+ ...(url.startsWith("https:") ? { imageSecureUrl: url } : {}),
676
+ };
677
+ }
678
+ if (twFile && !hasTw) {
679
+ const m = readSocialImageMeta(twFile);
680
+ out.twitter = {
681
+ card: "summary_large_image",
682
+ ...(out.twitter ?? {}),
683
+ image: urlFor(twFile, m.v),
684
+ };
685
+ }
686
+ return out;
687
+ }
688
+
405
689
  /**
406
690
  * Drain a `renderToReadableStream` reader, injecting `headBlob` immediately
407
691
  * before the first `</head>` (or, if the document has none, the blob is
@@ -742,6 +1026,11 @@ export async function handleRenderRoute(
742
1026
  if (typeof mod.generateMetadata === "function") {
743
1027
  metadata = await mod.generateMetadata(props);
744
1028
  }
1029
+ // File conventions: auto-wire <meta og:image>/<twitter:image> from a
1030
+ // colocated opengraph-image.* / twitter-image.*, and <link rel="icon">
1031
+ // from icon.* / apple-icon.* / favicon.ico — unless the page set them.
1032
+ metadata = applyAutoSocialImages(msg.component, msg.headers, metadata);
1033
+ metadata = applyAutoIcons(msg.component, metadata);
745
1034
  const metaFragment = renderMetadata(React, metadata);
746
1035
 
747
1036
  // Resolve the layout chain. Each layout module exports a default