a tool for shared writing and social publishing
1"use server";
2
3import * as Y from "yjs";
4import * as base64 from "base64-js";
5import { createOauthClient } from "src/atproto-oauth";
6import { getIdentityData } from "actions/getIdentityData";
7import {
8 AtpBaseClient,
9 PubLeafletBlocksHeader,
10 PubLeafletBlocksImage,
11 PubLeafletBlocksText,
12 PubLeafletBlocksUnorderedList,
13 PubLeafletDocument,
14 PubLeafletPagesLinearDocument,
15 PubLeafletPagesCanvas,
16 PubLeafletRichtextFacet,
17 PubLeafletBlocksWebsite,
18 PubLeafletBlocksCode,
19 PubLeafletBlocksMath,
20 PubLeafletBlocksHorizontalRule,
21 PubLeafletBlocksBskyPost,
22 PubLeafletBlocksBlockquote,
23 PubLeafletBlocksIframe,
24 PubLeafletBlocksPage,
25 PubLeafletBlocksPoll,
26 PubLeafletBlocksButton,
27 PubLeafletPollDefinition,
28} from "lexicons/api";
29import { Block } from "components/Blocks/Block";
30import { TID } from "@atproto/common";
31import { supabaseServerClient } from "supabase/serverClient";
32import { scanIndexLocal } from "src/replicache/utils";
33import type { Fact } from "src/replicache";
34import type { Attribute } from "src/replicache/attributes";
35import {
36 Delta,
37 YJSFragmentToString,
38} from "components/Blocks/TextBlock/RenderYJSFragment";
39import { ids } from "lexicons/api/lexicons";
40import { BlobRef } from "@atproto/lexicon";
41import { AtUri } from "@atproto/syntax";
42import { Json } from "supabase/database.types";
43import { $Typed, UnicodeString } from "@atproto/api";
44import { List, parseBlocksToList } from "src/utils/parseBlocksToList";
45import { getBlocksWithTypeLocal } from "src/hooks/queries/useBlocks";
46import { Lock } from "src/utils/lock";
47import type { PubLeafletPublication } from "lexicons/api";
48import {
49 ColorToRGB,
50 ColorToRGBA,
51} from "components/ThemeManager/colorToLexicons";
52import { parseColor } from "@react-stately/color";
53
54export async function publishToPublication({
55 root_entity,
56 publication_uri,
57 leaflet_id,
58 title,
59 description,
60 entitiesToDelete,
61}: {
62 root_entity: string;
63 publication_uri?: string;
64 leaflet_id: string;
65 title?: string;
66 description?: string;
67 entitiesToDelete?: string[];
68}) {
69 const oauthClient = await createOauthClient();
70 let identity = await getIdentityData();
71 if (!identity || !identity.atp_did) throw new Error("No Identity");
72
73 let credentialSession = await oauthClient.restore(identity.atp_did);
74 let agent = new AtpBaseClient(
75 credentialSession.fetchHandler.bind(credentialSession),
76 );
77
78 // Check if we're publishing to a publication or standalone
79 let draft: any = null;
80 let existingDocUri: string | null = null;
81
82 if (publication_uri) {
83 // Publishing to a publication - use leaflets_in_publications
84 let { data, error } = await supabaseServerClient
85 .from("publications")
86 .select("*, leaflets_in_publications(*, documents(*))")
87 .eq("uri", publication_uri)
88 .eq("leaflets_in_publications.leaflet", leaflet_id)
89 .single();
90 console.log(error);
91
92 if (!data || identity.atp_did !== data?.identity_did)
93 throw new Error("No draft or not publisher");
94 draft = data.leaflets_in_publications[0];
95 existingDocUri = draft?.doc;
96 } else {
97 // Publishing standalone - use leaflets_to_documents
98 let { data } = await supabaseServerClient
99 .from("leaflets_to_documents")
100 .select("*, documents(*)")
101 .eq("leaflet", leaflet_id)
102 .single();
103 draft = data;
104 existingDocUri = draft?.document;
105 }
106
107 // Heuristic: Remove title entities if this is the first time publishing
108 // (when coming from a standalone leaflet with entitiesToDelete passed in)
109 if (entitiesToDelete && entitiesToDelete.length > 0 && !existingDocUri) {
110 await supabaseServerClient
111 .from("entities")
112 .delete()
113 .in("id", entitiesToDelete);
114 }
115
116 let { data } = await supabaseServerClient.rpc("get_facts", {
117 root: root_entity,
118 });
119 let facts = (data as unknown as Fact<Attribute>[]) || [];
120
121 let { pages } = await processBlocksToPages(
122 facts,
123 agent,
124 root_entity,
125 credentialSession.did!,
126 );
127
128 let existingRecord =
129 (draft?.documents?.data as PubLeafletDocument.Record | undefined) || {};
130
131 // Extract theme for standalone documents (not for publications)
132 let theme: PubLeafletPublication.Theme | undefined;
133 if (!publication_uri) {
134 theme = await extractThemeFromFacts(facts, root_entity, agent);
135 }
136
137 let record: PubLeafletDocument.Record = {
138 publishedAt: new Date().toISOString(),
139 ...existingRecord,
140 $type: "pub.leaflet.document",
141 author: credentialSession.did!,
142 ...(publication_uri && { publication: publication_uri }),
143 ...(theme && { theme }),
144 title: title || "Untitled",
145 description: description || "",
146 pages: pages.map((p) => {
147 if (p.type === "canvas") {
148 return {
149 $type: "pub.leaflet.pages.canvas" as const,
150 id: p.id,
151 blocks: p.blocks as PubLeafletPagesCanvas.Block[],
152 };
153 } else {
154 return {
155 $type: "pub.leaflet.pages.linearDocument" as const,
156 id: p.id,
157 blocks: p.blocks as PubLeafletPagesLinearDocument.Block[],
158 };
159 }
160 }),
161 };
162
163 // Keep the same rkey if updating an existing document
164 let rkey = existingDocUri ? new AtUri(existingDocUri).rkey : TID.nextStr();
165 let { data: result } = await agent.com.atproto.repo.putRecord({
166 rkey,
167 repo: credentialSession.did!,
168 collection: record.$type,
169 record,
170 validate: false, //TODO publish the lexicon so we can validate!
171 });
172
173 // Optimistically create database entries
174 await supabaseServerClient.from("documents").upsert({
175 uri: result.uri,
176 data: record as Json,
177 });
178
179 if (publication_uri) {
180 // Publishing to a publication - update both tables
181 await Promise.all([
182 supabaseServerClient.from("documents_in_publications").upsert({
183 publication: publication_uri,
184 document: result.uri,
185 }),
186 supabaseServerClient.from("leaflets_in_publications").upsert({
187 doc: result.uri,
188 leaflet: leaflet_id,
189 publication: publication_uri,
190 title: title,
191 description: description,
192 }),
193 ]);
194 } else {
195 // Publishing standalone - update leaflets_to_documents
196 await supabaseServerClient.from("leaflets_to_documents").upsert({
197 leaflet: leaflet_id,
198 document: result.uri,
199 title: title || "Untitled",
200 description: description || "",
201 });
202
203 // Heuristic: Remove title entities if this is the first time publishing standalone
204 // (when entitiesToDelete is provided and there's no existing document)
205 if (entitiesToDelete && entitiesToDelete.length > 0 && !existingDocUri) {
206 await supabaseServerClient
207 .from("entities")
208 .delete()
209 .in("id", entitiesToDelete);
210 }
211 }
212
213 return { rkey, record: JSON.parse(JSON.stringify(record)) };
214}
215
216async function processBlocksToPages(
217 facts: Fact<any>[],
218 agent: AtpBaseClient,
219 root_entity: string,
220 did: string,
221) {
222 let scan = scanIndexLocal(facts);
223 let pages: {
224 id: string;
225 blocks:
226 | PubLeafletPagesLinearDocument.Block[]
227 | PubLeafletPagesCanvas.Block[];
228 type: "doc" | "canvas";
229 }[] = [];
230
231 // Create a lock to serialize image uploads
232 const uploadLock = new Lock();
233
234 let firstEntity = scan.eav(root_entity, "root/page")?.[0];
235 if (!firstEntity) throw new Error("No root page");
236
237 // Check if the first page is a canvas or linear document
238 let [pageType] = scan.eav(firstEntity.data.value, "page/type");
239
240 if (pageType?.data.value === "canvas") {
241 // First page is a canvas
242 let canvasBlocks = await canvasBlocksToRecord(firstEntity.data.value, did);
243 pages.unshift({
244 id: firstEntity.data.value,
245 blocks: canvasBlocks,
246 type: "canvas",
247 });
248 } else {
249 // First page is a linear document
250 let blocks = getBlocksWithTypeLocal(facts, firstEntity?.data.value);
251 let b = await blocksToRecord(blocks, did);
252 pages.unshift({
253 id: firstEntity.data.value,
254 blocks: b,
255 type: "doc",
256 });
257 }
258
259 return { pages };
260
261 async function uploadImage(src: string) {
262 let data = await fetch(src);
263 if (data.status !== 200) return;
264 let binary = await data.blob();
265 return uploadLock.withLock(async () => {
266 let blob = await agent.com.atproto.repo.uploadBlob(binary, {
267 headers: { "Content-Type": binary.type },
268 });
269 return blob.data.blob;
270 });
271 }
272 async function blocksToRecord(
273 blocks: Block[],
274 did: string,
275 ): Promise<PubLeafletPagesLinearDocument.Block[]> {
276 let parsedBlocks = parseBlocksToList(blocks);
277 return (
278 await Promise.all(
279 parsedBlocks.map(async (blockOrList) => {
280 if (blockOrList.type === "block") {
281 let alignmentValue = scan.eav(
282 blockOrList.block.value,
283 "block/text-alignment",
284 )[0]?.data.value;
285 let alignment: ExcludeString<
286 PubLeafletPagesLinearDocument.Block["alignment"]
287 > =
288 alignmentValue === "center"
289 ? "lex:pub.leaflet.pages.linearDocument#textAlignCenter"
290 : alignmentValue === "right"
291 ? "lex:pub.leaflet.pages.linearDocument#textAlignRight"
292 : alignmentValue === "justify"
293 ? "lex:pub.leaflet.pages.linearDocument#textAlignJustify"
294 : alignmentValue === "left"
295 ? "lex:pub.leaflet.pages.linearDocument#textAlignLeft"
296 : undefined;
297 let b = await blockToRecord(blockOrList.block, did);
298 if (!b) return [];
299 let block: PubLeafletPagesLinearDocument.Block = {
300 $type: "pub.leaflet.pages.linearDocument#block",
301 alignment,
302 block: b,
303 };
304 return [block];
305 } else {
306 let block: PubLeafletPagesLinearDocument.Block = {
307 $type: "pub.leaflet.pages.linearDocument#block",
308 block: {
309 $type: "pub.leaflet.blocks.unorderedList",
310 children: await childrenToRecord(blockOrList.children, did),
311 },
312 };
313 return [block];
314 }
315 }),
316 )
317 ).flat();
318 }
319
320 async function childrenToRecord(children: List[], did: string) {
321 return (
322 await Promise.all(
323 children.map(async (child) => {
324 let content = await blockToRecord(child.block, did);
325 if (!content) return [];
326 let record: PubLeafletBlocksUnorderedList.ListItem = {
327 $type: "pub.leaflet.blocks.unorderedList#listItem",
328 content,
329 children: await childrenToRecord(child.children, did),
330 };
331 return record;
332 }),
333 )
334 ).flat();
335 }
336 async function blockToRecord(b: Block, did: string) {
337 const getBlockContent = (b: string) => {
338 let [content] = scan.eav(b, "block/text");
339 if (!content) return ["", [] as PubLeafletRichtextFacet.Main[]] as const;
340 let doc = new Y.Doc();
341 const update = base64.toByteArray(content.data.value);
342 Y.applyUpdate(doc, update);
343 let nodes = doc.getXmlElement("prosemirror").toArray();
344 let stringValue = YJSFragmentToString(nodes[0]);
345 let facets = YJSFragmentToFacets(nodes[0]);
346 return [stringValue, facets] as const;
347 };
348 if (b.type === "card") {
349 let [page] = scan.eav(b.value, "block/card");
350 if (!page) return;
351 let [pageType] = scan.eav(page.data.value, "page/type");
352
353 if (pageType?.data.value === "canvas") {
354 let canvasBlocks = await canvasBlocksToRecord(page.data.value, did);
355 pages.push({
356 id: page.data.value,
357 blocks: canvasBlocks,
358 type: "canvas",
359 });
360 } else {
361 let blocks = getBlocksWithTypeLocal(facts, page.data.value);
362 pages.push({
363 id: page.data.value,
364 blocks: await blocksToRecord(blocks, did),
365 type: "doc",
366 });
367 }
368
369 let block: $Typed<PubLeafletBlocksPage.Main> = {
370 $type: "pub.leaflet.blocks.page",
371 id: page.data.value,
372 };
373 return block;
374 }
375
376 if (b.type === "bluesky-post") {
377 let [post] = scan.eav(b.value, "block/bluesky-post");
378 if (!post || !post.data.value.post) return;
379 let block: $Typed<PubLeafletBlocksBskyPost.Main> = {
380 $type: ids.PubLeafletBlocksBskyPost,
381 postRef: {
382 uri: post.data.value.post.uri,
383 cid: post.data.value.post.cid,
384 },
385 };
386 return block;
387 }
388 if (b.type === "horizontal-rule") {
389 let block: $Typed<PubLeafletBlocksHorizontalRule.Main> = {
390 $type: ids.PubLeafletBlocksHorizontalRule,
391 };
392 return block;
393 }
394
395 if (b.type === "heading") {
396 let [headingLevel] = scan.eav(b.value, "block/heading-level");
397
398 let [stringValue, facets] = getBlockContent(b.value);
399 let block: $Typed<PubLeafletBlocksHeader.Main> = {
400 $type: "pub.leaflet.blocks.header",
401 level: headingLevel?.data.value || 1,
402 plaintext: stringValue,
403 facets,
404 };
405 return block;
406 }
407
408 if (b.type === "blockquote") {
409 let [stringValue, facets] = getBlockContent(b.value);
410 let block: $Typed<PubLeafletBlocksBlockquote.Main> = {
411 $type: ids.PubLeafletBlocksBlockquote,
412 plaintext: stringValue,
413 facets,
414 };
415 return block;
416 }
417
418 if (b.type == "text") {
419 let [stringValue, facets] = getBlockContent(b.value);
420 let block: $Typed<PubLeafletBlocksText.Main> = {
421 $type: ids.PubLeafletBlocksText,
422 plaintext: stringValue,
423 facets,
424 };
425 return block;
426 }
427 if (b.type === "embed") {
428 let [url] = scan.eav(b.value, "embed/url");
429 let [height] = scan.eav(b.value, "embed/height");
430 if (!url) return;
431 let block: $Typed<PubLeafletBlocksIframe.Main> = {
432 $type: "pub.leaflet.blocks.iframe",
433 url: url.data.value,
434 height: height?.data.value || 600,
435 };
436 return block;
437 }
438 if (b.type == "image") {
439 let [image] = scan.eav(b.value, "block/image");
440 if (!image) return;
441 let [altText] = scan.eav(b.value, "image/alt");
442 let blobref = await uploadImage(image.data.src);
443 if (!blobref) return;
444 let block: $Typed<PubLeafletBlocksImage.Main> = {
445 $type: "pub.leaflet.blocks.image",
446 image: blobref,
447 aspectRatio: {
448 height: image.data.height,
449 width: image.data.width,
450 },
451 alt: altText ? altText.data.value : undefined,
452 };
453 return block;
454 }
455 if (b.type === "link") {
456 let [previewImage] = scan.eav(b.value, "link/preview");
457 let [description] = scan.eav(b.value, "link/description");
458 let [src] = scan.eav(b.value, "link/url");
459 if (!src) return;
460 let blobref = previewImage
461 ? await uploadImage(previewImage?.data.src)
462 : undefined;
463 let [title] = scan.eav(b.value, "link/title");
464 let block: $Typed<PubLeafletBlocksWebsite.Main> = {
465 $type: "pub.leaflet.blocks.website",
466 previewImage: blobref,
467 src: src.data.value,
468 description: description?.data.value,
469 title: title?.data.value,
470 };
471 return block;
472 }
473 if (b.type === "code") {
474 let [language] = scan.eav(b.value, "block/code-language");
475 let [code] = scan.eav(b.value, "block/code");
476 let [theme] = scan.eav(root_entity, "theme/code-theme");
477 let block: $Typed<PubLeafletBlocksCode.Main> = {
478 $type: "pub.leaflet.blocks.code",
479 language: language?.data.value,
480 plaintext: code?.data.value || "",
481 syntaxHighlightingTheme: theme?.data.value,
482 };
483 return block;
484 }
485 if (b.type === "math") {
486 let [math] = scan.eav(b.value, "block/math");
487 let block: $Typed<PubLeafletBlocksMath.Main> = {
488 $type: "pub.leaflet.blocks.math",
489 tex: math?.data.value || "",
490 };
491 return block;
492 }
493 if (b.type === "poll") {
494 // Get poll options from the entity
495 let pollOptions = scan.eav(b.value, "poll/options");
496 let options: PubLeafletPollDefinition.Option[] = pollOptions.map(
497 (opt) => {
498 let optionName = scan.eav(opt.data.value, "poll-option/name")?.[0];
499 return {
500 $type: "pub.leaflet.poll.definition#option",
501 text: optionName?.data.value || "",
502 };
503 },
504 );
505
506 // Create the poll definition record
507 let pollRecord: PubLeafletPollDefinition.Record = {
508 $type: "pub.leaflet.poll.definition",
509 name: "Poll", // Default name, can be customized
510 options,
511 };
512
513 // Upload the poll record
514 let { data: pollResult } = await agent.com.atproto.repo.putRecord({
515 //use the entity id as the rkey so we can associate it in the editor
516 rkey: b.value,
517 repo: did,
518 collection: pollRecord.$type,
519 record: pollRecord,
520 validate: false,
521 });
522
523 // Optimistically write poll definition to database
524 console.log(
525 await supabaseServerClient.from("atp_poll_records").upsert({
526 uri: pollResult.uri,
527 cid: pollResult.cid,
528 record: pollRecord as Json,
529 }),
530 );
531
532 // Return a poll block with reference to the poll record
533 let block: $Typed<PubLeafletBlocksPoll.Main> = {
534 $type: "pub.leaflet.blocks.poll",
535 pollRef: {
536 uri: pollResult.uri,
537 cid: pollResult.cid,
538 },
539 };
540 return block;
541 }
542 if (b.type === "button") {
543 let [text] = scan.eav(b.value, "button/text");
544 let [url] = scan.eav(b.value, "button/url");
545 if (!text || !url) return;
546 let block: $Typed<PubLeafletBlocksButton.Main> = {
547 $type: "pub.leaflet.blocks.button",
548 text: text.data.value,
549 url: url.data.value,
550 };
551 return block;
552 }
553 return;
554 }
555
556 async function canvasBlocksToRecord(
557 pageID: string,
558 did: string,
559 ): Promise<PubLeafletPagesCanvas.Block[]> {
560 let canvasBlocks = scan.eav(pageID, "canvas/block");
561 return (
562 await Promise.all(
563 canvasBlocks.map(async (canvasBlock) => {
564 let blockEntity = canvasBlock.data.value;
565 let position = canvasBlock.data.position;
566
567 // Get the block content
568 let blockType = scan.eav(blockEntity, "block/type")?.[0];
569 if (!blockType) return null;
570
571 let block: Block = {
572 type: blockType.data.value,
573 value: blockEntity,
574 parent: pageID,
575 position: "",
576 factID: canvasBlock.id,
577 };
578
579 let content = await blockToRecord(block, did);
580 if (!content) return null;
581
582 // Get canvas-specific properties
583 let width =
584 scan.eav(blockEntity, "canvas/block/width")?.[0]?.data.value || 360;
585 let rotation = scan.eav(blockEntity, "canvas/block/rotation")?.[0]
586 ?.data.value;
587
588 let canvasBlockRecord: PubLeafletPagesCanvas.Block = {
589 $type: "pub.leaflet.pages.canvas#block",
590 block: content,
591 x: Math.floor(position.x),
592 y: Math.floor(position.y),
593 width: Math.floor(width),
594 ...(rotation !== undefined && { rotation: Math.floor(rotation) }),
595 };
596
597 return canvasBlockRecord;
598 }),
599 )
600 ).filter((b): b is PubLeafletPagesCanvas.Block => b !== null);
601 }
602}
603
604function YJSFragmentToFacets(
605 node: Y.XmlElement | Y.XmlText | Y.XmlHook,
606): PubLeafletRichtextFacet.Main[] {
607 if (node.constructor === Y.XmlElement) {
608 return node
609 .toArray()
610 .map((f) => YJSFragmentToFacets(f))
611 .flat();
612 }
613 if (node.constructor === Y.XmlText) {
614 let facets: PubLeafletRichtextFacet.Main[] = [];
615 let delta = node.toDelta() as Delta[];
616 let byteStart = 0;
617 for (let d of delta) {
618 let unicodestring = new UnicodeString(d.insert);
619 let facet: PubLeafletRichtextFacet.Main = {
620 index: {
621 byteStart,
622 byteEnd: byteStart + unicodestring.length,
623 },
624 features: [],
625 };
626
627 if (d.attributes?.strikethrough)
628 facet.features.push({
629 $type: "pub.leaflet.richtext.facet#strikethrough",
630 });
631
632 if (d.attributes?.code)
633 facet.features.push({ $type: "pub.leaflet.richtext.facet#code" });
634 if (d.attributes?.highlight)
635 facet.features.push({ $type: "pub.leaflet.richtext.facet#highlight" });
636 if (d.attributes?.underline)
637 facet.features.push({ $type: "pub.leaflet.richtext.facet#underline" });
638 if (d.attributes?.strong)
639 facet.features.push({ $type: "pub.leaflet.richtext.facet#bold" });
640 if (d.attributes?.em)
641 facet.features.push({ $type: "pub.leaflet.richtext.facet#italic" });
642 if (d.attributes?.link)
643 facet.features.push({
644 $type: "pub.leaflet.richtext.facet#link",
645 uri: d.attributes.link.href,
646 });
647 if (facet.features.length > 0) facets.push(facet);
648 byteStart += unicodestring.length;
649 }
650 return facets;
651 }
652 return [];
653}
654
655type ExcludeString<T> = T extends string
656 ? string extends T
657 ? never
658 : T /* maybe literal, not the whole `string` */
659 : T; /* not a string */
660
661async function extractThemeFromFacts(
662 facts: Fact<any>[],
663 root_entity: string,
664 agent: AtpBaseClient,
665): Promise<PubLeafletPublication.Theme | undefined> {
666 let scan = scanIndexLocal(facts);
667 let pageBackground = scan.eav(root_entity, "theme/page-background")?.[0]?.data
668 .value;
669 let cardBackground = scan.eav(root_entity, "theme/card-background")?.[0]?.data
670 .value;
671 let primary = scan.eav(root_entity, "theme/primary")?.[0]?.data.value;
672 let accentBackground = scan.eav(root_entity, "theme/accent-background")?.[0]
673 ?.data.value;
674 let accentText = scan.eav(root_entity, "theme/accent-text")?.[0]?.data.value;
675 let showPageBackground = !scan.eav(
676 root_entity,
677 "theme/card-border-hidden",
678 )?.[0]?.data.value;
679 let backgroundImage = scan.eav(root_entity, "theme/background-image")?.[0];
680 let backgroundImageRepeat = scan.eav(
681 root_entity,
682 "theme/background-image-repeat",
683 )?.[0];
684
685 let theme: PubLeafletPublication.Theme = {
686 showPageBackground: showPageBackground ?? true,
687 };
688
689 if (pageBackground)
690 theme.backgroundColor = ColorToRGBA(parseColor(`hsba(${pageBackground})`));
691 if (cardBackground)
692 theme.pageBackground = ColorToRGBA(parseColor(`hsba(${cardBackground})`));
693 if (primary) theme.primary = ColorToRGB(parseColor(`hsba(${primary})`));
694 if (accentBackground)
695 theme.accentBackground = ColorToRGB(
696 parseColor(`hsba(${accentBackground})`),
697 );
698 if (accentText)
699 theme.accentText = ColorToRGB(parseColor(`hsba(${accentText})`));
700
701 // Upload background image if present
702 if (backgroundImage?.data) {
703 let imageData = await fetch(backgroundImage.data.src);
704 if (imageData.status === 200) {
705 let binary = await imageData.blob();
706 let blob = await agent.com.atproto.repo.uploadBlob(binary, {
707 headers: { "Content-Type": binary.type },
708 });
709
710 theme.backgroundImage = {
711 $type: "pub.leaflet.theme.backgroundImage",
712 image: blob.data.blob,
713 repeat: backgroundImageRepeat?.data.value ? true : false,
714 ...(backgroundImageRepeat?.data.value && {
715 width: backgroundImageRepeat.data.value,
716 }),
717 };
718 }
719 }
720
721 // Only return theme if at least one property is set
722 if (Object.keys(theme).length > 1 || theme.showPageBackground !== true) {
723 return theme;
724 }
725
726 return undefined;
727}