diff --git a/public/link.png b/public/link.png new file mode 100644 index 00000000..da3aa048 Binary files /dev/null and b/public/link.png differ diff --git a/src/utils/markdown/get-picture-hack.ts b/src/utils/markdown/get-picture-hack.ts new file mode 100644 index 00000000..5564078d --- /dev/null +++ b/src/utils/markdown/get-picture-hack.ts @@ -0,0 +1,19 @@ +import * as astroImage from "@astrojs/image"; +import { + GetPictureParams, + GetPictureResult, +} from "@astrojs/image/dist/lib/get-picture"; +import sharp_service from "../../../node_modules/@astrojs/image/dist/loaders/sharp.js"; + +export function getPicture( + params: GetPictureParams +): Promise { + // HACK: This is a hack that heavily relies on `getImage`'s internals :( + globalThis.astroImage = { + ...(globalThis.astroImage || {}), + loader: globalThis.astroImage?.loader ?? sharp_service, + defaultLoader: globalThis.astroImage?.defaultLoader ?? sharp_service, + }; + + return astroImage.getPicture(params); +} diff --git a/src/utils/markdown/rehype-astro-image-md.ts b/src/utils/markdown/rehype-astro-image-md.ts index 48fa87f3..23555721 100644 --- a/src/utils/markdown/rehype-astro-image-md.ts +++ b/src/utils/markdown/rehype-astro-image-md.ts @@ -9,8 +9,7 @@ import path from "path"; /** * They need to be the same `getImage` with the same `globalThis` instance, thanks to the "hack" workaround. */ -import { getPicture } from "../../../node_modules/@astrojs/image/dist/index.js"; -import sharp_service from "../../../node_modules/@astrojs/image/dist/loaders/sharp.js"; +import { getPicture } from "./get-picture-hack"; import { getImageSize } from "../get-image-size"; import { fileURLToPath } from "url"; import { getFullRelativePath } from "../url-paths"; @@ -28,13 +27,6 @@ export const rehypeAstroImageMd: Plugin< Root > = ({ maxHeight, maxWidth }) => { return async (tree, file) => { - // HACK: This is a hack that heavily relies on `getImage`'s internals :( - globalThis.astroImage = { - ...(globalThis.astroImage || {}), - loader: sharp_service ?? globalThis.astroImage?.loader, - defaultLoader: sharp_service ?? globalThis.astroImage?.defaultLoader, - }; - const imgNodes: any[] = []; visit(tree, (node: any) => { if (node.tagName === "img") { diff --git a/src/utils/markdown/rehype-unicorn-iframe-click-to-run.ts b/src/utils/markdown/rehype-unicorn-iframe-click-to-run.ts index 215e00d9..5193d44c 100644 --- a/src/utils/markdown/rehype-unicorn-iframe-click-to-run.ts +++ b/src/utils/markdown/rehype-unicorn-iframe-click-to-run.ts @@ -7,37 +7,140 @@ import { EMBED_SIZE } from "./constants"; import { h } from "hastscript"; import { fromHtml } from "hast-util-from-html"; import find from "unist-util-find"; -import { getLargestManifestIcon, Manifest } from "../get-largest-manifest-icon"; - -/** - * They need to be the same `getImage` with the same `globalThis` instance, thanks to the "hack" workaround. - */ -import { getPicture } from "../../../node_modules/@astrojs/image/dist/index.js"; -import sharp_service from "../../../node_modules/@astrojs/image/dist/loaders/sharp.js"; +import { getLargestManifestIcon } from "../get-largest-manifest-icon"; +import { getPicture } from "./get-picture-hack"; import type { GetPictureResult } from "@astrojs/image/dist/lib/get-picture"; // This does not download the whole file to get the file size import probe from "probe-image-size"; interface RehypeUnicornIFrameClickToRunProps {} -const ManifestIconMap = new Map< - string, - { result: GetPictureResult; height: number; width: number } ->(); +// default icon, used if a frame's favicon cannot be resolved +let defaultPageIcon: GetPictureResult | null; +async function fetchDefaultPageIcon() { + return ( + defaultPageIcon || + (defaultPageIcon = await getPicture({ + src: "/link.png", + widths: [50], + formats: ["avif", "webp", "png"], + aspectRatio: 1, + alt: "", + })) + ); +} + +// Cache the fetch *promises* - so that only one request per manifest/icon is processed, +// and multiple fetchPageInfo() calls can await the same icon +const pageIconMap = new Map>(); +function fetchPageIcon(src: URL, srcHast: Root): Promise { + if (pageIconMap.has(src.origin)) return pageIconMap.get(src.origin); + + const promise = (async () => { + // + const manifestPath = find( + srcHast, + (node) => node?.properties?.rel?.[0] === "manifest" + ); + + let iconLink: string; + + if (manifestPath) { + // `/manifest.json` + const manifestRelativeURL = manifestPath.properties.href; + const fullManifestURL = new URL(manifestRelativeURL, src).href; + + const manifest = await fetch(fullManifestURL) + .then((r) => r.status === 200 && r.json()) + .catch(() => null); + + if (manifest) { + const largestIcon = getLargestManifestIcon(manifest); + iconLink = new URL(largestIcon.icon.src, src.origin).href; + } + } + + if (!iconLink) { + // fetch `favicon.ico` + // + const favicon = find(srcHast, (node) => + node?.properties?.rel?.includes("icon") + ); + + if (favicon) { + iconLink = new URL(favicon.properties.href, src).href; + } + } + + // no icon image URL is found + if (!iconLink) return null; + const { height: imgHeight, width: imgWidth } = await probe(iconLink); + const aspectRatio = imgHeight / imgWidth; + return await getPicture({ + src: iconLink, + widths: [50], + formats: ["avif", "webp", "png"], + aspectRatio: aspectRatio, + alt: "", + }); + })() + // if an error is thrown, or response is null, use the default page icon + .catch(() => null) + .then((p) => p || fetchDefaultPageIcon()); + + pageIconMap.set(src.origin, promise); + return promise; +} + +const pageHtmlMap = new Map>(); +function fetchPageHtml(src: string): Promise { + if (pageHtmlMap.has(src)) return pageHtmlMap.get(src); + + const promise = (async () => { + const srcHTML = await fetch(src) + .then((r) => r.status === 200 && r.text()) + .catch(() => null); + + // if fetch fails... + if (!srcHTML) return null; + + const srcHast = fromHtml(srcHTML); + + return srcHast; + })(); + + pageHtmlMap.set(src, promise); + return promise; +} + +type PageInfo = { + title?: string; + icon: GetPictureResult; +}; + +async function fetchPageInfo(src: string): Promise { + // fetch origin url, catch any connection timeout errors + const url = new URL(src); + url.search = ""; // remove any search params + + const srcHast = await fetchPageHtml(url.toString()); + if (!srcHast) return null; + + // find element in response HTML + const titleEl = find(srcHast, { tagName: "title" }); + const title = titleEl ? titleEl.children[0].value : undefined; + + // find the page favicon (cache by page origin) + const icon = await fetchPageIcon(url, srcHast); + return { title, icon }; +} // TODO: Add switch/case and dedicated files ala "Components" export const rehypeUnicornIFrameClickToRun: Plugin< [RehypeUnicornIFrameClickToRunProps | never], Root > = () => { - // HACK: This is a hack that heavily relies on `getImage`'s internals :( - globalThis.astroImage = { - ...(globalThis.astroImage || {}), - loader: sharp_service ?? globalThis.astroImage?.loader, - defaultLoader: sharp_service ?? globalThis.astroImage?.defaultLoader, - }; - - return async (tree, file) => { + return async (tree) => { const iframeNodes: any[] = []; visit(tree, (node: any) => { if (node.tagName === "iframe") { @@ -49,116 +152,42 @@ export const rehypeUnicornIFrameClickToRun: Plugin< iframeNodes.map(async (iframeNode) => { const width = iframeNode.properties.width ?? EMBED_SIZE.w; const height = iframeNode.properties.height ?? EMBED_SIZE.h; - const req = await fetch(iframeNode.properties.src).catch(() => null); - let pageTitleString: string | undefined; - let iconLink: string | undefined; - let iframePicture: - | ReturnType<(typeof ManifestIconMap)["get"]> - | undefined; - const iframeOrigin = new URL(iframeNode.properties.src).origin; - if (req && req.status === 200) { - const srcHTML = await req.text(); - const srcHast = fromHtml(srcHTML); - const titleEl = find(srcHast, { tagName: "title" }); + const info: PageInfo = (await fetchPageInfo( + iframeNode.properties.src + ).catch(() => null)) || { icon: await fetchDefaultPageIcon() }; - pageTitleString = titleEl.children[0].value; - - if (ManifestIconMap.has(iframeOrigin)) { - iframePicture = ManifestIconMap.get(iframeOrigin); - } else { - // <link rel="manifest" href="/manifest.json"> - const manifestPath = find( - srcHast, - (node) => node?.properties?.rel?.[0] === "manifest" - ); - - if (manifestPath) { - // `/manifest.json` - const manifestRelativeURL = manifestPath.properties.href; - const fullManifestURL = new URL( - manifestRelativeURL, - iframeNode.properties.src - ).href; - const manifestReq = await fetch(fullManifestURL).catch( - () => null - ); - if (manifestReq && manifestReq.status === 200) { - const manifestContents = await manifestReq.text(); - const manifestJSON: Manifest = JSON.parse(manifestContents); - const largestIcon = getLargestManifestIcon(manifestJSON); - iconLink = new URL(largestIcon.icon.src, iframeOrigin).href; - } - } else { - // fetch `favicon.ico` - // <link rel="icon" type="image/png" href="https://example.com/img.png"> - const favicon = find( - srcHast, - (node) => node?.properties?.rel?.[0] === "icon" - ); - if (favicon) { - iconLink = new URL(favicon.properties.href, iframeOrigin).href; - } - } - } - } - - if (iconLink) { - try { - const { height: imgHeight, width: imgWidth } = await probe( - iconLink - ); - const aspectRatio = imgHeight / imgWidth; - const result = await getPicture({ - src: iconLink, - widths: [50], - formats: ["webp", "png"], - aspectRatio: aspectRatio, - alt: "", - }); - - iframePicture = { result, height: imgHeight, width: imgWidth }; - - ManifestIconMap.set(iframeOrigin, iframePicture); - // eslint-disable-next-line no-empty - } catch (_e) {} - } - - // TODO: Add placeholder image - const sources = - iframePicture && - iframePicture.result.sources.map((attrs) => { - return h("source", attrs); - }); + const sources = info.icon.sources.map((attrs) => { + return h("source", attrs); + }); const iframeReplacement = h( "div", { class: "iframe-replacement-container", "data-iframeurl": iframeNode.properties.src, - "data-pagetitle": pageTitleString, - "data-pageicon": iframePicture - ? JSON.stringify(iframePicture) - : undefined, + "data-pagetitle": info.title, + "data-pageicon": info.icon ? JSON.stringify(info.icon) : undefined, style: `height: ${ Number(height) ? `${height}px` : height }; width: ${Number(width) ? `${width}px` : width};`, }, [ - iframePicture + info.icon ? h("picture", [ ...sources, h("img", { - ...(iframePicture.result.image as never as object), + ...(info.icon.image as object), class: "iframe-replacement-icon", alt: "", loading: "lazy", decoding: "async", + "data-nozoom": "true", }), ]) : null, h("p", { class: "iframe-replacement-title" }, [ h("span", { class: "visually-hidden" }, ["An embedded webpage:"]), - pageTitleString, + info.title, ]), h("button", { class: "baseBtn iframe-replacement-button" }, [ "Run embed",