mirror of
https://github.com/LukeHagar/unicorn-utterances.git
synced 2025-12-06 12:57:44 +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.
|
* 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 { getPicture } from "./get-picture-hack";
|
||||||
import sharp_service from "../../../node_modules/@astrojs/image/dist/loaders/sharp.js";
|
|
||||||
import { getImageSize } from "../get-image-size";
|
import { getImageSize } from "../get-image-size";
|
||||||
import { fileURLToPath } from "url";
|
import { fileURLToPath } from "url";
|
||||||
import { getFullRelativePath } from "../url-paths";
|
import { getFullRelativePath } from "../url-paths";
|
||||||
@@ -28,13 +27,6 @@ export const rehypeAstroImageMd: Plugin<
|
|||||||
Root
|
Root
|
||||||
> = ({ maxHeight, maxWidth }) => {
|
> = ({ maxHeight, maxWidth }) => {
|
||||||
return async (tree, file) => {
|
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[] = [];
|
const imgNodes: any[] = [];
|
||||||
visit(tree, (node: any) => {
|
visit(tree, (node: any) => {
|
||||||
if (node.tagName === "img") {
|
if (node.tagName === "img") {
|
||||||
|
|||||||
@@ -7,37 +7,140 @@ import { EMBED_SIZE } from "./constants";
|
|||||||
import { h } from "hastscript";
|
import { h } from "hastscript";
|
||||||
import { fromHtml } from "hast-util-from-html";
|
import { fromHtml } from "hast-util-from-html";
|
||||||
import find from "unist-util-find";
|
import find from "unist-util-find";
|
||||||
import { getLargestManifestIcon, Manifest } from "../get-largest-manifest-icon";
|
import { getLargestManifestIcon } from "../get-largest-manifest-icon";
|
||||||
|
import { getPicture } from "./get-picture-hack";
|
||||||
/**
|
|
||||||
* 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 type { GetPictureResult } from "@astrojs/image/dist/lib/get-picture";
|
import type { GetPictureResult } from "@astrojs/image/dist/lib/get-picture";
|
||||||
// This does not download the whole file to get the file size
|
// This does not download the whole file to get the file size
|
||||||
import probe from "probe-image-size";
|
import probe from "probe-image-size";
|
||||||
|
|
||||||
interface RehypeUnicornIFrameClickToRunProps {}
|
interface RehypeUnicornIFrameClickToRunProps {}
|
||||||
|
|
||||||
const ManifestIconMap = new Map<
|
// default icon, used if a frame's favicon cannot be resolved
|
||||||
string,
|
let defaultPageIcon: GetPictureResult | null;
|
||||||
{ result: GetPictureResult; height: number; width: number }
|
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"
|
// TODO: Add switch/case and dedicated files ala "Components"
|
||||||
export const rehypeUnicornIFrameClickToRun: Plugin<
|
export const rehypeUnicornIFrameClickToRun: Plugin<
|
||||||
[RehypeUnicornIFrameClickToRunProps | never],
|
[RehypeUnicornIFrameClickToRunProps | never],
|
||||||
Root
|
Root
|
||||||
> = () => {
|
> = () => {
|
||||||
// HACK: This is a hack that heavily relies on `getImage`'s internals :(
|
return async (tree) => {
|
||||||
globalThis.astroImage = {
|
|
||||||
...(globalThis.astroImage || {}),
|
|
||||||
loader: sharp_service ?? globalThis.astroImage?.loader,
|
|
||||||
defaultLoader: sharp_service ?? globalThis.astroImage?.defaultLoader,
|
|
||||||
};
|
|
||||||
|
|
||||||
return async (tree, file) => {
|
|
||||||
const iframeNodes: any[] = [];
|
const iframeNodes: any[] = [];
|
||||||
visit(tree, (node: any) => {
|
visit(tree, (node: any) => {
|
||||||
if (node.tagName === "iframe") {
|
if (node.tagName === "iframe") {
|
||||||
@@ -49,116 +152,42 @@ export const rehypeUnicornIFrameClickToRun: Plugin<
|
|||||||
iframeNodes.map(async (iframeNode) => {
|
iframeNodes.map(async (iframeNode) => {
|
||||||
const width = iframeNode.properties.width ?? EMBED_SIZE.w;
|
const width = iframeNode.properties.width ?? EMBED_SIZE.w;
|
||||||
const height = iframeNode.properties.height ?? EMBED_SIZE.h;
|
const height = iframeNode.properties.height ?? EMBED_SIZE.h;
|
||||||
const req = await fetch(iframeNode.properties.src).catch(() => null);
|
const info: PageInfo = (await fetchPageInfo(
|
||||||
let pageTitleString: string | undefined;
|
iframeNode.properties.src
|
||||||
let iconLink: string | undefined;
|
).catch(() => null)) || { icon: await fetchDefaultPageIcon() };
|
||||||
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" });
|
|
||||||
|
|
||||||
pageTitleString = titleEl.children[0].value;
|
const sources = info.icon.sources.map((attrs) => {
|
||||||
|
return h("source", attrs);
|
||||||
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 iframeReplacement = h(
|
const iframeReplacement = h(
|
||||||
"div",
|
"div",
|
||||||
{
|
{
|
||||||
class: "iframe-replacement-container",
|
class: "iframe-replacement-container",
|
||||||
"data-iframeurl": iframeNode.properties.src,
|
"data-iframeurl": iframeNode.properties.src,
|
||||||
"data-pagetitle": pageTitleString,
|
"data-pagetitle": info.title,
|
||||||
"data-pageicon": iframePicture
|
"data-pageicon": info.icon ? JSON.stringify(info.icon) : undefined,
|
||||||
? JSON.stringify(iframePicture)
|
|
||||||
: undefined,
|
|
||||||
style: `height: ${
|
style: `height: ${
|
||||||
Number(height) ? `${height}px` : height
|
Number(height) ? `${height}px` : height
|
||||||
}; width: ${Number(width) ? `${width}px` : width};`,
|
}; width: ${Number(width) ? `${width}px` : width};`,
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
iframePicture
|
info.icon
|
||||||
? h("picture", [
|
? h("picture", [
|
||||||
...sources,
|
...sources,
|
||||||
h("img", {
|
h("img", {
|
||||||
...(iframePicture.result.image as never as object),
|
...(info.icon.image as object),
|
||||||
class: "iframe-replacement-icon",
|
class: "iframe-replacement-icon",
|
||||||
alt: "",
|
alt: "",
|
||||||
loading: "lazy",
|
loading: "lazy",
|
||||||
decoding: "async",
|
decoding: "async",
|
||||||
|
"data-nozoom": "true",
|
||||||
}),
|
}),
|
||||||
])
|
])
|
||||||
: null,
|
: null,
|
||||||
h("p", { class: "iframe-replacement-title" }, [
|
h("p", { class: "iframe-replacement-title" }, [
|
||||||
h("span", { class: "visually-hidden" }, ["An embedded webpage:"]),
|
h("span", { class: "visually-hidden" }, ["An embedded webpage:"]),
|
||||||
pageTitleString,
|
info.title,
|
||||||
]),
|
]),
|
||||||
h("button", { class: "baseBtn iframe-replacement-button" }, [
|
h("button", { class: "baseBtn iframe-replacement-button" }, [
|
||||||
"Run embed",
|
"Run embed",
|
||||||
|
|||||||
Reference in New Issue
Block a user