@tribe-nest/forge 3.61.0 → 3.64.0
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 +4 -2
- package/src/index.ts +3 -0
- package/src/runtime/pages.ts +2 -0
- package/src/seo/_tests/seo.spec.ts +522 -0
- package/src/seo/applySeoToHead.ts +104 -0
- package/src/seo/index.ts +10 -0
- package/src/seo/markdown.ts +76 -0
- package/src/seo/resolve.ts +254 -0
- package/src/seo/seoHead.ts +67 -0
- package/src/seo/siteSeoHead.ts +114 -0
- package/src/seo/types.ts +113 -0
- package/src/server/_tests/buildPwaHead.spec.ts +18 -1
- package/src/server/_tests/siteBootstrap.spec.ts +65 -0
- package/src/server/index.ts +33 -4
- package/src/server/pwa.ts +16 -1
- package/src/ui/analytics/ForgeAnalytics.tsx +70 -0
- package/src/ui/analytics/_tests/ForgeAnalytics.webVitals.spec.tsx +129 -0
- package/src/utils/_tests/landing.spec.ts +39 -0
- package/src/utils/_tests/structuredDataSeo.spec.ts +283 -0
- package/src/utils/landing.ts +17 -4
- package/src/utils/structuredData.ts +363 -1
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import type { WebPage } from "../types";
|
|
1
|
+
import type { IEvent, ITicket, WebPage } from "../types";
|
|
2
|
+
import type { SeoIdentity } from "../seo/types";
|
|
3
|
+
import { ticketSeatsUnsold } from "../ui/format/ticketAvailability";
|
|
2
4
|
|
|
3
5
|
/**
|
|
4
6
|
* Generates Organization/Person JSON-LD for every page.
|
|
@@ -297,6 +299,30 @@ export function seoContextFromSiteConfig(
|
|
|
297
299
|
return { currency: config.currency || undefined, brand: config.siteName || undefined };
|
|
298
300
|
}
|
|
299
301
|
|
|
302
|
+
/**
|
|
303
|
+
* The name structured data gives the business behind a page: the SEO
|
|
304
|
+
* identity's name when the creator set one, else the SEO site name, else
|
|
305
|
+
* `fallback` (the site config's name, which is the profile's).
|
|
306
|
+
*
|
|
307
|
+
* One rule for every node that names the business (Product `brand`, Course,
|
|
308
|
+
* Service and Film `provider`, Event `organizer`), so a product page and an
|
|
309
|
+
* event page never disagree about who sells them.
|
|
310
|
+
*/
|
|
311
|
+
export function seoBrandName(
|
|
312
|
+
seo?: { identity?: { name?: string | null } | null; siteName?: string | null } | null,
|
|
313
|
+
fallback?: string | null,
|
|
314
|
+
): string | undefined {
|
|
315
|
+
return seo?.identity?.name?.trim() || seo?.siteName?.trim() || fallback?.trim() || undefined;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** A route's `SiteSeoContext` with its `brand` named by the SEO record (see `seoBrandName`). */
|
|
319
|
+
export function withSeoBrand(
|
|
320
|
+
context: SiteSeoContext | null | undefined,
|
|
321
|
+
seo?: { identity?: { name?: string | null } | null; siteName?: string | null } | null,
|
|
322
|
+
): SiteSeoContext {
|
|
323
|
+
return { ...context, brand: seoBrandName(seo, context?.brand) };
|
|
324
|
+
}
|
|
325
|
+
|
|
300
326
|
/**
|
|
301
327
|
* The offer for a product, derived from its variants.
|
|
302
328
|
*
|
|
@@ -529,3 +555,339 @@ export function jsonLdScripts(
|
|
|
529
555
|
.filter((s): s is Record<string, unknown> => !!s)
|
|
530
556
|
.map((s) => ({ type: "application/ld+json", children: JSON.stringify(s) }));
|
|
531
557
|
}
|
|
558
|
+
|
|
559
|
+
// ---- Events, posts, podcasts, the site and who runs it --------------------------
|
|
560
|
+
//
|
|
561
|
+
// Same rule as the sellables above: say only what the page itself says. A
|
|
562
|
+
// cancelled event is still described (so a search result can show it as
|
|
563
|
+
// cancelled) but sells nothing; a tier nobody may buy anonymously gets no
|
|
564
|
+
// offer; a missing currency drops the offers rather than guessing one.
|
|
565
|
+
|
|
566
|
+
/** A post's or episode's author, and the site's publisher, as the SEO record names them. */
|
|
567
|
+
export type SchemaIdentityInput = Pick<SeoIdentity, "kind" | "name"> & Partial<Omit<SeoIdentity, "kind" | "name">>;
|
|
568
|
+
|
|
569
|
+
const schemaTypeForIdentity = (kind: SeoIdentity["kind"]) =>
|
|
570
|
+
kind === "person" ? "Person" : kind === "local_business" ? "LocalBusiness" : "Organization";
|
|
571
|
+
|
|
572
|
+
const cleanUrls = (urls?: (string | null | undefined)[] | null) =>
|
|
573
|
+
(urls ?? []).filter((u): u is string => typeof u === "string" && /^https?:\/\//i.test(u.trim())).map((u) => u.trim());
|
|
574
|
+
|
|
575
|
+
function postalAddress(address?: SeoIdentity["address"]): Record<string, unknown> | undefined {
|
|
576
|
+
if (!address) return undefined;
|
|
577
|
+
const node = {
|
|
578
|
+
...(address.street ? { streetAddress: address.street } : {}),
|
|
579
|
+
...(address.locality ? { addressLocality: address.locality } : {}),
|
|
580
|
+
...(address.region ? { addressRegion: address.region } : {}),
|
|
581
|
+
...(address.postalCode ? { postalCode: address.postalCode } : {}),
|
|
582
|
+
...(address.country ? { addressCountry: address.country } : {}),
|
|
583
|
+
};
|
|
584
|
+
return Object.keys(node).length ? { "@type": "PostalAddress", ...node } : undefined;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/** A short reference to the identity, for `author` / `publisher` inside another node. */
|
|
588
|
+
function identityRef(identity?: SchemaIdentityInput | null, origin?: string | null): Record<string, unknown> | undefined {
|
|
589
|
+
const name = identity?.name?.trim();
|
|
590
|
+
if (!identity || !name) return undefined;
|
|
591
|
+
return {
|
|
592
|
+
"@type": schemaTypeForIdentity(identity.kind),
|
|
593
|
+
name,
|
|
594
|
+
...(origin ? { url: origin } : {}),
|
|
595
|
+
...(identity.logo && identity.kind !== "person" ? { logo: identity.logo } : {}),
|
|
596
|
+
};
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/** The `@id` the WebSite node's `publisher` points at, so the two link up. */
|
|
600
|
+
export const identitySchemaId = (origin: string) => `${origin.replace(/\/+$/, "")}/#identity`;
|
|
601
|
+
|
|
602
|
+
/**
|
|
603
|
+
* schema.org Person, Organization or LocalBusiness for whoever runs the site.
|
|
604
|
+
*
|
|
605
|
+
* `logo` is an Organization property; a Person carries the same picture as
|
|
606
|
+
* `image`. `address` and `telephone` are emitted for any kind that has them,
|
|
607
|
+
* because a coach working from a studio is as findable by address as a shop.
|
|
608
|
+
*/
|
|
609
|
+
export function buildIdentitySchema(
|
|
610
|
+
identity: SchemaIdentityInput | null | undefined,
|
|
611
|
+
options: { origin?: string | null } = {},
|
|
612
|
+
): Record<string, unknown> | null {
|
|
613
|
+
const name = identity?.name?.trim();
|
|
614
|
+
if (!identity || !name) return null;
|
|
615
|
+
const origin = options.origin?.replace(/\/+$/, "");
|
|
616
|
+
const sameAs = cleanUrls(identity.sameAs);
|
|
617
|
+
const address = postalAddress(identity.address);
|
|
618
|
+
return {
|
|
619
|
+
"@context": "https://schema.org",
|
|
620
|
+
"@type": schemaTypeForIdentity(identity.kind),
|
|
621
|
+
...(origin ? { "@id": identitySchemaId(origin), url: origin } : {}),
|
|
622
|
+
name,
|
|
623
|
+
...(identity.logo ? (identity.kind === "person" ? { image: identity.logo } : { logo: identity.logo }) : {}),
|
|
624
|
+
...(sameAs.length ? { sameAs } : {}),
|
|
625
|
+
...(address ? { address } : {}),
|
|
626
|
+
...(identity.phone ? { telephone: identity.phone } : {}),
|
|
627
|
+
};
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
/** schema.org/WebSite for the home page. */
|
|
631
|
+
export function buildWebSiteSchema(input: {
|
|
632
|
+
name: string;
|
|
633
|
+
url: string;
|
|
634
|
+
description?: string | null;
|
|
635
|
+
/** Links the site to its identity node by `@id`. */
|
|
636
|
+
publisherId?: string | null;
|
|
637
|
+
}): Record<string, unknown> | null {
|
|
638
|
+
const name = input.name?.trim();
|
|
639
|
+
if (!name || !input.url) return null;
|
|
640
|
+
return {
|
|
641
|
+
"@context": "https://schema.org",
|
|
642
|
+
"@type": "WebSite",
|
|
643
|
+
name,
|
|
644
|
+
url: input.url,
|
|
645
|
+
...(input.description ? { description: input.description } : {}),
|
|
646
|
+
...(input.publisherId ? { publisher: { "@id": input.publisherId } } : {}),
|
|
647
|
+
};
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
/** schema.org/BreadcrumbList. Each item needs an absolute URL, so pass full ones. */
|
|
651
|
+
export function buildBreadcrumbSchema(items: { name: string; url: string }[]): Record<string, unknown> | null {
|
|
652
|
+
const list = items.filter((i) => i.name?.trim() && i.url);
|
|
653
|
+
if (list.length < 2) return null;
|
|
654
|
+
return {
|
|
655
|
+
"@context": "https://schema.org",
|
|
656
|
+
"@type": "BreadcrumbList",
|
|
657
|
+
itemListElement: list.map((item, index) => ({
|
|
658
|
+
"@type": "ListItem",
|
|
659
|
+
position: index + 1,
|
|
660
|
+
name: item.name.trim(),
|
|
661
|
+
item: item.url,
|
|
662
|
+
})),
|
|
663
|
+
};
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
/** Structural view of `GET /public/events/:id`. `IEvent` satisfies it. */
|
|
667
|
+
export interface EventSchemaSource {
|
|
668
|
+
title: string;
|
|
669
|
+
description?: string | null;
|
|
670
|
+
dateTime: string;
|
|
671
|
+
endDateTime?: string | null;
|
|
672
|
+
doorsOpenAt?: string | null;
|
|
673
|
+
status?: IEvent["status"];
|
|
674
|
+
cancelledAt?: string | null;
|
|
675
|
+
type: IEvent["type"];
|
|
676
|
+
address?: IEvent["address"] | null;
|
|
677
|
+
media?: { url: string; type?: string }[] | null;
|
|
678
|
+
tickets?: Pick<
|
|
679
|
+
ITicket,
|
|
680
|
+
"title" | "price" | "quantity" | "sold" | "availableFrom" | "archivedAt" | "isHidden" | "membershipGate"
|
|
681
|
+
>[];
|
|
682
|
+
membershipGate?: { allowed: boolean } | null;
|
|
683
|
+
bookingFee?: { currency: string } | null;
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
const ATTENDANCE_MODE: Record<IEvent["type"], string> = {
|
|
687
|
+
physical: "https://schema.org/OfflineEventAttendanceMode",
|
|
688
|
+
virtual: "https://schema.org/OnlineEventAttendanceMode",
|
|
689
|
+
hybrid: "https://schema.org/MixedEventAttendanceMode",
|
|
690
|
+
};
|
|
691
|
+
|
|
692
|
+
/**
|
|
693
|
+
* schema.org/Event.
|
|
694
|
+
*
|
|
695
|
+
* `offers` is one Offer per public ticket tier, priced in the event's own
|
|
696
|
+
* settlement currency. A tier is left out when it is hidden, archived or
|
|
697
|
+
* members-only; a members-only EVENT sells nothing to the anonymous visitor
|
|
698
|
+
* Googlebot is, so it has no offers at all. Neither has a cancelled or finished
|
|
699
|
+
* event: there is nothing left to buy.
|
|
700
|
+
*
|
|
701
|
+
* A virtual event's location is a VirtualLocation at the event page itself,
|
|
702
|
+
* which is where a ticket-holder is let in. A hybrid event carries both.
|
|
703
|
+
*/
|
|
704
|
+
export function buildEventSchema(
|
|
705
|
+
event: EventSchemaSource | null | undefined,
|
|
706
|
+
options: SellableSchemaOptions & { organizer?: SchemaIdentityInput | null; origin?: string | null } = {},
|
|
707
|
+
): Record<string, unknown> | null {
|
|
708
|
+
const name = event?.title?.trim();
|
|
709
|
+
if (!event || !name || !event.dateTime) return null;
|
|
710
|
+
|
|
711
|
+
const cancelled = event.status === "cancelled" || !!event.cancelledAt;
|
|
712
|
+
const description = toSchemaText(event.description);
|
|
713
|
+
const images = imageUrls(event.media);
|
|
714
|
+
|
|
715
|
+
const place = (() => {
|
|
716
|
+
if (event.type === "virtual" || !event.address) return undefined;
|
|
717
|
+
const a = event.address;
|
|
718
|
+
const address = postalAddress({
|
|
719
|
+
street: a.street || undefined,
|
|
720
|
+
locality: a.city || undefined,
|
|
721
|
+
region: a.state || undefined,
|
|
722
|
+
postalCode: a.zipCode || undefined,
|
|
723
|
+
country: a.country || undefined,
|
|
724
|
+
});
|
|
725
|
+
return { "@type": "Place", ...(a.name ? { name: a.name } : {}), ...(address ? { address } : {}) };
|
|
726
|
+
})();
|
|
727
|
+
const virtual =
|
|
728
|
+
event.type !== "physical" && options.url ? { "@type": "VirtualLocation", url: options.url } : undefined;
|
|
729
|
+
const locations = [place, virtual].filter(Boolean);
|
|
730
|
+
|
|
731
|
+
const currency = event.bookingFee?.currency || options.currency || undefined;
|
|
732
|
+
const sellable =
|
|
733
|
+
!!currency &&
|
|
734
|
+
!cancelled &&
|
|
735
|
+
event.status !== "completed" &&
|
|
736
|
+
!(event.membershipGate && event.membershipGate.allowed === false);
|
|
737
|
+
const now = Date.now();
|
|
738
|
+
const offers = sellable
|
|
739
|
+
? (event.tickets ?? [])
|
|
740
|
+
.filter((t) => !t.archivedAt && !t.isHidden && !(t.membershipGate && t.membershipGate.allowed === false))
|
|
741
|
+
.map((t) => {
|
|
742
|
+
const price = toPrice(t.price);
|
|
743
|
+
if (price === undefined) return null;
|
|
744
|
+
const notYet = !!t.availableFrom && Date.parse(t.availableFrom) > now;
|
|
745
|
+
const soldOut = event.status === "sold_out" || ticketSeatsUnsold(t) <= 0;
|
|
746
|
+
return {
|
|
747
|
+
"@type": "Offer",
|
|
748
|
+
...(t.title ? { name: t.title } : {}),
|
|
749
|
+
price: money(price),
|
|
750
|
+
priceCurrency: currency,
|
|
751
|
+
availability: `https://schema.org/${soldOut ? "SoldOut" : notYet ? "PreOrder" : "InStock"}`,
|
|
752
|
+
...(t.availableFrom ? { validFrom: t.availableFrom } : {}),
|
|
753
|
+
...(options.url ? { url: options.url } : {}),
|
|
754
|
+
};
|
|
755
|
+
})
|
|
756
|
+
.filter((o): o is NonNullable<typeof o> => !!o)
|
|
757
|
+
: [];
|
|
758
|
+
|
|
759
|
+
const organizer =
|
|
760
|
+
identityRef(options.organizer, options.origin) ??
|
|
761
|
+
(options.brand ? { "@type": "Organization", name: options.brand } : undefined);
|
|
762
|
+
|
|
763
|
+
return {
|
|
764
|
+
"@context": "https://schema.org",
|
|
765
|
+
"@type": "Event",
|
|
766
|
+
name,
|
|
767
|
+
startDate: event.dateTime,
|
|
768
|
+
...(event.endDateTime ? { endDate: event.endDateTime } : {}),
|
|
769
|
+
...(event.doorsOpenAt ? { doorTime: event.doorsOpenAt } : {}),
|
|
770
|
+
eventStatus: `https://schema.org/${cancelled ? "EventCancelled" : "EventScheduled"}`,
|
|
771
|
+
eventAttendanceMode: ATTENDANCE_MODE[event.type] ?? ATTENDANCE_MODE.physical,
|
|
772
|
+
...(locations.length ? { location: locations.length === 1 ? locations[0] : locations } : {}),
|
|
773
|
+
...(description ? { description } : {}),
|
|
774
|
+
...(images.length ? { image: images.length === 1 ? images[0] : images } : {}),
|
|
775
|
+
...(options.url ? { url: options.url } : {}),
|
|
776
|
+
...(organizer ? { organizer } : {}),
|
|
777
|
+
...(offers.length ? { offers: offers.length === 1 ? offers[0] : offers } : {}),
|
|
778
|
+
};
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
/** Structural view of `GET /public/blog/posts/:id`. `BlogPost` satisfies it. */
|
|
782
|
+
export interface BlogPostingSchemaSource {
|
|
783
|
+
title: string;
|
|
784
|
+
content?: string | null;
|
|
785
|
+
publishedAt?: string | null;
|
|
786
|
+
updatedAt?: string | null;
|
|
787
|
+
media?: { url: string; type?: string }[] | null;
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
/** schema.org/BlogPosting. `author` and `publisher` both come from the site's identity. */
|
|
791
|
+
export function buildBlogPostingSchema(
|
|
792
|
+
post: BlogPostingSchemaSource | null | undefined,
|
|
793
|
+
options: { url?: string | null; identity?: SchemaIdentityInput | null; origin?: string | null } = {},
|
|
794
|
+
): Record<string, unknown> | null {
|
|
795
|
+
const headline = post?.title?.trim();
|
|
796
|
+
if (!post || !headline) return null;
|
|
797
|
+
const description = toSchemaText(post.content, 300);
|
|
798
|
+
const images = imageUrls((post.media ?? []).filter((m) => (m.type ?? "").startsWith("image")));
|
|
799
|
+
const author = identityRef(options.identity, options.origin);
|
|
800
|
+
return {
|
|
801
|
+
"@context": "https://schema.org",
|
|
802
|
+
"@type": "BlogPosting",
|
|
803
|
+
// Google truncates a headline past 110 characters; clamping keeps it valid.
|
|
804
|
+
headline: headline.length > 110 ? `${headline.slice(0, 109).trimEnd()}…` : headline,
|
|
805
|
+
...(description ? { description } : {}),
|
|
806
|
+
...(post.publishedAt ? { datePublished: post.publishedAt } : {}),
|
|
807
|
+
...(post.updatedAt || post.publishedAt ? { dateModified: post.updatedAt || post.publishedAt } : {}),
|
|
808
|
+
...(images.length ? { image: images.length === 1 ? images[0] : images } : {}),
|
|
809
|
+
...(options.url ? { url: options.url, mainEntityOfPage: { "@type": "WebPage", "@id": options.url } } : {}),
|
|
810
|
+
...(author ? { author } : {}),
|
|
811
|
+
...(author && options.identity?.kind !== "person" ? { publisher: author } : {}),
|
|
812
|
+
};
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
/** Structural view of a podcast show. `PodcastShow` and `BlogCategory` both satisfy it. */
|
|
816
|
+
export interface PodcastSeriesSchemaSource {
|
|
817
|
+
title: string;
|
|
818
|
+
description?: string | null;
|
|
819
|
+
coverImage?: { url: string } | null;
|
|
820
|
+
author?: string | null;
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
/** schema.org/PodcastSeries. */
|
|
824
|
+
export function buildPodcastSeriesSchema(
|
|
825
|
+
show: PodcastSeriesSchemaSource | null | undefined,
|
|
826
|
+
options: { url?: string | null; identity?: SchemaIdentityInput | null; origin?: string | null } = {},
|
|
827
|
+
): Record<string, unknown> | null {
|
|
828
|
+
const name = show?.title?.trim();
|
|
829
|
+
if (!show || !name) return null;
|
|
830
|
+
const description = toSchemaText(show.description);
|
|
831
|
+
const author = show.author?.trim()
|
|
832
|
+
? { "@type": "Person", name: show.author.trim() }
|
|
833
|
+
: identityRef(options.identity, options.origin);
|
|
834
|
+
return {
|
|
835
|
+
"@context": "https://schema.org",
|
|
836
|
+
"@type": "PodcastSeries",
|
|
837
|
+
name,
|
|
838
|
+
...(description ? { description } : {}),
|
|
839
|
+
...(show.coverImage?.url ? { image: show.coverImage.url } : {}),
|
|
840
|
+
...(options.url ? { url: options.url } : {}),
|
|
841
|
+
...(author ? { author } : {}),
|
|
842
|
+
};
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
/** Structural view of a podcast episode (a blog post with an audio medium). */
|
|
846
|
+
export interface PodcastEpisodeSchemaSource {
|
|
847
|
+
title: string;
|
|
848
|
+
content?: string | null;
|
|
849
|
+
publishedAt?: string | null;
|
|
850
|
+
media?: { url: string; type?: string; durationSec?: number | null }[] | null;
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
/** schema.org/PodcastEpisode, linked to its series. */
|
|
854
|
+
export function buildPodcastEpisodeSchema(
|
|
855
|
+
episode: PodcastEpisodeSchemaSource | null | undefined,
|
|
856
|
+
options: { url?: string | null; series?: { name: string; url?: string | null } | null } = {},
|
|
857
|
+
): Record<string, unknown> | null {
|
|
858
|
+
const name = episode?.title?.trim();
|
|
859
|
+
if (!episode || !name) return null;
|
|
860
|
+
const description = toSchemaText(episode.content);
|
|
861
|
+
const audio = (episode.media ?? []).find((m) => (m.type ?? "").startsWith("audio"));
|
|
862
|
+
const image = (episode.media ?? []).find((m) => (m.type ?? "").startsWith("image"))?.url;
|
|
863
|
+
const duration = isoDuration(audio?.durationSec);
|
|
864
|
+
return {
|
|
865
|
+
"@context": "https://schema.org",
|
|
866
|
+
"@type": "PodcastEpisode",
|
|
867
|
+
name,
|
|
868
|
+
...(description ? { description } : {}),
|
|
869
|
+
...(episode.publishedAt ? { datePublished: episode.publishedAt } : {}),
|
|
870
|
+
...(options.url ? { url: options.url } : {}),
|
|
871
|
+
...(image ? { image } : {}),
|
|
872
|
+
...(duration ? { timeRequired: duration } : {}),
|
|
873
|
+
...(audio?.url
|
|
874
|
+
? {
|
|
875
|
+
associatedMedia: {
|
|
876
|
+
"@type": "MediaObject",
|
|
877
|
+
contentUrl: audio.url,
|
|
878
|
+
...(audio.type ? { encodingFormat: audio.type } : {}),
|
|
879
|
+
...(duration ? { duration } : {}),
|
|
880
|
+
},
|
|
881
|
+
}
|
|
882
|
+
: {}),
|
|
883
|
+
...(options.series?.name
|
|
884
|
+
? {
|
|
885
|
+
partOfSeries: {
|
|
886
|
+
"@type": "PodcastSeries",
|
|
887
|
+
name: options.series.name,
|
|
888
|
+
...(options.series.url ? { url: options.series.url } : {}),
|
|
889
|
+
},
|
|
890
|
+
}
|
|
891
|
+
: {}),
|
|
892
|
+
};
|
|
893
|
+
}
|