@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 +1 -1
- package/src/ssr-runtime.test.ts +80 -0
- package/src/ssr-runtime.ts +116 -0
package/package.json
CHANGED
package/src/ssr-runtime.test.ts
CHANGED
|
@@ -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)", () => {
|
package/src/ssr-runtime.ts
CHANGED
|
@@ -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
|
|