diff --git a/apps/sim/public/library/ai-agent-ideas/cover.jpg b/apps/sim/public/library/ai-agent-ideas/cover.jpg index 03349a7716a..aec346c5579 100644 Binary files a/apps/sim/public/library/ai-agent-ideas/cover.jpg and b/apps/sim/public/library/ai-agent-ideas/cover.jpg differ diff --git a/apps/sim/public/library/ai-agent-ideas/cover.png b/apps/sim/public/library/ai-agent-ideas/cover.png deleted file mode 100644 index e2ad1f6ed16..00000000000 Binary files a/apps/sim/public/library/ai-agent-ideas/cover.png and /dev/null differ diff --git a/apps/sim/public/library/ai-agent-observability/cover.jpg b/apps/sim/public/library/ai-agent-observability/cover.jpg new file mode 100644 index 00000000000..9a04c9dcc93 Binary files /dev/null and b/apps/sim/public/library/ai-agent-observability/cover.jpg differ diff --git a/apps/sim/public/library/ai-agent-vs-chatbot/cover.jpg b/apps/sim/public/library/ai-agent-vs-chatbot/cover.jpg index 67486a544fd..ac1612a44e6 100644 Binary files a/apps/sim/public/library/ai-agent-vs-chatbot/cover.jpg and b/apps/sim/public/library/ai-agent-vs-chatbot/cover.jpg differ diff --git a/apps/sim/public/library/ai-agent-vs-chatbot/cover.png b/apps/sim/public/library/ai-agent-vs-chatbot/cover.png deleted file mode 100644 index c2033d12230..00000000000 Binary files a/apps/sim/public/library/ai-agent-vs-chatbot/cover.png and /dev/null differ diff --git a/apps/sim/public/library/ai-agents-in-procurement/cover.jpg b/apps/sim/public/library/ai-agents-in-procurement/cover.jpg new file mode 100644 index 00000000000..6d97ccf1bef Binary files /dev/null and b/apps/sim/public/library/ai-agents-in-procurement/cover.jpg differ diff --git a/apps/sim/public/library/ai-agents-vs-rpa/cover.jpg b/apps/sim/public/library/ai-agents-vs-rpa/cover.jpg index 13d3b242217..d15630fb287 100644 Binary files a/apps/sim/public/library/ai-agents-vs-rpa/cover.jpg and b/apps/sim/public/library/ai-agents-vs-rpa/cover.jpg differ diff --git a/apps/sim/public/library/ai-agents-vs-rpa/cover.png b/apps/sim/public/library/ai-agents-vs-rpa/cover.png deleted file mode 100644 index 1c1e6cffe14..00000000000 Binary files a/apps/sim/public/library/ai-agents-vs-rpa/cover.png and /dev/null differ diff --git a/apps/sim/public/library/apache-2-0-vs-fair-code/cover.jpg b/apps/sim/public/library/apache-2-0-vs-fair-code/cover.jpg index 0572501103e..e7511042a35 100644 Binary files a/apps/sim/public/library/apache-2-0-vs-fair-code/cover.jpg and b/apps/sim/public/library/apache-2-0-vs-fair-code/cover.jpg differ diff --git a/apps/sim/public/library/best-ai-agent-platforms-2026/cover.jpg b/apps/sim/public/library/best-ai-agent-platforms-2026/cover.jpg index ef79efb8832..889f12f9d39 100644 Binary files a/apps/sim/public/library/best-ai-agent-platforms-2026/cover.jpg and b/apps/sim/public/library/best-ai-agent-platforms-2026/cover.jpg differ diff --git a/apps/sim/public/library/best-ai-agents-for-customer-support-automation/cover.jpg b/apps/sim/public/library/best-ai-agents-for-customer-support-automation/cover.jpg index 14234aef1a6..6ee3bcf0c55 100644 Binary files a/apps/sim/public/library/best-ai-agents-for-customer-support-automation/cover.jpg and b/apps/sim/public/library/best-ai-agents-for-customer-support-automation/cover.jpg differ diff --git a/apps/sim/public/library/best-ai-agents-for-data-extraction-and-rag-in-2026/cover.jpg b/apps/sim/public/library/best-ai-agents-for-data-extraction-and-rag-in-2026/cover.jpg index a90a2c607f1..8f0aeaa4146 100644 Binary files a/apps/sim/public/library/best-ai-agents-for-data-extraction-and-rag-in-2026/cover.jpg and b/apps/sim/public/library/best-ai-agents-for-data-extraction-and-rag-in-2026/cover.jpg differ diff --git a/apps/sim/public/library/best-ai-agents-sales-crm-automation/cover.jpg b/apps/sim/public/library/best-ai-agents-sales-crm-automation/cover.jpg index cfeb0548ec8..90487c8d9e6 100644 Binary files a/apps/sim/public/library/best-ai-agents-sales-crm-automation/cover.jpg and b/apps/sim/public/library/best-ai-agents-sales-crm-automation/cover.jpg differ diff --git a/apps/sim/public/library/best-relay-app-alternatives-2026/cover.jpg b/apps/sim/public/library/best-relay-app-alternatives-2026/cover.jpg index 9be1ade4f52..75a2691b8fa 100644 Binary files a/apps/sim/public/library/best-relay-app-alternatives-2026/cover.jpg and b/apps/sim/public/library/best-relay-app-alternatives-2026/cover.jpg differ diff --git a/apps/sim/public/library/best-zapier-alternatives/cover.jpg b/apps/sim/public/library/best-zapier-alternatives/cover.jpg index 39326c90a86..a292d395ae8 100644 Binary files a/apps/sim/public/library/best-zapier-alternatives/cover.jpg and b/apps/sim/public/library/best-zapier-alternatives/cover.jpg differ diff --git a/apps/sim/public/library/best-zapier-alternatives/cover.png b/apps/sim/public/library/best-zapier-alternatives/cover.png deleted file mode 100644 index 4cabf2d4664..00000000000 Binary files a/apps/sim/public/library/best-zapier-alternatives/cover.png and /dev/null differ diff --git a/apps/sim/public/library/byok-multi-model-ai-agent-builder/cover.jpg b/apps/sim/public/library/byok-multi-model-ai-agent-builder/cover.jpg index 1ce935859c4..036ce4f6257 100644 Binary files a/apps/sim/public/library/byok-multi-model-ai-agent-builder/cover.jpg and b/apps/sim/public/library/byok-multi-model-ai-agent-builder/cover.jpg differ diff --git a/apps/sim/public/library/how-to-create-an-ai-agent/cover.jpg b/apps/sim/public/library/how-to-create-an-ai-agent/cover.jpg index 55c9ead55c4..72a14878ecf 100644 Binary files a/apps/sim/public/library/how-to-create-an-ai-agent/cover.jpg and b/apps/sim/public/library/how-to-create-an-ai-agent/cover.jpg differ diff --git a/apps/sim/public/library/how-to-create-an-ai-agent/cover.png b/apps/sim/public/library/how-to-create-an-ai-agent/cover.png deleted file mode 100644 index b6ed81da1ad..00000000000 Binary files a/apps/sim/public/library/how-to-create-an-ai-agent/cover.png and /dev/null differ diff --git a/apps/sim/public/library/langgraph-alternatives/cover.jpg b/apps/sim/public/library/langgraph-alternatives/cover.jpg index 1147e7b36e8..b6b0c121fa5 100644 Binary files a/apps/sim/public/library/langgraph-alternatives/cover.jpg and b/apps/sim/public/library/langgraph-alternatives/cover.jpg differ diff --git a/apps/sim/public/library/mcp-security/cover.jpg b/apps/sim/public/library/mcp-security/cover.jpg new file mode 100644 index 00000000000..0b2b66adc63 Binary files /dev/null and b/apps/sim/public/library/mcp-security/cover.jpg differ diff --git a/apps/sim/public/library/n8n-alternatives/cover.jpg b/apps/sim/public/library/n8n-alternatives/cover.jpg index 970f190bd4e..604848f97a7 100644 Binary files a/apps/sim/public/library/n8n-alternatives/cover.jpg and b/apps/sim/public/library/n8n-alternatives/cover.jpg differ diff --git a/apps/sim/public/library/open-source-ai-agent-platforms/cover.jpg b/apps/sim/public/library/open-source-ai-agent-platforms/cover.jpg index 17e1d7b8338..2fafd5638ea 100644 Binary files a/apps/sim/public/library/open-source-ai-agent-platforms/cover.jpg and b/apps/sim/public/library/open-source-ai-agent-platforms/cover.jpg differ diff --git a/apps/sim/public/library/openai-vs-n8n-vs-sim/cover.jpg b/apps/sim/public/library/openai-vs-n8n-vs-sim/cover.jpg index fe02be688a7..e0411b40f44 100644 Binary files a/apps/sim/public/library/openai-vs-n8n-vs-sim/cover.jpg and b/apps/sim/public/library/openai-vs-n8n-vs-sim/cover.jpg differ diff --git a/apps/sim/public/library/openai-vs-n8n-vs-sim/cover.png b/apps/sim/public/library/openai-vs-n8n-vs-sim/cover.png deleted file mode 100644 index f90d16243e4..00000000000 Binary files a/apps/sim/public/library/openai-vs-n8n-vs-sim/cover.png and /dev/null differ diff --git a/apps/sim/public/library/what-is-an-mcp-server/cover.jpg b/apps/sim/public/library/what-is-an-mcp-server/cover.jpg index e87140b7528..7ab72a9d2c1 100644 Binary files a/apps/sim/public/library/what-is-an-mcp-server/cover.jpg and b/apps/sim/public/library/what-is-an-mcp-server/cover.jpg differ diff --git a/bun.lock b/bun.lock index edb0f0edc81..e86c75c0379 100644 --- a/bun.lock +++ b/bun.lock @@ -11,11 +11,16 @@ "@biomejs/biome": "2.0.0-beta.5", "@clack/prompts": "1.7.0", "@octokit/rest": "^21.0.0", + "@types/opentype.js": "1.3.10", + "@vercel/og": "0.6.8", "chalk": "5.6.2", "glob": "13.0.0", + "gray-matter": "4.0.3", "husky": "9.1.7", "json-schema-to-typescript": "15.0.4", "lint-staged": "16.0.0", + "opentype.js": "1.3.4", + "sharp": "0.35.3", "turbo": "2.9.14", "yaml": "^2.8.1", }, @@ -1136,7 +1141,7 @@ "@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.35.3", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.3.2" }, "os": "linux", "cpu": "x64" }, "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg=="], - "@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.5", "", { "dependencies": { "@emnapi/runtime": "^1.7.0" }, "cpu": "none" }, "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw=="], + "@img/sharp-wasm32": ["@img/sharp-wasm32@0.35.3", "", { "dependencies": { "@emnapi/runtime": "^1.11.1" } }, "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w=="], "@img/sharp-webcontainers-wasm32": ["@img/sharp-webcontainers-wasm32@0.35.3", "", { "dependencies": { "@img/sharp-wasm32": "0.35.3" }, "cpu": "none" }, "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q=="], @@ -1988,6 +1993,8 @@ "@types/nodemailer": ["@types/nodemailer@8.0.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-PxpaInm8V1JQDd4j0ds5HfvWQk8JupS1C0Picb96QJsrrRDjBH+DlK7L4ZdNSqNULhiZRQHc40nLVShaGxXAMw=="], + "@types/opentype.js": ["@types/opentype.js@1.3.10", "", {}, "sha512-F67EFyk6j02okHz5JCgata3ZRAcZi9GLnzmkHw/rzJq3OCc8/ZVdoKrxMTYjcQP6IYHGBz2cav1cpzkOkPiPCQ=="], + "@types/prismjs": ["@types/prismjs@1.26.6", "", {}, "sha512-vqlvI7qlMvcCBbVe0AKAb4f97//Hy0EBTaiW8AalRnG/xAN5zOiWWyrNqNXeq8+KAuvRewjCVY1+IPxk4RdNYw=="], "@types/react": ["@types/react@19.2.17", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw=="], @@ -3418,6 +3425,8 @@ "openapi-typescript-helpers": ["openapi-typescript-helpers@0.0.15", "", {}, "sha512-opyTPaunsklCBpTK8JGef6mfPhLSnyy5a0IN9vKtx3+4aExf+KxEqYwIy3hqkedXIB97u357uLMJsOnm3GVjsw=="], + "opentype.js": ["opentype.js@1.3.4", "", { "dependencies": { "string.prototype.codepointat": "^0.2.1", "tiny-inflate": "^1.0.3" }, "bin": { "ot": "bin/ot" } }, "sha512-d2JE9RP/6uagpQAVtJoF0pJJA/fgai89Cc50Yp0EJHk+eLp6QQ7gBoblsnubRULNY132I0J1QKMJ+JTbMqz4sw=="], + "option": ["option@0.2.4", "", {}, "sha512-pkEqbDyl8ou5cpq+VsnQbe/WlEy5qS7xPzMS1U55OCG9KPvwFD46zDbxQIj3egJSFc3D+XhYOPUzz49zQAVy7A=="], "ora": ["ora@8.2.0", "", { "dependencies": { "chalk": "^5.3.0", "cli-cursor": "^5.0.0", "cli-spinners": "^2.9.2", "is-interactive": "^2.0.0", "is-unicode-supported": "^2.0.0", "log-symbols": "^6.0.0", "stdin-discarder": "^0.2.2", "string-width": "^7.2.0", "strip-ansi": "^7.1.0" } }, "sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw=="], @@ -3778,7 +3787,7 @@ "selderee": ["selderee@0.11.0", "", { "dependencies": { "parseley": "^0.12.0" } }, "sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA=="], - "semver": ["semver@7.8.0", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA=="], + "semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], "send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="], @@ -4274,6 +4283,8 @@ "@earendil-works/pi-coding-agent/jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="], + "@earendil-works/pi-coding-agent/semver": ["semver@7.8.0", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA=="], + "@earendil-works/pi-coding-agent/undici": ["undici@8.5.0", "", {}, "sha512-xamtWoB1EshgjpmlXd7GGm2VfdDtw1+rD8uhry8pSNW3If6S8E0m2T2+orSKeZXEn/aPJMviCpDBA65WJt8zhg=="], "@earendil-works/pi-tui/marked": ["marked@18.0.5", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w=="], @@ -4282,10 +4293,6 @@ "@google-cloud/storage/google-auth-library": ["google-auth-library@9.15.1", "", { "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", "gaxios": "^6.1.1", "gcp-metadata": "^6.1.0", "gtoken": "^7.0.0", "jws": "^4.0.0" } }, "sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng=="], - "@img/sharp-freebsd-wasm32/@img/sharp-wasm32": ["@img/sharp-wasm32@0.35.3", "", { "dependencies": { "@emnapi/runtime": "^1.11.1" } }, "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w=="], - - "@img/sharp-webcontainers-wasm32/@img/sharp-wasm32": ["@img/sharp-wasm32@0.35.3", "", { "dependencies": { "@emnapi/runtime": "^1.11.1" } }, "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w=="], - "@modelcontextprotocol/sdk/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], "@modelcontextprotocol/sdk/jose": ["jose@6.2.3", "", {}, "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw=="], @@ -4916,8 +4923,6 @@ "rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="], - "sharp/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], - "sim/tailwindcss": ["tailwindcss@3.4.19", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", "chokidar": "^3.6.0", "didyoumean": "^1.2.2", "dlv": "^1.1.3", "fast-glob": "^3.3.2", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", "jiti": "^1.21.7", "lilconfig": "^3.1.3", "micromatch": "^4.0.8", "normalize-path": "^3.0.0", "object-hash": "^3.0.0", "picocolors": "^1.1.1", "postcss": "^8.4.47", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", "postcss-nested": "^6.2.0", "postcss-selector-parser": "^6.1.2", "resolve": "^1.22.8", "sucrase": "^3.35.0" }, "bin": { "tailwind": "lib/cli.js", "tailwindcss": "lib/cli.js" } }, "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ=="], "simstudio/@types/node": ["@types/node@20.19.43", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA=="], @@ -5368,6 +5373,8 @@ "next/sharp/@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="], + "next/sharp/@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.5", "", { "dependencies": { "@emnapi/runtime": "^1.7.0" }, "cpu": "none" }, "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw=="], + "next/sharp/@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="], "next/sharp/@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.34.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg=="], diff --git a/package.json b/package.json index 08457264a00..f4ec2af72e5 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,8 @@ "vfs-snapshot-contract:check": "bun run scripts/sync-vfs-snapshot-contract.ts --check", "mship:generate": "bun run scripts/generate-mship-contracts.ts", "mship:check": "bun run scripts/generate-mship-contracts.ts --check", + "library:covers": "bun run scripts/generate-library-covers.tsx", + "library:covers:check": "bun run scripts/generate-library-covers.tsx --check", "skills:sync": "bun run scripts/sync-skills.ts", "skills:check": "bun run scripts/sync-skills.ts --check", "setup": "bun run scripts/setup/index.ts setup", @@ -89,11 +91,16 @@ "@biomejs/biome": "2.0.0-beta.5", "@clack/prompts": "1.7.0", "@octokit/rest": "^21.0.0", + "@types/opentype.js": "1.3.10", + "@vercel/og": "0.6.8", "chalk": "5.6.2", "glob": "13.0.0", + "gray-matter": "4.0.3", "husky": "9.1.7", "json-schema-to-typescript": "15.0.4", "lint-staged": "16.0.0", + "opentype.js": "1.3.4", + "sharp": "0.35.3", "turbo": "2.9.14", "yaml": "^2.8.1" }, diff --git a/scripts/generate-library-covers.tsx b/scripts/generate-library-covers.tsx new file mode 100644 index 00000000000..f4c1245f68a --- /dev/null +++ b/scripts/generate-library-covers.tsx @@ -0,0 +1,412 @@ +/** + * Generates the cover images for `/library` posts from their MDX frontmatter. + * + * Every cover follows one template: light gray field, the "sim" wordmark + * top-left, a diagonal open arrow top-right, and the post title set large at + * the bottom-left. The same template is rendered at request time for docs + * pages by `apps/docs/app/api/og/route.tsx`; this script is the build-time + * equivalent for library posts, whose covers ship as static assets because + * they are rendered with `unoptimized` (see #5528) and the SEO builders probe + * their real dimensions off disk. + * + * Covers are derived artifacts, not source of truth: the title in the image + * comes from frontmatter, so editing a post's `title` makes its committed + * cover stale. Every run therefore re-renders from scratch rather than + * skipping outputs that already exist. Rendering is deterministic on a given + * machine, so a full run is a no-op in git for everything that did not + * actually change. + * + * Usage, from the repo root: + * bun run library:covers # re-render every post's cover + * bun run library:covers ... # re-render only the named posts + * bun run library:covers --check # verify committed covers are in sync + */ + +import { existsSync } from 'node:fs' +import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises' +import path from 'node:path' +import type { CSSProperties } from 'react' +import { ImageResponse } from '@vercel/og' +import matter from 'gray-matter' +import { parse as parseFont } from 'opentype.js' +import sharp from 'sharp' + +const REPO_ROOT = path.resolve(import.meta.dirname, '..') +const CONTENT_DIR = path.join(REPO_ROOT, 'apps/sim/content/library') +const OUTPUT_DIR = path.join(REPO_ROOT, 'apps/sim/public/library') +/** + * Söhne Kräftig (weight 500), the typeface of the reference cover template, + * as a plain TTF — Satori (the renderer behind `ImageResponse`) parses neither + * WOFF2 nor variable fonts. Shared with the docs OG route, which serves this + * same file over HTTP because it runs on the edge with no filesystem. + */ +const FONT_PATH = path.join(REPO_ROOT, 'apps/docs/public/static/fonts/Soehne-Kraftig.ttf') + +const COVER_WIDTH = 1200 +const COVER_HEIGHT = 675 +/** + * mozjpeg at 82 lands these covers around 30 KB — in line with the hand-compressed + * ones they replace. The artwork is a flat field plus large text, so the only detail + * the encoder has to preserve is glyph edges. + */ +const JPEG_QUALITY = 82 +/** + * How far one greyscale pixel must move to count as genuinely redrawn rather + * than re-encoded. Measured across three deliberately different encodes of an + * identical render (quality 60/70 without mozjpeg, quality 95 with), the + * largest single-pixel deviation was 20; 48 clears that by well over 2x. + */ +const COVER_PIXEL_DELTA = 48 +/** + * How many redrawn pixels `--check` tolerates before calling a cover stale. + * + * Deliberately a count and not an average. Averaging dilutes a local edit + * across all 810,000 pixels: changing a title's "2026" to "2027" moves the + * mean by only 0.42, which any threshold loose enough to absorb encoder noise + * would wave through. That same edit redraws 2,559 pixels, while the three + * re-encodes above redraw none at all — so a count separates the two cases + * with margin to spare in both directions. + */ +const MAX_REDRAWN_PIXELS = 200 + +/** Exact hex from a vector trace of the reference cover template, not an estimate off compressed JPEG pixels. */ +const INK_COLOR = '#515151' +const BACKGROUND_COLOR = '#c1c1c1' +const TITLE_BOX_WIDTH = 1020 +/** Tried largest-first; the first size whose title wraps into at most `MAX_TITLE_LINES` wins. */ +const TITLE_FONT_SIZES = [110, 96, 85, 76] as const +/** + * Four lines of title crowd the wordmark and read as a paragraph rather than a + * headline. Titles too long to fit in three lines at the smallest step are set + * at that step anyway and allowed to run to a fourth line. + */ +const MAX_TITLE_LINES = 3 + +const CONTAINER_STYLE = { + height: '100%', + width: '100%', + display: 'flex', + flexDirection: 'column', + justifyContent: 'space-between', + padding: '26px', + background: BACKGROUND_COLOR, + fontFamily: 'Soehne', +} satisfies CSSProperties +const HEADER_STYLE = { + display: 'flex', + justifyContent: 'space-between', + alignItems: 'flex-start', + width: '100%', +} satisfies CSSProperties +const TITLE_STYLE = { + display: 'flex', + flexDirection: 'column', + fontWeight: 500, + color: INK_COLOR, + lineHeight: 1.1, + width: `${TITLE_BOX_WIDTH}px`, + /** Compensates for Satori adding extra invisible leading below the last line instead of splitting it evenly. */ + transform: 'translateY(14px)', +} satisfies CSSProperties + +/** Measures a string's rendered width in pixels at `fontSize`, in the cover typeface. */ +type TextMeasurer = (text: string, fontSize: number) => number + +/** Greedily packs `pieces` into chunks that each measure within `TITLE_BOX_WIDTH`. */ +function packChunks(pieces: string[], fontSize: number, measure: TextMeasurer): string[] { + const chunks: string[] = [] + let current = '' + + for (const piece of pieces) { + const candidate = current + piece + if (measure(candidate, fontSize) > TITLE_BOX_WIDTH && current) { + chunks.push(current) + current = piece + } else { + current = candidate + } + } + if (current) chunks.push(current) + + return chunks +} + +/** + * Breaks a single token that is wider than the title box on its own. + * + * `wrapTitleLines` can only break between space-separated words, so a long + * hyphenated compound ("Bring-Your-Own-Key-Management") would otherwise sit on + * a line that overflows the canvas — and since the rendered lines join with + * non-breaking spaces, Satori's only remaining break opportunity is a hyphen, + * putting the break somewhere nobody chose. Splitting here keeps that decision + * in this file. + * + * Hyphens are tried first because that is where a reader (and a browser) + * expects a compound to break; the trailing hyphen stays on the upper line. + * A chunk with no usable hyphen falls back to a character-level split, which + * only a pathological token (a long URL, an unbroken identifier) ever reaches. + */ +function splitOversizedWord(word: string, fontSize: number, measure: TextMeasurer): string[] { + const afterHyphens = packChunks(word.split(/(?<=-)/), fontSize, measure) + + return afterHyphens.flatMap((chunk) => + measure(chunk, fontSize) <= TITLE_BOX_WIDTH + ? [chunk] + : packChunks([...chunk], fontSize, measure) + ) +} + +/** + * Greedily packs words into lines that fit `TITLE_BOX_WIDTH` at `fontSize`, + * then joins each line with U+00A0 instead of a plain space. Satori has a + * text-measurement bug where the first plain space (U+0020) in a text node + * renders at roughly double width — a non-breaking space measures correctly + * and reads identically at this size, so it sidesteps the bug instead of + * fighting Satori's own line-wrapping. + * + * Because those non-breaking spaces leave Satori no word boundaries to break + * on, a line that turns out to overflow gets re-broken at whatever hyphen it + * happens to contain. That is why widths come from the font's real advance + * metrics rather than an average-glyph-width estimate: caps-heavy titles + * ("BYOK Multi-Model AI Agent") run ~15% wider than the average, and + * under-measuring one lands the break mid-compound in the rendered image. + */ +function wrapTitleLines(title: string, fontSize: number, measure: TextMeasurer): string[] { + const lines: string[] = [] + let current = '' + + for (const word of title.split(' ')) { + if (measure(word, fontSize) > TITLE_BOX_WIDTH) { + if (current) { + lines.push(current) + current = '' + } + const chunks = splitOversizedWord(word, fontSize, measure) + lines.push(...chunks.slice(0, -1)) + current = chunks[chunks.length - 1] ?? '' + continue + } + + const candidate = current ? `${current} ${word}` : word + if (measure(candidate, fontSize) > TITLE_BOX_WIDTH && current) { + lines.push(current) + current = word + } else { + current = candidate + } + } + if (current) lines.push(current) + + return lines.map((line) => line.replace(/ /g, ' ')) +} + +/** + * Largest step whose title fits `MAX_TITLE_LINES` *and* whose every line fits + * `TITLE_BOX_WIDTH`, falling back to the smallest step. + * + * Both conditions matter. `wrapTitleLines` cannot break inside a token, so a + * single long word (a hyphenated compound like "Bring-Your-Own-Key", a URL) + * can exceed the box on its own and still produce few enough lines to pass a + * line-count-only test. That line would then overflow, and because the joined + * spaces are non-breaking, Satori's only recourse is to break it at a hyphen — + * reintroducing the mid-compound break this layout exists to avoid. Checking + * measured width catches it and steps the size down instead. + */ +function layoutTitle(title: string, measure: TextMeasurer): { fontSize: number; lines: string[] } { + let layout: { fontSize: number; lines: string[] } = { + fontSize: TITLE_FONT_SIZES[0], + lines: [title], + } + + for (const fontSize of TITLE_FONT_SIZES) { + const lines = wrapTitleLines(title, fontSize, measure) + layout = { fontSize, lines } + + const fitsBox = lines.every((line) => measure(line, fontSize) <= TITLE_BOX_WIDTH) + if (lines.length <= MAX_TITLE_LINES && fitsBox) break + } + + return layout +} + +/** "sim" wordmark, no icon — same brandbook wordmark geometry as the docs navbar/landing OG cards. */ +function SimWordmark() { + return ( + + + + + + ) +} + +/** Diagonal "open" arrow, top-right — square caps and a miter join to match the reference's sharp corners. */ +function CornerArrow() { + return ( + + + + ) +} + +async function renderCover( + title: string, + fontData: ArrayBuffer, + measure: TextMeasurer +): Promise { + const { fontSize, lines } = layoutTitle(title, measure) + const image = new ImageResponse( +
+
+ + +
+ +
+ {lines.map((line, index) => ( + {line} + ))} +
+
, + { + width: COVER_WIDTH, + height: COVER_HEIGHT, + fonts: [{ name: 'Soehne', data: fontData, style: 'normal', weight: 500 }], + } + ) + + const png = Buffer.from(await image.arrayBuffer()) + return await sharp(png).jpeg({ quality: JPEG_QUALITY, mozjpeg: true }).toBuffer() +} + +/** + * Number of greyscale pixels the committed cover draws differently from a + * freshly rendered one — `null` if the committed file is not the expected size. + * + * Deliberately not a byte comparison of the JPEGs. libvips/mozjpeg output is + * not portable across OS and CPU, so identical input can encode to different + * bytes on a contributor's machine or a Linux CI runner and fail a byte-equal + * check for no real reason. Decoding first discards that encoder variance + * while preserving what the check is actually about: whether the committed + * image still renders the current title. + */ +async function countRedrawnPixels(committedPath: string, rendered: Buffer): Promise { + const decode = (input: string | Buffer) => + sharp(input).greyscale().raw().toBuffer({ resolveWithObject: true }) + + const [a, b] = await Promise.all([decode(committedPath), decode(rendered)]) + if (a.info.width !== b.info.width || a.info.height !== b.info.height) return null + + let redrawn = 0 + for (let i = 0; i < a.data.length; i++) { + if (Math.abs(a.data[i] - b.data[i]) > COVER_PIXEL_DELTA) redrawn += 1 + } + return redrawn +} + +/** + * Reads the `title` out of an MDX file's YAML frontmatter without pulling in a + * YAML parser. + * + * Uses `gray-matter` rather than a regex because the page and its `og:title` + * are parsed with `gray-matter` too (`lib/content/registry-factory.ts`). A + * separate parser here could disagree with it on double-quoted escapes or + * block scalars, and the cover would then confidently render a title the page + * never shows — with `--check` calling it in sync. + */ +function readFrontmatterTitle(source: string): string | null { + const { title } = matter(source).data + return typeof title === 'string' && title.length > 0 ? title : null +} + +async function main() { + const args = process.argv.slice(2) + const check = args.includes('--check') + const only = new Set(args.filter((arg) => !arg.startsWith('--'))) + + const font = await readFile(FONT_PATH) + const fontData = font.buffer.slice( + font.byteOffset, + font.byteOffset + font.byteLength + ) as ArrayBuffer + const metrics = parseFont(fontData) + const measure: TextMeasurer = (text, fontSize) => metrics.getAdvanceWidth(text, fontSize) + + const slugs = (await readdir(CONTENT_DIR, { withFileTypes: true })) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .sort() + + const unknown = [...only].filter((slug) => !slugs.includes(slug)) + if (unknown.length > 0) { + throw new Error(`No library post for: ${unknown.join(', ')}`) + } + + const stale: string[] = [] + let written = 0 + + for (const slug of slugs) { + if (only.size > 0 && !only.has(slug)) continue + + const outputPath = path.join(OUTPUT_DIR, slug, 'cover.jpg') + const source = await readFile(path.join(CONTENT_DIR, slug, 'index.mdx'), 'utf8') + const title = readFrontmatterTitle(source) + if (!title) { + throw new Error(`Could not read a \`title\` from frontmatter of ${slug}/index.mdx`) + } + + const cover = await renderCover(title, fontData, measure) + + if (check) { + if (!existsSync(outputPath)) { + stale.push(`${slug} — no cover committed`) + continue + } + const redrawn = await countRedrawnPixels(outputPath, cover) + if (redrawn === null) { + stale.push(`${slug} — committed cover has unexpected dimensions`) + } else if (redrawn > MAX_REDRAWN_PIXELS) { + stale.push( + `${slug} — committed cover does not render "${title}" (${redrawn} pixels differ)` + ) + } + continue + } + + await mkdir(path.dirname(outputPath), { recursive: true }) + await writeFile(outputPath, cover) + written += 1 + console.log(`✓ ${slug}/cover.jpg — "${title}"`) + } + + if (check) { + if (stale.length > 0) { + console.error(`${stale.length} library cover(s) out of sync with frontmatter:\n`) + for (const entry of stale) console.error(` ✗ ${entry}`) + console.error('\nRun `bun run library:covers` and commit the result.') + process.exitCode = 1 + return + } + console.log('All library covers are in sync with their frontmatter titles.') + return + } + + console.log(`\n${written} cover${written === 1 ? '' : 's'} written to apps/sim/public/library/`) +} + +await main() diff --git a/scripts/tsconfig.json b/scripts/tsconfig.json index 9f1a5c45253..486cf36a7cc 100644 --- a/scripts/tsconfig.json +++ b/scripts/tsconfig.json @@ -10,11 +10,12 @@ "forceConsistentCasingInFileNames": true, "resolveJsonModule": true, "noEmit": true, - "allowImportingTsExtensions": true + "allowImportingTsExtensions": true, + "jsx": "react-jsx" }, "ts-node": { "esm": true, "experimentalSpecifierResolution": "node" }, - "include": ["./**/*.ts"] + "include": ["./**/*.ts", "./**/*.tsx"] }