Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions mcp-server/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion mcp-server/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "drawd-mcp-server",
"version": "1.2.1",
"version": "1.2.2",
"description": "MCP server for Drawd — AI agent flow builder. Create app flow designs programmatically with AI agents.",
"type": "module",
"bin": {
Expand Down
137 changes: 137 additions & 0 deletions mcp-server/src/renderer/emoji-loader.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import { readFile, writeFile, mkdir } from "node:fs/promises";
import { homedir } from "node:os";
import { join } from "node:path";

const TWEMOJI_VERSION = "15.1.0";
const CDN_BASE = `https://cdn.jsdelivr.net/gh/jdecked/twemoji@${TWEMOJI_VERSION}/assets/svg`;
const CACHE_DIR = join(homedir(), ".cache", "drawd-mcp", "emoji");
const FETCH_TIMEOUT_MS = 3000;

const TRANSPARENT_SVG_DATA_URI =
"data:image/svg+xml;base64," +
Buffer.from(
'<svg xmlns="http://www.w3.org/2000/svg" width="1" height="1" viewBox="0 0 1 1"/>'
).toString("base64");

const resolvedCache = new Map();
const inFlight = new Map();
const warnedCodes = new Set();

let cacheDirEnsured = null;

async function ensureCacheDir() {
if (!cacheDirEnsured) {
cacheDirEnsured = mkdir(CACHE_DIR, { recursive: true }).catch(() => null);
}
return cacheDirEnsured;
}

function toDataUri(svgText) {
return `data:image/svg+xml;base64,${Buffer.from(svgText, "utf8").toString("base64")}`;
}

function toCodePoint(rune, sep = "-") {
const r = [];
let p = 0;
let i = 0;
while (i < rune.length) {
const c = rune.charCodeAt(i++);
if (p) {
r.push((0x10000 + ((p - 0xd800) << 10) + (c - 0xdc00)).toString(16));
p = 0;
} else if (c >= 0xd800 && c <= 0xdbff) {
p = c;
} else {
r.push(c.toString(16));
}
}
return r.join(sep);
}

// Returns [primary, fallback]. jdecked/twemoji asset names mostly follow
// Twemoji's canonical rule (strip U+FE0F unless the sequence contains U+200D),
// but there are inconsistencies — e.g. "eye in speech bubble" is stored without
// FE0F despite being a ZWJ sequence. We try the canonical name first and fall
// back to the fully-stripped variant so both patterns resolve.
export function getEmojiCode(segment) {
const hasZwj = segment.includes("\u200d");
const stripped = toCodePoint(segment.replace(/\ufe0f/g, ""));
if (!hasZwj) return [stripped, null];
const kept = toCodePoint(segment);
return kept === stripped ? [kept, null] : [kept, stripped];
}

async function readDisk(code) {
try {
const svg = await readFile(join(CACHE_DIR, `${code}.svg`), "utf8");
return toDataUri(svg);
} catch {
return null;
}
}

async function writeDisk(code, svgText) {
try {
await ensureCacheDir();
await writeFile(join(CACHE_DIR, `${code}.svg`), svgText, "utf8");
} catch {
// Silently ignore: read-only FS, permissions, etc.
}
}

async function fetchFromCdn(code) {
const url = `${CDN_BASE}/${code}.svg`;
const res = await fetch(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
if (!res.ok) {
throw new Error(`Twemoji fetch ${code}: HTTP ${res.status}`);
}
return await res.text();
}

async function resolveOne(code) {
const fromDisk = await readDisk(code);
if (fromDisk) return fromDisk;

const svgText = await fetchFromCdn(code);
await writeDisk(code, svgText);
return toDataUri(svgText);
}

async function resolveEmoji(primary, fallback) {
try {
return await resolveOne(primary);
} catch (err) {
if (!fallback) throw err;
return await resolveOne(fallback);
}
}

export async function loadEmojiSvg(codes) {
const [primary, fallback] = Array.isArray(codes) ? codes : [codes, null];

const cached = resolvedCache.get(primary);
if (cached) return cached;

const pending = inFlight.get(primary);
if (pending) return pending;

const promise = resolveEmoji(primary, fallback)
.then((dataUri) => {
resolvedCache.set(primary, dataUri);
return dataUri;
})
.catch((err) => {
if (!warnedCodes.has(primary)) {
warnedCodes.add(primary);
console.warn(`[drawd-mcp] emoji ${primary} unavailable: ${err.message}`);
}
resolvedCache.set(primary, TRANSPARENT_SVG_DATA_URI);
return TRANSPARENT_SVG_DATA_URI;
})
.finally(() => {
inFlight.delete(primary);
});

inFlight.set(primary, promise);
return promise;
}
50 changes: 49 additions & 1 deletion mcp-server/src/renderer/satori-renderer.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { readFileSync } from "node:fs";
import { createRequire } from "node:module";
import { resolveViewport, DEVICE_PRESETS } from "./device-presets.js";
import { getEmojiCode, loadEmojiSvg } from "./emoji-loader.js";

// Dynamic imports resolved at runtime to support esbuild bundling
let _satori = null;
Expand Down Expand Up @@ -60,8 +61,14 @@ export class SatoriRenderer {
const { device, width, height } = options;
const viewport = resolveViewport(device, width, height);

// satori-html does not decode HTML entities. Agents frequently write
// numeric entities (&#9679;, &#x25cf;) and safe named entities (&bull;,
// &hellip;) — decode them before parsing so they render as glyphs, not
// literal text.
const decodedHtml = decodeSafeEntities(htmlString);

// Wrap bare content in a full-page container if needed
const wrappedHtml = ensureRootContainer(htmlString, viewport.width, viewport.height);
const wrappedHtml = ensureRootContainer(decodedHtml, viewport.width, viewport.height);

// Satori requires every element with multiple children to have an explicit
// display property. Auto-inject display:flex;flex-direction:column on any
Expand All @@ -76,6 +83,12 @@ export class SatoriRenderer {
width: viewport.width,
height: viewport.height,
fonts: this.fonts,
loadAdditionalAsset: async (code, segment) => {
if (code === "emoji") {
return await loadEmojiSvg(getEmojiCode(segment));
}
return [];
},
});

// SVG -> PNG buffer at 2x (Retina)
Expand Down Expand Up @@ -171,3 +184,38 @@ function ensureRootContainer(htmlString, width, height) {
// Wrap in a root container
return `<div style="width:${width}px;height:${height}px;overflow:hidden;font-family:Inter,sans-serif;display:flex;flex-direction:column;">${trimmed}</div>`;
}

// Named entities whose decoded form contains no HTML metacharacter
// (<, >, &, ", '). Safe to decode before HTML parsing because the result
// can never be mistaken for markup. Deliberately excludes &amp;/&lt;/&gt;/
// &quot;/&apos; — decoding those would break the parser.
const SAFE_NAMED_ENTITIES = {
nbsp: "\u00a0", copy: "\u00a9", reg: "\u00ae", trade: "\u2122",
hellip: "\u2026", mdash: "\u2014", ndash: "\u2013",
laquo: "\u00ab", raquo: "\u00bb", middot: "\u00b7", bull: "\u2022",
deg: "\u00b0", plusmn: "\u00b1", times: "\u00d7", divide: "\u00f7",
para: "\u00b6", sect: "\u00a7", dagger: "\u2020", Dagger: "\u2021",
spades: "\u2660", clubs: "\u2663", hearts: "\u2665", diams: "\u2666",
larr: "\u2190", uarr: "\u2191", rarr: "\u2192", darr: "\u2193",
harr: "\u2194", crarr: "\u21b5", lArr: "\u21d0", rArr: "\u21d2",
check: "\u2713", cross: "\u2717",
lsquo: "\u2018", rsquo: "\u2019", ldquo: "\u201c", rdquo: "\u201d",
prime: "\u2032", Prime: "\u2033",
};

function decodeSafeEntities(html) {
return html
.replace(/&#(\d+);/g, (match, dec) => {
const code = parseInt(dec, 10);
return Number.isFinite(code) && code > 0 && code <= 0x10ffff
? String.fromCodePoint(code)
: match;
})
.replace(/&#[xX]([0-9a-fA-F]+);/g, (match, hex) => {
const code = parseInt(hex, 16);
return Number.isFinite(code) && code > 0 && code <= 0x10ffff
? String.fromCodePoint(code)
: match;
})
.replace(/&([a-zA-Z]+);/g, (match, name) => SAFE_NAMED_ENTITIES[name] || match);
}
Loading