diff --git a/apps/studio/next.config.mjs b/apps/studio/next.config.mjs
index 131adb8367..e11fa94fb6 100644
--- a/apps/studio/next.config.mjs
+++ b/apps/studio/next.config.mjs
@@ -36,10 +36,14 @@ const ContentSecurityPolicy = `
frame-src
'self'
https://intercom-sheets.com
- https://www.intercom-reporting.com
- https://www.youtube.com
+ https://www.intercom-reporting.com
https://player.vimeo.com
https://fast.wistia.net
+ https://www.google.com
+ https://www.youtube.com
+ https://www.youtube-nocookie.com
+ https://www.onemap.gov.sg
+ https://www.facebook.com
;
object-src 'none';
script-src
@@ -63,7 +67,6 @@ const ContentSecurityPolicy = `
;
connect-src
'self'
- https://schema.isomer.gov.sg
https://browser-intake-datadoghq.com
https://*.browser-intake-datadoghq.com
https://vitals.vercel-insights.com
@@ -78,19 +81,19 @@ const ContentSecurityPolicy = `
https://api.eu.intercom.io
https://api-iam.intercom.io
https://api-iam.eu.intercom.io
- https://api-iam.au.intercom.io
- https://api-ping.intercom.io
+ https://api-iam.au.intercom.io
+ https://api-ping.intercom.io
https://nexus-websocket-a.intercom.io
wss://nexus-websocket-a.intercom.io
https://nexus-websocket-b.intercom.io
wss://nexus-websocket-b.intercom.io
- https://nexus-europe-websocket.intercom.io
- wss://nexus-europe-websocket.intercom.io
+ https://nexus-europe-websocket.intercom.io
+ wss://nexus-europe-websocket.intercom.io
https://nexus-australia-websocket.intercom.io
- wss://nexus-australia-websocket.intercom.io
+ wss://nexus-australia-websocket.intercom.io
https://uploads.intercomcdn.com
- https://uploads.intercomcdn.eu
- https://uploads.au.intercomcdn.com
+ https://uploads.intercomcdn.eu
+ https://uploads.au.intercomcdn.com
https://uploads.eu.intercomcdn.com
https://uploads.intercomusercontent.com
;
@@ -98,7 +101,7 @@ const ContentSecurityPolicy = `
'self'
blob:
https://intercom-sheets.com
- https://www.intercom-reporting.com
+ https://www.intercom-reporting.com
https://www.youtube.com
https://player.vimeo.com
https://fast.wistia.net
diff --git a/apps/studio/src/components/PageEditor/constants.ts b/apps/studio/src/components/PageEditor/constants.ts
index cd0ece3f94..7c27733412 100644
--- a/apps/studio/src/components/PageEditor/constants.ts
+++ b/apps/studio/src/components/PageEditor/constants.ts
@@ -279,6 +279,7 @@ type AllowedBlockSections = {
export const ARTICLE_ALLOWED_BLOCKS: AllowedBlockSections = [
{ label: "Basic building blocks", types: ["prose", "image", "callout"] },
+ { label: "Embed external content", types: ["map", "video"] },
]
export const CONTENT_ALLOWED_BLOCKS: AllowedBlockSections = [
@@ -287,6 +288,7 @@ export const CONTENT_ALLOWED_BLOCKS: AllowedBlockSections = [
label: "Organise complex content",
types: ["contentpic", "infocards", "accordion", "infocols"],
},
+ { label: "Embed external content", types: ["map", "video"] },
]
export const HOMEPAGE_ALLOWED_BLOCKS: AllowedBlockSections = [
{
diff --git a/apps/studio/src/features/editing-experience/components/form-builder/FormBuilder.tsx b/apps/studio/src/features/editing-experience/components/form-builder/FormBuilder.tsx
index d5e7a35367..d9f548b553 100644
--- a/apps/studio/src/features/editing-experience/components/form-builder/FormBuilder.tsx
+++ b/apps/studio/src/features/editing-experience/components/form-builder/FormBuilder.tsx
@@ -25,6 +25,8 @@ import {
jsonFormsConstControlTester,
JsonFormsDateControl,
jsonFormsDateControlTester,
+ JsonFormsEmbedControl,
+ jsonFormsEmbedControlTester,
jsonFormsGroupLayoutRenderer,
jsonFormsGroupLayoutTester,
JsonFormsImageControl,
@@ -57,6 +59,7 @@ const renderers: JsonFormsRendererRegistryEntry[] = [
{ tester: jsonFormsArrayControlTester, renderer: JsonFormsArrayControl },
{ tester: jsonFormsBooleanControlTester, renderer: JsonFormsBooleanControl },
{ tester: jsonFormsConstControlTester, renderer: JsonFormsConstControl },
+ { tester: jsonFormsEmbedControlTester, renderer: JsonFormsEmbedControl },
{ tester: jsonFormsIntegerControlTester, renderer: JsonFormsIntegerControl },
{ tester: jsonFormsImageControlTester, renderer: JsonFormsImageControl },
{ tester: jsonFormsLinkControlTester, renderer: JsonFormsLinkControl },
diff --git a/apps/studio/src/features/editing-experience/components/form-builder/renderers/controls/JsonFormsEmbedControl.tsx b/apps/studio/src/features/editing-experience/components/form-builder/renderers/controls/JsonFormsEmbedControl.tsx
new file mode 100644
index 0000000000..12242c6fa0
--- /dev/null
+++ b/apps/studio/src/features/editing-experience/components/form-builder/renderers/controls/JsonFormsEmbedControl.tsx
@@ -0,0 +1,242 @@
+import type { ControlProps, RankedTester } from "@jsonforms/core"
+import {
+ Box,
+ FormControl,
+ HStack,
+ Icon,
+ ListItem,
+ Modal,
+ ModalBody,
+ ModalContent,
+ ModalFooter,
+ ModalHeader,
+ ModalOverlay,
+ Text,
+ UnorderedList,
+ useDisclosure,
+ VStack,
+} from "@chakra-ui/react"
+import { and, isStringControl, rankWith, schemaMatches } from "@jsonforms/core"
+import { withJsonFormsControlProps } from "@jsonforms/react"
+import {
+ Button,
+ FormErrorMessage,
+ FormLabel,
+ Infobox,
+ ModalCloseButton,
+ Textarea,
+} from "@opengovsg/design-system-react"
+import {
+ MAPS_EMBED_URL_REGEXES,
+ VIDEO_EMBED_URL_REGEXES,
+} from "@opengovsg/isomer-components"
+import { BiLink } from "react-icons/bi"
+import { z } from "zod"
+
+import { JSON_FORMS_RANKING } from "~/constants/formBuilder"
+import { useZodForm } from "~/lib/form"
+import {
+ EMBED_NAME_MAPPING,
+ getEmbedNameFromUrl,
+ getIframeSrc,
+} from "../../../utils"
+
+const SUPPORTED_MAPS = Object.keys(MAPS_EMBED_URL_REGEXES).map(
+ (key) => EMBED_NAME_MAPPING[key as keyof typeof MAPS_EMBED_URL_REGEXES],
+)
+
+const SUPPORTED_VIDEOS = Object.keys(VIDEO_EMBED_URL_REGEXES).map(
+ (key) => EMBED_NAME_MAPPING[key as keyof typeof VIDEO_EMBED_URL_REGEXES],
+)
+
+export const jsonFormsEmbedControlTester: RankedTester = rankWith(
+ JSON_FORMS_RANKING.TextControl,
+ and(
+ isStringControl,
+ schemaMatches((schema) => schema.format === "embed"),
+ ),
+)
+
+interface EmbedCodeModalProps {
+ isOpen: boolean
+ onClose: () => void
+ onSave: (embedCode: string) => void
+ urlPattern?: string
+}
+
+function EmbedCodeModal({
+ isOpen,
+ onClose,
+ onSave,
+ urlPattern,
+}: EmbedCodeModalProps) {
+ const {
+ register,
+ handleSubmit,
+ reset,
+ formState: { errors, isValid },
+ } = useZodForm({
+ mode: "onChange",
+ schema: z.object({
+ embedCode: z
+ .string()
+ .min(1, "Embed code is required")
+ .refine(
+ (value) => {
+ const iframeSrc = getIframeSrc(value)
+
+ if (!urlPattern || !iframeSrc) {
+ return !!iframeSrc
+ }
+
+ return new RegExp(urlPattern).test(iframeSrc)
+ },
+ {
+ message:
+ "This code doesn’t look valid. Copy and paste the code as-is without modifications.",
+ },
+ ),
+ }),
+ reValidateMode: "onChange",
+ })
+
+ const onSubmit = handleSubmit(({ embedCode }) => {
+ onClose()
+ onSave(embedCode)
+ reset()
+ })
+
+ return (
+
+
+
+
+
+
+ )
+}
+
+export function JsonFormsEmbedControl({
+ data,
+ label,
+ handleChange,
+ path,
+ description,
+ required,
+ errors,
+ schema,
+}: ControlProps) {
+ const {
+ isOpen: isEmbedModalOpen,
+ onOpen: onEmbedModalOpen,
+ onClose: onEmbedModalClose,
+ } = useDisclosure()
+
+ const handleEmbedCodeSave = (embedCode: string) => {
+ const src = getIframeSrc(embedCode)
+ handleChange(path, src)
+ }
+
+ return (
+ <>
+ handleEmbedCodeSave(embedCode)}
+ urlPattern={schema.pattern}
+ />
+
+
+
+ {label}
+
+
+
+
+
+
+ {getEmbedNameFromUrl(String(data || "")) ??
+ "External content"}
+
+
+
+
+
+ {String(data || "")}
+
+
+
+
+
+
+
+
+ {label} {errors}
+
+
+
+ >
+ )
+}
+
+export default withJsonFormsControlProps(JsonFormsEmbedControl)
diff --git a/apps/studio/src/features/editing-experience/components/form-builder/renderers/controls/index.ts b/apps/studio/src/features/editing-experience/components/form-builder/renderers/controls/index.ts
index b1e971ba35..595d2fc752 100644
--- a/apps/studio/src/features/editing-experience/components/form-builder/renderers/controls/index.ts
+++ b/apps/studio/src/features/editing-experience/components/form-builder/renderers/controls/index.ts
@@ -62,3 +62,7 @@ export {
default as JsonFormsRefControl,
jsonFormsRefControlTester,
} from "./JsonFormsRefControl"
+export {
+ default as JsonFormsEmbedControl,
+ jsonFormsEmbedControlTester,
+} from "./JsonFormsEmbedControl"
diff --git a/apps/studio/src/features/editing-experience/components/utils.ts b/apps/studio/src/features/editing-experience/components/utils.ts
index 034cc55670..258cf3d80d 100644
--- a/apps/studio/src/features/editing-experience/components/utils.ts
+++ b/apps/studio/src/features/editing-experience/components/utils.ts
@@ -3,6 +3,11 @@ import type {
IsomerGeneratedSiteProps,
} from "@opengovsg/isomer-components"
import type { UseMutateAsyncFunction } from "@tanstack/react-query"
+import {
+ MAPS_EMBED_URL_REGEXES,
+ VIDEO_EMBED_URL_REGEXES,
+} from "@opengovsg/isomer-components"
+import DOMPurify from "isomorphic-dompurify"
import set from "lodash/set"
import type collectionSitemap from "~/features/editing-experience/data/collectionSitemap.json"
@@ -13,6 +18,17 @@ import type {
import type { ModifiedAsset } from "~/types/assets"
import { PLACEHOLDER_IMAGE_FILENAME } from "./constants"
+export const EMBED_NAME_MAPPING: Record<
+ keyof typeof MAPS_EMBED_URL_REGEXES | keyof typeof VIDEO_EMBED_URL_REGEXES,
+ string
+> = {
+ fbvideo: "Facebook Video",
+ googlemaps: "Google Map",
+ onemap: "OneMap",
+ youtube: "YouTube",
+ vimeo: "Vimeo",
+}
+
export const generateResourceUrl = (value: string) => {
return (
value
@@ -91,3 +107,31 @@ export const generatePreviewSitemap = (
})),
} as IsomerGeneratedSiteProps["siteMap"]
}
+
+export const getIframeSrc = (embedCode: string): string | undefined => {
+ const elem = DOMPurify.sanitize(embedCode, {
+ ALLOWED_TAGS: ["iframe"],
+ RETURN_DOM_FRAGMENT: true,
+ })
+ const sanitizedUrl = elem.firstElementChild?.getAttribute("src")
+
+ return sanitizedUrl ?? undefined
+}
+
+export const getEmbedNameFromUrl = (url: string) =>
+ Object.entries({
+ ...MAPS_EMBED_URL_REGEXES,
+ ...VIDEO_EMBED_URL_REGEXES,
+ }).reduce((acc, curr) => {
+ if (acc) {
+ // Embed name already found, return it
+ return acc
+ }
+
+ const [embedName, regex] = curr
+ if (new RegExp(regex).test(url)) {
+ return EMBED_NAME_MAPPING[embedName as keyof typeof EMBED_NAME_MAPPING]
+ }
+
+ return undefined
+ }, undefined)
diff --git a/apps/studio/tests/msw/handlers/page.ts b/apps/studio/tests/msw/handlers/page.ts
index 66ff47ca42..9c1993f7ad 100644
--- a/apps/studio/tests/msw/handlers/page.ts
+++ b/apps/studio/tests/msw/handlers/page.ts
@@ -413,6 +413,16 @@ export const pageHandlers = {
title: "This is an infocols block",
infoBoxes: [],
},
+ {
+ type: "map",
+ title: "Singapore region",
+ url: "https://www.google.com/maps/embed?pb=!1m14!1m12!1m3!1d127639.0647119137!2d103.79481771806647!3d1.343949056391766!2m3!1f0!2f0!3f0!3m2!1i1024!2i768!4f13.1!5e0!3m2!1sen!2ssg!4v1731681854346!5m2!1sen!2ssg",
+ },
+ {
+ type: "video",
+ title: "Rick Astley - Never Gonna Give You Up",
+ url: "https://www.youtube.com/embed/dQw4w9WgXcQ?si=ggGGn4uvFWAIelWD",
+ },
],
version: "0.1.0",
},
diff --git a/packages/components/src/interfaces/complex/Map.ts b/packages/components/src/interfaces/complex/Map.ts
index 9f032c459d..ccceb15ab9 100644
--- a/packages/components/src/interfaces/complex/Map.ts
+++ b/packages/components/src/interfaces/complex/Map.ts
@@ -9,6 +9,7 @@ export const MapSchema = Type.Object(
url: Type.String({
title: "Map to embed",
pattern: MAPS_EMBED_URL_PATTERN,
+ format: "embed",
}),
title: Type.String({
title: "Label for screen readers",
diff --git a/packages/components/src/interfaces/complex/Video.ts b/packages/components/src/interfaces/complex/Video.ts
index 112dab2611..bbd3d16d66 100644
--- a/packages/components/src/interfaces/complex/Video.ts
+++ b/packages/components/src/interfaces/complex/Video.ts
@@ -9,6 +9,7 @@ export const VideoSchema = Type.Object(
url: Type.String({
title: "Video to embed",
pattern: VIDEO_EMBED_URL_PATTERN,
+ format: "embed",
}),
title: Type.String({
title: "Label for screen readers",