diff --git a/EXTERNAL_IMAGES.md b/EXTERNAL_IMAGES.md new file mode 100644 index 000000000..3ec0441c7 --- /dev/null +++ b/EXTERNAL_IMAGES.md @@ -0,0 +1,45 @@ +# Using images from external URLs in AEM Franklin pages + +## Introduction +This document explains a mechanism for getting images served from external URLs on AEM Franklin pages. You may find this useful if you want to have your images served from an external assets repository. + +## Process +During the page authoring process, the author has to specify the external URL from which the image is served. This is done by placing external image links containing the hyperlinked publicly accessible image URLs on the Word/Google Document. The image links are then replaced with the actual images during the page rendering process. + +### Note for site authors +Here's [an example page and document](https://ext-images--franklin-assets-selector--hlxsites.hlx.page/external-images-example?view-doc-source=true) that shows how to use external images in AEM Franklin pages. +You can specify external images by just copying and pasting the image URL in the Word/Google Document. The image URL must be hyperlinked. If the hyperlink has an image file extension, it will be treated as an external image. If the hyperlink does not have an image file extension, it will be treated as a regular hyperlink. +Alternatively, you can also explicitly specify an external image marker and adding the external image url as a hyperlink in it. +The above [example page](https://ext-images--franklin-assets-selector--hlxsites.hlx.page/external-images-example?view-doc-source=true) demonstrates both the approaches. + +### Note for site developers +Anchor tags get treated as external images if their `textContent` is same as their `href` attribute and the URL specified in `href` is of an image file extension. For e.g. `'jpg', 'jpeg', 'png', 'gif', 'webp'`. Web optimized image formats such as `webp` should be preferred. You can find the implementation of this [here](https://github.com/hlxsites/franklin-assets-selector/blob/9145aeac55512ec199152065b16db6c24cea3421/scripts/scripts.js#L105-L110) + +Alternatively, an *image marker* text can be used to explicitly indicate external images. This is a pre-configured value. You can configure it [here](https://github.com/hlxsites/franklin-assets-selector/blob/9145aeac55512ec199152065b16db6c24cea3421/scripts/scripts.js#L227). + + +Also note that for creating optimized `picture` tags for external images, you must override `createOptimizedPicture` function. You can find a sample overidden implementation of `createOptimizedPicture` [here](https://github.com/hlxsites/franklin-assets-selector/blob/9145aeac55512ec199152065b16db6c24cea3421/scripts/scripts.js#L142-L182). + +To summarize, most of the logic for this [is here](https://github.com/hlxsites/franklin-assets-selector/blob/9145aeac55512ec199152065b16db6c24cea3421/scripts/scripts.js#L69-L218) and trigger point for it starts with `decorateExternalImages`. For e.g. [here with an implict external image decoration](https://github.com/hlxsites/franklin-assets-selector/blob/9145aeac55512ec199152065b16db6c24cea3421/scripts/scripts.js#L229-L230). + +``` +export function decorateMain(main) { + // decorate external images with implicit external image detection + decorateExternalImages(main); + ... +} +``` + +And [here with an explicit external image marker](https://github.com/hlxsites/franklin-assets-selector/blob/9145aeac55512ec199152065b16db6c24cea3421/scripts/scripts.js#L226-L227). +``` +export function decorateMain(main) { + // decorate external images with explicit external image marker + decorateExternalImages(main, '//External Image//'); +... +} +``` + +## How does this work? +During the page rendering process, the frontend code replaces the anchor tags identified as exteernal images on the page with the `picture` tags with `src`/`srcset` attributes set as the external image's url as specified in the external image link placed on the Word / Google Document during the page authoring process. + +Authors can optionally specify query paramaters in the hyperlinked external url and they would be retained in the `picture` tag's `src`/`srcset` attributes. These are useful for specifying image delivery parameters such as image width, height, format, etc. as understood by the external image delivery service. \ No newline at end of file diff --git a/README.md b/README.md index 774cf5514..a24a135fd 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,11 @@ -# Your Project's Title... -Your project's description... +# AEM Asset Selector for Franklin Authoring +Integration between AEM Asset Selector and AEM Franklin to make AEM assets available in Franklin site authoring. + +# High level flow + +[Link to Diagram Source](https://lucid.app/lucidchart/d6db1b7d-144f-4ac9-94a2-fce760ed2ca4/edit?viewport_loc=-368%2C-403%2C1899%2C1069%2C0_0&invitationId=inv_cd6848d0-dfc0-4be9-b0cb-3cae5a1ba757) + +![High Level Flow](/resources/using-asset-selector-with-franklin.jpeg) ## Environments - Preview: https://main--{repo}--{owner}.hlx.page/ diff --git a/blocks/embed/embed.css b/blocks/embed/embed.css new file mode 100644 index 000000000..a8901799e --- /dev/null +++ b/blocks/embed/embed.css @@ -0,0 +1,65 @@ +main .embed { + width: unset; + text-align: center; + max-width: 800px; + margin: 32px auto; + } + + main .embed > div { + display: flex; + justify-content: center; + } + + main .embed.embed-twitter .twitter-tweet-rendered { + margin-left: auto; + margin-right: auto; + } + + main .embed .embed-placeholder { + width: 100%; + aspect-ratio: 16 / 9; + position: relative; + } + + main .embed .embed-placeholder > * { + display: flex; + align-items: center; + justify-content: center; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + } + + main .embed .embed-placeholder picture img { + width: 100%; + height: 100%; + object-fit: cover; + } + + main .embed .embed-placeholder-play button { + box-sizing: border-box; + position: relative; + display: block; + transform: scale(3); + width: 22px; + height: 22px; + border: 2px solid; + border-radius: 20px; + padding: 0; + } + + main .embed .embed-placeholder-play button::before { + content: ""; + display: block; + box-sizing: border-box; + position: absolute; + width: 0; + height: 10px; + border-top: 5px solid transparent; + border-bottom: 5px solid transparent; + border-left: 6px solid; + top: 4px; + left: 7px; + } \ No newline at end of file diff --git a/blocks/embed/embed.js b/blocks/embed/embed.js new file mode 100644 index 000000000..f67ef2163 --- /dev/null +++ b/blocks/embed/embed.js @@ -0,0 +1,113 @@ +/* + * Embed Block + * Show videos and social posts directly on your page + * https://www.hlx.live/developer/block-collection/embed + */ + +const loadScript = (url, callback, type) => { + const head = document.querySelector('head'); + const script = document.createElement('script'); + script.src = url; + if (type) { + script.setAttribute('type', type); + } + script.onload = callback; + head.append(script); + return script; +}; + +const getDefaultEmbed = (url) => `
+ +
`; + +const embedYoutube = (url, autoplay) => { + const usp = new URLSearchParams(url.search); + const suffix = autoplay ? '&muted=1&autoplay=1' : ''; + let vid = usp.get('v') ? encodeURIComponent(usp.get('v')) : ''; + const embed = url.pathname; + if (url.origin.includes('youtu.be')) { + [, vid] = url.pathname.split('/'); + } + const embedHTML = `
+ +
`; + return embedHTML; +}; + +const embedVimeo = (url, autoplay) => { + const [, video] = url.pathname.split('/'); + const suffix = autoplay ? '?muted=1&autoplay=1' : ''; + const embedHTML = `
+ +
`; + return embedHTML; +}; + +const embedTwitter = (url) => { + const embedHTML = `
`; + loadScript('https://platform.twitter.com/widgets.js'); + return embedHTML; +}; + +const loadEmbed = (block, link, autoplay) => { + if (block.classList.contains('embed-is-loaded')) { + return; + } + + const EMBEDS_CONFIG = [ + { + match: ['youtube', 'youtu.be'], + embed: embedYoutube, + }, + { + match: ['vimeo'], + embed: embedVimeo, + }, + { + match: ['twitter'], + embed: embedTwitter, + }, + ]; + + const config = EMBEDS_CONFIG.find((e) => e.match.some((match) => link.includes(match))); + const url = new URL(link); + if (config) { + block.innerHTML = config.embed(url, autoplay); + block.classList = `block embed embed-${config.match[0]}`; + } else { + block.innerHTML = getDefaultEmbed(url); + block.classList = 'block embed'; + } + block.classList.add('embed-is-loaded'); +}; + +export default function decorate(block) { + const placeholder = block.querySelector('picture'); + const link = block.querySelector('a').href; + block.textContent = ''; + + if (placeholder) { + const wrapper = document.createElement('div'); + wrapper.className = 'embed-placeholder'; + wrapper.innerHTML = '
'; + wrapper.prepend(placeholder); + wrapper.addEventListener('click', () => { + loadEmbed(block, link, true); + }); + block.append(wrapper); + } else { + const observer = new IntersectionObserver((entries) => { + if (entries.some((e) => e.isIntersecting)) { + observer.disconnect(); + loadEmbed(block, link); + } + }); + observer.observe(block); + } +} \ No newline at end of file diff --git a/fstab.yaml b/fstab.yaml index 40ef9de12..6da5b6770 100644 --- a/fstab.yaml +++ b/fstab.yaml @@ -1,2 +1,2 @@ mountpoints: - /: https://drive.google.com/drive/u/0/folders/1MGzOt7ubUh3gu7zhZIPb7R7dyRzG371j \ No newline at end of file + /: https://adobe.sharepoint.com/:f:/r/sites/HelixProjects/Shared%20Documents/sites/aem-assets \ No newline at end of file diff --git a/resources/using-asset-selector-with-franklin.jpeg b/resources/using-asset-selector-with-franklin.jpeg new file mode 100644 index 000000000..c740daca0 Binary files /dev/null and b/resources/using-asset-selector-with-franklin.jpeg differ diff --git a/scripts/scripts.js b/scripts/scripts.js index 0211c1dd3..713906675 100644 --- a/scripts/scripts.js +++ b/scripts/scripts.js @@ -1,6 +1,7 @@ import { sampleRUM, buildBlock, + createOptimizedPicture as libCreateOptimizedPicture, loadHeader, loadFooter, decorateButtons, @@ -55,12 +56,170 @@ function buildAutoBlocks(main) { } } +/** + * Gets the extension of a URL. + * @param {string} url The URL + * @returns {string} The extension + * @private + * @example + * get_url_extension('https://example.com/foo.jpg'); + * // returns 'jpg' + * get_url_extension('https://example.com/foo.jpg?bar=baz'); + * // returns 'jpg' + * get_url_extension('https://example.com/foo'); + * // returns '' + * get_url_extension('https://example.com/foo.jpg#qux'); + * // returns 'jpg' + */ +function getUrlExtension(url) { + return url.split(/[#?]/)[0].split('.').pop().trim(); +} + +/** + * Checks if an element is an external image. + * @param {Element} element The element + * @param {string} externalImageMarker The marker for external images + * @returns {boolean} Whether the element is an external image + * @private + */ +function isExternalImage(element, externalImageMarker) { + // if the element is not an anchor, it's not an external image + if (element.tagName !== 'A') return false; + + // if the element is an anchor with the external image marker as text content, + // it's an external image + if (element.textContent.trim() === externalImageMarker) { + return true; + } + + // if the element is an anchor with the href as text content and the href has + // an image extension, it's an external image + if (element.textContent.trim() === element.getAttribute('href')) { + const ext = getUrlExtension(element.getAttribute('href')); + return ext && ['jpg', 'jpeg', 'png', 'gif', 'webp'].includes(ext.toLowerCase()); + } + + return false; +} + +/* + * Appends query params to a URL + * @param {string} url The URL to append query params to + * @param {object} params The query params to append + * @returns {string} The URL with query params appended + * @private + * @example + * appendQueryParams('https://example.com', { foo: 'bar' }); + * // returns 'https://example.com?foo=bar' +*/ +function appendQueryParams(url, params) { + const { searchParams } = url; + params.forEach((value, key) => { + searchParams.set(key, value); + }); + url.search = searchParams.toString(); + return url.toString(); +} + +/** + * Creates an optimized picture element for an image. + * If the image is not an absolute URL, it will be passed to libCreateOptimizedPicture. + * @param {string} src The image source URL + * @param {string} alt The image alt text + * @param {boolean} eager Whether to load the image eagerly + * @param {object[]} breakpoints The breakpoints to use + * @returns {Element} The picture element + * + */ +export function createOptimizedPicture(src, alt = '', eager = false, breakpoints = [{ media: '(min-width: 600px)', width: '2000' }, { width: '750' }]) { + const isAbsoluteUrl = /^https?:\/\//i.test(src); + + // Fallback to createOptimizedPicture if src is not an absolute URL + if (!isAbsoluteUrl) return libCreateOptimizedPicture(src, alt, eager, breakpoints); + + const url = new URL(src); + const picture = document.createElement('picture'); + const { pathname } = url; + const ext = pathname.substring(pathname.lastIndexOf('.') + 1); + + // webp + breakpoints.forEach((br) => { + const source = document.createElement('source'); + if (br.media) source.setAttribute('media', br.media); + source.setAttribute('type', 'image/webp'); + const searchParams = new URLSearchParams({ width: br.width, format: 'webply' }); + source.setAttribute('srcset', appendQueryParams(url, searchParams)); + picture.appendChild(source); + }); + + // fallback + breakpoints.forEach((br, i) => { + const searchParams = new URLSearchParams({ width: br.width, format: ext }); + + if (i < breakpoints.length - 1) { + const source = document.createElement('source'); + if (br.media) source.setAttribute('media', br.media); + source.setAttribute('srcset', appendQueryParams(url, searchParams)); + picture.appendChild(source); + } else { + const img = document.createElement('img'); + img.setAttribute('loading', eager ? 'eager' : 'lazy'); + img.setAttribute('alt', alt); + picture.appendChild(img); + img.setAttribute('src', appendQueryParams(url, searchParams)); + } + }); + + return picture; +} + +/* + * Decorates external images with a picture element + * @param {Element} ele The element + * @param {string} deliveryMarker The marker for external images + * @private + * @example + * decorateExternalImages(main, '//External Image//'); + */ +function decorateExternalImages(ele, deliveryMarker) { + const extImages = ele.querySelectorAll('a'); + extImages.forEach((extImage) => { + if (isExternalImage(extImage, deliveryMarker)) { + const extImageSrc = extImage.getAttribute('href'); + const extPicture = createOptimizedPicture(extImageSrc); + + /* copy query params from link to img */ + const extImageUrl = new URL(extImageSrc); + const { searchParams } = extImageUrl; + extPicture.querySelectorAll('source, img').forEach((child) => { + if (child.tagName === 'SOURCE') { + const srcset = child.getAttribute('srcset'); + if (srcset) { + child.setAttribute('srcset', appendQueryParams(new URL(srcset, extImageSrc), searchParams)); + } + } else if (child.tagName === 'IMG') { + const src = child.getAttribute('src'); + if (src) { + child.setAttribute('src', appendQueryParams(new URL(src, extImageSrc), searchParams)); + } + } + }); + extImage.parentNode.replaceChild(extPicture, extImage); + } + }); +} + /** * Decorates the main element. * @param {Element} main The main element */ // eslint-disable-next-line import/prefer-default-export export function decorateMain(main) { + // decorate external images with explicit external image marker + decorateExternalImages(main, '//External Image//'); + + // decorate external images with implicit external image marker + decorateExternalImages(main); // hopefully forward compatible button decoration decorateButtons(main); decorateIcons(main); diff --git a/tools/sidekick/config.json b/tools/sidekick/config.json new file mode 100644 index 000000000..41feb6316 --- /dev/null +++ b/tools/sidekick/config.json @@ -0,0 +1,14 @@ +{ + "project": "franklin-asset-selector", + "plugins": [ + { + "id": "asset-library", + "title": "AEM Assets Library", + "environments": ["edit"], + "url": "https://experience.adobe.com/solutions/CQ-assets-selectors/static-assets/resources/franklin/asset-selector.html", + "isPalette": true, + "includePaths": ["**.docx**"], + "paletteRect": "top: 50px; bottom: 10px; right: 10px; left: auto; width:400px; height: calc(100vh - 60px)" + } + ] +}