mirror of
https://github.com/LukeHagar/unicorn-utterances.git
synced 2025-12-06 04:21:55 +00:00
add default iframe icon, optimize iframe fetch behavior
This commit is contained in:
BIN
public/link.png
Normal file
BIN
public/link.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.9 KiB |
19
src/utils/markdown/get-picture-hack.ts
Normal file
19
src/utils/markdown/get-picture-hack.ts
Normal file
@@ -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<GetPictureResult> {
|
||||
// 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);
|
||||
}
|
||||
@@ -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") {
|
||||
|
||||
@@ -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<string, Promise<GetPictureResult>>();
|
||||
function fetchPageIcon(src: URL, srcHast: Root): Promise<GetPictureResult> {
|
||||
if (pageIconMap.has(src.origin)) return pageIconMap.get(src.origin);
|
||||
|
||||
const promise = (async () => {
|
||||
// <link rel="manifest" href="/manifest.json">
|
||||
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`
|
||||
// <link rel="shortcut icon" type="image/png" href="https://example.com/img.png">
|
||||
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<string, Promise<Root | null>>();
|
||||
function fetchPageHtml(src: string): Promise<Root | null> {
|
||||
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<PageInfo | null> {
|
||||
// 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 <title> 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",
|
||||
|
||||
Reference in New Issue
Block a user