@cf-wasm/og
Version:
Generate Open Graph Images dynamically from HTML/CSS without a browser.
363 lines (361 loc) • 12.6 kB
JavaScript
// src/figma/figma.ts
import { detectRuntime } from "@cf-wasm/internals/detect-runtime";
import { modules } from "../core/index.js";
import { FetchError } from "../core/errors.js";
import { render } from "../core/render.js";
import { BaseResponse } from "../core/response.js";
import { replaceEntities } from "../core/utils/entities.js";
if (!modules.isUsable()) {
throw new Error(
`Modules are not initialized! Please import the main module based on runtime (i.e. '@cf-wasm/og', '@cf-wasm/og/next', etc.) before importing the 'figma' submodule (i.e. '@cf-wasm/og/figma').
For example:
------------
import '@cf-wasm/og/next'; // <- import main module based on runtime first
import { FigmaImageResponse } from '@cf-wasm/og/figma' // <- now you can import figma submodule
// ...
------------`
);
}
var parseFigmaUrl = (figmaUrl) => {
const regex = /\/file\/([^/]+)\/[^?]+\?[^#]*node-id=([^&#]+)/;
const match2 = figmaUrl.match(regex);
let fileId = "";
let nodeId = "";
if (match2) {
fileId = match2[1] || "";
nodeId = match2[2] || "";
}
return { fileId, nodeId };
};
var assertValue = (value, errorMessage) => {
if (typeof value === "undefined") {
throw new Error(errorMessage);
}
return value;
};
var match = (string, matcher, index = 1, defaultValue = "") => {
const matches = string.match(matcher);
if (matches && typeof matches[index] === "string") {
return matches[index];
}
return defaultValue;
};
var isComplexTemplate = (template) => typeof template !== "string" && template !== void 0 && "value" in template;
var svgToBase64 = (svg) => {
let base64;
if (typeof Buffer !== "undefined") {
base64 = Buffer.from(svg).toString("base64");
} else if (typeof btoa === "function") {
base64 = btoa(svg);
} else {
throw new Error("Base64 encoding is not supported in this environment.");
}
return `data:image/svg+xml;base64,${base64}`;
};
var getSvgDimensions = (svg) => {
const widthMatch = svg.match(/width="(\d+)/);
const heightMatch = svg.match(/height="(\d+)/);
if (widthMatch && heightMatch) {
const width = Number(widthMatch[1]);
const height = Number(heightMatch[1]);
return { width, height };
}
return { width: 0, height: 0 };
};
var getTextNodes = (svg) => {
const regex = /<text[^>]*>(.*?)<\/text>/g;
let execArray = regex.exec(svg);
const matches = [];
while (execArray !== null) {
matches.push(execArray[0]);
execArray = regex.exec(svg);
}
return matches;
};
var getTspanNodes = (text) => {
const regex = /<tspan[^>]*>(.*?)<\/tspan>/g;
let execArray = regex.exec(text);
const matches = [];
while (execArray !== null) {
matches.push(execArray[0]);
execArray = regex.exec(text);
}
return matches;
};
var parseTspanNode = (tspanNode) => ({
x: match(tspanNode, /x="([^"]*)"/),
y: match(tspanNode, /y="([^"]*)"/),
content: replaceEntities(match(tspanNode, /<tspan[^>]*>([^<]*)<\/tspan>/))
});
var parseTextNode = (textNode) => {
const tspanNodesAttributes = getTspanNodes(textNode).map(parseTspanNode);
return {
id: match(textNode, /id="([^"]*)"/),
fill: match(textNode, /fill="([^"]*)"/),
fontFamily: match(textNode, /font-family="([^"]*)"/),
fontSize: match(textNode, /font-size="([^"]*)"/),
fontWeight: match(textNode, /font-weight="([^"]*)"/).replace("normal", "400").replace("bold", "700") || void 0,
fontStyle: match(textNode, /font-style="([^"]*)"/) || void 0,
letterSpacing: match(textNode, /letter-spacing="([^"]*)"/),
x: tspanNodesAttributes[0].x,
y: tspanNodesAttributes[0].y,
children: tspanNodesAttributes
};
};
var removeTextNodes = (svg) => svg.replace(/<text[^>]*>(.*?)<\/text>/g, "");
var getFigmaSvg = async (figmaOptions) => {
const { url, token } = figmaOptions;
const { fileId, nodeId } = parseFigmaUrl(url);
assertValue(url, "(@cf-wasm/og) [ERROR] 'url' field is required");
assertValue(token, "(@cf-wasm/og) [ERROR] 'token' field is required");
const apiUrl = `https://api.figma.com/v1/images/${fileId}?ids=${nodeId}&svg_outline_text=false&format=svg&svg_include_id=true`;
const figmaResponse = await fetch(apiUrl, {
method: "GET",
headers: {
"X-FIGMA-TOKEN": token
},
// This is to prevent errors on workerd runtime
...detectRuntime() === "workerd" ? void 0 : { cache: "no-store" }
}).then((res) => {
if (!res.ok) {
throw new FetchError(`Response was not successful (status: ${res.status}, statusText: ${res.statusText})`, { response: res });
}
return res;
}).catch((e) => {
throw new FetchError(`Failed to fetch figma image. An error ocurred while fetching ${apiUrl}`, {
cause: e,
response: e instanceof FetchError ? e.response : void 0
});
});
const figmaResponseJson = await figmaResponse.json();
const svgDownloadPath = figmaResponseJson.images[nodeId.replace("-", ":")];
const svgResponse = await fetch(
svgDownloadPath,
// This is to prevent errors on workerd runtime
detectRuntime() === "workerd" ? void 0 : { cache: "no-store" }
);
const response = new Response(svgResponse.body, svgResponse);
response.headers.set("Content-Type", "text/xml");
const svg = await svgResponse.text();
return svg;
};
var loadTextNodeFonts = async (nodeAttributes, loadFonts) => (await Promise.all(
nodeAttributes.filter(
(value, index, self) => index === self.findIndex((t) => t?.fontFamily === value.fontFamily && t.fontWeight === value.fontWeight && t.fontStyle === value.fontStyle)
).map(async (attrs) => {
const { family, weight, style } = {
family: attrs.fontFamily,
weight: attrs.fontWeight ? Number(attrs.fontWeight) : void 0,
style: attrs.fontStyle || void 0
};
const awaited = await loadFonts(family, weight, style);
if (awaited) {
return {
name: family,
weight,
style,
get data() {
return typeof awaited === "object" && "data" in awaited ? awaited.data : awaited;
}
};
}
return void 0;
})
)).filter(Boolean);
var renderFigma = (figmaOptions, renderOptions) => {
const getFigmaData = async () => {
const { template } = figmaOptions;
const svg = await getFigmaSvg(figmaOptions);
const { width, height } = getSvgDimensions(svg);
const textNodes = getTextNodes(svg);
const textNodeAttributes = textNodes.map(parseTextNode);
const dynamicFonts = typeof renderOptions?.loadFonts === "function" ? await loadTextNodeFonts(textNodeAttributes, renderOptions.loadFonts) : [];
const element = {
key: "0",
type: "div",
props: {
style: { display: "flex" },
children: [
{
type: "img",
props: {
style: { position: "absolute" },
alt: "",
width,
height,
src: svgToBase64(removeTextNodes(svg))
}
},
{
type: "div",
props: {
style: {
display: "flex",
position: "relative",
width: "100%"
},
children: textNodeAttributes.map((textNode) => {
const t = template[textNode.id];
let value = "";
if (t === void 0) {
value = textNode.children;
} else if (isComplexTemplate(t)) {
value = t.value;
} else {
value = t;
}
let cssProps = {};
let centerHorizontally = false;
if (isComplexTemplate(t)) {
const complexTemplate = t;
if (complexTemplate.props) {
const { centerHorizontally: centerHorizontallyProp, ...otherCSSProps } = complexTemplate.props;
cssProps = otherCSSProps;
centerHorizontally = centerHorizontallyProp || false;
}
}
const children = Array.isArray(value) ? value.map(
(e, i) => ({
key: String(i),
type: "span",
props: {
style: {
position: "absolute",
left: `${Number(e.x) - Number(textNode.x)}px`,
top: `${Number(e.y) - Number(textNode.y) - Number(textNode.fontSize) / 2}px`
},
children: e.content
}
})
) : value;
if (centerHorizontally) {
return {
type: "div",
props: {
style: {
position: "absolute",
display: "flex",
justifyContent: "center",
width: "100%"
},
children: {
type: "span",
props: {
style: {
color: textNode.fill,
marginTop: `${Number(textNode.y) - Number(textNode.fontSize) / 2}px`,
fontWeight: textNode.fontWeight || "400",
fontSize: textNode.fontSize,
fontFamily: textNode.fontFamily,
letterSpacing: textNode.letterSpacing,
textAlign: "center",
...cssProps
},
children
}
}
}
};
}
return {
type: "span",
props: {
style: {
position: "absolute",
color: textNode.fill,
left: `${textNode.x}px`,
top: `${Number(textNode.y) - Number(textNode.fontSize) / 2}px`,
fontWeight: textNode.fontWeight || "400",
fontSize: textNode.fontSize,
fontFamily: textNode.fontFamily,
letterSpacing: textNode.letterSpacing,
...cssProps
},
children
}
};
})
}
}
]
}
};
return { element, width, height, fonts: dynamicFonts };
};
const data = {
renderer: null,
element: null,
svg: null,
png: null
};
const asElement = async () => {
if (!data.element) {
data.element = await getFigmaData();
}
return data.element;
};
const getRenderer = async () => {
if (!data.renderer) {
const elementData = await asElement();
data.renderer = render(elementData.element, {
...renderOptions,
width: elementData.width,
height: elementData.height,
fonts: [...elementData.fonts, ...renderOptions?.fonts || []]
});
}
return data.renderer;
};
const asSvg = async () => {
if (!data.svg) {
data.svg = await (await getRenderer()).asSvg();
}
return data.svg;
};
const asPng = async () => {
if (!data.png) {
data.png = await (await getRenderer()).asPng();
}
return data.png;
};
return { asElement, asSvg, asPng };
};
var FigmaImageResponse = class extends BaseResponse {
/**
* Creates an instance of {@link FigmaImageResponse}
*
* @param figmaOptions Figma options {@link FigmaOptions}
* @param responseOptions The same as {@link ImageResponseOptions} except `width` and `height`. `width` and `height` are automatically set from the Figma frame's size.
*/
constructor(figmaOptions, responseOptions) {
super(async () => {
const renderer = renderFigma(figmaOptions, responseOptions);
const elementData = await renderer.asElement();
return [
elementData.element,
{
...responseOptions,
width: elementData.width,
height: elementData.height,
fonts: [...elementData.fonts, ...responseOptions?.fonts || []]
}
];
}, responseOptions);
}
};
export {
FigmaImageResponse,
assertValue,
getFigmaSvg,
getSvgDimensions,
getTextNodes,
getTspanNodes,
isComplexTemplate,
loadTextNodeFonts,
parseFigmaUrl,
parseTextNode,
parseTspanNode,
removeTextNodes,
renderFigma,
svgToBase64
};