From 5e2b73d3a2252923391dec5fadf2632916f517cd Mon Sep 17 00:00:00 2001
From: anurag629 <78868769+anurag629@users.noreply.github.com>
Date: Fri, 30 Oct 2026 12:06:59 +0530
Subject: [PATCH 1/3] release: v1.8.0, forty-eight new templates and editor
fixes
---
package-lock.json | 110 ++--
package.json | 2 +-
src/components/editor/ColorPicker.tsx | 9 +-
src/components/editor/CustomizePanel.tsx | 2 +-
src/components/editor/ExportBar.tsx | 1 +
src/components/editor/useEditorState.ts | 19 +-
src/lib/api-validation.ts | 16 +-
src/lib/url-builder.ts | 26 +
src/pages/api-docs.astro | 1 +
src/pages/api/og.ts | 14 +-
src/pages/api/templates/[id]/thumbnail.png.ts | 9 +-
src/styles/editor.css | 2 +-
src/templates/blog/benchmark.ts | 203 +++++++
src/templates/blog/index.ts | 3 +
src/templates/blog/table-of-contents.ts | 349 ++++++++++++
src/templates/blog/updated-post.ts | 331 +++++++++++
src/templates/developer/day-streak.ts | 130 +++++
src/templates/developer/index.ts | 3 +
src/templates/developer/now.ts | 66 +++
src/templates/developer/year-review.ts | 170 ++++++
src/templates/ecommerce/back-in-stock.ts | 167 ++++++
src/templates/ecommerce/bundle-deal.ts | 292 ++++++++++
src/templates/ecommerce/gift-card.ts | 212 +++++++
src/templates/ecommerce/index.ts | 4 +
src/templates/ecommerce/order-status.ts | 241 ++++++++
src/templates/event/call-for-speakers.ts | 194 +++++++
src/templates/event/index.ts | 4 +
src/templates/event/office-hours.ts | 80 +++
src/templates/event/rescheduled.ts | 160 ++++++
src/templates/event/ticket-cta.ts | 11 +-
src/templates/event/winners.ts | 228 ++++++++
src/templates/github/good-first-issues.ts | 84 +++
src/templates/github/index.ts | 6 +
src/templates/github/maintainers-wanted.ts | 489 ++++++++++++++++
src/templates/github/milestone.ts | 219 ++++++++
src/templates/github/package-card.ts | 530 ++++++++++++++++++
src/templates/github/security-advisory.ts | 173 ++++++
src/templates/github/sponsors-goal.ts | 313 +++++++++++
src/templates/job/benefits.ts | 190 +++++++
src/templates/job/company-culture.ts | 9 +-
src/templates/job/index.ts | 5 +
src/templates/job/internship.ts | 192 +++++++
src/templates/job/interview-process.ts | 255 +++++++++
src/templates/job/new-hire.ts | 269 +++++++++
src/templates/job/referral.ts | 218 +++++++
src/templates/newsletter/index.ts | 4 +
src/templates/newsletter/members-only.ts | 246 ++++++++
src/templates/newsletter/poll.ts | 222 ++++++++
src/templates/newsletter/sponsor.ts | 240 ++++++++
src/templates/newsletter/welcome.ts | 293 ++++++++++
src/templates/podcast/index.ts | 3 +
src/templates/podcast/listen-on.ts | 209 +++++++
src/templates/podcast/live-recording.ts | 311 ++++++++++
src/templates/podcast/top-episodes.ts | 123 ++++
src/templates/product/index.ts | 1 +
src/templates/product/waitlist.ts | 239 ++++++++
src/templates/quote/article-excerpt.ts | 256 +++++++++
src/templates/quote/code-comment.ts | 202 +++++++
src/templates/quote/index.ts | 4 +
src/templates/quote/interview.ts | 145 +++++
src/templates/quote/subtitle.ts | 188 +++++++
src/templates/registry.ts | 87 ++-
src/templates/saas/api-endpoint.ts | 161 ++++++
src/templates/saas/deprecation.ts | 136 +++++
src/templates/saas/incident.ts | 317 +++++++++++
src/templates/saas/index.ts | 5 +
src/templates/saas/maintenance.ts | 109 ++++
src/templates/saas/pricing-highlight.ts | 10 +-
src/templates/saas/roadmap.ts | 62 ++
src/templates/tutorial/before-after.ts | 234 ++++++++
src/templates/tutorial/cheat-sheet.ts | 284 ++++++++++
src/templates/tutorial/error-explained.ts | 343 ++++++++++++
src/templates/tutorial/glossary.ts | 44 ++
src/templates/tutorial/index.ts | 6 +
src/templates/tutorial/prerequisites.ts | 80 +++
src/templates/tutorial/til.ts | 275 +++++++++
tests/api/og.test.ts | 141 ++++-
tests/api/thumbnail.test.ts | 75 +++
tests/editor/color-picker.test.ts | 30 +
tests/editor/export-bar.test.ts | 27 +
tests/lib/url-builder.test.ts | 26 +
tests/templates/api-endpoint.test.ts | 189 +++++++
tests/templates/back-in-stock.test.ts | 94 ++++
tests/templates/before-after.test.ts | 128 +++++
tests/templates/benchmark.test.ts | 170 ++++++
tests/templates/benefits.test.ts | 81 +++
tests/templates/bundle-deal.test.ts | 126 +++++
tests/templates/call-for-speakers.test.ts | 225 ++++++++
tests/templates/cheat-sheet.test.ts | 117 ++++
tests/templates/company-culture.test.ts | 71 +++
tests/templates/day-streak.test.ts | 34 ++
tests/templates/deprecation.test.ts | 111 ++++
tests/templates/developer-year-review.test.ts | 336 +++++++++++
tests/templates/error-explained.test.ts | 158 ++++++
tests/templates/event-rescheduled.test.ts | 281 ++++++++++
tests/templates/gift-card.test.ts | 98 ++++
tests/templates/glossary.test.ts | 26 +
tests/templates/good-first-issues.test.ts | 35 ++
tests/templates/incident.test.ts | 149 +++++
tests/templates/internship.test.ts | 94 ++++
tests/templates/interview-process.test.ts | 66 +++
tests/templates/job-new-hire.test.ts | 249 ++++++++
tests/templates/job-referral.test.ts | 172 ++++++
tests/templates/live-recording.test.ts | 146 +++++
tests/templates/maintainers-wanted.test.ts | 222 ++++++++
tests/templates/maintenance.test.ts | 45 ++
tests/templates/members-only.test.ts | 148 +++++
tests/templates/milestone.test.ts | 160 ++++++
tests/templates/order-status.test.ts | 114 ++++
tests/templates/package-card.test.ts | 199 +++++++
tests/templates/podcast-listen-on.test.ts | 113 ++++
tests/templates/poll.test.ts | 124 ++++
tests/templates/prerequisites.test.ts | 27 +
tests/templates/pricing-highlight.test.ts | 35 ++
tests/templates/quote-article-excerpt.test.ts | 137 +++++
tests/templates/quote-code-comment.test.ts | 309 ++++++++++
tests/templates/quote-interview.test.ts | 136 +++++
tests/templates/quote-subtitle.test.ts | 97 ++++
tests/templates/registry.test.ts | 11 +
tests/templates/roadmap.test.ts | 27 +
tests/templates/security-advisory.test.ts | 96 ++++
tests/templates/sponsors-goal.test.ts | 219 ++++++++
tests/templates/table-of-contents.test.ts | 219 ++++++++
tests/templates/ticket-cta.test.ts | 39 ++
tests/templates/til.test.ts | 160 ++++++
tests/templates/top-episodes.test.ts | 110 ++++
tests/templates/updated-post.test.ts | 303 ++++++++++
tests/templates/waitlist.test.ts | 112 ++++
tests/templates/welcome.test.ts | 255 +++++++++
129 files changed, 17620 insertions(+), 102 deletions(-)
create mode 100644 src/templates/blog/benchmark.ts
create mode 100644 src/templates/blog/table-of-contents.ts
create mode 100644 src/templates/blog/updated-post.ts
create mode 100644 src/templates/developer/day-streak.ts
create mode 100644 src/templates/developer/now.ts
create mode 100644 src/templates/developer/year-review.ts
create mode 100644 src/templates/ecommerce/back-in-stock.ts
create mode 100644 src/templates/ecommerce/bundle-deal.ts
create mode 100644 src/templates/ecommerce/gift-card.ts
create mode 100644 src/templates/ecommerce/order-status.ts
create mode 100644 src/templates/event/call-for-speakers.ts
create mode 100644 src/templates/event/office-hours.ts
create mode 100644 src/templates/event/rescheduled.ts
create mode 100644 src/templates/event/winners.ts
create mode 100644 src/templates/github/good-first-issues.ts
create mode 100644 src/templates/github/maintainers-wanted.ts
create mode 100644 src/templates/github/milestone.ts
create mode 100644 src/templates/github/package-card.ts
create mode 100644 src/templates/github/security-advisory.ts
create mode 100644 src/templates/github/sponsors-goal.ts
create mode 100644 src/templates/job/benefits.ts
create mode 100644 src/templates/job/internship.ts
create mode 100644 src/templates/job/interview-process.ts
create mode 100644 src/templates/job/new-hire.ts
create mode 100644 src/templates/job/referral.ts
create mode 100644 src/templates/newsletter/members-only.ts
create mode 100644 src/templates/newsletter/poll.ts
create mode 100644 src/templates/newsletter/sponsor.ts
create mode 100644 src/templates/newsletter/welcome.ts
create mode 100644 src/templates/podcast/listen-on.ts
create mode 100644 src/templates/podcast/live-recording.ts
create mode 100644 src/templates/podcast/top-episodes.ts
create mode 100644 src/templates/product/waitlist.ts
create mode 100644 src/templates/quote/article-excerpt.ts
create mode 100644 src/templates/quote/code-comment.ts
create mode 100644 src/templates/quote/interview.ts
create mode 100644 src/templates/quote/subtitle.ts
create mode 100644 src/templates/saas/api-endpoint.ts
create mode 100644 src/templates/saas/deprecation.ts
create mode 100644 src/templates/saas/incident.ts
create mode 100644 src/templates/saas/maintenance.ts
create mode 100644 src/templates/saas/roadmap.ts
create mode 100644 src/templates/tutorial/before-after.ts
create mode 100644 src/templates/tutorial/cheat-sheet.ts
create mode 100644 src/templates/tutorial/error-explained.ts
create mode 100644 src/templates/tutorial/glossary.ts
create mode 100644 src/templates/tutorial/prerequisites.ts
create mode 100644 src/templates/tutorial/til.ts
create mode 100644 tests/api/thumbnail.test.ts
create mode 100644 tests/editor/color-picker.test.ts
create mode 100644 tests/editor/export-bar.test.ts
create mode 100644 tests/lib/url-builder.test.ts
create mode 100644 tests/templates/api-endpoint.test.ts
create mode 100644 tests/templates/back-in-stock.test.ts
create mode 100644 tests/templates/before-after.test.ts
create mode 100644 tests/templates/benchmark.test.ts
create mode 100644 tests/templates/benefits.test.ts
create mode 100644 tests/templates/bundle-deal.test.ts
create mode 100644 tests/templates/call-for-speakers.test.ts
create mode 100644 tests/templates/cheat-sheet.test.ts
create mode 100644 tests/templates/company-culture.test.ts
create mode 100644 tests/templates/day-streak.test.ts
create mode 100644 tests/templates/deprecation.test.ts
create mode 100644 tests/templates/developer-year-review.test.ts
create mode 100644 tests/templates/error-explained.test.ts
create mode 100644 tests/templates/event-rescheduled.test.ts
create mode 100644 tests/templates/gift-card.test.ts
create mode 100644 tests/templates/glossary.test.ts
create mode 100644 tests/templates/good-first-issues.test.ts
create mode 100644 tests/templates/incident.test.ts
create mode 100644 tests/templates/internship.test.ts
create mode 100644 tests/templates/interview-process.test.ts
create mode 100644 tests/templates/job-new-hire.test.ts
create mode 100644 tests/templates/job-referral.test.ts
create mode 100644 tests/templates/live-recording.test.ts
create mode 100644 tests/templates/maintainers-wanted.test.ts
create mode 100644 tests/templates/maintenance.test.ts
create mode 100644 tests/templates/members-only.test.ts
create mode 100644 tests/templates/milestone.test.ts
create mode 100644 tests/templates/order-status.test.ts
create mode 100644 tests/templates/package-card.test.ts
create mode 100644 tests/templates/podcast-listen-on.test.ts
create mode 100644 tests/templates/poll.test.ts
create mode 100644 tests/templates/prerequisites.test.ts
create mode 100644 tests/templates/pricing-highlight.test.ts
create mode 100644 tests/templates/quote-article-excerpt.test.ts
create mode 100644 tests/templates/quote-code-comment.test.ts
create mode 100644 tests/templates/quote-interview.test.ts
create mode 100644 tests/templates/quote-subtitle.test.ts
create mode 100644 tests/templates/roadmap.test.ts
create mode 100644 tests/templates/security-advisory.test.ts
create mode 100644 tests/templates/sponsors-goal.test.ts
create mode 100644 tests/templates/table-of-contents.test.ts
create mode 100644 tests/templates/ticket-cta.test.ts
create mode 100644 tests/templates/til.test.ts
create mode 100644 tests/templates/top-episodes.test.ts
create mode 100644 tests/templates/updated-post.test.ts
create mode 100644 tests/templates/waitlist.test.ts
create mode 100644 tests/templates/welcome.test.ts
diff --git a/package-lock.json b/package-lock.json
index cd9260a..679a9e2 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -28,7 +28,7 @@
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"typescript": "^5.9.3",
- "vitest": "^4.1.11",
+ "vitest": "^4.1.12",
"wrangler": "^4.141.0"
}
},
@@ -2774,16 +2774,16 @@
}
},
"node_modules/@vitest/expect": {
- "version": "4.1.11",
- "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz",
- "integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==",
+ "version": "4.1.12",
+ "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.12.tgz",
+ "integrity": "sha512-vFlTt+YEu9WBpNjdGayNmlMVzrWPi1+sv853/7t2FU7hpO5gAFBKHzQmkCq5Xjpr+evegF2994LQT3hF3mRlqQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.1.0",
"@types/chai": "^5.2.2",
- "@vitest/spy": "4.1.11",
- "@vitest/utils": "4.1.11",
+ "@vitest/spy": "4.1.12",
+ "@vitest/utils": "4.1.12",
"chai": "^6.2.2",
"tinyrainbow": "^3.1.0"
},
@@ -2792,13 +2792,13 @@
}
},
"node_modules/@vitest/mocker": {
- "version": "4.1.11",
- "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz",
- "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==",
+ "version": "4.1.12",
+ "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.12.tgz",
+ "integrity": "sha512-fKShNpKZzupWZuY0z7egydvL0sqW4INEZBEExMBMAa6izlENEVHwoXgdaTCaBZ5fwlr4xf2o1wXC83n9RpO+NA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@vitest/spy": "4.1.11",
+ "@vitest/spy": "4.1.12",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.21"
},
@@ -2818,20 +2818,10 @@
}
}
},
- "node_modules/@vitest/mocker/node_modules/estree-walker": {
- "version": "3.0.3",
- "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
- "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@types/estree": "^1.0.0"
- }
- },
"node_modules/@vitest/pretty-format": {
- "version": "4.1.11",
- "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz",
- "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==",
+ "version": "4.1.12",
+ "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.12.tgz",
+ "integrity": "sha512-XW9O8+Mxx4VpCtkYdMJLZQQwsVryP9wk7cPKDxtXFfwSak6yRh5+2fep/Y0XF/ElBvJHq8bcYdi6aeuai0opZA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2842,13 +2832,13 @@
}
},
"node_modules/@vitest/runner": {
- "version": "4.1.11",
- "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz",
- "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==",
+ "version": "4.1.12",
+ "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.12.tgz",
+ "integrity": "sha512-NKlVKCr5QnCe8jijdMnmkpHHU+WTqiLFR6G0pHji9uRjXK9xJl1k1nTw6F3pOOj2PDcsDt2GKQMI7wIsaT491w==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@vitest/utils": "4.1.11",
+ "@vitest/utils": "4.1.12",
"pathe": "^2.0.3"
},
"funding": {
@@ -2856,14 +2846,14 @@
}
},
"node_modules/@vitest/snapshot": {
- "version": "4.1.11",
- "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz",
- "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==",
+ "version": "4.1.12",
+ "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.12.tgz",
+ "integrity": "sha512-ko1y1Qgb/slzQ9mr+b89Cuqud8WG1TKtsPD8r7ZUBdvVOGt85Af2HwCqHujrhwJq95hs/9RM7fcAPg57o1vlzw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@vitest/pretty-format": "4.1.11",
- "@vitest/utils": "4.1.11",
+ "@vitest/pretty-format": "4.1.12",
+ "@vitest/utils": "4.1.12",
"magic-string": "^0.30.21",
"pathe": "^2.0.3"
},
@@ -2872,9 +2862,9 @@
}
},
"node_modules/@vitest/spy": {
- "version": "4.1.11",
- "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz",
- "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==",
+ "version": "4.1.12",
+ "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.12.tgz",
+ "integrity": "sha512-RGq9x4JNGNTZHEJxvN0oSiJjeSCExm0n2gZKKJZHY9cX5AVA6M7VJILBT3ZJ/tJ/0J9uWT7fETV9/btoi//vSg==",
"dev": true,
"license": "MIT",
"funding": {
@@ -2882,13 +2872,13 @@
}
},
"node_modules/@vitest/utils": {
- "version": "4.1.11",
- "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz",
- "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==",
+ "version": "4.1.12",
+ "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.12.tgz",
+ "integrity": "sha512-Q3awXrwOfEV+qc3WmO5ONyE0yO7Q1gQ9/oyHxGLZ/pl7N3NO/ZztqQwKLuwMkV+CA1JYSIeO3gkHnRDIo4AVaA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@vitest/pretty-format": "4.1.11",
+ "@vitest/pretty-format": "4.1.12",
"convert-source-map": "^2.0.0",
"tinyrainbow": "^3.1.0"
},
@@ -3844,6 +3834,16 @@
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
"license": "MIT"
},
+ "node_modules/estree-walker": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
+ "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "^1.0.0"
+ }
+ },
"node_modules/eventemitter3": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
@@ -5905,19 +5905,19 @@
}
},
"node_modules/vitest": {
- "version": "4.1.11",
- "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz",
- "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==",
+ "version": "4.1.12",
+ "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.12.tgz",
+ "integrity": "sha512-5eWdz2yb3ajxWF3C8C4TjkXl7FGm3n2E/VlazALrQHzK7nYMSGFnCZto0bbPNl77AwkOZVLk0YnBAejUu0NiWA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@vitest/expect": "4.1.11",
- "@vitest/mocker": "4.1.11",
- "@vitest/pretty-format": "4.1.11",
- "@vitest/runner": "4.1.11",
- "@vitest/snapshot": "4.1.11",
- "@vitest/spy": "4.1.11",
- "@vitest/utils": "4.1.11",
+ "@vitest/expect": "4.1.12",
+ "@vitest/mocker": "4.1.12",
+ "@vitest/pretty-format": "4.1.12",
+ "@vitest/runner": "4.1.12",
+ "@vitest/snapshot": "4.1.12",
+ "@vitest/spy": "4.1.12",
+ "@vitest/utils": "4.1.12",
"es-module-lexer": "^2.0.0",
"expect-type": "^1.3.0",
"magic-string": "^0.30.21",
@@ -5945,12 +5945,12 @@
"@edge-runtime/vm": "*",
"@opentelemetry/api": "^1.9.0",
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
- "@vitest/browser-playwright": "4.1.11",
- "@vitest/browser-preview": "4.1.11",
- "@vitest/browser-webdriverio": "4.1.11",
- "@vitest/coverage-istanbul": "4.1.11",
- "@vitest/coverage-v8": "4.1.11",
- "@vitest/ui": "4.1.11",
+ "@vitest/browser-playwright": "4.1.12",
+ "@vitest/browser-preview": "4.1.12",
+ "@vitest/browser-webdriverio": "^4.1.11",
+ "@vitest/coverage-istanbul": "4.1.12",
+ "@vitest/coverage-v8": "4.1.12",
+ "@vitest/ui": "4.1.12",
"happy-dom": "*",
"jsdom": "*",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
diff --git a/package.json b/package.json
index 04f92c7..ca46c73 100644
--- a/package.json
+++ b/package.json
@@ -36,7 +36,7 @@
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"typescript": "^5.9.3",
- "vitest": "^4.1.11",
+ "vitest": "^4.1.12",
"wrangler": "^4.141.0"
},
"overrides": {
diff --git a/src/components/editor/ColorPicker.tsx b/src/components/editor/ColorPicker.tsx
index 413e82a..984618b 100644
--- a/src/components/editor/ColorPicker.tsx
+++ b/src/components/editor/ColorPicker.tsx
@@ -1,4 +1,5 @@
-import { useId } from 'react';
+import { useId, useRef } from 'react';
+import { normalizeHexColor } from '@/templates/utils';
interface ColorPickerProps {
label: string;
@@ -10,7 +11,9 @@ export function ColorPicker({ label, value, onChange }: ColorPickerProps) {
const id = useId();
const colorId = `${id}-color`;
const hexId = `${id}-hex`;
- const displayValue = value.startsWith('#') ? value : `#${value}`;
+ const displayValue = value && !value.startsWith('#') ? `#${value}` : value;
+ const lastValid = useRef('#000000');
+ lastValid.current = normalizeHexColor(value, lastValid.current);
return (
diff --git a/src/pages/api/og.ts b/src/pages/api/og.ts
index 58f8f23..05cae2d 100644
--- a/src/pages/api/og.ts
+++ b/src/pages/api/og.ts
@@ -13,9 +13,12 @@ export const GET: APIRoute = async ({ request, locals }) => {
const url = new URL(request.url);
// The same URL always renders the same image, so repeats come from the edge
- // cache instead of the renderer. The cache only exists on the custom domain
- // (not in `astro dev` or on workers.dev).
- const cache = (globalThis.caches as (CacheStorage & { default?: Cache }) | undefined)?.default;
+ // cache instead of the renderer. The Cloudflare adapter may expose a
+ // persistent `caches.default` during `astro dev`, so disable it there to
+ // avoid serving stale images after template changes.
+ const cache = import.meta.env.DEV
+ ? undefined
+ : (globalThis.caches as (CacheStorage & { default?: Cache }) | undefined)?.default;
const cacheKey = new Request(url.toString());
const cached = await cache?.match(cacheKey);
if (cached) return cached;
@@ -71,8 +74,9 @@ export const GET: APIRoute = async ({ request, locals }) => {
try {
// Merge template defaults with provided params. Schema params override
- // defaults when non-empty; template-specific query params override too.
- const mergedParams = mergeOgParams(params, template.defaults, url.searchParams);
+ // defaults when non-empty; template-specific query params override too,
+ // and an optional text field sent empty stays empty.
+ const mergedParams = mergeOgParams(params, template.defaults, url.searchParams, template.fields);
const normalizedParams = normalizeColorFields(template, mergedParams);
const element = template.render(normalizedParams);
diff --git a/src/pages/api/templates/[id]/thumbnail.png.ts b/src/pages/api/templates/[id]/thumbnail.png.ts
index 5405fd7..08c88d0 100644
--- a/src/pages/api/templates/[id]/thumbnail.png.ts
+++ b/src/pages/api/templates/[id]/thumbnail.png.ts
@@ -9,9 +9,12 @@ export const GET: APIRoute = async ({ params, url, locals }) => {
const { id } = params;
// Thumbnails only change on deploy, so repeats come from the edge cache
- // instead of the renderer. The cache only exists on the custom domain
- // (not in `astro dev` or on workers.dev).
- const cache = (globalThis.caches as (CacheStorage & { default?: Cache }) | undefined)?.default;
+ // instead of the renderer. The Cloudflare adapter may expose a persistent
+ // `caches.default` during `astro dev`, so disable it there to avoid serving
+ // stale thumbnails after template changes.
+ const cache = import.meta.env.DEV
+ ? undefined
+ : (globalThis.caches as (CacheStorage & { default?: Cache }) | undefined)?.default;
const cacheKey = new Request(url.toString());
const cached = await cache?.match(cacheKey);
if (cached) return cached;
diff --git a/src/styles/editor.css b/src/styles/editor.css
index 06f8c7b..7948102 100644
--- a/src/styles/editor.css
+++ b/src/styles/editor.css
@@ -273,7 +273,7 @@
display: flex;
align-items: center;
justify-content: center;
- flex: 1;
+ flex: 1 0 auto;
min-height: 300px;
position: relative;
background-image: radial-gradient(circle, var(--border-muted) 1px, transparent 1px);
diff --git a/src/templates/blog/benchmark.ts b/src/templates/blog/benchmark.ts
new file mode 100644
index 0000000..0332350
--- /dev/null
+++ b/src/templates/blog/benchmark.ts
@@ -0,0 +1,203 @@
+import type { SatoriElement, TemplateDefinition } from '../types';
+import { autoFontSize, commonFields, isLightColor, mixColors, truncate } from '../utils';
+
+type BenchmarkRow = { name: string; value: number };
+
+const template: TemplateDefinition = {
+ id: 'blog-benchmark',
+ name: 'Benchmark',
+ category: 'blog',
+ description: 'Compare performance or size measurements with scaled horizontal bars',
+ tags: ['blog', 'benchmark', 'performance', 'comparison', 'stats'],
+ fields: [
+ commonFields.title,
+ commonFields.description,
+ { key: 'name1', label: 'Name 1', type: 'text', defaultValue: 'Bun', placeholder: 'Bun', group: 'Content' },
+ { key: 'value1', label: 'Value 1', type: 'number', defaultValue: 112000, placeholder: '112000', min: 0, max: 1_000_000_000, group: 'Content' },
+ { key: 'name2', label: 'Name 2', type: 'text', defaultValue: 'Deno', placeholder: 'Deno', group: 'Content' },
+ { key: 'value2', label: 'Value 2', type: 'number', defaultValue: 71000, placeholder: '71000', min: 0, max: 1_000_000_000, group: 'Content' },
+ { key: 'name3', label: 'Name 3', type: 'text', defaultValue: 'Node', placeholder: 'Node', group: 'Content' },
+ { key: 'value3', label: 'Value 3', type: 'number', defaultValue: 48000, placeholder: '48000', min: 0, max: 1_000_000_000, group: 'Content' },
+ { key: 'name4', label: 'Name 4', type: 'text', defaultValue: '', placeholder: 'Optional comparison', group: 'Content' },
+ { key: 'value4', label: 'Value 4', type: 'number', defaultValue: '', placeholder: 'Optional value', min: 0, max: 1_000_000_000, group: 'Content' },
+ { key: 'unit', label: 'Unit', type: 'text', defaultValue: 'req/s', placeholder: 'req/s', group: 'Content' },
+ commonFields.siteName,
+ commonFields.bgColor,
+ commonFields.accentColor,
+ commonFields.textColor,
+ ],
+ defaults: {
+ title: 'Bun vs Deno vs Node: HTTP throughput',
+ description: 'Requests per second, higher is better',
+ name1: 'Bun', value1: 112000,
+ name2: 'Deno', value2: 71000,
+ name3: 'Node', value3: 48000,
+ name4: '', value4: '',
+ unit: 'req/s',
+ siteName: 'codercops.com',
+ bgColor: '#0a0a0a',
+ accentColor: '#E07A5F',
+ textColor: '#FFFFFF',
+ },
+ render: (params) => {
+ const rawTitle = String(params.title ?? template.defaults.title).replace(/\s+/g, ' ').trim();
+ const title = truncate(rawTitle || String(template.defaults.title), 120);
+ const titleSize = autoFontSize(title, [
+ { maxLen: 36, size: 46 },
+ { maxLen: 60, size: 40 },
+ { maxLen: 90, size: 34 },
+ { maxLen: 120, size: 28 },
+ ]);
+ const rawDescription = String(params.description ?? '').replace(/\s+/g, ' ').trim();
+ const description = truncate(rawDescription, 120);
+ const descriptionSize = autoFontSize(description, [
+ { maxLen: 40, size: 20 },
+ { maxLen: 75, size: 18 },
+ { maxLen: 120, size: 15 },
+ ]);
+ const rows: BenchmarkRow[] = [1, 2, 3, 4].flatMap((index) => {
+ const name = String(params[`name${index}`] ?? '').replace(/\s+/g, ' ').trim();
+ const rawValue = params[`value${index}`];
+ if (!name || rawValue === undefined || rawValue === null || String(rawValue).trim() === '') return [];
+
+ const value = Number(rawValue);
+ return Number.isFinite(value) && value >= 0 ? [{ name: truncate(name, 80), value }] : [];
+ });
+ const maxValue = rows.reduce((max, row) => Math.max(max, row.value), 0);
+ const largestIndex = rows.findIndex(({ value }) => value === maxValue);
+ const unit = String(params.unit ?? 'req/s').replace(/\s+/g, ' ').trim();
+ const valueLabels = rows.map(({ value }) => {
+ const formattedValue = new Intl.NumberFormat('en-US', { maximumFractionDigits: 3 }).format(value);
+ return `${formattedValue}${unit ? ` ${unit}` : ''}`;
+ });
+ const longestValueLabel = valueLabels.reduce((longest, label) =>
+ label.length > longest.length ? label : longest, '');
+ const displayLongestValueLabel = truncate(longestValueLabel, 36);
+ const valueSize = autoFontSize(displayLongestValueLabel, [
+ { maxLen: 18, size: 20 },
+ { maxLen: 26, size: 17 },
+ { maxLen: 36, size: 14 },
+ ]);
+ const valueColumnWidth = Math.min(360, Math.max(220, Math.ceil(displayLongestValueLabel.length * valueSize * 0.9 + 12)));
+ const siteName = String(params.siteName ?? '').replace(/\s+/g, ' ').trim();
+ const bgColor = String(params.bgColor || '#0a0a0a');
+ const accentColor = String(params.accentColor || '#E07A5F');
+ const textColor = String(params.textColor || '#FFFFFF');
+ const lightBg = isLightColor(bgColor);
+ const accentInk = lightBg ? mixColors(accentColor, '#000000', 0.45) : accentColor;
+ const foregroundColor = lightBg && isLightColor(textColor) ? '#111111' : textColor;
+ const mutedText = mixColors(bgColor, foregroundColor, lightBg ? 0.68 : 0.62);
+ const trackColor = mixColors(bgColor, foregroundColor, lightBg ? 0.13 : 0.2);
+
+ const rowElements: SatoriElement[] = rows.map(({ name, value }, index) => {
+ const nameSize = autoFontSize(name, [
+ { maxLen: 16, size: 22 },
+ { maxLen: 28, size: 19 },
+ { maxLen: 48, size: 16 },
+ { maxLen: 80, size: 14 },
+ ]);
+ const fillColor = index === largestIndex ? accentInk : mixColors(bgColor, foregroundColor, lightBg ? 0.3 : 0.36);
+ const width = maxValue > 0 ? (value / maxValue) * 100 : 0;
+ const displayValue = truncate(valueLabels[index], 36);
+
+ return {
+ type: 'div',
+ props: {
+ style: { display: 'flex', alignItems: 'center', gap: '22px', width: '100%', minHeight: '68px' },
+ children: [
+ {
+ type: 'span',
+ props: {
+ style: {
+ display: 'flex', flexShrink: 0, width: '190px', minWidth: 0,
+ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
+ fontSize: `${nameSize}px`, fontWeight: 600, color: foregroundColor,
+ },
+ children: name,
+ },
+ },
+ {
+ type: 'div',
+ props: {
+ style: { display: 'flex', flex: 1, minWidth: 0, height: '16px', overflow: 'hidden', borderRadius: '999px', backgroundColor: trackColor },
+ children: [
+ {
+ type: 'div',
+ props: {
+ style: { display: 'flex', width: `${width}%`, height: '100%', borderRadius: '999px', backgroundColor: fillColor },
+ children: [],
+ },
+ },
+ ],
+ },
+ },
+ {
+ type: 'span',
+ props: {
+ style: {
+ display: 'flex', justifyContent: 'flex-end', flexShrink: 0, width: `${valueColumnWidth}px`, minWidth: 0,
+ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
+ fontSize: `${valueSize}px`, fontWeight: 700, color: foregroundColor,
+ },
+ children: displayValue,
+ },
+ },
+ ],
+ },
+ };
+ });
+
+ return {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex', flexDirection: 'column', width: '1200px', height: '630px',
+ boxSizing: 'border-box', padding: '52px 64px 42px', backgroundColor: bgColor,
+ fontFamily: 'Inter, sans-serif',
+ },
+ children: [
+ {
+ type: 'div',
+ props: {
+ style: { display: 'flex', alignSelf: 'flex-start', padding: '8px 16px', marginBottom: '18px', borderRadius: '999px', backgroundColor: mixColors(bgColor, accentInk, lightBg ? 0.08 : 0.17), border: `1px solid ${accentInk}66` },
+ children: {
+ type: 'span',
+ props: { style: { display: 'flex', color: accentInk, fontSize: '14px', fontWeight: 700, letterSpacing: '0.12em', textTransform: 'uppercase' }, children: 'Benchmark' },
+ },
+ },
+ },
+ {
+ type: 'h1',
+ props: {
+ style: { display: 'flex', width: '100%', margin: 0, color: foregroundColor, fontSize: `${titleSize}px`, fontWeight: 700, lineHeight: 1.18, letterSpacing: '-0.02em', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' },
+ children: title,
+ },
+ },
+ ...(description ? [{
+ type: 'span',
+ props: {
+ style: { display: 'flex', marginTop: '10px', color: mutedText, fontSize: `${descriptionSize}px`, lineHeight: 1.3, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' },
+ children: description,
+ },
+ }] : []),
+ ...(rowElements.length ? [{
+ type: 'div',
+ props: {
+ style: { display: 'flex', flexDirection: 'column', width: '100%', gap: '14px', marginTop: 'auto', marginBottom: 'auto' },
+ children: rowElements,
+ },
+ }] : []),
+ ...(siteName ? [{
+ type: 'div',
+ props: {
+ style: { display: 'flex', justifyContent: 'flex-end', marginTop: 'auto', paddingTop: '14px', borderTop: `1px solid ${trackColor}` },
+ children: { type: 'span', props: { style: { display: 'flex', fontSize: '16px', fontWeight: 600, color: mutedText }, children: truncate(siteName, 48) } },
+ },
+ }] : []),
+ ],
+ },
+ };
+ },
+};
+
+export default template;
diff --git a/src/templates/blog/index.ts b/src/templates/blog/index.ts
index d0f1ef7..98b0017 100644
--- a/src/templates/blog/index.ts
+++ b/src/templates/blog/index.ts
@@ -12,3 +12,6 @@ export { default as blogGeometric } from './geometric';
export { default as blogRetroTerminal } from './retro-terminal';
export { default as blogSeriesPart } from './series-part';
export { default as blogSummaryCard } from './summary-card';
+export { default as blogTableOfContents } from './table-of-contents';
+export { default as blogBenchmark } from './benchmark';
+export { default as blogUpdatedPost } from './updated-post';
diff --git a/src/templates/blog/table-of-contents.ts b/src/templates/blog/table-of-contents.ts
new file mode 100644
index 0000000..92d387b
--- /dev/null
+++ b/src/templates/blog/table-of-contents.ts
@@ -0,0 +1,349 @@
+import type { SatoriElement, TemplateDefinition } from '../types';
+import { truncate, autoFontSize, isLightColor, mixColors, commonFields } from '../utils';
+
+const template: TemplateDefinition = {
+ id: 'blog-table-of-contents',
+ name: 'Table of Contents',
+ category: 'blog',
+ description: 'A table of contents card showing post sections with reading time and site branding',
+ tags: ['blog', 'table-of-contents', 'toc', 'outline', 'sections'],
+ fields: [
+ commonFields.title,
+ {
+ key: 'section1',
+ label: 'Section 1',
+ type: 'text',
+ defaultValue: 'Why micro-frontends fail at scale',
+ placeholder: 'First section heading...',
+ group: 'Content',
+ },
+ {
+ key: 'section2',
+ label: 'Section 2',
+ type: 'text',
+ defaultValue: 'The hidden cost of runtime integration',
+ placeholder: 'Second section heading...',
+ group: 'Content',
+ },
+ {
+ key: 'section3',
+ label: 'Section 3',
+ type: 'text',
+ defaultValue: 'Module federation vs build-time composition',
+ placeholder: 'Third section heading...',
+ group: 'Content',
+ },
+ {
+ key: 'section4',
+ label: 'Section 4',
+ type: 'text',
+ defaultValue: 'Migration playbook step-by-step',
+ placeholder: 'Fourth section heading...',
+ group: 'Content',
+ },
+ {
+ key: 'section5',
+ label: 'Section 5',
+ type: 'text',
+ defaultValue: 'Benchmarks and team impact',
+ placeholder: 'Fifth section heading (optional)...',
+ group: 'Content',
+ },
+ {
+ key: 'readTime',
+ label: 'Read Time',
+ type: 'text',
+ defaultValue: '12 min read',
+ placeholder: '12 min read',
+ group: 'Content',
+ },
+ commonFields.siteName,
+ commonFields.bgColor,
+ commonFields.accentColor,
+ commonFields.textColor,
+ ],
+ defaults: {
+ title: 'Architecture Patterns for Large-Scale Frontend Applications',
+ section1: 'Why micro-frontends fail at scale',
+ section2: 'The hidden cost of runtime integration',
+ section3: 'Module federation vs build-time composition',
+ section4: 'Migration playbook step-by-step',
+ section5: 'Benchmarks and team impact',
+ readTime: '12 min read',
+ siteName: 'codercops.com',
+ bgColor: '#0a0a0a',
+ accentColor: '#E07A5F',
+ textColor: '#FFFFFF',
+ },
+ render: (params) => {
+ const rawTitle = params.title !== undefined && params.title !== null
+ ? String(params.title).trim()
+ : 'Architecture Patterns for Large-Scale Frontend Applications';
+ const fallbackTitle = 'Architecture Patterns for Large-Scale Frontend Applications';
+ const titleText = rawTitle || fallbackTitle;
+ const title = truncate(titleText, 90);
+
+ const titleSize = autoFontSize(title, [
+ { maxLen: 30, size: 44 },
+ { maxLen: 50, size: 38 },
+ { maxLen: 70, size: 32 },
+ { maxLen: 90, size: 28 },
+ ]);
+
+ const rawSections = [
+ params.section1,
+ params.section2,
+ params.section3,
+ params.section4,
+ params.section5,
+ ].map((s) => (s !== undefined && s !== null ? String(s).trim() : ''));
+
+ const sections = rawSections
+ .filter((s) => s.length > 0)
+ .map((s) => truncate(s, 70));
+
+ const rawReadTime = params.readTime !== undefined
+ ? String(params.readTime).trim()
+ : '12 min read';
+ const readTime = rawReadTime ? truncate(rawReadTime, 24) : '';
+
+ const rawSiteName = params.siteName !== undefined
+ ? String(params.siteName).trim()
+ : 'codercops.com';
+ const siteName = rawSiteName ? truncate(rawSiteName, 36) : '';
+
+ const bgColor = String(params.bgColor || '#0a0a0a');
+ const accentColor = String(params.accentColor || '#E07A5F');
+ const textColor = String(params.textColor || '#FFFFFF');
+
+ const lightBg = isLightColor(bgColor);
+ const accentInk = isLightColor(accentColor) === lightBg
+ ? mixColors(accentColor, textColor, 0.55)
+ : accentColor;
+
+ const cardBg = mixColors(bgColor, textColor, lightBg ? 0.04 : 0.07);
+ const cardBorder = mixColors(bgColor, textColor, lightBg ? 0.12 : 0.18);
+ const numberCircleBg = mixColors(bgColor, accentInk, lightBg ? 0.1 : 0.18);
+ const numberCircleBorder = mixColors(bgColor, accentInk, lightBg ? 0.25 : 0.35);
+ const secondaryText = mixColors(bgColor, textColor, 0.7);
+
+ const sectionRows: SatoriElement[] = sections.map((section, index) => ({
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ alignItems: 'center',
+ padding: '8px 18px',
+ borderRadius: '12px',
+ backgroundColor: cardBg,
+ border: `1px solid ${cardBorder}`,
+ marginBottom: index < sections.length - 1 ? '10px' : '0px',
+ },
+ children: [
+ {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ width: '32px',
+ height: '32px',
+ borderRadius: '50%',
+ backgroundColor: numberCircleBg,
+ border: `1px solid ${numberCircleBorder}`,
+ marginRight: '16px',
+ flexShrink: 0,
+ },
+ children: {
+ type: 'span',
+ props: {
+ style: {
+ fontSize: '16px',
+ fontWeight: 700,
+ color: accentInk,
+ },
+ children: String(index + 1),
+ },
+ },
+ },
+ },
+ {
+ type: 'span',
+ props: {
+ style: {
+ display: 'flex',
+ fontSize: '20px',
+ fontWeight: 500,
+ color: textColor,
+ lineHeight: 1.3,
+ whiteSpace: 'nowrap',
+ overflow: 'hidden',
+ textOverflow: 'ellipsis',
+ },
+ children: section,
+ },
+ },
+ ],
+ },
+ }));
+
+ const hasFooter = Boolean(readTime || siteName);
+
+ return {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ flexDirection: 'column',
+ width: '1200px',
+ height: '630px',
+ backgroundColor: bgColor,
+ fontFamily: 'Inter, sans-serif',
+ padding: '46px 64px',
+ boxSizing: 'border-box',
+ },
+ children: [
+ // Title
+ {
+ type: 'h1',
+ props: {
+ style: {
+ display: 'flex',
+ fontSize: `${titleSize}px`,
+ fontWeight: 700,
+ color: textColor,
+ lineHeight: 1.2,
+ margin: 0,
+ marginBottom: '14px',
+ letterSpacing: '-0.02em',
+ },
+ children: title,
+ },
+ },
+ // Accent-colored "in this post" label
+ {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ alignItems: 'center',
+ padding: '5px 14px',
+ borderRadius: '999px',
+ backgroundColor: mixColors(bgColor, accentInk, lightBg ? 0.08 : 0.16),
+ border: `1px solid ${accentInk}55`,
+ marginBottom: '16px',
+ alignSelf: 'flex-start',
+ },
+ children: {
+ type: 'span',
+ props: {
+ style: {
+ fontSize: '13px',
+ fontWeight: 700,
+ color: accentInk,
+ letterSpacing: '0.08em',
+ textTransform: 'uppercase',
+ },
+ children: 'In this post',
+ },
+ },
+ },
+ },
+ // Sequentially numbered list of section headings
+ ...(sectionRows.length > 0
+ ? [
+ {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ flexDirection: 'column',
+ width: '100%',
+ },
+ children: sectionRows,
+ },
+ },
+ ]
+ : []),
+ // Bottom footer containing reading time and site name
+ ...(hasFooter
+ ? [
+ {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ marginTop: 'auto',
+ paddingTop: '16px',
+ borderTop: `1px solid ${textColor}1A`,
+ },
+ children: [
+ ...(readTime
+ ? [
+ {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ alignItems: 'center',
+ padding: '6px 14px',
+ borderRadius: '999px',
+ backgroundColor: `${textColor}0F`,
+ },
+ children: {
+ type: 'span',
+ props: {
+ style: {
+ fontSize: '15px',
+ fontWeight: 600,
+ color: secondaryText,
+ },
+ children: readTime,
+ },
+ },
+ },
+ },
+ ]
+ : []),
+ {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ flex: 1,
+ },
+ children: [],
+ },
+ },
+ ...(siteName
+ ? [
+ {
+ type: 'span',
+ props: {
+ style: {
+ display: 'flex',
+ fontSize: '16px',
+ fontWeight: 600,
+ color: secondaryText,
+ letterSpacing: '0.04em',
+ },
+ children: siteName,
+ },
+ },
+ ]
+ : []),
+ ],
+ },
+ },
+ ]
+ : []),
+ ],
+ },
+ };
+ },
+};
+
+export default template;
diff --git a/src/templates/blog/updated-post.ts b/src/templates/blog/updated-post.ts
new file mode 100644
index 0000000..cde3704
--- /dev/null
+++ b/src/templates/blog/updated-post.ts
@@ -0,0 +1,331 @@
+import type { SatoriElement, TemplateDefinition } from '../types';
+import { truncate, autoFontSize, isLightColor, mixColors, contrastText, commonFields } from '../utils';
+
+const DEFAULT_TITLE = 'A practical guide to React Server Components';
+const DEFAULT_PUBLISHED = 'First published Mar 3, 2024';
+const DEFAULT_UPDATED = 'Updated Oct 27, 2026';
+
+// Collapse runs of whitespace so a field padded with spaces reads as empty.
+const cleanText = (value: unknown, maxLength: number) =>
+ truncate(String(value ?? '').replace(/\s+/g, ' ').trim(), maxLength);
+
+const template: TemplateDefinition = {
+ id: 'blog-updated-post',
+ name: 'Updated Post',
+ category: 'blog',
+ description: 'A refreshed post card with an Updated label, the publish and update dates, and a short list of what changed',
+ tags: ['blog', 'updated', 'changelog', 'post', 'revision'],
+ fields: [
+ commonFields.title,
+ { key: 'published', label: 'First Published', type: 'text', defaultValue: DEFAULT_PUBLISHED, placeholder: 'Optional, e.g. First published Mar 3, 2024', group: 'Content' },
+ { key: 'updated', label: 'Updated', type: 'text', defaultValue: DEFAULT_UPDATED, placeholder: 'Optional, e.g. Updated Oct 27, 2026', group: 'Content' },
+ { key: 'change1', label: 'Change 1', type: 'text', defaultValue: 'Covers React 19', placeholder: 'Optional, what changed', group: 'Content' },
+ { key: 'change2', label: 'Change 2', type: 'text', defaultValue: 'New section on server actions', placeholder: 'Optional, what changed', group: 'Content' },
+ { key: 'change3', label: 'Change 3', type: 'text', defaultValue: 'Fixed the broken examples', placeholder: 'Optional, what changed', group: 'Content' },
+ commonFields.siteName,
+ commonFields.bgColor,
+ commonFields.accentColor,
+ commonFields.textColor,
+ ],
+ defaults: {
+ title: DEFAULT_TITLE,
+ published: DEFAULT_PUBLISHED,
+ updated: DEFAULT_UPDATED,
+ change1: 'Covers React 19',
+ change2: 'New section on server actions',
+ change3: 'Fixed the broken examples',
+ siteName: 'codercops.com',
+ bgColor: '#0b0f1a',
+ accentColor: '#7dd3a0',
+ textColor: '#f8fafc',
+ },
+ render: (params) => {
+ const title = cleanText(params.title, 84) || DEFAULT_TITLE;
+ // A blank or omitted date hides on its own, so the line never renders a stray separator.
+ const published = cleanText(params.published, 40);
+ const updated = cleanText(params.updated, 40);
+ const siteName = cleanText(params.siteName, 36);
+
+ const changes = [params.change1, params.change2, params.change3]
+ .map((change) => cleanText(change, 64))
+ .filter((change) => change.length > 0);
+
+ const bgColor = String(params.bgColor || '#0b0f1a');
+ const accentColor = String(params.accentColor || '#7dd3a0');
+ const textColor = String(params.textColor || '#f8fafc');
+
+ const titleSize = autoFontSize(title, [
+ { maxLen: 24, size: 72 },
+ { maxLen: 36, size: 62 },
+ { maxLen: 48, size: 54 },
+ { maxLen: 60, size: 46 },
+ { maxLen: 72, size: 40 },
+ { maxLen: 84, size: 34 },
+ ]);
+
+ // Both dates share one line, so size them together for the room they share.
+ const datesLength = published.length + updated.length + (published && updated ? 3 : 0);
+ // Both dates are capped at 40 characters, so 83 is the longest line they can make.
+ const dateSize = autoFontSize('x'.repeat(datesLength), [
+ { maxLen: 54, size: 22 },
+ { maxLen: 68, size: 20 },
+ { maxLen: Number.MAX_SAFE_INTEGER, size: 18 },
+ ]);
+
+ // Neutral colors are mixed from the background and text colors, so the card reads the
+ // same on dark and on light backgrounds.
+ const lightBg = isLightColor(bgColor);
+ const secondaryText = mixColors(bgColor, textColor, 0.62);
+ // An accent as light as the background (or as dark as it) is hard to read as text, so
+ // pull it toward the text color. Otherwise it reads as it is.
+ const accentText = isLightColor(accentColor) === lightBg
+ ? mixColors(accentColor, textColor, 0.55)
+ : accentColor;
+ const pillText = contrastText(accentColor);
+
+ const dates: SatoriElement[] = [
+ ...(published
+ ? [
+ {
+ type: 'span',
+ props: {
+ style: {
+ display: 'flex',
+ minWidth: 0,
+ fontSize: `${dateSize}px`,
+ fontWeight: 500,
+ color: secondaryText,
+ whiteSpace: 'nowrap',
+ overflow: 'hidden',
+ textOverflow: 'ellipsis',
+ },
+ children: published,
+ },
+ },
+ ]
+ : []),
+ ...(published && updated
+ ? [
+ {
+ type: 'div',
+ props: {
+ style: { display: 'flex', margin: '0 14px', flexShrink: 0 },
+ children: {
+ type: 'span',
+ props: {
+ style: { fontSize: `${dateSize}px`, fontWeight: 500, color: secondaryText },
+ children: '·',
+ },
+ },
+ },
+ },
+ ]
+ : []),
+ ...(updated
+ ? [
+ {
+ type: 'span',
+ props: {
+ style: {
+ display: 'flex',
+ minWidth: 0,
+ fontSize: `${dateSize}px`,
+ fontWeight: 600,
+ color: accentText,
+ whiteSpace: 'nowrap',
+ overflow: 'hidden',
+ textOverflow: 'ellipsis',
+ },
+ children: updated,
+ },
+ },
+ ]
+ : []),
+ ];
+
+ const changeRows: SatoriElement[] = changes.map((change, index) => ({
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ alignItems: 'center',
+ marginBottom: index < changes.length - 1 ? '12px' : '0px',
+ },
+ children: [
+ {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ width: '10px',
+ height: '10px',
+ borderRadius: '50%',
+ backgroundColor: accentText,
+ marginRight: '18px',
+ flexShrink: 0,
+ },
+ children: [],
+ },
+ },
+ {
+ type: 'span',
+ props: {
+ style: {
+ display: 'flex',
+ flex: 1,
+ minWidth: 0,
+ fontSize: '24px',
+ fontWeight: 500,
+ color: textColor,
+ lineHeight: 1.3,
+ whiteSpace: 'nowrap',
+ overflow: 'hidden',
+ textOverflow: 'ellipsis',
+ },
+ children: change,
+ },
+ },
+ ],
+ },
+ }));
+
+ // Title, dates and changes share the space between the pill and the footer, so a card
+ // with only a few lines stays centred instead of leaving a gap at the bottom.
+ const body: SatoriElement = {
+ type: 'div',
+ props: {
+ style: { display: 'flex', flexDirection: 'column', flex: 1, justifyContent: 'center' },
+ children: [
+ {
+ type: 'h1',
+ props: {
+ style: {
+ display: 'flex',
+ fontSize: `${titleSize}px`,
+ fontWeight: 700,
+ color: textColor,
+ lineHeight: 1.12,
+ letterSpacing: '-0.03em',
+ overflowWrap: 'anywhere',
+ // Satori does not break a single unbroken word on overflowWrap alone, so a
+ // title with no spaces at all is broken by character instead.
+ ...(title.includes(' ') ? {} : { wordBreak: 'break-all' as const }),
+ margin: 0,
+ marginBottom: '16px',
+ },
+ children: title,
+ },
+ },
+ ...(dates.length > 0
+ ? [{
+ type: 'div',
+ props: {
+ // No trailing gap when there is no change list underneath.
+ style: {
+ display: 'flex',
+ alignItems: 'center',
+ width: '100%',
+ marginBottom: changes.length > 0 ? '32px' : '0px',
+ },
+ children: dates,
+ },
+ }]
+ : []),
+ ...(changes.length > 0
+ ? [
+ {
+ type: 'div',
+ props: {
+ style: { display: 'flex', flexDirection: 'column' },
+ children: [
+ {
+ type: 'span',
+ props: {
+ style: {
+ fontSize: '18px',
+ fontWeight: 700,
+ color: accentText,
+ letterSpacing: '0.14em',
+ textTransform: 'uppercase',
+ marginBottom: '16px',
+ },
+ children: 'What changed',
+ },
+ },
+ ...changeRows,
+ ],
+ },
+ },
+ ]
+ : []),
+ ],
+ },
+ };
+
+ return {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ flexDirection: 'column',
+ width: '1200px',
+ height: '630px',
+ backgroundColor: bgColor,
+ fontFamily: 'Inter, sans-serif',
+ padding: '60px 80px',
+ boxSizing: 'border-box',
+ },
+ children: [
+ // Updated pill
+ {
+ type: 'div',
+ props: {
+ style: { display: 'flex', marginBottom: '26px' },
+ children: {
+ type: 'div',
+ props: {
+ style: { display: 'flex', padding: '9px 24px', borderRadius: '999px', backgroundColor: accentColor },
+ children: {
+ type: 'span',
+ props: {
+ style: { fontSize: '20px', fontWeight: 700, color: pillText, letterSpacing: '0.12em', textTransform: 'uppercase' },
+ children: 'Updated',
+ },
+ },
+ },
+ },
+ },
+ },
+ body,
+ // Site name
+ ...(siteName
+ ? [
+ {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ // Keeps a floor between the last change line and the rule once the
+ // body above is centred in the leftover space.
+ marginTop: '32px',
+ paddingTop: '20px',
+ borderTop: `1px solid ${textColor}1A`,
+ },
+ children: {
+ type: 'span',
+ props: {
+ style: { fontSize: '17px', fontWeight: 600, color: secondaryText, letterSpacing: '0.04em' },
+ children: siteName,
+ },
+ },
+ },
+ },
+ ]
+ : []),
+ ],
+ },
+ };
+ },
+};
+
+export default template;
diff --git a/src/templates/developer/day-streak.ts b/src/templates/developer/day-streak.ts
new file mode 100644
index 0000000..9b04507
--- /dev/null
+++ b/src/templates/developer/day-streak.ts
@@ -0,0 +1,130 @@
+import type { SatoriElement, TemplateDefinition } from '../types';
+import { isLightColor, mixColors, truncate, commonFields } from '../utils';
+
+const DEFAULT_CURRENT_DAY = 42;
+const DEFAULT_TOTAL_DAYS = 100;
+
+const parseDayNumber = (value: unknown, fallback: number): number => {
+ if (value === undefined || value === null || value === '') return fallback;
+ const number = Number(value);
+ return Number.isFinite(number) ? Math.round(number) : fallback;
+};
+
+const cell = (backgroundColor: string, border: string): SatoriElement => ({
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ width: '18px',
+ height: '18px',
+ borderRadius: '5px',
+ backgroundColor,
+ border,
+ boxSizing: 'border-box',
+ },
+ children: '',
+ },
+});
+
+const template: TemplateDefinition = {
+ id: 'developer-day-streak',
+ name: 'Day Streak',
+ category: 'developer',
+ description: 'Progress card for sharing a 100-day challenge or learning streak',
+ tags: ['developer', 'streak', 'challenge', 'progress', '100 days'],
+ fields: [
+ commonFields.title,
+ commonFields.author,
+ { key: 'currentDay', label: 'Current Day', type: 'number', defaultValue: DEFAULT_CURRENT_DAY, min: 1, max: 100, group: 'Content' },
+ { key: 'totalDays', label: 'Total Days', type: 'number', defaultValue: DEFAULT_TOTAL_DAYS, min: 1, max: 100, group: 'Content' },
+ commonFields.bgColor,
+ commonFields.accentColor,
+ commonFields.textColor,
+ ],
+ defaults: {
+ title: '100 Days of AI Engineering',
+ author: '',
+ currentDay: DEFAULT_CURRENT_DAY,
+ totalDays: DEFAULT_TOTAL_DAYS,
+ bgColor: '#0a0a0a',
+ accentColor: '#E07A5F',
+ textColor: '#FFFFFF',
+ },
+ render: (params) => {
+ const title = truncate(String(params.title || '100 Days of AI Engineering'), 48);
+ const author = params.author ? truncate(String(params.author), 36) : '';
+ const bgColor = String(params.bgColor || '#0a0a0a');
+ const accentColor = String(params.accentColor || '#E07A5F');
+ const accentTextColor = isLightColor(bgColor) ? mixColors(accentColor, '#000000', 0.45) : accentColor;
+ const textColor = String(params.textColor || '#FFFFFF');
+ const totalDays = Math.min(100, Math.max(1, parseDayNumber(params.totalDays, DEFAULT_TOTAL_DAYS)));
+ const currentDay = Math.min(totalDays, Math.max(1, parseDayNumber(params.currentDay, DEFAULT_CURRENT_DAY)));
+
+ const rows = Array.from({ length: Math.ceil(totalDays / 10) }, (_, rowIndex) => ({
+ type: 'div',
+ props: {
+ style: { display: 'flex', flexDirection: 'row', gap: '7px' },
+ children: Array.from({ length: Math.min(10, totalDays - rowIndex * 10) }, (_, columnIndex) => {
+ const day = rowIndex * 10 + columnIndex + 1;
+ if (day > totalDays) return cell('transparent', '1px solid transparent');
+ const isCurrent = day === currentDay;
+ const isComplete = day <= currentDay;
+ return cell(
+ isComplete ? accentColor : `${accentColor}18`,
+ isCurrent ? `3px solid ${textColor}` : `1px solid ${accentColor}66`,
+ );
+ }),
+ },
+ }));
+
+ return {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ width: '1200px',
+ height: '630px',
+ padding: '64px 72px',
+ boxSizing: 'border-box',
+ backgroundColor: bgColor,
+ color: textColor,
+ fontFamily: 'Inter, sans-serif',
+ alignItems: 'center',
+ gap: '72px',
+ },
+ children: [
+ {
+ type: 'div',
+ props: {
+ style: { display: 'flex', flexDirection: 'column', flex: 1, justifyContent: 'center' },
+ children: [
+ { type: 'span', props: { style: { fontSize: '20px', color: accentTextColor, fontWeight: 700 }, children: 'THE CHALLENGE' } },
+ { type: 'h1', props: { style: { fontSize: title.length > 32 ? '38px' : '46px', lineHeight: 1.15, margin: '18px 0 34px', color: textColor }, children: title } },
+ {
+ type: 'div',
+ props: {
+ style: { display: 'flex', alignItems: 'baseline', gap: '14px' },
+ children: [
+ { type: 'span', props: { style: { fontSize: '88px', lineHeight: 1, fontWeight: 800, color: accentTextColor }, children: `Day ${currentDay}` } },
+ { type: 'span', props: { style: { fontSize: '24px', color: `${textColor}88` }, children: `of ${totalDays}` } },
+ ],
+ },
+ },
+ ...(author ? [{ type: 'span', props: { style: { marginTop: '26px', fontSize: '20px', color: `${textColor}aa` }, children: author } }] : []),
+ ],
+ },
+ },
+ {
+ type: 'div',
+ props: {
+ style: { display: 'flex', flexDirection: 'column', gap: '7px', justifyContent: 'center', alignItems: 'flex-start' },
+ children: rows,
+ },
+ },
+ ],
+ },
+ };
+ },
+};
+
+export default template;
diff --git a/src/templates/developer/index.ts b/src/templates/developer/index.ts
index e058458..7f6f421 100644
--- a/src/templates/developer/index.ts
+++ b/src/templates/developer/index.ts
@@ -8,3 +8,6 @@ export { default as developerResumeHeader } from './resume-header';
export { default as developerVscodeTheme } from './vscode-theme';
export { default as developerLinkTree } from './link-tree';
export { default as developerOpenToWork } from './open-to-work';
+export { default as developerDayStreak } from './day-streak';
+export { default as developerNow } from './now';
+export { default as developerYearReview } from './year-review';
diff --git a/src/templates/developer/now.ts b/src/templates/developer/now.ts
new file mode 100644
index 0000000..56639f1
--- /dev/null
+++ b/src/templates/developer/now.ts
@@ -0,0 +1,66 @@
+import type { TemplateDefinition } from '../types';
+import { autoFontSize, commonFields, isLightColor, mixColors, truncate } from '../utils';
+
+const template: TemplateDefinition = {
+ id: 'developer-now',
+ name: 'Developer Now',
+ category: 'developer',
+ description: 'A focused developer now card for what you are building, learning, and reading',
+ tags: ['developer', 'now', 'status', 'building', 'learning'],
+ fields: [
+ { key: 'name', label: 'Name', type: 'text' as const, defaultValue: '', placeholder: 'Jane Smith', required: true, group: 'Content' as const },
+ commonFields.title,
+ { key: 'building', label: 'Building', type: 'text' as const, defaultValue: '', placeholder: 'A calmer way to plan projects', group: 'Content' as const },
+ { key: 'learning', label: 'Learning', type: 'text' as const, defaultValue: '', placeholder: 'Distributed systems', group: 'Content' as const },
+ { key: 'reading', label: 'Reading', type: 'text' as const, defaultValue: '', placeholder: 'Designing Data-Intensive Applications', group: 'Content' as const },
+ { key: 'updated', label: 'Updated', type: 'text' as const, defaultValue: '', placeholder: 'oct 2026', group: 'Content' as const },
+ commonFields.bgColor,
+ commonFields.accentColor,
+ commonFields.textColor,
+ ],
+ defaults: {
+ name: 'Jane Smith', title: 'What I am focused on now', building: 'A calmer way to plan projects',
+ learning: 'Distributed systems', reading: 'Designing Data-Intensive Applications', updated: 'oct 2026',
+ bgColor: '#F5F1EA', accentColor: '#E07A5F', textColor: '#1E1E1E',
+ },
+ render: (params) => {
+ const name = truncate(String(params.name || 'Developer'), 42);
+ const title = truncate(String(params.title || 'What I am focused on now'), 90);
+ const building = String(params.building ?? '').trim();
+ const learning = String(params.learning ?? '').trim();
+ const reading = String(params.reading ?? '').trim();
+ const updatedValue = String(params.updated ?? '').trim();
+ const updated = updatedValue ? truncate(updatedValue, 24) : '';
+ const bgColor = String(params.bgColor || '#F5F1EA');
+ const accentColor = String(params.accentColor || '#E07A5F');
+ const textColor = String(params.textColor || '#1E1E1E');
+ const rows = [
+ { label: 'BUILDING', value: building }, { label: 'LEARNING', value: learning },
+ { label: 'READING', value: reading },
+ ].filter((row) => row.value);
+
+ return {
+ type: 'div',
+ props: {
+ style: { display: 'flex', flexDirection: 'column', width: '1200px', height: '630px', padding: '56px 72px', background: bgColor, color: textColor, fontFamily: 'Inter, sans-serif', boxSizing: 'border-box' },
+ children: [
+ { type: 'div', props: { style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center' }, children: [
+ { type: 'span', props: { style: { display: 'flex', fontSize: '32px', fontWeight: 700, fontFamily: 'Playfair Display, serif' }, children: name } },
+ ...(updated ? [{ type: 'span', props: { style: { display: 'flex', fontSize: '14px', color: `${textColor}99`, letterSpacing: '0.08em', textTransform: 'uppercase' }, children: `updated ${updated}` } }] : []),
+ ] } },
+ { type: 'div', props: { style: { display: 'flex', flexDirection: 'column', marginTop: '74px', flex: 1 }, children: [
+ { type: 'span', props: { style: { display: 'flex', fontSize: autoFontSize(title, [{ maxLen: 36, size: 54 }, { maxLen: 60, size: 46 }, { maxLen: 90, size: 38 }]), lineHeight: 1.08, fontWeight: 700, maxWidth: '920px', fontFamily: 'Playfair Display, serif' }, children: title } },
+ { type: 'div', props: { style: { display: 'flex', flexDirection: 'column', gap: '18px', marginTop: '48px', maxWidth: '1050px' }, children: rows.map((row) => ({
+ type: 'div', props: { style: { display: 'flex', alignItems: 'flex-start', gap: '32px' }, children: [
+ { type: 'span', props: { style: { display: 'flex', flexShrink: 0, width: '120px', paddingTop: '5px', fontSize: '13px', fontWeight: 700, color: isLightColor(bgColor) ? mixColors(accentColor, '#000000', 0.45) : accentColor, letterSpacing: '0.14em' }, children: row.label } },
+ { type: 'span', props: { style: { display: 'flex', minWidth: 0, fontSize: '22px', lineHeight: 1.35, color: textColor, overflowWrap: 'anywhere' }, children: truncate(String(row.value), 120) } },
+ ] },
+ })) } },
+ ] } },
+ ],
+ },
+ };
+ },
+};
+
+export default template;
diff --git a/src/templates/developer/year-review.ts b/src/templates/developer/year-review.ts
new file mode 100644
index 0000000..d0318bb
--- /dev/null
+++ b/src/templates/developer/year-review.ts
@@ -0,0 +1,170 @@
+import type { SatoriElement, TemplateDefinition } from '../types';
+import { truncate, mixColors, isLightColor, commonFields } from '../utils';
+
+const MAX_STATS = 3;
+const CONTENT_WIDTH = 1040;
+const GAP = 24;
+const CARD_PADDING = 28;
+// Capitals are wider than lowercase letters, so size the values for a generous character width.
+const VALUE_CHAR_WIDTH = 0.75;
+
+const cleanText = (value: unknown, maxLength: number) =>
+ truncate(String(value ?? '').replace(/\s+/g, ' ').trim(), maxLength);
+
+const template: TemplateDefinition = {
+ id: 'developer-year-review',
+ name: 'Year in Review',
+ category: 'developer',
+ description: 'A "my year in code" card with the year, a name, three big stats and an optional highlight',
+ tags: ['developer', 'year in review', 'stats', 'wrapped', 'recap'],
+ fields: [
+ { key: 'name', label: 'Name', type: 'text', defaultValue: 'Alex Rivera', placeholder: 'Your name', group: 'Content' },
+ { key: 'year', label: 'Year', type: 'text', defaultValue: '2026', placeholder: '2026', group: 'Content' },
+ { key: 'stat1Value', label: 'Stat 1 Value', type: 'text', defaultValue: '1,248', placeholder: '1,248', group: 'Content' },
+ { key: 'stat1Label', label: 'Stat 1 Label', type: 'text', defaultValue: 'commits', placeholder: 'commits', group: 'Content' },
+ { key: 'stat2Value', label: 'Stat 2 Value', type: 'text', defaultValue: '86', placeholder: '86', group: 'Content' },
+ { key: 'stat2Label', label: 'Stat 2 Label', type: 'text', defaultValue: 'pull requests', placeholder: 'pull requests', group: 'Content' },
+ { key: 'stat3Value', label: 'Stat 3 Value', type: 'text', defaultValue: 'TypeScript', placeholder: 'TypeScript', group: 'Content' },
+ { key: 'stat3Label', label: 'Stat 3 Label', type: 'text', defaultValue: 'top language', placeholder: 'top language', group: 'Content' },
+ { key: 'highlight', label: 'Highlight', type: 'text', defaultValue: 'Shipped my first open source library', placeholder: 'Optional, one line about the year', group: 'Content' },
+ commonFields.bgColor,
+ commonFields.accentColor,
+ commonFields.textColor,
+ ],
+ defaults: {
+ name: 'Alex Rivera',
+ year: '2026',
+ stat1Value: '1,248',
+ stat1Label: 'commits',
+ stat2Value: '86',
+ stat2Label: 'pull requests',
+ stat3Value: 'TypeScript',
+ stat3Label: 'top language',
+ highlight: 'Shipped my first open source library',
+ bgColor: '#0b0f1a',
+ accentColor: '#34d399',
+ textColor: '#f1f5f9',
+ },
+ render: (params) => {
+ const name = cleanText(params.name, 26);
+ const year = cleanText(params.year, 10) || '2026';
+ const highlight = cleanText(params.highlight, 80);
+ const bgColor = String(params.bgColor || '#0b0f1a');
+ const accentColor = String(params.accentColor || '#34d399');
+ const textColor = String(params.textColor || '#f1f5f9');
+
+ // A stat with no value is left out, and the others share the row.
+ const stats = Array.from({ length: MAX_STATS }, (_, i) => ({
+ value: cleanText(params[`stat${i + 1}Value`], 14),
+ label: cleanText(params[`stat${i + 1}Label`], 28),
+ })).filter((stat) => stat.value);
+
+ // One size for every value, so the cards read as a set: the largest that fits the longest.
+ const innerWidth = (CONTENT_WIDTH - GAP * (stats.length - 1)) / Math.max(stats.length, 1) - CARD_PADDING * 2;
+ const longest = Math.max(1, ...stats.map((stat) => stat.value.length));
+ const valueSize = Math.max(28, Math.min(84, Math.floor(innerWidth / (longest * VALUE_CHAR_WIDTH))));
+ const yearSize = year.length <= 4 ? 150 : year.length <= 6 ? 110 : 76;
+
+ // Neutral colors are mixed from the background and text colors, so the card reads the
+ // same on dark and on light backgrounds.
+ const lightBg = isLightColor(bgColor);
+ const panelBg = mixColors(bgColor, textColor, lightBg ? 0.04 : 0.07);
+ const panelBorder = mixColors(bgColor, textColor, 0.2);
+ const labelColor = mixColors(bgColor, textColor, 0.72);
+ // An accent as light as the background (or as dark as it) is hard to read as text, so
+ // pull it toward the text color. Otherwise it reads as it is.
+ const accentText = isLightColor(accentColor) === lightBg ? mixColors(accentColor, textColor, 0.55) : accentColor;
+
+ const card = (stat: { value: string; label: string }): SatoriElement => ({
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ flexDirection: 'column',
+ flex: 1,
+ minWidth: 0,
+ overflow: 'hidden',
+ padding: `28px ${CARD_PADDING}px`,
+ background: panelBg,
+ border: `1px solid ${panelBorder}`,
+ borderRadius: '20px',
+ },
+ children: [
+ {
+ type: 'span',
+ props: {
+ style: { fontSize: `${valueSize}px`, fontWeight: 700, color: accentText, lineHeight: 1.1, letterSpacing: '-0.02em', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' },
+ children: stat.value,
+ },
+ },
+ ...(stat.label
+ ? [{ type: 'span', props: { style: { marginTop: '10px', fontSize: '24px', fontWeight: 500, color: labelColor, lineHeight: 1.25 }, children: stat.label } }]
+ : []),
+ ],
+ },
+ });
+
+ return {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ flexDirection: 'column',
+ width: '1200px',
+ height: '630px',
+ background: bgColor,
+ fontFamily: 'Inter, sans-serif',
+ padding: '56px 80px',
+ },
+ children: [
+ // The year, large, with the name next to it
+ {
+ type: 'div',
+ props: {
+ style: { display: 'flex', alignItems: 'flex-end' },
+ children: [
+ { type: 'span', props: { style: { flexShrink: 0, fontSize: `${yearSize}px`, fontWeight: 700, color: accentText, lineHeight: 1, letterSpacing: '-0.04em' }, children: year } },
+ ...(name
+ ? [
+ {
+ type: 'span',
+ props: {
+ style: { marginLeft: '36px', marginBottom: '14px', fontSize: '44px', fontWeight: 600, color: textColor, lineHeight: 1.15 },
+ children: name,
+ },
+ },
+ ]
+ : []),
+ ],
+ },
+ },
+ // Spacer above the stats
+ { type: 'div', props: { style: { display: 'flex', flex: 1 }, children: [] } },
+ // The stats, sharing the row
+ ...(stats.length
+ ? [{ type: 'div', props: { style: { display: 'flex', gap: `${GAP}px` }, children: stats.map(card) } }]
+ : []),
+ // Spacer below the stats
+ { type: 'div', props: { style: { display: 'flex', flex: 1 }, children: [] } },
+ // Highlight
+ ...(highlight
+ ? [
+ {
+ type: 'div',
+ props: {
+ style: { display: 'flex', alignItems: 'center' },
+ children: [
+ { type: 'div', props: { style: { display: 'flex', width: '6px', height: '36px', borderRadius: '3px', background: accentColor, marginRight: '20px', flexShrink: 0 }, children: [] } },
+ { type: 'span', props: { style: { fontSize: '30px', fontWeight: 500, color: textColor, lineHeight: 1.25 }, children: highlight } },
+ ],
+ },
+ },
+ ]
+ : []),
+ ],
+ },
+ };
+ },
+};
+
+export default template;
diff --git a/src/templates/ecommerce/back-in-stock.ts b/src/templates/ecommerce/back-in-stock.ts
new file mode 100644
index 0000000..3f5d9ab
--- /dev/null
+++ b/src/templates/ecommerce/back-in-stock.ts
@@ -0,0 +1,167 @@
+import type { TemplateDefinition } from '../types';
+import { autoFontSize, commonFields, contrastText, isLightColor, mixColors, truncate } from '../utils';
+
+const template: TemplateDefinition = {
+ id: 'ecommerce-back-in-stock',
+ name: 'Back in Stock',
+ category: 'ecommerce',
+ description: 'A "back in stock" badge with the product name, price and an optional urgency line',
+ tags: ['ecommerce', 'restock', 'back-in-stock', 'product', 'urgency'],
+ fields: [
+ { key: 'productName', label: 'Product Name', type: 'text', defaultValue: 'Classic Canvas Weekender', placeholder: 'Classic Canvas Weekender', required: true, group: 'Content' },
+ { key: 'price', label: 'Price', type: 'text', defaultValue: '$89', placeholder: '$89', group: 'Content' },
+ { key: 'stockNote', label: 'Stock Note', type: 'text', defaultValue: 'Only 12 left', placeholder: 'Only 12 left', group: 'Content' },
+ commonFields.siteName,
+ commonFields.bgColor,
+ commonFields.accentColor,
+ commonFields.textColor,
+ ],
+ defaults: {
+ productName: 'Classic Canvas Weekender',
+ price: '$89',
+ stockNote: 'Only 12 left',
+ siteName: 'Field & Co.',
+ bgColor: '#0a0a0a',
+ accentColor: '#E07A5F',
+ textColor: '#FFFFFF',
+ },
+ render: (params) => {
+ const rawName = String(params.productName || 'Classic Canvas Weekender').trim() || 'Classic Canvas Weekender';
+ const nameSize = autoFontSize(rawName, [
+ { maxLen: 24, size: 64 },
+ { maxLen: 40, size: 54 },
+ { maxLen: 64, size: 44 },
+ { maxLen: 90, size: 36 },
+ ]);
+ const productName = truncate(rawName, 80);
+ const price = truncate(String(params.price || '').trim(), 24);
+ const stockNote = truncate(String(params.stockNote || '').trim(), 40);
+ const siteName = truncate(String(params.siteName || '').trim(), 40);
+ const bgColor = String(params.bgColor || '#0a0a0a');
+ const accentColor = String(params.accentColor || '#E07A5F');
+ const textColor = String(params.textColor || '#FFFFFF');
+ // The badge keeps the accent as its background, so its label follows the accent's contrast.
+ // Text drawn in the accent sits on the page background, so it is darkened when that is light.
+ const accentInk = isLightColor(bgColor) ? mixColors(accentColor, '#000000', 0.45) : accentColor;
+
+ return {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ flexDirection: 'column',
+ justifyContent: 'center',
+ width: '1200px',
+ height: '630px',
+ boxSizing: 'border-box',
+ backgroundColor: bgColor,
+ fontFamily: 'Inter, sans-serif',
+ padding: '64px 80px',
+ },
+ children: [
+ // Back in stock badge
+ {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ alignSelf: 'flex-start',
+ padding: '9px 24px',
+ backgroundColor: accentColor,
+ borderRadius: '100px',
+ },
+ children: {
+ type: 'span',
+ props: {
+ style: {
+ fontSize: '16px',
+ fontWeight: 800,
+ color: contrastText(accentColor),
+ letterSpacing: '0.1em',
+ textTransform: 'uppercase' as const,
+ },
+ children: 'Back in stock',
+ },
+ },
+ },
+ },
+ // Product name
+ {
+ type: 'h1',
+ props: {
+ style: {
+ fontSize: `${nameSize}px`,
+ fontWeight: 700,
+ color: textColor,
+ lineHeight: 1.15,
+ margin: 0,
+ marginTop: '30px',
+ },
+ children: productName,
+ },
+ },
+ // Price
+ ...(price
+ ? [{
+ type: 'span',
+ props: {
+ style: {
+ fontSize: '56px',
+ fontWeight: 800,
+ color: accentInk,
+ lineHeight: 1.1,
+ marginTop: '34px',
+ },
+ children: price,
+ },
+ }]
+ : []),
+ // Urgency line
+ ...(stockNote
+ ? [{
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ alignSelf: 'flex-start',
+ marginTop: '16px',
+ padding: '8px 20px',
+ borderRadius: '8px',
+ backgroundColor: `${accentInk}1F`,
+ },
+ children: {
+ type: 'span',
+ props: {
+ style: {
+ fontSize: '20px',
+ fontWeight: 600,
+ color: accentInk,
+ },
+ children: stockNote,
+ },
+ },
+ },
+ }]
+ : []),
+ // Store name
+ ...(siteName
+ ? [{
+ type: 'span',
+ props: {
+ style: {
+ fontSize: '18px',
+ fontWeight: 500,
+ color: `${textColor}66`,
+ marginTop: '44px',
+ },
+ children: siteName,
+ },
+ }]
+ : []),
+ ],
+ },
+ };
+ },
+};
+
+export default template;
diff --git a/src/templates/ecommerce/bundle-deal.ts b/src/templates/ecommerce/bundle-deal.ts
new file mode 100644
index 0000000..5075a6d
--- /dev/null
+++ b/src/templates/ecommerce/bundle-deal.ts
@@ -0,0 +1,292 @@
+import type { TemplateDefinition } from '../types';
+import { autoFontSize, commonFields, contrastText, isLightColor, mixColors, truncate } from '../utils';
+
+const defaultProducts = 'Wireless Headphones\nTravel Backpack\nSmart Watch';
+const pricePattern = /^[\$£€¥₹]?\s*([+-]?(?:(?:\d{1,3}(?:,\d{3})+|\d+)(?:\.\d+)?|\.\d+))\s*[\$£€¥₹]?$/;
+
+function parsePrice(value: string): number | undefined {
+ const match = value.trim().match(pricePattern);
+ if (!match) return undefined;
+
+ const price = Number(match[1].replace(/,/g, ''));
+ return Number.isFinite(price) ? price : undefined;
+}
+
+const template: TemplateDefinition = {
+ id: 'ecommerce-bundle-deal',
+ name: 'Bundle Deal',
+ category: 'ecommerce',
+ description: 'Show products bundled together with a combined price and savings',
+ tags: ['ecommerce', 'bundle', 'deal', 'products', 'savings'],
+ fields: [
+ {
+ key: 'products',
+ label: 'Products',
+ type: 'textarea',
+ defaultValue: defaultProducts,
+ placeholder: 'One product per line',
+ required: true,
+ group: 'Content',
+ },
+ {
+ key: 'oldPrice',
+ label: 'Old Total',
+ type: 'text',
+ defaultValue: '$120',
+ placeholder: '$120',
+ required: true,
+ group: 'Content',
+ },
+ {
+ key: 'newPrice',
+ label: 'Bundle Total',
+ type: 'text',
+ defaultValue: '$89',
+ placeholder: '$89',
+ required: true,
+ group: 'Content',
+ },
+ commonFields.siteName,
+ commonFields.bgColor,
+ commonFields.accentColor,
+ commonFields.textColor,
+ ],
+ defaults: {
+ products: defaultProducts,
+ oldPrice: '$120',
+ newPrice: '$89',
+ siteName: 'GearUp',
+ bgColor: '#0a0a0a',
+ accentColor: '#E07A5F',
+ textColor: '#FFFFFF',
+ },
+ render: (params) => {
+ const products = String(params.products ?? defaultProducts)
+ .split(/\r?\n/)
+ .map((product) => product.trim())
+ .filter(Boolean)
+ .slice(0, 3)
+ .map((product) => truncate(product, 36));
+ const oldPriceValue = String(params.oldPrice ?? '$120');
+ const newPriceValue = String(params.newPrice ?? '$89');
+ const oldPrice = truncate(oldPriceValue, 14);
+ const newPrice = truncate(newPriceValue, 14);
+ const siteName = truncate(String(params.siteName ?? ''), 40);
+ const bgColor = String(params.bgColor ?? '#0a0a0a');
+ const accentColor = String(params.accentColor ?? '#E07A5F');
+ const textColor = String(params.textColor ?? '#FFFFFF');
+ const oldPriceNumber = parsePrice(oldPriceValue);
+ const newPriceNumber = parsePrice(newPriceValue);
+ const accentInk = isLightColor(bgColor) ? mixColors(accentColor, '#000000', 0.45) : accentColor;
+ const savings =
+ oldPriceNumber !== undefined &&
+ newPriceNumber !== undefined &&
+ oldPriceNumber > newPriceNumber &&
+ newPriceNumber >= 0
+ ? Math.floor(((oldPriceNumber - newPriceNumber) / oldPriceNumber) * 100)
+ : undefined;
+
+ const productCards = products.flatMap((product, index) => {
+ const card = {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ width: '280px',
+ height: '174px',
+ boxSizing: 'border-box',
+ padding: '24px',
+ border: `2px solid ${accentColor}`,
+ borderRadius: '24px',
+ },
+ children: {
+ type: 'span',
+ props: {
+ style: {
+ fontSize: autoFontSize(product, [
+ { maxLen: 18, size: 32 },
+ { maxLen: 28, size: 26 },
+ { maxLen: 36, size: 20 },
+ ]),
+ fontWeight: 600,
+ color: textColor,
+ textAlign: 'center',
+ },
+ children: product,
+ },
+ },
+ },
+ };
+
+ if (index === 0) return [card];
+
+ return [
+ {
+ type: 'span',
+ props: {
+ style: {
+ fontSize: '44px',
+ fontWeight: 500,
+ color: accentInk,
+ },
+ children: '+',
+ },
+ },
+ card,
+ ];
+ });
+
+ return {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ flexDirection: 'column',
+ alignItems: 'stretch',
+ justifyContent: 'space-between',
+ width: '1200px',
+ height: '630px',
+ boxSizing: 'border-box',
+ padding: '56px',
+ backgroundColor: bgColor,
+ fontFamily: 'Inter, sans-serif',
+ },
+ children: [
+ {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ },
+ children: [
+ {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ padding: '10px 22px',
+ borderRadius: '100px',
+ backgroundColor: accentColor,
+ },
+ children: {
+ type: 'span',
+ props: {
+ style: {
+ fontSize: '17px',
+ fontWeight: 700,
+ color: contrastText(accentColor),
+ letterSpacing: '0.08em',
+ textTransform: 'uppercase' as const,
+ },
+ children: 'BUNDLE DEAL',
+ },
+ },
+ },
+ },
+ ...(siteName
+ ? [
+ {
+ type: 'span',
+ props: {
+ style: {
+ fontSize: '22px',
+ fontWeight: 600,
+ color: textColor,
+ },
+ children: siteName,
+ },
+ },
+ ]
+ : []),
+ ],
+ },
+ },
+ {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ gap: '20px',
+ },
+ children: productCards,
+ },
+ },
+ {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ gap: '28px',
+ },
+ children: [
+ {
+ type: 'span',
+ props: {
+ style: {
+ fontSize: '42px',
+ fontWeight: 400,
+ color: textColor,
+ textDecoration: 'line-through',
+ },
+ children: oldPrice,
+ },
+ },
+ {
+ type: 'span',
+ props: {
+ style: {
+ fontSize: autoFontSize(newPrice, [
+ { maxLen: 8, size: 72 },
+ { maxLen: 14, size: 52 },
+ ]),
+ fontWeight: 700,
+ color: accentInk,
+ lineHeight: 1,
+ },
+ children: newPrice,
+ },
+ },
+ ...(savings !== undefined
+ ? [
+ {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ padding: '12px 20px',
+ borderRadius: '100px',
+ backgroundColor: accentColor,
+ },
+ children: {
+ type: 'span',
+ props: {
+ style: {
+ fontSize: '20px',
+ fontWeight: 700,
+ color: contrastText(accentColor),
+ },
+ children: `Save ${savings}%`,
+ },
+ },
+ },
+ },
+ ]
+ : []),
+ ],
+ },
+ },
+ ],
+ },
+ };
+ },
+};
+
+export default template;
diff --git a/src/templates/ecommerce/gift-card.ts b/src/templates/ecommerce/gift-card.ts
new file mode 100644
index 0000000..c1cddee
--- /dev/null
+++ b/src/templates/ecommerce/gift-card.ts
@@ -0,0 +1,212 @@
+import type { TemplateDefinition } from '../types';
+import { autoFontSize, commonFields, contrastText, truncate } from '../utils';
+
+const template: TemplateDefinition = {
+ id: 'ecommerce-gift-card',
+ name: 'Gift Card',
+ category: 'ecommerce',
+ description: 'Gift card promo with a headline, optional note, and accent-colored card',
+ tags: ['ecommerce', 'gift card', 'gift', 'promo', 'holiday', 'store'],
+ fields: [
+ commonFields.siteName,
+ commonFields.title,
+ {
+ key: 'amount',
+ label: 'Amount',
+ type: 'text' as const,
+ defaultValue: '$50',
+ placeholder: '$50',
+ required: true,
+ group: 'Content' as const,
+ },
+ {
+ key: 'note',
+ label: 'Note',
+ type: 'text' as const,
+ defaultValue: '',
+ placeholder: 'Delivered instantly by email',
+ group: 'Content' as const,
+ },
+ commonFields.bgColor,
+ commonFields.accentColor,
+ commonFields.textColor,
+ ],
+ defaults: {
+ siteName: 'Northstar Supply',
+ title: 'Give them the choice',
+ amount: '$50',
+ note: 'Delivered instantly by email',
+ bgColor: '#0a0a0a',
+ accentColor: '#E07A5F',
+ textColor: '#FFFFFF',
+ },
+ render: (params) => {
+ const rawTitle = String(params.title ?? '').trim();
+ const title = truncate(rawTitle || 'Give them the choice', 50);
+ const rawAmount = String(params.amount ?? '').trim();
+ const amount = truncate(rawAmount || '$50', 14);
+ const note = truncate(String(params.note ?? '').trim(), 90);
+ const siteName = truncate(String(params.siteName ?? '').trim(), 24);
+ const bgColor = String(params.bgColor || '#0a0a0a');
+ const accentColor = String(params.accentColor || '#E07A5F');
+ const textColor = String(params.textColor || '#FFFFFF');
+ const cardTextColor = contrastText(accentColor);
+
+ const titleSize = autoFontSize(title, [
+ { maxLen: 24, size: 64 },
+ { maxLen: 38, size: 54 },
+ { maxLen: 50, size: 44 },
+ ]);
+
+ const amountSize = autoFontSize(amount, [
+ { maxLen: 4, size: 92 },
+ { maxLen: 6, size: 80 },
+ { maxLen: 10, size: 64 },
+ { maxLen: 14, size: 44 },
+ ]);
+
+ return {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ alignItems: 'center',
+ gap: '64px',
+ width: '1200px',
+ height: '630px',
+ boxSizing: 'border-box',
+ padding: '64px 72px',
+ backgroundColor: bgColor,
+ fontFamily: 'Inter, sans-serif',
+ },
+ children: [
+ {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ flex: 1,
+ flexDirection: 'column',
+ justifyContent: 'center',
+ minWidth: 0,
+ },
+ children: [
+ {
+ type: 'h1',
+ props: {
+ style: {
+ fontSize: `${titleSize}px`,
+ fontWeight: 700,
+ color: textColor,
+ lineHeight: 1.15,
+ letterSpacing: '-0.02em',
+ margin: 0,
+ },
+ children: title,
+ },
+ },
+ ...(note
+ ? [
+ {
+ type: 'p',
+ props: {
+ style: {
+ fontSize: '22px',
+ fontWeight: 400,
+ color: `${textColor}CC`,
+ lineHeight: 1.5,
+ margin: '20px 0 0 0',
+ },
+ children: note,
+ },
+ },
+ ]
+ : []),
+ ],
+ },
+ },
+ {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ flexShrink: 0,
+ flexDirection: 'column',
+ justifyContent: 'space-between',
+ width: '500px',
+ height: '360px',
+ boxSizing: 'border-box',
+ padding: '44px',
+ borderRadius: '32px',
+ backgroundColor: accentColor,
+ },
+ children: [
+ {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ gap: '24px',
+ },
+ children: [
+ ...(siteName
+ ? [
+ {
+ type: 'span',
+ props: {
+ style: {
+ display: 'flex',
+ minWidth: 0,
+ fontSize: '22px',
+ fontWeight: 600,
+ color: cardTextColor,
+ letterSpacing: '0.01em',
+ },
+ children: siteName,
+ },
+ },
+ ]
+ : []),
+ {
+ type: 'span',
+ props: {
+ style: {
+ display: 'flex',
+ flexShrink: 0,
+ fontSize: '19px',
+ fontWeight: 500,
+ color: cardTextColor,
+ letterSpacing: '0.04em',
+ },
+ children: 'Gift card',
+ },
+ },
+ ],
+ },
+ },
+ {
+ type: 'span',
+ props: {
+ style: {
+ display: 'flex',
+ fontSize: `${amountSize}px`,
+ fontWeight: 700,
+ color: cardTextColor,
+ lineHeight: 1,
+ letterSpacing: '-0.03em',
+ },
+ children: amount,
+ },
+ },
+ ],
+ },
+ },
+ ],
+ },
+ };
+ },
+};
+
+export default template;
diff --git a/src/templates/ecommerce/index.ts b/src/templates/ecommerce/index.ts
index a423b23..288fc28 100644
--- a/src/templates/ecommerce/index.ts
+++ b/src/templates/ecommerce/index.ts
@@ -7,3 +7,7 @@ export { default as ecommerceNewArrival } from './new-arrival';
export { default as ecommerceComparisonGrid } from './comparison-grid';
export { default as ecommerceBrandStory } from './brand-story';
export { default as ecommerceCouponCode } from './coupon-code';
+export { default as ecommerceBundleDeal } from './bundle-deal';
+export { default as ecommerceOrderStatus } from './order-status';
+export { default as ecommerceGiftCard } from './gift-card';
+export { default as ecommerceBackInStock } from './back-in-stock';
diff --git a/src/templates/ecommerce/order-status.ts b/src/templates/ecommerce/order-status.ts
new file mode 100644
index 0000000..373801f
--- /dev/null
+++ b/src/templates/ecommerce/order-status.ts
@@ -0,0 +1,241 @@
+import type { TemplateDefinition } from '../types';
+import { autoFontSize, commonFields, contrastText, isLightColor, mixColors, truncate } from '../utils';
+
+const steps = ['Ordered', 'Shipped', 'Out for delivery', 'Delivered'];
+
+const template: TemplateDefinition = {
+ id: 'ecommerce-order-status',
+ name: 'Order Status',
+ category: 'ecommerce',
+ description: 'Product order update with a four-step delivery tracker',
+ tags: ['ecommerce', 'order', 'shipping', 'delivery', 'status'],
+ fields: [
+ commonFields.siteName,
+ commonFields.title,
+ { key: 'orderNumber', label: 'Order Number', type: 'text', defaultValue: '#10482', placeholder: '#10482', group: 'Content' },
+ { key: 'currentStep', label: 'Current Step (1-4)', type: 'number', defaultValue: 2, min: 1, max: 4, group: 'Content' },
+ { key: 'estimate', label: 'Estimated Delivery', type: 'text', defaultValue: 'Arrives Friday, Oct 24', placeholder: 'Arrives Friday, Oct 24', group: 'Content' },
+ commonFields.bgColor,
+ commonFields.accentColor,
+ commonFields.textColor,
+ ],
+ defaults: {
+ siteName: 'Northstar Supply',
+ title: 'Classic Canvas Weekender Bag',
+ orderNumber: '#10482',
+ currentStep: 2,
+ estimate: 'Arrives Friday, Oct 24',
+ bgColor: '#0a0a0a',
+ accentColor: '#E07A5F',
+ textColor: '#FFFFFF',
+ },
+ render: (params) => {
+ const title = truncate(String(params.title || 'Your order'), 72);
+ const titleSize = autoFontSize(title, [
+ { maxLen: 28, size: 48 },
+ { maxLen: 44, size: 40 },
+ { maxLen: 60, size: 34 },
+ { maxLen: 72, size: 30 },
+ ]);
+ const siteName = String(params.siteName || '');
+ const orderNumber = String(params.orderNumber || '').trim();
+ const estimate = String(params.estimate || '').trim();
+ const stepValue = String(params.currentStep ?? '').trim();
+ const parsedStep = stepValue ? Number(stepValue) : Number.NaN;
+ const currentStep = Number.isFinite(parsedStep)
+ ? Math.min(4, Math.max(1, Math.round(parsedStep)))
+ : 2;
+ const bgColor = String(params.bgColor || '#0a0a0a');
+ const accentColor = String(params.accentColor || '#E07A5F');
+ const textColor = String(params.textColor || '#FFFFFF');
+ const progressColor = isLightColor(bgColor) ? mixColors(accentColor, '#000000', 0.45) : accentColor;
+ const mutedColor = mixColors(bgColor, textColor, 0.68);
+
+ return {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ flexDirection: 'column',
+ justifyContent: 'center',
+ width: '1200px',
+ height: '630px',
+ padding: '56px 72px',
+ backgroundColor: bgColor,
+ fontFamily: 'Inter, sans-serif',
+ },
+ children: [
+ {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ marginBottom: '30px',
+ },
+ children: [
+ {
+ type: 'span',
+ props: {
+ style: { fontSize: '18px', fontWeight: 700, color: textColor, letterSpacing: '0.04em' },
+ children: siteName,
+ },
+ },
+ ...(orderNumber
+ ? [{
+ type: 'span',
+ props: {
+ style: {
+ display: 'flex',
+ fontSize: '17px',
+ fontWeight: 600,
+ color: mutedColor,
+ },
+ children: `Order ${orderNumber}`,
+ },
+ }]
+ : []),
+ ],
+ },
+ },
+ {
+ type: 'h1',
+ props: {
+ style: {
+ fontSize: `${titleSize}px`,
+ fontWeight: 700,
+ color: textColor,
+ lineHeight: 1.15,
+ margin: 0,
+ marginBottom: '56px',
+ },
+ children: title,
+ },
+ },
+ {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ position: 'relative',
+ width: '100%',
+ marginBottom: '28px',
+ },
+ children: [
+ {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ position: 'absolute',
+ left: '12.5%',
+ right: '12.5%',
+ top: '21px',
+ height: '4px',
+ backgroundColor: `${progressColor}40`,
+ borderRadius: '4px',
+ },
+ },
+ },
+ {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ position: 'absolute',
+ left: '12.5%',
+ top: '21px',
+ width: `${((currentStep - 1) / 3) * 75}%`,
+ height: '4px',
+ backgroundColor: progressColor,
+ borderRadius: '4px',
+ },
+ },
+ },
+ ...steps.map((step, index) => {
+ const isComplete = index + 1 <= currentStep;
+ return {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ flex: 1,
+ flexDirection: 'column',
+ alignItems: 'center',
+ position: 'relative',
+ },
+ children: [
+ {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ width: '46px',
+ height: '46px',
+ borderRadius: '50%',
+ border: `3px solid ${isComplete ? progressColor : `${textColor}55`}`,
+ backgroundColor: isComplete ? progressColor : bgColor,
+ color: isComplete ? contrastText(progressColor) : mutedColor,
+ fontSize: '18px',
+ fontWeight: 700,
+ },
+ children: `${index + 1}`,
+ },
+ },
+ {
+ type: 'span',
+ props: {
+ style: {
+ display: 'flex',
+ justifyContent: 'center',
+ width: '100%',
+ marginTop: '16px',
+ textAlign: 'center',
+ fontSize: '16px',
+ fontWeight: isComplete ? 600 : 500,
+ color: isComplete ? progressColor : mutedColor,
+ lineHeight: 1.25,
+ },
+ children: step,
+ },
+ },
+ ],
+ },
+ };
+ }),
+ ],
+ },
+ },
+ ...(estimate
+ ? [{
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ justifyContent: 'center',
+ marginTop: '10px',
+ },
+ children: {
+ type: 'span',
+ props: {
+ style: {
+ fontSize: '20px',
+ fontWeight: 600,
+ color: textColor,
+ },
+ children: estimate,
+ },
+ },
+ },
+ }]
+ : []),
+ ],
+ },
+ };
+ },
+};
+
+export default template;
diff --git a/src/templates/event/call-for-speakers.ts b/src/templates/event/call-for-speakers.ts
new file mode 100644
index 0000000..12d50cd
--- /dev/null
+++ b/src/templates/event/call-for-speakers.ts
@@ -0,0 +1,194 @@
+import type { SatoriElement, TemplateDefinition } from '../types';
+import { truncate, autoFontSize, mixColors, isLightColor, contrastText, commonFields } from '../utils';
+
+const MAX_TOPICS = 4;
+
+const template: TemplateDefinition = {
+ id: 'event-call-for-speakers',
+ name: 'Call for Speakers',
+ category: 'event',
+ description: 'A call for speakers with the event name, up to four topic chips, a deadline and a Submit a talk button',
+ tags: ['event', 'speakers', 'cfp', 'conference', 'meetup'],
+ fields: [
+ commonFields.title,
+ { key: 'topics', label: 'Topics (comma separated)', type: 'text', defaultValue: 'AI, Web, DevOps, Security', placeholder: 'AI, Web, DevOps', group: 'Content' },
+ { key: 'deadline', label: 'Deadline', type: 'text', defaultValue: 'Nov 30, 2026', placeholder: 'Nov 30, 2026', group: 'Content' },
+ { key: 'ctaText', label: 'Button Text', type: 'text', defaultValue: 'Submit a talk', placeholder: 'Submit a talk', group: 'Content' },
+ commonFields.siteName,
+ { ...commonFields.bgColor, defaultValue: '#0b1020' },
+ { ...commonFields.accentColor, defaultValue: '#4f46e5' },
+ { ...commonFields.textColor, defaultValue: '#f8fafc' },
+ ],
+ defaults: {
+ title: 'DevConf 2027',
+ topics: 'AI, Web, DevOps, Security',
+ deadline: 'Nov 30, 2026',
+ ctaText: 'Submit a talk',
+ siteName: '',
+ bgColor: '#0b1020',
+ accentColor: '#4f46e5',
+ textColor: '#f8fafc',
+ },
+ render: (params) => {
+ const title = truncate(String(params.title || '').trim() || 'DevConf 2027', 60);
+ // Empty entries are dropped, at most four chips show, and each is shortened.
+ const topics = String(params.topics ?? '')
+ .split(',')
+ .map((topic) => topic.trim())
+ .filter(Boolean)
+ .slice(0, MAX_TOPICS)
+ .map((topic) => truncate(topic, 22));
+ const deadline = truncate(String(params.deadline ?? '').trim(), 24);
+ const ctaText = truncate(String(params.ctaText ?? '').trim() || 'Submit a talk', 20);
+ const siteName = truncate(String(params.siteName ?? '').trim(), 40);
+ const bgColor = String(params.bgColor || '#0b1020');
+ const accentColor = String(params.accentColor || '#4f46e5');
+ const textColor = String(params.textColor || '#f8fafc');
+
+ const titleSize = autoFontSize(title, [
+ { maxLen: 16, size: 96 },
+ { maxLen: 28, size: 80 },
+ { maxLen: 44, size: 64 },
+ { maxLen: 60, size: 52 },
+ ]);
+
+ // Neutral colors are mixed from the background and text colors, so the card reads
+ // the same on dark and on light backgrounds.
+ const lightBg = isLightColor(bgColor);
+ const mutedText = mixColors(bgColor, textColor, 0.65);
+ const secondaryText = mixColors(bgColor, textColor, 0.8);
+ // An accent as light as the background (or as dark as it) is hard to read as text,
+ // so pull it toward the text color. Otherwise it reads as it is.
+ const accentText = isLightColor(accentColor) === lightBg ? mixColors(accentColor, textColor, 0.55) : accentColor;
+ const buttonText = contrastText(accentColor);
+
+ const chips: SatoriElement[] = topics.map((topic) => ({
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ flexShrink: 0,
+ padding: '10px 24px',
+ borderRadius: '999px',
+ background: `${accentColor}22`,
+ border: `2px solid ${accentColor}88`,
+ },
+ children: {
+ type: 'span',
+ props: { style: { fontSize: '26px', fontWeight: 600, color: accentText }, children: topic },
+ },
+ },
+ }));
+
+ return {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ flexDirection: 'column',
+ width: '1200px',
+ height: '630px',
+ background: bgColor,
+ fontFamily: 'Inter, sans-serif',
+ padding: '60px 72px',
+ },
+ children: [
+ // Label and site name
+ {
+ type: 'div',
+ props: {
+ style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between' },
+ children: [
+ {
+ type: 'span',
+ props: {
+ style: { fontSize: '24px', fontWeight: 700, color: accentText, letterSpacing: '0.14em', textTransform: 'uppercase' },
+ children: 'Call for speakers',
+ },
+ },
+ ...(siteName
+ ? [{ type: 'span', props: { style: { fontSize: '22px', color: mutedText }, children: siteName } }]
+ : []),
+ ],
+ },
+ },
+ // Spacer above the event name
+ { type: 'div', props: { style: { display: 'flex', flex: 1 }, children: [] } },
+ // Event name
+ {
+ type: 'div',
+ props: {
+ style: { display: 'flex' },
+ children: {
+ type: 'span',
+ props: {
+ style: { fontSize: `${titleSize}px`, fontWeight: 700, color: textColor, lineHeight: 1.1, letterSpacing: '-0.03em' },
+ children: title,
+ },
+ },
+ },
+ },
+ // Topic chips
+ ...(chips.length
+ ? [
+ {
+ type: 'div',
+ props: {
+ style: { display: 'flex', flexShrink: 0, flexWrap: 'wrap', gap: '14px', marginTop: '36px' },
+ children: chips,
+ },
+ },
+ ]
+ : []),
+ // Spacer below the chips
+ { type: 'div', props: { style: { display: 'flex', flex: 1 }, children: [] } },
+ // Deadline and button
+ {
+ type: 'div',
+ props: {
+ style: { display: 'flex', alignItems: 'flex-end', justifyContent: 'space-between' },
+ children: [
+ // The deadline is left out when empty, and the button stays on the right
+ ...(deadline
+ ? [
+ {
+ type: 'div',
+ props: {
+ style: { display: 'flex', flexDirection: 'column', marginRight: '40px' },
+ children: [
+ {
+ type: 'span',
+ props: {
+ style: { fontSize: '20px', fontWeight: 600, color: mutedText, letterSpacing: '0.12em', textTransform: 'uppercase' },
+ children: 'Deadline',
+ },
+ },
+ {
+ type: 'span',
+ props: { style: { fontSize: '40px', fontWeight: 700, color: secondaryText, marginTop: '6px' }, children: deadline },
+ },
+ ],
+ },
+ },
+ ]
+ : [{ type: 'div', props: { style: { display: 'flex' }, children: [] } }]),
+ {
+ type: 'div',
+ props: {
+ style: { display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '22px 44px', borderRadius: '16px', background: accentColor },
+ children: {
+ type: 'span',
+ props: { style: { fontSize: '30px', fontWeight: 700, color: buttonText }, children: ctaText },
+ },
+ },
+ },
+ ],
+ },
+ },
+ ],
+ },
+ };
+ },
+};
+
+export default template;
diff --git a/src/templates/event/index.ts b/src/templates/event/index.ts
index 49f4adc..e288a53 100644
--- a/src/templates/event/index.ts
+++ b/src/templates/event/index.ts
@@ -9,3 +9,7 @@ export { default as eventVenueMap } from './venue-map';
export { default as eventTicketCta } from './ticket-cta';
export { default as eventMultiSpeaker } from './multi-speaker';
export { default as eventAgenda } from './agenda';
+export { default as eventOfficeHours } from './office-hours';
+export { default as eventWinners } from './winners';
+export { default as eventCallForSpeakers } from './call-for-speakers';
+export { default as eventRescheduled } from './rescheduled';
diff --git a/src/templates/event/office-hours.ts b/src/templates/event/office-hours.ts
new file mode 100644
index 0000000..4fb2e1b
--- /dev/null
+++ b/src/templates/event/office-hours.ts
@@ -0,0 +1,80 @@
+import type { TemplateDefinition } from '../types';
+import { autoFontSize, commonFields, contrastText, isLightColor, mixColors, truncate } from '../utils';
+
+const template: TemplateDefinition = {
+ id: 'event-office-hours',
+ name: 'Office Hours',
+ category: 'event',
+ description: 'Friendly recurring office-hours card for maintainer and community calls',
+ tags: ['office hours', 'maintainers', 'community', 'discord', 'event'],
+ fields: [
+ commonFields.title,
+ { key: 'schedule', label: 'Schedule', type: 'text' as const, defaultValue: 'Every Thursday · 17:00 to 18:00 IST', placeholder: 'Every Thursday · 17:00 to 18:00 IST', group: 'Content' as const },
+ { key: 'host', label: 'Host', type: 'text' as const, defaultValue: '', placeholder: 'Host name', required: true, group: 'Content' as const },
+ { key: 'hostRole', label: 'Host Role', type: 'text' as const, defaultValue: '', placeholder: 'Maintainer', group: 'Content' as const },
+ { key: 'topic', label: 'Topic', type: 'text' as const, defaultValue: '', placeholder: 'Bring your PR questions', group: 'Content' as const },
+ { key: 'ctaText', label: 'Call to Action', type: 'text' as const, defaultValue: 'Join on Discord', placeholder: 'Join on Discord', group: 'Content' as const },
+ commonFields.bgColor,
+ commonFields.accentColor,
+ commonFields.textColor,
+ ],
+ defaults: {
+ title: 'Ask the maintainers',
+ schedule: 'Every Thursday · 17:00 to 18:00 IST',
+ host: 'Priya Shah',
+ hostRole: 'Maintainer',
+ topic: 'Bring your PR questions',
+ ctaText: 'Join on Discord',
+ bgColor: '#f5f1e8',
+ accentColor: '#5b4bdb',
+ textColor: '#171521',
+ },
+ render: (params) => {
+ const value = (key: string, fallback = '') => {
+ const raw = params[key];
+ return typeof raw === 'string' ? raw.trim() : raw == null ? fallback : String(raw).trim();
+ };
+ const title = truncate(value('title', 'Ask the maintainers') || 'Ask the maintainers', 90);
+ const schedule = truncate(value('schedule', 'Every Thursday · 17:00 to 18:00 IST') || 'Every Thursday · 17:00 to 18:00 IST', 64);
+ const host = truncate(value('host', ''), 40);
+ const hostRole = truncate(value('hostRole'), 48);
+ const topic = truncate(value('topic'), 90);
+ const ctaText = truncate(value('ctaText'), 28);
+ const bgColor = value('bgColor', '#f5f1e8');
+ const accentColor = value('accentColor', '#5b4bdb');
+ const textColor = value('textColor', '#171521');
+ const ctaColor = contrastText(accentColor);
+ const labelColor = isLightColor(accentColor) === isLightColor(bgColor) ? mixColors(accentColor, textColor, 0.55) : accentColor;
+ const titleSize = autoFontSize(title, [
+ { maxLen: 30, size: 70 },
+ { maxLen: 50, size: 58 },
+ { maxLen: 72, size: 48 },
+ { maxLen: 90, size: 40 },
+ ]);
+
+ return {
+ type: 'div',
+ props: {
+ style: { display: 'flex', flexDirection: 'column', width: '1200px', height: '630px', background: bgColor, fontFamily: 'Inter, sans-serif', padding: '58px 68px' },
+ children: [
+ { type: 'span', props: { style: { fontSize: '16px', fontWeight: 700, color: labelColor, letterSpacing: '0.16em', textTransform: 'uppercase' }, children: 'Office hours' } },
+ { type: 'div', props: { style: { display: 'flex', flexDirection: 'column', flex: 1, justifyContent: 'center', maxWidth: '1000px' }, children: [
+ { type: 'h1', props: { style: { fontSize: `${titleSize}px`, fontWeight: 700, lineHeight: 1.08, color: textColor, margin: 0, maxWidth: '1000px' }, children: title } },
+ { type: 'span', props: { style: { marginTop: '28px', fontSize: '25px', fontWeight: 600, color: textColor }, children: schedule } },
+ ...(host ? [{ type: 'div', props: { style: { display: 'flex', alignItems: 'center', marginTop: '24px', gap: '12px' }, children: [
+ { type: 'span', props: { style: { fontSize: '22px', fontWeight: 700, color: textColor }, children: host } },
+ ...(hostRole ? [{ type: 'span', props: { style: { fontSize: '20px', color: `${textColor}99` }, children: `· ${hostRole}` } }] : []),
+ ] } }] : []),
+ ...(topic ? [{ type: 'span', props: { style: { marginTop: '16px', fontSize: '20px', color: `${textColor}aa` }, children: topic } }] : []),
+ ] } },
+ { type: 'div', props: { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', borderTop: `1px solid ${textColor}22`, paddingTop: '24px' }, children: [
+ { type: 'span', props: { style: { fontSize: '15px', fontWeight: 600, color: `${textColor}88`, letterSpacing: '0.08em', textTransform: 'uppercase' }, children: 'Community connection' } },
+ ...(ctaText ? [{ type: 'div', props: { style: { display: 'flex', alignItems: 'center', padding: '14px 24px', borderRadius: '999px', background: accentColor }, children: { type: 'span', props: { style: { fontSize: '18px', fontWeight: 700, color: ctaColor }, children: ctaText } } } }] : []),
+ ] } },
+ ],
+ },
+ };
+ },
+};
+
+export default template;
diff --git a/src/templates/event/rescheduled.ts b/src/templates/event/rescheduled.ts
new file mode 100644
index 0000000..272b451
--- /dev/null
+++ b/src/templates/event/rescheduled.ts
@@ -0,0 +1,160 @@
+import type { SatoriElement, TemplateDefinition } from '../types';
+import { truncate, autoFontSize, mixColors, isLightColor, contrastText, commonFields, arrowRightIcon } from '../utils';
+
+const DEFAULT_TITLE = 'Frontend Meetup #14';
+const DEFAULT_NEW_DATE = 'Thu, 13 Nov, 7 PM';
+const CONTENT_WIDTH = 1040;
+// The arrow and the gaps around it, between the old date and the new one.
+const DATE_GAPS = 90;
+
+const cleanText = (value: unknown, maxLength: number) =>
+ truncate(String(value ?? '').replace(/\s+/g, ' ').trim(), maxLength);
+
+const template: TemplateDefinition = {
+ id: 'event-rescheduled',
+ name: 'Rescheduled',
+ category: 'event',
+ description: 'A Rescheduled notice with the event name, the old date struck through next to the new date, and an optional note',
+ tags: ['event', 'rescheduled', 'new date', 'update', 'meetup'],
+ fields: [
+ commonFields.title,
+ { key: 'oldDate', label: 'Old Date', type: 'text', defaultValue: 'Thu, 6 Nov, 7 PM', placeholder: 'Optional, the date it was on', group: 'Content' },
+ { key: 'newDate', label: 'New Date', type: 'text', defaultValue: DEFAULT_NEW_DATE, placeholder: 'Thu, 13 Nov, 7 PM', required: true, group: 'Content' },
+ { key: 'note', label: 'Note', type: 'text', defaultValue: 'Same venue, your tickets still work', placeholder: 'Optional, one line for attendees', group: 'Content' },
+ commonFields.bgColor,
+ commonFields.accentColor,
+ commonFields.textColor,
+ ],
+ defaults: {
+ title: DEFAULT_TITLE,
+ oldDate: 'Thu, 6 Nov, 7 PM',
+ newDate: DEFAULT_NEW_DATE,
+ note: 'Same venue, your tickets still work',
+ bgColor: '#0b0f1a',
+ accentColor: '#f59e0b',
+ textColor: '#f8fafc',
+ },
+ render: (params) => {
+ const title = cleanText(params.title, 80) || DEFAULT_TITLE;
+ const oldDate = cleanText(params.oldDate, 28);
+ const newDate = cleanText(params.newDate, 28) || DEFAULT_NEW_DATE;
+ const note = cleanText(params.note, 80);
+ const bgColor = String(params.bgColor || '#0b0f1a');
+ const accentColor = String(params.accentColor || '#f59e0b');
+ const textColor = String(params.textColor || '#f8fafc');
+
+ const titleSize = autoFontSize(title, [
+ { maxLen: 18, size: 96 },
+ { maxLen: 30, size: 80 },
+ { maxLen: 46, size: 64 },
+ { maxLen: 64, size: 52 },
+ { maxLen: 80, size: 44 },
+ ]);
+
+ // Each date stays on one line: the new date is bold and the old one is smaller, so size
+ // them together for the room they share. A single date can be larger.
+ const OLD_SCALE = 0.75;
+ const newWidth = 0.64 * newDate.length;
+ const oldWidth = oldDate ? 0.55 * OLD_SCALE * oldDate.length : 0;
+ const room = oldDate ? CONTENT_WIDTH - DATE_GAPS : CONTENT_WIDTH;
+ const dateSize = Math.max(24, Math.min(oldDate ? 48 : 60, Math.floor(room / (newWidth + oldWidth))));
+
+ // Neutral colors are mixed from the background and text colors, so the card reads the
+ // same on dark and on light backgrounds.
+ const lightBg = isLightColor(bgColor);
+ const dimmedText = mixColors(bgColor, textColor, 0.55);
+ const noteColor = mixColors(bgColor, textColor, 0.78);
+ // An accent as light as the background (or as dark as it) is hard to read as text, so
+ // pull it toward the text color. Otherwise it reads as it is.
+ const accentText = isLightColor(accentColor) === lightBg ? mixColors(accentColor, textColor, 0.55) : accentColor;
+ const pillText = contrastText(accentColor);
+
+ const dates: SatoriElement[] = [
+ ...(oldDate
+ ? [
+ {
+ type: 'span',
+ props: {
+ style: {
+ fontSize: `${Math.round(dateSize * OLD_SCALE)}px`,
+ fontWeight: 500,
+ color: dimmedText,
+ textDecoration: 'line-through',
+ whiteSpace: 'nowrap',
+ },
+ children: oldDate,
+ },
+ },
+ { type: 'div', props: { style: { display: 'flex', margin: '0 26px', flexShrink: 0 }, children: arrowRightIcon(Math.round(dateSize * 0.8), accentText) } },
+ ]
+ : []),
+ {
+ type: 'span',
+ props: {
+ style: { fontSize: `${dateSize}px`, fontWeight: 700, color: accentText, whiteSpace: 'nowrap' },
+ children: newDate,
+ },
+ },
+ ];
+
+ return {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ flexDirection: 'column',
+ width: '1200px',
+ height: '630px',
+ background: bgColor,
+ fontFamily: 'Inter, sans-serif',
+ padding: '64px 80px',
+ },
+ children: [
+ // Rescheduled pill
+ {
+ type: 'div',
+ props: {
+ style: { display: 'flex' },
+ children: {
+ type: 'div',
+ props: {
+ style: { display: 'flex', padding: '10px 26px', borderRadius: '999px', background: accentColor },
+ children: {
+ type: 'span',
+ props: {
+ style: { fontSize: '24px', fontWeight: 700, color: pillText, letterSpacing: '0.12em', textTransform: 'uppercase' },
+ children: 'Rescheduled',
+ },
+ },
+ },
+ },
+ },
+ },
+ // Event name and dates
+ {
+ type: 'div',
+ props: {
+ style: { display: 'flex', flexDirection: 'column', flex: 1, justifyContent: 'center' },
+ children: [
+ {
+ type: 'span',
+ props: {
+ style: { fontSize: `${titleSize}px`, fontWeight: 700, color: textColor, lineHeight: 1.1, letterSpacing: '-0.03em' },
+ children: title,
+ },
+ },
+ { type: 'div', props: { style: { display: 'flex', alignItems: 'center', marginTop: '36px' }, children: dates } },
+ ],
+ },
+ },
+ // Note
+ ...(note
+ ? [{ type: 'span', props: { style: { fontSize: '28px', fontWeight: 500, color: noteColor, lineHeight: 1.3 }, children: note } }]
+ : []),
+ ],
+ },
+ };
+ },
+};
+
+export default template;
diff --git a/src/templates/event/ticket-cta.ts b/src/templates/event/ticket-cta.ts
index 0df7320..ab7d0da 100644
--- a/src/templates/event/ticket-cta.ts
+++ b/src/templates/event/ticket-cta.ts
@@ -35,7 +35,7 @@ const template: TemplateDefinition = {
const title = truncate(String(params.title || 'Event'), 50);
const date = String(params.date || '');
const location = String(params.location || '');
- const price = String(params.price || '');
+ const price = truncate(String(params.price || ''), 24);
const priceLabel = String(params.priceLabel || '');
const ctaText = String(params.ctaText || 'Get Tickets');
const siteName = String(params.siteName || '');
@@ -48,6 +48,13 @@ const template: TemplateDefinition = {
{ maxLen: 50, size: 38 },
{ maxLen: 70, size: 32 },
]);
+ const priceSize = autoFontSize(price, [
+ { maxLen: 4, size: 64 },
+ { maxLen: 6, size: 48 },
+ { maxLen: 9, size: 40 },
+ { maxLen: 12, size: 32 },
+ { maxLen: 24, size: 24 },
+ ]);
return {
type: 'div',
@@ -230,7 +237,7 @@ const template: TemplateDefinition = {
type: 'span',
props: {
style: {
- fontSize: '64px',
+ fontSize: `${priceSize}px`,
fontWeight: 800,
color: textColor,
lineHeight: 1,
diff --git a/src/templates/event/winners.ts b/src/templates/event/winners.ts
new file mode 100644
index 0000000..d3f3b50
--- /dev/null
+++ b/src/templates/event/winners.ts
@@ -0,0 +1,228 @@
+import type { TemplateDefinition } from '../types';
+import { truncate, autoFontSize, commonFields, isLightColor, mixColors } from '../utils';
+
+const template: TemplateDefinition = {
+ id: 'event-winners',
+ name: 'Winners Podium',
+ category: 'event',
+ description: 'Hackathon or contest results card with a 2nd, 1st, 3rd podium, winner names and project names',
+ tags: ['winners', 'podium', 'hackathon', 'contest', 'results', 'event'],
+ fields: [
+ { ...commonFields.siteName, label: 'Event Name', placeholder: 'HackFest 2026' },
+ commonFields.title,
+ { key: 'first', label: '1st Place', type: 'text' as const, defaultValue: '', placeholder: 'Team Nova', group: 'Content' as const },
+ { key: 'firstProject', label: '1st Place Project', type: 'text' as const, defaultValue: '', placeholder: 'AI Code Reviewer', group: 'Content' as const },
+ { key: 'second', label: '2nd Place', type: 'text' as const, defaultValue: '', placeholder: 'Jane Smith', group: 'Content' as const },
+ { key: 'secondProject', label: '2nd Place Project', type: 'text' as const, defaultValue: '', placeholder: 'Open Source Linter', group: 'Content' as const },
+ { key: 'third', label: '3rd Place', type: 'text' as const, defaultValue: '', placeholder: 'Byte Bandits', group: 'Content' as const },
+ { key: 'thirdProject', label: '3rd Place Project', type: 'text' as const, defaultValue: '', placeholder: 'Realtime Dashboard', group: 'Content' as const },
+ commonFields.bgColor,
+ commonFields.accentColor,
+ commonFields.textColor,
+ ],
+ defaults: {
+ siteName: 'HackFest 2026',
+ title: 'Hackathon Winners',
+ first: 'Team Nova',
+ firstProject: 'AI Code Reviewer',
+ second: 'Jane Smith',
+ secondProject: 'Open Source Linter',
+ third: 'Byte Bandits',
+ thirdProject: 'Realtime Dashboard',
+ bgColor: '#0a0a0a',
+ accentColor: '#00D4FF',
+ textColor: '#FFFFFF',
+ },
+ render: (params) => {
+ const siteName = String(params.siteName || '');
+ const title = truncate(String(params.title || 'Winners'), 40);
+ const first = String(params.first || 'Winner');
+ const firstProject = String(params.firstProject || '');
+ const second = String(params.second || '');
+ const secondProject = String(params.secondProject || '');
+ const third = String(params.third || '');
+ const thirdProject = String(params.thirdProject || '');
+ const bgColor = String(params.bgColor || '#0a0a0a');
+ const accentColor = String(params.accentColor || '#00D4FF');
+ const textColor = String(params.textColor || '#FFFFFF');
+
+ const titleSize = autoFontSize(title, [
+ { maxLen: 20, size: 60 },
+ { maxLen: 30, size: 52 },
+ { maxLen: 40, size: 44 },
+ ]);
+
+ // Readable number color on the accent-filled 1st place block
+ const onAccentColor = isLightColor(accentColor) ? '#111111' : '#FFFFFF';
+ // Accent used as text on the page background: darkened on light backgrounds
+ const accentTextColor = isLightColor(bgColor) ? mixColors(accentColor, '#000000', 0.45) : accentColor;
+
+ const podiumColumn = (
+ place: number,
+ name: string,
+ project: string,
+ blockHeight: number,
+ isFirst: boolean,
+ ) => ({
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ flexDirection: 'column',
+ alignItems: 'center',
+ width: '320px',
+ },
+ children: [
+ // Winner name
+ {
+ type: 'span',
+ props: {
+ style: {
+ fontSize: isFirst ? '32px' : '28px',
+ fontWeight: 700,
+ color: textColor,
+ textAlign: 'center',
+ maxWidth: '320px',
+ },
+ children: truncate(name, isFirst ? 16 : 18),
+ },
+ },
+ // Project name (hidden when empty)
+ ...(project
+ ? [
+ {
+ type: 'span',
+ props: {
+ style: {
+ fontSize: '18px',
+ color: `${textColor}99`,
+ textAlign: 'center',
+ marginTop: '6px',
+ maxWidth: '320px',
+ },
+ children: truncate(project, 28),
+ },
+ },
+ ]
+ : []),
+ // Podium block
+ {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ width: '320px',
+ height: `${blockHeight}px`,
+ marginTop: '16px',
+ borderRadius: '16px 16px 0 0',
+ background: isFirst ? accentColor : `${textColor}15`,
+ border: isFirst ? `2px solid ${accentColor}` : `2px solid ${textColor}30`,
+ },
+ children: {
+ type: 'span',
+ props: {
+ style: {
+ fontSize: isFirst ? '96px' : '72px',
+ fontWeight: 700,
+ color: isFirst ? onAccentColor : `${textColor}cc`,
+ fontFamily: 'JetBrains Mono, monospace',
+ },
+ children: String(place),
+ },
+ },
+ },
+ },
+ ],
+ },
+ });
+
+ // Order: 2nd, 1st, 3rd. Empty 2nd/3rd are skipped, so the podium stays centered.
+ const columns = [
+ ...(second ? [podiumColumn(2, second, secondProject, 160, false)] : []),
+ podiumColumn(1, first, firstProject, 220, true),
+ ...(third ? [podiumColumn(3, third, thirdProject, 110, false)] : []),
+ ];
+
+ return {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ flexDirection: 'column',
+ width: '1200px',
+ height: '630px',
+ background: bgColor,
+ fontFamily: 'Inter, sans-serif',
+ padding: '48px 60px 0 60px',
+ position: 'relative',
+ overflow: 'hidden',
+ },
+ children: [
+ // Header: event name + title
+ {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ flexDirection: 'column',
+ alignItems: 'center',
+ },
+ children: [
+ ...(siteName
+ ? [
+ {
+ type: 'span',
+ props: {
+ style: {
+ fontSize: '20px',
+ fontWeight: 600,
+ color: accentTextColor,
+ letterSpacing: '0.12em',
+ textTransform: 'uppercase',
+ marginBottom: '12px',
+ },
+ children: truncate(siteName, 40),
+ },
+ },
+ ]
+ : []),
+ {
+ type: 'h1',
+ props: {
+ style: {
+ fontSize: `${titleSize}px`,
+ fontWeight: 800,
+ color: textColor,
+ lineHeight: 1.1,
+ margin: 0,
+ textAlign: 'center',
+ },
+ children: title,
+ },
+ },
+ ],
+ },
+ },
+ // Podium, bottom-aligned
+ {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ flex: 1,
+ alignItems: 'flex-end',
+ justifyContent: 'center',
+ gap: '24px',
+ },
+ children: columns,
+ },
+ },
+ ],
+ },
+ };
+ },
+};
+
+export default template;
diff --git a/src/templates/github/good-first-issues.ts b/src/templates/github/good-first-issues.ts
new file mode 100644
index 0000000..bb06b4b
--- /dev/null
+++ b/src/templates/github/good-first-issues.ts
@@ -0,0 +1,84 @@
+import type { SatoriElement, TemplateDefinition } from '../types';
+import { isLightColor, mixColors, truncate, commonFields } from '../utils';
+
+const MAX_ISSUES = 4;
+
+export function parseIssues(input: unknown): { number: string; title: string }[] {
+ if (typeof input !== 'string') return [];
+ return input
+ .split(/\r?\n/)
+ .map((line) => line.trim())
+ .filter(Boolean)
+ .map((line) => {
+ const match = /^(\d+)\s+(.+)$/.exec(line);
+ return match ? { number: match[1], title: match[2].trim() } : { number: '', title: line };
+ });
+}
+
+const template: TemplateDefinition = {
+ id: 'github-good-first-issues',
+ name: 'Good First Issues',
+ category: 'github',
+ description: 'A shareable list of beginner-friendly GitHub issues',
+ tags: ['github', 'issues', 'open-source', 'good first issue', 'community'],
+ fields: [
+ { key: 'repoName', label: 'Repository', type: 'text', defaultValue: 'codercops/ogcops', placeholder: 'owner/repo', group: 'Content' },
+ commonFields.title,
+ { key: 'issues', label: 'Issues', type: 'textarea', defaultValue: '128 Add a dark mode toggle\n117 Improve the onboarding flow\n103 Add keyboard shortcuts\n96 Update the documentation', placeholder: '128 Add a dark mode toggle', group: 'Content' },
+ { key: 'footer', label: 'Footer', type: 'text', defaultValue: 'Comment on an issue to get assigned', placeholder: 'Footer message', group: 'Content' },
+ { ...commonFields.bgColor, defaultValue: '#0d1117' },
+ commonFields.accentColor,
+ { ...commonFields.textColor, defaultValue: '#e6edf3' },
+ ],
+ defaults: {
+ repoName: 'codercops/ogcops',
+ title: 'Good first issues this week',
+ issues: '128 Add a dark mode toggle\n117 Improve the onboarding flow\n103 Add keyboard shortcuts\n96 Update the documentation',
+ footer: 'Comment on an issue to get assigned',
+ bgColor: '#0d1117',
+ accentColor: '#58a6ff',
+ textColor: '#e6edf3',
+ },
+ render: (params) => {
+ const repoName = truncate(String(params.repoName ?? '').trim(), 48);
+ const title = truncate(String(params.title || 'Good first issues this week').trim(), 60);
+ const allIssues = parseIssues(params.issues);
+ const displayedIssues = allIssues.slice(0, MAX_ISSUES);
+ const moreCount = allIssues.length - displayedIssues.length;
+ const footer = String(params.footer ?? '').trim();
+ const bgColor = String(params.bgColor || '#0d1117');
+ const accentColor = String(params.accentColor || '#58a6ff');
+ const textColor = String(params.textColor || '#e6edf3');
+ const adaptedAccent = isLightColor(bgColor) ? mixColors(accentColor, '#000000', 0.45) : accentColor;
+
+ const issueRows: SatoriElement[] = displayedIssues.map((issue) => ({
+ type: 'div',
+ props: {
+ style: { display: 'flex', alignItems: 'center', height: '52px', gap: '18px', width: '100%' },
+ children: [
+ ...(issue.number
+ ? [{ type: 'span', props: { style: { width: '64px', flexShrink: 0, fontSize: '24px', fontWeight: 700, color: adaptedAccent, fontFamily: 'JetBrains Mono, monospace' }, children: `#${issue.number}` } }]
+ : []),
+ { type: 'span', props: { style: { flex: 1, minWidth: 0, fontSize: '25px', color: textColor, whiteSpace: 'nowrap', overflow: 'hidden' }, children: truncate(issue.title, 64) } },
+ ],
+ },
+ }));
+
+ return {
+ type: 'div',
+ props: {
+ style: { display: 'flex', flexDirection: 'column', width: '1200px', height: '630px', padding: '58px 72px', boxSizing: 'border-box', background: bgColor, fontFamily: 'Inter, sans-serif', color: textColor },
+ children: [
+ { type: 'span', props: { style: { fontSize: '22px', fontWeight: 600, color: adaptedAccent, fontFamily: 'JetBrains Mono, monospace', marginBottom: '18px' }, children: repoName } },
+ { type: 'h1', props: { style: { fontSize: '50px', lineHeight: 1.15, margin: 0, color: textColor }, children: title } },
+ { type: 'div', props: { style: { display: 'flex', flexDirection: 'column', marginTop: '28px' }, children: issueRows } },
+ ...(moreCount > 0 ? [{ type: 'span', props: { style: { fontSize: '18px', color: `${textColor}88`, marginTop: '6px' }, children: `+${moreCount} more` } }] : []),
+ { type: 'div', props: { style: { display: 'flex', flex: 1 }, children: [] } },
+ ...(footer ? [{ type: 'div', props: { style: { display: 'flex', borderTop: `1px solid ${textColor}22`, paddingTop: '18px' }, children: { type: 'span', props: { style: { fontSize: '20px', color: `${textColor}99` }, children: footer } } } }] : []),
+ ],
+ },
+ };
+ },
+};
+
+export default template;
diff --git a/src/templates/github/index.ts b/src/templates/github/index.ts
index 9ed96ed..7dd81c6 100644
--- a/src/templates/github/index.ts
+++ b/src/templates/github/index.ts
@@ -11,3 +11,9 @@ export { default as githubMatrix } from './matrix';
export { default as githubContributorsWall } from './contributors-wall';
export { default as githubIssueCard } from './issue-card';
export { default as githubPullRequest } from './pull-request';
+export { default as githubGoodFirstIssues } from './good-first-issues';
+export { default as githubMilestone } from './milestone';
+export { default as githubSponsorsGoal } from './sponsors-goal';
+export { default as githubSecurityAdvisory } from './security-advisory';
+export { default as githubMaintainersWanted } from './maintainers-wanted';
+export { default as githubPackageCard } from './package-card';
diff --git a/src/templates/github/maintainers-wanted.ts b/src/templates/github/maintainers-wanted.ts
new file mode 100644
index 0000000..40a04e1
--- /dev/null
+++ b/src/templates/github/maintainers-wanted.ts
@@ -0,0 +1,489 @@
+import type { SatoriElement, TemplateDefinition } from '../types';
+import { autoFontSize, commonFields, isLightColor, mixColors, starIcon, truncate } from '../utils';
+
+function issueIcon(size: number, color: string) {
+ return {
+ type: 'svg',
+ props: {
+ width: size,
+ height: size,
+ viewBox: '0 0 16 16',
+ children: [
+ {
+ type: 'path',
+ props: {
+ d: 'M8 9.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3z',
+ fill: color,
+ },
+ },
+ {
+ type: 'path',
+ props: {
+ d: 'M8 0a8 8 0 1 0 0 16A8 8 0 0 0 8 0zM1.5 8a6.5 6.5 0 1 1 13 0 6.5 6.5 0 0 1-13 0z',
+ fill: color,
+ },
+ },
+ ],
+ },
+ };
+}
+
+function checkIcon(size: number, color: string) {
+ return {
+ type: 'svg',
+ props: {
+ width: size,
+ height: size,
+ viewBox: '0 0 16 16',
+ fill: 'none',
+ stroke: color,
+ strokeWidth: '2.5',
+ strokeLinecap: 'round',
+ strokeLinejoin: 'round',
+ children: {
+ type: 'path',
+ props: {
+ d: 'M3.5 8.5l3 3 6-6',
+ },
+ },
+ },
+ };
+}
+
+const template: TemplateDefinition = {
+ id: 'github-maintainers-wanted',
+ name: 'Maintainers Wanted',
+ category: 'github',
+ description: 'Call for open source maintainers with repository stats, pitch, help checklist and site branding',
+ tags: ['github', 'maintainers', 'open-source', 'contributors', 'community'],
+ fields: [
+ {
+ ...commonFields.title,
+ defaultValue: 'acme/csv-stream',
+ placeholder: 'owner/repo',
+ },
+ {
+ ...commonFields.description,
+ defaultValue: 'A streaming CSV parser for Node',
+ placeholder: 'A streaming CSV parser for Node',
+ required: false,
+ },
+ {
+ key: 'stars',
+ label: 'Stars',
+ type: 'text',
+ defaultValue: '4.2k stars',
+ placeholder: '4.2k stars',
+ required: false,
+ group: 'Content',
+ },
+ {
+ key: 'openIssues',
+ label: 'Open Issues',
+ type: 'text',
+ defaultValue: '38 open issues',
+ placeholder: '38 open issues',
+ required: false,
+ group: 'Content',
+ },
+ {
+ key: 'help1',
+ label: 'Help Item 1',
+ type: 'text',
+ defaultValue: 'Review pull requests',
+ placeholder: 'Review pull requests',
+ required: false,
+ group: 'Content',
+ },
+ {
+ key: 'help2',
+ label: 'Help Item 2',
+ type: 'text',
+ defaultValue: 'Triage new issues',
+ placeholder: 'Triage new issues',
+ required: false,
+ group: 'Content',
+ },
+ {
+ key: 'help3',
+ label: 'Help Item 3',
+ type: 'text',
+ defaultValue: 'Cut releases',
+ placeholder: 'Cut releases',
+ required: false,
+ group: 'Content',
+ },
+ {
+ ...commonFields.siteName,
+ defaultValue: 'github.com/acme/csv-stream',
+ placeholder: 'github.com/acme/csv-stream',
+ },
+ {
+ ...commonFields.bgColor,
+ defaultValue: '#0d1117',
+ },
+ {
+ ...commonFields.accentColor,
+ defaultValue: '#58a6ff',
+ },
+ {
+ ...commonFields.textColor,
+ defaultValue: '#e6edf3',
+ },
+ ],
+ defaults: {
+ title: 'acme/csv-stream',
+ description: 'A streaming CSV parser for Node',
+ stars: '4.2k stars',
+ openIssues: '38 open issues',
+ help1: 'Review pull requests',
+ help2: 'Triage new issues',
+ help3: 'Cut releases',
+ siteName: 'github.com/acme/csv-stream',
+ bgColor: '#0d1117',
+ accentColor: '#58a6ff',
+ textColor: '#e6edf3',
+ },
+ render: (params) => {
+ const rawTitle = String(params.title ?? '').trim() || 'acme/csv-stream';
+ const titleSize = autoFontSize(rawTitle, [
+ { maxLen: 18, size: 54 },
+ { maxLen: 28, size: 46 },
+ { maxLen: 40, size: 38 },
+ { maxLen: 55, size: 32 },
+ ]);
+ const title = truncate(rawTitle, 60);
+
+ const rawDescription = params.description !== undefined && params.description !== null
+ ? String(params.description).trim()
+ : 'A streaming CSV parser for Node';
+ const descriptionSize = autoFontSize(rawDescription, [
+ { maxLen: 40, size: 24 },
+ { maxLen: 70, size: 21 },
+ { maxLen: 100, size: 19 },
+ { maxLen: 140, size: 17 },
+ ]);
+ const description = rawDescription ? truncate(rawDescription, 140) : '';
+
+ const rawStars = params.stars !== undefined && params.stars !== null
+ ? String(params.stars).trim()
+ : '4.2k stars';
+ const stars = rawStars ? truncate(rawStars, 30) : '';
+
+ const rawOpenIssues = params.openIssues !== undefined && params.openIssues !== null
+ ? String(params.openIssues).trim()
+ : '38 open issues';
+ const openIssues = rawOpenIssues ? truncate(rawOpenIssues, 30) : '';
+
+ const defaultHelps = ['Review pull requests', 'Triage new issues', 'Cut releases'];
+ const helpKeys = ['help1', 'help2', 'help3'] as const;
+ const helpItems = helpKeys
+ .map((key, i) => {
+ const val = params[key];
+ const text = val !== undefined && val !== null ? String(val).trim() : defaultHelps[i];
+ return text ? truncate(text, 60) : '';
+ })
+ .filter(Boolean);
+
+ const rawSiteName = params.siteName !== undefined && params.siteName !== null
+ ? String(params.siteName).trim()
+ : 'github.com/acme/csv-stream';
+ const siteName = rawSiteName ? truncate(rawSiteName, 50) : '';
+
+ const bgColor = String(params.bgColor || '#0d1117');
+ const accentColor = String(params.accentColor || '#58a6ff');
+ const textColor = String(params.textColor || '#e6edf3');
+
+ const lightBg = isLightColor(bgColor);
+ const accentInk = lightBg ? mixColors(accentColor, '#000000', 0.45) : accentColor;
+ const cardBg = mixColors(bgColor, textColor, lightBg ? 0.04 : 0.06);
+ const cardBorder = mixColors(bgColor, textColor, lightBg ? 0.12 : 0.16);
+ const checkCircleBg = mixColors(bgColor, accentInk, lightBg ? 0.12 : 0.2);
+ const checkCircleBorder = mixColors(bgColor, accentInk, lightBg ? 0.25 : 0.35);
+
+ const statsItems = [
+ ...(stars
+ ? [
+ {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ alignItems: 'center',
+ gap: '8px',
+ padding: '8px 18px',
+ borderRadius: '10px',
+ backgroundColor: cardBg,
+ border: `1px solid ${cardBorder}`,
+ },
+ children: [
+ starIcon(18, accentInk),
+ {
+ type: 'span',
+ props: {
+ style: {
+ fontSize: '17px',
+ fontWeight: 600,
+ color: textColor,
+ fontFamily: 'JetBrains Mono, monospace',
+ },
+ children: stars,
+ },
+ },
+ ],
+ },
+ },
+ ]
+ : []),
+ ...(openIssues
+ ? [
+ {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ alignItems: 'center',
+ gap: '8px',
+ padding: '8px 18px',
+ borderRadius: '10px',
+ backgroundColor: cardBg,
+ border: `1px solid ${cardBorder}`,
+ },
+ children: [
+ issueIcon(18, accentInk),
+ {
+ type: 'span',
+ props: {
+ style: {
+ fontSize: '17px',
+ fontWeight: 600,
+ color: textColor,
+ fontFamily: 'JetBrains Mono, monospace',
+ },
+ children: openIssues,
+ },
+ },
+ ],
+ },
+ },
+ ]
+ : []),
+ ];
+
+ const helpRows: SatoriElement[] = helpItems.map((item) => ({
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ alignItems: 'center',
+ padding: '14px 22px',
+ borderRadius: '12px',
+ backgroundColor: cardBg,
+ border: `1px solid ${cardBorder}`,
+ width: '100%',
+ },
+ children: [
+ {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ width: '30px',
+ height: '30px',
+ borderRadius: '50%',
+ backgroundColor: checkCircleBg,
+ border: `1px solid ${checkCircleBorder}`,
+ marginRight: '16px',
+ flexShrink: 0,
+ },
+ children: checkIcon(14, accentInk),
+ },
+ },
+ {
+ type: 'span',
+ props: {
+ style: {
+ fontSize: '19px',
+ fontWeight: 500,
+ color: textColor,
+ lineHeight: 1.4,
+ },
+ children: item,
+ },
+ },
+ ],
+ },
+ }));
+
+ return {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ flexDirection: 'column',
+ width: '1200px',
+ height: '630px',
+ boxSizing: 'border-box',
+ backgroundColor: bgColor,
+ fontFamily: 'Inter, sans-serif',
+ padding: '48px 64px',
+ },
+ children: [
+ // Header block: Label badge, Title, Pitch
+ {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ flexDirection: 'column',
+ },
+ children: [
+ {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ alignItems: 'center',
+ padding: '5px 14px',
+ borderRadius: '999px',
+ backgroundColor: mixColors(bgColor, accentInk, lightBg ? 0.08 : 0.16),
+ border: `1px solid ${accentInk}55`,
+ marginBottom: '14px',
+ alignSelf: 'flex-start',
+ },
+ children: {
+ type: 'span',
+ props: {
+ style: {
+ fontSize: '13px',
+ fontWeight: 700,
+ color: accentInk,
+ letterSpacing: '0.08em',
+ textTransform: 'uppercase',
+ },
+ children: 'Maintainers wanted',
+ },
+ },
+ },
+ },
+ {
+ type: 'h1',
+ props: {
+ style: {
+ fontSize: `${titleSize}px`,
+ fontWeight: 800,
+ color: textColor,
+ lineHeight: 1.15,
+ margin: 0,
+ letterSpacing: '-0.02em',
+ },
+ children: title,
+ },
+ },
+ ...(description
+ ? [
+ {
+ type: 'p',
+ props: {
+ style: {
+ fontSize: `${descriptionSize}px`,
+ color: lightBg ? mixColors(bgColor, textColor, 0.7) : `${textColor}aa`,
+ lineHeight: 1.4,
+ margin: 0,
+ marginTop: '10px',
+ maxWidth: '960px',
+ },
+ children: description,
+ },
+ },
+ ]
+ : []),
+ ],
+ },
+ },
+ // Stats row
+ ...(statsItems.length > 0
+ ? [
+ {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ alignItems: 'center',
+ gap: '14px',
+ marginTop: '20px',
+ marginBottom: '20px',
+ },
+ children: statsItems,
+ },
+ },
+ ]
+ : []),
+ // Help items list
+ ...(helpRows.length > 0
+ ? [
+ {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ flexDirection: 'column',
+ gap: '10px',
+ width: '100%',
+ marginTop: statsItems.length > 0 ? '0px' : '24px',
+ },
+ children: helpRows,
+ },
+ },
+ ]
+ : []),
+ // Spacer
+ {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ flex: 1,
+ },
+ children: [],
+ },
+ },
+ // Bottom branding
+ ...(siteName
+ ? [
+ {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ alignItems: 'center',
+ paddingTop: '16px',
+ borderTop: `1px solid ${lightBg ? mixColors(bgColor, textColor, 0.12) : `${textColor}15`}`,
+ },
+ children: [
+ {
+ type: 'span',
+ props: {
+ style: {
+ fontSize: '17px',
+ fontWeight: 500,
+ color: lightBg ? mixColors(bgColor, textColor, 0.6) : `${textColor}88`,
+ fontFamily: 'Inter, sans-serif',
+ },
+ children: siteName,
+ },
+ },
+ ],
+ },
+ },
+ ]
+ : []),
+ ],
+ },
+ };
+ },
+};
+
+export default template;
diff --git a/src/templates/github/milestone.ts b/src/templates/github/milestone.ts
new file mode 100644
index 0000000..6321134
--- /dev/null
+++ b/src/templates/github/milestone.ts
@@ -0,0 +1,219 @@
+import type { TemplateDefinition } from '../types';
+import { truncate, autoFontSize, commonFields, isLightColor, mixColors } from '../utils';
+
+/** "34" -> 34, "" / "abc" -> fallback, " 12 " -> 12. */
+function toNumber(value: unknown, fallback: number): number {
+ if (value === undefined || value === null) return fallback;
+ const raw = String(value).trim();
+ if (raw === '') return fallback;
+ const n = Number(raw);
+ return Number.isFinite(n) ? n : fallback;
+}
+
+const template: TemplateDefinition = {
+ id: 'github-milestone',
+ name: 'Milestone Progress',
+ category: 'github',
+ description: 'Repository and milestone name over a progress bar, with closed and total issue counts, percentage and an optional due date',
+ tags: ['github', 'milestone', 'progress', 'roadmap', 'open-source'],
+ fields: [
+ {
+ key: 'repoName',
+ label: 'Repository',
+ type: 'text' as const,
+ defaultValue: 'codercops/ogcops',
+ placeholder: 'owner/repo',
+ group: 'Content' as const,
+ },
+ commonFields.title,
+ {
+ key: 'closed',
+ label: 'Closed Issues',
+ type: 'number' as const,
+ defaultValue: 34,
+ min: 0,
+ max: 1000,
+ group: 'Content' as const,
+ },
+ {
+ key: 'total',
+ label: 'Total Issues',
+ type: 'number' as const,
+ defaultValue: 42,
+ min: 0,
+ max: 1000,
+ group: 'Content' as const,
+ },
+ {
+ key: 'dueDate',
+ label: 'Due Date',
+ type: 'text' as const,
+ defaultValue: 'Nov 30',
+ placeholder: 'Nov 30',
+ group: 'Content' as const,
+ },
+ commonFields.bgColor,
+ commonFields.accentColor,
+ commonFields.textColor,
+ ],
+ defaults: {
+ repoName: 'codercops/ogcops',
+ title: 'v2.0',
+ closed: 34,
+ total: 42,
+ dueDate: 'Nov 30',
+ bgColor: '#0d1117',
+ accentColor: '#58a6ff',
+ textColor: '#e6edf3',
+ },
+ render: (params) => {
+ const bgColor = String(params.bgColor || '#0d1117');
+ const accentColor = String(params.accentColor || '#58a6ff');
+ const textColor = String(params.textColor || '#e6edf3');
+ const repoName = truncate(String(params.repoName ?? '').trim(), 48);
+ const dueDate = truncate(String(params.dueDate ?? '').trim(), 24);
+
+ // The accent as text drops to 2.5:1 on a light background, so darken it there.
+ const accentInk = isLightColor(bgColor) ? mixColors(accentColor, '#000000', 0.45) : accentColor;
+
+ // Numbers arrive as strings from the API, and users can type anything into the fields.
+ const total = Math.max(1, Math.floor(toNumber(params.total, 42)));
+ const closed = Math.min(total, Math.max(0, Math.round(toNumber(params.closed, 34))));
+ const percent = Math.round((closed / total) * 100);
+
+ const title = truncate(String(params.title ?? '').trim() || 'v2.0', 80);
+ const titleSize = autoFontSize(title, [
+ { maxLen: 6, size: 96 },
+ { maxLen: 12, size: 80 },
+ { maxLen: 24, size: 64 },
+ { maxLen: 40, size: 52 },
+ { maxLen: 80, size: 40 },
+ ]);
+
+ const captured = `${closed} of ${total} issues closed`;
+
+ return {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ flexDirection: 'column',
+ alignItems: 'center',
+ justifyContent: 'center',
+ width: '1200px',
+ height: '630px',
+ backgroundColor: bgColor,
+ fontFamily: 'Inter, sans-serif',
+ padding: '64px',
+ },
+ children: [
+ ...(repoName
+ ? [
+ {
+ type: 'span',
+ props: {
+ style: {
+ fontSize: '22px',
+ fontWeight: 500,
+ color: `${textColor}88`,
+ letterSpacing: '0.02em',
+ marginBottom: '14px',
+ },
+ children: repoName,
+ },
+ },
+ ]
+ : []),
+ {
+ type: 'span',
+ props: {
+ style: {
+ fontSize: `${titleSize}px`,
+ fontWeight: 800,
+ color: textColor,
+ letterSpacing: '-0.02em',
+ lineHeight: 1.05,
+ textAlign: 'center' as const,
+ marginBottom: '52px',
+ },
+ children: title,
+ },
+ },
+ // Progress bar
+ {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ width: '880px',
+ height: '24px',
+ borderRadius: '100px',
+ backgroundColor: `${textColor}1A`,
+ overflow: 'hidden',
+ },
+ children: {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ width: `${percent}%`,
+ height: '100%',
+ borderRadius: '100px',
+ backgroundColor: accentColor,
+ },
+ },
+ },
+ },
+ },
+ // Counts on the left, percentage on the right
+ {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ alignItems: 'baseline',
+ justifyContent: 'space-between',
+ width: '880px',
+ marginTop: '20px',
+ },
+ children: [
+ {
+ type: 'span',
+ props: {
+ style: { fontSize: '22px', fontWeight: 500, color: `${textColor}AA` },
+ children: captured,
+ },
+ },
+ {
+ type: 'span',
+ props: {
+ style: { fontSize: '34px', fontWeight: 800, color: accentInk },
+ children: `${percent}%`,
+ },
+ },
+ ],
+ },
+ },
+ ...(dueDate
+ ? [
+ {
+ type: 'span',
+ props: {
+ style: {
+ fontSize: '18px',
+ fontWeight: 500,
+ color: `${textColor}66`,
+ marginTop: '44px',
+ },
+ children: `Due ${dueDate}`,
+ },
+ },
+ ]
+ : []),
+ ],
+ },
+ };
+ },
+};
+
+export default template;
diff --git a/src/templates/github/package-card.ts b/src/templates/github/package-card.ts
new file mode 100644
index 0000000..6683654
--- /dev/null
+++ b/src/templates/github/package-card.ts
@@ -0,0 +1,530 @@
+import type { TemplateDefinition } from '../types';
+import { autoFontSize, commonFields, isLightColor, mixColors, truncate } from '../utils';
+
+function downloadIcon(size: number, color: string) {
+ return {
+ type: 'svg',
+ props: {
+ width: size,
+ height: size,
+ viewBox: '0 0 16 16',
+ fill: 'none',
+ stroke: color,
+ strokeWidth: '1.75',
+ strokeLinecap: 'round',
+ strokeLinejoin: 'round',
+ children: [
+ {
+ type: 'path',
+ props: {
+ d: 'M2.5 10.5v2a1 1 0 0 0 1 1h9a1 1 0 0 0 1-1v-2M8 2.5v7m-3-3l3 3 3-3',
+ },
+ },
+ ],
+ },
+ };
+}
+
+function licenseIcon(size: number, color: string) {
+ return {
+ type: 'svg',
+ props: {
+ width: size,
+ height: size,
+ viewBox: '0 0 16 16',
+ fill: 'none',
+ stroke: color,
+ strokeWidth: '1.75',
+ strokeLinecap: 'round',
+ strokeLinejoin: 'round',
+ children: [
+ {
+ type: 'path',
+ props: {
+ d: 'M8 1.5l5.5 2v4.5c0 3.5-2.5 5.5-5.5 6.5-3-1-5.5-3-5.5-6.5V3.5l5.5-2z',
+ },
+ },
+ ],
+ },
+ };
+}
+
+const template: TemplateDefinition = {
+ id: 'github-package-card',
+ name: 'Package Card',
+ category: 'github',
+ description: 'Published package announcement with registry badge, version pill, pitch, downloads, license, and install command',
+ tags: ['github', 'package', 'npm', 'pypi', 'crates', 'open-source'],
+ fields: [
+ {
+ ...commonFields.title,
+ defaultValue: '@acme/csv-stream',
+ placeholder: '@scope/package-name',
+ },
+ {
+ ...commonFields.description,
+ defaultValue: 'A streaming CSV parser for Node',
+ placeholder: 'A streaming CSV parser for Node',
+ required: false,
+ },
+ {
+ key: 'registry',
+ label: 'Registry',
+ type: 'text' as const,
+ defaultValue: 'npm',
+ placeholder: 'npm',
+ required: false,
+ group: 'Content' as const,
+ },
+ {
+ key: 'version',
+ label: 'Version',
+ type: 'text' as const,
+ defaultValue: 'v2.4.1',
+ placeholder: 'v2.4.1',
+ required: false,
+ group: 'Content' as const,
+ },
+ {
+ key: 'downloads',
+ label: 'Downloads',
+ type: 'text' as const,
+ defaultValue: '182k weekly downloads',
+ placeholder: '182k weekly downloads',
+ required: false,
+ group: 'Content' as const,
+ },
+ {
+ key: 'license',
+ label: 'License',
+ type: 'text' as const,
+ defaultValue: 'MIT',
+ placeholder: 'MIT',
+ required: false,
+ group: 'Content' as const,
+ },
+ {
+ key: 'install',
+ label: 'Install Command',
+ type: 'text' as const,
+ defaultValue: 'npm i @acme/csv-stream',
+ placeholder: 'npm i @acme/csv-stream',
+ required: false,
+ group: 'Content' as const,
+ },
+ {
+ ...commonFields.siteName,
+ defaultValue: 'github.com/acme/csv-stream',
+ placeholder: 'github.com/acme/csv-stream',
+ },
+ {
+ ...commonFields.bgColor,
+ defaultValue: '#0d1117',
+ },
+ {
+ ...commonFields.accentColor,
+ defaultValue: '#58a6ff',
+ },
+ {
+ ...commonFields.textColor,
+ defaultValue: '#e6edf3',
+ },
+ ],
+ defaults: {
+ title: '@acme/csv-stream',
+ description: 'A streaming CSV parser for Node',
+ registry: 'npm',
+ version: 'v2.4.1',
+ downloads: '182k weekly downloads',
+ license: 'MIT',
+ install: 'npm i @acme/csv-stream',
+ siteName: 'github.com/acme/csv-stream',
+ bgColor: '#0d1117',
+ accentColor: '#58a6ff',
+ textColor: '#e6edf3',
+ },
+ render: (params) => {
+ const rawTitle = String(params.title ?? '').trim() || '@acme/csv-stream';
+ const titleSize = autoFontSize(rawTitle, [
+ { maxLen: 18, size: 52 },
+ { maxLen: 28, size: 44 },
+ { maxLen: 38, size: 36 },
+ { maxLen: 50, size: 30 },
+ ]);
+ const title = truncate(rawTitle, 55);
+
+ const rawDescription = params.description !== undefined && params.description !== null
+ ? String(params.description).trim()
+ : 'A streaming CSV parser for Node';
+ const descriptionSize = autoFontSize(rawDescription, [
+ { maxLen: 40, size: 24 },
+ { maxLen: 70, size: 21 },
+ { maxLen: 100, size: 19 },
+ { maxLen: 140, size: 17 },
+ ]);
+ const description = rawDescription ? truncate(rawDescription, 140) : '';
+
+ const rawRegistry = params.registry !== undefined && params.registry !== null
+ ? String(params.registry).trim()
+ : 'npm';
+ const registry = rawRegistry ? truncate(rawRegistry, 30) : '';
+ const labelText = registry
+ ? registry.toLowerCase().endsWith('package')
+ ? registry
+ : `${registry} package`
+ : '';
+
+ const rawVersion = params.version !== undefined && params.version !== null
+ ? String(params.version).trim()
+ : 'v2.4.1';
+ const version = rawVersion ? truncate(rawVersion, 25) : '';
+
+ const rawDownloads = params.downloads !== undefined && params.downloads !== null
+ ? String(params.downloads).trim()
+ : '182k weekly downloads';
+ const downloads = rawDownloads ? truncate(rawDownloads, 35) : '';
+
+ const rawLicense = params.license !== undefined && params.license !== null
+ ? String(params.license).trim()
+ : 'MIT';
+ const license = rawLicense ? truncate(rawLicense, 30) : '';
+
+ const rawInstall = params.install !== undefined && params.install !== null
+ ? String(params.install).trim()
+ : 'npm i @acme/csv-stream';
+ const installCmd = rawInstall ? truncate(rawInstall, 70) : '';
+
+ const rawSiteName = params.siteName !== undefined && params.siteName !== null
+ ? String(params.siteName).trim()
+ : 'github.com/acme/csv-stream';
+ const siteName = rawSiteName ? truncate(rawSiteName, 50) : '';
+
+ const bgColor = String(params.bgColor || '#0d1117');
+ const accentColor = String(params.accentColor || '#58a6ff');
+ const textColor = String(params.textColor || '#e6edf3');
+
+ const lightBg = isLightColor(bgColor);
+ const accentInk = lightBg ? mixColors(accentColor, '#000000', 0.45) : accentColor;
+ const cardBg = mixColors(bgColor, textColor, lightBg ? 0.04 : 0.06);
+ const cardBorder = mixColors(bgColor, textColor, lightBg ? 0.12 : 0.16);
+
+ const statsItems = [
+ ...(downloads
+ ? [
+ {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ alignItems: 'center',
+ gap: '8px',
+ padding: '8px 18px',
+ borderRadius: '10px',
+ backgroundColor: cardBg,
+ border: `1px solid ${cardBorder}`,
+ },
+ children: [
+ downloadIcon(18, accentInk),
+ {
+ type: 'span',
+ props: {
+ style: {
+ fontSize: '17px',
+ fontWeight: 600,
+ color: textColor,
+ fontFamily: 'JetBrains Mono, monospace',
+ },
+ children: downloads,
+ },
+ },
+ ],
+ },
+ },
+ ]
+ : []),
+ ...(license
+ ? [
+ {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ alignItems: 'center',
+ gap: '8px',
+ padding: '8px 18px',
+ borderRadius: '10px',
+ backgroundColor: cardBg,
+ border: `1px solid ${cardBorder}`,
+ },
+ children: [
+ licenseIcon(18, accentInk),
+ {
+ type: 'span',
+ props: {
+ style: {
+ fontSize: '17px',
+ fontWeight: 600,
+ color: textColor,
+ fontFamily: 'JetBrains Mono, monospace',
+ },
+ children: license,
+ },
+ },
+ ],
+ },
+ },
+ ]
+ : []),
+ ];
+
+ return {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ flexDirection: 'column',
+ width: '1200px',
+ height: '630px',
+ boxSizing: 'border-box',
+ backgroundColor: bgColor,
+ fontFamily: 'Inter, sans-serif',
+ padding: '48px 64px',
+ },
+ children: [
+ // Header block
+ {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ flexDirection: 'column',
+ },
+ children: [
+ ...(labelText
+ ? [
+ {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ alignItems: 'center',
+ padding: '5px 14px',
+ borderRadius: '999px',
+ backgroundColor: mixColors(bgColor, accentInk, lightBg ? 0.08 : 0.16),
+ border: `1px solid ${accentInk}55`,
+ marginBottom: '16px',
+ alignSelf: 'flex-start',
+ },
+ children: {
+ type: 'span',
+ props: {
+ style: {
+ fontSize: '13px',
+ fontWeight: 700,
+ color: accentInk,
+ letterSpacing: '0.04em',
+ },
+ children: labelText,
+ },
+ },
+ },
+ },
+ ]
+ : []),
+ // Package name + version pill
+ {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ alignItems: 'center',
+ gap: '16px',
+ },
+ children: [
+ {
+ type: 'h1',
+ props: {
+ style: {
+ fontSize: `${titleSize}px`,
+ fontWeight: 700,
+ color: textColor,
+ lineHeight: 1.15,
+ margin: 0,
+ fontFamily: 'JetBrains Mono, monospace',
+ letterSpacing: '-0.02em',
+ },
+ children: title,
+ },
+ },
+ ...(version
+ ? [
+ {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ alignItems: 'center',
+ padding: '4px 14px',
+ borderRadius: '999px',
+ backgroundColor: mixColors(bgColor, accentInk, lightBg ? 0.08 : 0.16),
+ border: `1px solid ${mixColors(bgColor, accentInk, lightBg ? 0.25 : 0.35)}`,
+ flexShrink: 0,
+ },
+ children: {
+ type: 'span',
+ props: {
+ style: {
+ fontSize: '17px',
+ fontWeight: 600,
+ color: accentInk,
+ fontFamily: 'JetBrains Mono, monospace',
+ },
+ children: version,
+ },
+ },
+ },
+ },
+ ]
+ : []),
+ ],
+ },
+ },
+ // Pitch
+ ...(description
+ ? [
+ {
+ type: 'p',
+ props: {
+ style: {
+ fontSize: `${descriptionSize}px`,
+ color: lightBg ? mixColors(bgColor, textColor, 0.7) : `${textColor}aa`,
+ lineHeight: 1.4,
+ margin: 0,
+ marginTop: '12px',
+ maxWidth: '960px',
+ },
+ children: description,
+ },
+ },
+ ]
+ : []),
+ ],
+ },
+ },
+ // Stats row: downloads & license side by side
+ ...(statsItems.length > 0
+ ? [
+ {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ alignItems: 'center',
+ gap: '14px',
+ marginTop: '22px',
+ },
+ children: statsItems,
+ },
+ },
+ ]
+ : []),
+ // Install command box
+ ...(installCmd
+ ? [
+ {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ alignItems: 'center',
+ padding: '16px 22px',
+ borderRadius: '12px',
+ backgroundColor: cardBg,
+ border: `1px solid ${cardBorder}`,
+ marginTop: '22px',
+ alignSelf: 'flex-start',
+ maxWidth: '960px',
+ },
+ children: [
+ {
+ type: 'span',
+ props: {
+ style: {
+ fontSize: '18px',
+ fontWeight: 700,
+ color: accentInk,
+ marginRight: '12px',
+ fontFamily: 'JetBrains Mono, monospace',
+ flexShrink: 0,
+ },
+ children: '$',
+ },
+ },
+ {
+ type: 'span',
+ props: {
+ style: {
+ fontSize: '18px',
+ fontWeight: 500,
+ color: textColor,
+ fontFamily: 'JetBrains Mono, monospace',
+ whiteSpace: 'nowrap',
+ overflow: 'hidden',
+ textOverflow: 'ellipsis',
+ },
+ children: installCmd,
+ },
+ },
+ ],
+ },
+ },
+ ]
+ : []),
+ // Spacer
+ {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ flex: 1,
+ },
+ children: [],
+ },
+ },
+ // Site name at bottom
+ ...(siteName
+ ? [
+ {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ alignItems: 'center',
+ paddingTop: '16px',
+ borderTop: `1px solid ${lightBg ? mixColors(bgColor, textColor, 0.12) : `${textColor}15`}`,
+ },
+ children: [
+ {
+ type: 'span',
+ props: {
+ style: {
+ fontSize: '17px',
+ fontWeight: 500,
+ color: lightBg ? mixColors(bgColor, textColor, 0.6) : `${textColor}88`,
+ fontFamily: 'Inter, sans-serif',
+ },
+ children: siteName,
+ },
+ },
+ ],
+ },
+ },
+ ]
+ : []),
+ ],
+ },
+ };
+ },
+};
+
+export default template;
diff --git a/src/templates/github/security-advisory.ts b/src/templates/github/security-advisory.ts
new file mode 100644
index 0000000..96e111b
--- /dev/null
+++ b/src/templates/github/security-advisory.ts
@@ -0,0 +1,173 @@
+import type { TemplateDefinition } from '../types';
+import { autoFontSize, commonFields, contrastText, isLightColor, mixColors, truncate } from '../utils';
+
+const SEVERITIES = {
+ critical: { label: 'Critical', color: '#8250df' },
+ high: { label: 'High', color: '#d1242f' },
+ medium: { label: 'Medium', color: '#bf8700' },
+ low: { label: 'Low', color: '#1a7f37' },
+};
+
+const DEFAULT_TITLE = 'Path traversal in @acme/static-files';
+
+const template: TemplateDefinition = {
+ id: 'github-security-advisory',
+ name: 'Security Advisory',
+ category: 'github',
+ description: 'Security fix announcement with severity, advisory ID, affected package versions and site name',
+ tags: ['github', 'security', 'advisory', 'vulnerability', 'package'],
+ fields: [
+ { ...commonFields.title, defaultValue: DEFAULT_TITLE, placeholder: 'Security advisory title' },
+ {
+ key: 'severity',
+ label: 'Severity',
+ type: 'select',
+ defaultValue: 'high',
+ options: [
+ { label: 'Critical', value: 'critical' },
+ { label: 'High', value: 'high' },
+ { label: 'Medium', value: 'medium' },
+ { label: 'Low', value: 'low' },
+ ],
+ group: 'Content',
+ },
+ { key: 'advisoryId', label: 'Advisory ID', type: 'text', defaultValue: 'GHSA-3x9q-7m2v-p4hc', placeholder: 'GHSA-xxxx-xxxx-xxxx', required: false, group: 'Content' },
+ { key: 'packageName', label: 'Package Name', type: 'text', defaultValue: '@acme/static-files', placeholder: '@scope/package', required: false, group: 'Content' },
+ { key: 'affected', label: 'Affected Versions', type: 'text', defaultValue: '< 2.4.1', placeholder: '< 2.4.1', required: false, group: 'Content' },
+ { key: 'patched', label: 'Fixed Version', type: 'text', defaultValue: '2.4.1', placeholder: '2.4.1', required: false, group: 'Content' },
+ commonFields.siteName,
+ commonFields.bgColor,
+ commonFields.accentColor,
+ commonFields.textColor,
+ ],
+ defaults: {
+ title: DEFAULT_TITLE,
+ severity: 'high',
+ advisoryId: 'GHSA-3x9q-7m2v-p4hc',
+ packageName: '@acme/static-files',
+ affected: '< 2.4.1',
+ patched: '2.4.1',
+ siteName: 'Acme Security',
+ bgColor: '#0d1117',
+ accentColor: '#58a6ff',
+ textColor: '#e6edf3',
+ },
+ render: (params) => {
+ const rawTitle = String(params.title ?? '').trim() || DEFAULT_TITLE;
+ const title = truncate(rawTitle, 90);
+ const rawSeverity = String(params.severity ?? '').trim().toLowerCase();
+ const severity = Object.hasOwn(SEVERITIES, rawSeverity)
+ ? SEVERITIES[rawSeverity as keyof typeof SEVERITIES]
+ : SEVERITIES.high;
+ const advisoryId = truncate(String(params.advisoryId ?? '').trim(), 32);
+ const packageName = truncate(String(params.packageName ?? '').trim(), 48);
+ const affected = truncate(String(params.affected ?? '').trim(), 32);
+ const patched = truncate(String(params.patched ?? '').trim(), 32);
+ const siteName = truncate(String(params.siteName ?? '').trim(), 40);
+ const bgColor = String(params.bgColor || '#0d1117');
+ const accentColor = String(params.accentColor || '#58a6ff');
+ const textColor = String(params.textColor || '#e6edf3');
+ const accentTextColor = isLightColor(bgColor) ? mixColors(accentColor, '#000000', 0.45) : accentColor;
+ const details = [
+ packageName,
+ affected ? `affected ${affected}` : '',
+ patched ? `fixed in ${patched}` : '',
+ ].filter(Boolean).join(' · ');
+ const titleSize = autoFontSize(rawTitle, [
+ { maxLen: 28, size: 68 },
+ { maxLen: 48, size: 58 },
+ { maxLen: 68, size: 48 },
+ { maxLen: 90, size: 40 },
+ ]);
+
+ return {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ flexDirection: 'column',
+ justifyContent: 'space-between',
+ width: '1200px',
+ height: '630px',
+ padding: '64px 72px',
+ boxSizing: 'border-box',
+ backgroundColor: bgColor,
+ fontFamily: 'Inter, sans-serif',
+ },
+ children: [
+ {
+ type: 'div',
+ props: {
+ style: { display: 'flex', alignItems: 'center', gap: '18px' },
+ children: [
+ {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ alignItems: 'center',
+ padding: '10px 20px',
+ borderRadius: '999px',
+ backgroundColor: severity.color,
+ },
+ children: {
+ type: 'span',
+ props: {
+ style: { fontSize: '20px', fontWeight: 700, color: contrastText(severity.color) },
+ children: severity.label,
+ },
+ },
+ },
+ },
+ ...(advisoryId
+ ? [{
+ type: 'span',
+ props: {
+ style: { fontSize: '20px', fontWeight: 600, color: accentTextColor, fontFamily: 'JetBrains Mono, monospace' },
+ children: advisoryId,
+ },
+ }]
+ : []),
+ ],
+ },
+ },
+ {
+ type: 'div',
+ props: {
+ style: { display: 'flex', flexDirection: 'column', justifyContent: 'center', flex: 1, gap: '24px' },
+ children: [
+ {
+ type: 'h1',
+ props: {
+ style: { fontSize: `${titleSize}px`, lineHeight: 1.1, fontWeight: 700, color: textColor, margin: 0 },
+ children: title,
+ },
+ },
+ ...(details
+ ? [{
+ type: 'span',
+ props: {
+ style: { fontSize: '26px', fontWeight: 500, color: textColor },
+ children: details,
+ },
+ }]
+ : []),
+ ],
+ },
+ },
+ ...(siteName
+ ? [{
+ type: 'span',
+ props: {
+ style: { fontSize: '22px', fontWeight: 600, color: textColor },
+ children: siteName,
+ },
+ }]
+ : []),
+ ],
+ },
+ };
+ },
+};
+
+export default template;
diff --git a/src/templates/github/sponsors-goal.ts b/src/templates/github/sponsors-goal.ts
new file mode 100644
index 0000000..6820e88
--- /dev/null
+++ b/src/templates/github/sponsors-goal.ts
@@ -0,0 +1,313 @@
+import type { TemplateDefinition } from '../types';
+import { autoFontSize, commonFields, isLightColor, mixColors, normalizeHexColor, truncate } from '../utils';
+
+function parseRaised(value: unknown, fallback: number): number {
+ if (value === undefined || value === null) return fallback;
+ const raw = String(value).trim();
+ if (raw === '') return 0;
+ const n = Number(raw);
+ if (!Number.isFinite(n) || n < 0) return 0;
+ return Math.round(n);
+}
+
+function parseGoal(value: unknown, fallback: number): number {
+ if (value === undefined || value === null) return fallback;
+ const raw = String(value).trim();
+ if (raw === '') return 1;
+ const n = Number(raw);
+ if (!Number.isFinite(n) || n < 1) return 1;
+ return Math.round(n);
+}
+
+const template: TemplateDefinition = {
+ id: 'github-sponsors-goal',
+ name: 'Sponsors Goal',
+ category: 'github',
+ description: 'Maintainer or project sponsorship progress bar toward a monthly or recurring goal, with raised amounts, percentage, and sponsor count',
+ tags: ['github', 'sponsors', 'funding', 'goal', 'open-source'],
+ fields: [
+ commonFields.title,
+ commonFields.description,
+ {
+ key: 'raised',
+ label: 'Raised',
+ type: 'number' as const,
+ defaultValue: 420,
+ min: 0,
+ max: 10000,
+ group: 'Content' as const,
+ },
+ {
+ key: 'goal',
+ label: 'Goal',
+ type: 'number' as const,
+ defaultValue: 1000,
+ min: 0,
+ max: 10000,
+ group: 'Content' as const,
+ },
+ {
+ key: 'currency',
+ label: 'Currency',
+ type: 'text' as const,
+ defaultValue: '$',
+ placeholder: '$',
+ group: 'Content' as const,
+ },
+ {
+ key: 'period',
+ label: 'Period',
+ type: 'text' as const,
+ defaultValue: 'a month',
+ placeholder: 'a month',
+ group: 'Content' as const,
+ },
+ {
+ key: 'sponsors',
+ label: 'Sponsors',
+ type: 'text' as const,
+ defaultValue: '37 sponsors',
+ placeholder: '37 sponsors',
+ group: 'Content' as const,
+ },
+ commonFields.siteName,
+ commonFields.bgColor,
+ commonFields.accentColor,
+ commonFields.textColor,
+ ],
+ defaults: {
+ title: 'codercops/toolbelt',
+ description: 'Free developer tools that run in your browser',
+ raised: 420,
+ goal: 1000,
+ currency: '$',
+ period: 'a month',
+ sponsors: '37 sponsors',
+ siteName: 'github.com/sponsors/codercops',
+ bgColor: '#0d1117',
+ accentColor: '#ea4aaa',
+ textColor: '#e6edf3',
+ },
+ render: (params) => {
+ const bgColor = String(params.bgColor || '#0d1117');
+ const accentColor = String(params.accentColor || '#ea4aaa');
+ const textColor = String(params.textColor || '#e6edf3');
+ const normTextColor = normalizeHexColor(textColor, '#e6edf3');
+
+ const accentInk = isLightColor(bgColor) ? mixColors(accentColor, '#000000', 0.45) : accentColor;
+
+ const rawTitle = String(params.title ?? '').trim() || 'codercops/toolbelt';
+ const titleSize = autoFontSize(rawTitle, [
+ { maxLen: 16, size: 76 },
+ { maxLen: 28, size: 64 },
+ { maxLen: 42, size: 52 },
+ { maxLen: 60, size: 40 },
+ ]);
+ const title = truncate(rawTitle, 60);
+
+ const rawDescription = String(params.description ?? '').trim();
+ const descriptionSize = autoFontSize(rawDescription, [
+ { maxLen: 40, size: 28 },
+ { maxLen: 70, size: 24 },
+ { maxLen: 100, size: 20 },
+ ]);
+ const description = truncate(rawDescription, 100);
+
+ const raised = parseRaised(params.raised, 420);
+ const goal = parseGoal(params.goal, 1000);
+ const percent = Math.round((raised / goal) * 100);
+ const barWidthPercent = Math.min(100, Math.max(0, percent));
+
+ const currency = String(params.currency ?? '$').trim();
+ const formattedRaised = `${currency}${raised.toLocaleString('en-US')}`;
+ const formattedGoal = `${currency}${goal.toLocaleString('en-US')}`;
+
+ const rawPeriod = String(params.period ?? '').trim();
+ const period = truncate(rawPeriod, 24);
+ const progressText = period ? `${formattedRaised} of ${formattedGoal} ${period}` : `${formattedRaised} of ${formattedGoal}`;
+
+ const sponsors = truncate(String(params.sponsors ?? '').trim(), 32);
+ const siteName = truncate(String(params.siteName ?? '').trim(), 40);
+
+ return {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ flexDirection: 'column',
+ alignItems: 'center',
+ justifyContent: 'center',
+ width: '1200px',
+ height: '630px',
+ boxSizing: 'border-box',
+ backgroundColor: bgColor,
+ fontFamily: 'Inter, sans-serif',
+ padding: '64px',
+ },
+ children: [
+ {
+ type: 'span',
+ props: {
+ style: {
+ fontSize: '16px',
+ fontWeight: 700,
+ color: accentInk,
+ letterSpacing: '0.14em',
+ textTransform: 'uppercase' as const,
+ marginBottom: '16px',
+ },
+ children: 'Sponsor',
+ },
+ },
+ {
+ type: 'h1',
+ props: {
+ style: {
+ fontSize: `${titleSize}px`,
+ fontWeight: 800,
+ color: textColor,
+ lineHeight: 1.1,
+ letterSpacing: '-0.02em',
+ textAlign: 'center' as const,
+ margin: 0,
+ marginBottom: description ? '14px' : '48px',
+ },
+ children: title,
+ },
+ },
+ ...(description
+ ? [
+ {
+ type: 'p',
+ props: {
+ style: {
+ fontSize: `${descriptionSize}px`,
+ fontWeight: 400,
+ color: `${normTextColor}AA`,
+ lineHeight: 1.4,
+ textAlign: 'center' as const,
+ margin: 0,
+ marginBottom: '48px',
+ maxWidth: '880px',
+ },
+ children: description,
+ },
+ },
+ ]
+ : []),
+ {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ width: '880px',
+ height: '24px',
+ borderRadius: '100px',
+ backgroundColor: `${normTextColor}1A`,
+ overflow: 'hidden',
+ },
+ children: {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ width: `${barWidthPercent}%`,
+ height: '100%',
+ borderRadius: '100px',
+ backgroundColor: accentColor,
+ },
+ },
+ },
+ },
+ },
+ {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ alignItems: 'baseline',
+ justifyContent: 'space-between',
+ width: '880px',
+ marginTop: '18px',
+ },
+ children: [
+ {
+ type: 'span',
+ props: {
+ style: {
+ fontSize: '24px',
+ fontWeight: 600,
+ color: `${normTextColor}DD`,
+ },
+ children: progressText,
+ },
+ },
+ {
+ type: 'span',
+ props: {
+ style: {
+ fontSize: '32px',
+ fontWeight: 800,
+ color: accentInk,
+ },
+ children: `${percent}%`,
+ },
+ },
+ ],
+ },
+ },
+ ...((sponsors || siteName)
+ ? [
+ {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: sponsors ? 'space-between' : 'flex-end',
+ width: '880px',
+ marginTop: '36px',
+ },
+ children: [
+ ...(sponsors
+ ? [
+ {
+ type: 'span',
+ props: {
+ style: {
+ fontSize: '18px',
+ fontWeight: 500,
+ color: `${normTextColor}88`,
+ },
+ children: sponsors,
+ },
+ },
+ ]
+ : []),
+ ...(siteName
+ ? [
+ {
+ type: 'span',
+ props: {
+ style: {
+ fontSize: '18px',
+ fontWeight: 500,
+ color: `${normTextColor}66`,
+ },
+ children: siteName,
+ },
+ },
+ ]
+ : []),
+ ],
+ },
+ },
+ ]
+ : []),
+ ],
+ },
+ };
+ },
+};
+
+export default template;
diff --git a/src/templates/job/benefits.ts b/src/templates/job/benefits.ts
new file mode 100644
index 0000000..edeaa2a
--- /dev/null
+++ b/src/templates/job/benefits.ts
@@ -0,0 +1,190 @@
+import type { TemplateDefinition } from '../types';
+import { autoFontSize, commonFields, isLightColor, mixColors, truncate } from '../utils';
+
+const template: TemplateDefinition = {
+ id: 'job-benefits',
+ name: 'Benefits',
+ category: 'job',
+ description: 'A hiring card that highlights the reasons people love working at a company',
+ tags: ['job', 'hiring', 'benefits', 'perks', 'culture'],
+ fields: [
+ { key: 'company', label: 'Company', type: 'text', defaultValue: 'Northstar', placeholder: 'Company name', group: 'Content' },
+ commonFields.title,
+ { key: 'perk1', label: 'Perk 1', type: 'text', defaultValue: 'Remote first', placeholder: 'A company benefit', group: 'Content' },
+ { key: 'perk2', label: 'Perk 2', type: 'text', defaultValue: '$1,500 learning budget', placeholder: 'A company benefit', group: 'Content' },
+ { key: 'perk3', label: 'Perk 3', type: 'text', defaultValue: '4-day weeks in August', placeholder: 'A company benefit', group: 'Content' },
+ { key: 'perk4', label: 'Perk 4', type: 'text', defaultValue: 'Health cover for your family', placeholder: 'A company benefit', group: 'Content' },
+ commonFields.bgColor,
+ commonFields.accentColor,
+ commonFields.textColor,
+ ],
+ defaults: {
+ company: 'Northstar',
+ title: 'Why people stay',
+ perk1: 'Remote first',
+ perk2: '$1,500 learning budget',
+ perk3: '4-day weeks in August',
+ perk4: 'Health cover for your family',
+ bgColor: '#0a0a0a',
+ accentColor: '#E07A5F',
+ textColor: '#FFFFFF',
+ },
+ render: (params) => {
+ const company = truncate(String(params.company || '').trim(), 40);
+ const title = truncate(String(params.title || 'Why people stay').trim(), 64);
+ const titleSize = autoFontSize(title, [
+ { maxLen: 24, size: 72 },
+ { maxLen: 40, size: 60 },
+ { maxLen: 64, size: 48 },
+ ]);
+ const perks = [params.perk1, params.perk2, params.perk3, params.perk4]
+ .map((perk) => truncate(String(perk || '').trim(), 72))
+ .filter(Boolean);
+ const bgColor = String(params.bgColor || '#0a0a0a');
+ const accentColor = String(params.accentColor || '#E07A5F');
+ const textColor = String(params.textColor || '#FFFFFF');
+ const accentInk = isLightColor(bgColor) ? mixColors(accentColor, '#000000', 0.45) : accentColor;
+
+ const rows = [];
+ for (let index = 0; index < perks.length; index += 2) {
+ rows.push({
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ alignItems: 'stretch',
+ gap: '20px',
+ },
+ children: perks.slice(index, index + 2).map((perk) => ({
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ alignItems: 'center',
+ width: '48%',
+ boxSizing: 'border-box',
+ padding: '22px 24px',
+ borderRadius: '18px',
+ border: `1px solid ${textColor}1A`,
+ backgroundColor: `${textColor}08`,
+ gap: '18px',
+ },
+ children: [
+ {
+ type: 'svg',
+ props: {
+ width: 34,
+ height: 34,
+ viewBox: '0 0 34 34',
+ style: { flexShrink: 0 },
+ children: [
+ {
+ type: 'circle',
+ props: {
+ cx: 17,
+ cy: 17,
+ r: 15,
+ fill: 'none',
+ stroke: accentInk,
+ strokeWidth: 2,
+ },
+ },
+ {
+ type: 'path',
+ props: {
+ d: 'm10 17 4.5 4.5L24 12',
+ fill: 'none',
+ stroke: accentInk,
+ strokeWidth: 2.5,
+ strokeLinecap: 'round',
+ strokeLinejoin: 'round',
+ },
+ },
+ ],
+ },
+ },
+ {
+ type: 'span',
+ props: {
+ style: {
+ flex: 1,
+ minWidth: 0,
+ fontSize: '23px',
+ fontWeight: 600,
+ color: textColor,
+ lineHeight: 1.3,
+ overflowWrap: 'anywhere',
+ },
+ children: perk,
+ },
+ },
+ ],
+ },
+ })),
+ },
+ });
+ }
+
+ return {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ flexDirection: 'column',
+ width: '1200px',
+ height: '630px',
+ boxSizing: 'border-box',
+ backgroundColor: bgColor,
+ fontFamily: 'Inter, sans-serif',
+ padding: '64px',
+ },
+ children: [
+ ...(company
+ ? [{
+ type: 'span',
+ props: {
+ style: {
+ fontSize: '20px',
+ fontWeight: 700,
+ color: accentInk,
+ letterSpacing: '0.04em',
+ marginBottom: '20px',
+ },
+ children: company,
+ },
+ }]
+ : []),
+ {
+ type: 'h1',
+ props: {
+ style: {
+ fontSize: `${titleSize}px`,
+ fontWeight: 700,
+ color: textColor,
+ lineHeight: 1.1,
+ margin: 0,
+ marginBottom: '42px',
+ },
+ children: title,
+ },
+ },
+ ...(rows.length
+ ? [{
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ flexDirection: 'column',
+ gap: '18px',
+ },
+ children: rows,
+ },
+ }]
+ : []),
+ ],
+ },
+ };
+ },
+};
+
+export default template;
diff --git a/src/templates/job/company-culture.ts b/src/templates/job/company-culture.ts
index 1c2b95d..a4c80b6 100644
--- a/src/templates/job/company-culture.ts
+++ b/src/templates/job/company-culture.ts
@@ -35,10 +35,10 @@ const template: TemplateDefinition = {
const title = truncate(String(params.title || 'Product Designer'), 50);
const company = String(params.company || '');
const values = [
- String(params.value1 || ''),
- String(params.value2 || ''),
- String(params.value3 || ''),
- String(params.value4 || ''),
+ truncate(String(params.value1 || ''), 40),
+ truncate(String(params.value2 || ''), 40),
+ truncate(String(params.value3 || ''), 40),
+ truncate(String(params.value4 || ''), 40),
].filter(Boolean);
const teamSize = String(params.teamSize || '');
const bgColor = String(params.bgColor || '#0a0a0a');
@@ -152,6 +152,7 @@ const template: TemplateDefinition = {
props: {
style: {
display: 'flex',
+ flexShrink: 0,
padding: '10px 24px',
border: `2px solid ${accentColor}55`,
borderRadius: '100px',
diff --git a/src/templates/job/index.ts b/src/templates/job/index.ts
index e9d75fc..2d7de53 100644
--- a/src/templates/job/index.ts
+++ b/src/templates/job/index.ts
@@ -7,3 +7,8 @@ export { default as jobTeamPhoto } from './team-photo';
export { default as jobStartupHiring } from './startup-hiring';
export { default as jobDeveloperRole } from './developer-role';
export { default as jobOpenRoles } from './open-roles';
+export { default as jobInterviewProcess } from './interview-process';
+export { default as jobBenefits } from './benefits';
+export { default as jobInternship } from './internship';
+export { default as jobReferral } from './referral';
+export { default as jobNewHire } from './new-hire';
diff --git a/src/templates/job/internship.ts b/src/templates/job/internship.ts
new file mode 100644
index 0000000..efa9ca0
--- /dev/null
+++ b/src/templates/job/internship.ts
@@ -0,0 +1,192 @@
+import type { TemplateDefinition } from '../types';
+import { autoFontSize, commonFields, contrastText, isLightColor, mixColors, truncate } from '../utils';
+
+const template: TemplateDefinition = {
+ id: 'job-internship',
+ name: 'Internship',
+ category: 'job',
+ description: 'An internship opening with an accent pill, the role, duration, start and stipend pills, and an application deadline',
+ tags: ['job', 'internship', 'hiring', 'students', 'deadline'],
+ fields: [
+ { key: 'company', label: 'Company', type: 'text', defaultValue: 'Acme Labs', placeholder: 'Company name', required: true, group: 'Content' },
+ commonFields.title,
+ { key: 'duration', label: 'Duration', type: 'text', defaultValue: '12 weeks', placeholder: '12 weeks', group: 'Content' },
+ { key: 'start', label: 'Start', type: 'text', defaultValue: 'Starts May 2027', placeholder: 'Starts May 2027', group: 'Content' },
+ { key: 'stipend', label: 'Stipend', type: 'text', defaultValue: 'Paid', placeholder: 'Paid', group: 'Content' },
+ { key: 'deadline', label: 'Deadline', type: 'text', defaultValue: 'Apply by 15 November', placeholder: 'Apply by 15 November', group: 'Content' },
+ commonFields.bgColor,
+ commonFields.accentColor,
+ commonFields.textColor,
+ ],
+ defaults: {
+ company: 'Acme Labs',
+ title: 'Frontend Engineering Intern',
+ duration: '12 weeks',
+ start: 'Starts May 2027',
+ stipend: 'Paid',
+ deadline: 'Apply by 15 November',
+ bgColor: '#0a0a0a',
+ accentColor: '#E07A5F',
+ textColor: '#FFFFFF',
+ },
+ render: (params) => {
+ const company = truncate(String(params.company || 'Acme Labs').trim(), 40);
+ const rawRole = String(params.title || 'Frontend Engineering Intern').trim() || 'Frontend Engineering Intern';
+ const roleSize = autoFontSize(rawRole, [
+ { maxLen: 26, size: 56 },
+ { maxLen: 44, size: 46 },
+ { maxLen: 70, size: 38 },
+ ]);
+ const role = truncate(rawRole, 70);
+ const pills = [params.duration, params.start, params.stipend]
+ .map((value) => truncate(String(value || '').trim(), 32))
+ .filter(Boolean);
+ const deadline = truncate(String(params.deadline || '').trim(), 48);
+ const bgColor = String(params.bgColor || '#0a0a0a');
+ const accentColor = String(params.accentColor || '#E07A5F');
+ const textColor = String(params.textColor || '#FFFFFF');
+ // The pill keeps the accent as its background, so its label follows the accent's contrast.
+ // The deadline is drawn in the accent on the page background, so it is darkened when that is light.
+ const accentInk = isLightColor(bgColor) ? mixColors(accentColor, '#000000', 0.45) : accentColor;
+
+ const pill = (text: string) => ({
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ padding: '8px 18px',
+ backgroundColor: `${textColor}0F`,
+ borderRadius: '100px',
+ border: `1px solid ${textColor}1A`,
+ },
+ children: {
+ type: 'span',
+ props: {
+ style: {
+ fontSize: '17px',
+ fontWeight: 500,
+ color: `${textColor}CC`,
+ },
+ children: text,
+ },
+ },
+ },
+ });
+
+ return {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ flexDirection: 'column',
+ justifyContent: 'center',
+ width: '1200px',
+ height: '630px',
+ boxSizing: 'border-box',
+ backgroundColor: bgColor,
+ fontFamily: 'Inter, sans-serif',
+ padding: '64px 80px',
+ },
+ children: [
+ // Internship pill next to the company name
+ {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ alignItems: 'center',
+ gap: '16px',
+ },
+ children: [
+ {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ padding: '8px 20px',
+ backgroundColor: accentColor,
+ borderRadius: '100px',
+ },
+ children: {
+ type: 'span',
+ props: {
+ style: {
+ fontSize: '15px',
+ fontWeight: 800,
+ color: contrastText(accentColor),
+ letterSpacing: '0.1em',
+ textTransform: 'uppercase' as const,
+ },
+ children: 'Internship',
+ },
+ },
+ },
+ },
+ ...(company
+ ? [{
+ type: 'span',
+ props: {
+ style: {
+ fontSize: '22px',
+ fontWeight: 600,
+ color: textColor,
+ },
+ children: company,
+ },
+ }]
+ : []),
+ ],
+ },
+ },
+ // Role
+ {
+ type: 'h1',
+ props: {
+ style: {
+ fontSize: `${roleSize}px`,
+ fontWeight: 700,
+ color: textColor,
+ lineHeight: 1.15,
+ margin: 0,
+ marginTop: '26px',
+ },
+ children: role,
+ },
+ },
+ // Duration, start and stipend pills
+ ...(pills.length
+ ? [{
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ flexWrap: 'wrap',
+ gap: '12px',
+ marginTop: '34px',
+ },
+ children: pills.map(pill),
+ },
+ }]
+ : []),
+ // Deadline
+ ...(deadline
+ ? [{
+ type: 'span',
+ props: {
+ style: {
+ fontSize: '20px',
+ fontWeight: 600,
+ color: accentInk,
+ marginTop: '48px',
+ },
+ children: deadline,
+ },
+ }]
+ : []),
+ ],
+ },
+ };
+ },
+};
+
+export default template;
diff --git a/src/templates/job/interview-process.ts b/src/templates/job/interview-process.ts
new file mode 100644
index 0000000..dc964c3
--- /dev/null
+++ b/src/templates/job/interview-process.ts
@@ -0,0 +1,255 @@
+import type { TemplateDefinition } from '../types';
+import { truncate, autoFontSize, contrastText, isLightColor, mixColors, commonFields } from '../utils';
+
+const template: TemplateDefinition = {
+ id: 'job-interview-process',
+ name: 'Interview Process',
+ category: 'job',
+ description: 'Hiring process overview with the company name, a headline, four numbered interview steps connected by a timeline, and an optional duration',
+ tags: ['job', 'interview', 'hiring', 'process', 'recruitment'],
+ fields: [
+ { key: 'company', label: 'Company', type: 'text' as const, defaultValue: 'Acme Corp', placeholder: 'Company name', required: true, group: 'Content' as const },
+ commonFields.title,
+ { key: 'step1', label: 'Step 1', type: 'text' as const, defaultValue: 'Application Review', placeholder: 'Step 1', group: 'Content' as const },
+ { key: 'step2', label: 'Step 2', type: 'text' as const, defaultValue: 'Recruiter Screen', placeholder: 'Step 2', group: 'Content' as const },
+ { key: 'step3', label: 'Step 3', type: 'text' as const, defaultValue: 'Team Interview', placeholder: 'Step 3', group: 'Content' as const },
+ { key: 'step4', label: 'Step 4', type: 'text' as const, defaultValue: 'Offer & Onboarding', placeholder: 'Step 4', group: 'Content' as const },
+ { key: 'duration', label: 'Duration', type: 'text' as const, defaultValue: '3-4 weeks', placeholder: '3-4 weeks', group: 'Content' as const },
+ commonFields.bgColor,
+ commonFields.accentColor,
+ commonFields.textColor,
+ ],
+ defaults: {
+ company: 'Acme Corp',
+ title: 'How We Hire',
+ step1: 'Application Review',
+ step2: 'Recruiter Screen',
+ step3: 'Team Interview',
+ step4: 'Offer & Onboarding',
+ duration: '3-4 weeks',
+ bgColor: '#0a0a0a',
+ accentColor: '#E07A5F',
+ textColor: '#FFFFFF',
+ },
+ render: (params) => {
+ const bgColor = String(params.bgColor || '#0a0a0a');
+ const accentColor = String(params.accentColor || '#E07A5F');
+ const textColor = String(params.textColor || '#FFFFFF');
+ const company = truncate(String(params.company ?? '').trim(), 32);
+ const title = truncate(String(params.title ?? '').trim() || 'How We Hire', 60);
+ const duration = truncate(String(params.duration ?? '').trim(), 40);
+ const steps = [
+ String(params.step1 ?? ''),
+ String(params.step2 ?? ''),
+ String(params.step3 ?? ''),
+ String(params.step4 ?? ''),
+ ]
+ .map((step) => truncate(step.trim(), 60))
+ .filter(Boolean);
+
+ // The accent as text drops below a readable contrast on a light background.
+ const accentInk = isLightColor(bgColor) ? mixColors(accentColor, '#000000', 0.45) : accentColor;
+ const onAccent = contrastText(accentColor);
+ const titleSize = autoFontSize(title, [
+ { maxLen: 20, size: 54 },
+ { maxLen: 34, size: 46 },
+ { maxLen: 48, size: 40 },
+ { maxLen: 60, size: 34 },
+ ]);
+
+ return {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ flexDirection: 'column',
+ width: '1200px',
+ height: '630px',
+ backgroundColor: bgColor,
+ fontFamily: 'Inter, sans-serif',
+ padding: '64px',
+ },
+ children: [
+ ...(company
+ ? [
+ {
+ type: 'span',
+ props: {
+ style: {
+ fontSize: '22px',
+ fontWeight: 700,
+ color: accentInk,
+ letterSpacing: '0.04em',
+ marginBottom: '18px',
+ },
+ children: company,
+ },
+ },
+ ]
+ : []),
+ {
+ type: 'h1',
+ props: {
+ style: {
+ fontSize: `${titleSize}px`,
+ fontWeight: 800,
+ color: textColor,
+ letterSpacing: '-0.02em',
+ lineHeight: 1.15,
+ margin: 0,
+ marginBottom: '56px',
+ },
+ children: title,
+ },
+ },
+ ...(steps.length > 0
+ ? [
+ {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ alignItems: 'flex-start',
+ marginTop: 'auto',
+ marginBottom: 'auto',
+ width: '100%',
+ },
+ children: steps.map((step, i) => ({
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ flex: 1,
+ flexDirection: 'column',
+ alignItems: 'center',
+ },
+ children: [
+ {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ alignItems: 'center',
+ width: '100%',
+ },
+ children: [
+ {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ flex: 1,
+ height: '2px',
+ backgroundColor: i === 0 ? 'transparent' : `${textColor}33`,
+ },
+ },
+ },
+ {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ width: '64px',
+ height: '64px',
+ borderRadius: '50%',
+ backgroundColor: accentColor,
+ },
+ children: {
+ type: 'span',
+ props: {
+ style: {
+ fontSize: '24px',
+ fontWeight: 800,
+ color: onAccent,
+ },
+ children: `${i + 1}`,
+ },
+ },
+ },
+ },
+ {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ flex: 1,
+ height: '2px',
+ backgroundColor: i === steps.length - 1 ? 'transparent' : `${textColor}33`,
+ },
+ },
+ },
+ ],
+ },
+ },
+ {
+ type: 'span',
+ props: {
+ style: {
+ padding: '0 16px',
+ marginTop: '24px',
+ fontSize: '20px',
+ fontWeight: 600,
+ color: `${textColor}DD`,
+ lineHeight: 1.35,
+ textAlign: 'center' as const,
+ wordBreak: 'break-word' as const,
+ },
+ children: step,
+ },
+ },
+ ],
+ },
+ })),
+ },
+ },
+ ]
+ : []),
+ ...(duration
+ ? [
+ {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ justifyContent: 'center',
+ width: '100%',
+ marginTop: 'auto',
+ paddingTop: '32px',
+ },
+ children: {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ alignItems: 'center',
+ padding: '10px 24px',
+ borderRadius: '100px',
+ backgroundColor: `${accentColor}1A`,
+ border: `1px solid ${accentColor}55`,
+ },
+ children: {
+ type: 'span',
+ props: {
+ style: {
+ fontSize: '17px',
+ fontWeight: 600,
+ color: accentInk,
+ },
+ children: `Duration · ${duration}`,
+ },
+ },
+ },
+ },
+ },
+ },
+ ]
+ : []),
+ ],
+ },
+ };
+ },
+};
+
+export default template;
diff --git a/src/templates/job/new-hire.ts b/src/templates/job/new-hire.ts
new file mode 100644
index 0000000..6e5f1e1
--- /dev/null
+++ b/src/templates/job/new-hire.ts
@@ -0,0 +1,269 @@
+import type { TemplateDefinition } from '../types';
+import { autoFontSize, commonFields, contrastText, isLightColor, mixColors, truncate } from '../utils';
+
+// An omitted field falls back to its default, but a blank one is hidden.
+function optionalText(value: unknown, fallback: string, maxLength: number): string {
+ return truncate((value === undefined ? fallback : String(value)).trim(), maxLength);
+}
+
+// The first letter or digit of the first and of the last word ("Priya Sharma" -> "PS",
+// one letter for a one-word name). Leading punctuation, "@" and emoji are skipped, and
+// a character is taken by code point so an astral letter is never cut in half.
+function initialsOf(name: string): string {
+ const letters = name
+ .split(/\s+/)
+ .map((word) => Array.from(word).find((ch) => /[\p{L}\p{N}]/u.test(ch)) ?? '')
+ .filter(Boolean);
+ if (letters.length === 0) return '';
+ const first = letters[0];
+ return (letters.length === 1 ? first : first + letters[letters.length - 1]).toUpperCase();
+}
+
+const template: TemplateDefinition = {
+ id: 'job-new-hire',
+ name: 'New Hire',
+ category: 'job',
+ description: 'Welcome a new teammate with an initials avatar, their name and role, the team, the start date and a short note',
+ tags: ['job', 'new hire', 'welcome', 'onboarding', 'team'],
+ fields: [
+ { ...commonFields.title, label: 'Name', defaultValue: 'Priya Sharma', placeholder: 'Priya Sharma' },
+ { key: 'role', label: 'Role', type: 'text', defaultValue: 'Senior Backend Engineer', placeholder: 'Senior Backend Engineer', group: 'Content' },
+ { key: 'team', label: 'Team', type: 'text', defaultValue: 'Platform team', placeholder: 'Platform team', group: 'Content' },
+ { key: 'startDate', label: 'Start Date', type: 'text', defaultValue: 'Starts Nov 3', placeholder: 'Starts Nov 3', group: 'Content' },
+ { ...commonFields.description, label: 'Note', type: 'text', defaultValue: 'Excited to help build the payments stack', placeholder: 'Excited to help build the payments stack' },
+ commonFields.siteName,
+ commonFields.bgColor,
+ commonFields.accentColor,
+ commonFields.textColor,
+ ],
+ defaults: {
+ title: 'Priya Sharma',
+ role: 'Senior Backend Engineer',
+ team: 'Platform team',
+ startDate: 'Starts Nov 3',
+ description: 'Excited to help build the payments stack',
+ siteName: 'acme.com',
+ bgColor: '#0a0a0a',
+ accentColor: '#E07A5F',
+ textColor: '#FFFFFF',
+ },
+ render: (params) => {
+ const rawName = String(params.title || 'Priya Sharma').trim() || 'Priya Sharma';
+ const nameSize = autoFontSize(rawName, [
+ { maxLen: 18, size: 64 },
+ { maxLen: 26, size: 54 },
+ { maxLen: 36, size: 46 },
+ { maxLen: 48, size: 38 },
+ ]);
+ const name = truncate(rawName, 48);
+ const initials = initialsOf(rawName);
+ const rawRole = optionalText(params.role, 'Senior Backend Engineer', 200);
+ const roleSize = autoFontSize(rawRole, [
+ { maxLen: 30, size: 30 },
+ { maxLen: 46, size: 26 },
+ { maxLen: 60, size: 22 },
+ ]);
+ const role = truncate(rawRole, 60);
+ const team = optionalText(params.team, 'Platform team', 32);
+ const startDate = optionalText(params.startDate, 'Starts Nov 3', 32);
+ const rawNote = optionalText(params.description, 'Excited to help build the payments stack', 400);
+ const noteSize = autoFontSize(rawNote, [
+ { maxLen: 60, size: 28 },
+ { maxLen: 90, size: 24 },
+ { maxLen: 120, size: 22 },
+ ]);
+ const note = truncate(rawNote, 120);
+ const siteName = optionalText(params.siteName, 'acme.com', 50);
+ const bgColor = String(params.bgColor || '#0a0a0a');
+ const accentColor = String(params.accentColor || '#E07A5F');
+ const textColor = String(params.textColor || '#FFFFFF');
+ // The label, the circle and the start date are drawn in the accent on the page
+ // background, so the accent is darkened when that background is light.
+ const accentInk = isLightColor(bgColor) ? mixColors(accentColor, '#000000', 0.45) : accentColor;
+
+ const details = [
+ ...(team
+ ? [{
+ type: 'span',
+ props: { style: { fontSize: '26px', fontWeight: 600, color: textColor }, children: team },
+ }]
+ : []),
+ ...(team && startDate
+ ? [{
+ type: 'span',
+ props: { style: { fontSize: '26px', fontWeight: 600, color: `${textColor}66` }, children: '·' },
+ }]
+ : []),
+ ...(startDate
+ ? [{
+ type: 'span',
+ props: { style: { fontSize: '26px', fontWeight: 600, color: accentInk }, children: startDate },
+ }]
+ : []),
+ ];
+
+ return {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ flexDirection: 'column',
+ width: '1200px',
+ height: '630px',
+ boxSizing: 'border-box',
+ backgroundColor: bgColor,
+ fontFamily: 'Inter, sans-serif',
+ padding: '64px 80px',
+ },
+ children: [
+ // Label, initials circle, name and role
+ {
+ type: 'div',
+ props: {
+ style: { display: 'flex', flexDirection: 'column' },
+ children: [
+ {
+ type: 'span',
+ props: {
+ style: {
+ fontSize: '20px',
+ fontWeight: 800,
+ color: accentInk,
+ letterSpacing: '0.14em',
+ textTransform: 'uppercase' as const,
+ },
+ children: 'Welcome to the team',
+ },
+ },
+ {
+ type: 'div',
+ props: {
+ style: { display: 'flex', alignItems: 'center', marginTop: '28px' },
+ children: [
+ {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ flexShrink: 0,
+ width: '128px',
+ height: '128px',
+ borderRadius: '64px',
+ backgroundColor: accentInk,
+ marginRight: '36px',
+ },
+ children: {
+ type: 'span',
+ props: {
+ style: {
+ fontSize: '52px',
+ fontWeight: 700,
+ color: contrastText(accentInk),
+ },
+ children: initials,
+ },
+ },
+ },
+ },
+ {
+ type: 'div',
+ props: {
+ style: { display: 'flex', flexDirection: 'column', flexGrow: 1, flexShrink: 1, minWidth: 0 },
+ children: [
+ {
+ type: 'h1',
+ props: {
+ style: {
+ fontSize: `${nameSize}px`,
+ fontWeight: 700,
+ color: textColor,
+ lineHeight: 1.1,
+ margin: 0,
+ },
+ children: name,
+ },
+ },
+ ...(role
+ ? [{
+ type: 'span',
+ props: {
+ style: {
+ fontSize: `${roleSize}px`,
+ fontWeight: 500,
+ color: `${textColor}CC`,
+ marginTop: '10px',
+ },
+ children: role,
+ },
+ }]
+ : []),
+ ],
+ },
+ },
+ ],
+ },
+ },
+ ],
+ },
+ },
+ // Team, start date and note
+ {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ flexDirection: 'column',
+ flexGrow: 1,
+ justifyContent: 'center',
+ },
+ children: [
+ ...(details.length
+ ? [{
+ type: 'div',
+ props: {
+ style: { display: 'flex', alignItems: 'center', columnGap: '16px' },
+ children: details,
+ },
+ }]
+ : []),
+ ...(note
+ ? [{
+ type: 'span',
+ props: {
+ style: {
+ fontSize: `${noteSize}px`,
+ fontWeight: 400,
+ color: `${textColor}99`,
+ marginTop: details.length ? '22px' : '0px',
+ },
+ children: note,
+ },
+ }]
+ : []),
+ ],
+ },
+ },
+ // Site name
+ ...(siteName
+ ? [{
+ type: 'span',
+ props: {
+ style: {
+ fontSize: '18px',
+ fontWeight: 600,
+ color: `${textColor}80`,
+ letterSpacing: '0.06em',
+ },
+ children: siteName,
+ },
+ }]
+ : []),
+ ],
+ },
+ };
+ },
+};
+
+export default template;
diff --git a/src/templates/job/referral.ts b/src/templates/job/referral.ts
new file mode 100644
index 0000000..72399bf
--- /dev/null
+++ b/src/templates/job/referral.ts
@@ -0,0 +1,218 @@
+import type { TemplateDefinition } from '../types';
+import { autoFontSize, commonFields, isLightColor, mixColors, truncate } from '../utils';
+
+// An omitted field falls back to its default, but a blank one is hidden.
+function optionalText(value: unknown, fallback: string, maxLength: number): string {
+ return truncate((value === undefined ? fallback : String(value)).trim(), maxLength);
+}
+
+const template: TemplateDefinition = {
+ id: 'job-referral',
+ name: 'Referral Bonus',
+ category: 'job',
+ description: 'Ask people to refer a friend for an open role, with the role, the company, a large referral bonus and how to refer',
+ tags: ['job', 'referral', 'hiring', 'bonus', 'recruiting'],
+ fields: [
+ commonFields.title,
+ { key: 'company', label: 'Company', type: 'text', defaultValue: 'Acme', placeholder: 'Company name', group: 'Content' },
+ { key: 'bonus', label: 'Referral Bonus', type: 'text', defaultValue: '$2,000', placeholder: '$2,000', group: 'Content' },
+ { key: 'bonusLabel', label: 'Bonus Label', type: 'text', defaultValue: 'referral bonus', placeholder: 'referral bonus', group: 'Content' },
+ { key: 'note', label: 'How to Refer', type: 'text', defaultValue: 'Send their name to jobs@acme.com', placeholder: 'Send their name to jobs@acme.com', group: 'Content' },
+ commonFields.siteName,
+ commonFields.bgColor,
+ commonFields.accentColor,
+ commonFields.textColor,
+ ],
+ defaults: {
+ title: 'Senior Backend Engineer',
+ company: 'Acme',
+ bonus: '$2,000',
+ bonusLabel: 'referral bonus',
+ note: 'Send their name to jobs@acme.com',
+ siteName: 'acme.com',
+ bgColor: '#0a0a0a',
+ accentColor: '#E07A5F',
+ textColor: '#FFFFFF',
+ },
+ render: (params) => {
+ const rawRole = String(params.title || 'Senior Backend Engineer').trim() || 'Senior Backend Engineer';
+ const roleSize = autoFontSize(rawRole, [
+ { maxLen: 22, size: 64 },
+ { maxLen: 36, size: 54 },
+ { maxLen: 50, size: 44 },
+ { maxLen: 60, size: 38 },
+ ]);
+ const role = truncate(rawRole, 60);
+ const company = optionalText(params.company, 'Acme', 40);
+ const bonus = optionalText(params.bonus, '$2,000', 14);
+ // The label is drawn inside the bonus row, so it goes when the bonus does.
+ const bonusLabel = optionalText(params.bonusLabel, 'referral bonus', 24);
+ const bonusSize = autoFontSize(bonus, [
+ { maxLen: 8, size: 128 },
+ { maxLen: 11, size: 104 },
+ { maxLen: 14, size: 84 },
+ ]);
+ const note = optionalText(params.note, 'Send their name to jobs@acme.com', 70);
+ const siteName = optionalText(params.siteName, 'acme.com', 50);
+ const bgColor = String(params.bgColor || '#0a0a0a');
+ const accentColor = String(params.accentColor || '#E07A5F');
+ const textColor = String(params.textColor || '#FFFFFF');
+ // The label and the bonus are drawn in the accent on the page background,
+ // so the accent is darkened when that background is light.
+ const accentInk = isLightColor(bgColor) ? mixColors(accentColor, '#000000', 0.45) : accentColor;
+
+ return {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ flexDirection: 'column',
+ width: '1200px',
+ height: '630px',
+ boxSizing: 'border-box',
+ backgroundColor: bgColor,
+ fontFamily: 'Inter, sans-serif',
+ padding: '64px 80px',
+ },
+ children: [
+ // Label, role and company
+ {
+ type: 'div',
+ props: {
+ style: { display: 'flex', flexDirection: 'column' },
+ children: [
+ {
+ type: 'span',
+ props: {
+ style: {
+ fontSize: '20px',
+ fontWeight: 800,
+ color: accentInk,
+ letterSpacing: '0.14em',
+ textTransform: 'uppercase' as const,
+ },
+ children: 'Know someone?',
+ },
+ },
+ {
+ type: 'h1',
+ props: {
+ style: {
+ fontSize: `${roleSize}px`,
+ fontWeight: 700,
+ color: textColor,
+ lineHeight: 1.1,
+ margin: 0,
+ marginTop: '16px',
+ },
+ children: role,
+ },
+ },
+ ...(company
+ ? [{
+ type: 'span',
+ props: {
+ style: {
+ fontSize: '30px',
+ fontWeight: 500,
+ color: `${textColor}CC`,
+ marginTop: '12px',
+ },
+ children: `at ${company}`,
+ },
+ }]
+ : []),
+ ],
+ },
+ },
+ // Bonus, its label and how to refer
+ {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ flexDirection: 'column',
+ flexGrow: 1,
+ justifyContent: 'center',
+ },
+ children: [
+ ...(bonus
+ ? [{
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ flexWrap: 'wrap' as const,
+ alignItems: 'baseline',
+ columnGap: '20px',
+ },
+ children: [
+ {
+ type: 'span',
+ props: {
+ style: {
+ fontSize: `${bonusSize}px`,
+ fontWeight: 700,
+ color: accentInk,
+ lineHeight: 1,
+ fontFamily: 'JetBrains Mono, monospace',
+ },
+ children: bonus,
+ },
+ },
+ ...(bonusLabel
+ ? [{
+ type: 'span',
+ props: {
+ style: {
+ fontSize: '28px',
+ fontWeight: 600,
+ color: `${textColor}99`,
+ },
+ children: bonusLabel,
+ },
+ }]
+ : []),
+ ],
+ },
+ }]
+ : []),
+ ...(note
+ ? [{
+ type: 'span',
+ props: {
+ style: {
+ fontSize: '26px',
+ fontWeight: 500,
+ color: textColor,
+ marginTop: bonus ? '28px' : '0px',
+ },
+ children: note,
+ },
+ }]
+ : []),
+ ],
+ },
+ },
+ // Site name
+ ...(siteName
+ ? [{
+ type: 'span',
+ props: {
+ style: {
+ fontSize: '18px',
+ fontWeight: 600,
+ color: `${textColor}80`,
+ letterSpacing: '0.06em',
+ },
+ children: siteName,
+ },
+ }]
+ : []),
+ ],
+ },
+ };
+ },
+};
+
+export default template;
diff --git a/src/templates/newsletter/index.ts b/src/templates/newsletter/index.ts
index f0cfa75..6dafabf 100644
--- a/src/templates/newsletter/index.ts
+++ b/src/templates/newsletter/index.ts
@@ -7,3 +7,7 @@ export { default as newsletterMinimalNumber } from './minimal-number';
export { default as newsletterNewspaperFold } from './newspaper-fold';
export { default as newsletterSubscriberCount } from './subscriber-count';
export { default as newsletterSubscribeCard } from './subscribe-card';
+export { default as newsletterSponsor } from './sponsor';
+export { default as newsletterPoll } from './poll';
+export { default as newsletterWelcome } from './welcome';
+export { default as newsletterMembersOnly } from './members-only';
diff --git a/src/templates/newsletter/members-only.ts b/src/templates/newsletter/members-only.ts
new file mode 100644
index 0000000..d72f088
--- /dev/null
+++ b/src/templates/newsletter/members-only.ts
@@ -0,0 +1,246 @@
+import type { TemplateDefinition } from '../types';
+import { truncate, autoFontSize, mixColors, isLightColor, contrastText, hexToRgb, commonFields } from '../utils';
+
+const template: TemplateDefinition = {
+ id: 'newsletter-members-only',
+ name: 'Members Only',
+ category: 'newsletter',
+ description: 'A members-only newsletter preview with teaser text fading into the background and an upgrade CTA',
+ tags: ['newsletter', 'members', 'subscribers', 'paid', 'premium'],
+ fields: [
+ commonFields.title,
+ commonFields.description,
+ {
+ key: 'label',
+ label: 'Label',
+ type: 'text',
+ defaultValue: 'For paid subscribers',
+ placeholder: 'For paid subscribers',
+ group: 'Content',
+ },
+ {
+ key: 'cta',
+ label: 'Button Text',
+ type: 'text',
+ defaultValue: 'Upgrade to keep reading',
+ placeholder: 'Upgrade to keep reading',
+ group: 'Content',
+ },
+ commonFields.siteName,
+ commonFields.bgColor,
+ commonFields.accentColor,
+ commonFields.textColor,
+ ],
+ defaults: {
+ title: 'How we cut our cloud bill in half',
+ description: 'We spent three months moving off managed databases. Here is what worked, what broke, and the numbers before and after.',
+ label: 'For paid subscribers',
+ cta: 'Upgrade to keep reading',
+ siteName: 'The Weekly Byte',
+ bgColor: '#0a0a0a',
+ accentColor: '#E07A5F',
+ textColor: '#FFFFFF',
+ },
+ render: (params) => {
+ const rawTitle = params.title !== undefined ? String(params.title).trim() : 'How we cut our cloud bill in half';
+ const rawDescription = params.description !== undefined ? String(params.description).trim() : '';
+ const rawLabel = params.label !== undefined ? String(params.label).trim() : 'For paid subscribers';
+ const rawCta = params.cta !== undefined ? String(params.cta).trim() : 'Upgrade to keep reading';
+ const rawSiteName = params.siteName !== undefined ? String(params.siteName).trim() : 'The Weekly Byte';
+
+ const titleSize = autoFontSize(rawTitle, [
+ { maxLen: 35, size: 58 },
+ { maxLen: 55, size: 48 },
+ { maxLen: 75, size: 40 },
+ { maxLen: 100, size: 34 },
+ ]);
+ const title = truncate(rawTitle || 'How we cut our cloud bill in half', 90);
+
+ const teaserSize = autoFontSize(rawDescription, [
+ { maxLen: 80, size: 24 },
+ { maxLen: 140, size: 21 },
+ { maxLen: 220, size: 19 },
+ { maxLen: 320, size: 17 },
+ ]);
+ const description = rawDescription ? truncate(rawDescription, 300) : '';
+
+ const label = rawLabel ? truncate(rawLabel, 40) : '';
+ const cta = rawCta ? truncate(rawCta, 32) : '';
+ const siteName = rawSiteName ? truncate(rawSiteName, 50) : '';
+
+ const bgColor = String(params.bgColor || '#0a0a0a');
+ const accentColor = String(params.accentColor || '#E07A5F');
+ const textColor = String(params.textColor || '#FFFFFF');
+
+ const lightBg = isLightColor(bgColor);
+ const accentInk = isLightColor(accentColor) === lightBg
+ ? mixColors(accentColor, lightBg ? '#000000' : '#FFFFFF', 0.55)
+ : accentColor;
+ const buttonText = contrastText(accentColor);
+
+ const bgRgb = hexToRgb(bgColor);
+ const fadeStart = bgRgb ? `rgba(${bgRgb.r}, ${bgRgb.g}, ${bgRgb.b}, 0)` : 'transparent';
+
+ return {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ flexDirection: 'column',
+ width: '1200px',
+ height: '630px',
+ backgroundColor: bgColor,
+ fontFamily: 'Inter, sans-serif',
+ padding: '60px 80px',
+ },
+ children: [
+ ...(label
+ ? [
+ {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ alignItems: 'center',
+ marginBottom: '20px',
+ },
+ children: {
+ type: 'span',
+ props: {
+ style: {
+ fontSize: '18px',
+ fontWeight: 700,
+ color: accentInk,
+ letterSpacing: '0.08em',
+ textTransform: 'uppercase' as const,
+ },
+ children: label,
+ },
+ },
+ },
+ },
+ ]
+ : []),
+ {
+ type: 'h1',
+ props: {
+ style: {
+ fontSize: `${titleSize}px`,
+ fontWeight: 700,
+ color: textColor,
+ lineHeight: 1.2,
+ margin: 0,
+ marginBottom: description ? '20px' : '32px',
+ },
+ children: title,
+ },
+ },
+ ...(description
+ ? [
+ {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ position: 'relative',
+ overflow: 'hidden',
+ maxHeight: '145px',
+ marginBottom: '32px',
+ },
+ children: [
+ {
+ type: 'p',
+ props: {
+ style: {
+ fontSize: `${teaserSize}px`,
+ fontWeight: 400,
+ color: `${textColor}99`,
+ lineHeight: 1.55,
+ margin: 0,
+ },
+ children: description,
+ },
+ },
+ {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ position: 'absolute',
+ left: '0px',
+ width: '100%',
+ bottom: '0px',
+ height: '55px',
+ background: `linear-gradient(180deg, ${fadeStart} 0%, ${bgColor} 100%)`,
+ },
+ children: [],
+ },
+ },
+ ],
+ },
+ },
+ ]
+ : []),
+ ...(cta
+ ? [
+ {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ alignSelf: 'flex-start',
+ padding: '14px 28px',
+ backgroundColor: accentColor,
+ borderRadius: '999px',
+ },
+ children: {
+ type: 'span',
+ props: {
+ style: {
+ fontSize: '18px',
+ fontWeight: 700,
+ color: buttonText,
+ letterSpacing: '0.02em',
+ },
+ children: cta,
+ },
+ },
+ },
+ },
+ ]
+ : []),
+ ...(siteName
+ ? [
+ {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ alignItems: 'center',
+ marginTop: 'auto',
+ },
+ children: {
+ type: 'span',
+ props: {
+ style: {
+ fontSize: '18px',
+ fontWeight: 600,
+ color: `${textColor}80`,
+ letterSpacing: '0.04em',
+ },
+ children: siteName,
+ },
+ },
+ },
+ },
+ ]
+ : []),
+ ],
+ },
+ };
+ },
+};
+
+export default template;
diff --git a/src/templates/newsletter/poll.ts b/src/templates/newsletter/poll.ts
new file mode 100644
index 0000000..5aa6f5b
--- /dev/null
+++ b/src/templates/newsletter/poll.ts
@@ -0,0 +1,222 @@
+import type { SatoriElement, TemplateDefinition } from '../types';
+import { autoFontSize, commonFields, isLightColor, mixColors, truncate } from '../utils';
+
+const template: TemplateDefinition = {
+ id: 'newsletter-poll',
+ name: 'Reader Poll',
+ category: 'newsletter',
+ description: 'A newsletter reader poll with a question and up to four answer choices',
+ tags: ['newsletter', 'poll', 'reader', 'question', 'email'],
+ fields: [
+ commonFields.siteName,
+ { key: 'issue', label: 'Issue', type: 'text', defaultValue: 'Issue 42', placeholder: 'Issue 42', group: 'Content' },
+ commonFields.title,
+ { key: 'option1', label: 'Option 1', type: 'text', defaultValue: 'On my phone', placeholder: 'Answer option', group: 'Content' },
+ { key: 'option2', label: 'Option 2', type: 'text', defaultValue: 'On my laptop', placeholder: 'Answer option', group: 'Content' },
+ { key: 'option3', label: 'Option 3', type: 'text', defaultValue: 'Printed out', placeholder: 'Answer option', group: 'Content' },
+ { key: 'option4', label: 'Option 4', type: 'text', defaultValue: 'I skim the subject line', placeholder: 'Answer option', group: 'Content' },
+ commonFields.bgColor,
+ commonFields.accentColor,
+ commonFields.textColor,
+ ],
+ defaults: {
+ siteName: 'The Friday Diff',
+ issue: 'Issue 42',
+ title: 'How do you read this newsletter?',
+ option1: 'On my phone',
+ option2: 'On my laptop',
+ option3: 'Printed out',
+ option4: 'I skim the subject line',
+ bgColor: '#0a0a0a',
+ accentColor: '#E07A5F',
+ textColor: '#FFFFFF',
+ },
+ render: (params) => {
+ const rawTitle = String(params.title || 'What do you think?').trim();
+ const questionSize = autoFontSize(rawTitle, [
+ { maxLen: 36, size: 58 },
+ { maxLen: 64, size: 48 },
+ { maxLen: 96, size: 40 },
+ { maxLen: 140, size: 34 },
+ ]);
+ const title = truncate(rawTitle, 140);
+ const siteName = truncate(String(params.siteName || '').trim(), 42);
+ const issue = truncate(String(params.issue || '').trim(), 24);
+ const answers = [params.option1, params.option2, params.option3, params.option4]
+ .map((answer) => truncate(String(answer || '').trim(), 36))
+ .filter(Boolean);
+ const bgColor = String(params.bgColor || '#0a0a0a');
+ const accentColor = String(params.accentColor || '#E07A5F');
+ const textColor = String(params.textColor || '#FFFFFF');
+ const lightBg = isLightColor(bgColor);
+ const accentInk = lightBg ? mixColors(accentColor, '#000000', 0.45) : accentColor;
+ const pillBackground = mixColors(bgColor, textColor, lightBg ? 0.045 : 0.08);
+ const pillBorder = mixColors(bgColor, textColor, lightBg ? 0.18 : 0.2);
+
+ const optionPills: SatoriElement[] = answers.map((answer) => ({
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ alignItems: 'center',
+ width: '510px',
+ height: '82px',
+ boxSizing: 'border-box',
+ padding: '18px 24px',
+ gap: '18px',
+ borderRadius: '16px',
+ border: `1px solid ${pillBorder}`,
+ backgroundColor: pillBackground,
+ marginRight: '20px',
+ marginBottom: '16px',
+ },
+ children: [
+ {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ flexShrink: 0,
+ width: '28px',
+ height: '28px',
+ boxSizing: 'border-box',
+ borderRadius: '50%',
+ border: `2px solid ${accentInk}`,
+ },
+ children: [],
+ },
+ },
+ {
+ type: 'span',
+ props: {
+ style: {
+ display: 'flex',
+ flex: 1,
+ minWidth: 0,
+ fontSize: '22px',
+ fontWeight: 600,
+ lineHeight: 1.25,
+ whiteSpace: 'nowrap',
+ overflow: 'hidden',
+ textOverflow: 'ellipsis',
+ color: textColor,
+ },
+ children: answer,
+ },
+ },
+ ],
+ },
+ }));
+
+ return {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ flexDirection: 'column',
+ boxSizing: 'border-box',
+ width: '1200px',
+ height: '630px',
+ padding: '54px 64px',
+ backgroundColor: bgColor,
+ fontFamily: 'Inter, sans-serif',
+ },
+ children: [
+ {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ minHeight: '34px',
+ marginBottom: '30px',
+ },
+ children: [
+ ...(siteName
+ ? [{
+ type: 'span',
+ props: {
+ style: { fontSize: '21px', fontWeight: 700, color: textColor },
+ children: siteName,
+ },
+ }]
+ : []),
+ ...(issue
+ ? [{
+ type: 'span',
+ props: {
+ style: { fontSize: '17px', fontWeight: 600, color: mixColors(bgColor, textColor, 0.72) },
+ children: issue,
+ },
+ }]
+ : []),
+ ],
+ },
+ },
+ {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ alignItems: 'center',
+ alignSelf: 'flex-start',
+ padding: '8px 16px',
+ marginBottom: '20px',
+ borderRadius: '999px',
+ border: `1px solid ${accentInk}55`,
+ backgroundColor: mixColors(bgColor, accentInk, lightBg ? 0.08 : 0.16),
+ },
+ children: {
+ type: 'span',
+ props: {
+ style: {
+ fontSize: '14px',
+ fontWeight: 700,
+ color: accentInk,
+ letterSpacing: '0.08em',
+ textTransform: 'uppercase',
+ },
+ children: 'Reader poll',
+ },
+ },
+ },
+ },
+ {
+ type: 'h1',
+ props: {
+ style: {
+ display: 'flex',
+ alignItems: 'center',
+ minHeight: '112px',
+ maxHeight: '132px',
+ margin: 0,
+ marginBottom: '24px',
+ fontSize: `${questionSize}px`,
+ fontWeight: 700,
+ lineHeight: 1.15,
+ color: textColor,
+ },
+ children: title,
+ },
+ },
+ ...(optionPills.length
+ ? [{
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ flexWrap: 'wrap',
+ width: '1060px',
+ },
+ children: optionPills,
+ },
+ }]
+ : []),
+ ],
+ },
+ };
+ },
+};
+
+export default template;
diff --git a/src/templates/newsletter/sponsor.ts b/src/templates/newsletter/sponsor.ts
new file mode 100644
index 0000000..b8dcf81
--- /dev/null
+++ b/src/templates/newsletter/sponsor.ts
@@ -0,0 +1,240 @@
+import type { TemplateDefinition } from '../types';
+import { truncate, autoFontSize, mixColors, contrastText, isLightColor, commonFields } from '../utils';
+
+const template: TemplateDefinition = {
+ id: 'newsletter-sponsor',
+ name: 'Sponsor',
+ category: 'newsletter',
+ description: 'A newsletter sponsor feature with a centered brand pitch and call to action',
+ tags: ['newsletter', 'sponsor', 'ad', 'cta', 'brand'],
+ fields: [
+ commonFields.siteName,
+ { key: 'issueNumber', label: 'Issue Number', type: 'text', defaultValue: '42', placeholder: '42 (leave empty to hide)', group: 'Content' },
+ { key: 'sponsorLabel', label: 'Sponsor Label', type: 'text', defaultValue: 'Sponsored by', placeholder: 'Sponsored by', group: 'Content' },
+ { key: 'sponsorName', label: 'Sponsor Name', type: 'text', defaultValue: 'Acme', placeholder: 'Acme', group: 'Content' },
+ commonFields.description,
+ { key: 'ctaText', label: 'Button Text', type: 'text', defaultValue: 'Learn more', placeholder: 'Learn more (leave empty to hide)', group: 'Content' },
+ { ...commonFields.bgColor, defaultValue: '#0a0a0a' },
+ commonFields.accentColor,
+ commonFields.textColor,
+ ],
+ defaults: {
+ siteName: 'The Friday Brief',
+ issueNumber: '42',
+ sponsorLabel: 'Sponsored by',
+ sponsorName: 'Acme',
+ description: 'The fastest way to ship production-ready apps.',
+ ctaText: 'Learn more',
+ bgColor: '#0a0a0a',
+ accentColor: '#E07A5F',
+ textColor: '#FFFFFF',
+ },
+ render: (params) => {
+ const siteName = truncate(String(params.siteName ?? '').trim(), 40);
+ const issueNumber = truncate(String(params.issueNumber ?? '').trim(), 16);
+ const sponsorLabel = truncate(String(params.sponsorLabel ?? '').trim(), 24);
+ const sponsorName = truncate(String(params.sponsorName ?? '').trim(), 40);
+ const description = truncate(String(params.description ?? '').trim(), 90);
+ const ctaText = truncate(String(params.ctaText ?? '').trim(), 24);
+ const bgColor = String(params.bgColor ?? '#0a0a0a');
+ const accentColor = String(params.accentColor ?? '#E07A5F');
+ const textColor = String(params.textColor ?? '#FFFFFF');
+ const labelColor = mixColors(bgColor, textColor, 0.6);
+ const buttonText = contrastText(accentColor);
+ const accentInk = isLightColor(bgColor) ? mixColors(accentColor, '#000000', 0.45) : accentColor;
+ const sponsorNameSize = autoFontSize(sponsorName, [
+ { maxLen: 10, size: 96 },
+ { maxLen: 18, size: 80 },
+ { maxLen: 28, size: 64 },
+ { maxLen: 40, size: 52 },
+ ]);
+
+ return {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ flexDirection: 'column',
+ width: '1200px',
+ height: '630px',
+ backgroundColor: bgColor,
+ fontFamily: 'Inter, sans-serif',
+ padding: '60px',
+ },
+ children: [
+ {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ marginBottom: '20px',
+ },
+ children: [
+ ...(siteName
+ ? [
+ {
+ type: 'span',
+ props: {
+ style: {
+ fontSize: '18px',
+ fontWeight: 600,
+ color: accentInk,
+ letterSpacing: '0.08em',
+ textTransform: 'uppercase' as const,
+ },
+ children: siteName,
+ },
+ },
+ ]
+ : []),
+ ...(issueNumber
+ ? [
+ {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ padding: '8px 20px',
+ backgroundColor: accentColor,
+ borderRadius: '100px',
+ },
+ children: {
+ type: 'span',
+ props: {
+ style: {
+ fontSize: '16px',
+ fontWeight: 700,
+ color: buttonText,
+ letterSpacing: '0.05em',
+ },
+ children: `Issue #${issueNumber}`,
+ },
+ },
+ },
+ },
+ ]
+ : []),
+ ],
+ },
+ },
+ {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ width: '100%',
+ height: '3px',
+ backgroundColor: accentColor,
+ marginBottom: '24px',
+ },
+ children: [],
+ },
+ },
+ {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ flex: 1,
+ flexDirection: 'column',
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ children: [
+ ...(sponsorLabel
+ ? [
+ {
+ type: 'span',
+ props: {
+ style: {
+ fontSize: '18px',
+ fontWeight: 600,
+ color: labelColor,
+ letterSpacing: '0.16em',
+ textTransform: 'uppercase' as const,
+ },
+ children: sponsorLabel,
+ },
+ },
+ ]
+ : []),
+ ...(sponsorName
+ ? [
+ {
+ type: 'h1',
+ props: {
+ style: {
+ display: 'flex',
+ fontSize: `${sponsorNameSize}px`,
+ fontWeight: 700,
+ color: textColor,
+ lineHeight: 1.1,
+ margin: 0,
+ marginTop: '18px',
+ textAlign: 'center',
+ letterSpacing: '-0.03em',
+ },
+ children: sponsorName,
+ },
+ },
+ ]
+ : []),
+ ...(description
+ ? [
+ {
+ type: 'p',
+ props: {
+ style: {
+ display: 'flex',
+ fontSize: '26px',
+ fontWeight: 400,
+ color: `${textColor}CC`,
+ lineHeight: 1.4,
+ margin: 0,
+ marginTop: '18px',
+ textAlign: 'center',
+ },
+ children: description,
+ },
+ },
+ ]
+ : []),
+ ],
+ },
+ },
+ ...(ctaText
+ ? [
+ {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ alignSelf: 'flex-start',
+ padding: '18px 32px',
+ backgroundColor: accentColor,
+ borderRadius: '12px',
+ },
+ children: {
+ type: 'span',
+ props: {
+ style: { fontSize: '24px', fontWeight: 700, color: buttonText },
+ children: ctaText,
+ },
+ },
+ },
+ },
+ ]
+ : []),
+ ],
+ },
+ };
+ },
+};
+
+export default template;
\ No newline at end of file
diff --git a/src/templates/newsletter/welcome.ts b/src/templates/newsletter/welcome.ts
new file mode 100644
index 0000000..dc69f89
--- /dev/null
+++ b/src/templates/newsletter/welcome.ts
@@ -0,0 +1,293 @@
+import type { SatoriElement, TemplateDefinition } from '../types';
+import { truncate, autoFontSize, mixColors, isLightColor, commonFields } from '../utils';
+
+const template: TemplateDefinition = {
+ id: 'newsletter-welcome',
+ name: 'Welcome',
+ category: 'newsletter',
+ description: 'A welcome card for new subscribers with what to expect and an optional sign-off',
+ tags: ['newsletter', 'welcome', 'onboarding', 'expectations', 'subscribers'],
+ fields: [
+ {
+ key: 'label',
+ label: 'Label',
+ type: 'text',
+ defaultValue: 'Welcome to',
+ placeholder: 'Welcome to',
+ group: 'Content',
+ },
+ commonFields.siteName,
+ {
+ key: 'item1',
+ label: 'Expectation 1',
+ type: 'text',
+ defaultValue: 'A 5-minute read every Friday',
+ placeholder: 'What to expect...',
+ group: 'Content',
+ },
+ {
+ key: 'item2',
+ label: 'Expectation 2',
+ type: 'text',
+ defaultValue: 'One deep dive a month',
+ placeholder: 'What to expect...',
+ group: 'Content',
+ },
+ {
+ key: 'item3',
+ label: 'Expectation 3',
+ type: 'text',
+ defaultValue: 'No ads, ever',
+ placeholder: 'What to expect...',
+ group: 'Content',
+ },
+ {
+ key: 'signoff',
+ label: 'Sign-off',
+ type: 'text',
+ defaultValue: 'Priya, editor',
+ placeholder: 'Priya, editor (leave empty to hide)',
+ group: 'Content',
+ },
+ commonFields.bgColor,
+ commonFields.accentColor,
+ commonFields.textColor,
+ ],
+ defaults: {
+ label: 'Welcome to',
+ siteName: 'The Friday Brief',
+ item1: 'A 5-minute read every Friday',
+ item2: 'One deep dive a month',
+ item3: 'No ads, ever',
+ signoff: 'Priya, editor',
+ bgColor: '#0a0a0a',
+ accentColor: '#E07A5F',
+ textColor: '#FFFFFF',
+ },
+ render: (params) => {
+ const rawLabel = params.label !== undefined ? String(params.label).trim() : 'Welcome to';
+ const label = rawLabel ? truncate(rawLabel, 32) : '';
+ const rawSiteName = params.siteName !== undefined ? String(params.siteName).trim() : 'The Friday Brief';
+ const siteName = rawSiteName ? truncate(rawSiteName, 50) : '';
+ const rawSignoff = params.signoff !== undefined ? String(params.signoff).trim() : 'Priya, editor';
+ const signoff = rawSignoff ? truncate(rawSignoff, 50) : '';
+
+ const rawItems = [
+ params.item1 !== undefined ? String(params.item1).trim() : 'A 5-minute read every Friday',
+ params.item2 !== undefined ? String(params.item2).trim() : 'One deep dive a month',
+ params.item3 !== undefined ? String(params.item3).trim() : 'No ads, ever',
+ ];
+
+ const items = rawItems
+ .filter((item) => item.length > 0)
+ .map((item) => truncate(item, 70));
+
+ const bgColor = String(params.bgColor || '#0a0a0a');
+ const accentColor = String(params.accentColor || '#E07A5F');
+ const textColor = String(params.textColor || '#FFFFFF');
+
+ const lightBg = isLightColor(bgColor);
+ const accentInk = isLightColor(accentColor) === lightBg
+ ? mixColors(accentColor, textColor, 0.55)
+ : accentColor;
+
+ const cardBg = mixColors(bgColor, textColor, lightBg ? 0.04 : 0.07);
+ const cardBorder = mixColors(bgColor, textColor, lightBg ? 0.14 : 0.2);
+ const numberCircleBg = mixColors(bgColor, accentInk, lightBg ? 0.1 : 0.18);
+ const numberCircleBorder = mixColors(bgColor, accentInk, lightBg ? 0.25 : 0.35);
+ const secondaryText = mixColors(bgColor, textColor, 0.7);
+
+ const nameSize = autoFontSize(siteName, [
+ { maxLen: 16, size: 60 },
+ { maxLen: 28, size: 50 },
+ { maxLen: 40, size: 42 },
+ { maxLen: 50, size: 36 },
+ ]);
+
+ const itemRows: SatoriElement[] = items.map((item, index) => ({
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ alignItems: 'center',
+ padding: '14px 22px',
+ borderRadius: '16px',
+ backgroundColor: cardBg,
+ border: `1px solid ${cardBorder}`,
+ marginBottom: index < items.length - 1 ? '12px' : '0px',
+ },
+ children: [
+ {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ width: '38px',
+ height: '38px',
+ borderRadius: '50%',
+ backgroundColor: numberCircleBg,
+ border: `1px solid ${numberCircleBorder}`,
+ marginRight: '18px',
+ flexShrink: 0,
+ },
+ children: {
+ type: 'span',
+ props: {
+ style: {
+ fontSize: '18px',
+ fontWeight: 700,
+ color: accentInk,
+ },
+ children: String(index + 1),
+ },
+ },
+ },
+ },
+ {
+ type: 'span',
+ props: {
+ style: {
+ display: 'flex',
+ fontSize: '22px',
+ fontWeight: 500,
+ color: textColor,
+ lineHeight: 1.3,
+ whiteSpace: 'nowrap',
+ overflow: 'hidden',
+ textOverflow: 'ellipsis',
+ },
+ children: item,
+ },
+ },
+ ],
+ },
+ }));
+
+ return {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ flexDirection: 'column',
+ width: '1200px',
+ height: '630px',
+ backgroundColor: bgColor,
+ fontFamily: 'Inter, sans-serif',
+ padding: '54px 72px',
+ boxSizing: 'border-box',
+ },
+ children: [
+ // Header section: label + newsletter name
+ {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ flexDirection: 'column',
+ alignItems: 'flex-start',
+ marginBottom: '26px',
+ },
+ children: [
+ ...(label
+ ? [
+ {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ alignItems: 'center',
+ padding: '6px 18px',
+ borderRadius: '999px',
+ backgroundColor: mixColors(bgColor, accentInk, lightBg ? 0.08 : 0.16),
+ border: `1px solid ${accentInk}55`,
+ marginBottom: '14px',
+ },
+ children: {
+ type: 'span',
+ props: {
+ style: {
+ fontSize: '16px',
+ fontWeight: 600,
+ color: accentInk,
+ letterSpacing: '0.04em',
+ },
+ children: label,
+ },
+ },
+ },
+ },
+ ]
+ : []),
+ ...(siteName
+ ? [
+ {
+ type: 'h1',
+ props: {
+ style: {
+ display: 'flex',
+ fontSize: `${nameSize}px`,
+ fontWeight: 700,
+ color: textColor,
+ lineHeight: 1.15,
+ margin: 0,
+ letterSpacing: '-0.02em',
+ },
+ children: siteName,
+ },
+ },
+ ]
+ : []),
+ ],
+ },
+ },
+ // Expectation items
+ ...(itemRows.length
+ ? [
+ {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ flexDirection: 'column',
+ width: '100%',
+ },
+ children: itemRows,
+ },
+ },
+ ]
+ : []),
+ // Optional sign-off
+ ...(signoff
+ ? [
+ {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ alignItems: 'center',
+ marginTop: 'auto',
+ },
+ children: {
+ type: 'span',
+ props: {
+ style: {
+ fontSize: '20px',
+ fontWeight: 500,
+ color: secondaryText,
+ },
+ children: signoff,
+ },
+ },
+ },
+ },
+ ]
+ : []),
+ ],
+ },
+ };
+ },
+};
+
+export default template;
diff --git a/src/templates/podcast/index.ts b/src/templates/podcast/index.ts
index e818913..9bf52fd 100644
--- a/src/templates/podcast/index.ts
+++ b/src/templates/podcast/index.ts
@@ -7,3 +7,6 @@ export { default as podcastMinimalPlayer } from './minimal-player';
export { default as podcastVideoThumbnail } from './video-thumbnail';
export { default as podcastAudiogram } from './audiogram';
export { default as podcastShowNotes } from './show-notes';
+export { default as podcastListenOn } from './listen-on';
+export { default as podcastTopEpisodes } from './top-episodes';
+export { default as podcastLiveRecording } from './live-recording';
diff --git a/src/templates/podcast/listen-on.ts b/src/templates/podcast/listen-on.ts
new file mode 100644
index 0000000..d028d07
--- /dev/null
+++ b/src/templates/podcast/listen-on.ts
@@ -0,0 +1,209 @@
+import type { TemplateDefinition } from '../types';
+import { autoFontSize, commonFields, contrastText, isLightColor, mixColors, truncate } from '../utils';
+
+const template: TemplateDefinition = {
+ id: 'podcast-listen-on',
+ name: 'Listen On',
+ category: 'podcast',
+ description: 'Podcast episode announcement with links to listening platforms',
+ tags: ['podcast', 'episode', 'listen', 'platforms', 'announcement'],
+ fields: [
+ { key: 'showName', label: 'Show Name', type: 'text' as const, defaultValue: 'The Signal', placeholder: 'The Signal', group: 'Content' as const },
+ { key: 'episodeNumber', label: 'Episode Number', type: 'text' as const, defaultValue: '42', placeholder: '42', group: 'Content' as const },
+ commonFields.title,
+ { key: 'platform1', label: 'Platform 1', type: 'text' as const, defaultValue: 'Spotify', placeholder: 'Spotify', group: 'Content' as const },
+ { key: 'platform2', label: 'Platform 2', type: 'text' as const, defaultValue: 'Apple Podcasts', placeholder: 'Apple Podcasts', group: 'Content' as const },
+ { key: 'platform3', label: 'Platform 3', type: 'text' as const, defaultValue: 'YouTube', placeholder: 'YouTube', group: 'Content' as const },
+ { key: 'tagline', label: 'Tagline', type: 'text' as const, defaultValue: 'Out now', placeholder: 'Out now', group: 'Content' as const },
+ commonFields.bgColor,
+ commonFields.accentColor,
+ commonFields.textColor,
+ ],
+ defaults: {
+ showName: 'The Signal',
+ episodeNumber: '42',
+ title: 'Why We Build for the Open Web',
+ platform1: 'Spotify',
+ platform2: 'Apple Podcasts',
+ platform3: 'YouTube',
+ tagline: 'Out now',
+ bgColor: '#0d1117',
+ accentColor: '#E07A5F',
+ textColor: '#FFFFFF',
+ },
+ render: (params) => {
+ const showName = truncate(String(params.showName ?? '').trim(), 40);
+ const episodeNumber = String(params.episodeNumber ?? '').trim();
+ const title = truncate(String(params.title ?? '').trim() || 'Episode Title', 80);
+ const tagline = String(params.tagline ?? '').trim();
+ const bgColor = String(params.bgColor || '#0d1117');
+ const accentColor = String(params.accentColor || '#E07A5F');
+ const textColor = String(params.textColor || '#FFFFFF');
+ const accentTextColor = isLightColor(bgColor) ? mixColors(accentColor, '#000000', 0.45) : accentColor;
+ const platformTextColor = contrastText(accentColor);
+ const platforms = ['platform1', 'platform2', 'platform3']
+ .map((key) => String(params[key] ?? '').trim())
+ .filter(Boolean)
+ .map((platform) => truncate(platform, 22));
+ const titleSize = autoFontSize(title, [
+ { maxLen: 28, size: 64 },
+ { maxLen: 48, size: 54 },
+ { maxLen: 68, size: 44 },
+ { maxLen: 80, size: 36 },
+ ]);
+
+ return {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ flexDirection: 'column',
+ justifyContent: 'space-between',
+ width: '1200px',
+ height: '630px',
+ padding: '72px 84px',
+ background: bgColor,
+ fontFamily: 'Inter, sans-serif',
+ },
+ children: [
+ {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ alignItems: 'center',
+ gap: '14px',
+ minHeight: '32px',
+ },
+ children: [
+ ...(showName
+ ? [{
+ type: 'span',
+ props: {
+ style: { fontSize: '20px', fontWeight: 700, color: textColor },
+ children: showName,
+ },
+ }]
+ : []),
+ ...(showName && episodeNumber
+ ? [{
+ type: 'span',
+ props: {
+ style: { fontSize: '20px', fontWeight: 600, color: accentTextColor },
+ children: '·',
+ },
+ }]
+ : []),
+ ...(episodeNumber
+ ? [{
+ type: 'span',
+ props: {
+ style: { fontSize: '18px', fontWeight: 600, color: textColor },
+ children: `Episode ${truncate(episodeNumber, 20)}`,
+ },
+ }]
+ : []),
+ ],
+ },
+ },
+ {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ flex: 1,
+ alignItems: 'center',
+ padding: '32px 0',
+ },
+ children: {
+ type: 'h1',
+ props: {
+ style: {
+ fontSize: `${titleSize}px`,
+ fontWeight: 700,
+ color: textColor,
+ lineHeight: 1.15,
+ margin: 0,
+ maxWidth: '960px',
+ },
+ children: title,
+ },
+ },
+ },
+ },
+ ...(tagline || platforms.length
+ ? [{
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ gap: '24px',
+ minHeight: '52px',
+ },
+ children: [
+ ...(tagline
+ ? [{
+ type: 'span',
+ props: {
+ style: {
+ fontSize: '20px',
+ fontWeight: 700,
+ color: accentTextColor,
+ flexShrink: 0,
+ },
+ children: truncate(tagline, 32),
+ },
+ }]
+ : []),
+ ...(platforms.length
+ ? [{
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'flex-end',
+ gap: '12px',
+ flex: 1,
+ },
+ children: platforms.map((platform) => ({
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ maxWidth: '250px',
+ padding: '12px 18px',
+ borderRadius: '100px',
+ background: accentColor,
+ color: platformTextColor,
+ fontSize: '16px',
+ fontWeight: 700,
+ flexShrink: 0,
+ },
+ children: {
+ type: 'span',
+ props: {
+ style: { whiteSpace: 'nowrap' },
+ children: platform,
+ },
+ },
+ },
+ })),
+ },
+ }]
+ : []),
+ ],
+ },
+ }]
+ : []),
+ ],
+ },
+ };
+ },
+};
+
+export default template;
diff --git a/src/templates/podcast/live-recording.ts b/src/templates/podcast/live-recording.ts
new file mode 100644
index 0000000..cb582fb
--- /dev/null
+++ b/src/templates/podcast/live-recording.ts
@@ -0,0 +1,311 @@
+import type { TemplateDefinition } from '../types';
+import {
+ autoFontSize,
+ commonFields,
+ contrastText,
+ isLightColor,
+ mixColors,
+ truncate,
+} from '../utils';
+
+const template: TemplateDefinition = {
+ id: 'podcast-live-recording',
+ name: 'Live Recording',
+ category: 'podcast',
+ description: 'Podcast episode announcement for a live recording with an audience',
+ tags: ['podcast', 'live', 'recording', 'episode', 'audience'],
+ fields: [
+ commonFields.title,
+ {
+ key: 'showName',
+ label: 'Show Name',
+ type: 'text' as const,
+ defaultValue: 'The Dev Show',
+ placeholder: 'The Dev Show',
+ group: 'Content' as const,
+ },
+ {
+ key: 'when',
+ label: 'When',
+ type: 'text' as const,
+ defaultValue: 'Thursday, 7:00 PM IST',
+ placeholder: 'Thursday, 7:00 PM IST',
+ group: 'Content' as const,
+ },
+ {
+ key: 'where',
+ label: 'Where',
+ type: 'text' as const,
+ defaultValue: 'Live on our channel',
+ placeholder: 'Live on our channel',
+ group: 'Content' as const,
+ },
+ commonFields.bgColor,
+ commonFields.accentColor,
+ commonFields.textColor,
+ ],
+ defaults: {
+ title: 'Shipping on Fridays: yes or no?',
+ showName: 'The Dev Show',
+ when: 'Thursday, 7:00 PM IST',
+ where: 'Live on our channel',
+ bgColor: '#F7F7F2',
+ accentColor: '#E07A5F',
+ textColor: '#18212B',
+ },
+ render: (params) => {
+ const rawTitle = String(params.title || 'Episode Topic');
+
+ const titleSize = autoFontSize(rawTitle, [
+ { maxLen: 30, size: 58 },
+ { maxLen: 50, size: 48 },
+ { maxLen: 75, size: 40 },
+ { maxLen: 110, size: 32 },
+ { maxLen: 160, size: 26 },
+ ]);
+
+ const title = truncate(rawTitle, 160);
+ const showName = String(params.showName || '').trim();
+ const when = String(params.when || '').trim();
+ const where = String(params.where || '').trim();
+ const bgColor = String(params.bgColor || '#F7F7F2');
+ const accentColor = String(params.accentColor || '#E07A5F');
+ const textColor = String(params.textColor || '#18212B');
+
+ // Resolve readable text color for the accent-colored live badge.
+ // Keep this aligned with the contrast helper used elsewhere in the project.
+ const badgeTextColor = contrastText(accentColor);
+ const accentLabelColor = isLightColor(bgColor)
+ ? mixColors(accentColor, '#000000', 0.45)
+ : accentColor;
+ const metaItems = [
+ ...(when
+ ? [
+ {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ flexDirection: 'column',
+ gap: '10px',
+ flex: 1,
+ minWidth: 0,
+ },
+ children: [
+ {
+ type: 'span',
+ props: {
+ style: {
+ fontSize: '15px',
+ fontWeight: 800,
+ color: accentLabelColor,
+ letterSpacing: '0.12em',
+ textTransform: 'uppercase',
+ },
+ children: 'When',
+ },
+ },
+ {
+ type: 'span',
+ props: {
+ style: {
+ fontSize: '21px',
+ fontWeight: 600,
+ color: textColor,
+ lineHeight: 1.4,
+ },
+ children: when,
+ },
+ },
+ ],
+ },
+ },
+ ]
+ : []),
+ ...(where
+ ? [
+ {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ flexDirection: 'column',
+ gap: '10px',
+ flex: 1,
+ minWidth: 0,
+ },
+ children: [
+ {
+ type: 'span',
+ props: {
+ style: {
+ fontSize: '15px',
+ fontWeight: 800,
+ color: accentLabelColor,
+ letterSpacing: '0.12em',
+ textTransform: 'uppercase',
+ },
+ children: 'Where',
+ },
+ },
+ {
+ type: 'span',
+ props: {
+ style: {
+ fontSize: '21px',
+ fontWeight: 600,
+ color: textColor,
+ lineHeight: 1.4,
+ },
+ children: where,
+ },
+ },
+ ],
+ },
+ },
+ ]
+ : []),
+ ];
+
+ return {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ flexDirection: 'column',
+ width: '1200px',
+ height: '630px',
+ boxSizing: 'border-box',
+ overflow: 'hidden',
+ background: bgColor,
+ fontFamily: 'Inter, sans-serif',
+ padding: '64px',
+ },
+ children: [
+ // Live status and show name.
+ {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ alignItems: 'center',
+ flexWrap: 'wrap',
+ gap: '18px',
+ marginBottom: '52px',
+ },
+ children: [
+ {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ alignItems: 'center',
+ gap: '10px',
+ padding: '11px 17px',
+ borderRadius: '100px',
+ background: accentColor,
+ color: badgeTextColor,
+ },
+ children: [
+ {
+ type: 'div',
+ props: {
+ style: {
+ width: '10px',
+ height: '10px',
+ minWidth: '10px',
+ borderRadius: '50%',
+ background: badgeTextColor,
+ },
+ },
+ },
+ {
+ type: 'span',
+ props: {
+ style: {
+ fontSize: '16px',
+ fontWeight: 800,
+ color: badgeTextColor,
+ },
+ children: 'Recording live',
+ },
+ },
+ ],
+ },
+ },
+ ...(showName
+ ? [
+ {
+ type: 'span',
+ props: {
+ style: {
+ fontSize: '22px',
+ fontWeight: 700,
+ color: textColor,
+ },
+ children: showName,
+ },
+ },
+ ]
+ : []),
+ ],
+ },
+ },
+
+ // Episode topic.
+ {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ flex: 1,
+ minHeight: 0,
+ alignItems: 'center',
+ },
+ children: {
+ type: 'h1',
+ props: {
+ style: {
+ fontSize: `${titleSize}px`,
+ fontWeight: 800,
+ color: textColor,
+ lineHeight: 1.15,
+ margin: 0,
+ maxWidth: '1040px',
+ overflow: 'hidden',
+ display: '-webkit-box',
+ WebkitBoxOrient: 'vertical',
+ WebkitLineClamp: 3,
+ overflowWrap: 'break-word',
+ },
+ children: title,
+ },
+ },
+ },
+ },
+
+ // Event details. Hide the entire section when both values are empty.
+ ...(metaItems.length
+ ? [
+ {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ gap: '64px',
+ paddingTop: '28px',
+ marginTop: '30px',
+ borderTop: `1px solid ${textColor}22`,
+ },
+ children: metaItems,
+ },
+ },
+ ]
+ : []),
+ ],
+ },
+ };
+ },
+};
+
+export default template;
diff --git a/src/templates/podcast/top-episodes.ts b/src/templates/podcast/top-episodes.ts
new file mode 100644
index 0000000..267d604
--- /dev/null
+++ b/src/templates/podcast/top-episodes.ts
@@ -0,0 +1,123 @@
+import type { TemplateDefinition } from '../types';
+import { commonFields, isLightColor, mixColors, truncate } from '../utils';
+
+const template: TemplateDefinition = {
+ id: 'podcast-top-episodes',
+ name: 'Top Episodes',
+ category: 'podcast',
+ description: 'Podcast show card with a ranked list of up to three popular episodes and their durations',
+ tags: ['podcast', 'episodes', 'ranking', 'list', 'show'],
+ fields: [
+ { key: 'showName', label: 'Show Name', type: 'text' as const, defaultValue: 'The Signal', placeholder: 'Podcast name', group: 'Content' as const },
+ commonFields.title,
+ ...[1, 2, 3].flatMap((number) => [
+ { key: `episode${number}`, label: `Episode ${number}`, type: 'text' as const, defaultValue: ['The Stories We Tell About Tomorrow', 'Inside the Cities of the Future', 'A Better Way to Build'][number - 1], placeholder: 'Episode title', group: 'Content' as const },
+ { key: `duration${number}`, label: `Duration ${number}`, type: 'text' as const, defaultValue: ['48 min', '36 min', '52 min'][number - 1], placeholder: '48 min', group: 'Content' as const },
+ ]),
+ commonFields.bgColor,
+ commonFields.accentColor,
+ commonFields.textColor,
+ ],
+ defaults: {
+ showName: 'The Signal',
+ title: 'Most Played This Month',
+ episode1: 'The Stories We Tell About Tomorrow',
+ duration1: '48 min',
+ episode2: 'Inside the Cities of the Future',
+ duration2: '36 min',
+ episode3: 'A Better Way to Build',
+ duration3: '52 min',
+ bgColor: '#0d1117',
+ accentColor: '#E07A5F',
+ textColor: '#FFFFFF',
+ },
+ render: (params) => {
+ const showName = truncate(String(params.showName ?? '').trim(), 48);
+ const title = truncate(String(params.title ?? '').trim() || 'Top Episodes', 54);
+ const episodes = [1, 2, 3]
+ .map((number) => ({
+ title: String(params[`episode${number}`] ?? '').trim(),
+ duration: String(params[`duration${number}`] ?? '').trim(),
+ }))
+ .filter((episode) => episode.title);
+ const bgColor = String(params.bgColor || '#0d1117');
+ const accentColor = String(params.accentColor || '#E07A5F');
+ const textColor = String(params.textColor || '#FFFFFF');
+ const accentInk = isLightColor(bgColor) ? mixColors(accentColor, '#000000', 0.45) : accentColor;
+ const mutedColor = mixColors(bgColor, textColor, isLightColor(bgColor) ? 0.68 : 0.62);
+
+ return {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ flexDirection: 'column',
+ width: '1200px',
+ height: '630px',
+ padding: '64px 72px',
+ backgroundColor: bgColor,
+ fontFamily: 'Inter, sans-serif',
+ },
+ children: [
+ ...(showName
+ ? [{
+ type: 'div',
+ props: {
+ style: { display: 'flex', alignItems: 'center', gap: '14px', marginBottom: '14px' },
+ children: [
+ { type: 'div', props: { style: { display: 'flex', width: '10px', height: '10px', borderRadius: '50%', backgroundColor: accentInk } } },
+ { type: 'span', props: { style: { display: 'flex', fontSize: '20px', fontWeight: 700, color: mutedColor, letterSpacing: '0.04em' }, children: showName } },
+ ],
+ },
+ }]
+ : []),
+ {
+ type: 'h1',
+ props: { style: { display: 'flex', margin: '0 0 34px', fontSize: '42px', lineHeight: 1.15, fontWeight: 700, color: textColor }, children: title },
+ },
+ {
+ type: 'div',
+ props: {
+ style: { display: 'flex', flexDirection: 'column', gap: '12px', flex: 1 },
+ children: episodes.map((episode, index) => ({
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ alignItems: 'center',
+ minHeight: '92px',
+ padding: '0 24px',
+ gap: '24px',
+ borderRadius: '16px',
+ backgroundColor: `${textColor}08`,
+ border: `1px solid ${textColor}16`,
+ },
+ children: [
+ { type: 'span', props: { style: { display: 'flex', width: '76px', flexShrink: 0, fontSize: '38px', fontWeight: 800, color: accentInk }, children: String(index + 1).padStart(2, '0') } },
+ { type: 'span', props: { style: { display: 'flex', flex: 1, minWidth: 0, overflow: 'hidden', whiteSpace: 'nowrap', fontSize: '24px', fontWeight: 600, color: textColor }, children: truncate(episode.title, 56) } },
+ ...(episode.duration
+ ? [{ type: 'span', props: { style: { display: 'flex', justifyContent: 'flex-end', width: '120px', flexShrink: 0, whiteSpace: 'nowrap', fontSize: '18px', color: mutedColor }, children: truncate(episode.duration, 12) } }]
+ : []),
+ ],
+ },
+ })),
+ },
+ },
+ {
+ type: 'div',
+ props: {
+ style: { display: 'flex', alignItems: 'center', gap: '10px', marginTop: '22px' },
+ children: [
+ { type: 'span', props: { style: { display: 'flex', fontSize: '15px', fontWeight: 700, color: accentInk }, children: `${episodes.length} ${episodes.length === 1 ? 'EPISODE' : 'EPISODES'}` } },
+ { type: 'span', props: { style: { display: 'flex', fontSize: '15px', color: mutedColor }, children: '·' } },
+ { type: 'span', props: { style: { display: 'flex', fontSize: '15px', color: mutedColor }, children: 'PODCAST CHART' } },
+ ],
+ },
+ },
+ ],
+ },
+ };
+ },
+};
+
+export default template;
diff --git a/src/templates/product/index.ts b/src/templates/product/index.ts
index e33be29..ad42517 100644
--- a/src/templates/product/index.ts
+++ b/src/templates/product/index.ts
@@ -9,3 +9,4 @@ export { default as productAnnouncement } from './announcement';
export { default as productMinimalBadge } from './minimal-badge';
export { default as productDarkPremium } from './dark-premium';
export { default as productShortcutTip } from './shortcut-tip';
+export { default as productWaitlist } from './waitlist';
diff --git a/src/templates/product/waitlist.ts b/src/templates/product/waitlist.ts
new file mode 100644
index 0000000..1380e2f
--- /dev/null
+++ b/src/templates/product/waitlist.ts
@@ -0,0 +1,239 @@
+import type { TemplateDefinition } from '../types';
+import { autoFontSize, commonFields, isLightColor, mixColors, truncate } from '../utils';
+
+const template: TemplateDefinition = {
+ id: 'product-waitlist',
+ name: 'Waitlist',
+ category: 'product',
+ description: 'Pre-launch waitlist card with the product name, a one-line pitch, the number of people waiting and an optional launch window',
+ tags: ['product', 'waitlist', 'launch', 'signups', 'pre-launch'],
+ fields: [
+ commonFields.title,
+ commonFields.description,
+ { key: 'count', label: 'Waitlist Count', type: 'text', defaultValue: '2,148', placeholder: '2,148', group: 'Content' },
+ { key: 'countLabel', label: 'Count Label', type: 'text', defaultValue: 'people on the list', placeholder: 'people on the list', group: 'Content' },
+ { key: 'launchWindow', label: 'Launch Window', type: 'text', defaultValue: 'Opening in November', placeholder: 'Opening in November', group: 'Content' },
+ commonFields.siteName,
+ commonFields.bgColor,
+ commonFields.accentColor,
+ commonFields.textColor,
+ ],
+ defaults: {
+ title: 'Notewell',
+ description: 'Notes that link themselves',
+ count: '2,148',
+ countLabel: 'people on the list',
+ launchWindow: 'Opening in November',
+ siteName: 'notewell.app',
+ bgColor: '#0a0a0a',
+ accentColor: '#E07A5F',
+ textColor: '#FFFFFF',
+ },
+ render: (params) => {
+ const rawTitle = String(params.title || 'Notewell').trim() || 'Notewell';
+ const titleSize = autoFontSize(rawTitle, [
+ { maxLen: 16, size: 88 },
+ { maxLen: 28, size: 72 },
+ { maxLen: 42, size: 60 },
+ ]);
+ const title = truncate(rawTitle, 42);
+ const rawDescription = String(params.description || '').trim();
+ const descriptionSize = autoFontSize(rawDescription, [
+ { maxLen: 34, size: 30 },
+ { maxLen: 60, size: 26 },
+ { maxLen: 90, size: 22 },
+ ]);
+ const description = truncate(rawDescription, 90);
+ const count = truncate(String(params.count || '').trim(), 12);
+ // The label describes the number, so it goes with it rather than standing on its own.
+ const countLabel = count ? truncate(String(params.countLabel || '').trim(), 32) : '';
+ const launchWindow = truncate(String(params.launchWindow || '').trim(), 40);
+ const siteName = truncate(String(params.siteName || '').trim(), 40);
+ const bgColor = String(params.bgColor || '#0a0a0a');
+ const accentColor = String(params.accentColor || '#E07A5F');
+ const textColor = String(params.textColor || '#FFFFFF');
+ // The label and the launch window are drawn in the accent on the page background,
+ // so they are darkened when that background is light.
+ const accentInk = isLightColor(bgColor) ? mixColors(accentColor, '#000000', 0.45) : accentColor;
+
+ return {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ flexDirection: 'column',
+ justifyContent: 'center',
+ width: '1200px',
+ height: '630px',
+ boxSizing: 'border-box',
+ backgroundColor: bgColor,
+ fontFamily: 'Inter, sans-serif',
+ padding: '64px 80px',
+ },
+ children: [
+ // "Join the waitlist" label
+ {
+ type: 'span',
+ props: {
+ style: {
+ fontSize: '16px',
+ fontWeight: 700,
+ color: accentInk,
+ letterSpacing: '0.16em',
+ textTransform: 'uppercase' as const,
+ },
+ children: 'Join the waitlist',
+ },
+ },
+ // Product name
+ {
+ type: 'h1',
+ props: {
+ style: {
+ fontSize: `${titleSize}px`,
+ fontWeight: 700,
+ color: textColor,
+ lineHeight: 1.1,
+ margin: 0,
+ marginTop: '18px',
+ },
+ children: title,
+ },
+ },
+ // One-line pitch
+ ...(description
+ ? [
+ {
+ type: 'p',
+ props: {
+ style: {
+ fontSize: `${descriptionSize}px`,
+ fontWeight: 500,
+ color: `${textColor}B3`,
+ lineHeight: 1.4,
+ margin: 0,
+ marginTop: '14px',
+ },
+ children: description,
+ },
+ },
+ ]
+ : []),
+ // Count and its label
+ ...(count
+ ? [
+ {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ alignItems: 'baseline',
+ gap: '14px',
+ marginTop: '52px',
+ },
+ children: [
+ {
+ type: 'span',
+ props: {
+ style: {
+ fontSize: '64px',
+ fontWeight: 700,
+ color: textColor,
+ lineHeight: 1,
+ fontFamily: 'JetBrains Mono, monospace',
+ },
+ children: count,
+ },
+ },
+ ...(countLabel
+ ? [
+ {
+ type: 'span',
+ props: {
+ style: {
+ fontSize: '22px',
+ fontWeight: 500,
+ color: `${textColor}99`,
+ },
+ children: countLabel,
+ },
+ },
+ ]
+ : []),
+ ],
+ },
+ },
+ ]
+ : []),
+ // Launch window and site name
+ ...(launchWindow || siteName
+ ? [
+ {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ alignItems: 'center',
+ gap: '16px',
+ marginTop: '44px',
+ },
+ children: [
+ ...(launchWindow
+ ? [
+ {
+ type: 'span',
+ props: {
+ style: {
+ fontSize: '18px',
+ fontWeight: 600,
+ color: accentInk,
+ },
+ children: launchWindow,
+ },
+ },
+ ]
+ : []),
+ ...(launchWindow && siteName
+ ? [
+ {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ width: '4px',
+ height: '4px',
+ borderRadius: '50%',
+ backgroundColor: `${textColor}44`,
+ },
+ children: [],
+ },
+ },
+ ]
+ : []),
+ ...(siteName
+ ? [
+ {
+ type: 'span',
+ props: {
+ style: {
+ fontSize: '18px',
+ fontWeight: 500,
+ color: `${textColor}66`,
+ },
+ children: siteName,
+ },
+ },
+ ]
+ : []),
+ ],
+ },
+ },
+ ]
+ : []),
+ ],
+ },
+ };
+ },
+};
+
+export default template;
diff --git a/src/templates/quote/article-excerpt.ts b/src/templates/quote/article-excerpt.ts
new file mode 100644
index 0000000..9e6f487
--- /dev/null
+++ b/src/templates/quote/article-excerpt.ts
@@ -0,0 +1,256 @@
+import type { TemplateDefinition } from '../types';
+import { autoFontSize, commonFields, isLightColor, mixColors, truncate } from '../utils';
+
+const template: TemplateDefinition = {
+ id: 'quote-article-excerpt',
+ name: 'Article Excerpt',
+ category: 'quote',
+ description: 'Excerpt with a thin left accent bar and a link-style source preview box',
+ tags: ['quote', 'article', 'excerpt', 'blog', 'link', 'source'],
+ fields: [
+ {
+ key: 'quote',
+ label: 'Quote',
+ type: 'textarea',
+ defaultValue: 'Most of our outages started as a config change that looked too small to review.',
+ placeholder: 'Enter quote or excerpt...',
+ required: true,
+ group: 'Content',
+ },
+ {
+ ...commonFields.title,
+ label: 'Article Title',
+ defaultValue: 'What three years of incidents taught us',
+ placeholder: 'Article title',
+ required: false,
+ },
+ {
+ ...commonFields.siteName,
+ defaultValue: 'blog.example.com',
+ placeholder: 'blog.example.com',
+ },
+ {
+ ...commonFields.author,
+ defaultValue: 'Priya Raman',
+ placeholder: 'Author name',
+ },
+ commonFields.bgColor,
+ commonFields.accentColor,
+ commonFields.textColor,
+ ],
+ defaults: {
+ quote: 'Most of our outages started as a config change that looked too small to review.',
+ title: 'What three years of incidents taught us',
+ siteName: 'blog.example.com',
+ author: 'Priya Raman',
+ bgColor: '#0a0a0a',
+ accentColor: '#E07A5F',
+ textColor: '#FFFFFF',
+ },
+ render: (params) => {
+ const rawQuote =
+ String(params.quote ?? '').trim() ||
+ 'Most of our outages started as a config change that looked too small to review.';
+ const quoteSize = autoFontSize(rawQuote, [
+ { maxLen: 50, size: 52 },
+ { maxLen: 90, size: 44 },
+ { maxLen: 140, size: 36 },
+ { maxLen: 200, size: 28 },
+ ]);
+ const quote = truncate(rawQuote, 200);
+
+ const rawTitle = String(params.title ?? '').trim();
+ const titleSize = autoFontSize(rawTitle, [
+ { maxLen: 40, size: 28 },
+ { maxLen: 70, size: 24 },
+ { maxLen: 100, size: 20 },
+ ]);
+ const title = truncate(rawTitle, 100);
+
+ const siteName = truncate(String(params.siteName ?? '').trim(), 60);
+ const author = truncate(String(params.author ?? '').trim(), 60);
+ const authorLine = author ? (author.toLowerCase().startsWith('by ') ? author : `by ${author}`) : '';
+
+ const bgColor = String(params.bgColor || '#0a0a0a');
+ const accentColor = String(params.accentColor || '#E07A5F');
+ const textColor = String(params.textColor || '#FFFFFF');
+
+ const lightBg = isLightColor(bgColor);
+ const accentInk = lightBg ? mixColors(accentColor, '#000000', 0.45) : accentColor;
+ const quoteColor = lightBg && isLightColor(textColor) ? '#000000' : textColor;
+ const metaColor = isLightColor(quoteColor) ? 'rgba(255, 255, 255, 0.65)' : 'rgba(0, 0, 0, 0.65)';
+ const boxBg = lightBg ? 'rgba(0, 0, 0, 0.03)' : 'rgba(255, 255, 255, 0.05)';
+ const boxBorder = lightBg ? '1px solid rgba(0, 0, 0, 0.10)' : '1px solid rgba(255, 255, 255, 0.14)';
+
+ const hasMeta = Boolean(siteName || authorLine);
+ const hasBox = Boolean(title || siteName);
+
+ return {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ flexDirection: 'column',
+ justifyContent: 'center',
+ width: '1200px',
+ height: '630px',
+ backgroundColor: bgColor,
+ fontFamily: 'Inter, sans-serif',
+ padding: '72px 96px',
+ },
+ children: [
+ // Excerpt row with accent bar down its left side
+ {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ flexDirection: 'row',
+ alignItems: 'stretch',
+ gap: '28px',
+ width: '100%',
+ },
+ children: [
+ // Plain shape accent bar
+ {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ width: '6px',
+ backgroundColor: accentInk,
+ borderRadius: '3px',
+ flexShrink: 0,
+ },
+ children: [],
+ },
+ },
+ // Excerpt
+ {
+ type: 'p',
+ props: {
+ style: {
+ display: 'flex',
+ fontSize: `${quoteSize}px`,
+ fontWeight: 400,
+ color: quoteColor,
+ lineHeight: 1.4,
+ margin: 0,
+ maxWidth: '960px',
+ },
+ children: quote,
+ },
+ },
+ ],
+ },
+ },
+ // Link-style preview box
+ ...(hasBox
+ ? [
+ {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ flexDirection: 'column',
+ width: '100%',
+ marginTop: '44px',
+ padding: '28px 36px',
+ borderRadius: '16px',
+ border: boxBorder,
+ backgroundColor: boxBg,
+ },
+ children: [
+ ...(title
+ ? [
+ {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ fontSize: `${titleSize}px`,
+ fontWeight: 600,
+ color: quoteColor,
+ lineHeight: 1.3,
+ },
+ children: title,
+ },
+ },
+ ]
+ : []),
+ ...(hasMeta
+ ? [
+ {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ alignItems: 'center',
+ gap: '12px',
+ marginTop: title ? '16px' : '0px',
+ },
+ children: [
+ ...(siteName
+ ? [
+ {
+ type: 'span',
+ props: {
+ style: {
+ fontSize: '17px',
+ fontWeight: 600,
+ color: accentInk,
+ letterSpacing: '0.01em',
+ },
+ children: siteName,
+ },
+ },
+ ]
+ : []),
+ ...(siteName && authorLine
+ ? [
+ {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ width: '4px',
+ height: '4px',
+ borderRadius: '50%',
+ backgroundColor: metaColor,
+ },
+ children: [],
+ },
+ },
+ ]
+ : []),
+ ...(authorLine
+ ? [
+ {
+ type: 'span',
+ props: {
+ style: {
+ fontSize: '17px',
+ color: metaColor,
+ },
+ children: authorLine,
+ },
+ },
+ ]
+ : []),
+ ],
+ },
+ },
+ ]
+ : []),
+ ],
+ },
+ },
+ ]
+ : []),
+ ],
+ },
+ };
+ },
+};
+
+export default template;
diff --git a/src/templates/quote/code-comment.ts b/src/templates/quote/code-comment.ts
new file mode 100644
index 0000000..f24e171
--- /dev/null
+++ b/src/templates/quote/code-comment.ts
@@ -0,0 +1,202 @@
+import type { SatoriElement, TemplateDefinition } from '../types';
+import { truncate, mixColors, isLightColor, commonFields } from '../utils';
+
+const MONO = 'JetBrains Mono, monospace';
+// JetBrains Mono is fixed width: every character is 0.6 em wide.
+const CHAR_WIDTH = 0.6;
+const LINE_HEIGHT = 1.55;
+const MAX_QUOTE_LENGTH = 240;
+// Width of the text inside the panel, and the height the comment may use.
+const TEXT_WIDTH = 940;
+const TEXT_HEIGHT = 340;
+const FONT_SIZES = [40, 34, 30, 26, 22];
+
+/** Breaks a long word into pieces of at most `width` characters. */
+function splitWord(word: string, width: number): string[] {
+ const pieces: string[] = [];
+ for (let i = 0; i < word.length; i += width) pieces.push(word.slice(i, i + width));
+ return pieces;
+}
+
+/** Wraps one paragraph to lines of at most `width` characters, breaking at spaces. */
+function wrapParagraph(text: string, width: number): string[] {
+ const lines: string[] = [];
+ let line = '';
+ for (const word of text.split(' ').filter(Boolean)) {
+ for (const piece of word.length > width ? splitWord(word, width) : [word]) {
+ if (!line) line = piece;
+ else if (line.length + 1 + piece.length <= width) line += ` ${piece}`;
+ else {
+ lines.push(line);
+ line = piece;
+ }
+ }
+ }
+ if (line) lines.push(line);
+ return lines;
+}
+
+/** Wraps the quote, keeping the line breaks the author typed. An empty line stays empty. */
+function wrapQuote(quote: string, width: number): string[] {
+ return quote.split('\n').flatMap((paragraph) => {
+ const lines = wrapParagraph(paragraph, width);
+ return lines.length ? lines : [''];
+ });
+}
+
+const template: TemplateDefinition = {
+ id: 'quote-code-comment',
+ name: 'Code Comment',
+ category: 'quote',
+ description: 'A quote written as a block comment in an editor panel, with the author as an @author line and a file name tab',
+ tags: ['quote', 'code', 'comment', 'developer', 'editor'],
+ fields: [
+ { key: 'quote', label: 'Quote', type: 'textarea', defaultValue: 'Talk is cheap. Show me the code.', placeholder: 'Enter your quote...', required: true, group: 'Content' },
+ commonFields.author,
+ { key: 'fileName', label: 'File Name', type: 'text', defaultValue: 'quote.ts', placeholder: 'quote.ts', group: 'Content' },
+ commonFields.bgColor,
+ commonFields.accentColor,
+ commonFields.textColor,
+ ],
+ defaults: {
+ quote: 'Talk is cheap. Show me the code.',
+ author: 'Linus Torvalds',
+ fileName: 'quote.ts',
+ bgColor: '#0f111a',
+ accentColor: '#7dcfff',
+ textColor: '#e6e9f2',
+ },
+ render: (params) => {
+ const quote = truncate(
+ String(params.quote ?? '')
+ .replace(/\r\n?/g, '\n')
+ .replace(/[ \t]+/g, ' ')
+ .split('\n')
+ .map((line) => line.trim())
+ .join('\n')
+ .trim() || 'Talk is cheap. Show me the code.',
+ MAX_QUOTE_LENGTH,
+ );
+ const author = truncate(String(params.author ?? '').replace(/\s+/g, ' ').trim(), 40);
+ const fileName = truncate(String(params.fileName ?? '').replace(/\s+/g, ' ').trim(), 30);
+ const bgColor = String(params.bgColor || '#0f111a');
+ const accentColor = String(params.accentColor || '#7dcfff');
+ const textColor = String(params.textColor || '#e6e9f2');
+
+ // The comment is the opening line, the quote, the author and the closing line. Use the
+ // largest text size where all of it fits in the panel, and the smallest one otherwise.
+ const extraRows = 2 + (author ? 1 : 0);
+ const fits = (size: number, rows: number) => rows * size * LINE_HEIGHT <= TEXT_HEIGHT;
+ const widthFor = (size: number) => Math.floor((TEXT_WIDTH - 3 * size * CHAR_WIDTH) / (size * CHAR_WIDTH));
+ let fontSize = FONT_SIZES[FONT_SIZES.length - 1];
+ let quoteLines = wrapQuote(quote, widthFor(fontSize));
+ for (const size of FONT_SIZES) {
+ const lines = wrapQuote(quote, widthFor(size));
+ if (fits(size, lines.length + extraRows)) {
+ fontSize = size;
+ quoteLines = lines;
+ break;
+ }
+ }
+ // Even the smallest size can run out of room when the quote has many short lines.
+ const maxQuoteLines = Math.max(1, Math.floor(TEXT_HEIGHT / (fontSize * LINE_HEIGHT)) - extraRows);
+ if (quoteLines.length > maxQuoteLines) {
+ const kept = quoteLines.slice(0, maxQuoteLines);
+ kept[maxQuoteLines - 1] = truncate(`${kept[maxQuoteLines - 1]}…`, widthFor(fontSize));
+ quoteLines = kept;
+ }
+
+ // Neutral colors are mixed from the background and text colors, so the panel reads the
+ // same on dark and on light backgrounds.
+ const lightBg = isLightColor(bgColor);
+ const panelBg = mixColors(bgColor, textColor, lightBg ? 0.04 : 0.07);
+ const panelBorder = mixColors(bgColor, textColor, 0.2);
+ const markerColor = mixColors(bgColor, textColor, 0.55);
+ const fileNameColor = mixColors(bgColor, textColor, 0.8);
+ // An accent as light as the background (or as dark as it) is hard to read as text, so
+ // pull it toward the text color. Otherwise it reads as it is.
+ const accentText = isLightColor(accentColor) === lightBg ? mixColors(accentColor, textColor, 0.55) : accentColor;
+
+ const row = (marker: string, text: string, color: string, textWeight = 400): SatoriElement => ({
+ type: 'div',
+ props: {
+ style: { display: 'flex', height: `${Math.round(fontSize * LINE_HEIGHT)}px`, alignItems: 'center' },
+ children: [
+ { type: 'span', props: { style: { color: markerColor, whiteSpace: 'pre' }, children: marker } },
+ ...(text ? [{ type: 'span', props: { style: { color, fontWeight: textWeight, whiteSpace: 'pre' }, children: text } }] : []),
+ ],
+ },
+ });
+
+ const comment: SatoriElement[] = [
+ row('/**', '', markerColor),
+ ...quoteLines.map((line) => row(line ? ' * ' : ' *', line, textColor)),
+ ...(author ? [row(' * ', `@author ${author}`, accentText, 700)] : []),
+ row(' */', '', markerColor),
+ ];
+
+ return {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ flexDirection: 'column',
+ justifyContent: 'center',
+ width: '1200px',
+ height: '630px',
+ background: bgColor,
+ fontFamily: MONO,
+ padding: '60px 80px',
+ },
+ children: [
+ // File name tab
+ ...(fileName
+ ? [
+ {
+ type: 'div',
+ props: {
+ style: { display: 'flex' },
+ children: {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ padding: '10px 24px',
+ borderRadius: '12px 12px 0 0',
+ background: panelBg,
+ border: `1px solid ${panelBorder}`,
+ borderBottom: 'none',
+ },
+ children: {
+ type: 'span',
+ props: { style: { fontSize: '20px', color: fileNameColor }, children: fileName },
+ },
+ },
+ },
+ },
+ },
+ ]
+ : []),
+ // Editor panel
+ {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ flexDirection: 'column',
+ padding: '36px 40px',
+ background: panelBg,
+ border: `1px solid ${panelBorder}`,
+ borderRadius: fileName ? '0 16px 16px 16px' : '16px',
+ fontSize: `${fontSize}px`,
+ },
+ children: comment,
+ },
+ },
+ ],
+ },
+ };
+ },
+};
+
+export default template;
diff --git a/src/templates/quote/index.ts b/src/templates/quote/index.ts
index dc13d1f..3deb3e5 100644
--- a/src/templates/quote/index.ts
+++ b/src/templates/quote/index.ts
@@ -7,3 +7,7 @@ export { default as quoteBoldHighlight } from './bold-highlight';
export { default as quoteMinimalDash } from './minimal-dash';
export { default as quotePhotoBackground } from './photo-background';
export { default as quoteStickyNote } from './sticky-note';
+export { default as quoteCodeComment } from './code-comment';
+export { default as quoteSubtitle } from './subtitle';
+export { default as quoteArticleExcerpt } from './article-excerpt';
+export { default as quoteInterview } from './interview';
diff --git a/src/templates/quote/interview.ts b/src/templates/quote/interview.ts
new file mode 100644
index 0000000..0747c3c
--- /dev/null
+++ b/src/templates/quote/interview.ts
@@ -0,0 +1,145 @@
+import type { TemplateDefinition } from '../types';
+import { autoFontSize, commonFields, isLightColor, mixColors, truncate } from '../utils';
+
+const cleanText = (value: unknown, maxLength: number) =>
+ truncate(String(value ?? '').replace(/\s+/g, ' ').trim(), maxLength);
+
+const template: TemplateDefinition = {
+ id: 'quote-interview',
+ name: 'Interview Q&A',
+ category: 'quote',
+ description: 'Highlight a question and answer from an interview with the speaker and publication',
+ tags: ['quote', 'interview', 'question', 'answer', 'q&a'],
+ fields: [
+ { key: 'question', label: 'Question', type: 'text', defaultValue: 'What would you tell yourself on day one?', placeholder: 'Ask a question...', group: 'Content' },
+ { key: 'answer', label: 'Answer', type: 'textarea', defaultValue: 'Ship the small version first, then listen.', placeholder: 'Enter the answer...', required: true, group: 'Content' },
+ { ...commonFields.author, defaultValue: 'Priya Raman', placeholder: 'Person name' },
+ { key: 'role', label: 'Role', type: 'text', defaultValue: 'Staff Engineer at Acme', placeholder: 'Role or organization', group: 'Content' },
+ commonFields.siteName,
+ commonFields.bgColor,
+ commonFields.accentColor,
+ commonFields.textColor,
+ ],
+ defaults: {
+ question: 'What would you tell yourself on day one?',
+ answer: 'Ship the small version first, then listen.',
+ author: 'Priya Raman',
+ role: 'Staff Engineer at Acme',
+ siteName: 'THE BUILDER NOTES',
+ bgColor: '#0a0a0a',
+ accentColor: '#E07A5F',
+ textColor: '#FFFFFF',
+ },
+ render: (params) => {
+ const rawQuestion = String(params.question ?? '').replace(/\s+/g, ' ').trim();
+ const questionSize = autoFontSize(rawQuestion, [
+ { maxLen: 38, size: 38 },
+ { maxLen: 76, size: 32 },
+ { maxLen: 120, size: 28 },
+ { maxLen: 160, size: 24 },
+ ]);
+ const question = truncate(rawQuestion, 160);
+ const rawAnswer = String(params.answer ?? 'Ship the small version first, then listen.')
+ .replace(/\s+/g, ' ').trim() || 'Ship the small version first, then listen.';
+ const answerSize = autoFontSize(rawAnswer, [
+ { maxLen: 60, size: 54 },
+ { maxLen: 120, size: 46 },
+ { maxLen: 200, size: 38 },
+ { maxLen: 300, size: 32 },
+ ]);
+ const answer = truncate(rawAnswer, 300);
+ const author = cleanText(params.author, 48);
+ const role = cleanText(params.role, 64);
+ const siteName = cleanText(params.siteName, 48);
+ const bgColor = String(params.bgColor || '#0a0a0a');
+ const accentColor = String(params.accentColor || '#E07A5F');
+ const textColor = String(params.textColor || '#FFFFFF');
+ const lightBg = isLightColor(bgColor);
+ const accentInk = lightBg ? mixColors(accentColor, '#000000', 0.45) : accentColor;
+ const answerColor = lightBg && isLightColor(textColor) ? '#111111' : textColor;
+ const mutedColor = mixColors(bgColor, answerColor, 0.68);
+
+ return {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ flexDirection: 'column',
+ width: '1200px',
+ height: '630px',
+ boxSizing: 'border-box',
+ padding: '66px 84px 54px',
+ backgroundColor: bgColor,
+ fontFamily: 'Inter, sans-serif',
+ },
+ children: [
+ ...(question ? [{
+ type: 'div',
+ props: {
+ style: { display: 'flex', alignItems: 'flex-start', gap: '20px', minHeight: '76px' },
+ children: [
+ {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0,
+ width: '48px', height: '48px', borderRadius: '50%',
+ border: `2px solid ${accentInk}`, color: accentInk,
+ fontSize: '22px', fontWeight: 700, marginTop: '2px',
+ },
+ children: 'Q',
+ },
+ },
+ {
+ type: 'span',
+ props: {
+ style: { display: 'flex', flex: 1, minWidth: 0, fontSize: `${questionSize}px`, lineHeight: 1.2, fontWeight: 600, color: accentInk },
+ children: question,
+ },
+ },
+ ],
+ },
+ }] : []),
+ {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex', flexDirection: 'column', justifyContent: 'center', flex: 1,
+ minHeight: 0, marginTop: question ? '20px' : 0,
+ borderTop: question ? `1px solid ${mutedColor}55` : 'none',
+ },
+ children: [
+ { type: 'span', props: { style: { display: 'flex', fontSize: '22px', lineHeight: 1, fontWeight: 700, color: accentInk, marginBottom: '18px' }, children: 'A' } },
+ { type: 'span', props: { style: { display: 'flex', fontSize: `${answerSize}px`, lineHeight: 1.2, fontWeight: 600, color: answerColor }, children: answer } },
+ ],
+ },
+ },
+ ...(author || role || siteName ? [{
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex', alignItems: 'flex-end', justifyContent: 'space-between', gap: '24px',
+ minHeight: '56px', borderTop: `1px solid ${mutedColor}55`, paddingTop: '18px',
+ },
+ children: [
+ ...(author || role ? [{
+ type: 'div',
+ props: {
+ style: { display: 'flex', flex: '1 1 auto', flexDirection: 'column', gap: '4px', minWidth: 0 },
+ children: [
+ ...(author ? [{ type: 'span', props: { style: { fontSize: '20px', fontWeight: 700, color: answerColor }, children: author } }] : []),
+ ...(role ? [{ type: 'span', props: { style: { fontSize: '16px', color: mutedColor }, children: role } }] : []),
+ ],
+ },
+ }] : []),
+ ...(siteName ? [{ type: 'span', props: { style: { display: 'flex', flexShrink: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontSize: '18px', fontWeight: 600, color: mutedColor, letterSpacing: '0.06em' }, children: siteName } }] : []),
+ ],
+ },
+ }] : []),
+ ],
+ },
+ };
+ },
+};
+
+export default template;
diff --git a/src/templates/quote/subtitle.ts b/src/templates/quote/subtitle.ts
new file mode 100644
index 0000000..3f21bcf
--- /dev/null
+++ b/src/templates/quote/subtitle.ts
@@ -0,0 +1,188 @@
+import type { TemplateDefinition } from '../types';
+import { autoFontSize, commonFields, isLightColor, mixColors, truncate } from '../utils';
+
+const template: TemplateDefinition = {
+ id: 'quote-subtitle',
+ name: 'Subtitle',
+ category: 'quote',
+ description: 'Film-still letterbox frame with a subtitle quote, speaker, and source',
+ tags: ['quote', 'subtitle', 'film', 'cinema', 'video', 'talk'],
+ fields: [
+ {
+ key: 'quote',
+ label: 'Quote',
+ type: 'textarea',
+ defaultValue: 'Ship the small version first, then listen.',
+ placeholder: 'Enter your quote...',
+ required: true,
+ group: 'Content',
+ },
+ commonFields.author,
+ {
+ key: 'source',
+ label: 'Source',
+ type: 'text',
+ defaultValue: 'Opening keynote, 2026',
+ placeholder: 'Opening keynote, 2026',
+ group: 'Content',
+ },
+ commonFields.bgColor,
+ commonFields.accentColor,
+ commonFields.textColor,
+ ],
+ defaults: {
+ quote: 'Ship the small version first, then listen.',
+ author: 'Ana Ruiz',
+ source: 'Opening keynote, 2026',
+ bgColor: '#0a0a0a',
+ accentColor: '#E07A5F',
+ textColor: '#FFFFFF',
+ },
+ render: (params) => {
+ const rawQuote = String(params.quote || 'Ship the small version first, then listen.').trim() || 'Ship the small version first, then listen.';
+ const quoteSize = autoFontSize(rawQuote, [
+ { maxLen: 50, size: 48 },
+ { maxLen: 90, size: 40 },
+ { maxLen: 140, size: 34 },
+ { maxLen: 200, size: 28 },
+ ]);
+ const quote = truncate(rawQuote, 200);
+ const author = truncate(String(params.author || '').trim(), 60);
+ const source = truncate(String(params.source || '').trim(), 80);
+ const bgColor = String(params.bgColor || '#0a0a0a');
+ const accentColor = String(params.accentColor || '#E07A5F');
+ const textColor = String(params.textColor || '#FFFFFF');
+
+ const lightBg = isLightColor(bgColor);
+ const accentInk = lightBg ? mixColors(accentColor, '#000000', 0.45) : accentColor;
+ const quoteColor = lightBg && isLightColor(textColor) ? '#000000' : textColor;
+ const quoteShadow = lightBg
+ ? (isLightColor(quoteColor) ? '0 2px 4px rgba(0, 0, 0, 0.8)' : '0 1px 2px rgba(0, 0, 0, 0.15)')
+ : '0 2px 8px rgba(0, 0, 0, 0.8)';
+
+ return {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ flexDirection: 'column',
+ width: '1200px',
+ height: '630px',
+ backgroundColor: bgColor,
+ fontFamily: 'Inter, sans-serif',
+ position: 'relative',
+ },
+ children: [
+ // Top letterbox bar
+ {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ width: '1200px',
+ height: '60px',
+ backgroundColor: '#000000',
+ alignItems: 'center',
+ justifyContent: 'center',
+ padding: '0 40px',
+ flexShrink: 0,
+ borderBottom: lightBg ? '1px solid rgba(0, 0, 0, 0.15)' : '1px solid rgba(255, 255, 255, 0.12)',
+ },
+ children: source
+ ? [
+ {
+ type: 'span',
+ props: {
+ style: {
+ fontSize: '14px',
+ fontWeight: 600,
+ color: 'rgba(255, 255, 255, 0.65)',
+ letterSpacing: '0.08em',
+ textTransform: 'uppercase',
+ },
+ children: source,
+ },
+ },
+ ]
+ : [],
+ },
+ },
+ // Middle film still / subtitle area
+ {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ flexDirection: 'column',
+ alignItems: 'center',
+ justifyContent: 'flex-end',
+ flex: 1,
+ width: '1200px',
+ padding: '40px 100px 56px 100px',
+ background: lightBg
+ ? 'transparent'
+ : 'linear-gradient(180deg, rgba(255, 255, 255, 0.03) 0%, rgba(0, 0, 0, 0.2) 40%, rgba(0, 0, 0, 0.7) 100%)',
+ },
+ children: [
+ // Speaker
+ ...(author
+ ? [
+ {
+ type: 'span',
+ props: {
+ style: {
+ fontSize: '22px',
+ fontWeight: 600,
+ color: accentInk,
+ marginBottom: '16px',
+ letterSpacing: '0.03em',
+ textAlign: 'center',
+ textShadow: lightBg ? 'none' : '0 2px 4px rgba(0, 0, 0, 0.8)',
+ },
+ children: author,
+ },
+ },
+ ]
+ : []),
+ // Quote subtitle
+ {
+ type: 'p',
+ props: {
+ style: {
+ fontSize: `${quoteSize}px`,
+ fontWeight: 600,
+ color: quoteColor,
+ textAlign: 'center',
+ lineHeight: 1.35,
+ margin: 0,
+ maxWidth: '960px',
+ textShadow: quoteShadow,
+ },
+ children: quote,
+ },
+ },
+ ],
+ },
+ },
+ // Bottom letterbox bar
+ {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ width: '1200px',
+ height: '60px',
+ backgroundColor: '#000000',
+ flexShrink: 0,
+ borderTop: lightBg ? '1px solid rgba(0, 0, 0, 0.15)' : '1px solid rgba(255, 255, 255, 0.12)',
+ },
+ children: [],
+ },
+ },
+ ],
+ },
+ };
+ },
+};
+
+export default template;
diff --git a/src/templates/registry.ts b/src/templates/registry.ts
index f2c1e8f..e040c76 100644
--- a/src/templates/registry.ts
+++ b/src/templates/registry.ts
@@ -56,6 +56,9 @@ import blogGeometric from './blog/geometric';
import blogRetroTerminal from './blog/retro-terminal';
import blogSeriesPart from './blog/series-part';
import blogSummaryCard from './blog/summary-card';
+import blogTableOfContents from './blog/table-of-contents';
+import blogBenchmark from './blog/benchmark';
+import blogUpdatedPost from './blog/updated-post';
[
blogMinimalDark,
@@ -72,6 +75,9 @@ import blogSummaryCard from './blog/summary-card';
blogRetroTerminal,
blogSeriesPart,
blogSummaryCard,
+ blogTableOfContents,
+ blogBenchmark,
+ blogUpdatedPost,
].forEach(registerTemplate);
// Product
@@ -86,12 +92,13 @@ import productAnnouncement from './product/announcement';
import productMinimalBadge from './product/minimal-badge';
import productDarkPremium from './product/dark-premium';
import productShortcutTip from './product/shortcut-tip';
+import productWaitlist from './product/waitlist';
[
productCenteredHero, productSplitScreen, productGradientWave, productNeonGlow,
productGlassmorphism, productAppShowcase, productFeatureGrid, productAnnouncement,
productMinimalBadge, productDarkPremium,
- productShortcutTip,
+ productShortcutTip, productWaitlist,
].forEach(registerTemplate);
// SaaS
@@ -107,6 +114,11 @@ import saasFeatureSpotlight from './saas/feature-spotlight';
import saasSocialProof from './saas/social-proof';
import saasIntegration from './saas/integration';
import saasUptimeStatus from './saas/uptime-status';
+import saasApiEndpoint from './saas/api-endpoint';
+import saasRoadmap from './saas/roadmap';
+import saasMaintenance from './saas/maintenance';
+import saasDeprecation from './saas/deprecation';
+import saasIncident from './saas/incident';
[
saasDashboardPreview, saasPricingHighlight, saasMetricShowcase, saasTestimonialBanner,
@@ -114,6 +126,11 @@ import saasUptimeStatus from './saas/uptime-status';
saasFeatureSpotlight, saasSocialProof,
saasIntegration,
saasUptimeStatus,
+ saasApiEndpoint,
+ saasRoadmap,
+ saasMaintenance,
+ saasDeprecation,
+ saasIncident,
].forEach(registerTemplate);
// GitHub
@@ -130,6 +147,12 @@ import githubMatrix from './github/matrix';
import githubContributorsWall from './github/contributors-wall';
import githubIssueCard from './github/issue-card';
import githubPullRequest from './github/pull-request';
+import githubGoodFirstIssues from './github/good-first-issues';
+import githubMilestone from './github/milestone';
+import githubSponsorsGoal from './github/sponsors-goal';
+import githubSecurityAdvisory from './github/security-advisory';
+import githubMaintainersWanted from './github/maintainers-wanted';
+import githubPackageCard from './github/package-card';
[
githubRepoCard, githubContributionGraph, githubReadmeHero, githubReleaseNotes,
@@ -137,6 +160,12 @@ import githubPullRequest from './github/pull-request';
githubMinimalBadge, githubMatrix, githubContributorsWall,
githubIssueCard,
githubPullRequest,
+ githubGoodFirstIssues,
+ githubMilestone,
+ githubSponsorsGoal,
+ githubSecurityAdvisory,
+ githubMaintainersWanted,
+ githubPackageCard,
].forEach(registerTemplate);
// Event
@@ -151,11 +180,17 @@ import eventVenueMap from './event/venue-map';
import eventTicketCta from './event/ticket-cta';
import eventMultiSpeaker from './event/multi-speaker';
import eventAgenda from './event/agenda';
+import eventOfficeHours from './event/office-hours';
+import eventWinners from './event/winners';
+import eventCallForSpeakers from './event/call-for-speakers';
+import eventRescheduled from './event/rescheduled';
[
eventConference, eventMeetup, eventWebinar, eventWorkshop, eventHackathon,
eventSpeakerSpotlight, eventCountdown, eventVenueMap, eventTicketCta, eventMultiSpeaker,
- eventAgenda,
+ eventAgenda, eventOfficeHours, eventWinners,
+ eventCallForSpeakers,
+ eventRescheduled,
].forEach(registerTemplate);
// Podcast
@@ -168,10 +203,14 @@ import podcastMinimalPlayer from './podcast/minimal-player';
import podcastVideoThumbnail from './podcast/video-thumbnail';
import podcastAudiogram from './podcast/audiogram';
import podcastShowNotes from './podcast/show-notes';
+import podcastListenOn from './podcast/listen-on';
+import podcastTopEpisodes from './podcast/top-episodes';
+import podcastLiveRecording from './podcast/live-recording';
[
podcastEpisodeCard, podcastWaveform, podcastGuestSpotlight, podcastConversation, podcastSeriesBanner,
- podcastMinimalPlayer, podcastVideoThumbnail, podcastAudiogram, podcastShowNotes,
+ podcastMinimalPlayer, podcastVideoThumbnail, podcastAudiogram, podcastShowNotes, podcastTopEpisodes,
+ podcastListenOn, podcastLiveRecording,
].forEach(registerTemplate);
// Developer
@@ -185,11 +224,16 @@ import developerResumeHeader from './developer/resume-header';
import developerVscodeTheme from './developer/vscode-theme';
import developerLinkTree from './developer/link-tree';
import developerOpenToWork from './developer/open-to-work';
+import developerDayStreak from './developer/day-streak';
+import developerNow from './developer/now';
+import developerYearReview from './developer/year-review';
[
developerPortfolioCard, developerTechStack, developerTerminalBio, developerGradientProfile,
developerSkillsRadar, developerGithubStyle, developerResumeHeader, developerVscodeTheme,
- developerLinkTree, developerOpenToWork,
+ developerLinkTree, developerOpenToWork, developerDayStreak,
+ developerNow,
+ developerYearReview,
].forEach(registerTemplate);
// Newsletter
@@ -202,11 +246,16 @@ import newsletterMinimalNumber from './newsletter/minimal-number';
import newsletterNewspaperFold from './newsletter/newspaper-fold';
import newsletterSubscriberCount from './newsletter/subscriber-count';
import newsletterSubscribeCard from './newsletter/subscribe-card';
+import newsletterSponsor from './newsletter/sponsor';
+import newsletterPoll from './newsletter/poll';
+import newsletterWelcome from './newsletter/welcome';
+import newsletterMembersOnly from './newsletter/members-only';
[
newsletterIssueCard, newsletterDigestBanner, newsletterPreviewText, newsletterEditorial,
newsletterCuratedLinks, newsletterMinimalNumber, newsletterNewspaperFold, newsletterSubscriberCount,
- newsletterSubscribeCard,
+ newsletterSubscribeCard, newsletterSponsor, newsletterPoll,
+ newsletterWelcome, newsletterMembersOnly,
].forEach(registerTemplate);
// Quote
@@ -219,10 +268,15 @@ import quoteBoldHighlight from './quote/bold-highlight';
import quoteMinimalDash from './quote/minimal-dash';
import quotePhotoBackground from './quote/photo-background';
import quoteStickyNote from './quote/sticky-note';
+import quoteCodeComment from './quote/code-comment';
+import quoteSubtitle from './quote/subtitle';
+import quoteArticleExcerpt from './quote/article-excerpt';
+import quoteInterview from './quote/interview';
[
quoteCenteredSerif, quoteGradientCard, quoteSpeechBubble, quoteTwitterStyle,
quoteBookPage, quoteBoldHighlight, quoteMinimalDash, quotePhotoBackground, quoteStickyNote,
+ quoteCodeComment, quoteSubtitle, quoteArticleExcerpt, quoteInterview,
].forEach(registerTemplate);
// E-commerce
@@ -235,11 +289,16 @@ import ecommerceNewArrival from './ecommerce/new-arrival';
import ecommerceComparisonGrid from './ecommerce/comparison-grid';
import ecommerceBrandStory from './ecommerce/brand-story';
import ecommerceCouponCode from './ecommerce/coupon-code';
+import ecommerceBundleDeal from './ecommerce/bundle-deal';
+import ecommerceOrderStatus from './ecommerce/order-status';
+import ecommerceGiftCard from './ecommerce/gift-card';
+import ecommerceBackInStock from './ecommerce/back-in-stock';
[
ecommerceProductCard, ecommerceSaleBanner, ecommerceCollection, ecommerceReviewHighlight,
ecommercePriceDrop, ecommerceNewArrival, ecommerceComparisonGrid, ecommerceBrandStory,
- ecommerceCouponCode,
+ ecommerceCouponCode, ecommerceOrderStatus, ecommerceBundleDeal, ecommerceGiftCard,
+ ecommerceBackInStock,
].forEach(registerTemplate);
// Job
@@ -252,11 +311,16 @@ import jobTeamPhoto from './job/team-photo';
import jobStartupHiring from './job/startup-hiring';
import jobDeveloperRole from './job/developer-role';
import jobOpenRoles from './job/open-roles';
+import jobInterviewProcess from './job/interview-process';
+import jobBenefits from './job/benefits';
+import jobInternship from './job/internship';
+import jobReferral from './job/referral';
+import jobNewHire from './job/new-hire';
[
jobListingCard, jobTechStackRole, jobRemoteBadge, jobCompanyCulture,
jobSalaryHighlight, jobTeamPhoto, jobStartupHiring, jobDeveloperRole,
- jobOpenRoles,
+ jobOpenRoles, jobInterviewProcess, jobBenefits, jobInternship, jobReferral, jobNewHire,
].forEach(registerTemplate);
// Tutorial
@@ -269,9 +333,16 @@ import tutorialCodeTutorial from './tutorial/code-tutorial';
import tutorialWorkshopBanner from './tutorial/workshop-banner';
import tutorialCertification from './tutorial/certification';
import tutorialQuizCard from './tutorial/quiz-card';
+import tutorialCheatSheet from './tutorial/cheat-sheet';
+import tutorialBeforeAfter from './tutorial/before-after';
+import tutorialGlossary from './tutorial/glossary';
+import tutorialPrerequisites from './tutorial/prerequisites';
+import tutorialErrorExplained from './tutorial/error-explained';
+import tutorialTil from './tutorial/til';
[
tutorialCourseCard, tutorialStepByStep, tutorialVideoLesson, tutorialDifficultyBadge,
tutorialPlatformBranded, tutorialCodeTutorial, tutorialWorkshopBanner, tutorialCertification,
- tutorialQuizCard,
+ tutorialQuizCard, tutorialCheatSheet, tutorialBeforeAfter, tutorialGlossary, tutorialPrerequisites,
+ tutorialErrorExplained, tutorialTil,
].forEach(registerTemplate);
diff --git a/src/templates/saas/api-endpoint.ts b/src/templates/saas/api-endpoint.ts
new file mode 100644
index 0000000..7238a3b
--- /dev/null
+++ b/src/templates/saas/api-endpoint.ts
@@ -0,0 +1,161 @@
+import type { TemplateDefinition } from '../types';
+import { autoFontSize, commonFields, isLightColor, mixColors, truncate } from '../utils';
+
+const METHODS = {
+ GET: '#86efac',
+ POST: '#93c5fd',
+ PUT: '#fcd34d',
+ PATCH: '#fcd34d',
+ DELETE: '#fca5a5',
+};
+
+const template: TemplateDefinition = {
+ id: 'saas-api-endpoint',
+ name: 'API Endpoint',
+ category: 'saas',
+ description: 'Announce an API endpoint with its HTTP method, path and example response',
+ tags: ['saas', 'api', 'endpoint', 'http', 'docs', 'changelog'],
+ fields: [
+ commonFields.siteName,
+ {
+ key: 'method', label: 'HTTP Method', type: 'select', defaultValue: 'GET',
+ options: Object.keys(METHODS).map((method) => ({ label: method, value: method })),
+ group: 'Content',
+ },
+ {
+ key: 'path', label: 'Endpoint Path', type: 'text', defaultValue: '/v1/invoices/{id}',
+ placeholder: '/v1/invoices/{id}', required: true, group: 'Content',
+ },
+ commonFields.description,
+ {
+ key: 'responseStatus', label: 'Response Status', type: 'text', defaultValue: '200 OK',
+ placeholder: '200 OK', group: 'Content',
+ },
+ {
+ key: 'responseExample', label: 'Response Example', type: 'text', defaultValue: '',
+ placeholder: '{ "id": "inv_123", "status": "paid" }', group: 'Content',
+ },
+ commonFields.bgColor,
+ commonFields.accentColor,
+ commonFields.textColor,
+ ],
+ defaults: {
+ siteName: 'Acme API',
+ method: 'GET',
+ path: '/v1/invoices/{id}',
+ description: 'Retrieve an invoice and its payment details with one simple request.',
+ responseStatus: '200 OK',
+ responseExample: '',
+ bgColor: '#0b1220',
+ accentColor: '#60a5fa',
+ textColor: '#f8fafc',
+ },
+ render: (params) => {
+ const siteName = truncate(String(params.siteName || 'Acme API'), 36);
+ const rawMethod = String(params.method || 'GET').toUpperCase();
+ const method = Object.hasOwn(METHODS, rawMethod) ? rawMethod as keyof typeof METHODS : 'GET';
+ const path = truncate(String(params.path || '/v1/invoices/{id}'), 64);
+ const description = truncate(String(params.description ?? ''), 84);
+ const responseStatus = truncate(String(params.responseStatus ?? '200 OK').trim(), 40);
+ const responseExample = truncate(String(params.responseExample ?? '').trim(), 72);
+ const bgColor = String(params.bgColor || '#0b1220');
+ const accentColor = String(params.accentColor || '#60a5fa');
+ const accentTextColor = isLightColor(bgColor) ? mixColors(accentColor, '#000000', 0.45) : accentColor;
+ const textColor = String(params.textColor || '#f8fafc');
+ const pathSize = autoFontSize(path, [
+ { maxLen: 26, size: 48 },
+ { maxLen: 38, size: 36 },
+ { maxLen: 50, size: 28 },
+ { maxLen: 64, size: 22 },
+ ]);
+ const descriptionSize = autoFontSize(description, [
+ { maxLen: 72, size: 28 },
+ { maxLen: 84, size: 24 },
+ ]);
+ const nameSize = autoFontSize(siteName, [
+ { maxLen: 24, size: 28 },
+ { maxLen: 36, size: 24 },
+ ]);
+
+ return {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex', flexDirection: 'column', justifyContent: 'space-between',
+ width: '1200px', height: '630px', padding: '64px',
+ background: bgColor, color: textColor, fontFamily: 'Inter, sans-serif',
+ },
+ children: [
+ {
+ type: 'span',
+ props: { style: { fontSize: `${nameSize}px`, fontWeight: 600, color: accentTextColor }, children: siteName },
+ },
+ {
+ type: 'div',
+ props: {
+ style: { display: 'flex', flexDirection: 'column', justifyContent: 'center', flex: 1, gap: '28px' },
+ children: [
+ {
+ type: 'div',
+ props: {
+ style: { display: 'flex', alignItems: 'center', gap: '24px', width: '100%' },
+ children: [
+ {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex', alignItems: 'center', padding: '14px 22px', borderRadius: '999px',
+ background: METHODS[method], color: '#0b1220', flexShrink: 0,
+ fontFamily: 'JetBrains Mono, monospace', fontSize: '28px', fontWeight: 700,
+ },
+ children: method,
+ },
+ },
+ {
+ type: 'span',
+ props: {
+ style: {
+ fontFamily: 'JetBrains Mono, monospace', fontSize: `${pathSize}px`,
+ fontWeight: 700, whiteSpace: 'nowrap',
+ },
+ children: path,
+ },
+ },
+ ],
+ },
+ },
+ ...(description ? [{
+ type: 'span',
+ props: {
+ style: { fontSize: `${descriptionSize}px`, lineHeight: 1.45, width: '1000px', wordBreak: 'break-word' },
+ children: description,
+ },
+ }] : []),
+ ],
+ },
+ },
+ ...(responseStatus || responseExample ? [{
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex', flexDirection: 'column', gap: '12px', padding: '24px 28px',
+ background: mixColors(bgColor, textColor, 0.07), borderRadius: '12px',
+ fontFamily: 'JetBrains Mono, monospace',
+ },
+ children: [
+ ...(responseStatus ? [{
+ type: 'span', props: { style: { fontSize: '22px', fontWeight: 700 }, children: responseStatus },
+ }] : []),
+ ...(responseExample ? [{
+ type: 'span', props: { style: { fontSize: '22px' }, children: responseExample },
+ }] : []),
+ ],
+ },
+ }] : []),
+ ],
+ },
+ };
+ },
+};
+
+export default template;
diff --git a/src/templates/saas/deprecation.ts b/src/templates/saas/deprecation.ts
new file mode 100644
index 0000000..851e3bd
--- /dev/null
+++ b/src/templates/saas/deprecation.ts
@@ -0,0 +1,136 @@
+import type { SatoriElement, TemplateDefinition } from '../types';
+import { arrowRightIcon, autoFontSize, commonFields, isLightColor, mixColors, truncate } from '../utils';
+
+const cleanText = (value: unknown, maxLength: number) =>
+ truncate(String(value ?? '').replace(/\s+/g, ' ').trim(), maxLength);
+
+const template: TemplateDefinition = {
+ id: 'saas-deprecation',
+ name: 'Deprecation Notice',
+ category: 'saas',
+ description: 'Announce a deprecated API, feature, or version with its replacement and sunset date',
+ tags: ['saas', 'deprecated', 'deprecation', 'sunset', 'migration'],
+ fields: [
+ commonFields.title,
+ { key: 'replacement', label: 'Replacement', type: 'text', defaultValue: 'API v2', placeholder: 'What replaces it', group: 'Content' },
+ { key: 'sunsetDate', label: 'Sunset Date', type: 'text', defaultValue: 'Shuts down on March 31, 2027', placeholder: 'When it will be unavailable', group: 'Content' },
+ commonFields.description,
+ commonFields.siteName,
+ commonFields.bgColor,
+ commonFields.accentColor,
+ commonFields.textColor,
+ ],
+ defaults: {
+ title: 'API v1',
+ replacement: 'API v2',
+ sunsetDate: 'Shuts down on March 31, 2027',
+ description: 'The migration guide is in the docs',
+ siteName: 'Acme API',
+ bgColor: '#0b0f1a',
+ accentColor: '#f97316',
+ textColor: '#f8fafc',
+ },
+ render: (params) => {
+ const rawTitle = String(params.title || 'API v1').replace(/\s+/g, ' ').trim() || 'API v1';
+ const titleSize = autoFontSize(rawTitle, [
+ { maxLen: 16, size: 76 },
+ { maxLen: 28, size: 64 },
+ { maxLen: 44, size: 52 },
+ { maxLen: 72, size: 42 },
+ ]);
+ const title = truncate(rawTitle, 44);
+ const rawReplacement = String(params.replacement ?? '').replace(/\s+/g, ' ').trim();
+ const replacementSize = autoFontSize(rawReplacement, [
+ { maxLen: 16, size: 76 },
+ { maxLen: 28, size: 64 },
+ { maxLen: 44, size: 52 },
+ { maxLen: 72, size: 42 },
+ ]);
+ const replacement = truncate(rawReplacement, 44);
+ const sunsetDate = cleanText(params.sunsetDate, 72);
+ const description = cleanText(params.description, 100);
+ const siteName = cleanText(params.siteName, 40);
+ const bgColor = String(params.bgColor || '#0b0f1a');
+ const accentColor = String(params.accentColor || '#f97316');
+ const textColor = String(params.textColor || '#f8fafc');
+ const accentInk = isLightColor(bgColor) ? mixColors(accentColor, '#000000', 0.45) : accentColor;
+ const mutedColor = mixColors(bgColor, textColor, 0.68);
+
+ const nameRow: SatoriElement[] = [
+ {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ flexDirection: 'column',
+ ...(replacement ? { flex: 1 } : {}),
+ minWidth: 0,
+ },
+ children: [
+ { type: 'span', props: { style: { fontSize: '16px', fontWeight: 700, letterSpacing: '0.12em', color: mutedColor, textTransform: 'uppercase' }, children: 'NO LONGER SUPPORTED' } },
+ { type: 'h1', props: { style: { display: 'flex', overflow: 'hidden', fontSize: `${titleSize}px`, lineHeight: 1.05, fontWeight: 700, color: textColor, textDecoration: 'line-through', margin: '18px 0 0' }, children: title } },
+ ],
+ },
+ },
+ ...(replacement
+ ? [
+ { type: 'div', props: { style: { display: 'flex', flexShrink: 0, margin: '0 28px' }, children: arrowRightIcon(50, accentInk) } },
+ {
+ type: 'div',
+ props: {
+ style: { display: 'flex', flexDirection: 'column', flex: 1, minWidth: 0 },
+ children: [
+ { type: 'span', props: { style: { fontSize: '16px', fontWeight: 700, letterSpacing: '0.12em', color: accentInk, textTransform: 'uppercase' }, children: 'USE INSTEAD' } },
+ { type: 'span', props: { style: { display: 'flex', overflow: 'hidden', fontSize: `${replacementSize}px`, lineHeight: 1.05, fontWeight: 700, color: textColor, marginTop: '18px' }, children: replacement } },
+ ],
+ },
+ },
+ ]
+ : []),
+ ];
+
+ return {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ flexDirection: 'column',
+ width: '1200px',
+ height: '630px',
+ padding: '58px 72px 48px',
+ boxSizing: 'border-box',
+ backgroundColor: bgColor,
+ fontFamily: 'Inter, sans-serif',
+ },
+ children: [
+ {
+ type: 'div',
+ props: { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', minHeight: '54px' }, children: [
+ {
+ type: 'div',
+ props: { style: { display: 'flex', alignItems: 'center', padding: '10px 20px', borderRadius: '999px', backgroundColor: `${accentInk}14`, border: `1px solid ${accentInk}66` }, children: {
+ type: 'span', props: { style: { fontSize: '18px', fontWeight: 700, letterSpacing: '0.1em', textTransform: 'uppercase', color: accentInk }, children: 'Deprecated' },
+ } },
+ },
+ ] },
+ },
+ {
+ type: 'div',
+ props: { style: { display: 'flex', alignItems: 'center', flex: 1, minHeight: 0, marginTop: '28px' }, children: nameRow },
+ },
+ ...(sunsetDate
+ ? [{ type: 'span', props: { style: { fontSize: '30px', fontWeight: 700, lineHeight: 1.2, color: accentInk, marginTop: '16px' }, children: sunsetDate } }]
+ : []),
+ ...(description
+ ? [{ type: 'span', props: { style: { fontSize: '22px', lineHeight: 1.3, color: mutedColor, marginTop: '12px' }, children: description } }]
+ : []),
+ ...(siteName
+ ? [{ type: 'span', props: { style: { alignSelf: 'flex-end', fontSize: '20px', fontWeight: 600, color: mutedColor, marginTop: 'auto' }, children: siteName } }]
+ : []),
+ ],
+ },
+ };
+ },
+};
+
+export default template;
diff --git a/src/templates/saas/incident.ts b/src/templates/saas/incident.ts
new file mode 100644
index 0000000..b3a6214
--- /dev/null
+++ b/src/templates/saas/incident.ts
@@ -0,0 +1,317 @@
+import type { TemplateDefinition } from '../types';
+import { autoFontSize, commonFields, isLightColor, mixColors, truncate } from '../utils';
+
+const STEPS = ['Investigating', 'Identified', 'Monitoring', 'Resolved'] as const;
+
+const STATUS_INDEX: Record
= {
+ investigating: 0,
+ identified: 1,
+ monitoring: 2,
+ resolved: 3,
+};
+
+const template: TemplateDefinition = {
+ id: 'saas-incident',
+ name: 'Incident Update',
+ category: 'saas',
+ description: 'Status-page incident card with the four progress steps filled up to the current status',
+ tags: ['saas', 'incident', 'status', 'outage', 'status-page'],
+ fields: [
+ commonFields.title,
+ {
+ key: 'status',
+ label: 'Status',
+ type: 'select',
+ defaultValue: 'identified',
+ options: [
+ { label: 'Investigating', value: 'investigating' },
+ { label: 'Identified', value: 'identified' },
+ { label: 'Monitoring', value: 'monitoring' },
+ { label: 'Resolved', value: 'resolved' },
+ ],
+ group: 'Content',
+ },
+ { key: 'update', label: 'Latest Update', type: 'text', defaultValue: 'A fix is rolling out to all regions', placeholder: 'A fix is rolling out to all regions', group: 'Content' },
+ { key: 'started', label: 'Started', type: 'text', defaultValue: 'Started 14:05 UTC', placeholder: 'Started 14:05 UTC', group: 'Content' },
+ commonFields.siteName,
+ commonFields.bgColor,
+ commonFields.accentColor,
+ commonFields.textColor,
+ ],
+ defaults: {
+ title: 'Elevated API error rates',
+ status: 'identified',
+ update: 'A fix is rolling out to all regions',
+ started: 'Started 14:05 UTC',
+ siteName: 'status.acme.dev',
+ bgColor: '#0a0a0a',
+ accentColor: '#E07A5F',
+ textColor: '#FFFFFF',
+ },
+ render: (params) => {
+ const rawTitle = String(params.title || 'Elevated API error rates').trim() || 'Elevated API error rates';
+ const titleSize = autoFontSize(rawTitle, [
+ { maxLen: 24, size: 64 },
+ { maxLen: 40, size: 52 },
+ { maxLen: 60, size: 42 },
+ ]);
+ const title = truncate(rawTitle, 60);
+ const rawStatus = String(params.status || 'identified').trim().toLowerCase();
+ // An unknown or blank status falls back to the default step. `Object.hasOwn` is
+ // needed here: a name every object inherits, like `constructor`, would otherwise
+ // resolve to a built-in function and slip past the fallback.
+ const statusIndex = Object.hasOwn(STATUS_INDEX, rawStatus)
+ ? STATUS_INDEX[rawStatus]
+ : STATUS_INDEX.identified;
+ const update = truncate(String(params.update || '').trim(), 90);
+ const started = truncate(String(params.started || '').trim(), 40);
+ const siteName = truncate(String(params.siteName || '').trim(), 40);
+ const bgColor = String(params.bgColor || '#0a0a0a');
+ const accentColor = String(params.accentColor || '#E07A5F');
+ const textColor = String(params.textColor || '#FFFFFF');
+ // The label, the reached steps and their connectors are drawn in the accent,
+ // so they are darkened when the page background is light.
+ const accentInk = isLightColor(bgColor) ? mixColors(accentColor, '#000000', 0.45) : accentColor;
+
+ const steps = STEPS.map((label, index) => {
+ const reached = index <= statusIndex;
+ return {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ flexDirection: 'column',
+ flex: 1,
+ minWidth: 0,
+ },
+ children: [
+ // Marker and the connector that runs to the next step
+ {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ alignItems: 'center',
+ width: '100%',
+ },
+ children: [
+ {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ width: '30px',
+ height: '30px',
+ borderRadius: '50%',
+ boxSizing: 'border-box',
+ flexShrink: 0,
+ backgroundColor: reached ? accentInk : 'transparent',
+ border: `2px solid ${reached ? accentInk : `${textColor}33`}`,
+ },
+ children: reached
+ ? [
+ {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ width: '10px',
+ height: '10px',
+ borderRadius: '50%',
+ backgroundColor: bgColor,
+ },
+ children: '',
+ },
+ },
+ ]
+ : [],
+ },
+ },
+ // The connector is filled once the next step is reached too
+ ...(index < STEPS.length - 1
+ ? [
+ {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ flex: 1,
+ height: '2px',
+ marginLeft: '12px',
+ backgroundColor: index + 1 <= statusIndex ? accentInk : `${textColor}1F`,
+ },
+ children: '',
+ },
+ },
+ ]
+ : []),
+ ],
+ },
+ },
+ {
+ type: 'span',
+ props: {
+ style: {
+ fontSize: '17px',
+ fontWeight: reached ? 700 : 500,
+ color: reached ? textColor : `${textColor}66`,
+ marginTop: '14px',
+ },
+ children: label,
+ },
+ },
+ ],
+ },
+ };
+ });
+
+ return {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ flexDirection: 'column',
+ justifyContent: 'center',
+ width: '1200px',
+ height: '630px',
+ boxSizing: 'border-box',
+ backgroundColor: bgColor,
+ fontFamily: 'Inter, sans-serif',
+ padding: '64px 80px',
+ },
+ children: [
+ // "Incident" label
+ {
+ type: 'span',
+ props: {
+ style: {
+ fontSize: '16px',
+ fontWeight: 700,
+ color: accentInk,
+ letterSpacing: '0.16em',
+ textTransform: 'uppercase' as const,
+ },
+ children: 'Incident',
+ },
+ },
+ // Incident title
+ {
+ type: 'h1',
+ props: {
+ style: {
+ fontSize: `${titleSize}px`,
+ fontWeight: 700,
+ color: textColor,
+ lineHeight: 1.15,
+ margin: 0,
+ marginTop: '16px',
+ },
+ children: title,
+ },
+ },
+ // Investigating, Identified, Monitoring, Resolved
+ {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ alignItems: 'flex-start',
+ width: '100%',
+ marginTop: '48px',
+ },
+ children: steps,
+ },
+ },
+ // Latest update
+ ...(update
+ ? [
+ {
+ type: 'p',
+ props: {
+ style: {
+ fontSize: '22px',
+ fontWeight: 500,
+ color: `${textColor}CC`,
+ lineHeight: 1.4,
+ margin: 0,
+ marginTop: '48px',
+ },
+ children: update,
+ },
+ },
+ ]
+ : []),
+ // Started and site name
+ ...(started || siteName
+ ? [
+ {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ alignItems: 'center',
+ gap: '16px',
+ marginTop: update ? '18px' : '48px',
+ },
+ children: [
+ ...(started
+ ? [
+ {
+ type: 'span',
+ props: {
+ style: {
+ fontSize: '18px',
+ fontWeight: 600,
+ color: accentInk,
+ },
+ children: started,
+ },
+ },
+ ]
+ : []),
+ ...(started && siteName
+ ? [
+ {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ width: '4px',
+ height: '4px',
+ borderRadius: '50%',
+ backgroundColor: `${textColor}44`,
+ },
+ children: '',
+ },
+ },
+ ]
+ : []),
+ ...(siteName
+ ? [
+ {
+ type: 'span',
+ props: {
+ style: {
+ fontSize: '18px',
+ fontWeight: 500,
+ color: `${textColor}66`,
+ },
+ children: siteName,
+ },
+ },
+ ]
+ : []),
+ ],
+ },
+ },
+ ]
+ : []),
+ ],
+ },
+ };
+ },
+};
+
+export default template;
diff --git a/src/templates/saas/index.ts b/src/templates/saas/index.ts
index ddfb5cd..e13a68e 100644
--- a/src/templates/saas/index.ts
+++ b/src/templates/saas/index.ts
@@ -10,3 +10,8 @@ export { default as saasFeatureSpotlight } from './feature-spotlight';
export { default as saasSocialProof } from './social-proof';
export { default as saasIntegration } from './integration';
export { default as saasUptimeStatus } from './uptime-status';
+export { default as saasApiEndpoint } from './api-endpoint';
+export { default as saasRoadmap } from './roadmap';
+export { default as saasMaintenance } from './maintenance';
+export { default as saasDeprecation } from './deprecation';
+export { default as saasIncident } from './incident';
diff --git a/src/templates/saas/maintenance.ts b/src/templates/saas/maintenance.ts
new file mode 100644
index 0000000..a54feba
--- /dev/null
+++ b/src/templates/saas/maintenance.ts
@@ -0,0 +1,109 @@
+import type { SatoriElement, TemplateDefinition } from '../types';
+import { autoFontSize, commonFields, isLightColor, mixColors, truncate } from '../utils';
+
+const STATUS = {
+ scheduled: 'Scheduled maintenance',
+ 'in-progress': 'Maintenance in progress',
+ completed: 'Maintenance completed',
+};
+const MAX_SERVICES = 4;
+
+const template: TemplateDefinition = {
+ id: 'saas-maintenance',
+ name: 'Scheduled Maintenance',
+ category: 'saas',
+ description: 'Maintenance notice with a status pill, time window, expected impact and affected services',
+ tags: ['saas', 'maintenance', 'status', 'downtime', 'incident'],
+ fields: [
+ commonFields.siteName,
+ { ...commonFields.title, defaultValue: 'Database upgrade', placeholder: 'e.g. Database upgrade' },
+ {
+ key: 'status', label: 'Status', type: 'select', defaultValue: 'scheduled',
+ options: [
+ { label: 'Scheduled', value: 'scheduled' },
+ { label: 'In progress', value: 'in-progress' },
+ { label: 'Completed', value: 'completed' },
+ ],
+ group: 'Content',
+ },
+ { key: 'timeWindow', label: 'Time Window', type: 'text', defaultValue: 'Sat, Nov 1, 02:00 to 04:00 UTC', placeholder: 'Sat, Nov 1, 02:00 to 04:00 UTC', group: 'Content' },
+ { key: 'impact', label: 'Impact', type: 'text', defaultValue: 'The API is read-only for up to 30 minutes', placeholder: 'What users will notice', group: 'Content' },
+ { key: 'services', label: 'Affected Services', type: 'text', defaultValue: 'API, Dashboard, Webhooks', placeholder: 'API, Dashboard, Webhooks', group: 'Content' },
+ commonFields.bgColor, commonFields.accentColor, commonFields.textColor,
+ ],
+ defaults: {
+ siteName: 'Acme Cloud', title: 'Database upgrade', status: 'scheduled',
+ timeWindow: 'Sat, Nov 1, 02:00 to 04:00 UTC', impact: 'The API is read-only for up to 30 minutes', services: 'API, Dashboard, Webhooks',
+ bgColor: '#0f172a', accentColor: '#f59e0b', textColor: '#f8fafc',
+ },
+ render: (params) => {
+ const siteName = truncate(String(params.siteName ?? '').trim(), 32);
+ const title = truncate(String(params.title || 'Database upgrade').trim(), 60);
+ const rawStatus = String(params.status ?? '').trim().toLowerCase();
+ const status = Object.hasOwn(STATUS, rawStatus) ? STATUS[rawStatus as keyof typeof STATUS] : STATUS.scheduled;
+ const timeWindow = truncate(String(params.timeWindow ?? '').trim(), 50);
+ const impact = truncate(String(params.impact ?? '').trim(), 80);
+ const services = String(params.services ?? '').split(',').map((s) => s.trim()).filter(Boolean);
+ const bgColor = String(params.bgColor || '#0f172a');
+ const accentColor = String(params.accentColor || '#f59e0b');
+ const textColor = String(params.textColor || '#f8fafc');
+ const accentInk = isLightColor(bgColor) ? mixColors(accentColor, '#000000', 0.45) : accentColor;
+ const titleSize = autoFontSize(title, [
+ { maxLen: 24, size: 72 },
+ { maxLen: 40, size: 60 },
+ { maxLen: 60, size: 48 },
+ ]);
+
+ const chipLabels = services.slice(0, MAX_SERVICES).map((s) => truncate(s, 24));
+ if (services.length > MAX_SERVICES) chipLabels.push(`+${services.length - MAX_SERVICES} more`);
+ const chips: SatoriElement[] = chipLabels.map((label) => ({
+ type: 'div',
+ props: {
+ style: { display: 'flex', padding: '8px 18px', borderRadius: '999px', backgroundColor: `${textColor}0d`, border: `1px solid ${textColor}33` },
+ children: { type: 'span', props: { style: { fontSize: '20px', fontWeight: 500, color: `${textColor}cc` }, children: label } },
+ },
+ }));
+
+ return {
+ type: 'div',
+ props: {
+ style: { display: 'flex', flexDirection: 'column', width: '1200px', height: '630px', padding: '60px 72px', boxSizing: 'border-box', backgroundColor: bgColor, fontFamily: 'Inter, sans-serif' },
+ children: [
+ {
+ type: 'div',
+ props: {
+ style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between' },
+ children: [
+ {
+ type: 'div',
+ props: {
+ style: { display: 'flex', alignItems: 'center', gap: '10px', padding: '10px 20px', borderRadius: '999px', backgroundColor: `${accentColor}22`, border: `1px solid ${accentColor}` },
+ children: [
+ { type: 'div', props: { style: { display: 'flex', width: '10px', height: '10px', borderRadius: '50%', backgroundColor: accentColor }, children: [] } },
+ { type: 'span', props: { style: { fontSize: '20px', fontWeight: 600, color: accentInk }, children: status } },
+ ],
+ },
+ },
+ ...(siteName ? [{ type: 'span', props: { style: { fontSize: '24px', fontWeight: 600, color: `${textColor}b3` }, children: siteName } }] : []),
+ ],
+ },
+ },
+ {
+ type: 'div',
+ props: {
+ style: { display: 'flex', flexDirection: 'column', flex: 1, justifyContent: 'center' },
+ children: [
+ { type: 'h1', props: { style: { fontSize: `${titleSize}px`, lineHeight: 1.1, fontWeight: 700, color: textColor, margin: 0 }, children: title } },
+ ...(timeWindow ? [{ type: 'span', props: { style: { fontSize: '30px', fontWeight: 600, color: accentInk, marginTop: '24px' }, children: timeWindow } }] : []),
+ ...(impact ? [{ type: 'span', props: { style: { fontSize: '26px', color: `${textColor}b3`, marginTop: '12px' }, children: impact } }] : []),
+ ],
+ },
+ },
+ ...(chips.length ? [{ type: 'div', props: { style: { display: 'flex', flexWrap: 'wrap', gap: '12px' }, children: chips } }] : []),
+ ],
+ },
+ };
+ },
+};
+
+export default template;
diff --git a/src/templates/saas/pricing-highlight.ts b/src/templates/saas/pricing-highlight.ts
index dc28479..fdd5081 100644
--- a/src/templates/saas/pricing-highlight.ts
+++ b/src/templates/saas/pricing-highlight.ts
@@ -38,7 +38,7 @@ const template: TemplateDefinition = {
render: (params) => {
const title = truncate(String(params.title || 'Simple, transparent pricing'), 60);
const planName = String(params.planName || 'Pro');
- const price = String(params.price || '$29');
+ const price = truncate(String(params.price || '$29'), 12);
const period = String(params.period || '/month');
const features = [
String(params.feature1 || 'Unlimited projects'),
@@ -55,6 +55,11 @@ const template: TemplateDefinition = {
{ maxLen: 40, size: 38 },
{ maxLen: 60, size: 32 },
]);
+ const priceSize = autoFontSize(price, [
+ { maxLen: 6, size: 56 },
+ { maxLen: 9, size: 44 },
+ { maxLen: 12, size: 36 },
+ ]);
return {
type: 'div',
@@ -181,10 +186,11 @@ const template: TemplateDefinition = {
type: 'span',
props: {
style: {
- fontSize: '56px',
+ fontSize: `${priceSize}px`,
fontWeight: 800,
color: textColor,
lineHeight: 1,
+ flexShrink: 0,
},
children: price,
},
diff --git a/src/templates/saas/roadmap.ts b/src/templates/saas/roadmap.ts
new file mode 100644
index 0000000..2e7fefe
--- /dev/null
+++ b/src/templates/saas/roadmap.ts
@@ -0,0 +1,62 @@
+import type { SatoriElement, TemplateDefinition } from '../types';
+import { commonFields, isLightColor, mixColors, truncate } from '../utils';
+
+const template: TemplateDefinition = {
+ id: 'saas-roadmap',
+ name: 'Roadmap',
+ category: 'saas',
+ description: 'Product roadmap card with Now, Next, and Later columns',
+ tags: ['roadmap', 'product', 'saas', 'launch', 'planning'],
+ fields: [
+ commonFields.siteName,
+ commonFields.title,
+ { key: 'now', label: 'Now', type: 'text', defaultValue: 'Dark mode', placeholder: 'Current work', group: 'Content' },
+ { key: 'next', label: 'Next', type: 'text', defaultValue: 'Team billing', placeholder: 'Coming next', group: 'Content' },
+ { key: 'later', label: 'Later', type: 'text', defaultValue: 'Offline sync', placeholder: 'Future work', group: 'Content' },
+ commonFields.bgColor, commonFields.accentColor, commonFields.textColor,
+ ],
+ defaults: {
+ siteName: 'Acme Cloud', title: "What's coming in 2027", now: 'Dark mode', next: 'Team billing', later: 'Offline sync',
+ bgColor: '#0f172a', accentColor: '#38bdf8', textColor: '#f8fafc',
+ },
+ render: (params) => {
+ const siteName = truncate(String(params.siteName ?? '').trim(), 40);
+ const title = truncate(String(params.title || "What's coming in 2027").trim(), 60);
+ const bgColor = String(params.bgColor || '#0f172a');
+ const accentColor = String(params.accentColor || '#38bdf8');
+ const textColor = String(params.textColor || '#f8fafc');
+ const accentInk = isLightColor(bgColor) ? mixColors(accentColor, '#000000', 0.45) : accentColor;
+ const columns = [
+ ['Now', params.now],
+ ['Next', params.next],
+ ['Later', params.later],
+ ]
+ .map(([label, value]) => ({ label: String(label), item: String(value ?? '').trim() }))
+ .filter((column) => column.item);
+
+ const roadmapColumns: SatoriElement[] = columns.map((column) => ({
+ type: 'div',
+ props: {
+ style: { display: 'flex', flexDirection: 'column', flex: 1, minWidth: 0, padding: '26px 28px', borderRadius: '16px', backgroundColor: `${textColor}08`, border: `1px solid ${accentColor}33`, boxSizing: 'border-box' },
+ children: [
+ { type: 'span', props: { style: { fontSize: '16px', fontWeight: 700, color: accentInk, letterSpacing: '0.1em', textTransform: 'uppercase' }, children: column.label } },
+ { type: 'span', props: { style: { display: 'flex', flex: 1, marginTop: '22px', fontSize: '27px', lineHeight: 1.25, color: textColor, overflow: 'hidden' }, children: truncate(column.item, 72) } },
+ ],
+ },
+ }));
+
+ return {
+ type: 'div',
+ props: {
+ style: { display: 'flex', flexDirection: 'column', width: '1200px', height: '630px', padding: '58px 72px', boxSizing: 'border-box', backgroundColor: bgColor, fontFamily: 'Inter, sans-serif', color: textColor },
+ children: [
+ ...(siteName ? [{ type: 'span', props: { style: { fontSize: '22px', fontWeight: 700, color: accentInk }, children: siteName } }] : []),
+ { type: 'h1', props: { style: { fontSize: '52px', lineHeight: 1.15, fontWeight: 800, color: textColor, margin: siteName ? '16px 0 36px' : '0 0 36px' }, children: title } },
+ { type: 'div', props: { style: { display: 'flex', flex: 1, gap: '20px' }, children: roadmapColumns } },
+ ],
+ },
+ };
+ },
+};
+
+export default template;
diff --git a/src/templates/tutorial/before-after.ts b/src/templates/tutorial/before-after.ts
new file mode 100644
index 0000000..148b3eb
--- /dev/null
+++ b/src/templates/tutorial/before-after.ts
@@ -0,0 +1,234 @@
+import type { SatoriElement, TemplateDefinition } from '../types';
+import { autoFontSize, commonFields, isLightColor, mixColors, truncate } from '../utils';
+
+const MAX_CODE_LINES = 6;
+const MAX_CODE_LINE_LENGTH = 48;
+const CODE_LINE_HEIGHT = 28;
+
+const formatCodeLines = (value: unknown): string[] => {
+ const lines = (typeof value === 'string' ? value : '').replace(/\r\n?/g, '\n').split('\n');
+ return Array.from({ length: MAX_CODE_LINES }, (_, index) => truncate(lines[index] || '', MAX_CODE_LINE_LENGTH));
+};
+
+const template: TemplateDefinition = {
+ id: 'tutorial-before-after',
+ name: 'Before & After Code',
+ category: 'tutorial',
+ description: 'Compare a code refactor side by side with clear before and after panels',
+ tags: ['tutorial', 'code', 'refactor', 'comparison', 'programming'],
+ fields: [
+ commonFields.title,
+ { key: 'beforeCode', label: 'Before Code', type: 'textarea', defaultValue: 'const users = getUsers();\nconst active = [];\nfor (const user of users) {\n if (user.isActive) active.push(user);\n}', placeholder: 'Paste the original code', group: 'Content' },
+ { key: 'afterCode', label: 'After Code', type: 'textarea', defaultValue: 'const activeUsers = getUsers()\n .filter((user) => user.isActive);', placeholder: 'Paste the refactored code', group: 'Content' },
+ { key: 'language', label: 'Language', type: 'text', defaultValue: 'TypeScript', placeholder: 'TypeScript', group: 'Content' },
+ { key: 'beforeLabel', label: 'Before Label', type: 'text', defaultValue: 'Before', placeholder: 'Before', group: 'Content' },
+ { key: 'afterLabel', label: 'After Label', type: 'text', defaultValue: 'After', placeholder: 'After', group: 'Content' },
+ commonFields.bgColor,
+ commonFields.accentColor,
+ commonFields.textColor,
+ ],
+ defaults: {
+ title: 'Refactor for cleaner, simpler code',
+ beforeCode: 'const users = getUsers();\nconst active = [];\nfor (const user of users) {\n if (user.isActive) active.push(user);\n}',
+ afterCode: 'const activeUsers = getUsers()\n .filter((user) => user.isActive);',
+ language: 'TypeScript',
+ beforeLabel: 'Before',
+ afterLabel: 'After',
+ bgColor: '#0b1020',
+ accentColor: '#38bdf8',
+ textColor: '#e2e8f0',
+ },
+ render: (params) => {
+ const title = truncate(
+ typeof params.title === 'string' && params.title.trim() ? params.title.trim() : 'Refactor for cleaner, simpler code',
+ 72,
+ );
+ const language = typeof params.language === 'string' ? truncate(params.language.trim(), 24) : '';
+ const beforeLabel =
+ typeof params.beforeLabel === 'string' && params.beforeLabel.trim()
+ ? truncate(params.beforeLabel.trim(), 24)
+ : 'Before';
+ const afterLabel =
+ typeof params.afterLabel === 'string' && params.afterLabel.trim()
+ ? truncate(params.afterLabel.trim(), 24)
+ : 'After';
+ const beforeLines = formatCodeLines(params.beforeCode);
+ const afterLines = formatCodeLines(params.afterCode);
+ const bgColor = String(params.bgColor || '#0b1020');
+ const accentColor = String(params.accentColor || '#38bdf8');
+ const textColor = String(params.textColor || '#e2e8f0');
+ const lightBackground = isLightColor(bgColor);
+ const surfaceColor = lightBackground ? '#f1f5f9' : '#121a2a';
+ const codeBackground = lightBackground ? '#ffffff' : '#0b1220';
+ const borderColor = lightBackground ? '#dbe3ee' : '#283449';
+ const beforeColor = lightBackground ? '#b42318' : '#f87171';
+ const afterColor = lightBackground ? mixColors(accentColor, '#000000', 0.45) : accentColor;
+ const titleSize = autoFontSize(title, [
+ { maxLen: 28, size: 44 },
+ { maxLen: 48, size: 38 },
+ { maxLen: 72, size: 32 },
+ ]);
+
+ const renderCodeLines = (lines: string[], color: string): SatoriElement[] =>
+ lines.map((line) => ({
+ type: 'span',
+ props: {
+ style: {
+ fontSize: '15px',
+ fontFamily: 'JetBrains Mono, monospace',
+ color: line ? color : 'transparent',
+ lineHeight: `${CODE_LINE_HEIGHT}px`,
+ whiteSpace: 'pre',
+ },
+ children: line || ' ',
+ },
+ }));
+
+ const renderPanel = (label: string, labelColor: string, codeLines: string[]): SatoriElement => ({
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ flexDirection: 'column',
+ flex: 1,
+ height: '356px',
+ padding: '24px',
+ backgroundColor: surfaceColor,
+ border: `1px solid ${labelColor}55`,
+ borderRadius: '14px',
+ overflow: 'hidden',
+ },
+ children: [
+ {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ alignItems: 'center',
+ marginBottom: '18px',
+ },
+ children: {
+ type: 'span',
+ props: {
+ style: {
+ fontSize: '16px',
+ fontWeight: 700,
+ color: labelColor,
+ letterSpacing: '0.02em',
+ },
+ children: label,
+ },
+ },
+ },
+ },
+ {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ flexDirection: 'column',
+ flex: 1,
+ padding: '16px 18px',
+ backgroundColor: codeBackground,
+ border: `1px solid ${borderColor}`,
+ borderRadius: '9px',
+ overflow: 'hidden',
+ },
+ children: renderCodeLines(codeLines, textColor),
+ },
+ },
+ ],
+ },
+ });
+
+ return {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ flexDirection: 'column',
+ justifyContent: 'center',
+ width: '1200px',
+ height: '630px',
+ padding: '42px 54px',
+ backgroundColor: bgColor,
+ fontFamily: 'Inter, sans-serif',
+ },
+ children: [
+ {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ marginBottom: '26px',
+ gap: '24px',
+ },
+ children: [
+ {
+ type: 'h1',
+ props: {
+ style: {
+ flex: 1,
+ fontSize: `${titleSize}px`,
+ fontWeight: 700,
+ color: textColor,
+ lineHeight: 1.2,
+ margin: 0,
+ },
+ children: title,
+ },
+ },
+ ...(language
+ ? [
+ {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ flexShrink: 0,
+ padding: '9px 14px',
+ backgroundColor: surfaceColor,
+ border: `1px solid ${borderColor}`,
+ borderRadius: '8px',
+ },
+ children: {
+ type: 'span',
+ props: {
+ style: {
+ fontSize: '13px',
+ fontWeight: 400,
+ color: afterColor,
+ fontFamily: 'JetBrains Mono, monospace',
+ },
+ children: language,
+ },
+ },
+ },
+ },
+ ]
+ : []),
+ ],
+ },
+ },
+ {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ gap: '22px',
+ },
+ children: [
+ renderPanel(beforeLabel, beforeColor, beforeLines),
+ renderPanel(afterLabel, afterColor, afterLines),
+ ],
+ },
+ },
+ ],
+ },
+ };
+ },
+};
+
+export default template;
diff --git a/src/templates/tutorial/cheat-sheet.ts b/src/templates/tutorial/cheat-sheet.ts
new file mode 100644
index 0000000..df5f904
--- /dev/null
+++ b/src/templates/tutorial/cheat-sheet.ts
@@ -0,0 +1,284 @@
+import type { TemplateDefinition, SatoriElement } from '../types';
+import {
+ autoFontSize,
+ commonFields,
+ contrastText,
+ isLightColor,
+ mixColors,
+ truncate,
+} from '../utils';
+
+const DEFAULT_ITEMS = [
+ 'git status | See what changed',
+ 'git add -p | Stage changes piece by piece',
+ 'git commit --amend | Fix the last commit',
+ 'git switch -c name | Create a branch and switch to it',
+ 'git log --oneline | Short history',
+].join('\n');
+
+interface CheatSheetItem {
+ command: string;
+ description: string;
+}
+
+function parseItems(value: unknown): CheatSheetItem[] {
+ return String(value ?? '')
+ .split(/\r?\n/)
+ .map((line) => line.trim())
+ .filter(Boolean)
+ .slice(0, 5)
+ .map((line) => {
+ const separator = line.indexOf('|');
+ if (separator === -1) {
+ return { command: line.trim(), description: '' };
+ }
+ return {
+ command: line.slice(0, separator).trim(),
+ description: line.slice(separator + 1).trim(),
+ };
+ })
+ .filter((item) => item.command || item.description);
+}
+
+const template: TemplateDefinition = {
+ id: 'tutorial-cheat-sheet',
+ name: 'Cheat Sheet',
+ category: 'tutorial',
+ description: 'Quick reference card with up to five commands and short descriptions',
+ tags: ['git', 'cheat-sheet', 'commands', 'reference', 'tutorial'],
+ fields: [
+ commonFields.title,
+ {
+ key: 'topic',
+ label: 'Topic',
+ type: 'text',
+ defaultValue: 'Git',
+ placeholder: 'Git',
+ group: 'Content',
+ },
+ {
+ key: 'items',
+ label: 'Items',
+ type: 'textarea',
+ defaultValue: DEFAULT_ITEMS,
+ placeholder: 'command | description (one per line)',
+ group: 'Content',
+ },
+ commonFields.siteName,
+ commonFields.bgColor,
+ commonFields.accentColor,
+ commonFields.textColor,
+ ],
+ defaults: {
+ title: 'Git Commands Cheat Sheet',
+ topic: 'Git',
+ items: DEFAULT_ITEMS,
+ siteName: 'ogcops.com',
+ bgColor: '#FFFFFF',
+ accentColor: '#2563EB',
+ textColor: '#111827',
+ },
+ render: (params) => {
+ const title = truncate(
+ String(params.title || 'Git Commands Cheat Sheet'),
+ 56
+ );
+ const topic = truncate(String(params.topic || '').trim(), 18);
+ const items = parseItems(params.items);
+ const siteName = truncate(String(params.siteName || '').trim(), 36);
+ const bgColor = String(params.bgColor || '#FFFFFF');
+ const accentColor = String(params.accentColor || '#2563EB');
+ const textColor = String(params.textColor || '#111827');
+ const lightBackground = isLightColor(bgColor);
+ const accentInk = lightBackground
+ ? mixColors(accentColor, '#000000', 0.45)
+ : accentColor;
+ const mutedColor = lightBackground ? '#64748B' : `${textColor}99`;
+ const dividerColor = lightBackground ? '#E2E8F0' : `${textColor}1A`;
+ const pillBackground = lightBackground ? `${accentColor}14` : `${textColor}0A`;
+ const badgeText = contrastText(accentColor);
+ const titleSize = autoFontSize(title, [
+ { maxLen: 24, size: 50 },
+ { maxLen: 38, size: 44 },
+ { maxLen: 48, size: 38 },
+ { maxLen: 56, size: 32 },
+ ]);
+
+ const rows: SatoriElement[] = items.map((item, index) => ({
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ alignItems: 'center',
+ gap: '28px',
+ height: '76px',
+ borderBottom: index === items.length - 1 ? 'none' : `1px solid ${dividerColor}`,
+ },
+ children: [
+ {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ alignItems: 'center',
+ flexShrink: 0,
+ width: '438px',
+ height: '48px',
+ padding: '0 20px',
+ boxSizing: 'border-box',
+ borderRadius: '10px',
+ backgroundColor: pillBackground,
+ border: `1px solid ${accentColor}33`,
+ overflow: 'hidden',
+ },
+ children: {
+ type: 'span',
+ props: {
+ style: {
+ fontSize: '19px',
+ fontWeight: 500,
+ color: accentInk,
+ fontFamily: 'JetBrains Mono, monospace',
+ whiteSpace: 'nowrap',
+ },
+ children: truncate(item.command, 34),
+ },
+ },
+ },
+ },
+ {
+ type: 'span',
+ props: {
+ style: {
+ display: 'flex',
+ flex: 1,
+ fontSize: '20px',
+ fontWeight: 500,
+ color: textColor,
+ lineHeight: 1.2,
+ whiteSpace: 'nowrap',
+ },
+ children: truncate(item.description, 52),
+ },
+ },
+ ],
+ },
+ }));
+
+ return {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ flexDirection: 'column',
+ width: '1200px',
+ height: '630px',
+ padding: '50px 64px',
+ boxSizing: 'border-box',
+ backgroundColor: bgColor,
+ fontFamily: 'Inter, sans-serif',
+ overflow: 'hidden',
+ },
+ children: [
+ {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ flexDirection: 'column',
+ marginBottom: '24px',
+ },
+ children: [
+ {
+ type: 'h1',
+ props: {
+ style: {
+ fontSize: `${titleSize}px`,
+ fontWeight: 800,
+ color: textColor,
+ lineHeight: 1.15,
+ margin: 0,
+ whiteSpace: 'nowrap',
+ },
+ children: title,
+ },
+ },
+ {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ marginTop: '14px',
+ },
+ children: [
+ ...(topic
+ ? [
+ {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ alignItems: 'center',
+ padding: '5px 13px',
+ borderRadius: '100px',
+ backgroundColor: accentColor,
+ },
+ children: {
+ type: 'span',
+ props: {
+ style: {
+ fontSize: '14px',
+ fontWeight: 700,
+ color: badgeText,
+ letterSpacing: '0.06em',
+ textTransform: 'uppercase' as const,
+ whiteSpace: 'nowrap',
+ },
+ children: topic,
+ },
+ },
+ },
+ },
+ ]
+ : []),
+ ...(siteName
+ ? [
+ {
+ type: 'span',
+ props: {
+ style: {
+ fontSize: '16px',
+ fontWeight: 500,
+ color: mutedColor,
+ },
+ children: siteName,
+ },
+ },
+ ]
+ : []),
+ ],
+ },
+ },
+ ],
+ },
+ },
+ {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ flexDirection: 'column',
+ flex: 1,
+ },
+ children: rows,
+ },
+ },
+ ],
+ },
+ };
+ },
+};
+
+export default template;
diff --git a/src/templates/tutorial/error-explained.ts b/src/templates/tutorial/error-explained.ts
new file mode 100644
index 0000000..4474b16
--- /dev/null
+++ b/src/templates/tutorial/error-explained.ts
@@ -0,0 +1,343 @@
+import type { SatoriElement, TemplateDefinition } from '../types';
+import { autoFontSize, commonFields, isLightColor, mixColors, truncate } from '../utils';
+
+const DEFAULT_TITLE = "TypeError: Cannot read properties of undefined (reading 'map')";
+
+const template: TemplateDefinition = {
+ id: 'tutorial-error-explained',
+ name: 'Error Explained',
+ category: 'tutorial',
+ description: 'Explain a technical error message with what it means and how to fix it',
+ tags: ['error', 'debug', 'troubleshooting', 'tutorial', 'code'],
+ fields: [
+ {
+ ...commonFields.title,
+ label: 'Error Message',
+ defaultValue: DEFAULT_TITLE,
+ placeholder: DEFAULT_TITLE,
+ },
+ {
+ key: 'language',
+ label: 'Language',
+ type: 'text',
+ defaultValue: 'JavaScript',
+ placeholder: 'JavaScript',
+ group: 'Content',
+ },
+ {
+ key: 'meaning',
+ label: 'Meaning',
+ type: 'text',
+ defaultValue: "You called .map() on something that isn't an array yet",
+ placeholder: "You called .map() on something that isn't an array yet",
+ group: 'Content',
+ },
+ {
+ key: 'fix',
+ label: 'Fix',
+ type: 'text',
+ defaultValue: 'Start the state as [] or check before mapping',
+ placeholder: 'Start the state as [] or check before mapping',
+ group: 'Content',
+ },
+ commonFields.siteName,
+ commonFields.bgColor,
+ commonFields.accentColor,
+ commonFields.textColor,
+ ],
+ defaults: {
+ title: DEFAULT_TITLE,
+ language: 'JavaScript',
+ meaning: "You called .map() on something that isn't an array yet",
+ fix: 'Start the state as [] or check before mapping',
+ siteName: 'codercops.com',
+ bgColor: '#0f172a',
+ accentColor: '#38bdf8',
+ textColor: '#f8fafc',
+ },
+ render: (params) => {
+ const rawTitle =
+ typeof params.title === 'string' && params.title.trim() ? params.title.trim() : DEFAULT_TITLE;
+ const title = truncate(rawTitle, 120);
+ const titleSize = autoFontSize(rawTitle, [
+ { maxLen: 40, size: 36 },
+ { maxLen: 70, size: 30 },
+ { maxLen: 95, size: 25 },
+ { maxLen: 120, size: 21 },
+ ]);
+
+ const language = typeof params.language === 'string' ? truncate(params.language.trim(), 24) : '';
+ const meaning = typeof params.meaning === 'string' ? truncate(params.meaning.trim(), 140) : '';
+ const fix = typeof params.fix === 'string' ? truncate(params.fix.trim(), 140) : '';
+ const siteName = typeof params.siteName === 'string' ? truncate(params.siteName.trim(), 40) : '';
+
+ const bgColor = String(params.bgColor || '#0f172a');
+ const accentColor = String(params.accentColor || '#38bdf8');
+ const textColor = String(params.textColor || '#f8fafc');
+
+ const lightBg = isLightColor(bgColor);
+ const accentInk = lightBg ? mixColors(accentColor, '#000000', 0.45) : accentColor;
+ const bodyText = lightBg && isLightColor(textColor) ? '#0f172a' : textColor;
+ const mutedText = lightBg ? '#475569' : `${textColor}99`;
+
+ const errorBoxBg = lightBg ? mixColors('#ef4444', bgColor, 0.92) : mixColors('#ef4444', bgColor, 0.88);
+ const errorBoxBorder = lightBg ? mixColors('#ef4444', bgColor, 0.55) : mixColors('#ef4444', bgColor, 0.65);
+ const errorTextColor = lightBg ? '#991b1b' : '#fca5a5';
+
+ const pillBg = lightBg ? mixColors(bgColor, bodyText, 0.06) : `${bodyText}14`;
+ const pillBorder = lightBg ? mixColors(bgColor, bodyText, 0.2) : `${bodyText}28`;
+ const pillText = lightBg ? mixColors(bgColor, bodyText, 0.75) : mutedText;
+
+ const explanationRows: SatoriElement[] = [
+ ...(meaning
+ ? [
+ {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ alignItems: 'flex-start',
+ gap: '20px',
+ },
+ children: [
+ {
+ type: 'span',
+ props: {
+ style: {
+ fontSize: '18px',
+ fontWeight: 700,
+ color: mutedText,
+ textTransform: 'uppercase',
+ letterSpacing: '0.08em',
+ flexShrink: 0,
+ width: '105px',
+ marginTop: '4px',
+ },
+ children: 'Meaning',
+ },
+ },
+ {
+ type: 'span',
+ props: {
+ style: {
+ fontSize: '25px',
+ lineHeight: 1.4,
+ color: bodyText,
+ flex: 1,
+ },
+ children: meaning,
+ },
+ },
+ ],
+ },
+ },
+ ]
+ : []),
+ ...(fix
+ ? [
+ {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ alignItems: 'flex-start',
+ gap: '20px',
+ },
+ children: [
+ {
+ type: 'span',
+ props: {
+ style: {
+ fontSize: '18px',
+ fontWeight: 700,
+ color: accentInk,
+ textTransform: 'uppercase',
+ letterSpacing: '0.08em',
+ flexShrink: 0,
+ width: '105px',
+ marginTop: '4px',
+ },
+ children: 'Fix',
+ },
+ },
+ {
+ type: 'span',
+ props: {
+ style: {
+ fontSize: '25px',
+ fontWeight: 500,
+ lineHeight: 1.4,
+ color: bodyText,
+ flex: 1,
+ },
+ children: fix,
+ },
+ },
+ ],
+ },
+ },
+ ]
+ : []),
+ ];
+
+ return {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ flexDirection: 'column',
+ justifyContent: 'space-between',
+ width: '1200px',
+ height: '630px',
+ padding: '60px 72px',
+ boxSizing: 'border-box',
+ backgroundColor: bgColor,
+ fontFamily: 'Inter, sans-serif',
+ color: bodyText,
+ },
+ children: [
+ {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ flexDirection: 'column',
+ justifyContent: 'center',
+ flex: 1,
+ },
+ children: [
+ {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ alignItems: 'center',
+ gap: '16px',
+ marginBottom: '26px',
+ },
+ children: [
+ {
+ type: 'span',
+ props: {
+ style: {
+ fontSize: '18px',
+ fontWeight: 700,
+ color: accentInk,
+ letterSpacing: '0.08em',
+ textTransform: 'uppercase',
+ },
+ children: 'Error explained',
+ },
+ },
+ ...(language
+ ? [
+ {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ alignItems: 'center',
+ padding: '5px 14px',
+ borderRadius: '9999px',
+ backgroundColor: pillBg,
+ border: `1px solid ${pillBorder}`,
+ },
+ children: {
+ type: 'span',
+ props: {
+ style: {
+ fontSize: '15px',
+ fontWeight: 500,
+ color: pillText,
+ fontFamily: 'JetBrains Mono, monospace',
+ },
+ children: language,
+ },
+ },
+ },
+ },
+ ]
+ : []),
+ ],
+ },
+ },
+ {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ alignItems: 'center',
+ padding: '28px 32px',
+ borderRadius: '14px',
+ backgroundColor: errorBoxBg,
+ border: `1px solid ${errorBoxBorder}`,
+ boxSizing: 'border-box',
+ overflow: 'hidden',
+ },
+ children: {
+ type: 'span',
+ props: {
+ style: {
+ fontSize: `${titleSize}px`,
+ fontFamily: 'JetBrains Mono, monospace',
+ fontWeight: 600,
+ lineHeight: 1.35,
+ color: errorTextColor,
+ wordBreak: 'break-word',
+ },
+ children: title,
+ },
+ },
+ },
+ },
+ ...(explanationRows.length > 0
+ ? [
+ {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ flexDirection: 'column',
+ gap: '20px',
+ marginTop: '32px',
+ },
+ children: explanationRows,
+ },
+ },
+ ]
+ : []),
+ ],
+ },
+ },
+ ...(siteName
+ ? [
+ {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ alignItems: 'center',
+ paddingTop: '16px',
+ },
+ children: {
+ type: 'span',
+ props: {
+ style: {
+ fontSize: '18px',
+ fontWeight: 500,
+ color: mutedText,
+ },
+ children: siteName,
+ },
+ },
+ },
+ },
+ ]
+ : []),
+ ],
+ },
+ };
+ },
+};
+
+export default template;
diff --git a/src/templates/tutorial/glossary.ts b/src/templates/tutorial/glossary.ts
new file mode 100644
index 0000000..a5a0abd
--- /dev/null
+++ b/src/templates/tutorial/glossary.ts
@@ -0,0 +1,44 @@
+import type { TemplateDefinition } from '../types';
+import { autoFontSize, commonFields, isLightColor, mixColors, truncate } from '../utils';
+
+const template: TemplateDefinition = {
+ id: 'tutorial-glossary',
+ name: 'Glossary Term',
+ category: 'tutorial',
+ description: 'A glossary-style card for explaining a technical term',
+ tags: ['glossary', 'term', 'definition', 'tutorial', 'education'],
+ fields: [
+ { key: 'label', label: 'Label', type: 'text', defaultValue: 'Term of the day', placeholder: 'Term of the day', group: 'Content' },
+ { key: 'term', label: 'Term', type: 'text', defaultValue: 'Idempotent', placeholder: 'Technical term', required: true, group: 'Content' },
+ { key: 'partOfSpeech', label: 'Part of Speech', type: 'text', defaultValue: 'adjective', placeholder: 'adjective', group: 'Content' },
+ commonFields.description,
+ { key: 'example', label: 'Example', type: 'text', defaultValue: 'PUT /users/42 twice still leaves one user', placeholder: 'Usage example', group: 'Content' },
+ commonFields.bgColor, commonFields.accentColor, commonFields.textColor,
+ ],
+ defaults: {
+ label: 'Term of the day', term: 'Idempotent', partOfSpeech: 'adjective',
+ description: 'An operation that produces the same result whether it is performed once or repeated multiple times.',
+ example: 'PUT /users/42 twice still leaves one user', bgColor: '#0f172a', accentColor: '#38bdf8', textColor: '#f8fafc',
+ },
+ render: (params) => {
+ const label = truncate(String(params.label ?? '').trim(), 32);
+ const term = truncate(String(params.term || 'Your term').trim(), 48);
+ const partOfSpeech = String(params.partOfSpeech ?? '').trim();
+ const definition = truncate(String(params.description || '').trim(), 180);
+ const example = String(params.example ?? '').trim();
+ const bgColor = String(params.bgColor || '#0f172a');
+ const accentColor = String(params.accentColor || '#38bdf8');
+ const textColor = String(params.textColor || '#f8fafc');
+ const accentInk = isLightColor(bgColor) ? mixColors(accentColor, '#000000', 0.45) : accentColor;
+ const mutedColor = isLightColor(bgColor) ? '#475569' : `${textColor}99`;
+ const termSize = autoFontSize(term, [{ maxLen: 18, size: 76 }, { maxLen: 30, size: 62 }, { maxLen: 40, size: 50 }, { maxLen: 48, size: 42 }]);
+ return { type: 'div', props: { style: { display: 'flex', flexDirection: 'column', width: '1200px', height: '630px', padding: '58px 72px', boxSizing: 'border-box', backgroundColor: bgColor, fontFamily: 'Inter, sans-serif', color: textColor }, children: [
+ ...(label ? [{ type: 'span', props: { style: { fontSize: '20px', fontWeight: 700, color: accentInk, letterSpacing: '0.08em', textTransform: 'uppercase' }, children: label } }] : []),
+ { type: 'div', props: { style: { display: 'flex', alignItems: 'baseline', gap: '18px', marginTop: '20px' }, children: [{ type: 'h1', props: { style: { fontSize: `${termSize}px`, lineHeight: 1.05, fontWeight: 800, margin: 0, color: textColor }, children: term } }, ...(partOfSpeech ? [{ type: 'span', props: { style: { fontSize: '24px', color: mutedColor }, children: partOfSpeech } }] : [])] } },
+ { type: 'p', props: { style: { fontSize: '27px', lineHeight: 1.35, color: textColor, margin: '24px 0 0', maxWidth: '1000px' }, children: definition } },
+ ...(example ? [{ type: 'div', props: { style: { display: 'flex', marginTop: '30px', padding: '20px 24px', borderRadius: '12px', backgroundColor: `${textColor}0A`, border: `1px solid ${accentColor}44`, boxSizing: 'border-box' }, children: { type: 'span', props: { style: { fontSize: '20px', lineHeight: 1.35, color: accentInk, fontFamily: 'JetBrains Mono, monospace' }, children: truncate(example, 88) } } } }] : []),
+ ] } };
+ },
+};
+
+export default template;
diff --git a/src/templates/tutorial/index.ts b/src/templates/tutorial/index.ts
index 6228d4d..008aa8e 100644
--- a/src/templates/tutorial/index.ts
+++ b/src/templates/tutorial/index.ts
@@ -7,3 +7,9 @@ export { default as tutorialCodeTutorial } from './code-tutorial';
export { default as tutorialWorkshopBanner } from './workshop-banner';
export { default as tutorialCertification } from './certification';
export { default as tutorialQuizCard } from './quiz-card';
+export { default as tutorialCheatSheet } from './cheat-sheet';
+export { default as tutorialGlossary } from './glossary';
+export { default as tutorialPrerequisites } from './prerequisites';
+export { default as tutorialBeforeAfter } from './before-after';
+export { default as tutorialErrorExplained } from './error-explained';
+export { default as tutorialTil } from './til';
diff --git a/src/templates/tutorial/prerequisites.ts b/src/templates/tutorial/prerequisites.ts
new file mode 100644
index 0000000..8e1e457
--- /dev/null
+++ b/src/templates/tutorial/prerequisites.ts
@@ -0,0 +1,80 @@
+import type { SatoriElement, TemplateDefinition } from '../types';
+import { autoFontSize, commonFields, isLightColor, mixColors, truncate } from '../utils';
+
+const checkIcon = (size: number, color: string): SatoriElement => ({
+ type: 'svg',
+ props: {
+ width: size,
+ height: size,
+ viewBox: '0 0 24 24',
+ fill: 'none',
+ stroke: color,
+ strokeWidth: '2.5',
+ strokeLinecap: 'round',
+ strokeLinejoin: 'round',
+ children: { type: 'path', props: { d: 'M5 12l4 4L19 6' } },
+ },
+});
+
+const template: TemplateDefinition = {
+ id: 'tutorial-prerequisites',
+ name: 'Prerequisites',
+ category: 'tutorial',
+ description: 'A before-you-start card with a short tutorial prerequisite checklist',
+ tags: ['tutorial', 'prerequisites', 'checklist', 'course', 'education'],
+ fields: [
+ commonFields.title,
+ { key: 'item1', label: 'Prerequisite 1', type: 'text', defaultValue: 'Node 20 or newer', placeholder: 'Prerequisite', group: 'Content' },
+ { key: 'item2', label: 'Prerequisite 2', type: 'text', defaultValue: 'Basic TypeScript', placeholder: 'Prerequisite', group: 'Content' },
+ { key: 'item3', label: 'Prerequisite 3', type: 'text', defaultValue: 'A free Cloudflare account', placeholder: 'Prerequisite', group: 'Content' },
+ { key: 'duration', label: 'Duration', type: 'text', defaultValue: 'About 45 minutes', placeholder: 'About 45 minutes', group: 'Content' },
+ commonFields.bgColor, commonFields.accentColor, commonFields.textColor,
+ ],
+ defaults: {
+ title: 'Build a REST API with Hono',
+ item1: 'Node 20 or newer', item2: 'Basic TypeScript', item3: 'A free Cloudflare account', duration: 'About 45 minutes',
+ bgColor: '#0f172a', accentColor: '#38bdf8', textColor: '#f8fafc',
+ },
+ render: (params) => {
+ const title = truncate(String(params.title || 'Build a REST API with Hono').trim(), 70);
+ const items = [params.item1, params.item2, params.item3]
+ .map((item) => String(item ?? '').trim())
+ .filter(Boolean);
+ const duration = String(params.duration ?? '').trim();
+ const bgColor = String(params.bgColor || '#0f172a');
+ const accentColor = String(params.accentColor || '#38bdf8');
+ const textColor = String(params.textColor || '#f8fafc');
+ const accentInk = isLightColor(bgColor) ? mixColors(accentColor, '#000000', 0.45) : accentColor;
+ const mutedColor = isLightColor(bgColor) ? '#475569' : `${textColor}99`;
+ const titleSize = autoFontSize(title, [
+ { maxLen: 30, size: 58 },
+ { maxLen: 48, size: 48 },
+ { maxLen: 70, size: 40 },
+ ]);
+ const rows: SatoriElement[] = items.map((item) => ({
+ type: 'div',
+ props: {
+ style: { display: 'flex', alignItems: 'flex-start', gap: '18px', minHeight: '58px', padding: '10px 0' },
+ children: [
+ { type: 'div', props: { style: { display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0, width: '34px', height: '34px', borderRadius: '50%', backgroundColor: `${accentColor}18`, border: `1px solid ${accentColor}55` }, children: checkIcon(20, accentInk) } },
+ { type: 'span', props: { style: { display: 'flex', flex: 1, minWidth: 0, fontSize: '25px', lineHeight: 1.3, color: textColor, overflow: 'hidden' }, children: truncate(item, 72) } },
+ ],
+ },
+ }));
+
+ return {
+ type: 'div',
+ props: {
+ style: { display: 'flex', flexDirection: 'column', width: '1200px', height: '630px', padding: '58px 72px', boxSizing: 'border-box', backgroundColor: bgColor, fontFamily: 'Inter, sans-serif', color: textColor },
+ children: [
+ { type: 'span', props: { style: { fontSize: '20px', fontWeight: 700, color: accentInk, letterSpacing: '0.1em', textTransform: 'uppercase' }, children: 'BEFORE YOU START' } },
+ { type: 'h1', props: { style: { fontSize: `${titleSize}px`, lineHeight: 1.15, fontWeight: 800, color: textColor, margin: '18px 0 26px' }, children: title } },
+ { type: 'div', props: { style: { display: 'flex', flexDirection: 'column', flex: 1 }, children: rows } },
+ ...(duration ? [{ type: 'span', props: { style: { fontSize: '20px', fontWeight: 600, color: mutedColor, paddingTop: '18px', borderTop: `1px solid ${textColor}22` }, children: duration } }] : []),
+ ],
+ },
+ };
+ },
+};
+
+export default template;
diff --git a/src/templates/tutorial/til.ts b/src/templates/tutorial/til.ts
new file mode 100644
index 0000000..9aecb04
--- /dev/null
+++ b/src/templates/tutorial/til.ts
@@ -0,0 +1,275 @@
+import type { TemplateDefinition } from '../types';
+import { autoFontSize, commonFields, isLightColor, mixColors, truncate } from '../utils';
+
+const DEFAULT_TITLE = 'git switch - takes you back to the branch you were just on';
+const DEFAULT_CODE = 'git switch -';
+
+const template: TemplateDefinition = {
+ id: 'tutorial-til',
+ name: 'Today I Learned',
+ category: 'tutorial',
+ description: 'A card for sharing quick "Today I Learned" discoveries and commands',
+ tags: ['til', 'today-i-learned', 'tutorial', 'tip', 'code'],
+ fields: [
+ {
+ ...commonFields.title,
+ label: 'Title',
+ defaultValue: DEFAULT_TITLE,
+ placeholder: 'What you learned',
+ },
+ {
+ key: 'code',
+ label: 'Code / Command',
+ type: 'text',
+ defaultValue: DEFAULT_CODE,
+ placeholder: 'git switch -',
+ group: 'Content',
+ },
+ {
+ key: 'tag',
+ label: 'Tag',
+ type: 'text',
+ defaultValue: 'git',
+ placeholder: 'git',
+ group: 'Content',
+ },
+ {
+ ...commonFields.date,
+ defaultValue: 'Oct 27, 2026',
+ placeholder: 'Oct 27, 2026',
+ },
+ commonFields.siteName,
+ commonFields.bgColor,
+ commonFields.accentColor,
+ commonFields.textColor,
+ ],
+ defaults: {
+ title: DEFAULT_TITLE,
+ code: DEFAULT_CODE,
+ tag: 'git',
+ date: 'Oct 27, 2026',
+ siteName: 'codercops.com',
+ bgColor: '#0f172a',
+ accentColor: '#38bdf8',
+ textColor: '#f8fafc',
+ },
+ render: (params) => {
+ const rawTitle =
+ typeof params.title === 'string' && params.title.trim() ? params.title.trim() : DEFAULT_TITLE;
+ const title = truncate(rawTitle, 130);
+ const titleSize = autoFontSize(rawTitle, [
+ { maxLen: 35, size: 54 },
+ { maxLen: 65, size: 44 },
+ { maxLen: 95, size: 36 },
+ { maxLen: 130, size: 30 },
+ ]);
+
+ const code = typeof params.code === 'string' ? truncate(params.code.trim(), 75) : '';
+ const tag = typeof params.tag === 'string' ? truncate(params.tag.trim(), 24) : '';
+ const date = typeof params.date === 'string' ? truncate(params.date.trim(), 32) : '';
+ const siteName = typeof params.siteName === 'string' ? truncate(params.siteName.trim(), 40) : '';
+
+ const bgColor = String(params.bgColor || '#0f172a');
+ const accentColor = String(params.accentColor || '#38bdf8');
+ const textColor = String(params.textColor || '#f8fafc');
+
+ const lightBg = isLightColor(bgColor);
+ const accentInk = lightBg ? mixColors(accentColor, '#000000', 0.45) : accentColor;
+ const bodyText = lightBg && isLightColor(textColor) ? '#0f172a' : textColor;
+ const mutedText = lightBg ? '#475569' : `${textColor}99`;
+
+ const pillBg = lightBg ? mixColors(bgColor, bodyText, 0.06) : `${bodyText}14`;
+ const pillBorder = lightBg ? mixColors(bgColor, bodyText, 0.2) : `${bodyText}28`;
+ const pillText = lightBg ? mixColors(bgColor, bodyText, 0.75) : mutedText;
+
+ const codeBoxBg = lightBg ? mixColors(bgColor, '#000000', 0.05) : `${bodyText}0D`;
+ const codeBoxBorder = lightBg ? mixColors(bgColor, '#000000', 0.15) : `${bodyText}26`;
+
+ const hasFooter = Boolean(date || siteName);
+
+ return {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ flexDirection: 'column',
+ justifyContent: 'space-between',
+ width: '1200px',
+ height: '630px',
+ padding: '58px 72px',
+ boxSizing: 'border-box',
+ backgroundColor: bgColor,
+ fontFamily: 'Inter, sans-serif',
+ color: bodyText,
+ },
+ children: [
+ {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ flexDirection: 'column',
+ },
+ children: [
+ {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ alignItems: 'center',
+ gap: '14px',
+ },
+ children: [
+ {
+ type: 'span',
+ props: {
+ style: {
+ fontSize: '20px',
+ fontWeight: 700,
+ color: accentInk,
+ letterSpacing: '0.08em',
+ textTransform: 'uppercase',
+ },
+ children: 'Today I learned',
+ },
+ },
+ ...(tag
+ ? [
+ {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ alignItems: 'center',
+ padding: '4px 12px',
+ borderRadius: '9999px',
+ backgroundColor: pillBg,
+ border: `1px solid ${pillBorder}`,
+ },
+ children: {
+ type: 'span',
+ props: {
+ style: {
+ fontSize: '15px',
+ fontWeight: 600,
+ color: pillText,
+ fontFamily: 'JetBrains Mono, monospace',
+ },
+ children: tag,
+ },
+ },
+ },
+ },
+ ]
+ : []),
+ ],
+ },
+ },
+ {
+ type: 'h1',
+ props: {
+ style: {
+ fontSize: `${titleSize}px`,
+ lineHeight: 1.2,
+ fontWeight: 800,
+ color: bodyText,
+ margin: '36px 0 0',
+ maxWidth: '1050px',
+ letterSpacing: '-0.02em',
+ wordBreak: 'break-word',
+ },
+ children: title,
+ },
+ },
+ ...(code
+ ? [
+ {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ alignItems: 'center',
+ alignSelf: 'flex-start',
+ marginTop: '34px',
+ padding: '18px 24px',
+ borderRadius: '12px',
+ backgroundColor: codeBoxBg,
+ border: `1px solid ${codeBoxBorder}`,
+ boxSizing: 'border-box',
+ maxWidth: '1050px',
+ },
+ children: {
+ type: 'span',
+ props: {
+ style: {
+ fontSize: '22px',
+ lineHeight: 1.35,
+ color: accentInk,
+ fontFamily: 'JetBrains Mono, monospace',
+ wordBreak: 'break-word',
+ },
+ children: code,
+ },
+ },
+ },
+ },
+ ]
+ : []),
+ ],
+ },
+ },
+ ...(hasFooter
+ ? [
+ {
+ type: 'div',
+ props: {
+ style: {
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent:
+ date && siteName ? 'space-between' : date ? 'flex-start' : 'flex-end',
+ width: '100%',
+ },
+ children: [
+ ...(date
+ ? [
+ {
+ type: 'span',
+ props: {
+ style: {
+ fontSize: '18px',
+ fontWeight: 500,
+ color: mutedText,
+ },
+ children: date,
+ },
+ },
+ ]
+ : []),
+ ...(siteName
+ ? [
+ {
+ type: 'span',
+ props: {
+ style: {
+ fontSize: '18px',
+ fontWeight: 600,
+ color: mutedText,
+ },
+ children: siteName,
+ },
+ },
+ ]
+ : []),
+ ],
+ },
+ },
+ ]
+ : []),
+ ],
+ },
+ };
+ },
+};
+
+export default template;
diff --git a/tests/api/og.test.ts b/tests/api/og.test.ts
index 0711879..2cc0874 100644
--- a/tests/api/og.test.ts
+++ b/tests/api/og.test.ts
@@ -1,4 +1,4 @@
-import { describe, it, expect, vi } from 'vitest';
+import { afterEach, describe, it, expect, vi } from 'vitest';
import {
ogQuerySchema,
parseSearchParams,
@@ -7,6 +7,7 @@ import {
} from '@/lib/api-validation';
import { getTemplate, getAllTemplates } from '@/templates/registry';
import { normalizeColorFields } from '@/templates/utils';
+import type { TemplateField } from '@/templates/types';
describe('OG API Validation', () => {
it('should validate valid params', () => {
@@ -190,6 +191,19 @@ describe('resolveDimensions', () => {
});
});
+ it('rounds a fractional height to a whole number', () => {
+ expect(resolveDimensions(undefined, 315.6)).toEqual({
+ success: true,
+ width: 602,
+ height: 316,
+ });
+ expect(resolveDimensions(undefined, 175.2)).toEqual({
+ success: true,
+ width: 333,
+ height: 175,
+ });
+ });
+
it('derives width when only height is supplied', () => {
expect(resolveDimensions(undefined, 315)).toEqual({
success: true,
@@ -224,6 +238,11 @@ describe('resolveDimensions', () => {
width: 2400,
height: 1260,
});
+ expect(resolveDimensions(600.4, 315.2)).toEqual({
+ success: true,
+ width: 600,
+ height: 315,
+ });
});
it('rejects mismatched aspect ratios when both dimensions are supplied', () => {
@@ -323,6 +342,47 @@ describe('mergeOgParams', () => {
expect(merged.quote).toBe('Talk is cheap, show me the code.');
expect(merged.author).toBe(template!.defaults.author);
});
+
+ describe('with the template fields', () => {
+ const fieldDefaults: Record = {
+ ...defaults,
+ title: 'Default title',
+ imageUrl: 'https://example.com/cover.png',
+ };
+ const fields: TemplateField[] = [
+ { key: 'title', label: 'Title', type: 'text', defaultValue: 'Default title', required: true, group: 'Content' },
+ { key: 'quote', label: 'Quote', type: 'textarea', defaultValue: 'Default quote', group: 'Content' },
+ { key: 'author', label: 'Author', type: 'text', defaultValue: 'Default Author', group: 'Content' },
+ { key: 'bgColor', label: 'Background', type: 'color', defaultValue: '#0a0a0a', group: 'Style' },
+ { key: 'imageUrl', label: 'Image', type: 'image', defaultValue: 'https://example.com/cover.png', group: 'Style' },
+ ];
+
+ function mergeWithFields(query: string) {
+ const searchParams = new URLSearchParams(query);
+ const parsed = parseSearchParams(searchParams, ogQuerySchema);
+ if (!parsed.success) throw new Error(parsed.error);
+ return mergeOgParams(parsed.data, fieldDefaults, searchParams, fields);
+ }
+
+ it('keeps an optional text field empty when it is sent empty', () => {
+ const merged = mergeWithFields('quote=&author=');
+ expect(merged.quote).toBe('');
+ expect(merged.author).toBe('');
+ });
+
+ it('still uses the default when the param is left out', () => {
+ const merged = mergeWithFields('');
+ expect(merged.quote).toBe('Default quote');
+ expect(merged.author).toBe('Default Author');
+ });
+
+ it('falls back to the default for empty required fields, colors and images', () => {
+ const merged = mergeWithFields('title=&bgColor=&imageUrl=');
+ expect(merged.title).toBe('Default title');
+ expect(merged.bgColor).toBe('#0a0a0a');
+ expect(merged.imageUrl).toBe('https://example.com/cover.png');
+ });
+ });
});
// og-engine pulls in the resvg wasm binary through a Vite-only `?module`
@@ -334,16 +394,21 @@ vi.mock('@/lib/og-engine', () => ({
const { GET } = await import('@/pages/api/og');
-function makeContext(url: string) {
+function makeContext(url: string, locals: object = {}) {
return {
request: new Request(url),
- locals: {},
+ locals,
} as unknown as Parameters>[0];
}
const BASE = 'http://localhost:4321/api/og';
describe('OG API error caching', () => {
+ afterEach(() => {
+ vi.unstubAllEnvs();
+ vi.unstubAllGlobals();
+ });
+
it('does not cache a 404 for a template that does not exist', async () => {
const res = await GET(makeContext(`${BASE}?template=does-not-exist&title=Hi`));
@@ -380,6 +445,51 @@ describe('OG API error caching', () => {
expect(res.status).toBe(200);
expect(res.headers.get('cache-control')).toBe('public, max-age=86400');
});
+
+ it('does not read or write the Cloudflare cache in development', async () => {
+ const cache = {
+ match: vi.fn().mockResolvedValue(undefined),
+ put: vi.fn().mockResolvedValue(undefined),
+ };
+ vi.stubEnv('DEV', true);
+ vi.stubGlobal('caches', { default: cache });
+
+ const res = await GET(
+ makeContext(`${BASE}?template=blog-minimal-dark&title=Hi`, {
+ cfContext: { waitUntil: vi.fn() },
+ })
+ );
+
+ expect(res.status).toBe(200);
+ expect(res.headers.get('content-type')).toBe('image/png');
+ expect(cache.match).not.toHaveBeenCalled();
+ expect(cache.put).not.toHaveBeenCalled();
+ });
+
+ it('looks up and caches a successful 200 response outside development', async () => {
+ const cache = {
+ match: vi.fn().mockResolvedValue(undefined),
+ put: vi.fn().mockResolvedValue(undefined),
+ };
+ const waitUntil = vi.fn();
+ vi.stubEnv('DEV', false);
+ vi.stubGlobal('caches', { default: cache });
+
+ const res = await GET(
+ makeContext(`${BASE}?template=blog-minimal-dark&title=Hi`, {
+ cfContext: { waitUntil },
+ })
+ );
+
+ expect(res.status).toBe(200);
+ expect(cache.match).toHaveBeenCalledWith(expect.any(Request));
+ expect(cache.put).toHaveBeenCalledOnce();
+ expect(cache.put).toHaveBeenCalledWith(
+ expect.any(Request),
+ expect.objectContaining({ status: 200 })
+ );
+ expect(waitUntil).toHaveBeenCalledOnce();
+ });
});
describe('OG API color normalization and templates', () => {
@@ -466,7 +576,7 @@ describe('OG API dimension resolution and scaling', () => {
);
});
- it('scales down to 601 when height is fractional', async () => {
+ it('scales down to a whole-number width when height is fractional', async () => {
const { renderToPng } = await import('@/lib/og-engine');
vi.mocked(renderToPng).mockClear();
@@ -474,7 +584,7 @@ describe('OG API dimension resolution and scaling', () => {
expect(res.status).toBe(200);
expect(renderToPng).toHaveBeenCalledWith(
expect.anything(),
- { width: 1200, height: 630, scaleDown: 601 }
+ { width: 1200, height: 630, scaleDown: 602 }
);
});
@@ -548,3 +658,24 @@ describe('OG API empty parameters fall back to the defaults', () => {
expect(res.status).toBe(404);
});
});
+
+describe('OG API cleared fields', () => {
+ it('keeps an optional field the editor cleared empty, and uses the default when it is left out', async () => {
+ const template = getTemplate('newsletter-subscribe-card')!;
+ const render = vi.spyOn(template, 'render');
+
+ try {
+ const cleared = await GET(makeContext(`${BASE}?template=newsletter-subscribe-card&subscriberCount=`));
+ expect(cleared.status).toBe(200);
+ expect(render).toHaveBeenLastCalledWith(expect.objectContaining({ subscriberCount: '' }));
+
+ const omitted = await GET(makeContext(`${BASE}?template=newsletter-subscribe-card`));
+ expect(omitted.status).toBe(200);
+ expect(render).toHaveBeenLastCalledWith(
+ expect.objectContaining({ subscriberCount: template.defaults.subscriberCount })
+ );
+ } finally {
+ render.mockRestore();
+ }
+ });
+});
diff --git a/tests/api/thumbnail.test.ts b/tests/api/thumbnail.test.ts
new file mode 100644
index 0000000..bcad698
--- /dev/null
+++ b/tests/api/thumbnail.test.ts
@@ -0,0 +1,75 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { GET } from '@/pages/api/templates/[id]/thumbnail.png';
+
+vi.mock('@/lib/og-engine', () => ({
+ renderToPng: vi.fn(async () => new Uint8Array([137, 80, 78, 71])),
+}));
+vi.mock('@/templates/registry', () => ({
+ getTemplate: () => ({
+ defaults: { title: 'Thumbnail test' },
+ render: () => null,
+ }),
+}));
+
+function makeContext(url: string, id: string, locals: object = {}) {
+ return {
+ params: { id },
+ url: new URL(url),
+ locals,
+ } as unknown as Parameters[0];
+}
+
+describe('Template thumbnail API cache behavior', () => {
+ afterEach(() => {
+ vi.unstubAllEnvs();
+ vi.unstubAllGlobals();
+ });
+
+ it('does not read or write the Cloudflare cache in development', async () => {
+ const cache = {
+ match: vi.fn().mockResolvedValue(undefined),
+ put: vi.fn().mockResolvedValue(undefined),
+ };
+ vi.stubEnv('DEV', true);
+ vi.stubGlobal('caches', { default: cache });
+
+ const res = await GET(
+ makeContext(
+ 'http://localhost:4321/api/templates/blog-minimal-dark/thumbnail.png',
+ 'blog-minimal-dark'
+ )
+ );
+
+ expect(res.status).toBe(200);
+ expect(res.headers.get('content-type')).toBe('image/png');
+ expect(cache.match).not.toHaveBeenCalled();
+ expect(cache.put).not.toHaveBeenCalled();
+ }, 15000);
+
+ it('looks up and caches a successful 200 response outside development', async () => {
+ const cache = {
+ match: vi.fn().mockResolvedValue(undefined),
+ put: vi.fn().mockResolvedValue(undefined),
+ };
+ const waitUntil = vi.fn();
+ vi.stubEnv('DEV', false);
+ vi.stubGlobal('caches', { default: cache });
+
+ const res = await GET(
+ makeContext(
+ 'http://localhost:4321/api/templates/blog-minimal-light/thumbnail.png',
+ 'blog-minimal-light',
+ { cfContext: { waitUntil } }
+ )
+ );
+
+ expect(res.status).toBe(200);
+ expect(cache.match).toHaveBeenCalledWith(expect.any(Request));
+ expect(cache.put).toHaveBeenCalledOnce();
+ expect(cache.put).toHaveBeenCalledWith(
+ expect.any(Request),
+ expect.objectContaining({ status: 200 })
+ );
+ expect(waitUntil).toHaveBeenCalledOnce();
+ }, 15000);
+});
diff --git a/tests/editor/color-picker.test.ts b/tests/editor/color-picker.test.ts
new file mode 100644
index 0000000..9f6fcdf
--- /dev/null
+++ b/tests/editor/color-picker.test.ts
@@ -0,0 +1,30 @@
+import { describe, it, expect } from 'vitest';
+import { renderToString } from 'react-dom/server';
+import React from 'react';
+import { CustomizePanel } from '@/components/editor/CustomizePanel';
+import type { TemplateField } from '@/templates/types';
+
+const fields: TemplateField[] = [{ key: 'bgColor', label: 'Background', type: 'color', defaultValue: '#0f172a', group: 'Style' }];
+
+function inputs(bgColor: unknown) {
+ const html = renderToString(
+ React.createElement(CustomizePanel, { fields, params: { bgColor }, onParamChange: () => {}, onReset: () => {} }),
+ );
+ const value = (type: string) => html.match(new RegExp(` ]*type="${type}"[^>]*value="([^"]*)"`))?.[1];
+ return { swatch: value('color'), text: value('text') };
+}
+
+describe('ColorPicker', () => {
+ it('leaves a cleared hex field empty instead of filling in #000000', () => {
+ expect(inputs('').text).toBe('');
+ });
+
+ it('shows what was typed and gives the swatch a full #rrggbb color', () => {
+ expect(inputs('#fff')).toEqual({ swatch: '#ffffff', text: '#fff' });
+ expect(inputs('ff8800')).toEqual({ swatch: '#ff8800', text: '#ff8800' });
+ });
+
+ it('keeps a partial value in the text field', () => {
+ expect(inputs('#ff88').text).toBe('#ff88');
+ });
+});
diff --git a/tests/editor/export-bar.test.ts b/tests/editor/export-bar.test.ts
new file mode 100644
index 0000000..fd83f40
--- /dev/null
+++ b/tests/editor/export-bar.test.ts
@@ -0,0 +1,27 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { renderToString } from 'react-dom/server';
+import React from 'react';
+
+const generateMetaTags = vi.fn((_params: Record) => '');
+vi.mock('@/lib/meta-tag-generator', () => ({ generateMetaTags }));
+
+const { ExportBar } = await import('@/components/editor/ExportBar');
+
+function metaTagParams(params: Record) {
+ renderToString(
+ React.createElement(ExportBar, { apiUrl: '/api/og?x=1', downloadUrl: '/api/og?x=1', params, templateId: 'saas-maintenance' }),
+ );
+ return generateMetaTags.mock.calls.at(-1)![0];
+}
+
+describe('ExportBar meta tags', () => {
+ beforeEach(() => generateMetaTags.mockClear());
+
+ it('passes the Site Name so the copied tags include og:site_name', () => {
+ expect(metaTagParams({ title: 'Database upgrade', siteName: 'Acme Cloud' })).toMatchObject({ siteName: 'Acme Cloud' });
+ });
+
+ it('leaves siteName out when the Site Name is empty', () => {
+ expect(metaTagParams({ title: 'Database upgrade', siteName: '' }).siteName).toBeUndefined();
+ });
+});
diff --git a/tests/lib/url-builder.test.ts b/tests/lib/url-builder.test.ts
new file mode 100644
index 0000000..94eadf0
--- /dev/null
+++ b/tests/lib/url-builder.test.ts
@@ -0,0 +1,26 @@
+import { describe, it, expect } from 'vitest';
+import { buildOgQuery } from '@/lib/url-builder';
+
+describe('buildOgQuery', () => {
+ const defaults = { title: 'Weekly Dev', subscriberCount: '12,400', frequency: '' };
+
+ it('puts the template first and keeps filled-in values', () => {
+ const query = buildOgQuery('newsletter-subscribe-card', { title: 'Hello', subscriberCount: '900' }, defaults);
+ expect(query).toBe('template=newsletter-subscribe-card&title=Hello&subscriberCount=900');
+ });
+
+ it('keeps a cleared field as key= when its default is not empty', () => {
+ const query = buildOgQuery('newsletter-subscribe-card', { title: 'Hello', subscriberCount: '' }, defaults);
+ expect(new URLSearchParams(query).has('subscriberCount')).toBe(true);
+ expect(new URLSearchParams(query).get('subscriberCount')).toBe('');
+ });
+
+ it('leaves out empty values whose default is empty too, and null or undefined ones', () => {
+ const query = buildOgQuery('newsletter-subscribe-card', { title: 'Hello', frequency: '', extra: undefined, other: null }, defaults);
+ expect(query).toBe('template=newsletter-subscribe-card&title=Hello');
+ });
+
+ it('without defaults, leaves every empty value out like before', () => {
+ expect(buildOgQuery('blog-minimal-dark', { title: 'Hi', description: '' })).toBe('template=blog-minimal-dark&title=Hi');
+ });
+});
diff --git a/tests/templates/api-endpoint.test.ts b/tests/templates/api-endpoint.test.ts
new file mode 100644
index 0000000..97a6266
--- /dev/null
+++ b/tests/templates/api-endpoint.test.ts
@@ -0,0 +1,189 @@
+import { describe, it, expect } from 'vitest';
+import { readFileSync } from 'node:fs';
+import satori, { type SatoriNode } from 'satori';
+import { createElement } from 'react';
+import { renderToStaticMarkup } from 'react-dom/server';
+import apiEndpoint from '@/templates/saas/api-endpoint';
+import { getTemplate, getTemplatesByCategory } from '@/templates/registry';
+import type { SatoriElement } from '@/templates/types';
+import { editorReducer, useEditorState, type EditorState } from '@/components/editor/useEditorState';
+import { mergeOgParams, ogQuerySchema, parseSearchParams } from '@/lib/api-validation';
+
+function elements(node: unknown): SatoriElement[] {
+ if (Array.isArray(node)) return node.flatMap(elements);
+ if (node && typeof node === 'object' && 'props' in node) {
+ const element = node as SatoriElement;
+ return [element, ...elements(element.props.children)];
+ }
+ return [];
+}
+
+const tree = (params: Record = {}) => apiEndpoint.render({ ...apiEndpoint.defaults, ...params });
+const text = (params: Record = {}) => elements(tree(params)).flatMap((el) =>
+ typeof el.props.children === 'string' ? [el.props.children] : []);
+const methods = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'];
+const fonts = ['Inter-Regular', 'Inter-SemiBold', 'JetBrainsMono-Regular', 'JetBrainsMono-Bold'].map((file) => ({
+ name: file.startsWith('Inter') ? 'Inter' : 'JetBrains Mono',
+ data: readFileSync(`public/fonts/${file}.woff`),
+ weight: (file.endsWith('SemiBold') ? 600 : file.endsWith('Bold') ? 700 : 400) as 400 | 600 | 700,
+ style: 'normal' as const,
+}));
+
+function luminance(hex: string) {
+ const rgb = hex.match(/[a-f\d]{2}/gi)!.map((c) => parseInt(c, 16) / 255)
+ .map((c) => c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
+ return rgb[0] * 0.2126 + rgb[1] * 0.7152 + rgb[2] * 0.0722;
+}
+
+function serverParams(query: URLSearchParams) {
+ const parsed = parseSearchParams(query, ogQuerySchema);
+ if (!parsed.success) throw new Error(parsed.error);
+ return mergeOgParams(parsed.data, apiEndpoint.defaults, query, apiEndpoint.fields);
+}
+
+function downloadParams(params: Record) {
+ let downloadUrl = '';
+ function Probe() {
+ downloadUrl = useEditorState({ ...apiEndpoint, defaults: params }).downloadUrl;
+ return null;
+ }
+ renderToStaticMarkup(createElement(Probe));
+ const query = new URL(downloadUrl, 'https://example.test').searchParams;
+ return { query, params: serverParams(query) };
+}
+
+describe('saas-api-endpoint', () => {
+ it('is discoverable in the SaaS registry and renders the supplied endpoint', () => {
+ expect(getTemplate('saas-api-endpoint')).toBe(apiEndpoint);
+ expect(getTemplatesByCategory('saas')).toContain(apiEndpoint);
+ expect(text({ siteName: 'Invoice API', method: 'POST', path: '/v2/invoices', responseStatus: '201 Created' }))
+ .toEqual(expect.arrayContaining(['Invoice API', 'POST', '/v2/invoices', '201 Created']));
+ });
+
+ it.each(methods)('keeps %s readable on dark and light canvases', async (method) => {
+ for (const bgColor of ['#0b1220', '#ffffff']) {
+ const element = tree({ method, bgColor, textColor: bgColor === '#ffffff' ? '#111827' : '#f8fafc' });
+ const pill = elements(element).find((el) => el.props.children === method)!;
+ expect(pill.props.style.background).toMatch(/^#[a-f\d]{6}$/i);
+ expect((luminance(pill.props.style.background) + 0.05) / (luminance(pill.props.style.color) + 0.05)).toBeGreaterThan(4.5);
+ const svg = await satori(element as any, { width: 1200, height: 630, fonts });
+ expect(svg).toMatch(/^]*width="1200"[^>]*height="630"/);
+ }
+ });
+
+ it.each(['#ffffff', '#f1f5f9', '#0b1220'])('keeps the product name readable on %s', async (bgColor) => {
+ const element = tree({ bgColor });
+ const name = elements(element).find((el) => el.props.children === 'Acme API')!;
+ const levels = [luminance(name.props.style.color), luminance(bgColor)].sort((a, b) => a - b);
+ expect((levels[1] + 0.05) / (levels[0] + 0.05)).toBeGreaterThan(4.5);
+ if (bgColor === '#0b1220') expect(name.props.style.color).toBe(apiEndpoint.defaults.accentColor);
+ const svg = await satori(element as any, { width: 1200, height: 630, fonts });
+ expect(svg).toContain(`fill="${name.props.style.color}"`);
+ });
+
+ it.each([
+ { responseStatus: '', responseExample: '' },
+ { responseStatus: '200 OK', responseExample: '' },
+ { responseStatus: '', responseExample: '{ "ok": true }' },
+ { responseStatus: '200 OK', responseExample: '{ "ok": true }' },
+ ])('centers the endpoint with response fields %j', async (response) => {
+ for (const description of ['', 'Retrieve an invoice.']) {
+ const nodes: SatoriNode[] = [];
+ await satori(tree({ ...response, description }) as any, {
+ width: 1200, height: 630, fonts, onNodeDetected: (node) => nodes.push(node),
+ });
+ const name = nodes.find((node) => node.textContent === 'Acme API')!;
+ const pill = nodes.find((node) => node.type === 'div' && node.textContent === 'GET')!;
+ const descriptionNode = nodes.find((node) => node.textContent === description);
+ const panel = nodes.find((node) => node.props.style?.padding === '24px 28px');
+ const contentBottom = descriptionNode ? descriptionNode.top + descriptionNode.height : pill.top + pill.height;
+ const availableBottom = panel ? panel.top : 630 - 64;
+ const center = (pill.top + contentBottom) / 2;
+ const availableCenter = (name.top + name.height + availableBottom) / 2;
+ expect(Math.abs(center - availableCenter)).toBeLessThanOrEqual(1);
+ expect(pill.top).toBeGreaterThan(name.top + name.height);
+ expect(contentBottom).toBeLessThan(availableBottom);
+ }
+ });
+
+ it.each(['unknown', '__proto__', 'constructor', '', null])('falls back to GET for method %s', (method) => {
+ expect(tree({ method })).toEqual(tree({ method: 'GET' }));
+ });
+
+ it('accepts lowercase methods without introducing nondeterminism', () => {
+ expect(tree({ method: 'post' })).toEqual(tree({ method: 'POST' }));
+ expect(tree()).toEqual(tree());
+ });
+
+ it('shrinks and truncates a long unbroken path', async () => {
+ const element = tree({ method: 'DELETE', path: '/v1/' + 'invoices'.repeat(40) });
+ const path = elements(element).find((el) => typeof el.props.children === 'string' && el.props.children.startsWith('/v1/'))!;
+ expect(path.props.children).toHaveLength(64);
+ expect(path.props.children.endsWith('…')).toBe(true);
+ expect(parseInt(path.props.style.fontSize)).toBeLessThan(48);
+ expect(await satori(element as any, { width: 1200, height: 630, fonts })).toMatch(/^ {
+ const element = tree({ description: '', responseStatus: ' ', responseExample: ' ' });
+ expect(text({ description: '', responseStatus: '', responseExample: '' })).toEqual(['Acme API', 'GET', '/v1/invoices/{id}']);
+ expect(elements(element).some((el) => el.props.style?.padding === '24px 28px')).toBe(false);
+ expect(await satori(element as any, { width: 1200, height: 630, fonts })).toMatch(/^ {
+ expect(text({ responseExample: '' })).toContain('200 OK');
+ expect(text({ responseStatus: '', responseExample: '{ "ok": true }' })).toContain('{ "ok": true }');
+ const output = text({ responseExample: 'x'.repeat(200), responseStatus: 's'.repeat(80) });
+ expect(output).toContain('x'.repeat(71) + '…');
+ expect(output).toContain('s'.repeat(39) + '…');
+ });
+
+ it('uses explicit flex on every div and bounds long product/description input', () => {
+ const element = tree({ siteName: 'a'.repeat(200), description: 'b'.repeat(500) });
+ expect(element.props.style).toMatchObject({ width: '1200px', height: '630px', display: 'flex' });
+ expect(elements(element).filter((el) => el.type === 'div').every((el) => el.props.style.display === 'flex')).toBe(true);
+ expect(text({ siteName: 'a'.repeat(200) })).toContain('a'.repeat(35) + '…');
+ expect(text({ description: 'b'.repeat(500) })).toContain('b'.repeat(83) + '…');
+ });
+
+ describe('editor download response fields', () => {
+ it('keeps the status default and an empty example when query fields are omitted', () => {
+ const params = serverParams(new URLSearchParams({ template: apiEndpoint.id }));
+ expect(params.responseStatus).toBe('200 OK');
+ expect(params.responseExample).toBe('');
+ expect(apiEndpoint.render(params)).toEqual(tree({ responseExample: '' }));
+ });
+
+ it.each([
+ { name: 'default', example: '', clear: false },
+ { name: 'supplied', example: '{ "id": "inv_123" }', clear: false },
+ { name: 'cleared', example: '{ "id": "inv_123" }', clear: true },
+ ])('preserves a $name example through the editor URL and server merge', ({ example, clear }) => {
+ let state: EditorState = {
+ templateId: apiEndpoint.id, category: 'saas', params: { ...apiEndpoint.defaults }, searchQuery: '',
+ };
+ if (example) state = editorReducer(state, { type: 'SET_PARAM', key: 'responseExample', value: example });
+ if (clear) state = editorReducer(state, { type: 'SET_PARAM', key: 'responseExample', value: '' });
+
+ const expected = clear ? '' : example;
+ const download = downloadParams(state.params);
+ expect(download.query.get('responseExample')).toBe(expected || null);
+ expect(download.params.responseExample).toBe(expected);
+ expect(download.params.responseStatus).toBe('200 OK');
+ expect(apiEndpoint.render(download.params)).toEqual(apiEndpoint.render(state.params));
+ });
+
+ it('preserves supplied status text and keeps a cleared status empty, like the preview', () => {
+ const supplied = downloadParams({ ...apiEndpoint.defaults, responseStatus: '201 Created' });
+ expect(supplied.params.responseStatus).toBe('201 Created');
+ expect(apiEndpoint.render(supplied.params)).toEqual(tree({ responseStatus: '201 Created' }));
+
+ const cleared = downloadParams({ ...apiEndpoint.defaults, responseStatus: '' });
+ expect(cleared.query.get('responseStatus')).toBe('');
+ expect(cleared.params.responseStatus).toBe('');
+ // A status cleared in the editor stays empty in the download, the same as in the preview.
+ expect(apiEndpoint.render(cleared.params)).toEqual(tree({ responseStatus: '' }));
+ });
+ });
+});
diff --git a/tests/templates/back-in-stock.test.ts b/tests/templates/back-in-stock.test.ts
new file mode 100644
index 0000000..e88848c
--- /dev/null
+++ b/tests/templates/back-in-stock.test.ts
@@ -0,0 +1,94 @@
+import { describe, expect, it } from 'vitest';
+import backInStock from '@/templates/ecommerce/back-in-stock';
+import { getTemplate } from '@/templates/registry';
+import { contrastText } from '@/templates/utils';
+import type { SatoriElement } from '@/templates/types';
+
+function elements(node: unknown): SatoriElement[] {
+ if (Array.isArray(node)) return node.flatMap(elements);
+ if (!node || typeof node !== 'object' || !('type' in node)) return [];
+
+ const element = node as SatoriElement;
+ return [element, ...elements(element.props.children)];
+}
+
+function textContent(node: unknown): string[] {
+ if (typeof node === 'string') return [node];
+ if (Array.isArray(node)) return node.flatMap(textContent);
+ if (node && typeof node === 'object' && 'props' in node) {
+ return textContent((node as SatoriElement).props.children);
+ }
+ return [];
+}
+
+const render = (overrides: Record = {}) =>
+ backInStock.render({ ...backInStock.defaults, ...overrides });
+
+const findText = (rendered: unknown, startsWith: string) =>
+ elements(rendered).find((element) =>
+ textContent(element).some((text) => text.startsWith(startsWith)));
+
+describe('ecommerce-back-in-stock', () => {
+ it('is registered in the ecommerce category and re-exported', async () => {
+ expect(getTemplate('ecommerce-back-in-stock')?.category).toBe('ecommerce');
+ const exports = await import('@/templates/ecommerce');
+ expect(exports.ecommerceBackInStock.id).toBe('ecommerce-back-in-stock');
+ });
+
+ it('renders the badge, product name, price, urgency line and store name', () => {
+ expect(textContent(render())).toEqual([
+ 'Back in stock',
+ 'Classic Canvas Weekender',
+ '$89',
+ 'Only 12 left',
+ 'Field & Co.',
+ ]);
+ });
+
+ it('shrinks long product names with autoFontSize and then truncates them', () => {
+ const longName = 'Handmade Waxed Canvas Weekender Duffel Bag with Leather Straps and a Padded Shoulder Sling';
+ const rendered = render({ productName: longName });
+ const heading = elements(rendered).find((element) => element.type === 'h1');
+
+ expect(textContent(heading)[0]).toHaveLength(80);
+ expect(textContent(heading)[0]).toMatch(/…$/);
+ expect(heading?.props.style?.fontSize).toBe('36px');
+ expect(findText(rendered, 'Handmade Waxed')).toBeDefined();
+ });
+
+ it('uses a smaller size for a medium-length name than for a short one', () => {
+ const short = elements(render()).find((element) => element.type === 'h1');
+ const medium = elements(render({ productName: 'Waxed Canvas Weekender with Leather Straps' }))
+ .find((element) => element.type === 'h1');
+
+ expect(short?.props.style?.fontSize).toBe('64px');
+ expect(medium?.props.style?.fontSize).toBe('44px');
+ });
+
+ it('hides a blank price or stock note cleanly', () => {
+ const rendered = render({ price: ' ', stockNote: ' ' });
+
+ expect(textContent(rendered)).toEqual(['Back in stock', 'Classic Canvas Weekender', 'Field & Co.']);
+ });
+
+ it('keeps the badge label readable on any accent and the accent text readable on a light background', () => {
+ const dark = elements(render({ accentColor: '#E07A5F' }));
+ const light = elements(render({ bgColor: '#ffffff', textColor: '#111111', accentColor: '#fde047' }));
+
+ const badge = dark.find((element) => element.type === 'div' &&
+ element.props.style?.backgroundColor === '#E07A5F');
+ expect(badge?.props.style?.backgroundColor).toBe('#E07A5F');
+ expect(textContent(badge)).toEqual(['Back in stock']);
+ const badgeLabel = elements(badge).find((element) => element.type === 'span');
+ expect(badgeLabel?.props.style?.color).toBe(contrastText('#E07A5F'));
+
+ const paleBadge = light.find((element) => element.type === 'div' &&
+ element.props.style?.backgroundColor === '#fde047');
+ const paleLabel = elements(paleBadge).find((element) => element.type === 'span');
+ expect(paleLabel?.props.style?.color).toBe(contrastText('#fde047'));
+
+ const palePrice = elements(light).find((element) => element.type === 'span' &&
+ textContent(element)[0] === '$89');
+ expect(palePrice?.props.style?.color).toBe('#8b7b27');
+ });
+});
diff --git a/tests/templates/before-after.test.ts b/tests/templates/before-after.test.ts
new file mode 100644
index 0000000..8d63930
--- /dev/null
+++ b/tests/templates/before-after.test.ts
@@ -0,0 +1,128 @@
+import { readFileSync } from 'node:fs';
+import { join } from 'node:path';
+import satori from 'satori';
+import { describe, expect, it } from 'vitest';
+import { getTemplate, getTemplatesByCategory } from '@/templates/registry';
+import type { SatoriElement } from '@/templates/types';
+
+const template = getTemplate('tutorial-before-after');
+if (!template) throw new Error('Template not found: tutorial-before-after');
+
+const fonts = [
+ {
+ name: 'Inter',
+ data: readFileSync(join(process.cwd(), 'public/fonts/Inter-Regular.woff')),
+ weight: 400 as const,
+ style: 'normal' as const,
+ },
+ {
+ name: 'Inter',
+ data: readFileSync(join(process.cwd(), 'public/fonts/Inter-Bold.woff')),
+ weight: 700 as const,
+ style: 'normal' as const,
+ },
+ {
+ name: 'JetBrains Mono',
+ data: readFileSync(join(process.cwd(), 'public/fonts/JetBrainsMono-Regular.woff')),
+ weight: 400 as const,
+ style: 'normal' as const,
+ },
+];
+
+function isSatoriElement(value: unknown): value is SatoriElement {
+ return typeof value === 'object' && value !== null && 'type' in value && 'props' in value;
+}
+
+function getChildren(element: SatoriElement): SatoriElement[] {
+ const children = element.props.children;
+ return Array.isArray(children) ? children.filter(isSatoriElement) : isSatoriElement(children) ? [children] : [];
+}
+
+function getAllElements(element: SatoriElement): SatoriElement[] {
+ return [element, ...getChildren(element).flatMap(getAllElements)];
+}
+
+function getCodeLines(element: SatoriElement): string[] {
+ return getAllElements(element)
+ .filter(
+ (child) =>
+ child.type === 'span' &&
+ child.props.style?.whiteSpace === 'pre' &&
+ child.props.style?.lineHeight === '28px',
+ )
+ .map((line) => String(line.props.children));
+}
+
+describe('tutorial-before-after', () => {
+ it('is registered as a Tutorial template', () => {
+ expect(template.category).toBe('tutorial');
+ expect(getTemplatesByCategory('tutorial')).toContain(template);
+ });
+
+ it('keeps the default before snippet within six lines and includes its closing brace', () => {
+ const beforeLines = String(template.defaults.beforeCode).split('\n');
+
+ expect(beforeLines.length).toBeLessThanOrEqual(6);
+ expect(beforeLines.at(-1)).toBe('}');
+ expect(getCodeLines(template.render(template.defaults)).slice(0, beforeLines.length)).toEqual(beforeLines);
+ });
+
+ it('renders a Satori image when optional text fields are empty or omitted', async () => {
+ const element = template.render({
+ ...template.defaults,
+ beforeCode: '',
+ afterCode: '',
+ language: '',
+ beforeLabel: '',
+ afterLabel: '',
+ });
+
+ await expect(satori(element as any, { width: 1200, height: 630, fonts })).resolves.toMatch(/^ {
+ const element = template.render({ title: 'Small refactor' });
+
+ await expect(satori(element as any, { width: 1200, height: 630, fonts })).resolves.toMatch(/^ {
+ const element = template.render({
+ ...template.defaults,
+ beforeCode: ` ${'a'.repeat(100)}\n second\n third\n fourth\n fifth\n sixth\n ignored`,
+ afterCode: ' one\n two',
+ });
+ const codeLines = getCodeLines(element);
+
+ expect(codeLines).toHaveLength(12);
+ expect(codeLines[0]).toMatch(/^ a+…$/);
+ expect(codeLines[0].length).toBeLessThanOrEqual(48);
+ expect(codeLines.slice(6)).toEqual([' one', ' two', ' ', ' ', ' ', ' ']);
+ });
+
+ it('gives both comparison panels the same fixed height', () => {
+ const element = template.render({
+ ...template.defaults,
+ beforeCode: 'one\ntwo\nthree',
+ afterCode: 'one',
+ });
+ const panels = getAllElements(element).filter(
+ (child) => child.type === 'div' && child.props.style?.height === '356px',
+ );
+
+ expect(panels).toHaveLength(2);
+ expect(panels[0].props.style.height).toBe(panels[1].props.style.height);
+ });
+
+ it('uses flex display on every div for Satori compatibility', () => {
+ const element = template.render(template.defaults);
+
+ expect(
+ getAllElements(element)
+ .filter((child) => child.type === 'div')
+ .every((child) => child.props.style?.display === 'flex'),
+ ).toBe(true);
+ });
+});
diff --git a/tests/templates/benchmark.test.ts b/tests/templates/benchmark.test.ts
new file mode 100644
index 0000000..7c28246
--- /dev/null
+++ b/tests/templates/benchmark.test.ts
@@ -0,0 +1,170 @@
+import { describe, expect, it } from 'vitest';
+import benchmark from '@/templates/blog/benchmark';
+import { blogBenchmark } from '@/templates/blog';
+import { getTemplate, getTemplatesByCategory } from '@/templates/registry';
+import { mixColors } from '@/templates/utils';
+import type { SatoriElement } from '@/templates/types';
+
+function elements(node: unknown): SatoriElement[] {
+ if (Array.isArray(node)) return node.flatMap(elements);
+ if (!node || typeof node !== 'object' || !('type' in node)) return [];
+
+ const element = node as SatoriElement;
+ return [element, ...elements(element.props.children)];
+}
+
+function textContent(node: unknown): string[] {
+ if (typeof node === 'string') return [node];
+ if (Array.isArray(node)) return node.flatMap(textContent);
+ if (node && typeof node === 'object' && 'props' in node) {
+ return textContent((node as SatoriElement).props.children);
+ }
+ return [];
+}
+
+const render = (overrides: Record = {}) =>
+ benchmark.render({ ...benchmark.defaults, ...overrides });
+const rows = (tree: unknown) => elements(tree).filter((element) => element.type === 'div' && element.props.style?.minHeight === '68px');
+
+describe('blog-benchmark', () => {
+ it('is re-exported and registered once in the Blog category', () => {
+ expect(blogBenchmark).toBe(benchmark);
+ expect(getTemplate('blog-benchmark')).toBe(benchmark);
+ expect(getTemplatesByCategory('blog').filter(({ id }) => id === 'blog-benchmark')).toHaveLength(1);
+ });
+
+ it('renders the label, title, optional note, ordered rows, and formatted values', () => {
+ const tree = render();
+ const text = textContent(tree);
+
+ expect(text).toContain('Benchmark');
+ expect(text).toContain('Bun vs Deno vs Node: HTTP throughput');
+ expect(text).toContain('Requests per second, higher is better');
+ expect(text).toContain('112,000 req/s');
+ expect(text).toContain('71,000 req/s');
+ expect(text).toContain('48,000 req/s');
+ expect(text).toContain('codercops.com');
+ expect(rows(tree)).toHaveLength(3);
+ expect(rows(tree).map((row) => textContent((row.props.children as SatoriElement[])[0])[0]))
+ .toEqual(['Bun', 'Deno', 'Node']);
+ });
+
+ it('scales every bar to the largest value and highlights only the first largest row', () => {
+ const tree = render({ value1: '500', value2: '250', value3: '125' });
+ const rowElements = rows(tree);
+ const fills = rowElements.map((row) => (row.props.children as SatoriElement[])[1].props.children[0] as SatoriElement);
+
+ expect(fills.map(({ props }) => props.style.width)).toEqual(['100%', '50%', '25%']);
+ expect(fills[0].props.style.backgroundColor).toBe('#E07A5F');
+ expect(fills[1].props.style.backgroundColor).not.toBe('#E07A5F');
+ expect(fills[2].props.style.backgroundColor).not.toBe('#E07A5F');
+ });
+
+ it('skips blank names and invalid, negative, or blank values without gaps', () => {
+ const tree = render({
+ name1: ' ', value1: '100',
+ name2: 'Valid', value2: '25',
+ name3: 'Negative', value3: '-1',
+ name4: 'Not a number', value4: 'abc',
+ });
+
+ expect(rows(tree)).toHaveLength(1);
+ expect(textContent(rows(tree)[0])).toEqual(['Valid', '25 req/s']);
+ });
+
+ it('draws empty bars for all-zero values without dividing by zero', () => {
+ const tree = render({ value1: 0, value2: '0', value3: 0 });
+ const fills = rows(tree).map((row) => (row.props.children as SatoriElement[])[1].props.children[0] as SatoriElement);
+
+ expect(fills.map(({ props }) => props.style.width)).toEqual(['0%', '0%', '0%']);
+ expect(JSON.stringify(tree)).not.toContain('NaN');
+ });
+
+ it('hides the chart when every row is skipped and hides an empty note', () => {
+ const tree = render({
+ description: ' ',
+ name1: '', value1: 10,
+ name2: 'Invalid', value2: 'Infinity',
+ name3: 'Negative', value3: -1,
+ name4: 'Blank', value4: '',
+ });
+ const allText = textContent(tree);
+
+ expect(rows(tree)).toHaveLength(0);
+ expect(allText).not.toContain(' ');
+ expect(allText).not.toContain('10 req/s');
+ });
+
+ it('shrinks and truncates long titles, notes, and names', () => {
+ const tree = render({
+ title: 'Benchmark title '.repeat(10),
+ description: 'Benchmark note '.repeat(12),
+ name1: 'A very long benchmark name '.repeat(5),
+ });
+ const all = elements(tree);
+ const title = all.find((element) => element.type === 'h1');
+ const note = all.find((element) => element.type === 'span' && textContent(element)[0]?.startsWith('Benchmark note'));
+ const name = all.find((element) => element.type === 'span' && textContent(element)[0]?.startsWith('A very long benchmark'));
+
+ expect(textContent(title)[0]).toHaveLength(120);
+ expect(textContent(title)[0]).toMatch(/…$/);
+ expect(title?.props.style.fontSize).toBe('28px');
+ expect(textContent(note)[0]).toHaveLength(120);
+ expect(note?.props.style.fontSize).toBe('15px');
+ expect(textContent(name)[0]).toHaveLength(80);
+ expect(textContent(name)[0]).toMatch(/…$/);
+ expect(name?.props.style.fontSize).toBe('14px');
+ });
+
+ it('darkens the label and largest bar accent on a light background', () => {
+ const tree = render({ bgColor: '#ffffff', accentColor: '#fde047' });
+ const accentInk = mixColors('#fde047', '#000000', 0.45);
+ const all = elements(tree);
+ const label = all.find((element) => element.type === 'span' && textContent(element)[0] === 'Benchmark');
+ const title = all.find((element) => element.type === 'h1');
+ const name = rows(tree)[0].props.children[0] as SatoriElement;
+ const value = rows(tree)[0].props.children[2] as SatoriElement;
+ const largestFill = (rows(tree)[0].props.children as SatoriElement[])[1].props.children[0] as SatoriElement;
+
+ expect(label?.props.style.color).toBe(accentInk);
+ expect(largestFill.props.style.backgroundColor).toBe(accentInk);
+ expect(title?.props.style.color).toBe('#111111');
+ expect(name.props.style.color).toBe('#111111');
+ expect(value.props.style.color).toBe('#111111');
+ });
+
+ it('omits the unit when it is blank and trims number formatting', () => {
+ const tree = render({ value1: '1000', unit: ' ' });
+ expect(textContent(rows(tree)[0])).toContain('1,000');
+ expect(textContent(rows(tree)[0])).not.toContain('1,000 ');
+ });
+
+ it('sizes and truncates long value labels so they stay inside their column', () => {
+ const tree = render({ value1: '123456789.125', unit: 'requests per second '.repeat(5) });
+ const values = rows(tree).map((row) => row.props.children[2] as SatoriElement);
+
+ expect(textContent(values[0])[0]).toMatch(/^123,456,789\.125 requests per second/);
+ expect(textContent(values[0])[0]).toMatch(/…$/);
+ expect(textContent(values[0])[0]).toHaveLength(36);
+ expect(values.map(({ props }) => props.style.width)).toEqual(['360px', '360px', '360px']);
+ expect(values.map(({ props }) => props.style.fontSize)).toEqual(['14px', '14px', '14px']);
+ for (const value of values) {
+ expect(value.props.style.overflow).toBe('hidden');
+ expect(value.props.style.textOverflow).toBe('ellipsis');
+ expect(value.props.style.whiteSpace).toBe('nowrap');
+ }
+ });
+
+ it('uses the longest value label to size one shared column without truncating shorter rows', () => {
+ const tree = render({ unit: 'requests per second' });
+ const values = rows(tree).map((row) => row.props.children[2] as SatoriElement);
+
+ expect(values.map(({ props }) => props.style.width)).toEqual(['353px', '353px', '353px']);
+ expect(values.map(({ props }) => props.style.fontSize)).toEqual(['14px', '14px', '14px']);
+ expect(values.map((value) => textContent(value)[0])).toEqual([
+ '112,000 requests per second',
+ '71,000 requests per second',
+ '48,000 requests per second',
+ ]);
+ });
+});
diff --git a/tests/templates/benefits.test.ts b/tests/templates/benefits.test.ts
new file mode 100644
index 0000000..f54f7e3
--- /dev/null
+++ b/tests/templates/benefits.test.ts
@@ -0,0 +1,81 @@
+import { describe, expect, it } from 'vitest';
+import benefits from '@/templates/job/benefits';
+import { getTemplate } from '@/templates/registry';
+import type { SatoriElement } from '@/templates/types';
+
+function elements(node: unknown): SatoriElement[] {
+ if (Array.isArray(node)) return node.flatMap(elements);
+ if (!node || typeof node !== 'object' || !('type' in node)) return [];
+
+ const element = node as SatoriElement;
+ return [element, ...elements(element.props.children)];
+}
+
+function textContent(node: unknown): string[] {
+ if (typeof node === 'string') return [node];
+ if (Array.isArray(node)) return node.flatMap(textContent);
+ if (node && typeof node === 'object' && 'props' in node) {
+ return textContent((node as SatoriElement).props.children);
+ }
+ return [];
+}
+
+const render = (overrides: Record = {}) =>
+ benefits.render({ ...benefits.defaults, ...overrides });
+
+describe('job-benefits', () => {
+ it('is registered in the job category and re-exported', async () => {
+ expect(getTemplate('job-benefits')?.category).toBe('job');
+ const exports = await import('@/templates/job');
+ expect(exports.jobBenefits.id).toBe('job-benefits');
+ });
+
+ it('renders company, title, and the four perks in order', () => {
+ expect(textContent(render())).toEqual([
+ 'Northstar',
+ 'Why people stay',
+ 'Remote first',
+ '$1,500 learning budget',
+ '4-day weeks in August',
+ 'Health cover for your family',
+ ]);
+ });
+
+ it('skips empty and whitespace-only perks without changing the remaining order', () => {
+ expect(textContent(render({
+ perk1: ' Remote first ',
+ perk2: ' ',
+ perk3: 'Learning budget',
+ perk4: '',
+ }))).toEqual(['Northstar', 'Why people stay', 'Remote first', 'Learning budget']);
+ });
+
+ it('truncates long perks while allowing their text to wrap within its card', () => {
+ const longPerk = 'A generous annual learning budget for courses conferences and books and professional development opportunities for every employee';
+ const rendered = render({ perk1: longPerk });
+ const perkText = elements(rendered).find((element) => element.type === 'span' &&
+ textContent(element).some((text) => text.startsWith('A generous annual')));
+
+ expect(textContent(perkText)[0]).toHaveLength(72);
+ expect(textContent(perkText)[0]).toMatch(/^A generous annual learning budget/);
+ expect(textContent(perkText)[0]).toMatch(/…$/);
+ expect(perkText?.props.style?.overflowWrap).toBe('anywhere');
+ expect(perkText?.props.style?.flex).toBe(1);
+ });
+
+ it('draws each check with inline SVG and keeps the mark readable on a light canvas', () => {
+ const rendered = elements(render({
+ bgColor: '#ffffff',
+ accentColor: '#fde047',
+ }));
+ const icons = rendered.filter((element) => element.type === 'svg');
+
+ expect(icons).toHaveLength(4);
+ expect(JSON.stringify(icons[0])).toContain('#8b7b27');
+ expect(JSON.stringify(icons[0])).toContain('"type":"path"');
+ });
+
+ it('hides a blank company name cleanly', () => {
+ expect(textContent(render({ company: ' ' }))).not.toContain('Northstar');
+ });
+});
diff --git a/tests/templates/bundle-deal.test.ts b/tests/templates/bundle-deal.test.ts
new file mode 100644
index 0000000..910cd86
--- /dev/null
+++ b/tests/templates/bundle-deal.test.ts
@@ -0,0 +1,126 @@
+import { describe, expect, it } from 'vitest';
+import { getTemplate, getTemplatesByCategory } from '@/templates/registry';
+import type { SatoriElement } from '@/templates/types';
+
+const template = getTemplate('ecommerce-bundle-deal');
+if (!template) throw new Error('Template not found: ecommerce-bundle-deal');
+
+function isSatoriElement(value: unknown): value is SatoriElement {
+ return typeof value === 'object' && value !== null && 'type' in value && 'props' in value;
+}
+
+function getChildren(element: SatoriElement): SatoriElement[] {
+ const children = element.props.children;
+ return Array.isArray(children)
+ ? children.filter(isSatoriElement)
+ : isSatoriElement(children)
+ ? [children]
+ : [];
+}
+
+function getAllElements(element: SatoriElement): SatoriElement[] {
+ return [element, ...getChildren(element).flatMap(getAllElements)];
+}
+
+function getTextElements(element: SatoriElement): string[] {
+ return getAllElements(element)
+ .filter((child) => child.type === 'span' && typeof child.props.children === 'string')
+ .map((child) => child.props.children);
+}
+
+describe('ecommerce-bundle-deal', () => {
+ it('is registered as an E-commerce template', () => {
+ expect(template.category).toBe('ecommerce');
+ expect(getTemplatesByCategory('ecommerce')).toContain(template);
+ });
+
+ it('shows at most three non-empty products with plus signs only between them', () => {
+ const element = template.render({
+ ...template.defaults,
+ products: 'Keyboard\n\nDesk Lamp\nTravel Mug\nIgnored Product',
+ });
+ const text = getTextElements(element);
+
+ expect(text).toContain('Keyboard');
+ expect(text).toContain('Desk Lamp');
+ expect(text).toContain('Travel Mug');
+ expect(text).not.toContain('Ignored Product');
+ expect(text.filter((value) => value === '+')).toHaveLength(2);
+ });
+
+ it('truncates long product names', () => {
+ const element = template.render({
+ ...template.defaults,
+ products: 'A'.repeat(60),
+ });
+
+ expect(getTextElements(element)).toContain(`${'A'.repeat(35)}…`);
+ });
+
+ it('truncates displayed prices and fits the bundle total to its length', () => {
+ const element = template.render({
+ ...template.defaults,
+ oldPrice: '$999,999.99 USD after tax',
+ newPrice: '$999,999.99 USD after tax',
+ });
+ const elements = getAllElements(element);
+ const displayPrices = elements.filter((child) => child.props.children === '$999,999.99 U…');
+
+ expect(displayPrices).toHaveLength(2);
+ expect(displayPrices[0].props.style?.fontSize).toBe('42px');
+ expect(displayPrices[1].props.style?.fontSize).toBe(52);
+ });
+
+ it('calculates the floored savings percentage from currency prices', () => {
+ const element = template.render({
+ ...template.defaults,
+ oldPrice: '$120',
+ newPrice: '$89',
+ });
+
+ expect(getTextElements(element)).toContain('Save 25%');
+ });
+
+ it.each([
+ ['$120 sale', '$89'],
+ ['$89', '$120'],
+ ['$120', '$120'],
+ ['$0', '$0'],
+ ])('hides the savings badge for invalid or non-saving prices (%s, %s)', (oldPrice, newPrice) => {
+ const element = template.render({
+ ...template.defaults,
+ oldPrice,
+ newPrice,
+ });
+
+ expect(getTextElements(element).some((value) => value.startsWith('Save '))).toBe(false);
+ });
+
+ it('darkens the accent for price text on light backgrounds only', () => {
+ const darkElement = template.render(template.defaults);
+ const lightElement = template.render({
+ ...template.defaults,
+ bgColor: '#FFFFFF',
+ });
+ const findBundleTotal = (element: SatoriElement) =>
+ getAllElements(element).find((child) => child.props.children === '$89');
+ const findPlusSigns = (element: SatoriElement) =>
+ getAllElements(element).filter((child) => child.props.children === '+');
+
+ expect(findBundleTotal(darkElement)?.props.style?.color).toBe('#E07A5F');
+ expect(findBundleTotal(darkElement)?.props.style?.fontSize).toBe(72);
+ expect(findBundleTotal(lightElement)?.props.style?.color).toBe('#7b4334');
+ expect(findPlusSigns(darkElement).every((child) => child.props.style?.color === '#E07A5F')).toBe(true);
+ expect(findPlusSigns(lightElement).every((child) => child.props.style?.color === '#7b4334')).toBe(true);
+ });
+
+ it('uses flex layout for every div', () => {
+ const element = template.render(template.defaults);
+
+ expect(
+ getAllElements(element)
+ .filter((child) => child.type === 'div')
+ .every((child) => child.props.style?.display === 'flex'),
+ ).toBe(true);
+ });
+});
diff --git a/tests/templates/call-for-speakers.test.ts b/tests/templates/call-for-speakers.test.ts
new file mode 100644
index 0000000..80cf623
--- /dev/null
+++ b/tests/templates/call-for-speakers.test.ts
@@ -0,0 +1,225 @@
+import { describe, it, expect } from 'vitest';
+import callForSpeakers from '@/templates/event/call-for-speakers';
+import { getTemplate } from '@/templates/registry';
+import { hexToRgb } from '@/templates/utils';
+import type { SatoriElement } from '@/templates/types';
+
+/** Every string rendered anywhere in the element tree, in order. */
+function texts(node: unknown): string[] {
+ if (typeof node === 'string') return [node];
+ if (Array.isArray(node)) return node.flatMap(texts);
+ if (node && typeof node === 'object' && 'props' in node) {
+ return texts((node as SatoriElement).props.children);
+ }
+ return [];
+}
+
+/** The style of the element that directly holds this text. */
+function styleOf(node: unknown, text: string): Record | undefined {
+ if (Array.isArray(node)) {
+ for (const child of node) {
+ const found = styleOf(child, text);
+ if (found) return found;
+ }
+ return undefined;
+ }
+ if (node && typeof node === 'object' && 'props' in node) {
+ const { style, children } = (node as SatoriElement).props;
+ if (children === text) return style;
+ return styleOf(children, text);
+ }
+ return undefined;
+}
+
+function luminance(hex: string): number {
+ const rgb = hexToRgb(hex);
+ if (!rgb) throw new Error(`not a hex color: ${hex}`);
+ const [r, g, b] = [rgb.r, rgb.g, rgb.b].map((v) => {
+ const c = v / 255;
+ return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
+ });
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
+}
+
+function contrast(a: string, b: string): number {
+ const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x);
+ return (hi + 0.05) / (lo + 0.05);
+}
+
+const tree = (overrides: Record = {}) =>
+ callForSpeakers.render({ ...callForSpeakers.defaults, ...overrides });
+const render = (overrides: Record = {}) => texts(tree(overrides));
+
+const LABEL = 'Call for speakers';
+const DEFAULT_CHIPS = ['AI', 'Web', 'DevOps', 'Security'];
+/** The chips, which sit between the event name and the "Deadline" label. */
+const chipsOf = (overrides: Record = {}) => {
+ const out = render(overrides);
+ const start = out.indexOf(String(overrides.title ?? callForSpeakers.defaults.title)) + 1;
+ const end = out.includes('Deadline') ? out.indexOf('Deadline') : out.length - 1;
+ return out.slice(start, end);
+};
+
+describe('event-call-for-speakers', () => {
+ it('is registered in the event category', () => {
+ expect(getTemplate('event-call-for-speakers')?.category).toBe('event');
+ });
+
+ it('shows the label, event name, chips, deadline and button with its defaults', () => {
+ expect(render()).toEqual([
+ LABEL,
+ 'DevConf 2027',
+ ...DEFAULT_CHIPS,
+ 'Deadline',
+ 'Nov 30, 2026',
+ 'Submit a talk',
+ ]);
+ });
+
+ describe('topic chips', () => {
+ it('shows at most four, in order', () => {
+ expect(chipsOf({ topics: 'a, b, c, d, e, f' })).toEqual(['a', 'b', 'c', 'd']);
+ });
+
+ it('drops empty entries before counting, so blanks do not use up the four', () => {
+ expect(chipsOf({ topics: ',, a , , b,, c, d, e' })).toEqual(['a', 'b', 'c', 'd']);
+ });
+
+ it('trims spaces around each topic', () => {
+ expect(chipsOf({ topics: ' AI ,\tWeb ' })).toEqual(['AI', 'Web']);
+ });
+
+ it('shows fewer when there are fewer', () => {
+ expect(chipsOf({ topics: 'Only one' })).toEqual(['Only one']);
+ });
+
+ it('shows no chips for empty or blank topics', () => {
+ for (const topics of ['', ' ', ' , , ']) {
+ expect(chipsOf({ topics })).toEqual([]);
+ }
+ });
+
+ it('shortens each chip', () => {
+ const [chip] = chipsOf({ topics: 'An extremely long topic name that goes on and on' });
+ expect(chip.length).toBeLessThanOrEqual(22);
+ expect(chip.endsWith('…')).toBe(true);
+ });
+
+ it('reads the topics from a value that is not a string', () => {
+ expect(() => render({ topics: undefined })).not.toThrow();
+ expect(chipsOf({ topics: 42 })).toEqual(['42']);
+ });
+
+ // When the event name wraps onto two lines the card is full, and a flex item that is allowed
+ // to shrink gets squeezed until its outline is gone. The chips and their row must not shrink.
+ it('does not let the chips or their row shrink', () => {
+ const chain = (node: unknown, text: string): SatoriElement[] | undefined => {
+ if (Array.isArray(node)) {
+ for (const child of node) {
+ const found = chain(child, text);
+ if (found) return found;
+ }
+ return undefined;
+ }
+ if (node && typeof node === 'object' && 'props' in node) {
+ const element = node as SatoriElement;
+ if (element.props.children === text) return [element];
+ const found = chain(element.props.children, text);
+ return found && [...found, element];
+ }
+ return undefined;
+ };
+ const [, chip, row] = chain(tree(), 'AI') ?? [];
+ expect(chip.props.style?.flexShrink).toBe(0);
+ expect(row.props.style?.flexShrink).toBe(0);
+ });
+ });
+
+ describe('deadline', () => {
+ it('hides the deadline and its label when empty or blank', () => {
+ for (const deadline of ['', ' ']) {
+ const out = render({ deadline });
+ expect(out).not.toContain('Deadline');
+ expect(out).not.toContain('Nov 30, 2026');
+ }
+ });
+
+ it('keeps the button when there is no deadline', () => {
+ expect(render({ deadline: '' }).at(-1)).toBe('Submit a talk');
+ });
+
+ it('shortens a very long deadline so it cannot reach the button', () => {
+ const out = render({ deadline: 'D'.repeat(80) });
+ const deadline = out[out.indexOf('Deadline') + 1];
+ expect(deadline.length).toBeLessThanOrEqual(24);
+ });
+ });
+
+ describe('button', () => {
+ it('falls back to "Submit a talk" when the text is empty or blank', () => {
+ for (const ctaText of ['', ' ', undefined]) {
+ expect(render({ ctaText }).at(-1)).toBe('Submit a talk');
+ }
+ });
+
+ it('uses the text that is set', () => {
+ expect(render({ ctaText: 'Apply now' }).at(-1)).toBe('Apply now');
+ });
+
+ it('shortens a very long label', () => {
+ expect(render({ ctaText: 'C'.repeat(60) }).at(-1)!.length).toBeLessThanOrEqual(20);
+ });
+ });
+
+ it('shows the site name only when it is set', () => {
+ expect(render({ siteName: 'devconf.example.com' })).toContain('devconf.example.com');
+ expect(render()).not.toContain('devconf.example.com');
+ expect(render({ siteName: ' ' })).toEqual(render());
+ });
+
+ it('shortens a very long event name, and falls back when it is empty', () => {
+ expect(render({ title: 'T'.repeat(200) })[1].length).toBeLessThanOrEqual(60);
+ expect(render({ title: '' })[1]).toBe('DevConf 2027');
+ });
+
+ it('draws only characters the bundled fonts have (no arrows or icons)', () => {
+ const fontSafe = (text: string) => [...text].every((ch) => ch.codePointAt(0)! <= 0xff || ch === '…');
+ expect(render().every(fontSafe)).toBe(true);
+ expect(render({ title: 'T'.repeat(200), topics: 'x'.repeat(60) }).every(fontSafe)).toBe(true);
+ });
+
+ describe.each([
+ ['the dark default', {}],
+ ['white with dark text', { bgColor: '#ffffff', textColor: '#1f2937', accentColor: '#4f46e5' }],
+ ['white with a pale accent', { bgColor: '#ffffff', textColor: '#1f2937', accentColor: '#fde68a' }],
+ ['cream with a dark accent', { bgColor: '#faf5ea', textColor: '#1c1917', accentColor: '#7c2d12' }],
+ ['dark with a dark accent', { bgColor: '#0b1020', textColor: '#f8fafc', accentColor: '#1e3a8a' }],
+ ])('on %s', (_name, colors) => {
+ const params: Record = { ...callForSpeakers.defaults, ...colors };
+ const bg = String(params.bgColor);
+ const root = callForSpeakers.render(params);
+
+ it('keeps the event name and deadline readable', () => {
+ for (const text of ['DevConf 2027', 'Nov 30, 2026']) {
+ const color = styleOf(root, text)?.color;
+ expect(contrast(String(color), bg), text).toBeGreaterThanOrEqual(4.5);
+ }
+ });
+
+ it('keeps the "Deadline" label readable', () => {
+ expect(contrast(String(styleOf(root, 'Deadline')?.color), bg)).toBeGreaterThanOrEqual(4.5);
+ });
+
+ it('keeps the label and the chips readable', () => {
+ for (const text of [LABEL, 'AI', 'Security']) {
+ const color = styleOf(root, text)?.color;
+ expect(contrast(String(color), bg), text).toBeGreaterThanOrEqual(3);
+ }
+ });
+
+ it('keeps the button text readable on the accent', () => {
+ const color = styleOf(root, 'Submit a talk')?.color;
+ expect(contrast(String(color), String(params.accentColor))).toBeGreaterThanOrEqual(4.5);
+ });
+ });
+});
diff --git a/tests/templates/cheat-sheet.test.ts b/tests/templates/cheat-sheet.test.ts
new file mode 100644
index 0000000..037b332
--- /dev/null
+++ b/tests/templates/cheat-sheet.test.ts
@@ -0,0 +1,117 @@
+import { describe, expect, it } from 'vitest';
+import tutorialCheatSheet from '@/templates/tutorial/cheat-sheet';
+import { tutorialCheatSheet as tutorialCheatSheetExport } from '@/templates/tutorial';
+import { getTemplate, getTemplatesByCategory } from '@/templates/registry';
+import type { SatoriElement } from '@/templates/types';
+
+function texts(node: unknown): string[] {
+ if (typeof node === 'string') return [node];
+ if (Array.isArray(node)) return node.flatMap(texts);
+ if (node && typeof node === 'object' && 'props' in node) {
+ return texts((node as SatoriElement).props.children);
+ }
+ return [];
+}
+
+function walk(node: unknown, visit: (node: SatoriElement) => void): void {
+ if (!node || typeof node !== 'object') return;
+ const element = node as SatoriElement;
+ if (!('props' in element)) return;
+ visit(element);
+ const children = element.props.children;
+ if (Array.isArray(children)) children.forEach((child) => walk(child, visit));
+ else walk(children, visit);
+}
+
+const render = (overrides: Record = {}) =>
+ tutorialCheatSheet.render({ ...tutorialCheatSheet.defaults, ...overrides });
+
+const output = (overrides: Record = {}) => texts(render(overrides));
+
+describe('tutorial-cheat-sheet', () => {
+ it('is registered and exported from the Tutorial section', () => {
+ const registered = getTemplate('tutorial-cheat-sheet');
+ expect(registered).toBe(tutorialCheatSheet);
+ expect(tutorialCheatSheetExport).toBe(tutorialCheatSheet);
+ expect(registered?.category).toBe('tutorial');
+ expect(getTemplatesByCategory('tutorial').filter((template) => template.id === 'tutorial-cheat-sheet'))
+ .toHaveLength(1);
+ });
+
+ it('uses the shared fields and 4-6 relevant tags', () => {
+ for (const key of ['title', 'topic', 'items', 'siteName', 'bgColor', 'accentColor', 'textColor']) {
+ expect(tutorialCheatSheet.fields.some((field) => field.key === key)).toBe(true);
+ }
+ expect(tutorialCheatSheet.tags.length).toBeGreaterThanOrEqual(4);
+ expect(tutorialCheatSheet.tags.length).toBeLessThanOrEqual(6);
+ expect(tutorialCheatSheet.description).toBeTruthy();
+ });
+
+ it('renders the five default commands with descriptions', () => {
+ const values = output();
+ expect(values).toEqual(expect.arrayContaining([
+ 'Git Commands Cheat Sheet',
+ 'Git',
+ 'git status',
+ 'See what changed',
+ 'git add -p',
+ 'Stage changes piece by piece',
+ 'git commit --amend',
+ 'Fix the last commit',
+ 'git switch -c name',
+ 'Create a branch and switch to it',
+ 'git log --oneline',
+ 'Short history',
+ ]));
+ });
+
+ it('skips blank lines and renders only the first five nonblank rows', () => {
+ const values = output({
+ items: '\n\none | first\n \ntwo | second\nthree | third\nfour | fourth\nfive | fifth\nsix | sixth\n',
+ });
+ expect(values).toContain('one');
+ expect(values).toContain('five');
+ expect(values).not.toContain('six');
+ expect(values.filter((value) => value === 'first')).toHaveLength(1);
+ });
+
+ it('splits on the first separator and treats a line without one as a command', () => {
+ const values = output({
+ items: 'echo a | b | c\nplain command',
+ });
+ expect(values).toContain('echo a');
+ expect(values).toContain('b | c');
+ expect(values).toContain('plain command');
+ });
+
+ it('truncates long commands and descriptions', () => {
+ const values = output({
+ items: `${'c'.repeat(90)} | ${'d'.repeat(120)}`,
+ });
+ expect(values).toContain(`${'c'.repeat(33)}…`);
+ expect(values).toContain(`${'d'.repeat(51)}…`);
+ expect(values).not.toContain('d'.repeat(120));
+ });
+
+ it('keeps every row on one line and every div flex', () => {
+ const element = render({
+ items: 'git status | See what changed\nlong | description',
+ });
+ walk(element, (node) => {
+ if (node.type === 'div') expect(node.props.style.display).toBe('flex');
+ });
+ expect(output({ items: 'git status | See what changed' })).not.toContain('See what\nchanged');
+ });
+
+ it('hides empty topic and site name', () => {
+ const values = output({ topic: '', siteName: '' });
+ expect(values).not.toContain('Git');
+ expect(values).not.toContain('ogcops.com');
+ });
+
+ it('remains readable on a light background', () => {
+ const values = output({ bgColor: '#ffffff', textColor: '#111111', accentColor: '#fde047' });
+ expect(values).toContain('Git Commands Cheat Sheet');
+ expect(values).toContain('git status');
+ });
+});
diff --git a/tests/templates/company-culture.test.ts b/tests/templates/company-culture.test.ts
new file mode 100644
index 0000000..ced9730
--- /dev/null
+++ b/tests/templates/company-culture.test.ts
@@ -0,0 +1,71 @@
+import { describe, it, expect } from 'vitest';
+import companyCulture from '@/templates/job/company-culture';
+import { getTemplate } from '@/templates/registry';
+import type { SatoriElement } from '@/templates/types';
+
+const render = (overrides: Record = {}) =>
+ companyCulture.render({ ...companyCulture.defaults, ...overrides });
+
+/** The flex-wrap row that holds the value pills. */
+function findPillRow(node: unknown): SatoriElement | undefined {
+ if (Array.isArray(node)) {
+ for (const child of node) {
+ const found = findPillRow(child);
+ if (found) return found;
+ }
+ return undefined;
+ }
+ if (node && typeof node === 'object' && 'props' in node) {
+ const el = node as SatoriElement;
+ if (el.props.style?.flexWrap === 'wrap') return el;
+ return findPillRow(el.props.children);
+ }
+ return undefined;
+}
+
+function pills(node: unknown): SatoriElement[] {
+ return (findPillRow(node)?.props.children ?? []) as SatoriElement[];
+}
+
+function pillText(pill: SatoriElement): string {
+ return String(pill.props.children.props.children);
+}
+
+const LONG_VALUE_1 = 'We ship small changes every day and review each other code';
+const LONG_VALUE_2 = 'Everyone gets a learning budget and two focus days a month';
+
+describe('job-company-culture', () => {
+ it('is registered in the job category', () => {
+ expect(getTemplate('job-company-culture')?.category).toBe('job');
+ });
+
+ it('renders the four default values verbatim', () => {
+ expect(pills(render()).map(pillText)).toEqual([
+ 'Innovation',
+ 'Transparency',
+ 'Collaboration',
+ 'Impact',
+ ]);
+ });
+
+ it('truncates long values so pill text stays within its outline', () => {
+ const texts = pills(render({ value1: LONG_VALUE_1, value2: LONG_VALUE_2 })).map(pillText);
+ expect(texts).toEqual([
+ 'We ship small changes every day and rev…',
+ 'Everyone gets a learning budget and two…',
+ 'Collaboration',
+ 'Impact',
+ ]);
+ });
+
+ it('keeps every pill at full width with flexShrink 0', () => {
+ const rendered = pills(render({ value1: LONG_VALUE_1, value2: LONG_VALUE_2 }));
+ expect(rendered).toHaveLength(4);
+ for (const pill of rendered) {
+ expect(pill.props.style.flexShrink).toBe(0);
+ expect(pill.props.style.padding).toBe('10px 24px');
+ expect(pill.props.style.borderRadius).toBe('100px');
+ expect(pill.props.style.border).toContain('2px solid');
+ }
+ });
+});
\ No newline at end of file
diff --git a/tests/templates/day-streak.test.ts b/tests/templates/day-streak.test.ts
new file mode 100644
index 0000000..c3ff5d3
--- /dev/null
+++ b/tests/templates/day-streak.test.ts
@@ -0,0 +1,34 @@
+import { describe, expect, it } from 'vitest';
+import dayStreak from '../../src/templates/developer/day-streak';
+
+const render = (overrides: Record = {}) =>
+ dayStreak.render({ ...dayStreak.defaults, ...overrides });
+
+const text = (node: unknown): string[] => {
+ if (typeof node === 'string') return [node];
+ if (!node || typeof node !== 'object') return [];
+ const value = node as { props?: { children?: unknown } };
+ const children = value.props?.children;
+ return Array.isArray(children) ? children.flatMap(text) : text(children);
+};
+
+describe('developer-day-streak', () => {
+ it('uses the defaults and renders all days', () => {
+ const tree = render();
+ expect(text(tree)).toContain('Day 42');
+ expect(text(tree)).toContain('of 100');
+ expect(JSON.stringify(tree)).toContain('"width":"18px"');
+ expect(JSON.stringify(tree)).toContain('"color":"#E07A5F"');
+ });
+
+ it('defensively parses and clamps day values', () => {
+ expect(text(render({ currentDay: 'not-a-number', totalDays: '30' }))).toContain('Day 30');
+ expect(text(render({ currentDay: 99, totalDays: 7 }))).toContain('Day 7');
+ expect(text(render({ currentDay: 0, totalDays: 0 }))).toContain('Day 1');
+ expect(text(render({ currentDay: 12, totalDays: 200 }))).toContain('Day 12');
+ });
+
+ it('does not use Satori-incompatible centered grid content alignment', () => {
+ expect(JSON.stringify(render())).not.toContain('alignContent');
+ });
+});
diff --git a/tests/templates/deprecation.test.ts b/tests/templates/deprecation.test.ts
new file mode 100644
index 0000000..6eb734b
--- /dev/null
+++ b/tests/templates/deprecation.test.ts
@@ -0,0 +1,111 @@
+import { describe, expect, it } from 'vitest';
+import deprecation from '@/templates/saas/deprecation';
+import { saasDeprecation } from '@/templates/saas';
+import { getTemplate, getTemplatesByCategory } from '@/templates/registry';
+import type { SatoriElement } from '@/templates/types';
+
+function walk(node: unknown, visit: (element: SatoriElement) => void): void {
+ if (Array.isArray(node)) {
+ node.forEach((child) => walk(child, visit));
+ return;
+ }
+ if (!node || typeof node !== 'object' || !('type' in node)) return;
+
+ const element = node as SatoriElement;
+ visit(element);
+ walk(element.props.children, visit);
+}
+
+function allElements(node: unknown): SatoriElement[] {
+ const found: SatoriElement[] = [];
+ walk(node, (element) => found.push(element));
+ return found;
+}
+
+function textContent(node: unknown): string[] {
+ if (typeof node === 'string') return [node];
+ if (Array.isArray(node)) return node.flatMap(textContent);
+ if (node && typeof node === 'object' && 'props' in node) {
+ return textContent((node as SatoriElement).props.children);
+ }
+ return [];
+}
+
+const render = (overrides: Record = {}) =>
+ deprecation.render({ ...deprecation.defaults, ...overrides });
+
+describe('saas-deprecation', () => {
+ it('is re-exported and registered exactly once in the SaaS category', () => {
+ expect(saasDeprecation).toBe(deprecation);
+ expect(getTemplate('saas-deprecation')).toBe(deprecation);
+ expect(getTemplatesByCategory('saas').filter((template) => template.id === 'saas-deprecation'))
+ .toHaveLength(1);
+ });
+
+ it('renders the deprecated name, replacement, sunset date, note, and site name', () => {
+ expect(textContent(render())).toEqual([
+ 'Deprecated',
+ 'NO LONGER SUPPORTED',
+ 'API v1',
+ 'USE INSTEAD',
+ 'API v2',
+ 'Shuts down on March 31, 2027',
+ 'The migration guide is in the docs',
+ 'Acme API',
+ ]);
+ expect(allElements(render()).find((element) =>
+ element.type === 'span' && textContent(element).includes('Acme API')
+ )?.props.style.alignSelf).toBe('flex-end');
+ expect(allElements(render()).some((element) => element.type === 'svg')).toBe(true);
+ });
+
+ it('hides whitespace-only optional fields and the arrow when the replacement is empty', () => {
+ const tree = render({ replacement: ' ', sunsetDate: ' ', description: ' ', siteName: ' ' });
+ expect(textContent(tree)).toEqual(['Deprecated', 'NO LONGER SUPPORTED', 'API v1']);
+ expect(allElements(tree).some((element) => element.type === 'svg')).toBe(false);
+ });
+
+ it('shrinks then truncates long old and replacement names', () => {
+ const tree = render({ title: 'Old API name '.repeat(9), replacement: 'Replacement API name '.repeat(8) });
+ const elements = allElements(tree);
+ const oldName = elements.find((element) => element.type === 'h1');
+ const newName = elements.find((element) =>
+ element.type === 'span' && textContent(element).some((text) => text.startsWith('Replacement API'))
+ );
+
+ expect(textContent(oldName)[0].length).toBeLessThanOrEqual(44);
+ expect(textContent(oldName)[0]).toMatch(/…$/);
+ expect(oldName?.props.style.fontSize).toBe('42px');
+ expect(oldName?.props.style.textDecoration).toBe('line-through');
+ expect(textContent(newName)[0].length).toBeLessThanOrEqual(44);
+ expect(textContent(newName)[0]).toMatch(/…$/);
+ expect(newName?.props.style.fontSize).toBe('42px');
+ });
+
+ it('darkens label and accent text on a light background with a pale accent', () => {
+ const tree = render({ bgColor: '#ffffff', textColor: '#111111', accentColor: '#fde047' });
+ const elements = allElements(tree);
+ const deprecatedLabel = elements.find((element) =>
+ textContent(element).includes('Deprecated') && element.type === 'span'
+ );
+ const replacementLabel = elements.find((element) =>
+ textContent(element).includes('USE INSTEAD') && element.type === 'span'
+ );
+ const sunset = elements.find((element) =>
+ element.type === 'span' && textContent(element).includes('Shuts down on March 31, 2027')
+ );
+
+ expect(deprecatedLabel?.props.style.color).toBe('#8b7b27');
+ expect(replacementLabel?.props.style.color).toBe('#8b7b27');
+ expect(sunset?.props.style.color).toBe('#8b7b27');
+ });
+
+ it('uses the required canvas size and flex layout for all divs', () => {
+ const elements = allElements(render());
+ expect(elements[0].props.style.width).toBe('1200px');
+ expect(elements[0].props.style.height).toBe('630px');
+ expect(elements.every((element) =>
+ element.type !== 'div' || element.props.style?.display === 'flex'
+ )).toBe(true);
+ });
+});
diff --git a/tests/templates/developer-year-review.test.ts b/tests/templates/developer-year-review.test.ts
new file mode 100644
index 0000000..07d500d
--- /dev/null
+++ b/tests/templates/developer-year-review.test.ts
@@ -0,0 +1,336 @@
+import { describe, it, expect } from 'vitest';
+import yearReview from '@/templates/developer/year-review';
+import { getTemplate } from '@/templates/registry';
+import { hexToRgb } from '@/templates/utils';
+import type { SatoriElement } from '@/templates/types';
+
+type Style = Record;
+
+const asElement = (node: unknown) => node as SatoriElement;
+const childrenOf = (node: unknown): unknown[] => {
+ const { children } = asElement(node).props;
+ return Array.isArray(children) ? children : children === undefined ? [] : [children];
+};
+const styleOf = (node: unknown) => (asElement(node).props.style ?? {}) as Style;
+
+const tree = (overrides: Record = {}) =>
+ yearReview.render({ ...yearReview.defaults, ...overrides });
+
+/** Every string rendered anywhere in the element tree, in order. */
+function texts(node: unknown): string[] {
+ if (typeof node === 'string') return [node];
+ if (Array.isArray(node)) return node.flatMap(texts);
+ if (node && typeof node === 'object' && 'props' in node) return texts(asElement(node).props.children);
+ return [];
+}
+const render = (overrides: Record = {}) => texts(tree(overrides));
+
+/** Every element in the tree. */
+function everyElement(node: unknown): SatoriElement[] {
+ if (Array.isArray(node)) return node.flatMap(everyElement);
+ if (node && typeof node === 'object' && 'props' in node) {
+ return [asElement(node), ...everyElement(asElement(node).props.children)];
+ }
+ return [];
+}
+
+/** The stat cards: each is a value and, when there is one, a label. */
+function cards(overrides: Record = {}): SatoriElement[] {
+ const row = childrenOf(tree(overrides)).find((child) => styleOf(child).gap !== undefined);
+ return row ? (childrenOf(row) as SatoriElement[]) : [];
+}
+const valueSpan = (card: SatoriElement) => asElement(childrenOf(card)[0]);
+const values = (overrides: Record = {}) => cards(overrides).map((card) => texts(valueSpan(card)).join(''));
+const labels = (overrides: Record = {}) =>
+ cards(overrides).map((card) => (childrenOf(card)[1] ? texts(childrenOf(card)[1]).join('') : ''));
+const valueSize = (overrides: Record = {}) =>
+ cards(overrides).map((card) => Number(String(styleOf(valueSpan(card)).fontSize).replace('px', '')));
+
+const yearSpan = (overrides: Record = {}) => {
+ const top = childrenOf(tree(overrides))[0];
+ return asElement(childrenOf(top)[0]);
+};
+const nameSpan = (overrides: Record = {}) => {
+ const top = childrenOf(tree(overrides))[0];
+ return childrenOf(top)[1] ? asElement(childrenOf(top)[1]) : undefined;
+};
+const highlightText = (overrides: Record = {}) => {
+ const last = childrenOf(tree(overrides)).at(-1);
+ return styleOf(last).alignItems === 'center' && childrenOf(last).length === 2 ? texts(childrenOf(last)[1]).join('') : '';
+};
+
+function luminance(hex: string): number {
+ const rgb = hexToRgb(hex);
+ if (!rgb) throw new Error(`not a hex color: ${hex}`);
+ const [r, g, b] = [rgb.r, rgb.g, rgb.b].map((v) => {
+ const c = v / 255;
+ return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
+ });
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
+}
+function contrast(a: string, b: string): number {
+ const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x);
+ return (hi + 0.05) / (lo + 0.05);
+}
+
+describe('developer-year-review', () => {
+ it('is registered in the developer category', () => {
+ expect(getTemplate('developer-year-review')?.category).toBe('developer');
+ });
+
+ it('has the fields from the issue', () => {
+ expect(yearReview.fields.map((field) => field.key)).toEqual([
+ 'name',
+ 'year',
+ 'stat1Value',
+ 'stat1Label',
+ 'stat2Value',
+ 'stat2Label',
+ 'stat3Value',
+ 'stat3Label',
+ 'highlight',
+ 'bgColor',
+ 'accentColor',
+ 'textColor',
+ ]);
+ expect(yearReview.defaults.year).toBe('2026');
+ });
+
+ it('shows the year, name, three stats and the highlight with its defaults', () => {
+ expect(render()).toEqual([
+ '2026',
+ 'Alex Rivera',
+ '1,248',
+ 'commits',
+ '86',
+ 'pull requests',
+ 'TypeScript',
+ 'top language',
+ 'Shipped my first open source library',
+ ]);
+ });
+
+ describe('stats', () => {
+ it('shares the row between three stats', () => {
+ expect(cards()).toHaveLength(3);
+ for (const card of cards()) expect(styleOf(card).flex).toBe(1);
+ });
+
+ it('hides a stat with an empty value, and its label, and the others share the row', () => {
+ expect(values({ stat2Value: '' })).toEqual(['1,248', 'TypeScript']);
+ expect(labels({ stat2Value: '' })).toEqual(['commits', 'top language']);
+ expect(render({ stat2Value: '' })).not.toContain('pull requests');
+ });
+
+ it('hides a stat whose value is only spaces, or is missing', () => {
+ expect(values({ stat1Value: ' ' })).toEqual(['86', 'TypeScript']);
+ expect(values({ stat1Value: undefined })).toEqual(['86', 'TypeScript']);
+ expect(values({ stat3Value: null })).toEqual(['1,248', '86']);
+ });
+
+ it('shows one stat on its own, and none without a row', () => {
+ expect(values({ stat1Value: '', stat3Value: '' })).toEqual(['86']);
+ expect(cards({ stat1Value: '', stat2Value: '', stat3Value: '' })).toHaveLength(0);
+ // No empty row is left behind either
+ const none = { stat1Value: '', stat2Value: '', stat3Value: '' };
+ expect(childrenOf(tree(none)).some((child) => styleOf(child).gap !== undefined)).toBe(false);
+ });
+
+ it('keeps the order, even when the first one is missing', () => {
+ expect(values({ stat1Value: '' })).toEqual(['86', 'TypeScript']);
+ });
+
+ it('shows a stat without a label when the label is empty', () => {
+ expect(values({ stat2Label: '' })).toEqual(['1,248', '86', 'TypeScript']);
+ expect(labels({ stat2Label: ' ' })).toEqual(['commits', '', 'top language']);
+ // The card holds just the value, with no empty label and its spacing
+ expect(childrenOf(cards({ stat2Label: '' })[1])).toHaveLength(1);
+ });
+
+ it('shortens a long value to 14 characters and a long label to 28', () => {
+ const [value] = values({ stat1Value: '1,234,567,890,123,456' });
+ expect(value.length).toBeLessThanOrEqual(14);
+ expect(value.endsWith('…')).toBe(true);
+ const [label] = labels({ stat1Label: 'a very long label that goes on and on and on' });
+ expect(label.length).toBeLessThanOrEqual(28);
+ expect(label.endsWith('…')).toBe(true);
+ });
+
+ it('uses one size for every value', () => {
+ for (const overrides of [{}, { stat3Value: 'x'.repeat(14) }, { stat2Value: '' }]) {
+ expect(new Set(valueSize(overrides)).size, JSON.stringify(overrides)).toBe(1);
+ }
+ });
+
+ it('shrinks the values when one is long, and grows them when fewer share the row', () => {
+ const [normal] = valueSize();
+ const [long] = valueSize({ stat3Value: 'Rust, Go, Zig, C' });
+ expect(long).toBeLessThan(normal);
+ const [two] = valueSize({ stat2Value: '' });
+ expect(two).toBeGreaterThan(normal);
+ const [one] = valueSize({ stat1Value: '', stat3Value: '' });
+ expect(one).toBeGreaterThanOrEqual(two);
+ });
+
+ it('keeps a long value inside its card, with an ellipsis if it still does not fit', () => {
+ for (const card of cards({ stat1Value: 'W'.repeat(14) })) {
+ const style = styleOf(valueSpan(card));
+ expect(style.overflow).toBe('hidden');
+ expect(style.textOverflow).toBe('ellipsis');
+ expect(style.whiteSpace).toBe('nowrap');
+ }
+ });
+
+ it('turns a number into text', () => {
+ expect(values({ stat1Value: 1248, stat2Value: 0 })).toEqual(['1248', '0', 'TypeScript']);
+ });
+
+ it('puts text on one line, however it was typed', () => {
+ expect(values({ stat1Value: ' 1,248\n\ncommits ' })[0]).toBe('1,248 commits');
+ });
+ });
+
+ describe('highlight', () => {
+ it('hides when it is empty, only spaces, or missing', () => {
+ for (const highlight of ['', ' ', '\n\t', undefined, null]) {
+ expect(highlightText({ highlight }), String(highlight)).toBe('');
+ expect(render({ highlight })).toEqual([
+ '2026',
+ 'Alex Rivera',
+ '1,248',
+ 'commits',
+ '86',
+ 'pull requests',
+ 'TypeScript',
+ 'top language',
+ ]);
+ }
+ });
+
+ it('shows the line, and shortens a very long one', () => {
+ expect(highlightText({ highlight: 'Learned Rust' })).toBe('Learned Rust');
+ const long = highlightText({ highlight: 'word '.repeat(60) });
+ expect(long.length).toBeLessThanOrEqual(80);
+ expect(long.endsWith('…')).toBe(true);
+ });
+
+ it('is one line of text even when it was typed with line breaks', () => {
+ expect(highlightText({ highlight: 'First\n\nsecond' })).toBe('First second');
+ });
+ });
+
+ describe('year and name', () => {
+ it('shows the year and the name next to it', () => {
+ expect(texts(yearSpan())).toEqual(['2026']);
+ expect(texts(nameSpan())).toEqual(['Alex Rivera']);
+ });
+
+ it('hides the name when it is empty or blank', () => {
+ for (const name of ['', ' ', undefined]) expect(nameSpan({ name }), String(name)).toBeUndefined();
+ });
+
+ it('falls back to 2026 when the year is empty or blank', () => {
+ for (const year of ['', ' ', undefined]) expect(texts(yearSpan({ year })), String(year)).toEqual(['2026']);
+ });
+
+ it('shows another year, and uses a smaller size for a long one', () => {
+ expect(texts(yearSpan({ year: '2027' }))).toEqual(['2027']);
+ const size = (year: string) => Number(String(styleOf(yearSpan({ year })).fontSize).replace('px', ''));
+ expect(size('FY2026')).toBeLessThan(size('2026'));
+ expect(size('FY2026-27')).toBeLessThan(size('FY2026'));
+ });
+
+ // A long name in capitals can take more room than is left next to the year. The year must
+ // keep its width and the gap, and the name wraps beside it, instead of the year being squeezed.
+ it('does not let the year shrink next to a long name', () => {
+ for (const overrides of [{}, { name: 'MAXIMILIANO WOLFGANG RODRIGUEZ' }, { name: 'n'.repeat(60), year: 'FY2026-27' }]) {
+ expect(styleOf(yearSpan(overrides)).flexShrink, JSON.stringify(overrides)).toBe(0);
+ }
+ expect(styleOf(nameSpan({ name: 'MAXIMILIANO WOLFGANG RODRIGUEZ' })).marginLeft).toBe('36px');
+ });
+
+ it('shortens a very long year and name', () => {
+ const year = texts(yearSpan({ year: 'x'.repeat(40) })).join('');
+ expect(year.length).toBeLessThanOrEqual(10);
+ const name = texts(nameSpan({ name: 'n'.repeat(80) })).join('');
+ expect(name.length).toBeLessThanOrEqual(26);
+ expect(name.endsWith('…')).toBe(true);
+ });
+ });
+
+ describe('unusual input', () => {
+ it('does not break on values that are not strings', () => {
+ expect(() => tree({ name: undefined, year: null, highlight: 42, stat1Value: {}, stat2Label: [] })).not.toThrow();
+ expect(() => tree({ name: 7, year: 2026, stat3Value: true })).not.toThrow();
+ expect(texts(yearSpan({ year: 2026 }))).toEqual(['2026']);
+ });
+
+ it('does not break when nothing is filled in', () => {
+ expect(() =>
+ tree({ name: '', year: '', stat1Value: '', stat2Value: '', stat3Value: '', highlight: '' }),
+ ).not.toThrow();
+ });
+ });
+
+ describe('layout', () => {
+ it('fills the card, in Inter', () => {
+ const style = styleOf(tree());
+ expect(style.width).toBe('1200px');
+ expect(style.height).toBe('630px');
+ expect(style.fontFamily).toContain('Inter');
+ });
+
+ it('uses flexbox only, with display flex on every div', () => {
+ for (const overrides of [{}, { stat2Value: '' }, { highlight: '', name: '' }, { stat1Value: '', stat2Value: '', stat3Value: '' }]) {
+ for (const el of everyElement(tree(overrides))) {
+ const style = styleOf(el);
+ if (el.type === 'div') expect(style.display, JSON.stringify(overrides)).toBe('flex');
+ expect(style.display).not.toBe('grid');
+ }
+ }
+ });
+
+ it('draws the same image every time', () => {
+ expect(JSON.stringify(tree())).toBe(JSON.stringify(tree()));
+ });
+
+ it('only uses characters the fonts have, apart from an ellipsis the template adds', () => {
+ const fontSafe = (text: string) => [...text].every((ch) => ch.codePointAt(0)! <= 0xff || ch === '…');
+ expect(render().every(fontSafe)).toBe(true);
+ expect(render({ name: 'n'.repeat(60), highlight: 'h'.repeat(120), stat1Value: 'v'.repeat(30), stat1Label: 'l'.repeat(60) }).every(fontSafe)).toBe(true);
+ });
+ });
+
+ describe.each([
+ ['the dark default', {}],
+ ['white with dark text', { bgColor: '#ffffff', textColor: '#1f2937', accentColor: '#059669' }],
+ ['white with a pale accent', { bgColor: '#ffffff', textColor: '#1f2937', accentColor: '#fde68a' }],
+ ['cream with a dark accent', { bgColor: '#faf5ea', textColor: '#1c1917', accentColor: '#7c2d12' }],
+ ['dark with a dark accent', { bgColor: '#0b0f1a', textColor: '#f1f5f9', accentColor: '#1e3a8a' }],
+ ])('on %s', (_name, colors) => {
+ const params = { ...yearReview.defaults, ...colors };
+ const bg = String(params.bgColor);
+ const root = yearReview.render(params);
+ const card = childrenOf(childrenOf(root).find((child) => styleOf(child).gap !== undefined))[0];
+ const panelBg = String(styleOf(card).background);
+
+ it('keeps the year readable on the page', () => {
+ expect(contrast(String(styleOf(yearSpan(colors)).color), bg)).toBeGreaterThanOrEqual(3);
+ });
+
+ it('keeps the name and the highlight readable on the page', () => {
+ expect(contrast(String(styleOf(nameSpan(colors)).color), bg)).toBeGreaterThanOrEqual(4.5);
+ const last = childrenOf(root).at(-1);
+ expect(contrast(String(styleOf(childrenOf(last)[1]).color), bg)).toBeGreaterThanOrEqual(4.5);
+ });
+
+ it('keeps the stat values and labels readable on their cards', () => {
+ expect(contrast(String(styleOf(childrenOf(card)[0]).color), panelBg)).toBeGreaterThanOrEqual(3);
+ expect(contrast(String(styleOf(childrenOf(card)[1]).color), panelBg)).toBeGreaterThanOrEqual(4.5);
+ });
+
+ it('draws the cards apart from the page', () => {
+ expect(panelBg).not.toBe(bg);
+ });
+ });
+});
diff --git a/tests/templates/error-explained.test.ts b/tests/templates/error-explained.test.ts
new file mode 100644
index 0000000..3e824e0
--- /dev/null
+++ b/tests/templates/error-explained.test.ts
@@ -0,0 +1,158 @@
+import { readFileSync } from 'node:fs';
+import { join } from 'node:path';
+import satori from 'satori';
+import { describe, expect, it } from 'vitest';
+import errorExplained from '@/templates/tutorial/error-explained';
+import { getTemplate, getTemplatesByCategory } from '@/templates/registry';
+import type { SatoriElement } from '@/templates/types';
+
+const fonts = [
+ {
+ name: 'Inter',
+ data: readFileSync(join(process.cwd(), 'public/fonts/Inter-Regular.woff')),
+ weight: 400 as const,
+ style: 'normal' as const,
+ },
+ {
+ name: 'Inter',
+ data: readFileSync(join(process.cwd(), 'public/fonts/Inter-Bold.woff')),
+ weight: 700 as const,
+ style: 'normal' as const,
+ },
+ {
+ name: 'JetBrains Mono',
+ data: readFileSync(join(process.cwd(), 'public/fonts/JetBrainsMono-Regular.woff')),
+ weight: 400 as const,
+ style: 'normal' as const,
+ },
+];
+
+function isSatoriElement(value: unknown): value is SatoriElement {
+ return typeof value === 'object' && value !== null && 'type' in value && 'props' in value;
+}
+
+function getChildren(element: SatoriElement): SatoriElement[] {
+ const children = element.props.children;
+ return Array.isArray(children) ? children.filter(isSatoriElement) : isSatoriElement(children) ? [children] : [];
+}
+
+function getAllElements(element: SatoriElement): SatoriElement[] {
+ return [element, ...getChildren(element).flatMap(getAllElements)];
+}
+
+function texts(node: unknown): string[] {
+ if (typeof node === 'string') return [node];
+ if (Array.isArray(node)) return node.flatMap(texts);
+ if (node && typeof node === 'object' && 'props' in node) {
+ return texts((node as SatoriElement).props.children);
+ }
+ return [];
+}
+
+const render = (overrides: Record = {}) =>
+ errorExplained.render({ ...errorExplained.defaults, ...overrides });
+const serialized = (overrides: Record = {}) => JSON.stringify(render(overrides));
+
+describe('tutorial-error-explained', () => {
+ it('is registered in tutorial category and re-exported', async () => {
+ expect(getTemplate('tutorial-error-explained')?.category).toBe('tutorial');
+ expect(getTemplatesByCategory('tutorial')).toContain(errorExplained);
+ const exports = await import('@/templates/tutorial');
+ expect(exports.tutorialErrorExplained.id).toBe('tutorial-error-explained');
+ });
+
+ it('renders default error message, language pill, meaning, and fix', () => {
+ const rendered = texts(render());
+ expect(rendered).toContain('Error explained');
+ expect(rendered).toContain('JavaScript');
+ expect(rendered).toContain("TypeError: Cannot read properties of undefined (reading 'map')");
+ expect(rendered).toContain('Meaning');
+ expect(rendered).toContain("You called .map() on something that isn't an array yet");
+ expect(rendered).toContain('Fix');
+ expect(rendered).toContain('Start the state as [] or check before mapping');
+ });
+
+ it('renders site name when provided', () => {
+ const tree = serialized({ siteName: 'codercops.com' });
+ expect(tree).toContain('codercops.com');
+ });
+
+ it('hides empty language, meaning, fix, and site name cleanly when empty or only spaces', () => {
+ const tree = serialized({
+ language: ' ',
+ meaning: ' ',
+ fix: '',
+ siteName: ' ',
+ });
+ expect(tree).not.toContain('JavaScript');
+ expect(tree).not.toContain('Meaning');
+ expect(tree).not.toContain('Fix');
+ expect(tree).not.toContain('codercops.com');
+ });
+
+ it('shrinks long error messages with autoFontSize and truncates inside the box', () => {
+ const longError =
+ 'Uncaught TypeError: Failed to execute fetch on Window: Failed to parse URL from /api/v1/users/undefined/preferences/notifications?enabled=true';
+ const element = render({ title: longError });
+ const all = getAllElements(element);
+ const errorSpan = all.find(
+ (el) => el.type === 'span' && el.props.style?.wordBreak === 'break-word',
+ );
+
+ expect(errorSpan).toBeDefined();
+ expect(errorSpan?.props.style?.fontSize).toBe('21px');
+ const content = String(errorSpan?.props.children);
+ expect(content).toContain('…');
+ expect(content.length).toBeLessThanOrEqual(120);
+ });
+
+ it('stays readable on a light background with appropriate text and box colors', () => {
+ const lightTree = render({
+ bgColor: '#ffffff',
+ textColor: '#111827',
+ accentColor: '#38bdf8',
+ });
+ const all = getAllElements(lightTree);
+
+ const errorSpan = all.find(
+ (el) => el.type === 'span' && el.props.style?.wordBreak === 'break-word',
+ );
+ expect(errorSpan?.props.style?.color).toBe('#991b1b');
+
+ const labelSpan = all.find(
+ (el) => el.type === 'span' && el.props.children === 'Error explained',
+ );
+ expect(labelSpan?.props.style?.color).toBe('#1f6888');
+ });
+
+ it('uses flex display on every div for Satori compatibility', () => {
+ const element = render(errorExplained.defaults);
+ const divs = getAllElements(element).filter((child) => child.type === 'div');
+ expect(divs.every((child) => child.props.style?.display === 'flex')).toBe(true);
+ });
+
+ it('aligns explanation labels with the first line using flex-start', () => {
+ const element = render({
+ meaning:
+ 'You called .map() on a value that is still undefined, usually because the data has not loaded yet',
+ });
+ const all = getAllElements(element);
+ const meaningLabel = all.find(
+ (el) => el.type === 'span' && el.props.children === 'Meaning',
+ );
+ expect(meaningLabel?.props.style?.marginTop).toBe('4px');
+
+ const rows = all.filter(
+ (el) =>
+ el.type === 'div' &&
+ el.props.style?.gap === '20px' &&
+ el.props.style?.alignItems === 'flex-start',
+ );
+ expect(rows.length).toBe(2);
+ });
+
+ it('renders a valid SVG image via Satori', async () => {
+ const element = render(errorExplained.defaults);
+ await expect(satori(element as any, { width: 1200, height: 630, fonts })).resolves.toMatch(/^;
+
+const asElement = (node: unknown) => node as SatoriElement;
+const childrenOf = (node: unknown): unknown[] => {
+ const { children } = asElement(node).props;
+ return Array.isArray(children) ? children : children === undefined ? [] : [children];
+};
+const styleOf = (node: unknown) => (asElement(node).props.style ?? {}) as Style;
+
+const tree = (overrides: Record = {}) =>
+ rescheduled.render({ ...rescheduled.defaults, ...overrides });
+
+/** Every string rendered anywhere in the element tree, in order. */
+function texts(node: unknown): string[] {
+ if (typeof node === 'string') return [node];
+ if (Array.isArray(node)) return node.flatMap(texts);
+ if (node && typeof node === 'object' && 'props' in node) return texts(asElement(node).props.children);
+ return [];
+}
+const render = (overrides: Record = {}) => texts(tree(overrides));
+
+/** Every element in the tree. */
+function everyElement(node: unknown): SatoriElement[] {
+ if (Array.isArray(node)) return node.flatMap(everyElement);
+ if (node && typeof node === 'object' && 'props' in node) {
+ return [asElement(node), ...everyElement(asElement(node).props.children)];
+ }
+ return [];
+}
+
+// The card is the pill, then the event name over the dates, then the note when there is one.
+const pill = (overrides: Record = {}) => asElement(childrenOf(childrenOf(tree(overrides))[0])[0]);
+const middle = (overrides: Record = {}) => childrenOf(tree(overrides))[1];
+const titleSpan = (overrides: Record = {}) => asElement(childrenOf(middle(overrides))[0]);
+const dateRow = (overrides: Record = {}) => childrenOf(childrenOf(middle(overrides))[1]);
+const oldDateSpan = (overrides: Record = {}) => {
+ const row = dateRow(overrides);
+ return row.length === 3 ? asElement(row[0]) : undefined;
+};
+const newDateSpan = (overrides: Record = {}) => asElement(dateRow(overrides).at(-1));
+const noteSpan = (overrides: Record = {}) => {
+ const all = childrenOf(tree(overrides));
+ return all.length === 3 ? asElement(all[2]) : undefined;
+};
+const fontSize = (el: SatoriElement | undefined) => Number(String(styleOf(el).fontSize).replace('px', ''));
+
+function luminance(hex: string): number {
+ const rgb = hexToRgb(hex);
+ if (!rgb) throw new Error(`not a hex color: ${hex}`);
+ const [r, g, b] = [rgb.r, rgb.g, rgb.b].map((v) => {
+ const c = v / 255;
+ return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
+ });
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
+}
+function contrast(a: string, b: string): number {
+ const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x);
+ return (hi + 0.05) / (lo + 0.05);
+}
+
+describe('event-rescheduled', () => {
+ it('is registered in the event category', () => {
+ expect(getTemplate('event-rescheduled')?.category).toBe('event');
+ });
+
+ it('has the fields from the issue', () => {
+ expect(rescheduled.fields.map((field) => field.key)).toEqual([
+ 'title',
+ 'oldDate',
+ 'newDate',
+ 'note',
+ 'bgColor',
+ 'accentColor',
+ 'textColor',
+ ]);
+ });
+
+ it('shows the pill, event name, both dates and the note with its defaults', () => {
+ expect(render()).toEqual([
+ 'Rescheduled',
+ 'Frontend Meetup #14',
+ 'Thu, 6 Nov, 7 PM',
+ 'Thu, 13 Nov, 7 PM',
+ 'Same venue, your tickets still work',
+ ]);
+ });
+
+ describe('dates', () => {
+ it('strikes the old date through and dims it', () => {
+ const old = styleOf(oldDateSpan());
+ expect(old.textDecoration).toBe('line-through');
+ expect(old.color).not.toBe(styleOf(titleSpan()).color);
+ expect(old.color).not.toBe(styleOf(newDateSpan()).color);
+ });
+
+ it('shows the new date bold in the accent colour, with no strike', () => {
+ const style = styleOf(newDateSpan());
+ expect(style.textDecoration).toBeUndefined();
+ expect(style.fontWeight).toBe(700);
+ expect(fontSize(newDateSpan())).toBeGreaterThan(fontSize(oldDateSpan()));
+ });
+
+ it('puts an arrow between them', () => {
+ const row = dateRow();
+ expect(row).toHaveLength(3);
+ expect(JSON.stringify(row[1])).toContain('svg');
+ });
+
+ it('hides the old date, and the arrow, when it is empty, only spaces, or missing', () => {
+ for (const oldDate of ['', ' ', '\n', undefined, null]) {
+ expect(oldDateSpan({ oldDate }), String(oldDate)).toBeUndefined();
+ expect(dateRow({ oldDate }), String(oldDate)).toHaveLength(1);
+ expect(render({ oldDate })).toEqual(['Rescheduled', 'Frontend Meetup #14', 'Thu, 13 Nov, 7 PM', 'Same venue, your tickets still work']);
+ }
+ });
+
+ it('falls back to the default new date when it is empty or blank', () => {
+ for (const newDate of ['', ' ', undefined]) {
+ expect(texts(newDateSpan({ newDate })), String(newDate)).toEqual(['Thu, 13 Nov, 7 PM']);
+ }
+ });
+
+ it('shows other dates, with line breaks and extra spaces as single spaces', () => {
+ expect(texts(newDateSpan({ newDate: 'Fri,\n14 Nov' }))).toEqual(['Fri, 14 Nov']);
+ expect(texts(oldDateSpan({ oldDate: ' Fri, 7 Nov ' }))).toEqual(['Fri, 7 Nov']);
+ });
+
+ it('keeps each date on one line', () => {
+ expect(styleOf(newDateSpan()).whiteSpace).toBe('nowrap');
+ expect(styleOf(oldDateSpan()).whiteSpace).toBe('nowrap');
+ });
+
+ it('shortens a very long date to 28 characters', () => {
+ const long = 'Wednesday, 26 November 2026 at 7 PM, doors open at 6';
+ for (const span of [oldDateSpan({ oldDate: long }), newDateSpan({ newDate: long })]) {
+ const text = texts(span).join('');
+ expect(text.length).toBeLessThanOrEqual(28);
+ expect(text.endsWith('…')).toBe(true);
+ }
+ });
+
+ it('uses a smaller size for long dates, and a larger one when there is one date', () => {
+ const normal = fontSize(newDateSpan());
+ const long = fontSize(newDateSpan({ oldDate: 'Wednesday, 26 November 2026', newDate: 'Wednesday, 3 December 2026' }));
+ expect(long).toBeLessThan(normal);
+ expect(fontSize(newDateSpan({ oldDate: '' }))).toBeGreaterThan(normal);
+ });
+
+ it('sizes the longest dates so both fit on the row', () => {
+ // 1040px of room: the new date at 0.64 em a character, the old at 0.4125 em, 90px of arrow and gaps
+ const max = 'x'.repeat(28);
+ const size = fontSize(newDateSpan({ oldDate: max, newDate: max }));
+ expect(28 * 0.64 * size + 28 * 0.4125 * size + 90).toBeLessThanOrEqual(1040);
+ });
+ });
+
+ describe('note', () => {
+ it('hides when it is empty, only spaces, or missing', () => {
+ for (const note of ['', ' ', '\t\n', undefined, null]) {
+ expect(noteSpan({ note }), String(note)).toBeUndefined();
+ expect(render({ note }).at(-1), String(note)).toBe('Thu, 13 Nov, 7 PM');
+ }
+ });
+
+ it('shows the line on one line of text, and shortens a very long one', () => {
+ expect(texts(noteSpan({ note: 'New\n\nvenue' }))).toEqual(['New venue']);
+ const long = texts(noteSpan({ note: 'word '.repeat(60) })).join('');
+ expect(long.length).toBeLessThanOrEqual(80);
+ expect(long.endsWith('…')).toBe(true);
+ });
+ });
+
+ describe('event name', () => {
+ it('shows the name, and falls back to the default when it is empty or blank', () => {
+ expect(texts(titleSpan({ title: 'Design Night' }))).toEqual(['Design Night']);
+ for (const title of ['', ' ', undefined]) expect(texts(titleSpan({ title })), String(title)).toEqual(['Frontend Meetup #14']);
+ });
+
+ it('shrinks as the name gets longer', () => {
+ const sizes = ['Short', 'x'.repeat(25), 'x'.repeat(40), 'x'.repeat(55), 'x'.repeat(75)].map((title) => fontSize(titleSpan({ title })));
+ for (let i = 1; i < sizes.length; i += 1) expect(sizes[i], sizes.join()).toBeLessThan(sizes[i - 1]);
+ });
+
+ it('then truncates a very long name with an ellipsis', () => {
+ const text = texts(titleSpan({ title: 'word '.repeat(60) })).join('');
+ expect(text.length).toBeLessThanOrEqual(80);
+ expect(text.endsWith('…')).toBe(true);
+ });
+ });
+
+ describe('unusual input', () => {
+ it('does not break on values that are not strings', () => {
+ expect(() => tree({ title: undefined, oldDate: null, newDate: 42, note: {}, bgColor: undefined })).not.toThrow();
+ expect(texts(newDateSpan({ newDate: 42 }))).toEqual(['42']);
+ });
+
+ it('does not break when nothing is filled in', () => {
+ expect(() => tree({ title: '', oldDate: '', newDate: '', note: '' })).not.toThrow();
+ });
+ });
+
+ describe('layout', () => {
+ it('fills the card, in Inter', () => {
+ const style = styleOf(tree());
+ expect(style.width).toBe('1200px');
+ expect(style.height).toBe('630px');
+ expect(style.fontFamily).toContain('Inter');
+ });
+
+ it('uses flexbox only, with display flex on every div', () => {
+ for (const overrides of [{}, { oldDate: '', note: '' }, { title: 'x'.repeat(80) }]) {
+ for (const el of everyElement(tree(overrides))) {
+ if (el.type === 'div') expect(styleOf(el).display, JSON.stringify(overrides)).toBe('flex');
+ expect(styleOf(el).display).not.toBe('grid');
+ }
+ }
+ });
+
+ it('draws the same image every time', () => {
+ expect(JSON.stringify(tree())).toBe(JSON.stringify(tree()));
+ });
+
+ it('only uses characters the fonts have, apart from an ellipsis the template adds', () => {
+ const fontSafe = (text: string) => [...text].every((ch) => ch.codePointAt(0)! <= 0xff || ch === '…');
+ expect(render().every(fontSafe)).toBe(true);
+ expect(render({ title: 't'.repeat(120), oldDate: 'd'.repeat(60), newDate: 'n'.repeat(60), note: 'x'.repeat(120) }).every(fontSafe)).toBe(true);
+ });
+ });
+
+ describe('the pill', () => {
+ // The text is 24px bold, which counts as large text, so 3:1 is the minimum. contrastText()
+ // picks black or white by brightness, so a few mid-tone accents (pure red, mid grey) get
+ // 4:1 to 4.5:1 rather than the best ratio.
+ it.each(['#f59e0b', '#4f46e5', '#fde68a', '#7c2d12', '#1e3a8a', '#ff0000', '#00ff00', '#777777', '#000000', '#ffffff'])(
+ 'keeps its text readable on %s',
+ (accentColor) => {
+ const color = String(styleOf(childrenOf(pill({ accentColor }))[0]).color);
+ expect(color).toBe(contrastText(accentColor));
+ expect(contrast(color, accentColor)).toBeGreaterThanOrEqual(3);
+ expect(styleOf(pill({ accentColor })).background).toBe(accentColor);
+ },
+ );
+ });
+
+ describe.each([
+ ['the dark default', {}],
+ ['white with dark text', { bgColor: '#ffffff', textColor: '#1f2937', accentColor: '#d97706' }],
+ ['white with a pale accent', { bgColor: '#ffffff', textColor: '#1f2937', accentColor: '#fde68a' }],
+ ['cream with a dark accent', { bgColor: '#faf5ea', textColor: '#1c1917', accentColor: '#7c2d12' }],
+ ['dark with a dark accent', { bgColor: '#0b0f1a', textColor: '#f8fafc', accentColor: '#1e3a8a' }],
+ ])('on %s', (_name, colors) => {
+ const params = { ...rescheduled.defaults, ...colors };
+ const bg = String(params.bgColor);
+ const colorOf = (el: SatoriElement | undefined) => String(styleOf(el).color);
+
+ it('keeps the event name and the note readable', () => {
+ expect(contrast(colorOf(titleSpan(colors)), bg)).toBeGreaterThanOrEqual(4.5);
+ expect(contrast(colorOf(noteSpan(colors)), bg)).toBeGreaterThanOrEqual(4.5);
+ });
+
+ it('keeps the new date readable', () => {
+ expect(contrast(colorOf(newDateSpan(colors)), bg)).toBeGreaterThanOrEqual(3);
+ });
+
+ it('keeps the old date dimmed but still readable', () => {
+ const old = contrast(colorOf(oldDateSpan(colors)), bg);
+ expect(old).toBeGreaterThanOrEqual(3);
+ expect(old).toBeLessThan(contrast(colorOf(titleSpan(colors)), bg));
+ });
+
+ it('draws the arrow in the same colour as the new date', () => {
+ expect(JSON.stringify(dateRow(colors)[1])).toContain(colorOf(newDateSpan(colors)));
+ });
+ });
+});
diff --git a/tests/templates/gift-card.test.ts b/tests/templates/gift-card.test.ts
new file mode 100644
index 0000000..e815413
--- /dev/null
+++ b/tests/templates/gift-card.test.ts
@@ -0,0 +1,98 @@
+import { describe, expect, it } from 'vitest';
+import ecommerceGiftCard from '@/templates/ecommerce/gift-card';
+import { ecommerceGiftCard as ecommerceGiftCardExport } from '@/templates/ecommerce';
+import { getTemplate, getTemplatesByCategory } from '@/templates/registry';
+import { contrastText } from '@/templates/utils';
+
+function walk(node: any, visit: (node: any) => void): void {
+ if (!node || typeof node !== 'object') return;
+ visit(node);
+ const children = node.props?.children;
+ if (Array.isArray(children)) children.forEach((child) => walk(child, visit));
+ else walk(children, visit);
+}
+
+function textContent(element: any): string[] {
+ const values: string[] = [];
+ walk(element, (node) => {
+ if (typeof node.props?.children === 'string') values.push(node.props.children);
+ });
+ return values;
+}
+
+function findByText(element: any, text: string): any {
+ let found: any;
+ walk(element, (node) => {
+ if (!found && node.props?.children === text) found = node;
+ });
+ return found;
+}
+
+describe('ecommerce-gift-card', () => {
+ it('is exported and registered exactly once in E-commerce', () => {
+ expect(ecommerceGiftCardExport).toBe(ecommerceGiftCard);
+ expect(ecommerceGiftCard.category).toBe('ecommerce');
+ expect(getTemplate('ecommerce-gift-card')).toBe(ecommerceGiftCard);
+ expect(getTemplatesByCategory('ecommerce').filter((template) => template.id === 'ecommerce-gift-card')).toHaveLength(1);
+ });
+
+ it('exposes the requested fields with a $50 amount default', () => {
+ for (const field of ['siteName', 'title', 'amount', 'note', 'bgColor', 'accentColor', 'textColor']) {
+ expect(ecommerceGiftCard.fields.some((item) => item.key === field)).toBe(true);
+ }
+ expect(ecommerceGiftCard.fields.find((field) => field.key === 'amount')).toMatchObject({
+ type: 'text',
+ defaultValue: '$50',
+ });
+ });
+
+ it('hides an empty or whitespace-only note', () => {
+ for (const note of ['', ' ']) {
+ const rendered = ecommerceGiftCard.render({ ...ecommerceGiftCard.defaults, note });
+ expect(textContent(rendered)).not.toContain(ecommerceGiftCard.defaults.note);
+ expect(textContent(rendered)).not.toContain(note);
+ }
+ });
+
+ it('shows the note when provided', () => {
+ const rendered = ecommerceGiftCard.render({
+ ...ecommerceGiftCard.defaults,
+ note: 'Delivered instantly by email',
+ });
+ expect(textContent(rendered)).toContain('Delivered instantly by email');
+ });
+
+ it('uses contrast text for every card label on any accent color', () => {
+ const accentColor = '#fde047';
+ const rendered = ecommerceGiftCard.render({
+ ...ecommerceGiftCard.defaults,
+ accentColor,
+ });
+ const cardTextColor = contrastText(accentColor);
+
+ for (const text of [String(ecommerceGiftCard.defaults.siteName), 'Gift card', String(ecommerceGiftCard.defaults.amount)]) {
+ expect(findByText(rendered, text)?.props.style.color).toBe(cardTextColor);
+ }
+ });
+
+ it('truncates long headlines and amounts', () => {
+ const rendered = ecommerceGiftCard.render({
+ ...ecommerceGiftCard.defaults,
+ title: 'A'.repeat(120),
+ amount: 'B'.repeat(120),
+ });
+ const values = textContent(rendered);
+
+ expect(values).toContain(`${'A'.repeat(49)}…`);
+ expect(values).toContain(`${'B'.repeat(13)}…`);
+ });
+
+ it('uses flex layout for every div and the required canvas size', () => {
+ const rendered = ecommerceGiftCard.render(ecommerceGiftCard.defaults);
+
+ expect(rendered.props.style).toMatchObject({ width: '1200px', height: '630px' });
+ walk(rendered, (node) => {
+ if (node.type === 'div') expect(node.props.style.display).toBe('flex');
+ });
+ });
+});
diff --git a/tests/templates/glossary.test.ts b/tests/templates/glossary.test.ts
new file mode 100644
index 0000000..5628799
--- /dev/null
+++ b/tests/templates/glossary.test.ts
@@ -0,0 +1,26 @@
+import { describe, expect, it } from 'vitest';
+import glossary from '../../src/templates/tutorial/glossary';
+
+const render = (overrides: Record = {}) => glossary.render({ ...glossary.defaults, ...overrides });
+const serialized = (overrides: Record = {}) => JSON.stringify(render(overrides));
+
+describe('tutorial-glossary', () => {
+ it('renders the default term, definition, and example', () => {
+ const tree = serialized();
+ expect(tree).toContain('Idempotent');
+ expect(tree).toContain('adjective');
+ expect(tree).toContain('PUT /users/42 twice still leaves one user');
+ });
+
+ it('shrinks long terms and truncates long content', () => {
+ const tree = serialized({ term: 'A very long technical term that needs to shrink', description: 'D'.repeat(220), example: 'E'.repeat(120) });
+ expect(tree).toContain('…');
+ expect(tree).toContain('"fontSize":"42px"');
+ });
+
+ it('hides optional values containing only spaces', () => {
+ const tree = serialized({ partOfSpeech: ' ', example: ' ' });
+ expect(tree).not.toContain('adjective');
+ expect(tree).not.toContain('JetBrains Mono');
+ });
+});
diff --git a/tests/templates/good-first-issues.test.ts b/tests/templates/good-first-issues.test.ts
new file mode 100644
index 0000000..5b05201
--- /dev/null
+++ b/tests/templates/good-first-issues.test.ts
@@ -0,0 +1,35 @@
+import { describe, expect, it } from 'vitest';
+import goodFirstIssues, { parseIssues } from '../../src/templates/github/good-first-issues';
+
+const render = (overrides: Record = {}) => goodFirstIssues.render({ ...goodFirstIssues.defaults, ...overrides });
+const serialized = (overrides: Record = {}) => JSON.stringify(render(overrides));
+
+describe('github-good-first-issues', () => {
+ it('parses numbered and unnumbered issue lines', () => {
+ expect(parseIssues('128 Add a feature\nNeeds triage')).toEqual([
+ { number: '128', title: 'Add a feature' },
+ { number: '', title: 'Needs triage' },
+ ]);
+ });
+
+ it('caps rows at four and reports hidden issues', () => {
+ const tree = serialized({ issues: '1 One\n2 Two\n3 Three\n4 Four\n5 Five\n6 Six' });
+ expect(tree).toContain('+2 more');
+ expect(tree).toContain('#1');
+ expect(tree).toContain('#4');
+ expect(tree).not.toContain('"children":"#5"');
+ });
+
+ it('truncates long titles and hides an empty footer', () => {
+ const tree = serialized({ issues: '1 ' + 'A'.repeat(100), footer: '' });
+ expect(tree).toContain('…');
+ expect(tree).not.toContain('borderTop');
+ expect(tree).toContain('"flex":1');
+ expect(tree).toContain('"minWidth":0');
+ });
+
+ it('darkens accents further on light backgrounds', () => {
+ const tree = serialized({ bgColor: '#ffffff', accentColor: '#FFD700' });
+ expect(tree).toContain('"color":"#8c7600"');
+ });
+});
diff --git a/tests/templates/incident.test.ts b/tests/templates/incident.test.ts
new file mode 100644
index 0000000..ac3f0ad
--- /dev/null
+++ b/tests/templates/incident.test.ts
@@ -0,0 +1,149 @@
+import { describe, expect, it } from 'vitest';
+import incident from '@/templates/saas/incident';
+import { getTemplate } from '@/templates/registry';
+import type { SatoriElement } from '@/templates/types';
+
+const STEP_LABELS = ['Investigating', 'Identified', 'Monitoring', 'Resolved'];
+
+function elements(node: unknown): SatoriElement[] {
+ if (Array.isArray(node)) return node.flatMap(elements);
+ if (!node || typeof node !== 'object' || !('type' in node)) return [];
+
+ const element = node as SatoriElement;
+ return [element, ...elements(element.props.children)];
+}
+
+// Structural placeholders (empty `children`) are skipped so the text reads in order.
+function textContent(node: unknown): string[] {
+ if (typeof node === 'string') return node === '' ? [] : [node];
+ if (Array.isArray(node)) return node.flatMap(textContent);
+ if (node && typeof node === 'object' && 'props' in node) {
+ return textContent((node as SatoriElement).props.children);
+ }
+ return [];
+}
+
+const render = (overrides: Record = {}) =>
+ incident.render({ ...incident.defaults, ...overrides });
+
+/** The circle marker for each step, in order. */
+const markers = (node: unknown) =>
+ elements(node).filter((element) => element.type === 'div' &&
+ element.props.style?.width === '30px' && element.props.style?.height === '30px');
+
+/** The connector between each pair of steps, in order. */
+const connectors = (node: unknown) =>
+ elements(node).filter((element) => element.type === 'div' &&
+ element.props.style?.height === '2px' && element.props.style?.flex === 1);
+
+describe('saas-incident', () => {
+ it('is registered in the saas category and re-exported', async () => {
+ expect(getTemplate('saas-incident')?.category).toBe('saas');
+ const exports = await import('@/templates/saas');
+ expect(exports.saasIncident.id).toBe('saas-incident');
+ });
+
+ it('renders the label, title, all four steps, the update, the start and the site name', () => {
+ expect(textContent(render())).toEqual([
+ 'Incident',
+ 'Elevated API error rates',
+ ...STEP_LABELS,
+ 'A fix is rolling out to all regions',
+ 'Started 14:05 UTC',
+ 'status.acme.dev',
+ ]);
+ });
+
+ it('fills the steps up to the current status and leaves the rest empty', () => {
+ const filled = (status: string) => markers(render({ status })).map((marker) =>
+ marker.props.style?.backgroundColor !== 'transparent');
+
+ expect(filled('investigating')).toEqual([true, false, false, false]);
+ expect(filled('identified')).toEqual([true, true, false, false]);
+ expect(filled('monitoring')).toEqual([true, true, true, false]);
+ expect(filled('resolved')).toEqual([true, true, true, true]);
+ });
+
+ it('fills a connector only once the next step is reached', () => {
+ const connectorColors = (status: string) =>
+ connectors(render({ status })).map((connector) => connector.props.style?.backgroundColor);
+
+ // Identified: one connector filled, the other two still muted
+ const identified = connectorColors('identified');
+ expect(identified).toHaveLength(3);
+ expect(identified[0]).not.toMatch(/1F$/);
+ expect(identified[1]).toMatch(/1F$/);
+ expect(identified[2]).toMatch(/1F$/);
+
+ // Resolved: every connector filled
+ expect(connectorColors('resolved').filter((color) => !String(color).match(/1F$/))).toHaveLength(3);
+ });
+
+ it('falls back to the default step for an unknown or blank status', () => {
+ const filled = (status: string) => markers(render({ status })).map((marker) =>
+ marker.props.style?.backgroundColor !== 'transparent');
+
+ expect(filled('nonsense')).toEqual(filled('identified'));
+ expect(filled(' ')).toEqual(filled('identified'));
+ expect(filled('RESOLVED')).toEqual([true, true, true, true]);
+ });
+
+ it('falls back for names every object inherits instead of filling nothing', () => {
+ const filled = (status: string) => markers(render({ status })).map((marker) =>
+ marker.props.style?.backgroundColor !== 'transparent');
+
+ // `STATUS_INDEX['constructor']` finds Object.prototype.constructor without the
+ // own-property guard, which used to render a card with no steps filled at all.
+ expect(filled('constructor')).toEqual([true, true, false, false]);
+ expect(filled('toString')).toEqual([true, true, false, false]);
+ expect(filled('hasOwnProperty')).toEqual([true, true, false, false]);
+ expect(filled('__proto__')).toEqual([true, true, false, false]);
+ });
+
+ it('shrinks a long title with autoFontSize and then truncates it', () => {
+ const heading = elements(render({
+ title: 'Elevated API error rates across the EU and US regions affecting uploads and webhooks',
+ })).find((element) => element.type === 'h1');
+
+ expect(textContent(heading)[0]).toHaveLength(60);
+ expect(textContent(heading)[0]).toMatch(/…$/);
+ expect(heading?.props.style?.fontSize).toBe('42px');
+ });
+
+ it('hides a blank update, start and site name cleanly', () => {
+ expect(textContent(render({ update: ' ', started: '', siteName: ' ' }))).toEqual([
+ 'Incident',
+ 'Elevated API error rates',
+ ...STEP_LABELS,
+ ]);
+ });
+
+ it('keeps the separator out when only one of the start and site name is set', () => {
+ const dots = (params: Record) => elements(render(params)).filter((element) =>
+ element.type === 'div' && element.props.style?.width === '4px' && element.props.style?.height === '4px');
+
+ expect(dots({ started: ' ' })).toHaveLength(0);
+ expect(dots({ siteName: '' })).toHaveLength(0);
+ expect(dots({})).toHaveLength(1);
+ });
+
+ it('keeps the label, the reached steps and the start readable on a light background', () => {
+ const dark = render();
+ const light = render({ bgColor: '#ffffff', textColor: '#111111', accentColor: '#fde047' });
+
+ const label = (node: unknown) => elements(node).find((element) =>
+ element.type === 'span' && textContent(element)[0] === 'Incident');
+ const started = (node: unknown) => elements(node).find((element) =>
+ element.type === 'span' && textContent(element)[0] === 'Started 14:05 UTC');
+
+ expect(label(dark)?.props.style?.color).toBe('#E07A5F');
+ expect(label(light)?.props.style?.color).toBe('#8b7b27');
+ expect(started(dark)?.props.style?.color).toBe('#E07A5F');
+ expect(started(light)?.props.style?.color).toBe('#8b7b27');
+
+ // The reached markers take the darkened accent, the unreached ones stay muted
+ const lightMarkers = markers(light).map((marker) => marker.props.style?.backgroundColor);
+ expect(lightMarkers[0]).toBe('#8b7b27');
+ expect(lightMarkers[3]).toBe('transparent');
+ });
+});
diff --git a/tests/templates/internship.test.ts b/tests/templates/internship.test.ts
new file mode 100644
index 0000000..0fd406c
--- /dev/null
+++ b/tests/templates/internship.test.ts
@@ -0,0 +1,94 @@
+import { describe, expect, it } from 'vitest';
+import internship from '@/templates/job/internship';
+import { getTemplate } from '@/templates/registry';
+import { contrastText } from '@/templates/utils';
+import type { SatoriElement } from '@/templates/types';
+
+function elements(node: unknown): SatoriElement[] {
+ if (Array.isArray(node)) return node.flatMap(elements);
+ if (!node || typeof node !== 'object' || !('type' in node)) return [];
+
+ const element = node as SatoriElement;
+ return [element, ...elements(element.props.children)];
+}
+
+function textContent(node: unknown): string[] {
+ if (typeof node === 'string') return [node];
+ if (Array.isArray(node)) return node.flatMap(textContent);
+ if (node && typeof node === 'object' && 'props' in node) {
+ return textContent((node as SatoriElement).props.children);
+ }
+ return [];
+}
+
+const render = (overrides: Record = {}) =>
+ internship.render({ ...internship.defaults, ...overrides });
+
+describe('job-internship', () => {
+ it('is registered in the job category and re-exported', async () => {
+ expect(getTemplate('job-internship')?.category).toBe('job');
+ const exports = await import('@/templates/job');
+ expect(exports.jobInternship.id).toBe('job-internship');
+ });
+
+ it('renders the pill, company, role, the three detail pills in order and the deadline', () => {
+ expect(textContent(render())).toEqual([
+ 'Internship',
+ 'Acme Labs',
+ 'Frontend Engineering Intern',
+ '12 weeks',
+ 'Starts May 2027',
+ 'Paid',
+ 'Apply by 15 November',
+ ]);
+ });
+
+ it('drops blank optional fields one at a time and keeps the others in order', () => {
+ expect(textContent(render({ duration: ' ', start: '', stipend: 'Paid' }))).toEqual([
+ 'Internship',
+ 'Acme Labs',
+ 'Frontend Engineering Intern',
+ 'Paid',
+ 'Apply by 15 November',
+ ]);
+ });
+
+ it('hides a blank deadline and a blank company cleanly', () => {
+ expect(textContent(render({ deadline: ' ', company: ' ' }))).toEqual([
+ 'Internship',
+ 'Frontend Engineering Intern',
+ '12 weeks',
+ 'Starts May 2027',
+ 'Paid',
+ ]);
+ });
+
+ it('shrinks long roles with autoFontSize and then truncates them', () => {
+ const longRole = 'Frontend Engineering Intern on the Design Systems and Accessibility Platform Team';
+ const heading = elements(render({ title: longRole })).find((element) => element.type === 'h1');
+
+ expect(textContent(heading)[0]).toHaveLength(70);
+ expect(textContent(heading)[0]).toMatch(/…$/);
+ expect(heading?.props.style?.fontSize).toBe('38px');
+ });
+
+ it('keeps the pill label readable on any accent and the deadline readable on a light background', () => {
+ const dark = elements(render());
+ const light = elements(render({ bgColor: '#ffffff', textColor: '#111111', accentColor: '#fde047' }));
+
+ const pill = dark.find((element) => element.type === 'div' &&
+ element.props.style?.backgroundColor === '#E07A5F');
+ const pillLabel = elements(pill).find((element) => element.type === 'span');
+ expect(textContent(pillLabel)).toEqual(['Internship']);
+ expect(pillLabel?.props.style?.color).toBe(contrastText('#E07A5F'));
+
+ const palePill = light.find((element) => element.type === 'div' &&
+ element.props.style?.backgroundColor === '#fde047');
+ const palePillLabel = elements(palePill).find((element) => element.type === 'span');
+ expect(palePillLabel?.props.style?.color).toBe(contrastText('#fde047'));
+
+ const paleDeadline = elements(light).find((element) => element.type === 'span' &&
+ textContent(element)[0] === 'Apply by 15 November');
+ expect(paleDeadline?.props.style?.color).toBe('#8b7b27');
+ });
+});
diff --git a/tests/templates/interview-process.test.ts b/tests/templates/interview-process.test.ts
new file mode 100644
index 0000000..22f9dc3
--- /dev/null
+++ b/tests/templates/interview-process.test.ts
@@ -0,0 +1,66 @@
+import { describe, it, expect } from 'vitest';
+import interviewProcess from '@/templates/job/interview-process';
+import { getTemplate } from '@/templates/registry';
+import type { SatoriElement } from '@/templates/types';
+
+/** Every string rendered anywhere in the element tree, in order. */
+function texts(node: unknown): string[] {
+ if (typeof node === 'string') return [node];
+ if (Array.isArray(node)) return node.flatMap(texts);
+ if (node && typeof node === 'object' && 'props' in node) {
+ return texts((node as SatoriElement).props.children);
+ }
+ return [];
+}
+
+const render = (overrides: Record = {}) =>
+ texts(interviewProcess.render({ ...interviewProcess.defaults, ...overrides }));
+
+describe('job-interview-process', () => {
+ it('is registered in the job category', () => {
+ expect(getTemplate('job-interview-process')?.category).toBe('job');
+ });
+
+ it('renders the company, title, numbered steps and duration with its defaults', () => {
+ expect(render()).toEqual([
+ 'Acme Corp',
+ 'How We Hire',
+ '1',
+ 'Application Review',
+ '2',
+ 'Recruiter Screen',
+ '3',
+ 'Team Interview',
+ '4',
+ 'Offer & Onboarding',
+ 'Duration · 3-4 weeks',
+ ]);
+ });
+
+ it('skips empty steps and renumbers the remaining steps sequentially', () => {
+ expect(render({ step1: 'Portfolio Review', step2: '', step3: 'Team Interview', step4: ' ' })).toEqual([
+ 'Acme Corp',
+ 'How We Hire',
+ '1',
+ 'Portfolio Review',
+ '2',
+ 'Team Interview',
+ 'Duration · 3-4 weeks',
+ ]);
+ });
+
+ it('hides the duration when it is empty or blank', () => {
+ for (const duration of ['', ' ']) {
+ expect(render({ duration })).not.toContain('Duration · 3-4 weeks');
+ }
+ });
+
+ it('truncates excessively long step names', () => {
+ const longStep = 'Discovery and alignment with every stakeholder '.repeat(3);
+ const step = render({ step1: longStep }).find((text) => text.startsWith('Discovery and alignment'));
+
+ expect(step).toBeDefined();
+ expect(step!.length).toBeLessThanOrEqual(60);
+ expect(step!.endsWith('…')).toBe(true);
+ });
+});
diff --git a/tests/templates/job-new-hire.test.ts b/tests/templates/job-new-hire.test.ts
new file mode 100644
index 0000000..c14eb01
--- /dev/null
+++ b/tests/templates/job-new-hire.test.ts
@@ -0,0 +1,249 @@
+import { describe, expect, it } from 'vitest';
+import newHire from '@/templates/job/new-hire';
+import { getInitials } from '@/templates/github/contributors-wall';
+import { getTemplate } from '@/templates/registry';
+import { contrastText, isLightColor, mixColors } from '@/templates/utils';
+import type { SatoriElement } from '@/templates/types';
+
+function elements(node: unknown): SatoriElement[] {
+ if (Array.isArray(node)) return node.flatMap(elements);
+ if (!node || typeof node !== 'object' || !('type' in node)) return [];
+
+ const element = node as SatoriElement;
+ return [element, ...elements(element.props.children)];
+}
+
+function textContent(node: unknown): string[] {
+ if (typeof node === 'string') return [node];
+ if (Array.isArray(node)) return node.flatMap(textContent);
+ if (node && typeof node === 'object' && 'props' in node) {
+ return textContent((node as SatoriElement).props.children);
+ }
+ return [];
+}
+
+const render = (overrides: Record = {}) =>
+ newHire.render({ ...newHire.defaults, ...overrides });
+
+const span = (tree: SatoriElement, text: string) =>
+ elements(tree).find((element) => element.type === 'span' && textContent(element)[0] === text);
+
+// The circle is the one div with a fixed, equal width and height.
+const circle = (tree: SatoriElement) =>
+ elements(tree).find((element) => element.type === 'div' && element.props.style?.borderRadius === '64px');
+
+const initialsOf = (title: string) => textContent(circle(render({ title })))[0] ?? '';
+
+describe('job-new-hire', () => {
+ it('is registered in the job category and re-exported', async () => {
+ expect(getTemplate('job-new-hire')?.category).toBe('job');
+ const exports = await import('@/templates/job');
+ expect(exports.jobNewHire.id).toBe('job-new-hire');
+ });
+
+ it('has the fields the issue asks for, with the defaults it names', () => {
+ expect(newHire.fields.map((field) => field.key)).toEqual([
+ 'title', 'role', 'team', 'startDate', 'description', 'siteName', 'bgColor', 'accentColor', 'textColor',
+ ]);
+ expect(newHire.defaults).toMatchObject({
+ title: 'Priya Sharma',
+ role: 'Senior Backend Engineer',
+ team: 'Platform team',
+ startDate: 'Starts Nov 3',
+ description: 'Excited to help build the payments stack',
+ });
+ });
+
+ it('is 1200 by 630 and every div is flex', () => {
+ const tree = render();
+ expect(tree.props.style).toMatchObject({ width: '1200px', height: '630px' });
+ for (const element of elements(tree).filter((el) => el.type === 'div')) {
+ expect(element.props.style?.display).toBe('flex');
+ }
+ });
+
+ it('renders the label, initials, name, role, team, start date, note and site name in order', () => {
+ expect(textContent(render())).toEqual([
+ 'Welcome to the team',
+ 'PS',
+ 'Priya Sharma',
+ 'Senior Backend Engineer',
+ 'Platform team',
+ '·',
+ 'Starts Nov 3',
+ 'Excited to help build the payments stack',
+ 'acme.com',
+ ]);
+ });
+
+ it('hides a blank role, team, start date, note or site name, and one of only spaces', () => {
+ expect(textContent(render({ role: '' }))).not.toContain('Senior Backend Engineer');
+ expect(textContent(render({ role: ' ' }))).not.toContain('Senior Backend Engineer');
+ expect(textContent(render({ description: ' ' }))).not.toContain('Excited to help build the payments stack');
+ expect(textContent(render({ siteName: ' ' }))).not.toContain('acme.com');
+ expect(textContent(render({ siteName: '' }))).not.toContain('acme.com');
+ });
+
+ it('drops the separator together with whichever of team and start date is blank', () => {
+ expect(textContent(render({ team: ' ' }))).toEqual([
+ 'Welcome to the team', 'PS', 'Priya Sharma', 'Senior Backend Engineer',
+ 'Starts Nov 3', 'Excited to help build the payments stack', 'acme.com',
+ ]);
+ expect(textContent(render({ startDate: '' }))).toEqual([
+ 'Welcome to the team', 'PS', 'Priya Sharma', 'Senior Backend Engineer',
+ 'Platform team', 'Excited to help build the payments stack', 'acme.com',
+ ]);
+ expect(textContent(render({ team: '', startDate: '' }))).not.toContain('·');
+ });
+
+ it('leaves no gap where an optional block is hidden', () => {
+ const noDetails = render({ team: '', startDate: '' });
+ const note = span(noDetails, 'Excited to help build the payments stack');
+ expect(note?.props.style?.marginTop).toBe('0px');
+ const withDetails = span(render(), 'Excited to help build the payments stack');
+ expect(withDetails?.props.style?.marginTop).toBe('22px');
+
+ const name = elements(render({ role: '' })).find((element) => element.type === 'h1');
+ const roleless = elements(render({ role: '' })).filter((element) => element.type === 'span' &&
+ element.props.style?.marginTop === '10px');
+ expect(textContent(name)[0]).toBe('Priya Sharma');
+ expect(roleless).toHaveLength(0);
+ });
+
+ it('renders only the label, the initials and the name when every optional field is blank', () => {
+ expect(textContent(render({ role: '', team: '', startDate: '', description: '', siteName: '' }))).toEqual([
+ 'Welcome to the team',
+ 'PS',
+ 'Priya Sharma',
+ ]);
+ });
+
+ it('falls back to the defaults when optional fields are omitted, and to the default name when the title is blank', () => {
+ expect(textContent(newHire.render({}))).toEqual(textContent(render()));
+ expect(textContent(render({ title: ' ' }))[2]).toBe('Priya Sharma');
+ });
+
+ describe('initials', () => {
+ it('are the first letters of the first and the last word', () => {
+ expect(initialsOf('Priya Sharma')).toBe('PS');
+ expect(initialsOf('priya sharma')).toBe('PS');
+ expect(initialsOf('Mary Jane Watson')).toBe('MW');
+ expect(initialsOf(' Priya Sharma ')).toBe('PS');
+ });
+
+ it('are one letter for a one-word name', () => {
+ expect(initialsOf('Madonna')).toBe('M');
+ });
+
+ it('agree with getInitials for ordinary names', () => {
+ for (const name of ['Priya Sharma', 'Madonna', 'Mary Jane Watson', 'élan vital', 'Ada Lovelace']) {
+ expect(initialsOf(name)).toBe(getInitials(name));
+ }
+ });
+
+ it('skip leading punctuation, @ and emoji instead of drawing a broken glyph', () => {
+ expect(initialsOf('@octocat')).toBe('O');
+ expect(initialsOf("(Priya) O'Neil")).toBe('PO');
+ expect(initialsOf('\u{1F600} Smith')).toBe('S');
+ expect(initialsOf('\u{1F600}')).toBe('');
+ });
+
+ it('keep non-Latin and astral letters whole', () => {
+ expect(initialsOf('李雷 王')).toBe('李王');
+ expect(initialsOf('\u{20BB7}野 家')).toBe('\u{20BB7}家');
+ });
+
+ it('never contain half of a surrogate pair', () => {
+ for (const name of ['\u{1F600} \u{1F601}', '\u{20BB7}野', '\u{1D54F}ander Smith']) {
+ for (const ch of Array.from(initialsOf(name))) {
+ const cp = ch.codePointAt(0) as number;
+ expect(cp >= 0xd800 && cp <= 0xdfff).toBe(false);
+ }
+ }
+ });
+
+ it('sit in a circle of the same size, centered, for any name', () => {
+ for (const title of ['Madonna', 'Priya Sharma', 'Alexandria Montgomery-Featherstonehaugh III', '\u{1F600}']) {
+ const style = circle(render({ title }))?.props.style;
+ expect(style).toMatchObject({
+ width: '128px',
+ height: '128px',
+ borderRadius: '64px',
+ alignItems: 'center',
+ justifyContent: 'center',
+ flexShrink: 0,
+ });
+ }
+ });
+ });
+
+ it('shrinks a long name, role and note with autoFontSize and then truncates them', () => {
+ const sizeOf = (tree: SatoriElement, text: string) =>
+ elements(tree).find((element) => textContent(element)[0] === text && element.props.style?.fontSize)?.props.style?.fontSize;
+
+ const nameTree = (title: string) => elements(render({ title })).find((element) => element.type === 'h1');
+ expect(nameTree('Priya Sharma')?.props.style?.fontSize).toBe('64px');
+ expect(nameTree('Alexandria Montgomery-Smith')?.props.style?.fontSize).toBe('46px');
+ // Each band ends on its maxLen: 18 -> 64, 26 -> 54, 36 -> 46, then 38.
+ const bands: [number, string][] = [[18, '64px'], [19, '54px'], [26, '54px'], [27, '46px'], [36, '46px'], [37, '38px']];
+ for (const [length, size] of bands) {
+ expect(nameTree('A'.repeat(length))?.props.style?.fontSize).toBe(size);
+ }
+
+ const longName = 'Alexandria Montgomery-Featherstonehaugh III of the Northern Reaches';
+ const heading = nameTree(longName);
+ expect(textContent(heading)[0].length).toBeLessThanOrEqual(48);
+ expect(textContent(heading)[0]).toMatch(/…$/);
+ expect(heading?.props.style?.fontSize).toBe('38px');
+
+ const longRole = 'Principal Distributed Systems Reliability Engineer, Payments Platform and Fraud';
+ const role = span(render({ role: longRole }), `${longRole.slice(0, 59).trimEnd()}…`);
+ expect(role).toBeDefined();
+ expect(role?.props.style?.fontSize).toBe('22px');
+ expect(span(render({ role: 'Engineer' }), 'Engineer')?.props.style?.fontSize).toBe('30px');
+
+ const longNote = 'Excited to help build the payments stack and to work with the fraud, risk, identity and compliance teams on the next generation of the platform';
+ const note = elements(render({ description: longNote })).find((element) => element.type === 'span' &&
+ textContent(element)[0].startsWith('Excited'));
+ expect(textContent(note)[0].length).toBeLessThanOrEqual(120);
+ expect(textContent(note)[0]).toMatch(/…$/);
+ expect(note?.props.style?.fontSize).toBe('22px');
+ expect(sizeOf(render({ description: 'Welcome aboard' }), 'Welcome aboard')).toBe('28px');
+ });
+
+ it('truncates a long team and start date', () => {
+ const texts = textContent(render({
+ team: 'The Extremely Long Platform Engineering Team',
+ startDate: 'Starts on the third of November 2026',
+ }));
+ const team = texts[4];
+ const start = texts[6];
+ expect(team.length).toBeLessThanOrEqual(32);
+ expect(team).toMatch(/…$/);
+ expect(start.length).toBeLessThanOrEqual(32);
+ expect(start).toMatch(/…$/);
+ });
+
+ it('keeps the label, the circle and the start date readable on a light background', () => {
+ const dark = render();
+ expect(span(dark, 'Welcome to the team')?.props.style?.color).toBe('#E07A5F');
+ expect(circle(dark)?.props.style?.backgroundColor).toBe('#E07A5F');
+ expect(span(dark, 'Starts Nov 3')?.props.style?.color).toBe('#E07A5F');
+
+ const light = render({ bgColor: '#ffffff', textColor: '#111111', accentColor: '#fde047' });
+ expect(isLightColor('#ffffff')).toBe(true);
+ const ink = mixColors('#fde047', '#000000', 0.45);
+ expect(ink).toBe('#8b7b27');
+ expect(span(light, 'Welcome to the team')?.props.style?.color).toBe(ink);
+ expect(span(light, 'Starts Nov 3')?.props.style?.color).toBe(ink);
+ expect(circle(light)?.props.style?.backgroundColor).toBe(ink);
+
+ const initials = elements(circle(light)).find((element) => element.type === 'span');
+ expect(initials?.props.style?.color).toBe(contrastText(ink));
+ const darkInitials = elements(circle(dark)).find((element) => element.type === 'span');
+ expect(darkInitials?.props.style?.color).toBe(contrastText('#E07A5F'));
+
+ expect(elements(light).find((element) => element.type === 'h1')?.props.style?.color).toBe('#111111');
+ expect(span(light, 'Platform team')?.props.style?.color).toBe('#111111');
+ });
+});
diff --git a/tests/templates/job-referral.test.ts b/tests/templates/job-referral.test.ts
new file mode 100644
index 0000000..6579467
--- /dev/null
+++ b/tests/templates/job-referral.test.ts
@@ -0,0 +1,172 @@
+import { describe, expect, it } from 'vitest';
+import referral from '@/templates/job/referral';
+import { getTemplate } from '@/templates/registry';
+import { isLightColor, mixColors } from '@/templates/utils';
+import type { SatoriElement } from '@/templates/types';
+
+function elements(node: unknown): SatoriElement[] {
+ if (Array.isArray(node)) return node.flatMap(elements);
+ if (!node || typeof node !== 'object' || !('type' in node)) return [];
+
+ const element = node as SatoriElement;
+ return [element, ...elements(element.props.children)];
+}
+
+function textContent(node: unknown): string[] {
+ if (typeof node === 'string') return [node];
+ if (Array.isArray(node)) return node.flatMap(textContent);
+ if (node && typeof node === 'object' && 'props' in node) {
+ return textContent((node as SatoriElement).props.children);
+ }
+ return [];
+}
+
+const render = (overrides: Record = {}) =>
+ referral.render({ ...referral.defaults, ...overrides });
+
+const span = (tree: SatoriElement, text: string) =>
+ elements(tree).find((element) => element.type === 'span' && textContent(element)[0] === text);
+
+describe('job-referral', () => {
+ it('is registered in the job category and re-exported', async () => {
+ expect(getTemplate('job-referral')?.category).toBe('job');
+ const exports = await import('@/templates/job');
+ expect(exports.jobReferral.id).toBe('job-referral');
+ });
+
+ it('has the fields the issue asks for, with the defaults it names', () => {
+ expect(referral.fields.map((field) => field.key)).toEqual([
+ 'title', 'company', 'bonus', 'bonusLabel', 'note', 'siteName', 'bgColor', 'accentColor', 'textColor',
+ ]);
+ expect(referral.defaults).toMatchObject({
+ company: 'Acme',
+ bonus: '$2,000',
+ bonusLabel: 'referral bonus',
+ note: 'Send their name to jobs@acme.com',
+ });
+ });
+
+ it('is 1200 by 630 and every div is flex', () => {
+ const tree = render();
+ expect(tree.props.style).toMatchObject({ width: '1200px', height: '630px' });
+ for (const element of elements(tree).filter((el) => el.type === 'div')) {
+ expect(element.props.style?.display).toBe('flex');
+ }
+ });
+
+ it('renders the label, role, company, bonus, its label, the note and the site name in order', () => {
+ expect(textContent(render())).toEqual([
+ 'Know someone?',
+ 'Senior Backend Engineer',
+ 'at Acme',
+ '$2,000',
+ 'referral bonus',
+ 'Send their name to jobs@acme.com',
+ 'acme.com',
+ ]);
+ });
+
+ it('hides a blank company, bonus, note or site name, and a field of only spaces', () => {
+ expect(textContent(render({ company: '' }))).not.toContain('at Acme');
+ expect(textContent(render({ company: ' ' })).some((text) => text.startsWith('at '))).toBe(false);
+ expect(textContent(render({ note: ' ' }))).not.toContain('Send their name to jobs@acme.com');
+ expect(textContent(render({ siteName: ' ' }))).not.toContain('acme.com');
+ expect(textContent(render({ siteName: '' }))).toEqual([
+ 'Know someone?',
+ 'Senior Backend Engineer',
+ 'at Acme',
+ '$2,000',
+ 'referral bonus',
+ 'Send their name to jobs@acme.com',
+ ]);
+ });
+
+ it('hides the bonus label together with a blank bonus', () => {
+ for (const bonus of ['', ' ']) {
+ expect(textContent(render({ bonus }))).toEqual([
+ 'Know someone?',
+ 'Senior Backend Engineer',
+ 'at Acme',
+ 'Send their name to jobs@acme.com',
+ 'acme.com',
+ ]);
+ }
+ });
+
+ it('keeps the bonus when only its label is blank', () => {
+ expect(textContent(render({ bonusLabel: ' ' }))).toEqual([
+ 'Know someone?',
+ 'Senior Backend Engineer',
+ 'at Acme',
+ '$2,000',
+ 'Send their name to jobs@acme.com',
+ 'acme.com',
+ ]);
+ });
+
+ it('renders only the label and the role when every optional field is blank', () => {
+ expect(textContent(render({ company: '', bonus: '', note: '', siteName: '' }))).toEqual([
+ 'Know someone?',
+ 'Senior Backend Engineer',
+ ]);
+ });
+
+ it('falls back to the defaults when optional fields are omitted, and to the default role when the title is blank', () => {
+ expect(textContent(referral.render({}))).toEqual(textContent(render()));
+ expect(textContent(render({ title: ' ' }))[1]).toBe('Senior Backend Engineer');
+ });
+
+ it('shrinks a long role with autoFontSize and then truncates it', () => {
+ const sizeOf = (title: string) =>
+ elements(render({ title })).find((element) => element.type === 'h1')?.props.style?.fontSize;
+
+ expect(sizeOf('Backend Engineer')).toBe('64px');
+ expect(sizeOf('Senior Staff Backend Engineer, Payments')).toBe('44px');
+
+ const longRole = 'Principal Distributed Systems Engineer for the Payments Reliability and Fraud Platform';
+ const heading = elements(render({ title: longRole })).find((element) => element.type === 'h1');
+ expect(textContent(heading)[0]).toHaveLength(60);
+ expect(textContent(heading)[0]).toMatch(/…$/);
+ expect(heading?.props.style?.fontSize).toBe('38px');
+ });
+
+ it('truncates a long company, bonus, label and note', () => {
+ const tree = render({
+ company: 'The International Association of Extremely Long Company Names',
+ bonus: '$1,000,000 USD paid in full',
+ bonusLabel: 'paid after ninety days of employment',
+ note: 'Send their name, their CV and a short note about why they fit to the recruiting team at jobs@acme.com',
+ });
+ const texts = textContent(tree);
+ expect(texts[2]).toHaveLength('at '.length + 40);
+ expect(texts[2]).toMatch(/…$/);
+ expect(texts[3]).toHaveLength(14);
+ expect(texts[3]).toMatch(/…$/);
+ expect(texts[4].length).toBeLessThanOrEqual(24);
+ expect(texts[4]).toMatch(/…$/);
+ expect(texts[5].length).toBeLessThanOrEqual(70);
+ expect(texts[5]).toMatch(/…$/);
+ });
+
+ it('makes a longer bonus smaller so it still fits', () => {
+ const sizeOf = (bonus: string) => span(render({ bonus }), bonus)?.props.style?.fontSize;
+ expect(sizeOf('$500')).toBe('128px');
+ expect(sizeOf('$10,000 USD')).toBe('104px');
+ expect(sizeOf('£12,000 total')).toBe('84px');
+ });
+
+ it('keeps the label and the bonus readable on a light background', () => {
+ const dark = render();
+ expect(span(dark, 'Know someone?')?.props.style?.color).toBe('#E07A5F');
+ expect(span(dark, '$2,000')?.props.style?.color).toBe('#E07A5F');
+
+ const light = render({ bgColor: '#ffffff', textColor: '#111111', accentColor: '#fde047' });
+ expect(isLightColor('#ffffff')).toBe(true);
+ const ink = mixColors('#fde047', '#000000', 0.45);
+ expect(ink).toBe('#8b7b27');
+ expect(span(light, 'Know someone?')?.props.style?.color).toBe(ink);
+ expect(span(light, '$2,000')?.props.style?.color).toBe(ink);
+ expect(elements(light).find((element) => element.type === 'h1')?.props.style?.color).toBe('#111111');
+ expect(span(light, 'Send their name to jobs@acme.com')?.props.style?.color).toBe('#111111');
+ });
+});
diff --git a/tests/templates/live-recording.test.ts b/tests/templates/live-recording.test.ts
new file mode 100644
index 0000000..e423143
--- /dev/null
+++ b/tests/templates/live-recording.test.ts
@@ -0,0 +1,146 @@
+import { describe, expect, it } from 'vitest';
+import podcastLiveRecording from '@/templates/podcast/live-recording';
+import { podcastLiveRecording as podcastLiveRecordingExport } from '@/templates/podcast';
+import { getTemplate, getTemplatesByCategory } from '@/templates/registry';
+import { mixColors } from '@/templates/utils';
+
+
+function walk(node: any, visit: (node: any) => void): void {
+ if (!node || typeof node !== 'object') return;
+
+ visit(node);
+
+ const children = node.props?.children;
+
+ if (Array.isArray(children)) {
+ children.forEach((child) => walk(child, visit));
+ } else if (children && typeof children === 'object') {
+ walk(children, visit);
+ }
+}
+
+
+function textContent(element: any): string[] {
+ const values: string[] = [];
+ walk(element, (node) => {
+ if (typeof node.props?.children === 'string') {
+ values.push(node.props.children);
+ }
+ });
+ return values;
+}
+
+describe('podcast-live-recording', () => {
+ it('renders the live badge, show name, and episode topic', () => {
+ const rendered = podcastLiveRecording.render(podcastLiveRecording.defaults);
+ const text = textContent(rendered);
+
+ expect(text).toContain('Recording live');
+ expect(text).toContain('The Dev Show');
+ expect(text).toContain('Shipping on Fridays: yes or no?');
+ expect(rendered.props.style).toMatchObject({
+ width: '1200px',
+ height: '630px',
+ });
+ });
+
+ it('hides blank optional show name and event details', () => {
+ const rendered = podcastLiveRecording.render({
+ ...podcastLiveRecording.defaults,
+ showName: ' ',
+ when: ' ',
+ where: '',
+ });
+ const text = textContent(rendered);
+
+ expect(text).toContain('Recording live');
+ expect(text).not.toContain('The Dev Show');
+ expect(text).not.toContain('When');
+ expect(text).not.toContain('Where');
+ });
+
+ it('truncates long episode topics and reduces their font size', () => {
+ const longTitle = 'A remarkably long episode topic '.repeat(8);
+ const rendered = podcastLiveRecording.render({
+ ...podcastLiveRecording.defaults,
+ title: longTitle,
+ });
+ const text = textContent(rendered);
+
+ expect(text).toContain(`${longTitle.slice(0, 159).trimEnd()}…`);
+
+ let heading: any;
+ walk(rendered, (node) => {
+ if (node.type === 'h1') heading = node;
+ });
+
+ expect(heading).toBeDefined();
+ expect(heading.props.style.fontSize).toBe('26px');
+ });
+
+ it('renders the live indicator as a plain circular dot', () => {
+ const rendered = podcastLiveRecording.render(
+ podcastLiveRecording.defaults,
+ );
+
+ let dot: any;
+ walk(rendered, (node) => {
+ if (
+ node.props?.style?.width === '10px' &&
+ node.props?.style?.height === '10px' &&
+ node.props?.style?.borderRadius === '50%'
+ ) {
+ dot = node;
+ }
+ });
+
+ expect(dot).toBeDefined();
+ expect(dot.props.children).toBeUndefined();
+ });
+
+ it('darkens accent labels on a light background for readability', () => {
+ const rendered = podcastLiveRecording.render(
+ podcastLiveRecording.defaults,
+ );
+
+ const expectedColor = mixColors(
+ podcastLiveRecording.defaults.accentColor as string,
+ '#000000',
+ 0.45,
+ );
+
+ const labelColors: string[] = [];
+ walk(rendered, (node) => {
+ if (
+ node.type === 'span' &&
+ ['When', 'Where'].includes(node.props?.children)
+ ) {
+ labelColors.push(node.props.style.color);
+ }
+ });
+
+ expect(labelColors).toEqual([expectedColor, expectedColor]);
+ });
+
+ it('exposes required fields and is exported and registered once', () => {
+ for (const key of [
+ 'title',
+ 'showName',
+ 'when',
+ 'where',
+ 'bgColor',
+ 'accentColor',
+ 'textColor',
+ ]) {
+ expect(podcastLiveRecording.fields.some((field) => field.key === key)).toBe(true);
+ }
+
+ expect(podcastLiveRecordingExport).toBe(podcastLiveRecording);
+ expect(getTemplate('podcast-live-recording')).toBe(podcastLiveRecording);
+ expect(
+ getTemplatesByCategory('podcast').filter(
+ (template) => template.id === 'podcast-live-recording',
+ ),
+ ).toHaveLength(1);
+ });
+});
\ No newline at end of file
diff --git a/tests/templates/maintainers-wanted.test.ts b/tests/templates/maintainers-wanted.test.ts
new file mode 100644
index 0000000..abaa8ce
--- /dev/null
+++ b/tests/templates/maintainers-wanted.test.ts
@@ -0,0 +1,222 @@
+import { describe, expect, it } from 'vitest';
+import maintainersWanted from '@/templates/github/maintainers-wanted';
+import { getTemplate } from '@/templates/registry';
+import { isLightColor, mixColors } from '@/templates/utils';
+import type { SatoriElement } from '@/templates/types';
+
+function elements(node: unknown): SatoriElement[] {
+ if (Array.isArray(node)) return node.flatMap(elements);
+ if (!node || typeof node !== 'object' || !('type' in node)) return [];
+
+ const element = node as SatoriElement;
+ return [element, ...elements(element.props.children)];
+}
+
+function texts(node: unknown): string[] {
+ if (typeof node === 'string') return [node];
+ if (Array.isArray(node)) return node.flatMap(texts);
+ if (node && typeof node === 'object' && 'props' in node) {
+ return texts((node as SatoriElement).props.children);
+ }
+ return [];
+}
+
+function styleOf(node: unknown, text: string): Record | undefined {
+ if (Array.isArray(node)) {
+ for (const child of node) {
+ const found = styleOf(child, text);
+ if (found) return found;
+ }
+ return undefined;
+ }
+ if (node && typeof node === 'object' && 'props' in node) {
+ const { style, children } = (node as SatoriElement).props;
+ if (children === text) return style;
+ return styleOf(children, text);
+ }
+ return undefined;
+}
+
+const tree = (overrides: Record = {}) =>
+ maintainersWanted.render({ ...maintainersWanted.defaults, ...overrides });
+
+const render = (overrides: Record = {}) => texts(tree(overrides));
+
+describe('github-maintainers-wanted', () => {
+ it('is registered in the github category and re-exported', async () => {
+ expect(getTemplate('github-maintainers-wanted')?.category).toBe('github');
+ const exports = await import('@/templates/github');
+ expect(exports.githubMaintainersWanted.id).toBe('github-maintainers-wanted');
+ });
+
+ it('has the fields the issue asks for, with the defaults it names', () => {
+ expect(maintainersWanted.fields.map((field) => field.key)).toEqual([
+ 'title',
+ 'description',
+ 'stars',
+ 'openIssues',
+ 'help1',
+ 'help2',
+ 'help3',
+ 'siteName',
+ 'bgColor',
+ 'accentColor',
+ 'textColor',
+ ]);
+ expect(maintainersWanted.defaults).toMatchObject({
+ title: 'acme/csv-stream',
+ description: 'A streaming CSV parser for Node',
+ stars: '4.2k stars',
+ openIssues: '38 open issues',
+ help1: 'Review pull requests',
+ help2: 'Triage new issues',
+ help3: 'Cut releases',
+ siteName: 'github.com/acme/csv-stream',
+ bgColor: '#0d1117',
+ accentColor: '#58a6ff',
+ textColor: '#e6edf3',
+ });
+ });
+
+ it('renders a 1200x630 card and every div is flex', () => {
+ const root = tree();
+ expect(root.props.style).toMatchObject({ width: '1200px', height: '630px' });
+ for (const el of elements(root).filter((e) => e.type === 'div')) {
+ expect(el.props.style?.display).toBe('flex');
+ }
+ });
+
+ it('renders with its defaults in order', () => {
+ expect(render()).toEqual([
+ 'Maintainers wanted',
+ 'acme/csv-stream',
+ 'A streaming CSV parser for Node',
+ '4.2k stars',
+ '38 open issues',
+ 'Review pull requests',
+ 'Triage new issues',
+ 'Cut releases',
+ 'github.com/acme/csv-stream',
+ ]);
+ });
+
+ it('shrinks long repository name with autoFontSize and truncates', () => {
+ const normalTitle = 'acme/csv-stream';
+ const longTitle = 'acme-corporation/ultra-fast-streaming-csv-parser-and-serializer-for-nodejs';
+
+ const normalStyle = styleOf(tree({ title: normalTitle }), normalTitle);
+ const longTree = tree({ title: longTitle });
+ const renderedTexts = texts(longTree);
+ const truncatedTitle = renderedTexts.find((t) => t.startsWith('acme-corporation/'));
+ expect(truncatedTitle).toBeDefined();
+ expect(truncatedTitle?.length).toBeLessThanOrEqual(60);
+
+ const longStyle = styleOf(longTree, truncatedTitle!);
+ const normalSize = parseInt(String(normalStyle?.fontSize), 10);
+ const longSize = parseInt(String(longStyle?.fontSize), 10);
+ expect(longSize).toBeLessThan(normalSize);
+ });
+
+ it('shrinks long pitch with autoFontSize and truncates', () => {
+ const normalPitch = 'A streaming CSV parser for Node';
+ const longPitch =
+ 'A high-performance streaming CSV parser and serializer designed specifically for enterprise Node.js environments with custom delimiters, asynchronous pipelines, and schema validation.';
+
+ const normalStyle = styleOf(tree({ description: normalPitch }), normalPitch);
+ const longTree = tree({ description: longPitch });
+ const renderedTexts = texts(longTree);
+ const truncatedPitch = renderedTexts.find((t) => t.startsWith('A high-performance'));
+ expect(truncatedPitch).toBeDefined();
+ expect(truncatedPitch?.length).toBeLessThanOrEqual(140);
+
+ const longStyle = styleOf(longTree, truncatedPitch!);
+ const normalSize = parseInt(String(normalStyle?.fontSize), 10);
+ const longSize = parseInt(String(longStyle?.fontSize), 10);
+ expect(longSize).toBeLessThan(normalSize);
+ });
+
+ it('skips blank help lines with no gaps', () => {
+ const withBlankMiddle = render({
+ help1: 'Review pull requests',
+ help2: ' ',
+ help3: 'Cut releases',
+ });
+ expect(withBlankMiddle).toEqual([
+ 'Maintainers wanted',
+ 'acme/csv-stream',
+ 'A streaming CSV parser for Node',
+ '4.2k stars',
+ '38 open issues',
+ 'Review pull requests',
+ 'Cut releases',
+ 'github.com/acme/csv-stream',
+ ]);
+
+ const withOnlyOneHelp = render({
+ help1: ' ',
+ help2: 'Triage new issues',
+ help3: '',
+ });
+ expect(withOnlyOneHelp).toEqual([
+ 'Maintainers wanted',
+ 'acme/csv-stream',
+ 'A streaming CSV parser for Node',
+ '4.2k stars',
+ '38 open issues',
+ 'Triage new issues',
+ 'github.com/acme/csv-stream',
+ ]);
+ });
+
+ it('hides an empty pitch, stars, openIssues or site name (or only spaces) cleanly', () => {
+ expect(render({ description: '' })).not.toContain('A streaming CSV parser for Node');
+ expect(render({ description: ' ' })).not.toContain('A streaming CSV parser for Node');
+
+ expect(render({ stars: '' })).not.toContain('4.2k stars');
+ expect(render({ stars: ' ' })).not.toContain('4.2k stars');
+
+ expect(render({ openIssues: '' })).not.toContain('38 open issues');
+ expect(render({ openIssues: ' ' })).not.toContain('38 open issues');
+
+ expect(render({ siteName: '' })).not.toContain('github.com/acme/csv-stream');
+ expect(render({ siteName: ' ' })).not.toContain('github.com/acme/csv-stream');
+
+ const allOptionalCleared = render({
+ description: '',
+ stars: ' ',
+ openIssues: '',
+ help1: '',
+ help2: ' ',
+ help3: '',
+ siteName: '',
+ });
+ expect(allOptionalCleared).toEqual(['Maintainers wanted', 'acme/csv-stream']);
+ });
+
+ it('keeps the label and accent text readable on a light background', () => {
+ const lightTree = tree({ bgColor: '#ffffff', accentColor: '#58a6ff' });
+ const labelStyle = styleOf(lightTree, 'Maintainers wanted');
+ const labelColor = String(labelStyle?.color);
+
+ expect(labelColor).toBe(mixColors('#58a6ff', '#000000', 0.45));
+ expect(isLightColor(labelColor)).toBe(false);
+ });
+
+ it('gives the help list a top margin when the stats row is hidden', () => {
+ const defaultElements = elements(tree());
+ const helpContainerWithStats = defaultElements.find((el) =>
+ Array.isArray(el.props.children) &&
+ el.props.children.length === 3 &&
+ el.props.style?.gap === '10px'
+ );
+ expect(helpContainerWithStats?.props.style?.marginTop).toBe('0px');
+
+ const noStatsElements = elements(tree({ stars: '', openIssues: '' }));
+ const helpContainerWithoutStats = noStatsElements.find((el) =>
+ Array.isArray(el.props.children) &&
+ el.props.children.length === 3 &&
+ el.props.style?.gap === '10px'
+ );
+ expect(helpContainerWithoutStats?.props.style?.marginTop).toBe('24px');
+ });
+});
diff --git a/tests/templates/maintenance.test.ts b/tests/templates/maintenance.test.ts
new file mode 100644
index 0000000..8fc39b9
--- /dev/null
+++ b/tests/templates/maintenance.test.ts
@@ -0,0 +1,45 @@
+import { describe, expect, it } from 'vitest';
+import maintenance from '@/templates/saas/maintenance';
+import { getTemplate } from '@/templates/registry';
+
+const render = (overrides: Record = {}) => maintenance.render({ ...maintenance.defaults, ...overrides });
+const serialized = (overrides: Record = {}) => JSON.stringify(render(overrides));
+
+describe('saas-maintenance', () => {
+ it('is registered and renders the default notice', () => {
+ expect(getTemplate('saas-maintenance')).toBe(maintenance);
+ const tree = serialized();
+ expect(tree).toContain('Scheduled maintenance');
+ expect(tree).toContain('Database upgrade');
+ expect(tree).toContain('Sat, Nov 1, 02:00 to 04:00 UTC');
+ expect(tree).toContain('Webhooks');
+ });
+
+ it('sets the pill text from the status', () => {
+ expect(serialized({ status: 'in-progress' })).toContain('Maintenance in progress');
+ expect(serialized({ status: 'completed' })).toContain('Maintenance completed');
+ });
+
+ it.each(['abc', '__proto__', ''])('falls back to Scheduled for status %j', (status) => {
+ expect(render({ status })).toEqual(render({ status: 'scheduled' }));
+ });
+
+ it('shows at most four service chips plus a +N more chip', () => {
+ const tree = serialized({ services: 'API, Dashboard, Webhooks, Auth, Billing, Search' });
+ expect(tree).toContain('Auth');
+ expect(tree).not.toContain('Billing');
+ expect(tree).toContain('+2 more');
+ expect(serialized({ services: 'API, Dashboard, Webhooks, Auth' })).not.toContain('more');
+ });
+
+ it('hides empty time window, impact and services', () => {
+ const tree = serialized({ timeWindow: ' ', impact: '', services: ' , ' });
+ expect(tree).not.toContain('UTC');
+ expect(tree).not.toContain('read-only');
+ expect(tree).not.toContain('"flexWrap":"wrap"');
+ });
+
+ it('darkens the accent text on a light background', () => {
+ expect(serialized({ bgColor: '#ffffff', textColor: '#0f172a' })).toContain('"color":"#875706"');
+ });
+});
diff --git a/tests/templates/members-only.test.ts b/tests/templates/members-only.test.ts
new file mode 100644
index 0000000..89c0fed
--- /dev/null
+++ b/tests/templates/members-only.test.ts
@@ -0,0 +1,148 @@
+import { describe, it, expect } from 'vitest';
+import membersOnly from '@/templates/newsletter/members-only';
+import { getTemplate } from '@/templates/registry';
+import { contrastText, hexToRgb, isLightColor } from '@/templates/utils';
+import type { SatoriElement } from '@/templates/types';
+
+/** Every element in the tree, in order. */
+function elements(node: unknown): SatoriElement[] {
+ if (Array.isArray(node)) return node.flatMap(elements);
+ if (node && typeof node === 'object' && 'props' in node) {
+ const el = node as SatoriElement;
+ return [el, ...elements(el.props.children)];
+ }
+ return [];
+}
+
+/** Every string rendered anywhere in the element tree, in order. */
+function texts(node: unknown): string[] {
+ if (typeof node === 'string') return [node];
+ if (Array.isArray(node)) return node.flatMap(texts);
+ if (node && typeof node === 'object' && 'props' in node) {
+ return texts((node as SatoriElement).props.children);
+ }
+ return [];
+}
+
+/** The style of the element that directly holds this text. */
+function styleOf(node: unknown, text: string): Record | undefined {
+ if (Array.isArray(node)) {
+ for (const child of node) {
+ const found = styleOf(child, text);
+ if (found) return found;
+ }
+ return undefined;
+ }
+ if (node && typeof node === 'object' && 'props' in node) {
+ const { style, children } = (node as SatoriElement).props;
+ if (children === text) return style;
+ return styleOf(children, text);
+ }
+ return undefined;
+}
+
+const tree = (overrides: Record = {}) =>
+ membersOnly.render({ ...membersOnly.defaults, ...overrides });
+const render = (overrides: Record = {}) => texts(tree(overrides));
+
+describe('newsletter-members-only', () => {
+ it('is registered in the newsletter category', () => {
+ expect(getTemplate('newsletter-members-only')?.category).toBe('newsletter');
+ });
+
+ it('renders a 1200x630 card', () => {
+ const { style } = tree().props;
+ expect(style.width).toBe('1200px');
+ expect(style.height).toBe('630px');
+ });
+
+ it('renders with its defaults', () => {
+ expect(render()).toEqual([
+ 'For paid subscribers',
+ 'How we cut our cloud bill in half',
+ 'We spent three months moving off managed databases. Here is what worked, what broke, and the numbers before and after.',
+ 'Upgrade to keep reading',
+ 'The Weekly Byte',
+ ]);
+ });
+
+ it('hides teaser, label, cta, or site name when empty or whitespace-only', () => {
+ const emptyOverrides = {
+ label: ' ',
+ description: '',
+ cta: ' ',
+ siteName: '',
+ };
+ expect(render(emptyOverrides)).toEqual(['How we cut our cloud bill in half']);
+ });
+
+ it('shrinks long title with autoFontSize and truncates', () => {
+ const normalTitle = 'How we cut our cloud bill in half';
+ const longTitle = 'How we moved our entire multi-region cluster from managed cloud databases to bare metal servers in thirty days';
+
+ const normalStyle = styleOf(tree({ title: normalTitle }), normalTitle);
+ const longTree = tree({ title: longTitle });
+ const renderedTexts = texts(longTree);
+ const truncatedTitle = renderedTexts.find((t) => t.startsWith('How we moved'));
+ expect(truncatedTitle).toBeDefined();
+ expect(truncatedTitle?.length).toBeLessThanOrEqual(90);
+
+ const longStyle = styleOf(longTree, truncatedTitle!);
+ const normalSize = parseInt(String(normalStyle?.fontSize), 10);
+ const longSize = parseInt(String(longStyle?.fontSize), 10);
+ expect(longSize).toBeLessThan(normalSize);
+ });
+
+ it('shrinks long teaser with autoFontSize and truncates', () => {
+ const normalTeaser = 'We spent three months moving off managed databases.';
+ const longTeaser = 'We spent three months moving off managed databases. Here is what worked, what broke, and the numbers before and after. We documented everything across infrastructure, latency benchmarks, unexpected downtime, rollback plans, and team learnings.';
+
+ const normalStyle = styleOf(tree({ description: normalTeaser }), normalTeaser);
+ const longTree = tree({ description: longTeaser });
+ const renderedTexts = texts(longTree);
+ const truncatedTeaser = renderedTexts.find((t) => t.startsWith('We spent three months'));
+ expect(truncatedTeaser).toBeDefined();
+ expect(truncatedTeaser?.length).toBeLessThanOrEqual(300);
+
+ const longStyle = styleOf(longTree, truncatedTeaser!);
+ const normalSize = parseInt(String(normalStyle?.fontSize), 10);
+ const longSize = parseInt(String(longStyle?.fontSize), 10);
+ expect(longSize).toBeLessThan(normalSize);
+ });
+
+ it('fades the teaser into the background color including on light backgrounds', () => {
+ // Dark background fade
+ const darkTree = tree({ bgColor: '#0a0a0a' });
+ const darkFadeOverlay = elements(darkTree).find((el) =>
+ String(el.props.style?.background).includes('linear-gradient'),
+ );
+ expect(darkFadeOverlay).toBeDefined();
+ expect(darkFadeOverlay?.props.style?.background).toContain('rgba(10, 10, 10, 0)');
+ expect(darkFadeOverlay?.props.style?.background).toContain('#0a0a0a');
+
+ // Light background fade
+ const lightTree = tree({ bgColor: '#ffffff' });
+ const lightFadeOverlay = elements(lightTree).find((el) =>
+ String(el.props.style?.background).includes('linear-gradient'),
+ );
+ expect(lightFadeOverlay).toBeDefined();
+ expect(lightFadeOverlay?.props.style?.background).toContain('rgba(255, 255, 255, 0)');
+ expect(lightFadeOverlay?.props.style?.background).toContain('#ffffff');
+ });
+
+ it('keeps CTA pill readable with contrastText', () => {
+ for (const accentColor of ['#E07A5F', '#000000', '#FFFFFF', '#F5C518', '#3b82f6']) {
+ const ctaStyle = styleOf(tree({ accentColor }), 'Upgrade to keep reading');
+ expect(ctaStyle?.color).toBe(contrastText(accentColor));
+ }
+ });
+
+ it('keeps the label readable on a light background', () => {
+ const labelStyle = styleOf(tree({ bgColor: '#ffffff', accentColor: '#E07A5F' }), 'For paid subscribers');
+ const labelColor = labelStyle?.color;
+ expect(labelColor).toBeDefined();
+ // On a light background (#ffffff), a light accent (#E07A5F) should be darkened to contrast
+ expect(labelColor).not.toBe('#E07A5F');
+ expect(isLightColor(labelColor!)).toBe(false);
+ });
+});
diff --git a/tests/templates/milestone.test.ts b/tests/templates/milestone.test.ts
new file mode 100644
index 0000000..6dae5ce
--- /dev/null
+++ b/tests/templates/milestone.test.ts
@@ -0,0 +1,160 @@
+import { describe, it, expect } from 'vitest';
+import milestone from '@/templates/github/milestone';
+import { getTemplate } from '@/templates/registry';
+import { hexToRgb } from '@/templates/utils';
+import type { SatoriElement } from '@/templates/types';
+
+/** Every string rendered anywhere in the element tree, in order. */
+function texts(node: unknown): string[] {
+ if (typeof node === 'string') return [node];
+ if (Array.isArray(node)) return node.flatMap(texts);
+ if (node && typeof node === 'object' && 'props' in node) {
+ return texts((node as SatoriElement).props.children);
+ }
+ return [];
+}
+
+/** The style of the element that directly holds this text. */
+function styleOf(node: unknown, text: string): Record | undefined {
+ if (Array.isArray(node)) {
+ for (const child of node) {
+ const found = styleOf(child, text);
+ if (found) return found;
+ }
+ return undefined;
+ }
+ if (node && typeof node === 'object' && 'props' in node) {
+ const { style, children } = (node as SatoriElement).props;
+ if (children === text) return style;
+ return styleOf(children, text);
+ }
+ return undefined;
+}
+
+/** First style matching a predicate (used for the bar, which has no text). */
+function findStyle(node: unknown, match: (style: Record) => boolean): Record | undefined {
+ if (Array.isArray(node)) {
+ for (const child of node) {
+ const found = findStyle(child, match);
+ if (found) return found;
+ }
+ return undefined;
+ }
+ if (node && typeof node === 'object' && 'props' in node) {
+ const { style, children } = (node as SatoriElement).props;
+ if (style && match(style)) return style;
+ return findStyle(children, match);
+ }
+ return undefined;
+}
+
+const tree = (overrides: Record = {}) =>
+ milestone.render({ ...milestone.defaults, ...overrides });
+const render = (overrides: Record = {}) => texts(tree(overrides));
+
+function luminance(hex: string): number {
+ const rgb = hexToRgb(hex);
+ if (!rgb) throw new Error(`not a hex color: ${hex}`);
+ const [r, g, b] = [rgb.r, rgb.g, rgb.b].map((v) => {
+ const c = v / 255;
+ return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
+ });
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
+}
+
+function contrast(a: string, b: string): number {
+ const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x);
+ return (hi + 0.05) / (lo + 0.05);
+}
+
+describe('github-milestone', () => {
+ it('is registered in the github category', () => {
+ expect(getTemplate('github-milestone')?.category).toBe('github');
+ });
+
+ it('renders repo, milestone, counts, percentage and due date with its defaults', () => {
+ expect(render()).toEqual([
+ 'codercops/ogcops',
+ 'v2.0',
+ '34 of 42 issues closed',
+ '81%',
+ 'Due Nov 30',
+ ]);
+ });
+
+ it('fills the bar to the same percentage it prints', () => {
+ const bar = findStyle(tree({ closed: 21, total: 42 }), (s) => typeof s.width === 'string' && s.width.endsWith('%'));
+ expect(bar?.width).toBe('50%');
+ expect(render({ closed: 21, total: 42 })).toContain('50%');
+ });
+
+ it('never divides by zero: total 0 or negative becomes 1', () => {
+ expect(render({ total: 0, closed: 5 })).toEqual(
+ expect.arrayContaining(['1 of 1 issues closed', '100%'])
+ );
+ expect(render({ total: -7, closed: 0 })).toEqual(
+ expect.arrayContaining(['0 of 1 issues closed', '0%'])
+ );
+ });
+
+ it('clamps closed into 0..total', () => {
+ expect(render({ closed: 99, total: 42 })).toEqual(
+ expect.arrayContaining(['42 of 42 issues closed', '100%'])
+ );
+ expect(render({ closed: -5, total: 42 })).toEqual(
+ expect.arrayContaining(['0 of 42 issues closed', '0%'])
+ );
+ });
+
+ it('survives non-numeric input by falling back to the defaults', () => {
+ expect(render({ closed: 'abc', total: 'abc' })).toEqual(
+ expect.arrayContaining(['34 of 42 issues closed', '81%'])
+ );
+ expect(render({ closed: '', total: '' })).toEqual(
+ expect.arrayContaining(['34 of 42 issues closed', '81%'])
+ );
+ });
+
+ it('rounds the percentage and keeps bar and text in sync', () => {
+ // 1/3 -> 33.33% -> 33%
+ expect(render({ closed: 1, total: 3 })).toEqual(expect.arrayContaining(['33%']));
+ const bar = findStyle(tree({ closed: 1, total: 3 }), (s) => typeof s.width === 'string' && s.width.endsWith('%'));
+ expect(bar?.width).toBe('33%');
+ // 2/3 -> 66.67% -> 67%
+ expect(render({ closed: 2, total: 3 })).toEqual(expect.arrayContaining(['67%']));
+ });
+
+ it('hides the due date when it is empty or blank', () => {
+ for (const dueDate of ['', ' ']) {
+ expect(render({ dueDate })).not.toContain('Due');
+ }
+ });
+
+ it('hides the repo line when it is empty', () => {
+ expect(render({ repoName: '' })).not.toContain('codercops/ogcops');
+ });
+
+ it('shrinks long milestone names instead of overflowing', () => {
+ const shortSize = Number(styleOf(tree({ title: 'v2.0' }), 'v2.0')!.fontSize.replace('px', ''));
+ const longTitle = 'Version 2.0 — the everything bagel release with a very long name';
+ const longSize = Number(styleOf(tree({ title: longTitle }), longTitle)!.fontSize.replace('px', ''));
+ expect(longSize).toBeLessThan(shortSize);
+ expect(longSize).toBeGreaterThanOrEqual(40);
+ });
+
+ it('truncates absurdly long names rather than letting them run off the card', () => {
+ const huge = 'x'.repeat(200);
+ const [rendered] = render({ title: huge }).filter((t) => t.startsWith('x'));
+ expect(rendered.length).toBeLessThanOrEqual(80);
+ expect(rendered.endsWith('…')).toBe(true);
+ });
+
+ it('darkens the percentage on a light background so it stays readable', () => {
+ const style = styleOf(tree({ bgColor: '#ffffff', textColor: '#111111' }), '81%')!;
+ expect(contrast(style.color, '#ffffff')).toBeGreaterThanOrEqual(4.5);
+ });
+
+ it('leaves the accent untouched on a dark background', () => {
+ expect(styleOf(tree(), '81%')!.color).toBe('#58a6ff');
+ });
+});
diff --git a/tests/templates/order-status.test.ts b/tests/templates/order-status.test.ts
new file mode 100644
index 0000000..0feedb9
--- /dev/null
+++ b/tests/templates/order-status.test.ts
@@ -0,0 +1,114 @@
+import { describe, expect, it } from 'vitest';
+import ecommerceOrderStatus from '@/templates/ecommerce/order-status';
+import { ecommerceOrderStatus as ecommerceOrderStatusExport } from '@/templates/ecommerce';
+import { getTemplate, getTemplatesByCategory } from '@/templates/registry';
+import { contrastText, isLightColor, mixColors } from '@/templates/utils';
+
+function walk(node: any, visit: (node: any) => void): void {
+ if (!node || typeof node !== 'object') return;
+ visit(node);
+ const children = node.props?.children;
+ if (Array.isArray(children)) children.forEach((child) => walk(child, visit));
+ else walk(children, visit);
+}
+
+function textContent(element: any): string[] {
+ const values: string[] = [];
+ walk(element, (node) => {
+ if (typeof node.props?.children === 'string') values.push(node.props.children);
+ });
+ return values;
+}
+
+function trackerSteps(element: any): any[] {
+ return element.props.children[2].props.children.slice(2);
+}
+
+describe('ecommerce-order-status', () => {
+ it.each([
+ ['missing', undefined, 2],
+ ['invalid string', 'abc', 2],
+ ['empty string', '', 2],
+ ['below range', '0', 1],
+ ['above range', '5', 4],
+ ['decimal', '2.6', 3],
+ ['string number', '4', 4],
+ ])('parses and clamps currentStep for %s input', (_label, currentStep, expectedStep) => {
+ const rendered = ecommerceOrderStatus.render({ ...ecommerceOrderStatus.defaults, currentStep });
+ const steps = trackerSteps(rendered);
+ expect(steps).toHaveLength(4);
+ expect(steps.map((step) => step.props.children[0].props.style.backgroundColor === ecommerceOrderStatus.defaults.accentColor))
+ .toEqual([1, 2, 3, 4].map((step) => step <= expectedStep));
+ expect(textContent(rendered)).toEqual(expect.arrayContaining(['Ordered', 'Shipped', 'Out for delivery', 'Delivered']));
+ });
+
+ it('hides order number and delivery estimate when they are empty', () => {
+ const rendered = ecommerceOrderStatus.render({
+ ...ecommerceOrderStatus.defaults,
+ orderNumber: '',
+ estimate: '',
+ });
+ const values = textContent(rendered);
+ expect(values.some((value) => value.includes('Order '))).toBe(false);
+ expect(values).not.toContain(ecommerceOrderStatus.defaults.estimate);
+ expect(values).toContain(ecommerceOrderStatus.defaults.title);
+ });
+
+ it('hides whitespace-only order number and delivery estimate', () => {
+ const rendered = ecommerceOrderStatus.render({
+ ...ecommerceOrderStatus.defaults,
+ orderNumber: ' ',
+ estimate: ' ',
+ });
+ const values = textContent(rendered);
+ expect(values.some((value) => value.includes('Order '))).toBe(false);
+ expect(values).not.toContain(' ');
+ expect(values).not.toContain(ecommerceOrderStatus.defaults.estimate);
+ });
+
+ it('adapts progress colors for a light background and keeps contrast on active steps', () => {
+ const lightParams = {
+ ...ecommerceOrderStatus.defaults,
+ bgColor: '#ffffff',
+ textColor: '#111111',
+ accentColor: '#fde047',
+ };
+ const rendered = ecommerceOrderStatus.render(lightParams);
+ const steps = trackerSteps(rendered);
+ const progressColor = mixColors(lightParams.accentColor, '#000000', 0.45);
+
+ expect(isLightColor(lightParams.bgColor)).toBe(true);
+ expect(steps[0].props.children[0].props.style.backgroundColor).toBe(progressColor);
+ expect(steps[0].props.children[0].props.style.color).toBe(contrastText(progressColor));
+ expect(steps[0].props.children[1].props.style.color).toBe(progressColor);
+
+ walk(rendered, (node) => {
+ if (node.type === 'div') expect(node.props.style.display).toBe('flex');
+ });
+ expect(rendered.props.style).toMatchObject({ width: '1200px', height: '630px' });
+ });
+
+ it('truncates long product titles and exposes the requested fields', () => {
+ const rendered = ecommerceOrderStatus.render({
+ ...ecommerceOrderStatus.defaults,
+ title: 'A'.repeat(120),
+ currentStep: 'abc',
+ });
+ expect(textContent(rendered)).toContain(`${'A'.repeat(71)}…`);
+
+ for (const field of ['siteName', 'title', 'orderNumber', 'currentStep', 'estimate', 'bgColor', 'accentColor', 'textColor']) {
+ expect(ecommerceOrderStatus.fields.some((item) => item.key === field)).toBe(true);
+ }
+ expect(ecommerceOrderStatus.fields.find((field) => field.key === 'currentStep')).toMatchObject({
+ type: 'number',
+ min: 1,
+ max: 4,
+ });
+ });
+
+ it('is exported and registered exactly once in E-commerce', () => {
+ expect(ecommerceOrderStatusExport).toBe(ecommerceOrderStatus);
+ expect(getTemplate('ecommerce-order-status')).toBe(ecommerceOrderStatus);
+ expect(getTemplatesByCategory('ecommerce').filter((template) => template.id === 'ecommerce-order-status')).toHaveLength(1);
+ });
+});
diff --git a/tests/templates/package-card.test.ts b/tests/templates/package-card.test.ts
new file mode 100644
index 0000000..2eda865
--- /dev/null
+++ b/tests/templates/package-card.test.ts
@@ -0,0 +1,199 @@
+import { describe, expect, it } from 'vitest';
+import packageCard from '@/templates/github/package-card';
+import { getTemplate } from '@/templates/registry';
+import { isLightColor, mixColors } from '@/templates/utils';
+import type { SatoriElement } from '@/templates/types';
+
+function elements(node: unknown): SatoriElement[] {
+ if (Array.isArray(node)) return node.flatMap(elements);
+ if (!node || typeof node !== 'object' || !('type' in node)) return [];
+
+ const element = node as SatoriElement;
+ return [element, ...elements(element.props.children)];
+}
+
+function texts(node: unknown): string[] {
+ if (typeof node === 'string') return [node];
+ if (Array.isArray(node)) return node.flatMap(texts);
+ if (node && typeof node === 'object' && 'props' in node) {
+ return texts((node as SatoriElement).props.children);
+ }
+ return [];
+}
+
+function styleOf(node: unknown, text: string): Record | undefined {
+ if (Array.isArray(node)) {
+ for (const child of node) {
+ const found = styleOf(child, text);
+ if (found) return found;
+ }
+ return undefined;
+ }
+ if (node && typeof node === 'object' && 'props' in node) {
+ const { style, children } = (node as SatoriElement).props;
+ if (children === text) return style;
+ return styleOf(children, text);
+ }
+ return undefined;
+}
+
+const tree = (overrides: Record = {}) =>
+ packageCard.render({ ...packageCard.defaults, ...overrides });
+
+const render = (overrides: Record = {}) => texts(tree(overrides));
+
+describe('github-package-card', () => {
+ it('is registered in the github category and re-exported', async () => {
+ expect(getTemplate('github-package-card')?.category).toBe('github');
+ const exports = await import('@/templates/github');
+ expect(exports.githubPackageCard.id).toBe('github-package-card');
+ });
+
+ it('has the fields the issue asks for, with the defaults it names', () => {
+ expect(packageCard.fields.map((field) => field.key)).toEqual([
+ 'title',
+ 'description',
+ 'registry',
+ 'version',
+ 'downloads',
+ 'license',
+ 'install',
+ 'siteName',
+ 'bgColor',
+ 'accentColor',
+ 'textColor',
+ ]);
+ expect(packageCard.defaults).toMatchObject({
+ title: '@acme/csv-stream',
+ description: 'A streaming CSV parser for Node',
+ registry: 'npm',
+ version: 'v2.4.1',
+ downloads: '182k weekly downloads',
+ license: 'MIT',
+ install: 'npm i @acme/csv-stream',
+ siteName: 'github.com/acme/csv-stream',
+ bgColor: '#0d1117',
+ accentColor: '#58a6ff',
+ textColor: '#e6edf3',
+ });
+ });
+
+ it('renders a 1200x630 card and every div is flex', () => {
+ const root = tree();
+ expect(root.props.style).toMatchObject({ width: '1200px', height: '630px' });
+ for (const el of elements(root).filter((e) => e.type === 'div')) {
+ expect(el.props.style?.display).toBe('flex');
+ }
+ });
+
+ it('renders with its defaults in order', () => {
+ expect(render()).toEqual([
+ 'npm package',
+ '@acme/csv-stream',
+ 'v2.4.1',
+ 'A streaming CSV parser for Node',
+ '182k weekly downloads',
+ 'MIT',
+ '$',
+ 'npm i @acme/csv-stream',
+ 'github.com/acme/csv-stream',
+ ]);
+ });
+
+ it('formats custom registry names cleanly', () => {
+ expect(render({ registry: 'PyPI' })).toContain('PyPI package');
+ expect(render({ registry: 'crates.io' })).toContain('crates.io package');
+ expect(render({ registry: 'npm package' })).toContain('npm package');
+ });
+
+ it('shrinks long package name with autoFontSize and truncates', () => {
+ const normalTitle = '@acme/csv-stream';
+ const longTitle = '@acme-enterprise/ultra-resilient-high-throughput-csv-streaming-engine';
+
+ const normalStyle = styleOf(tree({ title: normalTitle }), normalTitle);
+ const longTree = tree({ title: longTitle });
+ const renderedTexts = texts(longTree);
+ const truncatedTitle = renderedTexts.find((t) => t.startsWith('@acme-enterprise/'));
+ expect(truncatedTitle).toBeDefined();
+ expect(truncatedTitle?.length).toBeLessThanOrEqual(55);
+
+ const longStyle = styleOf(longTree, truncatedTitle!);
+ const normalSize = parseInt(String(normalStyle?.fontSize), 10);
+ const longSize = parseInt(String(longStyle?.fontSize), 10);
+ expect(longSize).toBeLessThan(normalSize);
+ });
+
+ it('shrinks long pitch with autoFontSize and truncates', () => {
+ const normalPitch = 'A streaming CSV parser for Node';
+ const longPitch =
+ 'A high-performance streaming CSV parser and serializer designed specifically for enterprise Node.js environments with custom delimiters, asynchronous pipelines, and schema validation.';
+
+ const normalStyle = styleOf(tree({ description: normalPitch }), normalPitch);
+ const longTree = tree({ description: longPitch });
+ const renderedTexts = texts(longTree);
+ const truncatedPitch = renderedTexts.find((t) => t.startsWith('A high-performance'));
+ expect(truncatedPitch).toBeDefined();
+ expect(truncatedPitch?.length).toBeLessThanOrEqual(140);
+
+ const longStyle = styleOf(longTree, truncatedPitch!);
+ const normalSize = parseInt(String(normalStyle?.fontSize), 10);
+ const longSize = parseInt(String(longStyle?.fontSize), 10);
+ expect(longSize).toBeLessThan(normalSize);
+ });
+
+ it('truncates a long install command inside its box', () => {
+ const longInstall = 'npm install @acme-enterprise/ultra-resilient-high-throughput-csv-streaming-engine-with-all-plugins-enabled --save-exact';
+ const longTree = tree({ install: longInstall });
+ const renderedTexts = texts(longTree);
+ const truncatedInstall = renderedTexts.find((t) => t.startsWith('npm install'));
+ expect(truncatedInstall).toBeDefined();
+ expect(truncatedInstall?.length).toBeLessThanOrEqual(70);
+ });
+
+ it('hides an empty pitch, registry, version, downloads, license, install or site name (or only spaces) cleanly', () => {
+ expect(render({ registry: '' })).not.toContain('npm package');
+ expect(render({ registry: ' ' })).not.toContain('npm package');
+
+ expect(render({ version: '' })).not.toContain('v2.4.1');
+ expect(render({ version: ' ' })).not.toContain('v2.4.1');
+
+ expect(render({ description: '' })).not.toContain('A streaming CSV parser for Node');
+ expect(render({ description: ' ' })).not.toContain('A streaming CSV parser for Node');
+
+ expect(render({ downloads: '' })).not.toContain('182k weekly downloads');
+ expect(render({ downloads: ' ' })).not.toContain('182k weekly downloads');
+
+ expect(render({ license: '' })).not.toContain('MIT');
+ expect(render({ license: ' ' })).not.toContain('MIT');
+
+ expect(render({ install: '' })).not.toContain('npm i @acme/csv-stream');
+ expect(render({ install: ' ' })).not.toContain('npm i @acme/csv-stream');
+
+ expect(render({ siteName: '' })).not.toContain('github.com/acme/csv-stream');
+ expect(render({ siteName: ' ' })).not.toContain('github.com/acme/csv-stream');
+
+ const allOptionalCleared = render({
+ registry: '',
+ version: ' ',
+ description: '',
+ downloads: ' ',
+ license: '',
+ install: ' ',
+ siteName: '',
+ });
+ expect(allOptionalCleared).toEqual(['@acme/csv-stream']);
+ });
+
+ it('keeps the label, version pill and accent text readable on a light background', () => {
+ const lightTree = tree({ bgColor: '#ffffff', accentColor: '#58a6ff' });
+ const labelStyle = styleOf(lightTree, 'npm package');
+ const labelColor = String(labelStyle?.color);
+
+ const expectedColor = mixColors('#58a6ff', '#000000', 0.45);
+ expect(labelColor).toBe(expectedColor);
+ expect(isLightColor(labelColor)).toBe(false);
+
+ const versionStyle = styleOf(lightTree, 'v2.4.1');
+ expect(versionStyle?.color).toBe(expectedColor);
+ });
+});
diff --git a/tests/templates/podcast-listen-on.test.ts b/tests/templates/podcast-listen-on.test.ts
new file mode 100644
index 0000000..f0c592b
--- /dev/null
+++ b/tests/templates/podcast-listen-on.test.ts
@@ -0,0 +1,113 @@
+import { describe, expect, it } from 'vitest';
+import { getAllTemplates } from '@/templates/registry';
+
+const template = getAllTemplates().find(({ id }) => id === 'podcast-listen-on');
+
+if (!template) throw new Error('Template not found: podcast-listen-on');
+
+type TestNode = {
+ props?: {
+ children?: unknown;
+ style?: Record;
+ };
+};
+
+function findPillWithText(node: unknown, text: string): TestNode | undefined {
+ if (node === null || typeof node !== 'object') return undefined;
+
+ const candidate = node as TestNode;
+ const children = candidate.props?.children;
+ if (
+ children !== null &&
+ typeof children === 'object' &&
+ !Array.isArray(children) &&
+ (children as TestNode).props?.children === text &&
+ candidate.props?.style?.background
+ ) {
+ return candidate;
+ }
+
+ const nested = Array.isArray(children) ? children : [children];
+ for (const child of nested) {
+ const found = findPillWithText(child, text);
+ if (found) return found;
+ }
+
+ return undefined;
+}
+
+function findTextWithColor(node: unknown, text: string): TestNode | undefined {
+ if (node === null || typeof node !== 'object') return undefined;
+
+ const candidate = node as TestNode;
+ if (candidate.props?.children === text && candidate.props.style?.color) return candidate;
+
+ const children = candidate.props?.children;
+ const nested = Array.isArray(children) ? children : [children];
+ for (const child of nested) {
+ const found = findTextWithColor(child, text);
+ if (found) return found;
+ }
+
+ return undefined;
+}
+
+describe('podcast-listen-on', () => {
+ it('hides whitespace-only optional fields and empty platforms', () => {
+ const element = template.render({
+ ...template.defaults,
+ episodeNumber: ' ',
+ tagline: '\t ',
+ platform1: '',
+ platform2: ' ',
+ platform3: '\n',
+ });
+ const rendered = JSON.stringify(element);
+
+ expect(rendered).not.toContain('Episode');
+ expect(rendered).not.toContain('Out now');
+ expect(rendered).not.toContain('Spotify');
+ expect(rendered).not.toContain('Apple Podcasts');
+ expect(rendered).not.toContain('YouTube');
+ });
+
+ it('truncates platform names and keeps their pills from shrinking', () => {
+ const longPlatformName = 'A very long podcast listening platform name';
+ const element = template.render({
+ ...template.defaults,
+ platform1: longPlatformName,
+ platform2: '',
+ platform3: '',
+ });
+ const rendered = JSON.stringify(element);
+
+ expect(rendered).toContain(`${longPlatformName.slice(0, 21)}…`);
+ expect(rendered).not.toContain(longPlatformName);
+ expect(rendered).toContain('"flexShrink":0');
+ });
+
+ it.each([
+ ['#ffffff', '#000000'],
+ ['#000000', '#FFFFFF'],
+ ])('uses readable pill text for accent %s', (accentColor, expectedTextColor) => {
+ const element = template.render({ ...template.defaults, accentColor });
+ const pill = findPillWithText(element, 'Spotify');
+
+ expect(pill?.props?.style?.color).toBe(expectedTextColor);
+ });
+
+ it('darkens accent text on light backgrounds without changing pill contrast', () => {
+ const element = template.render({
+ ...template.defaults,
+ bgColor: '#ffffff',
+ accentColor: '#fde047',
+ });
+ const tagline = findTextWithColor(element, 'Out now');
+ const separator = findTextWithColor(element, '·');
+ const pill = findPillWithText(element, 'Spotify');
+
+ expect(tagline?.props?.style?.color).toBe('#8b7b27');
+ expect(separator?.props?.style?.color).toBe('#8b7b27');
+ expect(pill?.props?.style?.color).toBe('#000000');
+ });
+});
diff --git a/tests/templates/poll.test.ts b/tests/templates/poll.test.ts
new file mode 100644
index 0000000..f7cace6
--- /dev/null
+++ b/tests/templates/poll.test.ts
@@ -0,0 +1,124 @@
+import { describe, expect, it } from 'vitest';
+import newsletterPoll from '@/templates/newsletter/poll';
+import { newsletterPoll as newsletterPollExport } from '@/templates/newsletter';
+import { getTemplate, getTemplatesByCategory } from '@/templates/registry';
+import type { SatoriElement } from '@/templates/types';
+
+function walk(node: unknown, visit: (element: SatoriElement) => void): void {
+ if (Array.isArray(node)) {
+ node.forEach((child) => walk(child, visit));
+ return;
+ }
+ if (!node || typeof node !== 'object' || !('type' in node)) return;
+
+ const element = node as SatoriElement;
+ visit(element);
+ walk(element.props.children, visit);
+}
+
+function textContent(node: unknown): string[] {
+ if (typeof node === 'string') return [node];
+ if (Array.isArray(node)) return node.flatMap(textContent);
+ if (node && typeof node === 'object' && 'props' in node) {
+ return textContent((node as SatoriElement).props.children);
+ }
+ return [];
+}
+
+function allElements(node: unknown): SatoriElement[] {
+ const found: SatoriElement[] = [];
+ walk(node, (element) => found.push(element));
+ return found;
+}
+
+const tree = (overrides: Record = {}) =>
+ newsletterPoll.render({ ...newsletterPoll.defaults, ...overrides });
+
+describe('newsletter-poll', () => {
+ it('is re-exported and registered exactly once in the newsletter category', () => {
+ expect(newsletterPollExport).toBe(newsletterPoll);
+ expect(getTemplate('newsletter-poll')).toBe(newsletterPoll);
+ expect(getTemplatesByCategory('newsletter').filter((template) => template.id === 'newsletter-poll'))
+ .toHaveLength(1);
+ });
+
+ it('renders the newsletter name, issue, label, question, and four answers in order', () => {
+ expect(textContent(tree())).toEqual([
+ 'The Friday Diff',
+ 'Issue 42',
+ 'Reader poll',
+ 'How do you read this newsletter?',
+ 'On my phone',
+ 'On my laptop',
+ 'Printed out',
+ 'I skim the subject line',
+ ]);
+ });
+
+ it('skips empty and whitespace-only answers while preserving order', () => {
+ expect(textContent(tree({
+ option1: ' On my phone ',
+ option2: ' ',
+ option3: 'On my tablet',
+ option4: '',
+ }))).toEqual([
+ 'The Friday Diff',
+ 'Issue 42',
+ 'Reader poll',
+ 'How do you read this newsletter?',
+ 'On my phone',
+ 'On my tablet',
+ ]);
+ });
+
+ it('shrinks a long question before truncating it to the supported length', () => {
+ const longQuestion = 'How do you decide when to read every newsletter issue from start to finish? '.repeat(3);
+ const rendered = tree({ title: longQuestion });
+ const question = allElements(rendered).find((element) => element.type === 'h1');
+
+ expect(question?.props.style.fontSize).toBe('34px');
+ expect(textContent(question)[0]).toHaveLength(140);
+ expect(textContent(question)[0]).toMatch(/…$/);
+ });
+
+ it('truncates long answers within a single-line pill', () => {
+ const rendered = tree({
+ option1: 'MOSTLY ON MY PHONE DURING THE COMMUTE',
+ option2: 'ON A BIG MONITOR AT WORK EVERY MORNING',
+ });
+ const answers = allElements(rendered).filter((element) =>
+ element.type === 'span' && textContent(element).some((text) =>
+ text.startsWith('MOSTLY ON MY PHONE') || text.startsWith('ON A BIG MONITOR')
+ )
+ );
+
+ expect(answers).toHaveLength(2);
+ for (const answer of answers) {
+ expect(textContent(answer)[0]).toHaveLength(36);
+ expect(textContent(answer)[0]).toMatch(/…$/);
+ expect(answer.props.style.whiteSpace).toBe('nowrap');
+ expect(answer.props.style.overflow).toBe('hidden');
+ expect(answer.props.style.textOverflow).toBe('ellipsis');
+ }
+ });
+
+ it('hides an empty issue and newsletter name cleanly', () => {
+ const rendered = tree({ issue: ' ', siteName: ' ' });
+ expect(textContent(rendered)).not.toContain('Issue 42');
+ expect(textContent(rendered)).not.toContain('The Friday Diff');
+ expect(textContent(rendered)).toContain('Reader poll');
+ });
+
+ it('uses outline circles for the answers and darkens the accent on a light canvas', () => {
+ const rendered = tree({ bgColor: '#ffffff', textColor: '#111111', accentColor: '#fde047' });
+ const circles = allElements(rendered).filter((element) =>
+ element.type === 'div' && element.props.style?.borderRadius === '50%'
+ );
+
+ expect(circles).toHaveLength(4);
+ expect(circles.every((circle) => circle.props.style.border?.includes('#8b7b27'))).toBe(true);
+ expect(allElements(rendered).every((element) =>
+ element.type !== 'div' || element.props.style?.display === 'flex'
+ )).toBe(true);
+ });
+});
diff --git a/tests/templates/prerequisites.test.ts b/tests/templates/prerequisites.test.ts
new file mode 100644
index 0000000..1d267f8
--- /dev/null
+++ b/tests/templates/prerequisites.test.ts
@@ -0,0 +1,27 @@
+import { describe, expect, it } from 'vitest';
+import prerequisites from '../../src/templates/tutorial/prerequisites';
+
+const render = (overrides: Record = {}) => prerequisites.render({ ...prerequisites.defaults, ...overrides });
+const serialized = (overrides: Record = {}) => JSON.stringify(render(overrides));
+
+describe('tutorial-prerequisites', () => {
+ it('renders the title, checklist, inline SVG checks, and duration', () => {
+ const tree = serialized();
+ expect(tree).toContain('BEFORE YOU START');
+ expect(tree).toContain('Node 20 or newer');
+ expect(tree).toContain('"type":"svg"');
+ expect(tree).toContain('About 45 minutes');
+ });
+
+ it('skips empty items while preserving order and truncates long items', () => {
+ const tree = serialized({ item1: 'First', item2: ' ', item3: 'A'.repeat(100) });
+ expect(tree.indexOf('First')).toBeLessThan(tree.indexOf('A'.repeat(70)));
+ expect(tree).toContain('…');
+ });
+
+ it('hides a whitespace-only duration and adapts accent text on light backgrounds', () => {
+ const tree = serialized({ duration: ' ', bgColor: '#ffffff', accentColor: '#FFD700' });
+ expect(tree).not.toContain('About 45 minutes');
+ expect(tree).toContain('"color":"#8c7600"');
+ });
+});
diff --git a/tests/templates/pricing-highlight.test.ts b/tests/templates/pricing-highlight.test.ts
new file mode 100644
index 0000000..9fa69c5
--- /dev/null
+++ b/tests/templates/pricing-highlight.test.ts
@@ -0,0 +1,35 @@
+import { describe, expect, it } from 'vitest';
+import { readFileSync } from 'node:fs';
+import satori, { type SatoriNode } from 'satori';
+import pricingHighlight from '@/templates/saas/pricing-highlight';
+
+const fonts = ['Inter-Regular', 'Inter-SemiBold', 'Inter-Bold'].map((file) => ({
+ name: 'Inter',
+ data: readFileSync(`public/fonts/${file}.woff`),
+ weight: (file.endsWith('SemiBold') ? 600 : file.endsWith('Bold') ? 700 : 400) as 400 | 600 | 700,
+ style: 'normal' as const,
+}));
+
+async function layout(price: string) {
+ const nodes: SatoriNode[] = [];
+ await satori(pricingHighlight.render({ ...pricingHighlight.defaults, price }) as any, {
+ width: 1200, height: 630, fonts, onNodeDetected: (node) => nodes.push(node),
+ });
+ const priceNode = nodes.find((node) => node.props.style?.flexShrink === 0)!;
+ const period = nodes.find((node) => node.textContent === '/month')!;
+ const card = nodes.find((node) => node.props.style?.width === '380px')!;
+ return { priceNode, period, innerRight: card.left + card.width - 40 };
+}
+
+describe('saas-pricing-highlight', () => {
+ it('keeps the default price at 56px', async () => {
+ expect((await layout('$29')).priceNode.props.style.fontSize).toBe('56px');
+ });
+
+ it.each(['$1,299.00', '$12,499.99', '$1,234,567.89'])('fits %s and the period inside the card', async (price) => {
+ const { priceNode, period, innerRight } = await layout(price);
+ expect(parseInt(priceNode.props.style.fontSize)).toBeLessThan(56);
+ expect(period.left).toBeGreaterThanOrEqual(priceNode.left + priceNode.width);
+ expect(period.left + period.width).toBeLessThanOrEqual(innerRight);
+ });
+});
diff --git a/tests/templates/quote-article-excerpt.test.ts b/tests/templates/quote-article-excerpt.test.ts
new file mode 100644
index 0000000..e0c60a9
--- /dev/null
+++ b/tests/templates/quote-article-excerpt.test.ts
@@ -0,0 +1,137 @@
+import { describe, expect, it } from 'vitest';
+import articleExcerpt from '@/templates/quote/article-excerpt';
+import { getTemplate } from '@/templates/registry';
+import type { SatoriElement } from '@/templates/types';
+
+function elements(node: unknown): SatoriElement[] {
+ if (Array.isArray(node)) return node.flatMap(elements);
+ if (!node || typeof node !== 'object' || !('type' in node)) return [];
+
+ const element = node as SatoriElement;
+ return [element, ...elements(element.props.children)];
+}
+
+function textContent(node: unknown): string[] {
+ if (typeof node === 'string') return [node];
+ if (Array.isArray(node)) return node.flatMap(textContent);
+ if (node && typeof node === 'object' && 'props' in node) {
+ return textContent((node as SatoriElement).props.children);
+ }
+ return [];
+}
+
+const render = (overrides: Record = {}) =>
+ articleExcerpt.render({ ...articleExcerpt.defaults, ...overrides });
+
+describe('quote-article-excerpt', () => {
+ it('is registered in the quote category and re-exported', async () => {
+ expect(getTemplate('quote-article-excerpt')?.category).toBe('quote');
+ const exports = await import('@/templates/quote');
+ expect(exports.quoteArticleExcerpt.id).toBe('quote-article-excerpt');
+ });
+
+ it('renders the excerpt, title, site name, and author line with defaults', () => {
+ expect(textContent(render())).toEqual([
+ 'Most of our outages started as a config change that looked too small to review.',
+ 'What three years of incidents taught us',
+ 'blog.example.com',
+ 'by Priya Raman',
+ ]);
+ });
+
+ it('hides author, site name, or title cleanly when empty or only spaces', () => {
+ expect(textContent(render({ author: '' }))).toEqual([
+ 'Most of our outages started as a config change that looked too small to review.',
+ 'What three years of incidents taught us',
+ 'blog.example.com',
+ ]);
+ expect(textContent(render({ author: ' ' }))).toEqual([
+ 'Most of our outages started as a config change that looked too small to review.',
+ 'What three years of incidents taught us',
+ 'blog.example.com',
+ ]);
+ expect(textContent(render({ siteName: '' }))).toEqual([
+ 'Most of our outages started as a config change that looked too small to review.',
+ 'What three years of incidents taught us',
+ 'by Priya Raman',
+ ]);
+ expect(textContent(render({ title: '' }))).toEqual([
+ 'Most of our outages started as a config change that looked too small to review.',
+ 'blog.example.com',
+ 'by Priya Raman',
+ ]);
+ });
+
+ it('hides the whole box when the title and site name are both empty or only spaces', () => {
+ expect(textContent(render({ title: '', siteName: '' }))).toEqual([
+ 'Most of our outages started as a config change that looked too small to review.',
+ ]);
+ expect(textContent(render({ title: ' ', siteName: ' ' }))).toEqual([
+ 'Most of our outages started as a config change that looked too small to review.',
+ ]);
+ // Even if author is provided, the preview box stays hidden if both title and siteName are empty
+ expect(textContent(render({ title: '', siteName: '', author: 'Priya Raman' }))).toEqual([
+ 'Most of our outages started as a config change that looked too small to review.',
+ ]);
+ });
+
+ it('avoids duplicate by prefix when author already begins with by', () => {
+ expect(textContent(render({ author: 'by Priya Raman' }))).toContain('by Priya Raman');
+ expect(textContent(render({ author: 'By Priya Raman' }))).toContain('By Priya Raman');
+ });
+
+ it('shrinks long excerpts and titles with autoFontSize and then truncates them', () => {
+ const longQuote =
+ 'Most of our outages started as a config change that looked too small to review, but ended up cascading across all regions because fallback paths had never been tested under realistic production traffic levels.';
+ const longTitle =
+ 'What three years of incidents, postmortems, unexpected failures, and emergency hotfixes taught us about systems';
+ const rendered = elements(render({ quote: longQuote, title: longTitle }));
+ const quoteParagraph = rendered.find((el) => el.type === 'p');
+ const titleDiv = rendered.find(
+ (el) => el.type === 'div' && el.props.style?.lineHeight === 1.3,
+ );
+
+ expect(quoteParagraph).toBeDefined();
+ expect(textContent(quoteParagraph)[0]).toHaveLength(200);
+ expect(textContent(quoteParagraph)[0]).toMatch(/…$/);
+ expect(quoteParagraph?.props.style?.fontSize).toBe('28px');
+
+ expect(titleDiv).toBeDefined();
+ expect(textContent(titleDiv)[0]).toHaveLength(100);
+ expect(textContent(titleDiv)[0]).toMatch(/…$/);
+ expect(titleDiv?.props.style?.fontSize).toBe('20px');
+ });
+
+ it('keeps text readable on a light background by darkening the accent and text', () => {
+ const dark = elements(render());
+ const light = elements(render({ bgColor: '#ffffff', textColor: '#111111', accentColor: '#fde047' }));
+
+ const darkSiteName = dark.find(
+ (el) => el.type === 'span' && textContent(el)[0] === 'blog.example.com',
+ );
+ expect(darkSiteName?.props.style?.color).toBe('#E07A5F');
+
+ const lightSiteName = light.find(
+ (el) => el.type === 'span' && textContent(el)[0] === 'blog.example.com',
+ );
+ expect(lightSiteName?.props.style?.color).toBe('#8b7b27');
+
+ const lightQuote = light.find((el) => el.type === 'p');
+ expect(lightQuote?.props.style?.color).toBe('#111111');
+
+ const lightDefaultText = elements(render({ bgColor: '#ffffff' }));
+ const defaultQuoteOnLight = lightDefaultText.find((el) => el.type === 'p');
+ expect(defaultQuoteOnLight?.props.style?.color).toBe('#000000');
+ });
+
+ it('renders the accent bar as a plain shape without images', () => {
+ const rendered = elements(render());
+ const images = rendered.filter((el) => el.type === 'img');
+ expect(images).toHaveLength(0);
+
+ const accentBar = rendered.find(
+ (el) => el.type === 'div' && el.props.style?.backgroundColor === '#E07A5F' && el.props.style?.width === '6px',
+ );
+ expect(accentBar).toBeDefined();
+ });
+});
diff --git a/tests/templates/quote-code-comment.test.ts b/tests/templates/quote-code-comment.test.ts
new file mode 100644
index 0000000..e9d69f3
--- /dev/null
+++ b/tests/templates/quote-code-comment.test.ts
@@ -0,0 +1,309 @@
+import { describe, it, expect } from 'vitest';
+import codeComment from '@/templates/quote/code-comment';
+import { getTemplate } from '@/templates/registry';
+import { hexToRgb } from '@/templates/utils';
+import type { SatoriElement } from '@/templates/types';
+
+type Style = Record;
+
+const asElement = (node: unknown) => node as SatoriElement;
+const childrenOf = (node: unknown): unknown[] => {
+ const { children } = asElement(node).props;
+ return Array.isArray(children) ? children : children === undefined ? [] : [children];
+};
+
+/** The rows of the comment: each is the text of the spans in it, joined. */
+function rows(overrides: Record = {}): string[] {
+ const root = tree(overrides);
+ const panel = childrenOf(root).at(-1);
+ return childrenOf(panel).map((row) =>
+ childrenOf(row)
+ .map((span) => String(asElement(span).props.children))
+ .join(''),
+ );
+}
+
+/** The row of the comment that starts with this text, as its spans. */
+function rowSpans(overrides: Record, startsWith: string): SatoriElement[] {
+ const panel = childrenOf(tree(overrides)).at(-1);
+ const row = childrenOf(panel).find((candidate) =>
+ childrenOf(candidate)
+ .map((span) => String(asElement(span).props.children))
+ .join('')
+ .startsWith(startsWith),
+ );
+ if (!row) throw new Error(`no row starts with ${startsWith}`);
+ return childrenOf(row) as SatoriElement[];
+}
+
+const tree = (overrides: Record = {}) =>
+ codeComment.render({ ...codeComment.defaults, ...overrides });
+
+/** Every element in the tree, with its style. */
+function everyElement(node: unknown): SatoriElement[] {
+ if (Array.isArray(node)) return node.flatMap(everyElement);
+ if (node && typeof node === 'object' && 'props' in node) {
+ return [asElement(node), ...everyElement(asElement(node).props.children)];
+ }
+ return [];
+}
+
+function luminance(hex: string): number {
+ const rgb = hexToRgb(hex);
+ if (!rgb) throw new Error(`not a hex color: ${hex}`);
+ const [r, g, b] = [rgb.r, rgb.g, rgb.b].map((v) => {
+ const c = v / 255;
+ return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
+ });
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
+}
+
+function contrast(a: string, b: string): number {
+ const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x);
+ return (hi + 0.05) / (lo + 0.05);
+}
+
+const LONG =
+ 'Programs must be written for people to read, and only incidentally for machines to execute. ' +
+ 'Any fool can write code that a computer can understand; good programmers write code that humans can understand.';
+
+/** The text size of the comment, in pixels. */
+const panelFontSize = (root: unknown) =>
+ Number(String((asElement(childrenOf(root).at(-1)).props.style as Style).fontSize).replace('px', ''));
+
+/** The quote lines of the comment, without the opening and closing lines or the author. */
+const quoteRows = (overrides: Record = {}) =>
+ rows(overrides).filter((row) => row.startsWith(' *') && row !== ' */' && !row.startsWith(' * @author'));
+
+/** The quote as written back together from its comment lines. */
+const unwrap = (overrides: Record = {}) =>
+ quoteRows(overrides)
+ .map((row) => row.replace(/^ \* ?/, ''))
+ .join('\n');
+
+describe('quote-code-comment', () => {
+ it('is registered in the quote category', () => {
+ expect(getTemplate('quote-code-comment')?.category).toBe('quote');
+ });
+
+ it('is a block comment with the quote, an @author line and a closing line', () => {
+ expect(rows()).toEqual(['/**', ' * Talk is cheap. Show me the code.', ' * @author Linus Torvalds', ' */']);
+ });
+
+ it('shows the file name tab above the panel', () => {
+ const root = tree();
+ expect(childrenOf(root)).toHaveLength(2);
+ expect(JSON.stringify(childrenOf(root)[0])).toContain('quote.ts');
+ });
+
+ describe('wrapping', () => {
+ it('starts every wrapped line with " * "', () => {
+ const lines = quoteRows({ quote: LONG });
+ expect(lines.length).toBeGreaterThan(1);
+ for (const line of lines) expect(line.startsWith(' * ')).toBe(true);
+ });
+
+ it('breaks at spaces, so the lines read back as the same quote', () => {
+ expect(unwrap({ quote: LONG }).replace(/\n/g, ' ')).toBe(LONG);
+ expect(quoteRows({ quote: LONG }).every((line) => !line.endsWith(' '))).toBe(true);
+ });
+
+ it('keeps each line short enough to fit inside the panel', () => {
+ // 940px of text at 0.6em per character: at most 31 characters at 50px, 63 at 25px.
+ for (const line of quoteRows({ quote: LONG })) expect(line.length).toBeLessThanOrEqual(63);
+ for (const line of quoteRows({ quote: 'short' })) expect(line.length).toBeLessThanOrEqual(31);
+ });
+
+ it('splits one word that is longer than a line', () => {
+ const word = 'x'.repeat(150);
+ const lines = quoteRows({ quote: word });
+ expect(lines.length).toBeGreaterThan(1);
+ expect(lines.map((line) => line.slice(3)).join('')).toBe(word);
+ });
+
+ it('uses smaller text for a longer quote, and never gets bigger than a short one', () => {
+ const size = (quote: string) => panelFontSize(tree({ quote }));
+ expect(size(LONG)).toBeLessThan(size('Hi'));
+ expect(size('Hi')).toBeGreaterThanOrEqual(34);
+ });
+
+ it('keeps the line breaks that were typed, and shows an empty line as " *"', () => {
+ const lines = quoteRows({ quote: 'First.\n\nThen.' });
+ expect(lines).toEqual([' * First.', ' *', ' * Then.']);
+ });
+
+ it('treats Windows line breaks like any other, and trims spaces around lines', () => {
+ expect(quoteRows({ quote: ' First. \r\n Then. ' })).toEqual([' * First.', ' * Then.']);
+ });
+
+ it('treats a lone carriage return as a line break', () => {
+ expect(quoteRows({ quote: 'First.\rThen.' })).toEqual([' * First.', ' * Then.']);
+ });
+
+ it('turns tabs and runs of spaces into one space', () => {
+ expect(quoteRows({ quote: 'a\t\tb c' })).toEqual([' * a b c']);
+ });
+ });
+
+ describe('long quotes', () => {
+ it('shortens a very long quote with an ellipsis', () => {
+ const text = unwrap({ quote: LONG.repeat(5) });
+ expect(text.length).toBeLessThanOrEqual(240);
+ expect(text.endsWith('…')).toBe(true);
+ });
+
+ it('always keeps the comment inside the panel, however many lines there are', () => {
+ for (const quote of ['x\n'.repeat(60), 'a\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\nb', LONG.repeat(3), 'w '.repeat(200)]) {
+ const root = tree({ quote });
+ const panel = childrenOf(root).at(-1);
+ const rowCount = childrenOf(panel).length;
+ const fontSize = panelFontSize(root);
+ // The panel has 36px of padding above and below, and the card has 60px around it.
+ expect(rowCount * Math.round(fontSize * 1.55) + 72 + 22 + 120).toBeLessThanOrEqual(630);
+ }
+ });
+
+ it('marks the end of a quote cut short by running out of room', () => {
+ const lines = quoteRows({ quote: 'x\n'.repeat(60) });
+ expect(lines.at(-1)?.endsWith('…')).toBe(true);
+ });
+ });
+
+ describe('optional parts', () => {
+ it('hides the @author line when the author is empty or blank', () => {
+ for (const author of ['', ' ', undefined]) {
+ expect(rows({ author }).some((row) => row.includes('@author')), String(author)).toBe(false);
+ expect(rows({ author })).toHaveLength(3);
+ }
+ });
+
+ it('shortens a very long author', () => {
+ const line = rows({ author: 'A'.repeat(100) }).find((row) => row.includes('@author')) ?? '';
+ expect(line.length).toBeLessThanOrEqual(' * @author '.length + 40);
+ expect(line.endsWith('…')).toBe(true);
+ });
+
+ it('hides the tab when the file name is empty or blank', () => {
+ for (const fileName of ['', ' ', undefined]) {
+ expect(childrenOf(tree({ fileName })), String(fileName)).toHaveLength(1);
+ }
+ });
+
+ it('shows a different file name, shortened when very long', () => {
+ expect(JSON.stringify(tree({ fileName: 'wisdom.py' }))).toContain('wisdom.py');
+ expect(JSON.stringify(tree({ fileName: 'f'.repeat(80) }))).not.toContain('f'.repeat(31));
+ });
+
+ it('squares off the panel corner under the tab, and rounds all four without one', () => {
+ const radius = (overrides: Record) =>
+ (asElement(childrenOf(tree(overrides)).at(-1)).props.style as Style).borderRadius;
+ expect(radius({})).toMatch(/^0 /);
+ expect(radius({ fileName: '' })).toBe('16px');
+ });
+ });
+
+ describe('empty and unusual input', () => {
+ it('falls back to the default quote when the quote is empty or blank', () => {
+ for (const quote of ['', ' ', '\n\n']) expect(quoteRows({ quote })).toEqual([' * Talk is cheap. Show me the code.']);
+ });
+
+ it('reads values that are not strings', () => {
+ expect(() => tree({ quote: undefined, author: undefined, fileName: undefined })).not.toThrow();
+ expect(quoteRows({ quote: 42 })).toEqual([' * 42']);
+ expect(() => tree({ quote: null, author: 7, fileName: 7 })).not.toThrow();
+ });
+
+ it('does not break on text that looks like a comment', () => {
+ expect(quoteRows({ quote: 'a */ b /* c' })).toEqual([' * a */ b /* c']);
+ });
+ });
+
+ describe('layout', () => {
+ it('fills the card, in JetBrains Mono', () => {
+ const style = asElement(tree()).props.style as Style;
+ expect(style.width).toBe('1200px');
+ expect(style.height).toBe('630px');
+ expect(style.fontFamily).toContain('JetBrains Mono');
+ });
+
+ it('uses flexbox only, with display flex on every div', () => {
+ for (const overrides of [{}, { quote: LONG }, { author: '', fileName: '' }]) {
+ for (const el of everyElement(tree(overrides))) {
+ const style = el.props.style as Style | undefined;
+ if (el.type === 'div') expect(style?.display, JSON.stringify(overrides)).toBe('flex');
+ expect(style?.display).not.toBe('grid');
+ }
+ }
+ });
+
+ it('keeps spaces in the comment lines', () => {
+ for (const span of rowSpans({}, ' * ')) expect((span.props.style as Style).whiteSpace).toBe('pre');
+ });
+
+ it('colors the markers, the quote and the author differently', () => {
+ const [marker, text] = rowSpans({}, ' * Talk');
+ const [, author] = rowSpans({}, ' * @author');
+ const colors = [marker, text, author].map((span) => (span.props.style as Style).color);
+ expect(new Set(colors).size).toBe(3);
+ expect(colors[2]).toBe(codeComment.defaults.accentColor);
+ });
+
+ it('draws the same image every time', () => {
+ expect(JSON.stringify(tree({ quote: LONG }))).toBe(JSON.stringify(tree({ quote: LONG })));
+ });
+
+ it('only uses characters the fonts have, apart from an ellipsis the template adds', () => {
+ const fontSafe = (text: string) => [...text].every((ch) => ch.codePointAt(0)! <= 0xff || ch === '…');
+ expect(rows().every(fontSafe)).toBe(true);
+ expect(rows({ quote: LONG.repeat(5), author: 'A'.repeat(90) }).every(fontSafe)).toBe(true);
+ });
+ });
+
+ describe.each([
+ ['the dark default', {}],
+ ['white with dark text', { bgColor: '#ffffff', textColor: '#1f2937', accentColor: '#4f46e5' }],
+ ['white with a pale accent', { bgColor: '#ffffff', textColor: '#1f2937', accentColor: '#fde68a' }],
+ ['cream with a dark accent', { bgColor: '#faf5ea', textColor: '#1c1917', accentColor: '#7c2d12' }],
+ ['dark with a dark accent', { bgColor: '#0f111a', textColor: '#e6e9f2', accentColor: '#1e3a8a' }],
+ ])('on %s', (_name, colors) => {
+ const params = { ...codeComment.defaults, ...colors };
+ const root = codeComment.render(params);
+ const panelBg = String((asElement(childrenOf(root).at(-1)).props.style as Style).background);
+ const colorOf = (startsWith: string, index: number) =>
+ String(
+ (
+ (childrenOf(
+ childrenOf(childrenOf(root).at(-1)).find((row) =>
+ childrenOf(row)
+ .map((span) => String(asElement(span).props.children))
+ .join('')
+ .startsWith(startsWith),
+ ),
+ )[index] as SatoriElement).props.style as Style
+ ).color,
+ );
+
+ it('keeps the quote readable on the panel', () => {
+ expect(contrast(colorOf(' * Talk', 1), panelBg)).toBeGreaterThanOrEqual(4.5);
+ });
+
+ it('keeps the @author line readable on the panel', () => {
+ expect(contrast(colorOf(' * @author', 1), panelBg)).toBeGreaterThanOrEqual(3);
+ });
+
+ it('keeps the comment markers visible on the panel', () => {
+ for (const start of ['/**', ' */', ' * Talk']) expect(contrast(colorOf(start, 0), panelBg), start).toBeGreaterThanOrEqual(3);
+ });
+
+ it('keeps the file name readable on its tab', () => {
+ const tab = asElement(childrenOf(childrenOf(root)[0])[0]);
+ const tabBg = String((tab.props.style as Style).background);
+ const color = String((asElement(tab.props.children).props.style as Style).color);
+ expect(contrast(color, tabBg)).toBeGreaterThanOrEqual(4.5);
+ });
+
+ it('draws the panel apart from the page', () => {
+ expect(panelBg).not.toBe(String(params.bgColor));
+ });
+ });
+});
diff --git a/tests/templates/quote-interview.test.ts b/tests/templates/quote-interview.test.ts
new file mode 100644
index 0000000..005b65a
--- /dev/null
+++ b/tests/templates/quote-interview.test.ts
@@ -0,0 +1,136 @@
+import { describe, expect, it } from 'vitest';
+import { quoteInterview } from '@/templates/quote';
+import interview from '@/templates/quote/interview';
+import { getTemplate, getTemplatesByCategory } from '@/templates/registry';
+import { mixColors } from '@/templates/utils';
+import type { SatoriElement } from '@/templates/types';
+
+function elements(node: unknown): SatoriElement[] {
+ if (Array.isArray(node)) return node.flatMap(elements);
+ if (!node || typeof node !== 'object' || !('type' in node)) return [];
+
+ const element = node as SatoriElement;
+ return [element, ...elements(element.props.children)];
+}
+
+function textContent(node: unknown): string[] {
+ if (typeof node === 'string') return [node];
+ if (Array.isArray(node)) return node.flatMap(textContent);
+ if (node && typeof node === 'object' && 'props' in node) {
+ return textContent((node as SatoriElement).props.children);
+ }
+ return [];
+}
+
+const render = (overrides: Record = {}) =>
+ interview.render({ ...interview.defaults, ...overrides });
+
+describe('quote-interview', () => {
+ it('is re-exported and registered once in the Quote category', () => {
+ expect(quoteInterview).toBe(interview);
+ expect(getTemplate('quote-interview')).toBe(interview);
+ expect(getTemplatesByCategory('quote').filter(({ id }) => id === 'quote-interview')).toHaveLength(1);
+ });
+
+ it('renders the question, answer, person, role, and site name', () => {
+ expect(textContent(render())).toEqual([
+ 'Q',
+ 'What would you tell yourself on day one?',
+ 'A',
+ 'Ship the small version first, then listen.',
+ 'Priya Raman',
+ 'Staff Engineer at Acme',
+ 'THE BUILDER NOTES',
+ ]);
+ });
+
+ it('hides whitespace-only question, author, role, and site name, including the Q mark', () => {
+ const tree = render({ question: ' ', author: ' ', role: '\t', siteName: ' ' });
+ expect(textContent(tree)).toEqual(['A', 'Ship the small version first, then listen.']);
+ });
+
+ it('removes the answer divider when the question is blank', () => {
+ const answer = elements(render({ question: ' ' })).find((element) =>
+ element.type === 'div' && element.props.style?.flex === 1
+ );
+
+ expect(answer?.props.style.borderTop).toBe('none');
+ });
+
+ it('keeps normal site names intact and lets the footer columns shrink when all are long', () => {
+ const longSiteName = 'THE WONDERFUL WEEKLY BUILDER NOTES MAGAZINE OK';
+ const longFooter = render({
+ author: 'A'.repeat(48),
+ role: 'R'.repeat(64),
+ siteName: longSiteName,
+ });
+ const domain = 'newsletter.codercops.com';
+ const regularSiteNames = [
+ { value: 'THE BUILDER NOTES', tree: render() },
+ { value: domain, tree: render({ siteName: domain }) },
+ ];
+ const siteName = elements(longFooter).find((element) => textContent(element)[0] === longSiteName);
+ const author = elements(longFooter).find((element) =>
+ element.type === 'div' && element.props.style?.flexDirection === 'column'
+ && textContent(element)[0] === 'A'.repeat(48) && textContent(element).length === 2
+ );
+
+ for (const { value, tree } of regularSiteNames) {
+ expect(textContent(tree)).toContain(value);
+ }
+ expect(author?.props.style).toMatchObject({ flex: '1 1 auto', minWidth: 0 });
+ expect(siteName?.props.style).toMatchObject({
+ flexShrink: 1,
+ minWidth: 0,
+ overflow: 'hidden',
+ textOverflow: 'ellipsis',
+ whiteSpace: 'nowrap',
+ });
+ expect(siteName?.props.style.flex).toBeUndefined();
+ const footer = elements(longFooter).find((element) =>
+ element.type === 'div' && element.props.style?.justifyContent === 'space-between'
+ );
+ expect(footer).toBeDefined();
+ });
+
+ it('shrinks long questions and answers before truncating them', () => {
+ const tree = render({
+ question: 'What would you tell yourself on day one? '.repeat(5),
+ answer: 'Ship the small version first, then listen. '.repeat(10),
+ });
+ const all = elements(tree);
+ const question = all.find((element) => element.type === 'span' && textContent(element)[0]?.startsWith('What would'));
+ const answer = all.find((element) => element.type === 'span' && textContent(element)[0]?.startsWith('Ship the small'));
+
+ expect(textContent(question)[0].length).toBeLessThanOrEqual(160);
+ expect(textContent(question)[0]).toMatch(/…$/);
+ expect(question?.props.style.fontSize).toBe('24px');
+ expect(textContent(answer)[0].length).toBeLessThanOrEqual(300);
+ expect(textContent(answer)[0]).toMatch(/…$/);
+ expect(answer?.props.style.fontSize).toBe('32px');
+ });
+
+ it('darkens accent text on a light background and keeps default white text readable', () => {
+ const tree = render({ bgColor: '#ffffff', accentColor: '#fde047' });
+ const accentInk = mixColors('#fde047', '#000000', 0.45);
+ const all = elements(tree);
+ const question = all.find((element) =>
+ element.type === 'span' && textContent(element)[0]?.startsWith('What would')
+ );
+ const questionMark = all.find((element) => element.type === 'div' && textContent(element).join('') === 'Q');
+ const answer = all.find((element) =>
+ element.type === 'span' && textContent(element)[0]?.startsWith('Ship the small')
+ );
+
+ expect(question?.props.style.color).toBe(accentInk);
+ expect(questionMark?.props.style.color).toBe(accentInk);
+ expect(answer?.props.style.color).toBe('#111111');
+ });
+
+ it('uses a 1200 by 630 flex layout', () => {
+ const all = elements(render());
+ expect(all[0].props.style.width).toBe('1200px');
+ expect(all[0].props.style.height).toBe('630px');
+ expect(all.every((element) => element.type !== 'div' || element.props.style?.display === 'flex')).toBe(true);
+ });
+});
diff --git a/tests/templates/quote-subtitle.test.ts b/tests/templates/quote-subtitle.test.ts
new file mode 100644
index 0000000..1b723b5
--- /dev/null
+++ b/tests/templates/quote-subtitle.test.ts
@@ -0,0 +1,97 @@
+import { describe, expect, it } from 'vitest';
+import subtitle from '@/templates/quote/subtitle';
+import { getTemplate } from '@/templates/registry';
+import type { SatoriElement } from '@/templates/types';
+
+function elements(node: unknown): SatoriElement[] {
+ if (Array.isArray(node)) return node.flatMap(elements);
+ if (!node || typeof node !== 'object' || !('type' in node)) return [];
+
+ const element = node as SatoriElement;
+ return [element, ...elements(element.props.children)];
+}
+
+function textContent(node: unknown): string[] {
+ if (typeof node === 'string') return [node];
+ if (Array.isArray(node)) return node.flatMap(textContent);
+ if (node && typeof node === 'object' && 'props' in node) {
+ return textContent((node as SatoriElement).props.children);
+ }
+ return [];
+}
+
+const render = (overrides: Record = {}) =>
+ subtitle.render({ ...subtitle.defaults, ...overrides });
+
+describe('quote-subtitle', () => {
+ it('is registered in the quote category and re-exported', async () => {
+ expect(getTemplate('quote-subtitle')?.category).toBe('quote');
+ const exports = await import('@/templates/quote');
+ expect(exports.quoteSubtitle.id).toBe('quote-subtitle');
+ });
+
+ it('renders the source line, speaker name, and quote subtitle with defaults', () => {
+ expect(textContent(render())).toEqual([
+ 'Opening keynote, 2026',
+ 'Ana Ruiz',
+ 'Ship the small version first, then listen.',
+ ]);
+ });
+
+ it('hides the speaker and source cleanly when empty or only spaces', () => {
+ expect(textContent(render({ author: '', source: '' }))).toEqual([
+ 'Ship the small version first, then listen.',
+ ]);
+ expect(textContent(render({ author: ' ', source: ' ' }))).toEqual([
+ 'Ship the small version first, then listen.',
+ ]);
+ });
+
+ it('shrinks long quotes with autoFontSize and then truncates them', () => {
+ const longQuote =
+ 'Good design is actually a lot harder to notice than poor design, in part because good designs fit our needs so well that the design is invisible, serving us without drawing attention to itself in any way.';
+ const rendered = elements(render({ quote: longQuote }));
+ const quoteParagraph = rendered.find((el) => el.type === 'p');
+
+ expect(quoteParagraph).toBeDefined();
+ expect(textContent(quoteParagraph)[0]).toHaveLength(200);
+ expect(textContent(quoteParagraph)[0]).toMatch(/…$/);
+ expect(quoteParagraph?.props.style?.fontSize).toBe('28px');
+ });
+
+ it('keeps the quote and speaker readable on a light background', () => {
+ const dark = elements(render());
+ const light = elements(render({ bgColor: '#ffffff', textColor: '#111111', accentColor: '#fde047' }));
+
+ const darkSpeaker = dark.find(
+ (el) => el.type === 'span' && textContent(el)[0] === 'Ana Ruiz',
+ );
+ expect(darkSpeaker?.props.style?.color).toBe('#E07A5F');
+
+ const paleSpeaker = light.find(
+ (el) => el.type === 'span' && textContent(el)[0] === 'Ana Ruiz',
+ );
+ expect(paleSpeaker?.props.style?.color).toBe('#8b7b27');
+
+ const lightQuote = light.find((el) => el.type === 'p');
+ expect(lightQuote?.props.style?.color).toBe('#111111');
+
+ const lightDefaultText = elements(render({ bgColor: '#ffffff' }));
+ const defaultQuoteOnLight = lightDefaultText.find((el) => el.type === 'p');
+ expect(defaultQuoteOnLight?.props.style?.color).toBe('#000000');
+ });
+
+ it('renders letterbox bars and soft shadows as plain styles without images', () => {
+ const rendered = elements(render());
+ const images = rendered.filter((el) => el.type === 'img');
+ expect(images).toHaveLength(0);
+
+ const quoteParagraph = rendered.find((el) => el.type === 'p');
+ expect(quoteParagraph?.props.style?.textShadow).toContain('0 2px 8px');
+
+ const bars = rendered.filter(
+ (el) => el.type === 'div' && el.props.style?.backgroundColor === '#000000' && el.props.style?.height === '60px',
+ );
+ expect(bars).toHaveLength(2);
+ });
+});
diff --git a/tests/templates/registry.test.ts b/tests/templates/registry.test.ts
index 9760d52..8837925 100644
--- a/tests/templates/registry.test.ts
+++ b/tests/templates/registry.test.ts
@@ -65,6 +65,17 @@ describe('Template Registry', () => {
}
});
+ it('every number field default fits inside its slider range', () => {
+ for (const t of getAllTemplates()) {
+ for (const field of t.fields.filter((f) => f.type === 'number')) {
+ const value = Number(t.defaults[field.key] ?? field.defaultValue);
+ const where = `${t.id}.${field.key}`;
+ expect(value, where).toBeGreaterThanOrEqual(field.min ?? 0);
+ expect(value, where).toBeLessThanOrEqual(field.max ?? 100);
+ }
+ }
+ });
+
it('template IDs should be unique', () => {
const templates = getAllTemplates();
const ids = templates.map((t) => t.id);
diff --git a/tests/templates/roadmap.test.ts b/tests/templates/roadmap.test.ts
new file mode 100644
index 0000000..2ed9336
--- /dev/null
+++ b/tests/templates/roadmap.test.ts
@@ -0,0 +1,27 @@
+import { describe, expect, it } from 'vitest';
+import roadmap from '../../src/templates/saas/roadmap';
+
+const render = (overrides: Record = {}) => roadmap.render({ ...roadmap.defaults, ...overrides });
+const serialized = (overrides: Record = {}) => JSON.stringify(render(overrides));
+
+describe('saas-roadmap', () => {
+ it('renders all roadmap columns', () => {
+ const tree = serialized();
+ expect(tree).toContain('Now');
+ expect(tree).toContain('Team billing');
+ expect(tree).toContain('Offline sync');
+ });
+
+ it('hides whitespace-only columns and keeps remaining columns flexible', () => {
+ const tree = serialized({ next: ' ' });
+ expect(tree).not.toContain('Team billing');
+ expect(tree).toContain('"flex":1');
+ expect(tree).toContain('"minWidth":0');
+ });
+
+ it('truncates very long items and adapts accent text on light backgrounds', () => {
+ const tree = serialized({ later: 'A'.repeat(100), bgColor: '#ffffff', accentColor: '#FFD700' });
+ expect(tree).toContain('…');
+ expect(tree).toContain('"color":"#8c7600"');
+ });
+});
diff --git a/tests/templates/security-advisory.test.ts b/tests/templates/security-advisory.test.ts
new file mode 100644
index 0000000..22c2ba3
--- /dev/null
+++ b/tests/templates/security-advisory.test.ts
@@ -0,0 +1,96 @@
+import { describe, expect, it } from 'vitest';
+import { getTemplate } from '@/templates/registry';
+import type { SatoriElement } from '@/templates/types';
+import { contrastText, isLightColor, mixColors } from '@/templates/utils';
+import securityAdvisory from '@/templates/github/security-advisory';
+
+function elements(node: unknown): SatoriElement[] {
+ if (Array.isArray(node)) return node.flatMap(elements);
+ if (node && typeof node === 'object' && 'props' in node) {
+ const element = node as SatoriElement;
+ return [element, ...elements(element.props.children)];
+ }
+ return [];
+}
+
+function texts(node: unknown): string[] {
+ if (typeof node === 'string') return [node];
+ if (Array.isArray(node)) return node.flatMap(texts);
+ if (node && typeof node === 'object' && 'props' in node) {
+ return texts((node as SatoriElement).props.children);
+ }
+ return [];
+}
+
+const tree = (overrides: Record = {}) =>
+ securityAdvisory.render({ ...securityAdvisory.defaults, ...overrides });
+const render = (overrides: Record = {}) => texts(tree(overrides));
+
+describe('github-security-advisory', () => {
+ it('is registered in the github category', () => {
+ expect(getTemplate('github-security-advisory')?.category).toBe('github');
+ });
+
+ it('renders the severity, advisory, title, package details and site name', () => {
+ expect(render()).toEqual([
+ 'High',
+ 'GHSA-3x9q-7m2v-p4hc',
+ 'Path traversal in @acme/static-files',
+ '@acme/static-files · affected < 2.4.1 · fixed in 2.4.1',
+ 'Acme Security',
+ ]);
+ });
+
+ it('is a 1200x630 card', () => {
+ const { style } = tree().props;
+ expect(style.width).toBe('1200px');
+ expect(style.height).toBe('630px');
+ });
+
+ it('uses a distinct readable pill color for each severity and falls back to high', () => {
+ const pill = (severity: string) => elements(tree({ severity })).find((element) => element.props.style?.borderRadius === '999px')!;
+ const colors = ['critical', 'high', 'medium', 'low'].map((severity) => pill(severity).props.style.backgroundColor);
+
+ expect(new Set(colors).size).toBe(4);
+ for (const severity of ['critical', 'high', 'medium', 'low']) {
+ const badge = pill(severity);
+ const color = String(badge.props.style.backgroundColor);
+ const label = elements(badge).find((element) => element.type === 'span');
+
+ expect(texts(badge)).toEqual([severity[0].toUpperCase() + severity.slice(1)]);
+ expect(label?.props.style.color).toBe(contrastText(color));
+ }
+ expect(pill('unknown').props.style.backgroundColor).toBe(pill('high').props.style.backgroundColor);
+ expect(texts(pill('unknown'))).toEqual(['High']);
+ });
+
+ it('truncates long titles after sizing them and shrinks their font', () => {
+ const title = 'A'.repeat(120);
+ const heading = elements(tree({ title })).find((element) => element.type === 'h1');
+
+ expect(heading?.props.children).toHaveLength(90);
+ expect(String(heading?.props.children).endsWith('…')).toBe(true);
+ expect(heading?.props.style.fontSize).toBe('40px');
+ });
+
+ it('hides blank optional fields and only separates available package details', () => {
+ expect(render({
+ advisoryId: ' ',
+ packageName: ' \t',
+ affected: '',
+ patched: '\n ',
+ siteName: ' ',
+ })).toEqual(['High', 'Path traversal in @acme/static-files']);
+ expect(render({ packageName: '', affected: '< 3.0', patched: '' })).toContain('affected < 3.0');
+ expect(render({ packageName: '', affected: '< 3.0', patched: '' }).join('')).not.toContain('·');
+ });
+
+ it('keeps accent text readable on a light background', () => {
+ const element = tree({ bgColor: '#ffffff', accentColor: '#fde047' });
+ const advisoryId = elements(element).find((item) => item.props.children === 'GHSA-3x9q-7m2v-p4hc');
+ const accentText = String(advisoryId?.props.style?.color);
+
+ expect(accentText).toBe(mixColors('#fde047', '#000000', 0.45));
+ expect(isLightColor(accentText)).toBe(false);
+ });
+});
diff --git a/tests/templates/sponsors-goal.test.ts b/tests/templates/sponsors-goal.test.ts
new file mode 100644
index 0000000..b0c6b12
--- /dev/null
+++ b/tests/templates/sponsors-goal.test.ts
@@ -0,0 +1,219 @@
+import { describe, it, expect } from 'vitest';
+import sponsorsGoal from '@/templates/github/sponsors-goal';
+import { getTemplate } from '@/templates/registry';
+import { hexToRgb } from '@/templates/utils';
+import type { SatoriElement } from '@/templates/types';
+
+function texts(node: unknown): string[] {
+ if (typeof node === 'string') return [node];
+ if (Array.isArray(node)) return node.flatMap(texts);
+ if (node && typeof node === 'object' && 'props' in node) {
+ return texts((node as SatoriElement).props.children);
+ }
+ return [];
+}
+
+function elements(node: unknown): SatoriElement[] {
+ if (Array.isArray(node)) return node.flatMap(elements);
+ if (!node || typeof node !== 'object' || !('type' in node)) return [];
+ const element = node as SatoriElement;
+ return [element, ...elements(element.props.children)];
+}
+
+function styleOf(node: unknown, text: string): Record | undefined {
+ if (Array.isArray(node)) {
+ for (const child of node) {
+ const found = styleOf(child, text);
+ if (found) return found;
+ }
+ return undefined;
+ }
+ if (node && typeof node === 'object' && 'props' in node) {
+ const { style, children } = (node as SatoriElement).props;
+ if (children === text) return style;
+ return styleOf(children, text);
+ }
+ return undefined;
+}
+
+function findStyle(node: unknown, match: (style: Record) => boolean): Record | undefined {
+ if (Array.isArray(node)) {
+ for (const child of node) {
+ const found = findStyle(child, match);
+ if (found) return found;
+ }
+ return undefined;
+ }
+ if (node && typeof node === 'object' && 'props' in node) {
+ const { style, children } = (node as SatoriElement).props;
+ if (style && match(style)) return style;
+ return findStyle(children, match);
+ }
+ return undefined;
+}
+
+const tree = (overrides: Record = {}) =>
+ sponsorsGoal.render({ ...sponsorsGoal.defaults, ...overrides });
+const render = (overrides: Record = {}) => texts(tree(overrides));
+
+function luminance(hex: string): number {
+ const rgb = hexToRgb(hex);
+ if (!rgb) throw new Error(`not a hex color: ${hex}`);
+ const [r, g, b] = [rgb.r, rgb.g, rgb.b].map((v) => {
+ const c = v / 255;
+ return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
+ });
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
+}
+
+function contrast(a: string, b: string): number {
+ const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x);
+ return (hi + 0.05) / (lo + 0.05);
+}
+
+describe('github-sponsors-goal', () => {
+ it('is registered in the github category and re-exported', async () => {
+ expect(getTemplate('github-sponsors-goal')?.category).toBe('github');
+ const exports = await import('@/templates/github');
+ expect(exports.githubSponsorsGoal.id).toBe('github-sponsors-goal');
+ });
+
+ it('renders label, title, pitch, progress amounts, percentage, sponsors and site name with defaults', () => {
+ expect(render()).toEqual([
+ 'Sponsor',
+ 'codercops/toolbelt',
+ 'Free developer tools that run in your browser',
+ '$420 of $1,000 a month',
+ '42%',
+ '37 sponsors',
+ 'github.com/sponsors/codercops',
+ ]);
+ });
+
+ it('fills the bar to the same percentage it prints when below goal', () => {
+ const bar = findStyle(tree({ raised: 500, goal: 1000 }), (s) => typeof s.width === 'string' && s.width.endsWith('%'));
+ expect(bar?.width).toBe('50%');
+ expect(render({ raised: 500, goal: 1000 })).toContain('50%');
+ });
+
+ it('stops the bar at 100% when goal is passed while showing real amounts and percentage', () => {
+ const rendered = render({ raised: 1260, goal: 1000 });
+ expect(rendered).toContain('$1,260 of $1,000 a month');
+ expect(rendered).toContain('126%');
+
+ const bar = findStyle(tree({ raised: 1260, goal: 1000 }), (s) => typeof s.width === 'string' && s.width.endsWith('%'));
+ expect(bar?.width).toBe('100%');
+ });
+
+ it('counts a goal below 1 as 1', () => {
+ expect(render({ goal: 0, raised: 5 })).toEqual(
+ expect.arrayContaining(['$5 of $1 a month', '500%'])
+ );
+ expect(render({ goal: -10, raised: 0 })).toEqual(
+ expect.arrayContaining(['$0 of $1 a month', '0%'])
+ );
+ expect(render({ goal: 'abc', raised: 2 })).toEqual(
+ expect.arrayContaining(['$2 of $1 a month', '200%'])
+ );
+ });
+
+ it('counts negative or non-numeric raised as 0', () => {
+ expect(render({ raised: -5, goal: 1000 })).toEqual(
+ expect.arrayContaining(['$0 of $1,000 a month', '0%'])
+ );
+ expect(render({ raised: '-10', goal: 1000 })).toEqual(
+ expect.arrayContaining(['$0 of $1,000 a month', '0%'])
+ );
+ expect(render({ raised: 'abc', goal: 1000 })).toEqual(
+ expect.arrayContaining(['$0 of $1,000 a month', '0%'])
+ );
+ expect(render({ raised: '', goal: 1000 })).toEqual(
+ expect.arrayContaining(['$0 of $1,000 a month', '0%'])
+ );
+ expect(render({ raised: ' ', goal: 1000 })).toEqual(
+ expect.arrayContaining(['$0 of $1,000 a month', '0%'])
+ );
+ });
+
+ it('formats amounts with thousands separators', () => {
+ expect(render({ raised: 15000, goal: 20000 })).toEqual(
+ expect.arrayContaining(['$15,000 of $20,000 a month'])
+ );
+ expect(render({ raised: 1000000, goal: 2500000 })).toEqual(
+ expect.arrayContaining(['$1,000,000 of $2,500,000 a month'])
+ );
+ });
+
+ it('supports custom currency', () => {
+ expect(render({ currency: '€', raised: 500, goal: 1000 })).toEqual(
+ expect.arrayContaining(['€500 of €1,000 a month'])
+ );
+ expect(render({ currency: '', raised: 500, goal: 1000 })).toEqual(
+ expect.arrayContaining(['500 of 1,000 a month'])
+ );
+ });
+
+ it('hides period cleanly when empty or whitespace', () => {
+ expect(render({ period: '' })).toEqual(
+ expect.arrayContaining(['$420 of $1,000'])
+ );
+ expect(render({ period: ' ' })).toEqual(
+ expect.arrayContaining(['$420 of $1,000'])
+ );
+ expect(render({ period: '' })).not.toContain('$420 of $1,000 a month');
+ });
+
+ it('hides description cleanly when empty or whitespace', () => {
+ for (const emptyDesc of ['', ' ']) {
+ expect(render({ description: emptyDesc })).not.toContain('Free developer tools that run in your browser');
+ const nodes = elements(tree({ description: emptyDesc }));
+ expect(nodes.some((n) => n.type === 'p')).toBe(false);
+ }
+ });
+
+ it('hides sponsors and site name cleanly when empty or whitespace', () => {
+ expect(render({ sponsors: '', siteName: '' })).toEqual([
+ 'Sponsor',
+ 'codercops/toolbelt',
+ 'Free developer tools that run in your browser',
+ '$420 of $1,000 a month',
+ '42%',
+ ]);
+
+ expect(render({ sponsors: ' ' })).not.toContain('37 sponsors');
+ expect(render({ siteName: ' ' })).not.toContain('github.com/sponsors/codercops');
+ });
+
+ it('shrinks long maintainer names and pitches with autoFontSize and truncates them', () => {
+ const longTitle = 'codercops-open-source-foundation-and-extended-toolbelt-project';
+ const longPitch = 'Free, privacy-friendly, high-performance developer tools running completely client-side in modern web browsers without telemetry';
+ const nodes = elements(tree({ title: longTitle, description: longPitch }));
+
+ const heading = nodes.find((n) => n.type === 'h1');
+ expect(heading?.props.style?.fontSize).toBe('40px');
+ const [renderedTitle] = texts(heading);
+ expect(renderedTitle.length).toBeLessThanOrEqual(60);
+ expect(renderedTitle.endsWith('…')).toBe(true);
+
+ const pitch = nodes.find((n) => n.type === 'p');
+ expect(pitch?.props.style?.fontSize).toBe('20px');
+ const [renderedPitch] = texts(pitch);
+ expect(renderedPitch.length).toBeLessThanOrEqual(100);
+ expect(renderedPitch.endsWith('…')).toBe(true);
+ });
+
+ it('darkens the accent text on a light background for contrast', () => {
+ const light = tree({ bgColor: '#ffffff', textColor: '#111111' });
+ const percentStyle = styleOf(light, '42%')!;
+ expect(contrast(percentStyle.color, '#ffffff')).toBeGreaterThanOrEqual(4.5);
+
+ const labelStyle = styleOf(light, 'Sponsor')!;
+ expect(contrast(labelStyle.color, '#ffffff')).toBeGreaterThanOrEqual(4.5);
+ });
+
+ it('leaves the accent color untouched on a dark background', () => {
+ const dark = tree();
+ expect(styleOf(dark, '42%')!.color).toBe('#ea4aaa');
+ expect(styleOf(dark, 'Sponsor')!.color).toBe('#ea4aaa');
+ });
+});
diff --git a/tests/templates/table-of-contents.test.ts b/tests/templates/table-of-contents.test.ts
new file mode 100644
index 0000000..d9a3591
--- /dev/null
+++ b/tests/templates/table-of-contents.test.ts
@@ -0,0 +1,219 @@
+import { describe, expect, it } from 'vitest';
+import blogTableOfContents from '@/templates/blog/table-of-contents';
+import { blogTableOfContents as blogTableOfContentsExport } from '@/templates/blog';
+import { getTemplate, getTemplatesByCategory } from '@/templates/registry';
+import { hexToRgb } from '@/templates/utils';
+import type { SatoriElement } from '@/templates/types';
+
+function walk(node: unknown, visit: (element: SatoriElement) => void): void {
+ if (Array.isArray(node)) {
+ node.forEach((child) => walk(child, visit));
+ return;
+ }
+ if (!node || typeof node !== 'object' || !('type' in node)) return;
+
+ const element = node as SatoriElement;
+ visit(element);
+ walk(element.props?.children, visit);
+}
+
+function textContent(node: unknown): string[] {
+ if (typeof node === 'string') return [node];
+ if (Array.isArray(node)) return node.flatMap(textContent);
+ if (node && typeof node === 'object' && 'props' in node) {
+ return textContent((node as SatoriElement).props.children);
+ }
+ return [];
+}
+
+function styleOf(node: unknown, text: string): Record | undefined {
+ if (Array.isArray(node)) {
+ for (const child of node) {
+ const found = styleOf(child, text);
+ if (found) return found;
+ }
+ return undefined;
+ }
+ if (node && typeof node === 'object' && 'props' in node) {
+ const { style, children } = (node as SatoriElement).props;
+ if (children === text) return style;
+ return styleOf(children, text);
+ }
+ return undefined;
+}
+
+function luminance(hex: string): number {
+ const rgb = hexToRgb(hex);
+ if (!rgb) throw new Error(`not a hex color: ${hex}`);
+ const [r, g, b] = [rgb.r, rgb.g, rgb.b].map((v) => {
+ const c = v / 255;
+ return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
+ });
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
+}
+
+function contrast(a: string, b: string): number {
+ const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x);
+ return (hi + 0.05) / (lo + 0.05);
+}
+
+const tree = (overrides: Record = {}) =>
+ blogTableOfContents.render({ ...blogTableOfContents.defaults, ...overrides });
+
+describe('blog-table-of-contents', () => {
+ it('is re-exported and registered in the blog category', () => {
+ expect(blogTableOfContentsExport).toBe(blogTableOfContents);
+ expect(getTemplate('blog-table-of-contents')).toBe(blogTableOfContents);
+ expect(
+ getTemplatesByCategory('blog').filter((t) => t.id === 'blog-table-of-contents')
+ ).toHaveLength(1);
+ expect(blogTableOfContents.category).toBe('blog');
+ });
+
+ it('renders root element with 1200x630 dimensions', () => {
+ const root = tree();
+ expect(root.props.style.width).toBe('1200px');
+ expect(root.props.style.height).toBe('630px');
+ });
+
+ it('ensures every div in the tree uses flex display', () => {
+ walk(tree(), (element) => {
+ if (element.type === 'div') {
+ expect(element.props.style?.display).toBe('flex');
+ }
+ });
+ });
+
+ it('renders title, label, numbered sections, and footer with defaults', () => {
+ expect(textContent(tree())).toEqual([
+ 'Architecture Patterns for Large-Scale Frontend Applications',
+ 'In this post',
+ '1',
+ 'Why micro-frontends fail at scale',
+ '2',
+ 'The hidden cost of runtime integration',
+ '3',
+ 'Module federation vs build-time composition',
+ '4',
+ 'Migration playbook step-by-step',
+ '5',
+ 'Benchmarks and team impact',
+ '12 min read',
+ 'codercops.com',
+ ]);
+ });
+
+ it('skips empty and whitespace-only sections and renumbers remaining sections consecutively', () => {
+ const out = textContent(
+ tree({
+ section1: ' ',
+ section2: 'First valid section',
+ section3: '',
+ section4: 'Second valid section',
+ section5: ' ',
+ })
+ );
+
+ expect(out).toEqual([
+ 'Architecture Patterns for Large-Scale Frontend Applications',
+ 'In this post',
+ '1',
+ 'First valid section',
+ '2',
+ 'Second valid section',
+ '12 min read',
+ 'codercops.com',
+ ]);
+ });
+
+ it('retains default reading time when field is omitted, and hides empty or whitespace-only reading time cleanly', () => {
+ // When readTime is omitted completely
+ const omittedTree = blogTableOfContents.render({
+ title: 'Title',
+ section1: 'Section 1',
+ bgColor: '#0a0a0a',
+ accentColor: '#E07A5F',
+ textColor: '#FFFFFF',
+ });
+ expect(textContent(omittedTree)).toContain('12 min read');
+
+ // When readTime is explicitly empty or whitespace
+ for (const readTime of ['', ' ']) {
+ const out = textContent(tree({ readTime }));
+ expect(out).not.toContain('12 min read');
+ expect(out).toContain('codercops.com');
+ }
+ });
+
+ it('hides explicitly empty or whitespace-only siteName cleanly', () => {
+ for (const siteName of ['', ' ']) {
+ const out = textContent(tree({ siteName }));
+ expect(out).not.toContain('codercops.com');
+ expect(out).toContain('12 min read');
+ }
+ });
+
+ it('hides footer entirely when both readTime and siteName are empty', () => {
+ const out = textContent(tree({ readTime: '', siteName: '' }));
+ expect(out).not.toContain('12 min read');
+ expect(out).not.toContain('codercops.com');
+ });
+
+ it('truncates long title and section headings', () => {
+ const longString = 'Super long text heading that goes on and on and exceeds the maximum allowed length '.repeat(3);
+ const out = textContent(
+ tree({
+ title: longString,
+ section1: longString,
+ readTime: longString,
+ siteName: longString,
+ })
+ );
+
+ const title = out[0];
+ expect(title.length).toBeLessThanOrEqual(90);
+ expect(title.endsWith('…')).toBe(true);
+
+ const section = out[3];
+ expect(section.length).toBeLessThanOrEqual(70);
+ expect(section.endsWith('…')).toBe(true);
+ });
+
+ it('maintains high contrast for label, numbers, and text on light background', () => {
+ const lightTree = tree({
+ bgColor: '#ffffff',
+ textColor: '#111827',
+ accentColor: '#E07A5F',
+ });
+
+ const labelStyle = styleOf(lightTree, 'In this post');
+ expect(labelStyle?.color).toBeDefined();
+ expect(contrast('#ffffff', labelStyle!.color)).toBeGreaterThanOrEqual(4.5);
+
+ const number1Style = styleOf(lightTree, '1');
+ expect(number1Style?.color).toBeDefined();
+ expect(contrast('#ffffff', number1Style!.color)).toBeGreaterThanOrEqual(4.5);
+ });
+
+ it('maintains high contrast for label, numbers, and text on dark background', () => {
+ const darkTree = tree();
+
+ const labelStyle = styleOf(darkTree, 'In this post');
+ expect(labelStyle?.color).toBeDefined();
+ expect(contrast('#0a0a0a', labelStyle!.color)).toBeGreaterThanOrEqual(4.5);
+
+ const number1Style = styleOf(darkTree, '1');
+ expect(number1Style?.color).toBeDefined();
+ expect(contrast('#0a0a0a', number1Style!.color)).toBeGreaterThanOrEqual(4.5);
+ });
+
+ it('renders all five sections and a two-line title without overflowing the 630px height', () => {
+ const root = tree({
+ title: 'A Very Long Article Title That Clearly Wraps Over Multiple Lines in the Card Header Area',
+ });
+ expect(root.props.style.height).toBe('630px');
+ const texts = textContent(root);
+ expect(texts).toContain('5');
+ expect(texts).toContain('Benchmarks and team impact');
+ });
+});
diff --git a/tests/templates/ticket-cta.test.ts b/tests/templates/ticket-cta.test.ts
new file mode 100644
index 0000000..be99541
--- /dev/null
+++ b/tests/templates/ticket-cta.test.ts
@@ -0,0 +1,39 @@
+import { describe, expect, it } from 'vitest';
+import { readFileSync } from 'node:fs';
+import satori, { type SatoriNode } from 'satori';
+import ticketCta from '@/templates/event/ticket-cta';
+
+const fonts = ['Inter-Regular', 'Inter-Bold', 'PlayfairDisplay-Bold'].map((file) => ({
+ name: file.startsWith('Inter') ? 'Inter' : 'Playfair Display',
+ data: readFileSync(`public/fonts/${file}.woff`),
+ weight: (file.endsWith('Bold') ? 700 : 400) as 400 | 700,
+ style: 'normal' as const,
+}));
+
+async function layout(price: string) {
+ const nodes: SatoriNode[] = [];
+ await satori(ticketCta.render({ ...ticketCta.defaults, price }) as any, {
+ width: 1200, height: 630, fonts, onNodeDetected: (node) => nodes.push(node),
+ });
+ const priceNode = nodes.find((node) => node.props.style?.marginBottom === '24px' && node.props.style?.lineHeight === 1)!;
+ const column = nodes.find((node) => node.props.style?.width === '300px')!;
+ return { priceNode, left: column.left + 48, right: column.left + column.width - 48 };
+}
+
+describe('event-ticket-cta', () => {
+ it('keeps the default price at 64px', async () => {
+ expect((await layout('$99')).priceNode.props.style.fontSize).toBe('64px');
+ });
+
+ it.each(['$1,299.00', '$12,499.99', 'From $149 / person'])('fits %s inside the price column', async (price) => {
+ const { priceNode, left, right } = await layout(price);
+ expect(parseInt(priceNode.props.style.fontSize)).toBeLessThan(64);
+ expect(priceNode.left).toBeGreaterThanOrEqual(left);
+ expect(priceNode.left + priceNode.width).toBeLessThanOrEqual(right);
+ });
+
+ it('truncates a very long price', () => {
+ const text = JSON.stringify(ticketCta.render({ ...ticketCta.defaults, price: 'x'.repeat(40) }));
+ expect(text).toContain('x'.repeat(23) + '…');
+ });
+});
diff --git a/tests/templates/til.test.ts b/tests/templates/til.test.ts
new file mode 100644
index 0000000..d47a06c
--- /dev/null
+++ b/tests/templates/til.test.ts
@@ -0,0 +1,160 @@
+import { readFileSync } from 'node:fs';
+import { join } from 'node:path';
+import satori from 'satori';
+import { describe, expect, it } from 'vitest';
+import til from '@/templates/tutorial/til';
+import { getTemplate, getTemplatesByCategory } from '@/templates/registry';
+import type { SatoriElement } from '@/templates/types';
+
+const fonts = [
+ {
+ name: 'Inter',
+ data: readFileSync(join(process.cwd(), 'public/fonts/Inter-Regular.woff')),
+ weight: 400 as const,
+ style: 'normal' as const,
+ },
+ {
+ name: 'Inter',
+ data: readFileSync(join(process.cwd(), 'public/fonts/Inter-Bold.woff')),
+ weight: 700 as const,
+ style: 'normal' as const,
+ },
+ {
+ name: 'JetBrains Mono',
+ data: readFileSync(join(process.cwd(), 'public/fonts/JetBrainsMono-Regular.woff')),
+ weight: 400 as const,
+ style: 'normal' as const,
+ },
+];
+
+function isSatoriElement(value: unknown): value is SatoriElement {
+ return typeof value === 'object' && value !== null && 'type' in value && 'props' in value;
+}
+
+function getChildren(element: SatoriElement): SatoriElement[] {
+ const children = element.props.children;
+ return Array.isArray(children) ? children.filter(isSatoriElement) : isSatoriElement(children) ? [children] : [];
+}
+
+function getAllElements(element: SatoriElement): SatoriElement[] {
+ return [element, ...getChildren(element).flatMap(getAllElements)];
+}
+
+function texts(node: unknown): string[] {
+ if (typeof node === 'string') return [node];
+ if (Array.isArray(node)) return node.flatMap(texts);
+ if (node && typeof node === 'object' && 'props' in node) {
+ return texts((node as SatoriElement).props.children);
+ }
+ return [];
+}
+
+const render = (overrides: Record = {}) =>
+ til.render({ ...til.defaults, ...overrides });
+const serialized = (overrides: Record = {}) => JSON.stringify(render(overrides));
+
+describe('tutorial-til', () => {
+ it('is registered in tutorial category and re-exported', async () => {
+ expect(getTemplate('tutorial-til')?.category).toBe('tutorial');
+ expect(getTemplatesByCategory('tutorial')).toContain(til);
+ const exports = await import('@/templates/tutorial');
+ expect(exports.tutorialTil.id).toBe('tutorial-til');
+ });
+
+ it('renders default label, tag pill, title, code command, date, and site name', () => {
+ const rendered = texts(render());
+ expect(rendered).toContain('Today I learned');
+ expect(rendered).toContain('git');
+ expect(rendered).toContain('git switch - takes you back to the branch you were just on');
+ expect(rendered).toContain('git switch -');
+ expect(rendered).toContain('Oct 27, 2026');
+ expect(rendered).toContain('codercops.com');
+ });
+
+ it('hides empty code, tag, date, and site name cleanly when empty or only spaces', () => {
+ const tree = serialized({
+ title: 'A useful shortcut for switching branches',
+ code: ' ',
+ tag: ' ',
+ date: '',
+ siteName: ' ',
+ });
+ expect(tree).not.toContain('git switch -');
+ expect(tree).not.toContain('Oct 27, 2026');
+ expect(tree).not.toContain('codercops.com');
+ const all = getAllElements(render({ code: ' ', tag: ' ', date: '', siteName: '' }));
+ expect(all.some((el) => el.type === 'span' && el.props.children === 'git')).toBe(false);
+ });
+
+ it('shrinks long title with autoFontSize and truncates cleanly', () => {
+ const longTitle =
+ 'In Python 3.10 and newer you can use pattern matching with match-case statements to cleanly destructure complex nested dictionaries and tuples in one go';
+ const element = render({ title: longTitle });
+ const all = getAllElements(element);
+ const titleEl = all.find((el) => el.type === 'h1');
+ expect(titleEl).toBeDefined();
+ expect(titleEl?.props.style?.fontSize).toBe('30px');
+ const content = String(titleEl?.props.children);
+ expect(content).toContain('…');
+ expect(content.length).toBeLessThanOrEqual(130);
+ });
+
+ it('truncates long commands inside its box without running off', () => {
+ const longCode =
+ 'git log --graph --pretty=format:"%Cred%h%Creset -%C(yellow)%d%Creset %s %Cgreen(%cr) %C(bold blue)<%an>%Creset" --abbrev-commit';
+ const element = render({ code: longCode });
+ const all = getAllElements(element);
+ const codeBox = all.find(
+ (el) =>
+ el.type === 'div' &&
+ el.props.style?.fontFamily === undefined &&
+ el.props.style?.borderRadius === '12px' &&
+ el.props.style?.maxWidth === '1050px',
+ );
+ expect(codeBox).toBeDefined();
+
+ const codeSpan = all.find(
+ (el) =>
+ el.type === 'span' &&
+ el.props.style?.fontFamily === 'JetBrains Mono, monospace' &&
+ el.props.style?.fontSize === '22px',
+ );
+ expect(codeSpan).toBeDefined();
+ const content = String(codeSpan?.props.children);
+ expect(content).toContain('…');
+ expect(content.length).toBeLessThanOrEqual(75);
+ });
+
+ it('stays readable on a light background with appropriate text and accent colors', () => {
+ const lightTree = render({
+ bgColor: '#ffffff',
+ textColor: '#111827',
+ accentColor: '#38bdf8',
+ });
+ const all = getAllElements(lightTree);
+
+ const labelSpan = all.find(
+ (el) => el.type === 'span' && el.props.children === 'Today I learned',
+ );
+ expect(labelSpan?.props.style?.color).toBe('#1f6888');
+
+ const codeSpan = all.find(
+ (el) =>
+ el.type === 'span' &&
+ el.props.style?.fontFamily === 'JetBrains Mono, monospace' &&
+ el.props.style?.fontSize === '22px',
+ );
+ expect(codeSpan?.props.style?.color).toBe('#1f6888');
+ });
+
+ it('uses flex display on every div for Satori compatibility', () => {
+ const element = render(til.defaults);
+ const divs = getAllElements(element).filter((child) => child.type === 'div');
+ expect(divs.every((child) => child.props.style?.display === 'flex')).toBe(true);
+ });
+
+ it('renders a valid SVG image via Satori', async () => {
+ const element = render(til.defaults);
+ await expect(satori(element as any, { width: 1200, height: 630, fonts })).resolves.toMatch(/^ void): void {
+ if (!node || typeof node !== 'object') return;
+ visit(node);
+ const children = node.props?.children;
+ if (Array.isArray(children)) children.forEach((child) => walk(child, visit));
+ else walk(children, visit);
+}
+
+function rows(element: any): any[] {
+ return element.props.children.find((child: any) =>
+ child.type === 'div'
+ && child.props.style.flexDirection === 'column'
+ && Array.isArray(child.props.children),
+ ).props.children;
+}
+
+function textContent(element: any): string[] {
+ const values: string[] = [];
+ walk(element, (node) => {
+ if (typeof node.props?.children === 'string') values.push(node.props.children);
+ });
+ return values;
+}
+
+describe('podcast-top-episodes', () => {
+ it('skips blank episode titles and keeps ranks consecutive', () => {
+ const rendered = podcastTopEpisodes.render({
+ ...podcastTopEpisodes.defaults,
+ episode1: 'First episode',
+ episode2: ' ',
+ episode3: 'Third episode',
+ });
+ expect(rows(rendered)).toHaveLength(2);
+ expect(rows(rendered).map((row) => row.props.children[0].props.children)).toEqual(['01', '02']);
+ expect(textContent(rendered)).toContain('Third episode');
+ });
+
+ it('hides empty durations without hiding the episode', () => {
+ const rendered = podcastTopEpisodes.render({ ...podcastTopEpisodes.defaults, duration1: ' ' });
+ const firstRow = rows(rendered)[0];
+ expect(firstRow.props.children).toHaveLength(2);
+ expect(textContent(rendered)).toContain(podcastTopEpisodes.defaults.episode1);
+ });
+
+ it('uses singular footer text for one episode and hides a blank show name row', () => {
+ const rendered = podcastTopEpisodes.render({
+ ...podcastTopEpisodes.defaults,
+ showName: ' ',
+ episode2: '',
+ episode3: '',
+ });
+ const text = textContent(rendered);
+ expect(text).toContain('1 EPISODE');
+ expect(text).not.toContain('1 EPISODES');
+ expect(text).not.toContain('The Signal');
+ expect(rendered.props.children[0].type).toBe('h1');
+ });
+
+ it('truncates long titles on one line while retaining the duration column', () => {
+ const longTitle = 'A remarkably long episode title '.repeat(8);
+ const rendered = podcastTopEpisodes.render({
+ ...podcastTopEpisodes.defaults,
+ episode1: longTitle,
+ duration1: '48 min',
+ });
+ const firstRow = rows(rendered)[0];
+ expect(firstRow.props.children[1].props.children).toBe(`${longTitle.slice(0, 55).trimEnd()}…`);
+ expect(firstRow.props.children[1].props.style).toMatchObject({ flex: 1, minWidth: 0, whiteSpace: 'nowrap' });
+ expect(firstRow.props.children[2].props.children).toBe('48 min');
+ expect(firstRow.props.children[2].props.style).toMatchObject({ width: '120px', flexShrink: 0, whiteSpace: 'nowrap' });
+ });
+
+ it('truncates long durations to one line without changing the column width', () => {
+ const rendered = podcastTopEpisodes.render({
+ ...podcastTopEpisodes.defaults,
+ duration1: '1 hour 48 minutes and 30 seconds',
+ });
+ const duration = rows(rendered)[0].props.children[2];
+ expect(duration.props.children).toBe('1 hour 48 m…');
+ expect(duration.props.style).toMatchObject({ width: '120px', whiteSpace: 'nowrap' });
+ });
+
+ it('uses a readable darkened rank color on a light background with a pale accent', () => {
+ const params = { ...podcastTopEpisodes.defaults, bgColor: '#ffffff', textColor: '#111111', accentColor: '#fde047' };
+ const rendered = podcastTopEpisodes.render(params);
+ const rankColor = rows(rendered)[0].props.children[0].props.style.color;
+ expect(isLightColor(params.bgColor)).toBe(true);
+ expect(rankColor).toBe(mixColors(params.accentColor, '#000000', 0.45));
+ expect(textContent(rendered)).toContain('Most Played This Month');
+ walk(rendered, (node) => {
+ if (node.type === 'div') expect(node.props.style.display).toBe('flex');
+ });
+ expect(rendered.props.style).toMatchObject({ width: '1200px', height: '630px' });
+ });
+
+ it('exposes required fields and is exported and registered once', () => {
+ for (const key of ['showName', 'title', 'episode1', 'episode2', 'episode3', 'duration1', 'duration2', 'duration3', 'bgColor', 'accentColor', 'textColor']) {
+ expect(podcastTopEpisodes.fields.some((field) => field.key === key)).toBe(true);
+ }
+ expect(podcastTopEpisodesExport).toBe(podcastTopEpisodes);
+ expect(getTemplate('podcast-top-episodes')).toBe(podcastTopEpisodes);
+ expect(getTemplatesByCategory('podcast').filter((template) => template.id === 'podcast-top-episodes')).toHaveLength(1);
+ });
+});
diff --git a/tests/templates/updated-post.test.ts b/tests/templates/updated-post.test.ts
new file mode 100644
index 0000000..ba309e3
--- /dev/null
+++ b/tests/templates/updated-post.test.ts
@@ -0,0 +1,303 @@
+import { describe, expect, it } from 'vitest';
+import blogUpdatedPost from '@/templates/blog/updated-post';
+import { blogUpdatedPost as blogUpdatedPostExport } from '@/templates/blog';
+import { getTemplate, getTemplatesByCategory } from '@/templates/registry';
+import { hexToRgb } from '@/templates/utils';
+import type { SatoriElement } from '@/templates/types';
+
+function walk(node: unknown, visit: (element: SatoriElement) => void): void {
+ if (Array.isArray(node)) {
+ node.forEach((child) => walk(child, visit));
+ return;
+ }
+ if (!node || typeof node !== 'object' || !('type' in node)) return;
+
+ const element = node as SatoriElement;
+ visit(element);
+ walk(element.props?.children, visit);
+}
+
+function textContent(node: unknown): string[] {
+ if (typeof node === 'string') return [node];
+ if (Array.isArray(node)) return node.flatMap(textContent);
+ if (node && typeof node === 'object' && 'props' in node) {
+ return textContent((node as SatoriElement).props.children);
+ }
+ return [];
+}
+
+function styleOf(node: unknown, text: string): Record | undefined {
+ if (Array.isArray(node)) {
+ for (const child of node) {
+ const found = styleOf(child, text);
+ if (found) return found;
+ }
+ return undefined;
+ }
+ if (node && typeof node === 'object' && 'props' in node) {
+ const { style, children } = (node as SatoriElement).props;
+ if (children === text) return style;
+ return styleOf(children, text);
+ }
+ return undefined;
+}
+
+// The bullet before each "What changed" line.
+function bullets(node: unknown): Record[] {
+ const found: Record[] = [];
+ walk(node, (element) => {
+ if (element.props?.style?.borderRadius === '50%') found.push(element.props.style);
+ });
+ return found;
+}
+
+// The row wrapping each bullet, which carries the spacing between lines.
+function bulletRows(node: unknown): Record[] {
+ const found: Record[] = [];
+ walk(node, (element) => {
+ if (element.type !== 'div' || element.props?.style?.display !== 'flex') return;
+ const children = element.props.children;
+ if (!Array.isArray(children)) return;
+ const hasBullet = children.some(
+ (child) => child && typeof child === 'object' && child.props?.style?.borderRadius === '50%'
+ );
+ if (hasBullet) found.push(element.props.style);
+ });
+ return found;
+}
+
+function luminance(hex: string): number {
+ const rgb = hexToRgb(hex);
+ if (!rgb) throw new Error(`not a hex color: ${hex}`);
+ const [r, g, b] = [rgb.r, rgb.g, rgb.b].map((v) => {
+ const c = v / 255;
+ return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
+ });
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
+}
+
+function contrast(a: string, b: string): number {
+ const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x);
+ return (hi + 0.05) / (lo + 0.05);
+}
+
+const tree = (overrides: Record = {}) =>
+ blogUpdatedPost.render({ ...blogUpdatedPost.defaults, ...overrides });
+
+describe('blog-updated-post', () => {
+ it('is re-exported and registered in the blog category', () => {
+ expect(blogUpdatedPostExport).toBe(blogUpdatedPost);
+ expect(getTemplate('blog-updated-post')).toBe(blogUpdatedPost);
+ expect(
+ getTemplatesByCategory('blog').filter((t) => t.id === 'blog-updated-post')
+ ).toHaveLength(1);
+ expect(blogUpdatedPost.category).toBe('blog');
+ });
+
+ it('renders root element with 1200x630 dimensions', () => {
+ const root = tree();
+ expect(root.props.style.width).toBe('1200px');
+ expect(root.props.style.height).toBe('630px');
+ });
+
+ it('ensures every div in the tree uses flex display', () => {
+ walk(tree(), (element) => {
+ if (element.type === 'div') {
+ expect(element.props.style?.display).toBe('flex');
+ }
+ });
+ });
+
+ it('renders the label, title, both dates, the changes and the site name with defaults', () => {
+ expect(textContent(tree())).toEqual([
+ 'Updated',
+ 'A practical guide to React Server Components',
+ 'First published Mar 3, 2024',
+ '·',
+ 'Updated Oct 27, 2026',
+ 'What changed',
+ 'Covers React 19',
+ 'New section on server actions',
+ 'Fixed the broken examples',
+ 'codercops.com',
+ ]);
+ });
+
+ it('skips blank change lines with no gaps', () => {
+ for (const blank of ['', ' ']) {
+ const out = textContent(tree({ change1: blank, change3: blank }));
+ expect(out).toContain('What changed');
+ expect(out).toContain('New section on server actions');
+ expect(out).not.toContain('Covers React 19');
+ expect(out).not.toContain('Fixed the broken examples');
+
+ // One bullet per rendered line, and the last one carries no trailing margin.
+ const rows = bulletRows(tree({ change1: blank, change3: blank }));
+ expect(rows).toHaveLength(1);
+ expect(rows[0].marginBottom).toBe('0px');
+
+ expect(bullets(tree({ change1: blank, change3: blank }))).toHaveLength(1);
+ }
+ });
+
+ it('keeps a gap only between the lines that are rendered', () => {
+ const rows = bulletRows(tree({ change2: ' ' }));
+ expect(rows).toHaveLength(2);
+ expect(rows[0].marginBottom).toBe('12px');
+ expect(rows[1].marginBottom).toBe('0px');
+ });
+
+ it('hides the What changed heading when every change line is blank', () => {
+ for (const blank of ['', ' ']) {
+ const out = textContent(tree({ change1: blank, change2: blank, change3: blank }));
+ expect(out).not.toContain('What changed');
+ expect(bullets(tree({ change1: blank, change2: blank, change3: blank }))).toHaveLength(0);
+ // The rest of the card is untouched.
+ expect(out).toContain('A practical guide to React Server Components');
+ expect(out).toContain('codercops.com');
+ }
+ });
+
+ it('hides an empty or whitespace-only published date without a stray separator', () => {
+ for (const published of ['', ' ']) {
+ const out = textContent(tree({ published }));
+ expect(out).not.toContain('First published Mar 3, 2024');
+ expect(out).not.toContain('·');
+ expect(out).toContain('Updated Oct 27, 2026');
+ }
+ });
+
+ it('hides an empty or whitespace-only updated date without a stray separator', () => {
+ for (const updated of ['', ' ']) {
+ const out = textContent(tree({ updated }));
+ expect(out).not.toContain('Updated Oct 27, 2026');
+ expect(out).not.toContain('·');
+ expect(out).toContain('First published Mar 3, 2024');
+ }
+ });
+
+ it('hides the dates line entirely when both dates are empty', () => {
+ for (const blank of ['', ' ']) {
+ const out = textContent(tree({ published: blank, updated: blank }));
+ expect(out).not.toContain('·');
+ expect(out).toEqual([
+ 'Updated',
+ 'A practical guide to React Server Components',
+ 'What changed',
+ 'Covers React 19',
+ 'New section on server actions',
+ 'Fixed the broken examples',
+ 'codercops.com',
+ ]);
+ }
+ });
+
+ it('hides an empty or whitespace-only site name cleanly', () => {
+ for (const siteName of ['', ' ']) {
+ const out = textContent(tree({ siteName }));
+ expect(out).not.toContain('codercops.com');
+ expect(out).toContain('Covers React 19');
+ }
+ });
+
+ it('shrinks a long title with autoFontSize and then truncates it', () => {
+ const short = styleOf(tree({ title: 'Short' }), 'Short');
+ expect(short?.fontSize).toBe('72px');
+
+ const long = 'Super long post title that goes on and on and clearly exceeds the maximum length allowed on the card '.repeat(3);
+ const out = textContent(tree({ title: long }));
+ const title = out[1];
+
+ expect(title.length).toBeLessThanOrEqual(84);
+ expect(title.endsWith('…')).toBe(true);
+ // Once truncated it sits at the smallest step rather than overflowing.
+ expect(styleOf(tree({ title: long }), title)?.fontSize).toBe('34px');
+ });
+
+ it('truncates long change lines and the site name', () => {
+ const long = 'A change line that is far too long to fit on the card and keeps going '.repeat(3);
+ const out = textContent(tree({ change1: long, siteName: long }));
+
+ const change = out.find((line) => line.endsWith('…'));
+ expect(change).toBeDefined();
+ expect(change!.length).toBeLessThanOrEqual(64);
+
+ const site = out[out.length - 1];
+ expect(site.length).toBeLessThanOrEqual(36);
+ expect(site.endsWith('…')).toBe(true);
+ });
+
+ it('keeps the longest values on one line instead of overflowing the card', () => {
+ const maxed = tree({
+ title: 'M'.repeat(84),
+ published: 'W'.repeat(40),
+ updated: 'W'.repeat(40),
+ change1: 'M'.repeat(64),
+ change2: 'M'.repeat(64),
+ change3: 'M'.repeat(64),
+ });
+
+ // Each change line is clipped with an ellipsis rather than wrapping into the footer.
+ const out = textContent(maxed);
+ const changes = out.filter((line) => line.length === 64);
+ expect(changes).toHaveLength(3);
+ for (const change of changes) {
+ const style = styleOf(maxed, change);
+ expect(style?.whiteSpace).toBe('nowrap');
+ expect(style?.overflow).toBe('hidden');
+ expect(style?.textOverflow).toBe('ellipsis');
+ }
+
+ // Both dates shrink together and clip, so neither runs past the right edge.
+ const updatedStyle = styleOf(maxed, 'W'.repeat(40));
+ expect(updatedStyle?.fontSize).toBe('18px');
+ expect(updatedStyle?.textOverflow).toBe('ellipsis');
+ expect(updatedStyle?.minWidth).toBe(0);
+
+ // A single unbroken word wraps instead of running off the card.
+ expect(styleOf(maxed, 'M'.repeat(84))?.overflowWrap).toBe('anywhere');
+ });
+
+ it('sizes the dates down as the line gets longer', () => {
+ const size = (published: string, updated: string) =>
+ styleOf(tree({ published, updated }), updated)?.fontSize;
+
+ // Both dates share one line, so the size follows their combined length.
+ expect(size('First published Mar 3, 2024', 'Updated Oct 27, 2026')).toBe('22px');
+ expect(size('P'.repeat(40), 'U'.repeat(15))).toBe('20px');
+ expect(size('P'.repeat(40), 'U'.repeat(40))).toBe('18px');
+ // A single date is never pushed below the floor.
+ expect(size('', 'U'.repeat(40))).toBe('22px');
+ });
+
+ it('keeps the label and accent text readable on a light background', () => {
+ const lightTree = tree({
+ bgColor: '#ffffff',
+ textColor: '#111827',
+ accentColor: '#E07A5F',
+ });
+
+ // The pill sets its own text color against the accent fill.
+ const labelStyle = styleOf(lightTree, 'Updated');
+ expect(labelStyle?.color).toBeDefined();
+ expect(contrast('#E07A5F', labelStyle!.color)).toBeGreaterThanOrEqual(4.5);
+
+ for (const text of ['What changed', 'Updated Oct 27, 2026']) {
+ const style = styleOf(lightTree, text);
+ expect(style?.color).toBeDefined();
+ expect(contrast('#ffffff', style!.color)).toBeGreaterThanOrEqual(4.5);
+ }
+ });
+
+ it('keeps the label and accent text readable on a dark background', () => {
+ const darkTree = tree();
+
+ const labelStyle = styleOf(darkTree, 'Updated');
+ expect(contrast(blogUpdatedPost.defaults!.accentColor as string, labelStyle!.color)).toBeGreaterThanOrEqual(4.5);
+
+ for (const text of ['What changed', 'Updated Oct 27, 2026']) {
+ const style = styleOf(darkTree, text);
+ expect(contrast(blogUpdatedPost.defaults!.bgColor as string, style!.color)).toBeGreaterThanOrEqual(4.5);
+ }
+ });
+});
diff --git a/tests/templates/waitlist.test.ts b/tests/templates/waitlist.test.ts
new file mode 100644
index 0000000..090d25c
--- /dev/null
+++ b/tests/templates/waitlist.test.ts
@@ -0,0 +1,112 @@
+import { describe, expect, it } from 'vitest';
+import waitlist from '@/templates/product/waitlist';
+import { getTemplate } from '@/templates/registry';
+import type { SatoriElement } from '@/templates/types';
+
+function elements(node: unknown): SatoriElement[] {
+ if (Array.isArray(node)) return node.flatMap(elements);
+ if (!node || typeof node !== 'object' || !('type' in node)) return [];
+
+ const element = node as SatoriElement;
+ return [element, ...elements(element.props.children)];
+}
+
+function textContent(node: unknown): string[] {
+ if (typeof node === 'string') return [node];
+ if (Array.isArray(node)) return node.flatMap(textContent);
+ if (node && typeof node === 'object' && 'props' in node) {
+ return textContent((node as SatoriElement).props.children);
+ }
+ return [];
+}
+
+const render = (overrides: Record = {}) =>
+ waitlist.render({ ...waitlist.defaults, ...overrides });
+
+describe('product-waitlist', () => {
+ it('is registered in the product category and re-exported', async () => {
+ expect(getTemplate('product-waitlist')?.category).toBe('product');
+ const exports = await import('@/templates/product');
+ expect(exports.productWaitlist.id).toBe('product-waitlist');
+ });
+
+ it('renders the label, product name, pitch, count with its label and the launch window', () => {
+ expect(textContent(render())).toEqual([
+ 'Join the waitlist',
+ 'Notewell',
+ 'Notes that link themselves',
+ '2,148',
+ 'people on the list',
+ 'Opening in November',
+ 'notewell.app',
+ ]);
+ });
+
+ it('hides the count label whenever the count is empty', () => {
+ expect(textContent(render({ count: ' ' }))).toEqual([
+ 'Join the waitlist',
+ 'Notewell',
+ 'Notes that link themselves',
+ 'Opening in November',
+ 'notewell.app',
+ ]);
+ });
+
+ it('drops a blank description, launch window and site name cleanly', () => {
+ expect(textContent(render({ description: ' ', launchWindow: '', siteName: ' ' }))).toEqual([
+ 'Join the waitlist',
+ 'Notewell',
+ '2,148',
+ 'people on the list',
+ ]);
+ });
+
+ it('keeps the separator out when only one of the launch window and site name is set', () => {
+ const onlySite = elements(render({ launchWindow: ' ' }));
+ const dots = onlySite.filter((element) => element.type === 'div' &&
+ element.props.style?.width === '4px' && element.props.style?.height === '4px');
+
+ expect(dots).toHaveLength(0);
+ expect(textContent(render({ launchWindow: ' ' }))).toEqual([
+ 'Join the waitlist',
+ 'Notewell',
+ 'Notes that link themselves',
+ '2,148',
+ 'people on the list',
+ 'notewell.app',
+ ]);
+ });
+
+ it('shrinks long product names and pitches with autoFontSize and then truncates them', () => {
+ const longTitle = 'Notewell Collaborative Knowledge Workspace for Distributed Engineering Teams';
+ const longPitch = 'Notes that link themselves, find their own context, and stay in sync across every device and every teammate you work with';
+ const nodes = elements(render({ title: longTitle, description: longPitch }));
+
+ const heading = nodes.find((element) => element.type === 'h1');
+ expect(textContent(heading)[0]).toHaveLength(42);
+ expect(textContent(heading)[0]).toMatch(/…$/);
+ expect(heading?.props.style?.fontSize).toBe('60px');
+
+ const pitch = nodes.find((element) => element.type === 'p');
+ expect(textContent(pitch)[0].length).toBeLessThanOrEqual(90);
+ expect(textContent(pitch)[0]).toMatch(/…$/);
+ expect(pitch?.props.style?.fontSize).toBe('22px');
+ });
+
+ it('keeps the accent text readable on a light background', () => {
+ const dark = elements(render());
+ const light = elements(render({ bgColor: '#ffffff', textColor: '#111111', accentColor: '#fde047' }));
+
+ const darkLabel = dark.find((element) => element.type === 'span' &&
+ textContent(element)[0] === 'Join the waitlist');
+ expect(darkLabel?.props.style?.color).toBe('#E07A5F');
+
+ const lightLabel = light.find((element) => element.type === 'span' &&
+ textContent(element)[0] === 'Join the waitlist');
+ expect(lightLabel?.props.style?.color).toBe('#8b7b27');
+
+ const lightWindow = light.find((element) => element.type === 'span' &&
+ textContent(element)[0] === 'Opening in November');
+ expect(lightWindow?.props.style?.color).toBe('#8b7b27');
+ });
+});
diff --git a/tests/templates/welcome.test.ts b/tests/templates/welcome.test.ts
new file mode 100644
index 0000000..54bcd8e
--- /dev/null
+++ b/tests/templates/welcome.test.ts
@@ -0,0 +1,255 @@
+import { describe, expect, it } from 'vitest';
+import newsletterWelcome from '@/templates/newsletter/welcome';
+import { newsletterWelcome as newsletterWelcomeExport } from '@/templates/newsletter';
+import { getTemplate, getTemplatesByCategory } from '@/templates/registry';
+import { hexToRgb } from '@/templates/utils';
+import type { SatoriElement } from '@/templates/types';
+
+function walk(node: unknown, visit: (element: SatoriElement) => void): void {
+ if (Array.isArray(node)) {
+ node.forEach((child) => walk(child, visit));
+ return;
+ }
+ if (!node || typeof node !== 'object' || !('type' in node)) return;
+
+ const element = node as SatoriElement;
+ visit(element);
+ walk(element.props?.children, visit);
+}
+
+function textContent(node: unknown): string[] {
+ if (typeof node === 'string') return [node];
+ if (Array.isArray(node)) return node.flatMap(textContent);
+ if (node && typeof node === 'object' && 'props' in node) {
+ return textContent((node as SatoriElement).props.children);
+ }
+ return [];
+}
+
+function styleOf(node: unknown, text: string): Record | undefined {
+ if (Array.isArray(node)) {
+ for (const child of node) {
+ const found = styleOf(child, text);
+ if (found) return found;
+ }
+ return undefined;
+ }
+ if (node && typeof node === 'object' && 'props' in node) {
+ const { style, children } = (node as SatoriElement).props;
+ if (children === text) return style;
+ return styleOf(children, text);
+ }
+ return undefined;
+}
+
+function luminance(hex: string): number {
+ const rgb = hexToRgb(hex);
+ if (!rgb) throw new Error(`not a hex color: ${hex}`);
+ const [r, g, b] = [rgb.r, rgb.g, rgb.b].map((v) => {
+ const c = v / 255;
+ return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
+ });
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
+}
+
+function contrast(a: string, b: string): number {
+ const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x);
+ return (hi + 0.05) / (lo + 0.05);
+}
+
+const tree = (overrides: Record = {}) =>
+ newsletterWelcome.render({ ...newsletterWelcome.defaults, ...overrides });
+
+describe('newsletter-welcome', () => {
+ it('is re-exported and registered in the newsletter category', () => {
+ expect(newsletterWelcomeExport).toBe(newsletterWelcome);
+ expect(getTemplate('newsletter-welcome')).toBe(newsletterWelcome);
+ expect(
+ getTemplatesByCategory('newsletter').filter((t) => t.id === 'newsletter-welcome')
+ ).toHaveLength(1);
+ expect(newsletterWelcome.category).toBe('newsletter');
+ });
+
+ it('renders root element with 1200x630 dimensions', () => {
+ const root = tree();
+ expect(root.props.style.width).toBe('1200px');
+ expect(root.props.style.height).toBe('630px');
+ });
+
+ it('ensures every div in the tree uses flex display', () => {
+ walk(tree(), (element) => {
+ if (element.type === 'div') {
+ expect(element.props.style?.display).toBe('flex');
+ }
+ });
+ });
+
+ it('renders label, newsletter name, numbered items, and sign-off in order with defaults', () => {
+ expect(textContent(tree())).toEqual([
+ 'Welcome to',
+ 'The Friday Brief',
+ '1',
+ 'A 5-minute read every Friday',
+ '2',
+ 'One deep dive a month',
+ '3',
+ 'No ads, ever',
+ 'Priya, editor',
+ ]);
+ });
+
+ it('skips empty and whitespace-only items and renumbers remaining items consecutively from 1', () => {
+ const out = textContent(
+ tree({
+ item1: ' ',
+ item2: 'Curated links weekly',
+ item3: 'Original essays monthly',
+ })
+ );
+
+ expect(out).toEqual([
+ 'Welcome to',
+ 'The Friday Brief',
+ '1',
+ 'Curated links weekly',
+ '2',
+ 'Original essays monthly',
+ 'Priya, editor',
+ ]);
+ });
+
+ it('skips middle empty item and keeps numbering consecutive', () => {
+ const out = textContent(
+ tree({
+ item1: 'First item',
+ item2: '',
+ item3: 'Second item',
+ })
+ );
+
+ expect(out).toEqual([
+ 'Welcome to',
+ 'The Friday Brief',
+ '1',
+ 'First item',
+ '2',
+ 'Second item',
+ 'Priya, editor',
+ ]);
+ });
+
+ it('renders only one item numbered 1 when only one item is provided', () => {
+ const out = textContent(
+ tree({
+ item1: '',
+ item2: '',
+ item3: 'Single expectation',
+ })
+ );
+
+ expect(out).toEqual([
+ 'Welcome to',
+ 'The Friday Brief',
+ '1',
+ 'Single expectation',
+ 'Priya, editor',
+ ]);
+ });
+
+ it('renders cleanly when all items are empty', () => {
+ const out = textContent(
+ tree({
+ item1: '',
+ item2: ' ',
+ item3: '',
+ })
+ );
+
+ expect(out).toEqual(['Welcome to', 'The Friday Brief', 'Priya, editor']);
+ });
+
+ it('hides empty or whitespace-only sign-off cleanly', () => {
+ for (const signoff of ['', ' ']) {
+ const out = textContent(tree({ signoff }));
+ expect(out).not.toContain('Priya, editor');
+ expect(out).toEqual([
+ 'Welcome to',
+ 'The Friday Brief',
+ '1',
+ 'A 5-minute read every Friday',
+ '2',
+ 'One deep dive a month',
+ '3',
+ 'No ads, ever',
+ ]);
+ }
+ });
+
+ it('hides empty or whitespace-only label cleanly', () => {
+ for (const label of ['', ' ']) {
+ const out = textContent(tree({ label }));
+ expect(out).not.toContain('Welcome to');
+ expect(out).toEqual([
+ 'The Friday Brief',
+ '1',
+ 'A 5-minute read every Friday',
+ '2',
+ 'One deep dive a month',
+ '3',
+ 'No ads, ever',
+ 'Priya, editor',
+ ]);
+ }
+ });
+
+ it('truncates long expectation items, label, newsletter name, and sign-off without overflowing', () => {
+ const longText = 'A'.repeat(100);
+ const rendered = tree({
+ label: longText,
+ siteName: longText,
+ item1: longText,
+ signoff: longText,
+ });
+ const texts = textContent(rendered);
+
+ expect(texts[0].length).toBeLessThanOrEqual(32);
+ expect(texts[0].endsWith('…')).toBe(true);
+
+ expect(texts[1].length).toBeLessThanOrEqual(50);
+ expect(texts[1].endsWith('…')).toBe(true);
+
+ expect(texts[3].length).toBeLessThanOrEqual(70);
+ expect(texts[3].endsWith('…')).toBe(true);
+
+ expect(texts[texts.length - 1].length).toBeLessThanOrEqual(50);
+ expect(texts[texts.length - 1].endsWith('…')).toBe(true);
+ });
+
+ it('keeps label and numbers readable on light backgrounds with sufficient contrast', () => {
+ const lightTree = tree({
+ bgColor: '#ffffff',
+ textColor: '#000000',
+ accentColor: '#E07A5F',
+ });
+
+ const labelStyle = styleOf(lightTree, 'Welcome to');
+ expect(labelStyle?.color).toBeDefined();
+ expect(contrast('#ffffff', labelStyle!.color)).toBeGreaterThanOrEqual(4.5);
+
+ const number1Style = styleOf(lightTree, '1');
+ expect(number1Style?.color).toBeDefined();
+ expect(contrast('#ffffff', number1Style!.color)).toBeGreaterThanOrEqual(4.5);
+ });
+
+ it('maintains high contrast on default dark background', () => {
+ const darkTree = tree();
+
+ const labelStyle = styleOf(darkTree, 'Welcome to');
+ expect(labelStyle?.color).toBeDefined();
+ expect(contrast('#0a0a0a', labelStyle!.color)).toBeGreaterThanOrEqual(4.5);
+
+ const number1Style = styleOf(darkTree, '1');
+ expect(number1Style?.color).toBeDefined();
+ expect(contrast('#0a0a0a', number1Style!.color)).toBeGreaterThanOrEqual(4.5);
+ });
+});
From a528d9667e03b8667f5a989909bbfb703aaf2a35 Mon Sep 17 00:00:00 2001
From: Anurag Yadav <143180737+anurag2787@users.noreply.github.com>
Date: Fri, 30 Oct 2026 12:09:47 +0530
Subject: [PATCH 2/3] Added HTML named entities (#317)
Signed-off-by: Anurag Yadav
---
src/lib/meta-fetcher.ts | 4 ++++
tests/lib/meta-fetcher.test.ts | 16 ++++++++++++++++
2 files changed, 20 insertions(+)
diff --git a/src/lib/meta-fetcher.ts b/src/lib/meta-fetcher.ts
index 63cd4d0..347dfbc 100644
--- a/src/lib/meta-fetcher.ts
+++ b/src/lib/meta-fetcher.ts
@@ -181,6 +181,10 @@ const NAMED_ENTITIES: Record = {
quot: '"',
apos: "'",
nbsp: '\u00a0',
+ AMP: '&',
+ LT: '<',
+ GT: '>',
+ QUOT: '"',
};
function decodeEntities(str: string): string {
diff --git a/tests/lib/meta-fetcher.test.ts b/tests/lib/meta-fetcher.test.ts
index 589e44c..fee4538 100644
--- a/tests/lib/meta-fetcher.test.ts
+++ b/tests/lib/meta-fetcher.test.ts
@@ -47,6 +47,22 @@ describe('parseMetaTags', () => {
expect(tags.title).toBe("Tom & Jerry's");
});
+ it('decodes uppercase named entities &, <, >, and "', () => {
+ const tags = parseMetaTags(
+ '<Tom & Jerry "Show"> ',
+ base,
+ );
+ expect(tags.title).toBe('');
+ });
+
+ it('does not decode uppercase or mixed-case entities that are not valid in HTML', () => {
+ const tags = parseMetaTags(
+ ' ',
+ base,
+ );
+ expect(tags.description).toBe('&APOS; &NBSP; &Amp;');
+ });
+
it('parses unquoted meta attributes', () => {
const tags = parseMetaTags(' ', base);
expect(tags.twitterCard).toBe('summary_large_image');
From b8cdfa9dd6015c7c835e4820f85295fbc7157f9f Mon Sep 17 00:00:00 2001
From: anurag629
Date: Sat, 31 Oct 2026 21:22:18 +0530
Subject: [PATCH 3/3] release: bring v1.8.0 up to dev as of #376
---
.github/FUNDING.yml | 3 -
.github/workflows/accepted-label.yml | 24 +-
.github/workflows/claim-check.yml | 2 +-
.well-known/funding-manifest-urls | 1 +
CONTRIBUTING.md | 16 +-
README.md | 4 +
package-lock.json | 30 +-
package.json | 3 +-
public/.well-known/funding-manifest-urls | 1 +
src/components/editor/EditorApp.tsx | 4 +-
src/components/editor/ExportBar.tsx | 33 +-
src/components/editor/export-utils.ts | 26 ++
src/components/preview/AIAnalyzer.tsx | 3 +-
src/components/preview/FixItButton.tsx | 16 +-
src/components/preview/MetaTagReport.tsx | 3 +-
src/components/preview/usePreviewState.ts | 6 +-
src/lib/meta-analyzer.ts | 27 +-
src/lib/meta-fetcher.ts | 21 +-
src/lib/meta-value.ts | 4 +
src/lib/platform-specs.ts | 4 +-
src/styles/preview.css | 12 +
src/templates/blog/code-snippet.ts | 1 +
src/templates/ecommerce/product-card.ts | 4 +-
src/templates/ecommerce/review-highlight.ts | 4 +-
src/templates/event/index.ts | 1 +
src/templates/event/meetup.ts | 5 +-
src/templates/event/recap.ts | 141 ++++++++
src/templates/github/discussion.ts | 107 ++++++
src/templates/github/index.ts | 1 +
src/templates/github/language-bars.ts | 10 +-
src/templates/registry.ts | 4 +
src/templates/saas/testimonial-banner.ts | 4 +-
src/templates/tutorial/code-tutorial.ts | 1 +
src/templates/tutorial/difficulty-badge.ts | 2 -
src/templates/tutorial/platform-branded.ts | 4 +-
src/templates/utils.ts | 13 +-
tests/components/export-utils.test.ts | 14 +
tests/editor/export-bar.test.ts | 2 +-
tests/editor/query-params.test.ts | 15 +
tests/lib/meta-analyzer.test.ts | 91 +++++
tests/lib/meta-fetcher.test.ts | 77 +++++
tests/lib/meta-value.test.ts | 17 +
tests/lib/platform-specs.test.ts | 7 +
tests/preview/query-params.test.ts | 35 ++
tests/templates/code-snippet.test.ts | 29 ++
tests/templates/code-tutorial.test.ts | 22 ++
tests/templates/difficulty-badge.test.ts | 9 +
tests/templates/discussion.test.ts | 122 +++++++
tests/templates/event-recap.test.ts | 348 ++++++++++++++++++++
tests/templates/language-bars.test.ts | 28 ++
tests/templates/meetup.test.ts | 25 ++
tests/templates/rating-stars.test.ts | 30 ++
tests/templates/star-ratings.test.ts | 38 +++
tests/templates/utils.test.ts | 5 +
54 files changed, 1368 insertions(+), 91 deletions(-)
delete mode 100644 .github/FUNDING.yml
create mode 100644 .well-known/funding-manifest-urls
create mode 100644 public/.well-known/funding-manifest-urls
create mode 100644 src/components/editor/export-utils.ts
create mode 100644 src/lib/meta-value.ts
create mode 100644 src/templates/event/recap.ts
create mode 100644 src/templates/github/discussion.ts
create mode 100644 tests/components/export-utils.test.ts
create mode 100644 tests/lib/meta-value.test.ts
create mode 100644 tests/templates/code-snippet.test.ts
create mode 100644 tests/templates/code-tutorial.test.ts
create mode 100644 tests/templates/difficulty-badge.test.ts
create mode 100644 tests/templates/discussion.test.ts
create mode 100644 tests/templates/event-recap.test.ts
create mode 100644 tests/templates/language-bars.test.ts
create mode 100644 tests/templates/meetup.test.ts
create mode 100644 tests/templates/rating-stars.test.ts
create mode 100644 tests/templates/star-ratings.test.ts
diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml
deleted file mode 100644
index a949825..0000000
--- a/.github/FUNDING.yml
+++ /dev/null
@@ -1,3 +0,0 @@
-custom:
- - "https://buymeacoffee.com/anurag629"
- - "https://www.paypal.com/paypalme/codercops"
diff --git a/.github/workflows/accepted-label.yml b/.github/workflows/accepted-label.yml
index 2f88c9d..6f6b8f1 100644
--- a/.github/workflows/accepted-label.yml
+++ b/.github/workflows/accepted-label.yml
@@ -1,7 +1,8 @@
name: Accepted label
-# A merged PR counts on the leaderboard only with the hacktoberfest-accepted
-# label, and only for an issue that was assigned to its author (CONTRIBUTING.md).
+# A merged PR counts on the leaderboard (oss.codercops.com) only with the
+# codercops-accepted label, and only for an issue that was assigned to its
+# author (CONTRIBUTING.md). PRs merged in October also get hacktoberfest-accepted.
# Maintainers add the label when they merge. This adds it when that was missed,
# by the same rule as the claim check: every issue the PR closes is assigned to
# the author. It runs once, at the merge, so removing the label later sticks.
@@ -20,12 +21,13 @@ permissions:
jobs:
label:
name: Label a merged contributor PR
- # Maintainers and bots don't claim issues, and spam never counts.
+ # Maintainers and bots don't claim issues, and spam never counts. Outside
+ # collaborators (contributors with Triage) are labeled like everyone else.
if: >-
github.event.pull_request.merged == true &&
github.event.pull_request.user.type != 'Bot' &&
- !contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.pull_request.author_association) &&
- !contains(github.event.pull_request.labels.*.name, 'hacktoberfest-accepted') &&
+ !contains(fromJSON('["OWNER", "MEMBER"]'), github.event.pull_request.author_association) &&
+ !contains(github.event.pull_request.labels.*.name, 'codercops-accepted') &&
!contains(github.event.pull_request.labels.*.name, 'spam')
runs-on: ubuntu-latest
steps:
@@ -34,6 +36,7 @@ jobs:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
PR: ${{ github.event.pull_request.number }}
+ MERGED_AT: ${{ github.event.pull_request.merged_at }}
run: |
set -euo pipefail
@@ -70,7 +73,7 @@ jobs:
fi
if [ "$(jq length <<<"$linked")" -eq 0 ]; then
- echo "::notice title=Not labeled::This PR isn't linked to an issue, so it doesn't get hacktoberfest-accepted."
+ echo "::notice title=Not labeled::This PR isn't linked to an issue, so it doesn't get codercops-accepted."
exit 0
fi
@@ -81,9 +84,12 @@ jobs:
| join(", ")' <<<"$linked")
if [ -n "$missing" ]; then
- echo "::notice title=Not labeled::$missing isn't assigned to @$author, so this PR doesn't get hacktoberfest-accepted."
+ echo "::notice title=Not labeled::$missing isn't assigned to @$author, so this PR doesn't get codercops-accepted."
exit 0
fi
- gh api -X POST "repos/$REPO/issues/$PR/labels" -f "labels[]=hacktoberfest-accepted" --silent
- echo "Added hacktoberfest-accepted: $(jq -r '[.[].ref] | join(", ")' <<<"$linked") assigned to @$author"
+ labels=(-f "labels[]=codercops-accepted")
+ # Hacktoberfest only counts PRs merged in October (UTC).
+ if [ "${MERGED_AT:5:2}" = "10" ]; then labels+=(-f "labels[]=hacktoberfest-accepted"); fi
+ gh api -X POST "repos/$REPO/issues/$PR/labels" "${labels[@]}" --silent
+ echo "Labeled: $(jq -r '[.[].ref] | join(", ")' <<<"$linked") assigned to @$author"
diff --git a/.github/workflows/claim-check.yml b/.github/workflows/claim-check.yml
index ce89d32..b704041 100644
--- a/.github/workflows/claim-check.yml
+++ b/.github/workflows/claim-check.yml
@@ -26,7 +26,7 @@ jobs:
# Maintainers and bots don't claim issues.
if: >-
github.event.pull_request.user.type != 'Bot' &&
- !contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.pull_request.author_association)
+ !contains(fromJSON('["OWNER", "MEMBER"]'), github.event.pull_request.author_association)
runs-on: ubuntu-latest
steps:
- name: Check the linked issue
diff --git a/.well-known/funding-manifest-urls b/.well-known/funding-manifest-urls
new file mode 100644
index 0000000..42647b8
--- /dev/null
+++ b/.well-known/funding-manifest-urls
@@ -0,0 +1 @@
+https://oss.codercops.com/funding.json
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 83ed03c..76e26de 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -2,17 +2,17 @@
Thank you for your interest in contributing! OGCOPS is open source and welcomes contributions of all kinds.
-## Hacktoberfest 2026
+## How to contribute
-We're taking part in Hacktoberfest this year, and first-time contributors are very welcome.
+First-time contributors are very welcome.
-1. Pick an open issue labeled [`hacktoberfest`](https://github.com/codercops/ogcops/issues?q=is%3Aissue+is%3Aopen+label%3Ahacktoberfest+no%3Aassignee) or [`good first issue`](https://github.com/codercops/ogcops/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22+no%3Aassignee) that nobody is assigned to. New template ideas are listed in [#5](https://github.com/codercops/ogcops/issues/5).
+1. Pick an open issue labeled [`good first issue`](https://github.com/codercops/ogcops/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22+no%3Aassignee) that nobody is assigned to. New template ideas are listed in [#5](https://github.com/codercops/ogcops/issues/5).
2. Comment on it to ask for it. I'll assign it to you, usually within a day. Please don't start on an issue that's assigned to someone else.
3. Take one issue at a time. Once your PR is merged, grab the next one. After 3 merged PRs with us you can hold two issues at once.
4. If you're assigned and there's no PR or update for 5 days, I'll free the issue up for someone else. Just comment if you need more time.
5. Open your PR against `dev` here in codercops/ogcops and put `Closes #` in the description. A PR merged inside your own fork never reaches us, so it doesn't count. A check on the PR ("Claim check") fails if that issue isn't assigned to you. Another one ("Description check") fails while the "How I tested this" or "AI assistance" section of the description is missing or empty.
-PRs count for Hacktoberfest once they're merged or labeled `hacktoberfest-accepted`. PRs that aren't linked to an issue, only reformat code, or change things nobody asked for will be closed, and spammy ones get the `spam` label. If you have an idea that isn't an issue yet, open an issue first so we can agree on it before you write code. The full rules are under [Ground Rules](#ground-rules) below.
+A merged PR counts on the monthly leaderboard once it's labeled `codercops-accepted`. A workflow adds that label when the PR is merged, as long as every issue it closes is assigned to you. PRs that aren't linked to an issue, only reformat code, or change things nobody asked for will be closed, and spammy ones get the `spam` label. If you have an idea that isn't an issue yet, open an issue first so we can agree on it before you write code. The full rules are under [Ground Rules](#ground-rules) below.
Stuck? Ask on the issue, or in `#oss-help` on our [Discord](https://discord.gg/mpGdJyjaRQ). Questions are welcome. Claims still happen on the issue, so the assignment stays visible to everyone.
@@ -38,7 +38,7 @@ These get closed and labeled `spam`:
- claiming a pile of issues at once, or bulk and automated submissions
- untested generated code
-Two spam PRs and you won't be assigned more issues this month, and you're out of the CODERCOPS goodies.
+Two spam PRs and you won't be assigned more issues this month, and your PRs stop counting on the leaderboard.
### Reviews
@@ -48,15 +48,15 @@ For a bug fix, check that your new test fails without the fix: undo the change i
I review most PRs within a few days. Please don't ping repeatedly or DM maintainers about a review. If three days pass with no reply, one follow-up comment on the PR is fine.
-### Goodies
+### Monthly leaderboard
-The top 5 contributors this October get a CODERCOPS T-shirt and stickers. You need at least 5 merged PRs labeled `hacktoberfest-accepted` across chatcops, ogcops, toolbelt and discord-bot, merged between Oct 1 and Oct 31 (UTC), and ties go to whoever got there first. To keep it fair:
+Every month has its own leaderboard at [oss.codercops.com/leaderboard](https://oss.codercops.com/leaderboard). It counts PRs labeled `codercops-accepted` across chatcops, ogcops, toolbelt and discord-bot that were merged in that month (UTC). You're ranked from 3 merged PRs, and the top 5 get a shout-out on our X and LinkedIn pages. To keep it fair:
- only PRs for issues that were assigned to you count
- a fix split into several PRs counts once
- what counts is the maintainers' call, and that call is final
-These goodies come from CODERCOPS through our own open source program. They're separate from Hacktoberfest, so anything Hacktoberfest itself gives out is a different thing with its own rules.
+People who keep contributing can be invited as maintainers with Triage access. [GOVERNANCE.md](https://github.com/codercops/.github/blob/main/GOVERNANCE.md) explains how that works and who maintains what.
## Ways to Contribute
diff --git a/README.md b/README.md
index 48d5f54..80421f2 100644
--- a/README.md
+++ b/README.md
@@ -129,6 +129,10 @@ git checkout -b your-feature-branch
- [Join the Discord](https://discord.gg/mpGdJyjaRQ) to chat with other contributors
- [Start a discussion](https://github.com/codercops/ogcops/discussions) — questions, ideas, show & tell
+## Maintainers and funding
+
+OGCOPS is maintained by CODERCOPS together with contributors who have Triage access. [GOVERNANCE.md](https://github.com/codercops/.github/blob/main/GOVERNANCE.md) lists who they are and how to become one. The project is free and MIT licensed, and CODERCOPS doesn't sell it or a hosted version of it. If it's useful to you, you can [sponsor the maintainers](https://github.com/sponsors/anurag629). The money pays for review time and hosting.
+
## License
[MIT](LICENSE)
diff --git a/package-lock.json b/package-lock.json
index 679a9e2..dbd4d5e 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -437,9 +437,9 @@
}
},
"node_modules/@astrojs/sitemap/node_modules/zod": {
- "version": "4.6.5",
- "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.5.tgz",
- "integrity": "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==",
+ "version": "4.6.7",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.7.tgz",
+ "integrity": "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
@@ -3246,9 +3246,9 @@
}
},
"node_modules/astro/node_modules/zod": {
- "version": "4.6.5",
- "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.5.tgz",
- "integrity": "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==",
+ "version": "4.6.7",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.7.tgz",
+ "integrity": "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
@@ -3889,9 +3889,9 @@
}
},
"node_modules/fast-uri": {
- "version": "3.1.8",
- "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz",
- "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==",
+ "version": "3.1.9",
+ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.9.tgz",
+ "integrity": "sha512-hS/0Wt3Ya043oKXs0sIxrMpKQITXhMLIrVAKKXi4gozGC9v7YRWVnXIZzjNPTO02M9BdxLCWqK1yRXgNziVyJQ==",
"dev": true,
"funding": [
{
@@ -4616,9 +4616,9 @@
}
},
"node_modules/miniflare/node_modules/undici": {
- "version": "7.29.1",
- "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.1.tgz",
- "integrity": "sha512-RYONW2MeafgYlkVOKYKkA/Ag7BmXqgIWCa8t1m0JcxrQg9pI9lEqRhAOruOBCbAohOa/gkCF+iPi9hrgvTzu6Q==",
+ "version": "7.30.1",
+ "resolved": "https://registry.npmjs.org/undici/-/undici-7.30.1.tgz",
+ "integrity": "sha512-03KrMbjIFDWQR5RNPrSw3bd1r12JpyFQl9/yxjUbJtqkjmllqC5lFjZh6ujeuJCKr8EOQvRW872Oy/az+nLYxg==",
"license": "MIT",
"engines": {
"node": ">=20.18.1"
@@ -5541,9 +5541,9 @@
"license": "MIT"
},
"node_modules/undici": {
- "version": "8.11.2",
- "resolved": "https://registry.npmjs.org/undici/-/undici-8.11.2.tgz",
- "integrity": "sha512-u4UB2/IrKdU6lFxumHmmo1a3fCQO5tzQllRorfoRS63txhrB7xTpSn1PftwC4qEHkOaqP95fCWW4lJzwErwzhQ==",
+ "version": "8.12.3",
+ "resolved": "https://registry.npmjs.org/undici/-/undici-8.12.3.tgz",
+ "integrity": "sha512-+fdndUTwBP86cVjihTp21hZHo7d6oj1c/47XPA3lL8mM0XHs+C4EkcjmgRsFnFk2f/UONGDy9ItBItmXsLOQHw==",
"license": "MIT",
"engines": {
"node": ">=22.19.0"
diff --git a/package.json b/package.json
index ca46c73..510c44e 100644
--- a/package.json
+++ b/package.json
@@ -40,6 +40,7 @@
"wrangler": "^4.141.0"
},
"overrides": {
- "sharp": "^0.35.5"
+ "sharp": "^0.35.5",
+ "undici@7": "^7.30.1"
}
}
diff --git a/public/.well-known/funding-manifest-urls b/public/.well-known/funding-manifest-urls
new file mode 100644
index 0000000..42647b8
--- /dev/null
+++ b/public/.well-known/funding-manifest-urls
@@ -0,0 +1 @@
+https://oss.codercops.com/funding.json
diff --git a/src/components/editor/EditorApp.tsx b/src/components/editor/EditorApp.tsx
index 34df60a..4f5e61f 100644
--- a/src/components/editor/EditorApp.tsx
+++ b/src/components/editor/EditorApp.tsx
@@ -236,7 +236,7 @@ function EditorAppInner({ initialCategory }: EditorAppProps) {
{currentTemplate.name}