UNPKG

@cf-wasm/og

Version:

Generate Open Graph Images dynamically from HTML/CSS without a browser.

363 lines (361 loc) 12.6 kB
// 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 };