@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 +1 -1
- package/src/ssr-runtime.test.ts +182 -0
- package/src/ssr-runtime.ts +290 -1
package/package.json
CHANGED
|
@@ -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
|
+
});
|
package/src/ssr-runtime.ts
CHANGED
|
@@ -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)
|
|
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
|