@pylonsync/functions 0.3.291 → 0.3.292

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.291",
3
+ "version": "0.3.292",
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",
@@ -307,6 +307,86 @@ describe("renderMetadata head-tag marking (client-nav sync)", () => {
307
307
  expect(renderMetadata(fakeReact, undefined)).toBeNull();
308
308
  expect(renderMetadata(fakeReact, {})).toBeNull();
309
309
  });
310
+
311
+ test("emits JSON-LD as an escaped application/ld+json script", () => {
312
+ const frag = renderMetadata(fakeReact, {
313
+ jsonLd: {
314
+ "@context": "https://schema.org",
315
+ "@type": "Organization",
316
+ name: "Pylon </script><x>&y",
317
+ },
318
+ });
319
+ const scripts: any[] = frag.children.filter((k: any) => k.type === "script");
320
+ expect(scripts.length).toBe(1);
321
+ expect(scripts[0].props.type).toBe("application/ld+json");
322
+ expect(scripts[0].props["data-pylon-meta"]).toBe("");
323
+ const body = scripts[0].children[0] as string;
324
+ // Breakout chars must be \u-escaped — the payload can't contain a literal
325
+ // `</script>`, `<`, `>`, or `&`.
326
+ expect(body).not.toContain("</script>");
327
+ expect(body).not.toMatch(/[<>&]/);
328
+ expect(body).toContain("\\u003c");
329
+ // …and it's still valid structured data once a parser decodes it.
330
+ const parsed = JSON.parse(body);
331
+ expect(parsed["@type"]).toBe("Organization");
332
+ expect(parsed.name).toBe("Pylon </script><x>&y");
333
+ });
334
+
335
+ test("JSON-LD array emits one script per item", () => {
336
+ const frag = renderMetadata(fakeReact, {
337
+ jsonLd: [{ "@type": "A" }, { "@type": "B" }],
338
+ });
339
+ const scripts: any[] = frag.children.filter((k: any) => k.type === "script");
340
+ expect(scripts.map((s) => JSON.parse(s.children[0])["@type"])).toEqual(["A", "B"]);
341
+ });
342
+
343
+ test("emits the extended SEO/social tags", () => {
344
+ const frag = renderMetadata(fakeReact, {
345
+ authors: ["Ada", "Grace"],
346
+ themeColor: "#0b5fff",
347
+ openGraph: {
348
+ locale: "en_US",
349
+ images: [{ url: "https://x.test/a.png", width: 1200, height: 630, alt: "A" }],
350
+ article: { author: "Ada", publishedTime: "2026-01-01", tags: ["ai", "ssr"] },
351
+ },
352
+ twitter: { card: "summary", site: "@pylon", creator: "@ada", imageAlt: "card" },
353
+ alternates: {
354
+ languages: { "en-US": "https://x.test/en", "fr-FR": "https://x.test/fr" },
355
+ },
356
+ });
357
+ const metas: any[] = frag.children.filter((k: any) => k.type === "meta");
358
+ const links: any[] = frag.children.filter((k: any) => k.type === "link");
359
+ const find = (sel: (m: any) => boolean) => metas.find(sel);
360
+
361
+ expect(
362
+ metas.filter((m) => m.props.name === "author").map((m) => m.props.content),
363
+ ).toEqual(["Ada", "Grace"]);
364
+ expect(find((m) => m.props.name === "theme-color")?.props.content).toBe("#0b5fff");
365
+ expect(find((m) => m.props.property === "og:locale")?.props.content).toBe("en_US");
366
+ expect(
367
+ find((m) => m.props.property === "og:image" && m.props.content === "https://x.test/a.png"),
368
+ ).toBeDefined();
369
+ expect(find((m) => m.props.property === "article:author")?.props.content).toBe("Ada");
370
+ expect(find((m) => m.props.property === "article:published_time")?.props.content).toBe(
371
+ "2026-01-01",
372
+ );
373
+ expect(
374
+ metas.filter((m) => m.props.property === "article:tag").map((m) => m.props.content),
375
+ ).toEqual(["ai", "ssr"]);
376
+ expect(find((m) => m.props.name === "twitter:site")?.props.content).toBe("@pylon");
377
+ expect(find((m) => m.props.name === "twitter:creator")?.props.content).toBe("@ada");
378
+ expect(find((m) => m.props.name === "twitter:image:alt")?.props.content).toBe("card");
379
+
380
+ const alts = links.filter((l) => l.props.rel === "alternate");
381
+ expect(alts.map((l) => [l.props.hrefLang, l.props.href])).toEqual([
382
+ ["en-US", "https://x.test/en"],
383
+ ["fr-FR", "https://x.test/fr"],
384
+ ]);
385
+ // Every emitted meta/link still carries the nav-swap marker.
386
+ for (const el of [...metas, ...links]) {
387
+ expect(el.props["data-pylon-meta"]).toBe("");
388
+ }
389
+ });
310
390
  });
311
391
 
312
392
  describe("buildHydrationTail — boundary hydration (#279) + strip (#270)", () => {
@@ -337,12 +337,26 @@ export function finalizeHeaders(
337
337
  * `<meta>`, so set `description`/OG in EITHER the layout OR page metadata,
338
338
  * not both, to avoid duplicate tags.
339
339
  */
340
+ /** A single OpenGraph image (for `openGraph.images` — multiple images). */
341
+ export interface OgImage {
342
+ url: string;
343
+ secureUrl?: string;
344
+ type?: string;
345
+ width?: number;
346
+ height?: number;
347
+ alt?: string;
348
+ }
349
+
340
350
  export interface SsrMetadata {
341
351
  title?: string;
342
352
  description?: string;
343
353
  keywords?: string | string[];
344
354
  canonical?: string;
345
355
  robots?: string;
356
+ /** `<meta name="author">` — one tag per author. */
357
+ authors?: string | string[];
358
+ /** `<meta name="theme-color">` — browser UI tint for the page. */
359
+ themeColor?: string;
346
360
  openGraph?: {
347
361
  title?: string;
348
362
  description?: string;
@@ -355,17 +369,35 @@ export interface SsrMetadata {
355
369
  imageWidth?: number;
356
370
  imageHeight?: number;
357
371
  imageAlt?: string;
372
+ /** Additional images beyond the primary `image` (each emits its own
373
+ * `og:image` + dimensions). Provide absolute URLs. */
374
+ images?: OgImage[];
358
375
  url?: string;
359
376
  type?: string;
377
+ /** `og:locale` (e.g. "en_US"). */
378
+ locale?: string;
360
379
  /** `og:site_name` — the brand the page belongs to (e.g. "Pylon").
361
380
  * Discord and other unfurlers show this above the title. */
362
381
  siteName?: string;
382
+ /** `article:*` tags for `og:type=article` pages. */
383
+ article?: {
384
+ author?: string | string[];
385
+ publishedTime?: string;
386
+ modifiedTime?: string;
387
+ section?: string;
388
+ tags?: string | string[];
389
+ };
363
390
  };
364
391
  twitter?: {
365
392
  card?: string;
366
393
  title?: string;
367
394
  description?: string;
368
395
  image?: string;
396
+ /** `twitter:site` / `twitter:creator` — @handles. */
397
+ site?: string;
398
+ creator?: string;
399
+ /** `twitter:image:alt` — alt text for the card image. */
400
+ imageAlt?: string;
369
401
  };
370
402
  /** `<link rel="icon">` / `<link rel="apple-touch-icon">`. Auto-wired
371
403
  * from the app/icon.* + app/apple-icon.* + app/favicon.ico file
@@ -374,6 +406,16 @@ export interface SsrMetadata {
374
406
  icon?: { url: string; type?: string; sizes?: string };
375
407
  apple?: { url: string; type?: string; sizes?: string };
376
408
  };
409
+ /** Alternate URLs. `languages` emits `<link rel="alternate" hreflang>`
410
+ * per locale; `canonical` is an alias for the top-level `canonical`. */
411
+ alternates?: {
412
+ canonical?: string;
413
+ languages?: Record<string, string>;
414
+ };
415
+ /** Structured data, emitted as `<script type="application/ld+json">`.
416
+ * One object or an array (each item gets its own script). Serialized with
417
+ * `<` escaped so values can't break out of the script element. */
418
+ jsonLd?: Record<string, unknown> | Record<string, unknown>[];
377
419
  }
378
420
 
379
421
  /**
@@ -403,8 +445,25 @@ export function renderMetadata(React: any, m: SsrMetadata | undefined): any {
403
445
  const kw = Array.isArray(m.keywords) ? m.keywords.join(", ") : m.keywords;
404
446
  if (kw) kids.push(el("meta", { key: "kw", name: "keywords", content: kw }));
405
447
  if (m.robots) kids.push(el("meta", { key: "r", name: "robots", content: m.robots }));
448
+ const authors = Array.isArray(m.authors) ? m.authors : m.authors ? [m.authors] : [];
449
+ authors.forEach((a, i) => {
450
+ if (a) kids.push(el("meta", { key: `au${i}`, name: "author", content: a }));
451
+ });
452
+ if (m.themeColor) {
453
+ kids.push(el("meta", { key: "tc", name: "theme-color", content: m.themeColor }));
454
+ }
406
455
  if (m.canonical) {
407
456
  kids.push(el("link", { key: "c", rel: "canonical", href: m.canonical }));
457
+ } else if (m.alternates?.canonical) {
458
+ kids.push(el("link", { key: "c", rel: "canonical", href: m.alternates.canonical }));
459
+ }
460
+ const langs = m.alternates?.languages;
461
+ if (langs) {
462
+ Object.entries(langs).forEach(([lang, href], i) => {
463
+ if (href) {
464
+ kids.push(el("link", { key: `alt${i}`, rel: "alternate", hrefLang: lang, href }));
465
+ }
466
+ });
408
467
  }
409
468
  const og = m.openGraph;
410
469
  if (og) {
@@ -418,9 +477,35 @@ export function renderMetadata(React: any, m: SsrMetadata | undefined): any {
418
477
  if (og.imageHeight != null) kids.push(el("meta", { key: "ogih", property: "og:image:height", content: String(og.imageHeight) }));
419
478
  if (og.imageAlt) kids.push(el("meta", { key: "ogia", property: "og:image:alt", content: og.imageAlt }));
420
479
  }
480
+ if (Array.isArray(og.images)) {
481
+ og.images.forEach((img, i) => {
482
+ if (!img || !img.url) return;
483
+ kids.push(el("meta", { key: `ogim${i}`, property: "og:image", content: img.url }));
484
+ if (img.secureUrl) kids.push(el("meta", { key: `ogims${i}`, property: "og:image:secure_url", content: img.secureUrl }));
485
+ if (img.type) kids.push(el("meta", { key: `ogimt${i}`, property: "og:image:type", content: img.type }));
486
+ if (img.width != null) kids.push(el("meta", { key: `ogimw${i}`, property: "og:image:width", content: String(img.width) }));
487
+ if (img.height != null) kids.push(el("meta", { key: `ogimh${i}`, property: "og:image:height", content: String(img.height) }));
488
+ if (img.alt) kids.push(el("meta", { key: `ogima${i}`, property: "og:image:alt", content: img.alt }));
489
+ });
490
+ }
421
491
  if (og.url) kids.push(el("meta", { key: "ogu", property: "og:url", content: og.url }));
422
492
  if (og.type) kids.push(el("meta", { key: "ogy", property: "og:type", content: og.type }));
493
+ if (og.locale) kids.push(el("meta", { key: "ogl", property: "og:locale", content: og.locale }));
423
494
  if (og.siteName) kids.push(el("meta", { key: "ogsn", property: "og:site_name", content: og.siteName }));
495
+ const art = og.article;
496
+ if (art) {
497
+ const aAuthors = Array.isArray(art.author) ? art.author : art.author ? [art.author] : [];
498
+ aAuthors.forEach((a, i) => {
499
+ if (a) kids.push(el("meta", { key: `oga${i}`, property: "article:author", content: a }));
500
+ });
501
+ if (art.publishedTime) kids.push(el("meta", { key: "ogpt", property: "article:published_time", content: art.publishedTime }));
502
+ if (art.modifiedTime) kids.push(el("meta", { key: "ogmt", property: "article:modified_time", content: art.modifiedTime }));
503
+ if (art.section) kids.push(el("meta", { key: "ogsec", property: "article:section", content: art.section }));
504
+ const aTags = Array.isArray(art.tags) ? art.tags : art.tags ? [art.tags] : [];
505
+ aTags.forEach((t, i) => {
506
+ if (t) kids.push(el("meta", { key: `ogtag${i}`, property: "article:tag", content: t }));
507
+ });
508
+ }
424
509
  }
425
510
  const tw = m.twitter;
426
511
  if (tw) {
@@ -428,6 +513,9 @@ export function renderMetadata(React: any, m: SsrMetadata | undefined): any {
428
513
  if (tw.title != null) kids.push(el("meta", { key: "twt", name: "twitter:title", content: tw.title }));
429
514
  if (tw.description != null) kids.push(el("meta", { key: "twd", name: "twitter:description", content: tw.description }));
430
515
  if (tw.image) kids.push(el("meta", { key: "twi", name: "twitter:image", content: tw.image }));
516
+ if (tw.site) kids.push(el("meta", { key: "tws", name: "twitter:site", content: tw.site }));
517
+ if (tw.creator) kids.push(el("meta", { key: "twcr", name: "twitter:creator", content: tw.creator }));
518
+ if (tw.imageAlt) kids.push(el("meta", { key: "twia", name: "twitter:image:alt", content: tw.imageAlt }));
431
519
  }
432
520
  const ic = m.icons;
433
521
  if (ic) {
@@ -444,6 +532,34 @@ export function renderMetadata(React: any, m: SsrMetadata | undefined): any {
444
532
  kids.push(el("link", a));
445
533
  }
446
534
  }
535
+ if (m.jsonLd) {
536
+ const items = Array.isArray(m.jsonLd) ? m.jsonLd : [m.jsonLd];
537
+ items.forEach((item, i) => {
538
+ let json: string;
539
+ try {
540
+ json = JSON.stringify(item);
541
+ } catch {
542
+ return; // unserializable (cycle) — skip rather than throw the render
543
+ }
544
+ if (!json) return;
545
+ // Escape `<`, `>`, `&` to their \uXXXX JSON forms so the payload can't
546
+ // break out of the <script> element (e.g. a value containing
547
+ // `</script>`), no matter how the renderer treats raw-text children.
548
+ // JSON.parse decodes these back, so the structured data stays valid. This
549
+ // is why no dangerouslySetInnerHTML is needed: the text child is inert.
550
+ const safe = json
551
+ .replace(/</g, "\\u003c")
552
+ .replace(/>/g, "\\u003e")
553
+ .replace(/&/g, "\\u0026");
554
+ kids.push(
555
+ React.createElement(
556
+ "script",
557
+ { key: `ld${i}`, type: "application/ld+json", "data-pylon-meta": "" },
558
+ safe,
559
+ ),
560
+ );
561
+ });
562
+ }
447
563
  return kids.length > 0 ? el(React.Fragment, null, ...kids) : null;
448
564
  }
449
565