@pylonsync/functions 0.3.240 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pylonsync/functions",
3
- "version": "0.3.240",
3
+ "version": "0.3.241",
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",
@@ -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
+ });
@@ -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(componentPath: string, base: string): string | null {
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 SOCIAL_IMAGE_EXTS) {
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 convention: auto-wire <meta og:image>/<twitter:image> from a
943
- // colocated opengraph-image.* / twitter-image.* unless the page set one.
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