@pylonsync/functions 0.3.240 → 0.3.242
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 +50 -1
- package/src/ssr-runtime.ts +93 -4
package/package.json
CHANGED
package/src/ssr-runtime.test.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { afterEach, describe, expect, test } from "bun:test";
|
|
|
4
4
|
import * as fs from "node:fs";
|
|
5
5
|
import * as os from "node:os";
|
|
6
6
|
import * as path from "node:path";
|
|
7
|
-
import { applyAutoSocialImages } from "./ssr-runtime";
|
|
7
|
+
import { applyAutoIcons, applyAutoSocialImages } from "./ssr-runtime";
|
|
8
8
|
|
|
9
9
|
// A minimal PNG: 8-byte signature + an IHDR chunk carrying width/height.
|
|
10
10
|
// `readSocialImageMeta` only reads the first 32 bytes, so a full valid
|
|
@@ -131,3 +131,52 @@ describe("opengraph-image file convention", () => {
|
|
|
131
131
|
expect(md?.openGraph?.imageSecureUrl).toBeUndefined();
|
|
132
132
|
});
|
|
133
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
|
@@ -291,6 +291,13 @@ export interface SsrMetadata {
|
|
|
291
291
|
description?: string;
|
|
292
292
|
image?: string;
|
|
293
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
|
+
};
|
|
294
301
|
}
|
|
295
302
|
|
|
296
303
|
/**
|
|
@@ -335,6 +342,21 @@ function renderMetadata(React: any, m: SsrMetadata | undefined): any {
|
|
|
335
342
|
if (tw.description != null) kids.push(el("meta", { key: "twd", name: "twitter:description", content: tw.description }));
|
|
336
343
|
if (tw.image) kids.push(el("meta", { key: "twi", name: "twitter:image", content: tw.image }));
|
|
337
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
|
+
}
|
|
338
360
|
return kids.length > 0 ? el(React.Fragment, null, ...kids) : null;
|
|
339
361
|
}
|
|
340
362
|
|
|
@@ -433,14 +455,18 @@ const SOCIAL_IMAGE_EXTS = [".png", ".jpg", ".jpeg", ".webp", ".gif", ".avif"];
|
|
|
433
455
|
/** Walk up from a page's directory to the nearest colocated
|
|
434
456
|
* `<base>.<imgext>` (Next inheritance: a closer file overrides an
|
|
435
457
|
* ancestor's). Returns the cwd-relative path WITH extension, or null. */
|
|
436
|
-
function findColocatedImage(
|
|
458
|
+
function findColocatedImage(
|
|
459
|
+
componentPath: string,
|
|
460
|
+
base: string,
|
|
461
|
+
exts: string[] = SOCIAL_IMAGE_EXTS,
|
|
462
|
+
): string | null {
|
|
437
463
|
const fs = require("node:fs");
|
|
438
464
|
const path = require("node:path");
|
|
439
465
|
const cwd = process.cwd();
|
|
440
466
|
let dir = componentPath.replace(/\\/g, "/");
|
|
441
467
|
dir = dir.includes("/") ? dir.slice(0, dir.lastIndexOf("/")) : "";
|
|
442
468
|
while (dir && dir !== "." && dir !== "/") {
|
|
443
|
-
for (const ext of
|
|
469
|
+
for (const ext of exts) {
|
|
444
470
|
if (fs.existsSync(path.join(cwd, dir, `${base}${ext}`))) {
|
|
445
471
|
return `${dir}/${base}${ext}`;
|
|
446
472
|
}
|
|
@@ -502,6 +528,8 @@ function readSocialImageMeta(relPath: string): {
|
|
|
502
528
|
: ext === ".webp" ? "image/webp"
|
|
503
529
|
: ext === ".gif" ? "image/gif"
|
|
504
530
|
: ext === ".avif" ? "image/avif"
|
|
531
|
+
: ext === ".svg" ? "image/svg+xml"
|
|
532
|
+
: ext === ".ico" ? "image/x-icon"
|
|
505
533
|
: "application/octet-stream";
|
|
506
534
|
let width: number | undefined;
|
|
507
535
|
let height: number | undefined;
|
|
@@ -556,6 +584,65 @@ function resolveRequestOrigin(headers: Record<string, string> | undefined): stri
|
|
|
556
584
|
* explicit `openGraph.image` / `twitter.image` always wins; otherwise a
|
|
557
585
|
* colocated `opengraph-image.*` (and `twitter-image.*`, falling back to
|
|
558
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
|
+
|
|
559
646
|
export function applyAutoSocialImages(
|
|
560
647
|
component: string,
|
|
561
648
|
headers: Record<string, string> | undefined,
|
|
@@ -939,9 +1026,11 @@ export async function handleRenderRoute(
|
|
|
939
1026
|
if (typeof mod.generateMetadata === "function") {
|
|
940
1027
|
metadata = await mod.generateMetadata(props);
|
|
941
1028
|
}
|
|
942
|
-
// File
|
|
943
|
-
// colocated opengraph-image.* / twitter-image
|
|
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.
|
|
944
1032
|
metadata = applyAutoSocialImages(msg.component, msg.headers, metadata);
|
|
1033
|
+
metadata = applyAutoIcons(msg.component, metadata);
|
|
945
1034
|
const metaFragment = renderMetadata(React, metadata);
|
|
946
1035
|
|
|
947
1036
|
// Resolve the layout chain. Each layout module exports a default
|