@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.
@@ -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). Provide absolute URLs. */
242
+ * `og:image` + dimensions). Relative URLs are absolutized. */
240
243
  images?: OgImage[];
241
244
  url?: string;
242
245
  type?: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pylonsync/functions",
3
- "version": "0.3.360",
3
+ "version": "0.3.362",
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",
@@ -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();
@@ -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). Provide absolute URLs. */
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;