@pylonsync/functions 0.3.360 → 0.3.362
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/ssr-runtime.d.ts +4 -1
- package/package.json +1 -1
- package/src/ssr-runtime.test.ts +83 -0
- package/src/ssr-runtime.ts +95 -1
package/dist/ssr-runtime.d.ts
CHANGED
|
@@ -226,6 +226,9 @@ export interface SsrMetadata {
|
|
|
226
226
|
openGraph?: {
|
|
227
227
|
title?: string;
|
|
228
228
|
description?: string;
|
|
229
|
+
/** `og:image`. A root-relative path (`/og.png`) is resolved against
|
|
230
|
+
* the request origin before it's emitted — crawlers require an
|
|
231
|
+
* absolute URL and Twitter drops the card without one. */
|
|
229
232
|
image?: string;
|
|
230
233
|
/** `og:image:secure_url` — set automatically to the https image URL. */
|
|
231
234
|
imageSecureUrl?: string;
|
|
@@ -236,7 +239,7 @@ export interface SsrMetadata {
|
|
|
236
239
|
imageHeight?: number;
|
|
237
240
|
imageAlt?: string;
|
|
238
241
|
/** Additional images beyond the primary `image` (each emits its own
|
|
239
|
-
* `og:image` + dimensions).
|
|
242
|
+
* `og:image` + dimensions). Relative URLs are absolutized. */
|
|
240
243
|
images?: OgImage[];
|
|
241
244
|
url?: string;
|
|
242
245
|
type?: string;
|
package/package.json
CHANGED
package/src/ssr-runtime.test.ts
CHANGED
|
@@ -392,6 +392,89 @@ describe("opengraph-image file convention", () => {
|
|
|
392
392
|
});
|
|
393
393
|
});
|
|
394
394
|
|
|
395
|
+
/**
|
|
396
|
+
* Relative social URLs. Facebook and Slack resolve them; Twitter drops
|
|
397
|
+
* the image and ships a grey box, so a card looks fine everywhere the
|
|
398
|
+
* author checked and broken where it mattered.
|
|
399
|
+
*/
|
|
400
|
+
describe("absolutizing author-supplied social URLs", () => {
|
|
401
|
+
const withOrigin = <T,>(fn: () => T): T => {
|
|
402
|
+
const prev = process.env.PYLON_PUBLIC_URL;
|
|
403
|
+
process.env.PYLON_PUBLIC_URL = "https://www.example.com";
|
|
404
|
+
try {
|
|
405
|
+
return fn();
|
|
406
|
+
} finally {
|
|
407
|
+
if (prev === undefined) delete process.env.PYLON_PUBLIC_URL;
|
|
408
|
+
else process.env.PYLON_PUBLIC_URL = prev;
|
|
409
|
+
}
|
|
410
|
+
};
|
|
411
|
+
const apply = (md: any, url = "/") =>
|
|
412
|
+
withOrigin(() =>
|
|
413
|
+
applyAutoSocialImages("app/page", { host: "www.example.com" }, md, url),
|
|
414
|
+
);
|
|
415
|
+
|
|
416
|
+
test("a root-relative og:image becomes absolute", () => {
|
|
417
|
+
const md = apply({
|
|
418
|
+
openGraph: { image: "/marketing/og.png" },
|
|
419
|
+
twitter: { card: "summary_large_image", image: "/marketing/og.png" },
|
|
420
|
+
});
|
|
421
|
+
expect(md?.openGraph?.image).toBe("https://www.example.com/marketing/og.png");
|
|
422
|
+
expect(md?.twitter?.image).toBe("https://www.example.com/marketing/og.png");
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
test("an https origin also fills og:image:secure_url", () => {
|
|
426
|
+
const md = apply({ openGraph: { image: "/og.png" } });
|
|
427
|
+
expect(md?.openGraph?.imageSecureUrl).toBe("https://www.example.com/og.png");
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
test("an absolute URL is left exactly as authored", () => {
|
|
431
|
+
const md = apply({
|
|
432
|
+
openGraph: { image: "https://cdn.other.test/a.png" },
|
|
433
|
+
twitter: { image: "https://cdn.other.test/a.png" },
|
|
434
|
+
});
|
|
435
|
+
expect(md?.openGraph?.image).toBe("https://cdn.other.test/a.png");
|
|
436
|
+
expect(md?.twitter?.image).toBe("https://cdn.other.test/a.png");
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
test("a data: URL is left alone", () => {
|
|
440
|
+
const md = apply({ openGraph: { image: "data:image/png;base64,iVBOR" } });
|
|
441
|
+
expect(md?.openGraph?.image).toBe("data:image/png;base64,iVBOR");
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
test("a protocol-relative URL picks up the origin's scheme", () => {
|
|
445
|
+
const md = apply({ openGraph: { image: "//cdn.other.test/a.png" } });
|
|
446
|
+
expect(md?.openGraph?.image).toBe("https://cdn.other.test/a.png");
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
test("a path-relative URL resolves against the request path", () => {
|
|
450
|
+
const md = apply({ openGraph: { image: "og.png" } }, "/blog/post-1");
|
|
451
|
+
expect(md?.openGraph?.image).toBe("https://www.example.com/blog/og.png");
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
test("og:url and canonical are absolutized too", () => {
|
|
455
|
+
const md = apply({
|
|
456
|
+
canonical: "/pricing",
|
|
457
|
+
openGraph: { image: "/og.png", url: "/pricing" },
|
|
458
|
+
});
|
|
459
|
+
expect(md?.canonical).toBe("https://www.example.com/pricing");
|
|
460
|
+
expect(md?.openGraph?.url).toBe("https://www.example.com/pricing");
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
test("every entry in an images list is absolutized", () => {
|
|
464
|
+
const md = apply({
|
|
465
|
+
openGraph: { images: [{ url: "/a.png" }, { url: "https://x.test/b.png" }] },
|
|
466
|
+
});
|
|
467
|
+
expect(md?.openGraph?.images?.[0]?.url).toBe("https://www.example.com/a.png");
|
|
468
|
+
expect(md?.openGraph?.images?.[1]?.url).toBe("https://x.test/b.png");
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
test("metadata with no social URLs is untouched", () => {
|
|
472
|
+
const md = apply({ title: "Hi" });
|
|
473
|
+
expect(md?.title).toBe("Hi");
|
|
474
|
+
expect(md?.openGraph).toBeUndefined();
|
|
475
|
+
});
|
|
476
|
+
});
|
|
477
|
+
|
|
395
478
|
describe("icon / apple-icon / favicon file convention", () => {
|
|
396
479
|
test("auto-wires <link rel=icon> + apple-touch-icon (relative URL + sizes)", () => {
|
|
397
480
|
const dir = makeApp();
|
package/src/ssr-runtime.ts
CHANGED
|
@@ -496,6 +496,9 @@ export interface SsrMetadata {
|
|
|
496
496
|
openGraph?: {
|
|
497
497
|
title?: string;
|
|
498
498
|
description?: string;
|
|
499
|
+
/** `og:image`. A root-relative path (`/og.png`) is resolved against
|
|
500
|
+
* the request origin before it's emitted — crawlers require an
|
|
501
|
+
* absolute URL and Twitter drops the card without one. */
|
|
499
502
|
image?: string;
|
|
500
503
|
/** `og:image:secure_url` — set automatically to the https image URL. */
|
|
501
504
|
imageSecureUrl?: string;
|
|
@@ -506,7 +509,7 @@ export interface SsrMetadata {
|
|
|
506
509
|
imageHeight?: number;
|
|
507
510
|
imageAlt?: string;
|
|
508
511
|
/** Additional images beyond the primary `image` (each emits its own
|
|
509
|
-
* `og:image` + dimensions).
|
|
512
|
+
* `og:image` + dimensions). Relative URLs are absolutized. */
|
|
510
513
|
images?: OgImage[];
|
|
511
514
|
url?: string;
|
|
512
515
|
type?: string;
|
|
@@ -1134,12 +1137,103 @@ export function applyAutoIcons(
|
|
|
1134
1137
|
return out;
|
|
1135
1138
|
}
|
|
1136
1139
|
|
|
1140
|
+
/**
|
|
1141
|
+
* Resolve a possibly-relative URL against the request's absolute origin.
|
|
1142
|
+
*
|
|
1143
|
+
* Returns the input unchanged when it's already absolute (`https:`,
|
|
1144
|
+
* `data:`, …), when there's no trustworthy origin to resolve against, or
|
|
1145
|
+
* when it doesn't parse. A protocol-relative `//cdn/x.png` picks up the
|
|
1146
|
+
* origin's scheme, which is what makes it valid to a crawler.
|
|
1147
|
+
*/
|
|
1148
|
+
function absolutizeUrl(
|
|
1149
|
+
value: string,
|
|
1150
|
+
origin: string,
|
|
1151
|
+
requestPath: string,
|
|
1152
|
+
): string {
|
|
1153
|
+
if (!value || !origin) return value;
|
|
1154
|
+
// Already absolute — an explicit CDN or a fully-qualified URL.
|
|
1155
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(value)) return value;
|
|
1156
|
+
try {
|
|
1157
|
+
const base = requestPath ? new URL(requestPath, origin).href : origin;
|
|
1158
|
+
return new URL(value, base).href;
|
|
1159
|
+
} catch {
|
|
1160
|
+
return value;
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
/**
|
|
1165
|
+
* Make every crawler-facing URL in a page's metadata absolute.
|
|
1166
|
+
*
|
|
1167
|
+
* Covers `og:image` (single + list), `og:url`, `twitter:image`, and the
|
|
1168
|
+
* canonical link — the tags where a relative value is accepted by some
|
|
1169
|
+
* consumers and silently dropped by others.
|
|
1170
|
+
*/
|
|
1171
|
+
function absolutizeMetadataUrls(
|
|
1172
|
+
metadata: SsrMetadata | undefined,
|
|
1173
|
+
headers: Record<string, string> | undefined,
|
|
1174
|
+
requestUrl?: string,
|
|
1175
|
+
): SsrMetadata | undefined {
|
|
1176
|
+
if (!metadata) return metadata;
|
|
1177
|
+
const origin = resolveRequestOrigin(headers);
|
|
1178
|
+
if (!origin) return metadata;
|
|
1179
|
+
const path = (requestUrl ?? "/").split("?")[0] || "/";
|
|
1180
|
+
const abs = (v: string | undefined): string | undefined =>
|
|
1181
|
+
v == null ? v : absolutizeUrl(v, origin, path);
|
|
1182
|
+
|
|
1183
|
+
const out: SsrMetadata = { ...metadata };
|
|
1184
|
+
const og = out.openGraph;
|
|
1185
|
+
if (og) {
|
|
1186
|
+
const nextOg = { ...og };
|
|
1187
|
+
if (nextOg.image) {
|
|
1188
|
+
nextOg.image = abs(nextOg.image);
|
|
1189
|
+
// og:image:secure_url must be the https form. If the author gave a
|
|
1190
|
+
// relative path and the origin is https, the absolute URL *is* it.
|
|
1191
|
+
if (!nextOg.imageSecureUrl && nextOg.image?.startsWith("https:")) {
|
|
1192
|
+
nextOg.imageSecureUrl = nextOg.image;
|
|
1193
|
+
} else if (nextOg.imageSecureUrl) {
|
|
1194
|
+
nextOg.imageSecureUrl = abs(nextOg.imageSecureUrl);
|
|
1195
|
+
}
|
|
1196
|
+
}
|
|
1197
|
+
if (Array.isArray(nextOg.images)) {
|
|
1198
|
+
nextOg.images = nextOg.images.map((img) =>
|
|
1199
|
+
img?.url
|
|
1200
|
+
? {
|
|
1201
|
+
...img,
|
|
1202
|
+
url: abs(img.url) as string,
|
|
1203
|
+
...(img.secureUrl ? { secureUrl: abs(img.secureUrl) } : {}),
|
|
1204
|
+
}
|
|
1205
|
+
: img,
|
|
1206
|
+
);
|
|
1207
|
+
}
|
|
1208
|
+
if (nextOg.url) nextOg.url = abs(nextOg.url);
|
|
1209
|
+
out.openGraph = nextOg;
|
|
1210
|
+
}
|
|
1211
|
+
if (out.twitter?.image) {
|
|
1212
|
+
out.twitter = { ...out.twitter, image: abs(out.twitter.image) };
|
|
1213
|
+
}
|
|
1214
|
+
if (out.canonical) out.canonical = abs(out.canonical);
|
|
1215
|
+
if (out.alternates?.canonical) {
|
|
1216
|
+
out.alternates = {
|
|
1217
|
+
...out.alternates,
|
|
1218
|
+
canonical: abs(out.alternates.canonical),
|
|
1219
|
+
};
|
|
1220
|
+
}
|
|
1221
|
+
return out;
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1137
1224
|
export function applyAutoSocialImages(
|
|
1138
1225
|
component: string,
|
|
1139
1226
|
headers: Record<string, string> | undefined,
|
|
1140
1227
|
metadata: SsrMetadata | undefined,
|
|
1141
1228
|
requestUrl?: string,
|
|
1142
1229
|
): SsrMetadata | undefined {
|
|
1230
|
+
// A relative image is the single most common way a share card breaks:
|
|
1231
|
+
// the page ships `og:image="/og.png"`, Facebook and Slack resolve it,
|
|
1232
|
+
// Twitter does not, and the post goes out with an empty grey box. The
|
|
1233
|
+
// origin is already resolved here for the auto-injected images, so
|
|
1234
|
+
// resolve the author's URLs against it too.
|
|
1235
|
+
metadata = absolutizeMetadataUrls(metadata, headers, requestUrl);
|
|
1236
|
+
|
|
1143
1237
|
const hasOg = !!metadata?.openGraph?.image;
|
|
1144
1238
|
const hasTw = !!metadata?.twitter?.image;
|
|
1145
1239
|
if (hasOg && hasTw) return metadata;
|