Skip to content

Commit

Permalink
feature: Embed Block (#111)
Browse files Browse the repository at this point in the history
# Changes

- Adds an Embed Block that renders external content based on the block's
OEmbed data.
- 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.
- Adds specific implementations for Twitter, YouTube and Vimeo (the last
two reusing the Video Embed Block).
- If OEmbed data is unavailable, a link is displayed with the page title
of the given URL.
- Embed Block data is preloaded in the CMS using the [DatoCMS OEmbed
Plugin](https://github.com/voorhoede/datocms-plugin-oembed/).

# Associated issue

Resolves #107 

# How to test

1. Open preview link
2. Navigate to [page with
embeds](https://feat-embed-block.head-start.pages.dev/en/embeds/)
3. Verify the different embeds are rendered correctly
4. Verify embeds are lazy loaded when scrolled into view
5. Verify embeds show a basic (non-enhanced) version when JS is disabled
6. Review Embed Block model in the CMS
7. Add an Embed Block to a page
8. Verify a preview is displayed and the embed data is set
9. Verify the new embed is rendered on the page

# Checklist

- [x] I have performed a self-review of my own code
- [x] I have made sure that my PR is easy to review (not too big,
includes comments)
- [x] I have made updated relevant documentation files (in project
README, docs/, etc)
- ~~I have added a decision log entry if the change affects the
architecture or changes a significant technology~~
- ~~I have notified a reviewer~~

<!-- Please strike through and check off all items that do not apply
(rather than removing them) -->
  • Loading branch information
jbmoelker authored Jan 22, 2024
1 parent e004aa4 commit 3e00e91
Show file tree
Hide file tree
Showing 22 changed files with 624 additions and 4 deletions.
111 changes: 111 additions & 0 deletions config/datocms/migrations/1705948914_embedBlock.ts
Original file line number Diff line number Diff line change
@@ -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',
],
},
},
});
}
2 changes: 1 addition & 1 deletion datocms-environment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
15 changes: 15 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 2 additions & 0 deletions src/assets/icons/twitter.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 2 additions & 0 deletions src/blocks/Blocks.astro
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions src/blocks/Blocks.d.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
EmbedBlockFragment,
ImageBlockFragment,
PagePartialBlockFragment,
TableBlockFragment,
Expand All @@ -8,6 +9,7 @@ import {
} from '@lib/types/datocms';

export type AnyBlock =
| EmbedBlockFragment
| ImageBlockFragment
| PagePartialBlockFragment
| TableBlockFragment
Expand Down
43 changes: 43 additions & 0 deletions src/blocks/EmbedBlock/EmbedBlock.astro
Original file line number Diff line number Diff line change
@@ -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();
---

<div class='embed-block'>
{
data && data.html ? (
isProvider('Twitter') ? (
<TwitterEmbed {data} {url} />
) : isProvider('Vimeo') ? (
<VimeoEmbed {data} {url} />
) : isProvider('YouTube') ? (
<YouTubeEmbed {data} {url} />
) : (
<DefaultEmbed {data} {url} />
)
) : (
<BasicEmbed {data} {url} />
)
}
</div>

<style>
.embed-block {
margin-block: 20px;
}
</style>
5 changes: 5 additions & 0 deletions src/blocks/EmbedBlock/EmbedBlock.fragment.graphql
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
fragment EmbedBlock on EmbedBlockRecord {
id
url
data
}
16 changes: 16 additions & 0 deletions src/blocks/EmbedBlock/README.md
Original file line number Diff line number Diff line change
@@ -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/).

42 changes: 42 additions & 0 deletions src/blocks/EmbedBlock/embeds/Basic.astro
Original file line number Diff line number Diff line change
@@ -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%';
---

<a href={url} rel='noreferrer noopener' target='_blank'>
{
data.thumbnail_url ? (
<img
alt={placeholderText}
src={data.thumbnail_url}
loading='lazy'
style={{
aspectRatio,
maxWidth,
}}
/>
) : (
<div>
{data.title ? placeholderText : `${data.provider_name}: ${url}`}
</div>
)
}
</a>

<style>
a {
display: block;
background-color: #ebebeb;
}
img {
display: block;
}
div {
padding: 10px;
word-break: break-word;
}
</style>
77 changes: 77 additions & 0 deletions src/blocks/EmbedBlock/embeds/Default.astro
Original file line number Diff line number Diff line change
@@ -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%';
---

<default-embed
data-enhanced="false"
data-provider={data.provider_name}
class:list={[ classList, { 'with-iframe': isIframeHtml(html) } ]}
{ ...props }
>
<slot name="html">
{ isIframeHtml(html)
? (<>
<a href={ url } rel="noreferrer noopener" target="_blank" class="embed-placeholder">
{ data.thumbnail_url
? <img alt={ placeholderText } src={ data.thumbnail_url } loading="lazy" />
: <span>{ placeholderText }</span>
}
</a>
<template set:html={html}></template>
</>)
: <div set:html={html} />
}
</slot>
<slot name="noscript">
{ scripts.map(({ src, ...attributes }) => (
<script data-src={ src } {...attributes} />
)) }
</slot>
</default-embed>

<script src="./Default.client.ts"></script>

<style define:vars={{ aspectRatio, maxWidth }}>
default-embed {
display: block;
aspect-ratio: var(--aspectRatio);
max-width: var(--maxWidth);
overflow: hidden;
background-color: #ebebeb;
}

.embed-placeholder img {
display: block;
width: 100%;
height: 100% !important;
object-fit: cover;
object-position: center;
}
default-embed[data-enhanced="false"] .embed-placeholder {
display: block;
width: 100%;
height: 100%;
}
default-embed[data-enhanced="true"] .embed-placeholder {
display: none;
}

/* only force sizing on embeds with iframe in noscript html */
/* as scripts (like Twitter widget) may load more than just the iframe */
default-embed.with-iframe :global(iframe) {
aspect-ratio: var(--aspectRatio);
max-width: 100% !important;
height: auto !important;
}
default-embed :global(img) {
max-width: 100% !important;
height: auto !important;
}
</style>
Loading

0 comments on commit 3e00e91

Please sign in to comment.