@pylonsync/functions 0.3.238 → 0.3.240
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 +133 -0
- package/src/ssr-runtime.ts +201 -1
package/package.json
CHANGED
|
@@ -0,0 +1,133 @@
|
|
|
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 { 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
|
+
});
|
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
|
};
|
|
@@ -309,7 +317,14 @@ function renderMetadata(React: any, m: SsrMetadata | undefined): any {
|
|
|
309
317
|
if (og) {
|
|
310
318
|
if (og.title != null) kids.push(el("meta", { key: "ogt", property: "og:title", content: og.title }));
|
|
311
319
|
if (og.description != null) kids.push(el("meta", { key: "ogd", property: "og:description", content: og.description }));
|
|
312
|
-
if (og.image)
|
|
320
|
+
if (og.image) {
|
|
321
|
+
kids.push(el("meta", { key: "ogi", property: "og:image", content: og.image }));
|
|
322
|
+
if (og.imageSecureUrl) kids.push(el("meta", { key: "ogis", property: "og:image:secure_url", content: og.imageSecureUrl }));
|
|
323
|
+
if (og.imageType) kids.push(el("meta", { key: "ogit", property: "og:image:type", content: og.imageType }));
|
|
324
|
+
if (og.imageWidth != null) kids.push(el("meta", { key: "ogiw", property: "og:image:width", content: String(og.imageWidth) }));
|
|
325
|
+
if (og.imageHeight != null) kids.push(el("meta", { key: "ogih", property: "og:image:height", content: String(og.imageHeight) }));
|
|
326
|
+
if (og.imageAlt) kids.push(el("meta", { key: "ogia", property: "og:image:alt", content: og.imageAlt }));
|
|
327
|
+
}
|
|
313
328
|
if (og.url) kids.push(el("meta", { key: "ogu", property: "og:url", content: og.url }));
|
|
314
329
|
if (og.type) kids.push(el("meta", { key: "ogy", property: "og:type", content: og.type }));
|
|
315
330
|
}
|
|
@@ -402,6 +417,188 @@ function findBoundary(componentPath: string, fileName: string): string | null {
|
|
|
402
417
|
return null;
|
|
403
418
|
}
|
|
404
419
|
|
|
420
|
+
// ---------------------------------------------------------------------------
|
|
421
|
+
// Social-card image file convention (Next-style `opengraph-image.png` /
|
|
422
|
+
// `twitter-image.png` colocated with a `page.tsx`). Drop the file in a
|
|
423
|
+
// route folder and Pylon auto-emits the `<meta og:image>` (absolute URL,
|
|
424
|
+
// dimensions, type) pointing at the `/_pylon/og` asset endpoint — no
|
|
425
|
+
// metadata wiring required. An explicit `metadata.openGraph.image` always
|
|
426
|
+
// wins. Resolved fresh per render off the filesystem (same model as
|
|
427
|
+
// layouts / boundaries) so dropping a new image is picked up without a
|
|
428
|
+
// restart.
|
|
429
|
+
// ---------------------------------------------------------------------------
|
|
430
|
+
|
|
431
|
+
const SOCIAL_IMAGE_EXTS = [".png", ".jpg", ".jpeg", ".webp", ".gif", ".avif"];
|
|
432
|
+
|
|
433
|
+
/** Walk up from a page's directory to the nearest colocated
|
|
434
|
+
* `<base>.<imgext>` (Next inheritance: a closer file overrides an
|
|
435
|
+
* ancestor's). Returns the cwd-relative path WITH extension, or null. */
|
|
436
|
+
function findColocatedImage(componentPath: string, base: string): string | null {
|
|
437
|
+
const fs = require("node:fs");
|
|
438
|
+
const path = require("node:path");
|
|
439
|
+
const cwd = process.cwd();
|
|
440
|
+
let dir = componentPath.replace(/\\/g, "/");
|
|
441
|
+
dir = dir.includes("/") ? dir.slice(0, dir.lastIndexOf("/")) : "";
|
|
442
|
+
while (dir && dir !== "." && dir !== "/") {
|
|
443
|
+
for (const ext of SOCIAL_IMAGE_EXTS) {
|
|
444
|
+
if (fs.existsSync(path.join(cwd, dir, `${base}${ext}`))) {
|
|
445
|
+
return `${dir}/${base}${ext}`;
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
const slash = dir.lastIndexOf("/");
|
|
449
|
+
dir = slash >= 0 ? dir.slice(0, slash) : "";
|
|
450
|
+
}
|
|
451
|
+
return null;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/** Best-effort JPEG dimensions: scan SOF markers in the first 128KB. */
|
|
455
|
+
function readJpegSize(fs: any, fd: number): { w: number; h: number } | null {
|
|
456
|
+
const CAP = 128 * 1024;
|
|
457
|
+
const buf = Buffer.alloc(CAP);
|
|
458
|
+
const n = fs.readSync(fd, buf, 0, CAP, 0);
|
|
459
|
+
if (n < 4 || buf[0] !== 0xff || buf[1] !== 0xd8) return null;
|
|
460
|
+
let i = 2;
|
|
461
|
+
while (i + 9 < n) {
|
|
462
|
+
if (buf[i] !== 0xff) {
|
|
463
|
+
i++;
|
|
464
|
+
continue;
|
|
465
|
+
}
|
|
466
|
+
let marker = buf[i + 1];
|
|
467
|
+
while (marker === 0xff && i + 1 < n) {
|
|
468
|
+
i++;
|
|
469
|
+
marker = buf[i + 1];
|
|
470
|
+
}
|
|
471
|
+
const seg = i + 2;
|
|
472
|
+
if (seg + 2 > n) break;
|
|
473
|
+
const len = buf.readUInt16BE(seg);
|
|
474
|
+
const isSOF =
|
|
475
|
+
marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc;
|
|
476
|
+
if (isSOF) {
|
|
477
|
+
if (seg + 7 <= n) {
|
|
478
|
+
return { h: buf.readUInt16BE(seg + 3), w: buf.readUInt16BE(seg + 5) };
|
|
479
|
+
}
|
|
480
|
+
return null;
|
|
481
|
+
}
|
|
482
|
+
if (marker === 0xd9 || marker === 0xda) break; // EOI / SOS
|
|
483
|
+
i = seg + len;
|
|
484
|
+
}
|
|
485
|
+
return null;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/** Content-type + pixel dimensions + mtime for a colocated image. Dims
|
|
489
|
+
* are best-effort (PNG/GIF from the header, JPEG via SOF scan). */
|
|
490
|
+
function readSocialImageMeta(relPath: string): {
|
|
491
|
+
type: string;
|
|
492
|
+
width?: number;
|
|
493
|
+
height?: number;
|
|
494
|
+
v: number;
|
|
495
|
+
} {
|
|
496
|
+
const fs = require("node:fs");
|
|
497
|
+
const path = require("node:path");
|
|
498
|
+
const ext = relPath.slice(relPath.lastIndexOf(".")).toLowerCase();
|
|
499
|
+
const type =
|
|
500
|
+
ext === ".png" ? "image/png"
|
|
501
|
+
: ext === ".jpg" || ext === ".jpeg" ? "image/jpeg"
|
|
502
|
+
: ext === ".webp" ? "image/webp"
|
|
503
|
+
: ext === ".gif" ? "image/gif"
|
|
504
|
+
: ext === ".avif" ? "image/avif"
|
|
505
|
+
: "application/octet-stream";
|
|
506
|
+
let width: number | undefined;
|
|
507
|
+
let height: number | undefined;
|
|
508
|
+
let v = 0;
|
|
509
|
+
try {
|
|
510
|
+
const abs = path.join(process.cwd(), relPath);
|
|
511
|
+
v = Math.floor(fs.statSync(abs).mtimeMs);
|
|
512
|
+
const fd = fs.openSync(abs, "r");
|
|
513
|
+
try {
|
|
514
|
+
const head = Buffer.alloc(32);
|
|
515
|
+
fs.readSync(fd, head, 0, 32, 0);
|
|
516
|
+
if (ext === ".png" && head.toString("latin1", 1, 4) === "PNG") {
|
|
517
|
+
width = head.readUInt32BE(16); // IHDR: 8 sig + 4 len + 4 "IHDR"
|
|
518
|
+
height = head.readUInt32BE(20);
|
|
519
|
+
} else if (ext === ".gif" && head.toString("latin1", 0, 3) === "GIF") {
|
|
520
|
+
width = head.readUInt16LE(6);
|
|
521
|
+
height = head.readUInt16LE(8);
|
|
522
|
+
} else if (ext === ".jpg" || ext === ".jpeg") {
|
|
523
|
+
const d = readJpegSize(fs, fd);
|
|
524
|
+
if (d) {
|
|
525
|
+
width = d.w;
|
|
526
|
+
height = d.h;
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
} finally {
|
|
530
|
+
fs.closeSync(fd);
|
|
531
|
+
}
|
|
532
|
+
} catch {
|
|
533
|
+
/* dims/mtime are best-effort */
|
|
534
|
+
}
|
|
535
|
+
return { type, width, height, v };
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/** Absolute origin for OG URLs (crawlers require absolute). Prefers the
|
|
539
|
+
* request Host (works for any custom domain the app serves), falling
|
|
540
|
+
* back to PYLON_PUBLIC_URL. Empty string → relative (dev last resort). */
|
|
541
|
+
function resolveRequestOrigin(headers: Record<string, string> | undefined): string {
|
|
542
|
+
const host = headers?.["host"];
|
|
543
|
+
if (host) {
|
|
544
|
+
const proto =
|
|
545
|
+
headers?.["x-forwarded-proto"] ||
|
|
546
|
+
(/^(localhost|127\.|\[?::1|0\.0\.0\.0)/.test(host) ? "http" : "https");
|
|
547
|
+
return `${proto}://${host}`;
|
|
548
|
+
}
|
|
549
|
+
const env = ((globalThis as any).process?.env?.PYLON_PUBLIC_URL || "")
|
|
550
|
+
.trim()
|
|
551
|
+
.replace(/\/+$/, "");
|
|
552
|
+
return env;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/** Merge auto-discovered social-card images into a page's metadata. An
|
|
556
|
+
* explicit `openGraph.image` / `twitter.image` always wins; otherwise a
|
|
557
|
+
* colocated `opengraph-image.*` (and `twitter-image.*`, falling back to
|
|
558
|
+
* the og file) is wired in with absolute URL + dimensions. */
|
|
559
|
+
export function applyAutoSocialImages(
|
|
560
|
+
component: string,
|
|
561
|
+
headers: Record<string, string> | undefined,
|
|
562
|
+
metadata: SsrMetadata | undefined,
|
|
563
|
+
): SsrMetadata | undefined {
|
|
564
|
+
const hasOg = !!metadata?.openGraph?.image;
|
|
565
|
+
const hasTw = !!metadata?.twitter?.image;
|
|
566
|
+
if (hasOg && hasTw) return metadata;
|
|
567
|
+
|
|
568
|
+
const ogFile = hasOg ? null : findColocatedImage(component, "opengraph-image");
|
|
569
|
+
const twFile = hasTw
|
|
570
|
+
? null
|
|
571
|
+
: findColocatedImage(component, "twitter-image") ?? ogFile;
|
|
572
|
+
if (!ogFile && !twFile) return metadata;
|
|
573
|
+
|
|
574
|
+
const origin = resolveRequestOrigin(headers);
|
|
575
|
+
const urlFor = (rel: string, v: number): string =>
|
|
576
|
+
`${origin}/_pylon/og?src=${encodeURIComponent(rel)}${v ? `&v=${v}` : ""}`;
|
|
577
|
+
const out: SsrMetadata = { ...(metadata ?? {}) };
|
|
578
|
+
|
|
579
|
+
if (ogFile && !hasOg) {
|
|
580
|
+
const m = readSocialImageMeta(ogFile);
|
|
581
|
+
const url = urlFor(ogFile, m.v);
|
|
582
|
+
out.openGraph = {
|
|
583
|
+
...(out.openGraph ?? {}),
|
|
584
|
+
image: url,
|
|
585
|
+
imageType: m.type,
|
|
586
|
+
...(m.width ? { imageWidth: m.width } : {}),
|
|
587
|
+
...(m.height ? { imageHeight: m.height } : {}),
|
|
588
|
+
...(url.startsWith("https:") ? { imageSecureUrl: url } : {}),
|
|
589
|
+
};
|
|
590
|
+
}
|
|
591
|
+
if (twFile && !hasTw) {
|
|
592
|
+
const m = readSocialImageMeta(twFile);
|
|
593
|
+
out.twitter = {
|
|
594
|
+
card: "summary_large_image",
|
|
595
|
+
...(out.twitter ?? {}),
|
|
596
|
+
image: urlFor(twFile, m.v),
|
|
597
|
+
};
|
|
598
|
+
}
|
|
599
|
+
return out;
|
|
600
|
+
}
|
|
601
|
+
|
|
405
602
|
/**
|
|
406
603
|
* Drain a `renderToReadableStream` reader, injecting `headBlob` immediately
|
|
407
604
|
* before the first `</head>` (or, if the document has none, the blob is
|
|
@@ -742,6 +939,9 @@ export async function handleRenderRoute(
|
|
|
742
939
|
if (typeof mod.generateMetadata === "function") {
|
|
743
940
|
metadata = await mod.generateMetadata(props);
|
|
744
941
|
}
|
|
942
|
+
// File convention: auto-wire <meta og:image>/<twitter:image> from a
|
|
943
|
+
// colocated opengraph-image.* / twitter-image.* unless the page set one.
|
|
944
|
+
metadata = applyAutoSocialImages(msg.component, msg.headers, metadata);
|
|
745
945
|
const metaFragment = renderMetadata(React, metadata);
|
|
746
946
|
|
|
747
947
|
// Resolve the layout chain. Each layout module exports a default
|