diff --git a/config/datocms/migrations/1705948914_embedBlock.ts b/config/datocms/migrations/1705948914_embedBlock.ts new file mode 100644 index 0000000..5461fbc --- /dev/null +++ b/config/datocms/migrations/1705948914_embedBlock.ts @@ -0,0 +1,111 @@ +import { Client } from '@datocms/cli/lib/cma-client-node'; + +export default async function (client: Client) { + console.log('Manage upload filters'); + + console.log('Install plugin "OEmbed (embed anything)"'); + await client.plugins.create({ + // @ts-expect-error next-line DatoCMS auto-generated + id: 'AgNo2ntNTg-N-smf_zQIGQ', + package_name: 'datocms-plugin-oembed', + }); + + console.log('Create new models/block models'); + + console.log('Create block model "Embed Block" (`embed_block`)'); + await client.itemTypes.create( + { + // @ts-expect-error next-line DatoCMS auto-generated + id: 'VZvVfu52RZK81WG0Dxp-FQ', + name: 'Embed Block', + api_key: 'embed_block', + modular_block: true, + inverse_relationships_enabled: false, + }, + { skip_menu_item_creation: true } + ); + + console.log('Creating new fields/fieldsets'); + + console.log( + 'Create Single-line string field "URL" (`url`) in block model "Embed Block" (`embed_block`)' + ); + await client.fields.create('VZvVfu52RZK81WG0Dxp-FQ', { + // @ts-expect-error next-line DatoCMS auto-generated + id: 'IX57s5MtS9OdHC7GdbEbgg', + label: 'URL', + field_type: 'string', + api_key: 'url', + validators: { required: {}, format: { predefined_pattern: 'url' } }, + appearance: { + addons: [], + editor: 'single_line', + parameters: { heading: false }, + }, + default_value: '', + }); + + console.log( + 'Create JSON field "Data" (`data`) in block model "Embed Block" (`embed_block`)' + ); + await client.fields.create('VZvVfu52RZK81WG0Dxp-FQ', { + // @ts-expect-error next-line DatoCMS auto-generated + id: 'MGCF_FuyQaGzN9KvKzmgNA', + label: 'Data', + field_type: 'json', + api_key: 'data', + validators: { required: {} }, + appearance: { + addons: [ + { + id: 'AgNo2ntNTg-N-smf_zQIGQ', + parameters: { urlFieldKey: 'url' }, + field_extension: 'oembedPlugin', + }, + ], + editor: 'json', + parameters: {}, + }, + default_value: null, + }); + + console.log('Update existing fields/fieldsets'); + + console.log( + 'Update Modular content field "Body" (`body_blocks`) in model "Home" (`home`)' + ); + await client.fields.update('pUj2PObgTyC-8X4lvZLMBA', { + validators: { + rich_text_blocks: { + item_types: [ + 'BRbU6VwTRgmG5SbwUs0rBg', + 'PAk40zGjQJCcDXXPgygUrA', + 'VZvVfu52RZK81WG0Dxp-FQ', + 'V80liDVtRC-UYgd3Sm-dXg', + 'ZdBokLsWRgKKjHrKeJzdpw', + 'gezG9nO7SfaiWcWnp-HNqw', + '0SxYNS2CR1it_5LHYWuEQg', + ], + }, + }, + }); + + console.log( + 'Update Modular content field "Body" (`body_blocks`) in model "Page" (`page`)' + ); + await client.fields.update('Q-z1nyMsQtC8Sr6w6J2oGw', { + validators: { + rich_text_blocks: { + item_types: [ + 'BRbU6VwTRgmG5SbwUs0rBg', + 'PAk40zGjQJCcDXXPgygUrA', + 'VZvVfu52RZK81WG0Dxp-FQ', + 'V80liDVtRC-UYgd3Sm-dXg', + 'ZdBokLsWRgKKjHrKeJzdpw', + 'gezG9nO7SfaiWcWnp-HNqw', + '0SxYNS2CR1it_5LHYWuEQg', + ], + }, + }, + }); +} diff --git a/datocms-environment.ts b/datocms-environment.ts index e56da37..51b7743 100644 --- a/datocms-environment.ts +++ b/datocms-environment.ts @@ -3,5 +3,5 @@ * @see docs/getting-started.md on how to use this file * @see docs/decision-log/2023-10-24-datocms-env-file.md on why file is preferred over env vars */ -export const datocmsEnvironment = 'redirect-rules'; +export const datocmsEnvironment = 'embed-block'; export const datocmsBuildTriggerId = '30535'; diff --git a/package-lock.json b/package-lock.json index 36f116b..880a7f8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,10 +17,12 @@ "astro": "^4.0.7", "datocms-listen": "^0.1.15", "datocms-structured-text-utils": "^2.0.4", + "get-video-id": "^3.6.5", "globby": "^13.2.2", "html-validate": "^8.7.4", "jiti": "^1.20.0", "nanostores": "^0.9.5", + "oembed-providers": "^1.0.20230906", "promise-all-props": "^3.0.0", "rosetta": "^1.1.0", "typescript": "^5.2.2" @@ -9097,6 +9099,14 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-video-id": { + "version": "3.6.5", + "resolved": "https://registry.npmjs.org/get-video-id/-/get-video-id-3.6.5.tgz", + "integrity": "sha512-9RPHSQANIeGW5rU3CjWdm1zi+wUHbBWX+m4m+dqQAavrZ9p1P1J7AbxGvVEEHRyGfGrmMf5PqiRWYMyfqM+QYA==", + "engines": { + "node": ">=10" + } + }, "node_modules/github-from-package": { "version": "0.0.0", "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", @@ -13128,6 +13138,11 @@ "node": ">=0.10.0" } }, + "node_modules/oembed-providers": { + "version": "1.0.20230906", + "resolved": "https://registry.npmjs.org/oembed-providers/-/oembed-providers-1.0.20230906.tgz", + "integrity": "sha512-RugawNF0aO86Lk2vcDzQelTJiuDwq6pLU8mYs1H8vM/+Wn78uz7zto/cwqXf/4y85/K8f5X91+fZoVKDUvLB9w==" + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", diff --git a/package.json b/package.json index 83c89a4..bb400ca 100644 --- a/package.json +++ b/package.json @@ -44,10 +44,12 @@ "astro": "^4.0.7", "datocms-listen": "^0.1.15", "datocms-structured-text-utils": "^2.0.4", + "get-video-id": "^3.6.5", "globby": "^13.2.2", "html-validate": "^8.7.4", "jiti": "^1.20.0", "nanostores": "^0.9.5", + "oembed-providers": "^1.0.20230906", "promise-all-props": "^3.0.0", "rosetta": "^1.1.0", "typescript": "^5.2.2" diff --git a/src/assets/icons/twitter.svg b/src/assets/icons/twitter.svg new file mode 100644 index 0000000..51c8992 --- /dev/null +++ b/src/assets/icons/twitter.svg @@ -0,0 +1,2 @@ + + diff --git a/src/blocks/Blocks.astro b/src/blocks/Blocks.astro index d31e9fa..f8fdc69 100644 --- a/src/blocks/Blocks.astro +++ b/src/blocks/Blocks.astro @@ -1,5 +1,6 @@ --- import type { AnyBlock } from './Blocks'; +import EmbedBlock from './EmbedBlock/EmbedBlock.astro'; import ImageBlock from './ImageBlock/ImageBlock.astro'; import PagePartialBlock from './PagePartialBlock/PagePartialBlock.astro'; import TableBlock from './TableBlock/TableBlock.astro'; @@ -8,6 +9,7 @@ import TextImageBlock from './TextImageBlock/TextImageBlock.astro'; import VideoEmbedBlock from './VideoEmbedBlock/VideoEmbedBlock.astro'; const blocksByTypename = { + EmbedBlockRecord: EmbedBlock, ImageBlockRecord: ImageBlock, PagePartialBlockRecord: PagePartialBlock, TableBlockRecord: TableBlock, diff --git a/src/blocks/Blocks.d.ts b/src/blocks/Blocks.d.ts index 79ea8e9..214bfa8 100644 --- a/src/blocks/Blocks.d.ts +++ b/src/blocks/Blocks.d.ts @@ -1,4 +1,5 @@ import { + EmbedBlockFragment, ImageBlockFragment, PagePartialBlockFragment, TableBlockFragment, @@ -8,6 +9,7 @@ import { } from '@lib/types/datocms'; export type AnyBlock = + | EmbedBlockFragment | ImageBlockFragment | PagePartialBlockFragment | TableBlockFragment diff --git a/src/blocks/EmbedBlock/EmbedBlock.astro b/src/blocks/EmbedBlock/EmbedBlock.astro new file mode 100644 index 0000000..7d92d6c --- /dev/null +++ b/src/blocks/EmbedBlock/EmbedBlock.astro @@ -0,0 +1,43 @@ +--- +import type { EmbedBlockFragment } from '@lib/types/datocms'; +import { getOEmbedProvider } from './index'; +import type { OEmbedAny } from './index'; +import BasicEmbed from './embeds/Basic.astro'; +import DefaultEmbed from './embeds/Default.astro'; +import TwitterEmbed from './embeds/Twitter.astro'; +import VimeoEmbed from './embeds/Vimeo.astro'; +import YouTubeEmbed from './embeds/YouTube.astro'; + +interface Props { + block: EmbedBlockFragment; +} +const { block } = Astro.props; +const { data, url }: { data: OEmbedAny; url: string } = block; +const providerName = (data || getOEmbedProvider(url))?.provider_name; +const isProvider = (name: string) => + providerName?.toLowerCase() === name.toLowerCase(); +--- + +
+ { + data && data.html ? ( + isProvider('Twitter') ? ( + + ) : isProvider('Vimeo') ? ( + + ) : isProvider('YouTube') ? ( + + ) : ( + + ) + ) : ( + + ) + } +
+ + diff --git a/src/blocks/EmbedBlock/EmbedBlock.fragment.graphql b/src/blocks/EmbedBlock/EmbedBlock.fragment.graphql new file mode 100644 index 0000000..15f7f15 --- /dev/null +++ b/src/blocks/EmbedBlock/EmbedBlock.fragment.graphql @@ -0,0 +1,5 @@ +fragment EmbedBlock on EmbedBlockRecord { + id + url + data +} diff --git a/src/blocks/EmbedBlock/README.md b/src/blocks/EmbedBlock/README.md new file mode 100644 index 0000000..63c1966 --- /dev/null +++ b/src/blocks/EmbedBlock/README.md @@ -0,0 +1,16 @@ +# Embed Block + +**Renders external content based on the block's OEmbed data.** + +## Features + +- Supports [~300 content providers](https://oembed.com/providers.json) (like Twitter, Flickr, YouTube, etc) using the [OEmbed](https://oembed.com/) protocol. +- Renders a noscript embed version which is dynamically enhanced. +- Lazy loads embed scripts and iframes to improve performance. +- Provides a mechanism to define custom renderer per provider. +- If OEmbed data is unavailable, a link is displayed with the page title of the given URL. + +## Relevant links + +- Embed Block data is preloaded in the CMS using the [DatoCMS OEmbed Plugin](https://github.com/voorhoede/datocms-plugin-oembed/). + diff --git a/src/blocks/EmbedBlock/embeds/Basic.astro b/src/blocks/EmbedBlock/embeds/Basic.astro new file mode 100644 index 0000000..f63b1c0 --- /dev/null +++ b/src/blocks/EmbedBlock/embeds/Basic.astro @@ -0,0 +1,42 @@ +--- +import { getEmbedText } from '../index'; + +const { data, url } = Astro.props; +const placeholderText = getEmbedText(data); +const aspectRatio = data.width && data.height ? data.width / data.height : null; +const maxWidth = data.width ? `${data.width}px` : '100%'; +--- + + + { + data.thumbnail_url ? ( + {placeholderText} + ) : ( +
+ {data.title ? placeholderText : `${data.provider_name}: ${url}`} +
+ ) + } +
+ + diff --git a/src/blocks/EmbedBlock/embeds/Default.astro b/src/blocks/EmbedBlock/embeds/Default.astro new file mode 100644 index 0000000..197c3cd --- /dev/null +++ b/src/blocks/EmbedBlock/embeds/Default.astro @@ -0,0 +1,77 @@ +--- +import { extractScripts, getEmbedText, isIframeHtml, sanatizeHtml } from '../index'; + +const { data, url, class: classList, ...props } = Astro.props; +const { noscriptHtml, scripts } = extractScripts(data.html); +const html = sanatizeHtml(noscriptHtml); +const placeholderText = getEmbedText(data); +const aspectRatio = data.width && data.height ? data.width / data.height : null; +const maxWidth = data.width ? `${data.width}px` : '100%'; +--- + + + + { isIframeHtml(html) + ? (<> + + { data.thumbnail_url + ? { + : { placeholderText } + } + + + ) + :
+ } + + + { scripts.map(({ src, ...attributes }) => ( + + + diff --git a/src/blocks/EmbedBlock/embeds/Default.client.ts b/src/blocks/EmbedBlock/embeds/Default.client.ts new file mode 100644 index 0000000..fa0ee81 --- /dev/null +++ b/src/blocks/EmbedBlock/embeds/Default.client.ts @@ -0,0 +1,67 @@ +const enhanceIntersectedEmbeds = (entries: IntersectionObserverEntry[]) => { + entries.forEach((entry) => { + if (entry.isIntersecting && entry.target instanceof DefaultEmbed) { + entry.target.enhance(); + } + }); +}; + +const observer = new IntersectionObserver( + enhanceIntersectedEmbeds, + { rootMargin: '100px' } +); + +class DefaultEmbed extends HTMLElement { + #isEnhanced = false; + #provider: string; + #template?: HTMLTemplateElement; + #scriptTags: HTMLScriptElement[] = []; + + constructor() { + super(); + this.#provider = this.dataset.provider || ''; + this.#template = this.querySelector('template') || undefined; + this.#scriptTags = Array.from(this.querySelectorAll('script[data-src]')); + } + + connectedCallback() { + observer.observe(this); + } + + enhance() { + observer.unobserve(this); + + if (this.#isEnhanced) { + return; + } + this.#isEnhanced = true; + + if (!this.#template && this.#scriptTags.length === 0) { + return; + } + + console.log(`Todo: only enhance after consent for "${this.#provider}". See https://github.com/voorhoede/head-start/issues/49`); + + if (this.#template) { + const clone = this.#template.content.cloneNode(true); + this.appendChild(clone); + } + + if (this.#scriptTags.length === 0) { + this.setAttribute('data-enhanced', 'true'); + return; + } + + const allScriptsLoaded = Promise.all(this.#scriptTags.map((script) => { + return new Promise((resolve) => script.addEventListener('load', resolve)); + })); + allScriptsLoaded.then(() => { + this.setAttribute('data-enhanced', 'true'); + }); + this.#scriptTags.forEach((script) => { + script.src = script.dataset.src as string; + }); + } +} + +customElements.define('default-embed', DefaultEmbed); diff --git a/src/blocks/EmbedBlock/embeds/Twitter.astro b/src/blocks/EmbedBlock/embeds/Twitter.astro new file mode 100644 index 0000000..b0855f3 --- /dev/null +++ b/src/blocks/EmbedBlock/embeds/Twitter.astro @@ -0,0 +1,48 @@ +--- +import Icon from '@components/Icon'; +import DefaultEmbed from './Default.astro'; +import { extractScripts } from '../index'; +import type { OEmbedRich } from '../index'; + +const data = Astro.props.data as OEmbedRich; +const url = Astro.props.url; +const { noscriptHtml } = extractScripts(data.html); +--- + +