diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..3d25f07 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,14 @@ +node_modules +.git +tests +benchmarks +docs/analysis +docs/superpowers +docs/site/node_modules +docs/site/dist +docs/site/.astro +*.md +!docs/site/**/*.md +!docs/site/**/*.mdx +.dockerignore +Dockerfile diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..80c4b7f --- /dev/null +++ b/.editorconfig @@ -0,0 +1,15 @@ +root = true + +[*] +indent_style = space +indent_size = 2 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +[*.md] +trim_trailing_whitespace = false + +[Dockerfile] +indent_style = tab diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..391fe42 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,174 @@ +name: CI + +on: + push: + branches: [main, dev] + tags: ["v*"] + pull_request: + branches: [main, dev] + +jobs: + check: + name: Lint, Type-check & Test + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v6 + + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Download fonts + run: bun run fonts:download + + - name: Lint + run: bun run lint + + - name: Type-check + run: bun run type-check + + - name: Test + run: bun run test + env: + AUTH_ENABLED: "false" + + publish-sdk-github: + name: Publish SDK to GitHub Packages + runs-on: ubuntu-latest + needs: check + if: github.ref == 'refs/heads/dev' && github.event_name == 'push' + permissions: + contents: read + packages: write + defaults: + run: + working-directory: sdk + + steps: + - uses: actions/checkout@v6 + + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - uses: actions/setup-node@v6 + with: + node-version: 24 + registry-url: https://npm.pkg.github.com + scope: '@atypical-consulting' + + - name: Install dependencies + run: bun install + + - name: Build SDK + run: bun run build + + - name: Check if version is already published + id: version-check + run: | + PACKAGE_NAME=$(node -p "require('./package.json').name") + PACKAGE_VERSION=$(node -p "require('./package.json').version") + if npm view "${PACKAGE_NAME}@${PACKAGE_VERSION}" version --registry https://npm.pkg.github.com 2>/dev/null; then + echo "published=true" >> "$GITHUB_OUTPUT" + echo "Version ${PACKAGE_VERSION} already published — skipping" + else + echo "published=false" >> "$GITHUB_OUTPUT" + echo "Version ${PACKAGE_VERSION} not yet published — will publish" + fi + env: + NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Publish to GitHub Packages + if: steps.version-check.outputs.published == 'false' + run: npm publish --registry https://npm.pkg.github.com + env: + NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + publish-sdk-npm: + name: Publish SDK to npm registry + runs-on: ubuntu-latest + needs: check + if: startsWith(github.ref, 'refs/tags/v') && github.event_name == 'push' + permissions: + contents: read + defaults: + run: + working-directory: sdk + + steps: + - uses: actions/checkout@v6 + + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - uses: actions/setup-node@v6 + with: + node-version: 24 + registry-url: https://registry.npmjs.org + scope: '@atypical-consulting' + + - name: Install dependencies + run: bun install + + - name: Build SDK + run: bun run build + + - name: Check if version is already published + id: version-check + run: | + PACKAGE_NAME=$(node -p "require('./package.json').name") + PACKAGE_VERSION=$(node -p "require('./package.json').version") + if npm view "${PACKAGE_NAME}@${PACKAGE_VERSION}" version 2>/dev/null; then + echo "published=true" >> "$GITHUB_OUTPUT" + echo "Version ${PACKAGE_VERSION} already published — skipping" + else + echo "published=false" >> "$GITHUB_OUTPUT" + echo "Version ${PACKAGE_VERSION} not yet published — will publish" + fi + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + + - name: Publish to npm + if: steps.version-check.outputs.published == 'false' + run: npm publish + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + + deploy-staging: + name: Deploy to Fly.io (Staging) + runs-on: ubuntu-latest + needs: check + if: github.ref == 'refs/heads/dev' && github.event_name == 'push' + environment: staging + + steps: + - uses: actions/checkout@v6 + + - uses: superfly/flyctl-actions/setup-flyctl@master + + - name: Deploy to staging + run: flyctl deploy --remote-only --config fly.staging.toml + env: + FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN_STAGING }} + + deploy-production: + name: Deploy to Fly.io (Production) + runs-on: ubuntu-latest + needs: check + if: startsWith(github.ref, 'refs/tags/v') && github.event_name == 'push' + environment: production + + steps: + - uses: actions/checkout@v6 + + - uses: superfly/flyctl-actions/setup-flyctl@master + + - name: Deploy to production + run: flyctl deploy --remote-only --config fly.toml + env: + FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} diff --git a/.github/workflows/reset-free-quotas.yml b/.github/workflows/reset-free-quotas.yml new file mode 100644 index 0000000..3a1d224 --- /dev/null +++ b/.github/workflows/reset-free-quotas.yml @@ -0,0 +1,29 @@ +name: Reset Free Tier Quotas + +on: + schedule: + # 1st of each month at 00:05 UTC + - cron: '5 0 1 * *' + workflow_dispatch: # Allow manual trigger for testing + +jobs: + reset: + runs-on: ubuntu-latest + steps: + - name: Reset free-tier quotas + run: | + response=$(curl -s -w "\n%{http_code}" -X POST \ + https://og-engine.com/admin/reset-free-quotas \ + -H "Authorization: Bearer ${{ secrets.ADMIN_CRON_SECRET }}" \ + -H "Content-Type: application/json") + + http_code=$(echo "$response" | tail -1) + body=$(echo "$response" | head -n -1) + + echo "Status: $http_code" + echo "Response: $body" + + if [ "$http_code" != "200" ]; then + echo "::error::Reset failed with status $http_code" + exit 1 + fi diff --git a/.gitignore b/.gitignore index dd5078a..02cf2df 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ node_modules/ # Build output dist/ +docs-dist/ # Astro .astro/ @@ -20,3 +21,11 @@ Thumbs.db .vscode/ *.swp *.swo + +# Benchmark raw data (large JSON files) +benchmarks/results/*.json + +# SQLite database +data/*.db +data/*.db-wal +data/*.db-shm diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..2035a33 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,430 @@ +# OG Engine — Headless Chrome Killer + +## Handoff Brief for Claude Code + +> **TL;DR:** Build a server-side image generation API powered by Pretext's text measurement engine. It replaces Puppeteer/headless Chrome for generating OG images, social cards, email banners, and dynamic visual content. ~22ms renders (text layout <1ms, [benchmarked](/benchmarks/)), zero browser dependencies. + +--- + +## 1. What the POC Proved + +We built a working browser-based prototype that demonstrates: + +- **Canvas-based text measurement** (Pretext's core principle) computes exact line breaks, heights, and overflow for any text — including CJK, Arabic, emoji — without DOM +- **~22ms renders** vs Puppeteer (~130ms warm, ~660ms cold) = **6-30x speedup** ([benchmarked](/benchmarks/)) +- **Multi-format output** (OG 1200×630, Twitter, Square, LinkedIn, Story) +- **Google Fonts integration** with dynamic loading +- **Background image compositing** with overlay controls +- **PNG export** directly from Canvas + +The POC runs entirely client-side. The production version should run **server-side as an HTTP API**. + +> **Canonical decisions:** Product decisions (pricing, auth model, feature gating) are defined in `docs/analysis/DECISIONS.md`. That file is the source of truth — all documentation and implementation must align with it. + +--- + +## 2. Product Vision + +### What it is +An API service that generates images with perfectly laid-out text. Send JSON, get back a PNG/SVG/WebP. + +### Who it's for +- **SaaS platforms** generating OG/social cards per page (blogs, docs, e-commerce) +- **Email marketing tools** generating personalized banners at scale +- **Ad tech** validating creative copy fits ad unit dimensions +- **Any product** currently running Puppeteer/Playwright to render text into images + +### Why it wins +| | Puppeteer | OG Engine | +|---|---|---| +| Render time | ~130ms (warm) / ~660ms (cold) | ~22ms ([benchmarked](/benchmarks/)) | +| Memory per render | ~200-500MB | ~10MB | +| Infrastructure | Chrome binary, Xvfb, sandboxing | Node.js process | +| Concurrency | ~5-10 per instance | ~500+ per instance | +| Cold start | ~2-5s | ~50ms | +| Languages | All (full browser) | All (Pretext handles bidi, CJK, emoji, grapheme clusters) | + +--- + +## 3. Technical Architecture + +### Stack + +``` +Runtime: Bun (preferred) or Node.js 20+ +Text layout: @chenglou/pretext (npm) +Canvas: @napi-rs/canvas (for server-side Canvas API) +HTTP: Hono (lightweight, works on Bun/Node/CF Workers) +Fonts: Google Fonts downloaded + cached locally +Image output: PNG (default), WebP, SVG +Deployment: Docker → Fly.io / Railway / any container platform +``` + +### Why these choices + +- **@chenglou/pretext** — the core engine. Use `prepare()` + `layout()` for height-only checks, `prepareWithSegments()` + `layoutWithLines()` when we need actual line content for rendering +- **@napi-rs/canvas** — Node-compatible Canvas API (same as browser Canvas). Fastest server-side canvas for Node/Bun. Alternative: `skia-canvas` +- **Hono** — ultra-lightweight HTTP framework, runs everywhere (Bun, Node, Cloudflare Workers, Deno) +- **Local font files** — download Google Fonts as .ttf/.woff2 and register them with the canvas. No runtime font fetching + +### Project Structure + +``` +og-engine/ +├── CLAUDE.md # This file +├── package.json +├── tsconfig.json +├── Dockerfile +├── src/ +│ ├── index.ts # HTTP server (Hono) +│ ├── api/ +│ │ ├── render.ts # POST /render endpoint +│ │ ├── validate.ts # POST /validate endpoint (text-fits-check) +│ │ └── health.ts # GET /health +│ ├── engine/ +│ │ ├── layout.ts # Pretext wrapper — text measurement & line breaking +│ │ ├── renderer.ts # Canvas rendering — composites bg, text, decorations +│ │ ├── fonts.ts # Font loading & registration +│ │ └── templates.ts # Built-in card templates +│ ├── templates/ +│ │ ├── og-default.ts # Default OG card template +│ │ ├── social-card.ts # Social media card +│ │ ├── blog-hero.ts # Blog post hero image +│ │ └── email-banner.ts +│ ├── fonts/ # Downloaded .ttf files +│ │ ├── inter/ +│ │ ├── outfit/ +│ │ ├── playfair-display/ +│ │ └── ... +│ └── utils/ +│ ├── color.ts # Color manipulation +│ ├── image.ts # Image loading/resizing +│ └── cache.ts # LRU cache for prepared text +├── tests/ +│ ├── layout.test.ts # Text measurement accuracy tests +│ ├── render.test.ts # Snapshot/visual regression tests +│ ├── api.test.ts # API endpoint tests +│ └── benchmark.ts # Performance benchmarks +└── scripts/ + └── download-fonts.ts # Script to fetch Google Fonts +``` + +--- + +## 4. API Design + +### `POST /render` + +Generate an image from text + configuration. + +**Request:** +```json +{ + "format": "og", + "template": "default", + "title": "Server-Side Text Layout Without a Browser", + "description": "Pure JS text measurement replaces Puppeteer.", + "author": "Pretext Engine", + "tag": "Open Source", + "variables": { + "read_time": "4 min read", + "category": "Engineering" + }, + "images": { + "avatar": "https://example.com/author.png" + }, + "style": { + "accent": "#38ef7d", + "layout": "left", + "font": "Outfit", + "titleSize": 48, + "descSize": 22, + "gradient": "void", + "backgroundImage": null, + "overlayOpacity": 0.65 + }, + "output": { + "format": "png", + "quality": 90 + } +} +``` + +**Response:** Binary image (PNG/WebP) with headers: +``` +Content-Type: image/png +X-Render-Time-Ms: 2.34 +X-Title-Lines: 2 +X-Desc-Lines: 3 +X-Layout-Overflow: false +``` + +### `POST /validate` + +Check if text fits a given layout WITHOUT generating an image. Ultra-fast. + +**Request:** +```json +{ + "format": "og", + "title": "Some headline", + "description": "Some body text", + "font": "Outfit", + "titleSize": 48, + "descSize": 22, + "maxTitleLines": 3, + "maxDescLines": 4 +} +``` + +**Response:** +```json +{ + "fits": true, + "title": { "lines": 2, "maxLines": 3, "overflow": false }, + "description": { "lines": 3, "maxLines": 4, "overflow": false }, + "computeTimeMs": 0.12 +} +``` + +### `POST /render/from-url` + +Zero-config image generation — fetches OG tags from a URL and renders a card automatically. + +**Request:** +```json +{ + "url": "https://myblog.com/posts/my-article", + "format": "og", + "style": { "gradient": "deep-sea" }, + "overrides": { "tag": "New Post" } +} +``` + +**Response:** Binary image (PNG/WebP), same headers as `POST /render`. + +### `POST /render/batch` + +Render multiple images in one request (for bulk generation). + +**Request:** +```json +{ + "items": [ + { "format": "og", "title": "Post 1", ... }, + { "format": "twitter", "title": "Post 2", ... } + ] +} +``` + +**Response:** Multipart or ZIP archive of images. + +### `GET /health` + +```json +{ + "status": "ok", + "fonts": ["Outfit", "Playfair Display", "Sora", ...], + "formats": ["og", "twitter", "square", "linkedin", "story"], + "templates": ["default", "social-card", "blog-hero", "email-banner", "product-card", "event", "testimonial", "github-repo", "news-article", "pricing", "profile-card", "announcement"], + "version": "0.1.0" +} +``` + +--- + +## 5. Engine Implementation Notes + +### Text Layout (layout.ts) + +```typescript +import { prepareWithSegments, layoutWithLines } from '@chenglou/pretext' + +// Wrapper that returns lines + overflow info for a text block +export function layoutText(text: string, font: string, maxWidth: number, lineHeight: number, maxLines: number) { + const prepared = prepareWithSegments(text, font) + const { lines, height, lineCount } = layoutWithLines(prepared, maxWidth, lineHeight) + + const visibleLines = lines.slice(0, maxLines) + const overflow = lineCount > maxLines + + // Add ellipsis to last visible line if overflow + if (overflow && visibleLines.length > 0) { + const last = visibleLines[visibleLines.length - 1] + visibleLines[visibleLines.length - 1] = { + ...last, + text: last.text + '…' + } + } + + return { visibleLines, totalLines: lineCount, overflow, height } +} +``` + +### Font Registration (fonts.ts) + +```typescript +import { GlobalFonts } from '@napi-rs/canvas' +import { readdir } from 'fs/promises' + +export async function registerFonts(fontsDir: string) { + // Register all .ttf files in the fonts directory + // GlobalFonts.registerFromPath(path, familyName) + // Build a map of available fonts for validation +} +``` + +### Caching Strategy + +- **Font preparation cache:** Pretext's `prepare()` results can be cached per (text, font) pair using an LRU cache. This avoids re-segmenting identical strings. +- **Image cache:** Optional Redis/memory cache keyed on request hash. Most OG images are requested repeatedly (every social share hits the same URL). + +--- + +## 6. Templates System + +Templates are functions that take structured content + style and return Canvas drawing instructions: + +```typescript +interface TemplateInput { + canvas: Canvas + ctx: CanvasRenderingContext2D + width: number + height: number + content: { title: string; description?: string; author?: string; tag?: string } + style: { accent: string; font: string; layout: string; titleSize: number; descSize: number } + backgroundImage?: Image | null + overlayOpacity?: number +} + +type Template = (input: TemplateInput) => RenderResult +``` + +12 templates are available: +1. **default** — accent bar, grid background, tag pill (from POC) +2. **social-card** — large centered title, minimal +3. **blog-hero** — background image focused, text overlay at bottom +4. **email-banner** — horizontal, CTA-style +5. **product-card** — product name, price, and image highlight +6. **event** — date, venue, and event title prominent +7. **testimonial** — quote, author, and avatar layout +8. **github-repo** — repo name, description, and stats +9. **news-article** — publication, headline, and category badge +10. **pricing** — plan name, price, and key features +11. **profile-card** — avatar, name, title, and social handles +12. **announcement** — large headline with accent, ideal for launches + +--- + +## 7. Build Priorities + +### Phase 1 — Core API (week 1) +- [ ] Project setup (Bun + Hono + TypeScript) +- [ ] Font downloading script + registration with @napi-rs/canvas +- [ ] Pretext integration for text measurement +- [ ] Canvas renderer for the default template +- [ ] `POST /render` endpoint returning PNG +- [ ] `POST /validate` endpoint +- [ ] `GET /health` endpoint +- [ ] Basic tests + benchmark script +- [ ] Dockerfile + +### Phase 2 — Production Features (week 2) +- [ ] All 12 templates +- [ ] `variables` and `images` fields for template-level dynamic content +- [ ] `POST /render/from-url` endpoint +- [ ] Background image upload support (multipart form) +- [ ] WebP output option +- [ ] LRU cache for prepared text +- [ ] Request validation with Zod +- [ ] Error handling + structured error responses +- [ ] Rate limiting +- [ ] CORS configuration +- [ ] Batch endpoint + +### Phase 3 — Scale & Polish (week 3) +- [ ] Redis cache layer for rendered images +- [ ] API key authentication +- [ ] Usage tracking / metering +- [ ] OpenAPI/Swagger documentation +- [ ] SDK (TypeScript client library) +- [ ] Deploy to Fly.io with auto-scaling +- [ ] Landing page + +### Phase 4 — Growth (future) +- [ ] Custom template builder (JSON DSL) +- [ ] Webhook triggers (auto-regenerate on content update) +- [ ] Edge deployment (Cloudflare Workers — may need alternative to @napi-rs/canvas) +- [ ] AI text fitting: auto-adjust font size to fit constraints +- [ ] PDF output + +--- + +## 8. Key Decisions to Make + +1. **@napi-rs/canvas vs skia-canvas vs node-canvas?** + → Recommend @napi-rs/canvas for performance. Benchmark all three. + +2. **Bun vs Node?** + → Bun preferred for speed + native TypeScript. Ensure @napi-rs/canvas works in Bun (has native addon support since 1.0). + +3. **Pricing model?** + → Free tier: 500 renders/month. Starter: €10/mo for 10k. Pro: €39/mo for 50k. Scale: €99/mo for 200k. See docs/analysis/DECISIONS.md for canonical pricing. + +4. **Monorepo or separate repos?** + → Start monorepo: API + landing page + SDK in one repo. + +--- + +## 9. Reference + +- **Pretext repo:** https://github.com/chenglou/pretext +- **Pretext npm:** `@chenglou/pretext` +- **@napi-rs/canvas:** https://github.com/nicknisi/canvas +- **Hono:** https://hono.dev +- **POC code:** See the og-engine.jsx artifact from this conversation for the rendering logic — it's directly portable to server-side Canvas + +--- + +## 10. First Command for Claude Code + +```bash +mkdir og-engine && cd og-engine +bun init -y +bun add @chenglou/pretext @napi-rs/canvas hono zod +bun add -d typescript @types/node vitest +``` + +Then: implement `src/index.ts` with the Hono server, `src/engine/layout.ts` with Pretext integration, and `src/engine/renderer.ts` with the Canvas rendering logic ported from the POC. + +--- + +## 11. Release Checklist (License) + +Every time a release tag is pushed, append a row to +[`LICENSE-HISTORY.md`](./LICENSE-HISTORY.md): + +``` +| | | | +``` + +This is required so that the FSL Change Date for each release is +discoverable. Without it, the license conversion clause is legally vague. + +### When cutting v0.1.0 (the first release) + +Update the existing `0.1.0` row **in place** — do not add a new row. Replace +both `TBD` cells with the real release date and `release date + 2 years`. +After this update, the table should have exactly one row. + +### When cutting any subsequent release (v0.2.0, v0.3.0, ...) + +Append a new row below the existing ones. Example for a release cut on +2026-06-01: + +``` +| 0.2.0 | 2026-06-01 | 2028-06-01 | +``` + +Never modify an already-published release's row — the Change Date is a +legal commitment tied to the release date and must not move. diff --git a/COMMERCIAL-LICENSE.md b/COMMERCIAL-LICENSE.md new file mode 100644 index 0000000..d741d8b --- /dev/null +++ b/COMMERCIAL-LICENSE.md @@ -0,0 +1,44 @@ +# Commercial License + +OG Engine's server is free to use, modify, and self-host for most purposes +under the [Functional Source License](./LICENSE). **You need a commercial +license only if:** + +- You host OG Engine as a service that your own users call (even + internally-marketed, even free-to-your-users). +- You embed OG Engine's server code inside a product you sell, license, or + distribute. +- You operate a hosted OG-image-generation service that competes with OG + Engine's own hosted API. + +**You do NOT need a commercial license if:** + +- You're calling OG Engine's hosted API at `api.og-engine.com` — that's what + your subscription plan at covers. +- You're a developer using it for personal projects, side projects, or your + own learning. +- You're an open-source project self-hosting it as part of your own OSS + stack. +- You're a company using it purely internally — e.g. rendering OG images for + your own marketing site — without exposing it to your users or customers + as a feature. + +## I just want to call the hosted API + +You're in the right place: . No commercial +license needed — your plan covers it. + +## I need to self-host or embed + +Email **philippe@atypical.consulting** with: + +- Your company name +- A one-line description of how you plan to use OG Engine +- Expected render volume per month + +We'll get back to you within 2 business days with terms. + +## Not sure which side of the line you're on? + +Email **philippe@atypical.consulting** with your use case. We'll tell you +for free. No gotchas. diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md new file mode 100644 index 0000000..0c2cd62 --- /dev/null +++ b/DEPLOYMENT.md @@ -0,0 +1,273 @@ +# Deployment Guide + +## Environments + +| Environment | App | Trigger | Config | +|-------------|-----|---------|--------| +| Staging | `og-engine-staging` | Push to `dev` branch (via CI) | `fly.staging.toml` | +| Production | `og-engine` | Push a version tag `v*` (via CI) | `fly.toml` | + +> **Key rule:** Merging to `dev` deploys automatically to staging only. Production requires a deliberate version tag (`git tag v1.2.3 && git push --tags`). + +--- + +## Branch Protection + +Configure the following rules in **GitHub → Settings → Branches** for the `dev` branch: + +- Require pull request before merging (1 approval) +- Require status checks to pass: **CI / Lint, Type-check & Test** +- Require branches to be up to date before merging +- Do not allow bypassing the above settings + +This ensures no direct pushes reach `dev` without a passing CI build and peer review. + +--- + +## Prerequisites + +- [Fly.io CLI](https://fly.io/docs/hands-on/install-flyctl/) installed and authenticated +- A Fly.io Tigris object storage bucket (S3-compatible) + +## Initial Setup + +### 1. Create the Tigris Bucket + +```bash +fly storage create +``` + +Note the bucket name and credentials output. You'll need them in the next step. + +### 2. Set Required Secrets + +```bash +# Core secrets +fly secrets set STRIPE_SECRET_KEY=sk_live_... +fly secrets set STRIPE_WEBHOOK_SECRET=whsec_... +fly secrets set STRIPE_PRICE_STARTER=price_... +fly secrets set STRIPE_PRICE_PRO=price_... +fly secrets set STRIPE_PRICE_SCALE=price_... +fly secrets set RESEND_API_KEY=re_... + +# Admin cron authentication (generate a random secret) +fly secrets set ADMIN_CRON_SECRET=$(openssl rand -hex 32) + +# Admin email for backup failure alerts +fly secrets set ADMIN_EMAIL=admin@example.com + +# Tigris / S3 object storage (from `fly storage create` output) +fly secrets set TIGRIS_ACCESS_KEY_ID=tid_... +fly secrets set TIGRIS_SECRET_ACCESS_KEY=tsec_... +fly secrets set TIGRIS_BUCKET_NAME=your-bucket-name +# TIGRIS_ENDPOINT_URL defaults to https://fly.storage.tigris.dev +# TIGRIS_REGION defaults to auto +``` + +### 3. Set GitHub Actions Secrets + +In your GitHub repository → Settings → Secrets and variables → Actions, add: + +| Secret | Description | +|--------|-------------| +| `ADMIN_CRON_SECRET` | Same value as the Fly.io secret | +| `RESEND_API_KEY` | Same Resend API key | +| `ADMIN_EMAIL` | Admin email for failure alerts | + +### 4. Deploy + +```bash +fly deploy +``` + +--- + +## Staging Environment Setup + +Run these steps once to bootstrap the `og-engine-staging` Fly.io app. + +### 1. Create the app + +```bash +fly apps create og-engine-staging +``` + +### 2. Create a persistent volume (same region as production) + +```bash +fly volumes create og_engine_staging_data --region cdg --size 1 -a og-engine-staging +``` + +### 3. Set staging secrets + +Use **test-mode** Stripe keys and a separate Tigris bucket: + +```bash +fly secrets set -a og-engine-staging \ + STRIPE_SECRET_KEY=sk_test_... \ + STRIPE_WEBHOOK_SECRET=whsec_test_... \ + STRIPE_PRICE_STARTER=price_test_... \ + STRIPE_PRICE_PRO=price_test_... \ + STRIPE_PRICE_SCALE=price_test_... \ + RESEND_API_KEY=re_... \ + ADMIN_CRON_SECRET=$(openssl rand -hex 32) \ + ADMIN_EMAIL=admin@example.com \ + TIGRIS_ACCESS_KEY_ID=tid_... \ + TIGRIS_SECRET_ACCESS_KEY=tsec_... \ + TIGRIS_BUCKET_NAME=og-engine-staging-backups +``` + +### 4. Add GitHub Actions secret + +In **GitHub → Settings → Secrets → Actions**, add: + +| Secret | Description | +|--------|-------------| +| `FLY_API_TOKEN_STAGING` | Fly.io API token scoped to the staging app | + +> The production `FLY_API_TOKEN` secret already exists. The staging workflow uses the separate `FLY_API_TOKEN_STAGING` secret to limit blast radius. + +### 5. Initial deploy + +```bash +fly deploy --config fly.staging.toml +``` + +After this first manual deploy, all subsequent deploys happen automatically via CI on every push to `dev`. + +--- + +## Releasing to Production + +1. Ensure all desired changes are merged to `dev` and staging is healthy. +2. Tag the commit: + +```bash +git checkout dev +git pull +git tag v1.2.3 +git push origin v1.2.3 +``` + +3. CI runs `check` and then `deploy-production`, deploying to `og-engine` on Fly.io. + +--- + +## Database Backup Strategy + +OG Engine uses SQLite on a Fly.io persistent volume (`/data/og-engine.db`). +A daily automated backup job copies the database to Tigris object storage. + +### How It Works + +1. **GitHub Actions** triggers a `POST /admin/backup-db` request daily at **02:00 UTC**. +2. The app runs `VACUUM INTO '/tmp/backup-.db'` — an online, WAL-safe SQLite snapshot. +3. The snapshot is uploaded to Tigris under the key `backups/og-engine-.db`. +4. Backups older than **7 days** are automatically pruned. +5. If the backup fails, an alert email is sent via Resend to `ADMIN_EMAIL`. + +### Manual Trigger + +```bash +curl -X POST https://og-engine.com/admin/backup-db \ + -H "Authorization: Bearer $ADMIN_CRON_SECRET" \ + -H "Content-Type: application/json" +``` + +Expected response: +```json +{ + "success": true, + "key": "backups/og-engine-2026-04-16T02-00-00.db", + "sizeBytes": 1048576, + "pruned": [], + "timestamp": "2026-04-16T02:00:05.123Z" +} +``` + +### List Available Backups + +Using the AWS CLI (works with Tigris): + +```bash +AWS_ACCESS_KEY_ID=$TIGRIS_ACCESS_KEY_ID \ +AWS_SECRET_ACCESS_KEY=$TIGRIS_SECRET_ACCESS_KEY \ +aws s3 ls s3://$TIGRIS_BUCKET_NAME/backups/ \ + --endpoint-url https://fly.storage.tigris.dev +``` + +--- + +## Restore Procedure + +### Step 1 — Download a Backup + +```bash +# List backups to find the target +AWS_ACCESS_KEY_ID=$TIGRIS_ACCESS_KEY_ID \ +AWS_SECRET_ACCESS_KEY=$TIGRIS_SECRET_ACCESS_KEY \ +aws s3 ls s3://$TIGRIS_BUCKET_NAME/backups/ \ + --endpoint-url https://fly.storage.tigris.dev + +# Download the desired backup +AWS_ACCESS_KEY_ID=$TIGRIS_ACCESS_KEY_ID \ +AWS_SECRET_ACCESS_KEY=$TIGRIS_SECRET_ACCESS_KEY \ +aws s3 cp s3://$TIGRIS_BUCKET_NAME/backups/og-engine-2026-04-16T02-00-00.db \ + ./restored.db \ + --endpoint-url https://fly.storage.tigris.dev +``` + +### Step 2 — Verify Integrity + +```bash +sqlite3 ./restored.db "PRAGMA integrity_check;" +# Expected output: ok + +sqlite3 ./restored.db "SELECT COUNT(*) FROM users;" +# Verify row count looks reasonable +``` + +### Step 3 — Stop the Running Machine + +```bash +fly machine list +fly machine stop +``` + +### Step 4 — Copy the Backup onto the Volume + +```bash +# Open an SSH session and copy the file in +fly sftp shell +# Inside the SFTP shell: +put restored.db /data/og-engine.db +``` + +Alternatively, using `fly ssh console`: + +```bash +fly ssh console -C "cat > /data/og-engine.db" < ./restored.db +``` + +### Step 5 — Restart the Machine + +```bash +fly machine start +# Wait for health checks to pass +fly status +``` + +### Step 6 — Verify the App is Healthy + +```bash +curl https://og-engine.com/health +``` + +--- + +## Monitoring + +- **Backup history**: check the `Daily DB Backup` workflow in GitHub Actions. +- **Failure alerts**: an email is sent to `ADMIN_EMAIL` on any backup failure. +- **Volume health**: `fly volumes list` shows volume state. +- **Machine health**: `fly status` shows machine state and health check results. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..4f77ae4 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,45 @@ +FROM oven/bun:1 AS base +WORKDIR /app + +# Install dependencies (better-sqlite3 needs python3 + build tools) +FROM base AS deps +RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/* +COPY package.json bun.lock* ./ +RUN bun install --frozen-lockfile --production --ignore-scripts && \ + bunx node-gyp rebuild --release --directory=node_modules/better-sqlite3 + +# Build docs site (Astro + Starlight) +FROM base AS docs +WORKDIR /app/docs/site +COPY docs/site/package.json docs/site/bun.lock* ./ +RUN bun install --frozen-lockfile +COPY docs/site/ ./ +# AvailableFontsTable.tsx imports from ../../../../src/engine/font-catalog +COPY src/engine /app/src/engine +RUN bun run build + +# Runner stage — no build tools needed +FROM base AS runner +WORKDIR /app + +COPY --from=deps /app/node_modules ./node_modules +COPY package.json ./ +COPY tsconfig.json ./ +COPY src/ ./src/ +COPY scripts/ ./scripts/ +RUN bun run scripts/download-fonts.ts + +# Copy Astro build output +COPY --from=docs /app/docs/site/dist ./docs-dist + +# Create data directory for SQLite +RUN mkdir -p /data + +ENV NODE_ENV=production +ENV PORT=3000 +ENV DATABASE_URL=file:/data/og-engine.db +ENV AUTH_ENABLED=true + +EXPOSE 3000 + +CMD ["bun", "run", "src/index.ts"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..c65666d --- /dev/null +++ b/LICENSE @@ -0,0 +1,105 @@ +# Functional Source License, Version 1.1, ALv2 Future License + +## Abbreviation + +FSL-1.1-ALv2 + +## Notice + +Copyright 2026 Atypical Consulting SRL + +## Terms and Conditions + +### Licensor ("We") + +The party offering the Software under these Terms and Conditions. + +### The Software + +The "Software" is each version of the software that we make available under +these Terms and Conditions, as indicated by our inclusion of these Terms and +Conditions with the Software. + +### License Grant + +Subject to your compliance with this License Grant and the Patents, +Redistribution and Trademark clauses below, we hereby grant you the right to +use, copy, modify, create derivative works, publicly perform, publicly display +and redistribute the Software for any Permitted Purpose identified below. + +### Permitted Purpose + +A Permitted Purpose is any purpose other than a Competing Use. A Competing Use +means making the Software available to others in a commercial product or +service that: + +1. substitutes for the Software; + +2. substitutes for any other product or service we offer using the Software + that exists as of the date we make the Software available; or + +3. offers the same or substantially similar functionality as the Software. + +Permitted Purposes specifically include using the Software: + +1. for your internal use and access; + +2. for non-commercial education; + +3. for non-commercial research; and + +4. in connection with professional services that you provide to a licensee + using the Software in accordance with these Terms and Conditions. + +### Patents + +To the extent your use for a Permitted Purpose would necessarily infringe our +patents, the license grant above includes a license under our patents. If you +make a claim against any party that the Software infringes or contributes to +the infringement of any patent, then your patent license to the Software ends +immediately. + +### Redistribution + +The Terms and Conditions apply to all copies, modifications and derivatives of +the Software. + +If you redistribute any copies, modifications or derivatives of the Software, +you must include a copy of or a link to these Terms and Conditions and not +remove any copyright notices provided in or with the Software. + +### Disclaimer + +THE SOFTWARE IS PROVIDED "AS IS" AND WITHOUT WARRANTIES OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING WITHOUT LIMITATION WARRANTIES OF FITNESS FOR A PARTICULAR +PURPOSE, MERCHANTABILITY, TITLE OR NON-INFRINGEMENT. + +IN NO EVENT WILL WE HAVE ANY LIABILITY TO YOU ARISING OUT OF OR RELATED TO THE +SOFTWARE, INCLUDING INDIRECT, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES, +EVEN IF WE HAVE BEEN INFORMED OF THEIR POSSIBILITY IN ADVANCE. + +### Trademarks + +Except for displaying the License Details and identifying us as the origin of +the Software, you have no right under these Terms and Conditions to use our +trademarks, trade names, service marks or product names. + +## Grant of Future License + +We hereby irrevocably grant you an additional license to use the Software under +the Apache License, Version 2.0 that is effective on the second anniversary of +the date we make the Software available. On or after that date, you may use the +Software under the Apache License, Version 2.0, in which case the following +will apply: + +Licensed under the Apache License, Version 2.0 (the "License"); you may not use +this file except in compliance with the License. + +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed +under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR +CONDITIONS OF ANY KIND, either express or implied. See the License for the +specific language governing permissions and limitations under the License. diff --git a/LICENSE-APACHE-2.0 b/LICENSE-APACHE-2.0 new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/LICENSE-APACHE-2.0 @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/LICENSE-HISTORY.md b/LICENSE-HISTORY.md new file mode 100644 index 0000000..e6cc9cd --- /dev/null +++ b/LICENSE-HISTORY.md @@ -0,0 +1,20 @@ +# License History + +Every release of OG Engine ships under +[FSL-1.1-Apache-2.0](./LICENSE) and automatically converts to +[Apache License 2.0](./LICENSE-APACHE-2.0) **two years after its release +date**. This table records the converted-on date for each release. + +| Version | Release Date | Converts to Apache-2.0 on | +|---------|--------------|---------------------------| +| 0.1.0 | 2026-04-16 | 2028-04-16 | + +## How this gets updated + +When a release is cut, append a new row with: + +- `Version` — the semver tag (e.g. `0.2.0`) +- `Release Date` — the date the release tag was pushed, as `YYYY-MM-DD` +- `Converts to Apache-2.0 on` — `Release Date + 2 years`, as `YYYY-MM-DD` + +See `CLAUDE.md` release checklist. diff --git a/README.md b/README.md new file mode 100644 index 0000000..b8e35ec --- /dev/null +++ b/README.md @@ -0,0 +1,471 @@ +

+ Render Time ~22ms + Memory 10MB + Zero Browser Dependencies +

+ +

OG Engine

+ +

+ Generate OG images in milliseconds. No browser. No Puppeteer. No BS. +

+ +

+ Server-side image generation API powered by Canvas-based text measurement.
+ Drop-in replacement for Puppeteer/Playwright image pipelines. +

+ +

+ Quick Start • + API Reference • + Self-Host • + Benchmarks • + Templates • + Roadmap +

+ +--- + +## Why OG Engine? + +Every time you generate an OG image with Puppeteer, you're spinning up a **full Chromium instance** to render some text on a rectangle. That's 500MB of RAM and ~129ms (warm) to ~658ms (cold) of latency — for a PNG. + +OG Engine measures text and renders images using server-side Canvas. No DOM, no browser, no headless anything. + +| | Puppeteer | **OG Engine** | +|---|---|---| +| Render time | ~129ms (warm) / ~658ms (cold) | **~22ms** | +| Memory per render | ~200-500MB | **~10MB** | +| Infrastructure | Chrome binary, Xvfb, sandboxing | **Node.js process** | +| Concurrency | ~5-10 per instance | **~500+ per instance** | +| Cold start | ~2-5s | **~50ms** | +| CJK / Arabic / Emoji | Yes (full browser) | **Yes** (native Unicode support) | + +## Quick Start + +### One-liner + +```bash +curl -X POST http://localhost:3000/render \ + -H "Content-Type: application/json" \ + -d '{"title": "Hello, OG Engine", "format": "og"}' \ + --output card.png +``` + +### Run locally + +```bash +# Clone & install +git clone https://github.com/Atypical-Consulting/og-engine.git +cd og-engine +bun install + +# Download fonts (required on first run) +bun run fonts:download + +# Start the server +bun run dev +# → Server running at http://localhost:3000 +``` + +### With Docker + +```bash +docker build -t og-engine . +docker run -p 3000:3000 og-engine +``` + +## API Reference + +### `POST /render` + +Generate an image from text + configuration. + +```json +{ + "format": "og", + "title": "Server-Side Text Layout Without a Browser", + "description": "Pure JS text measurement replaces Puppeteer.", + "author": "OG Engine", + "tag": "Open Source", + "variables": { + "read_time": "4 min read", + "category": "Engineering" + }, + "images": { + "avatar": "https://example.com/author.png" + }, + "style": { + "accent": "#38ef7d", + "layout": "left", + "font": "Outfit", + "titleSize": 48, + "descSize": 22, + "gradient": "void", + "overlayOpacity": 0.65 + }, + "output": { + "format": "png", + "quality": 90 + } +} +``` + +**Response:** Binary PNG with performance headers: + +``` +Content-Type: image/png +X-Render-Time-Ms: 2.34 +X-Title-Lines: 2 +X-Desc-Lines: 3 +X-Layout-Overflow: false +``` + +### `POST /validate` + +Check if text fits without generating an image. Ultra-fast. + +```json +{ + "format": "og", + "title": "Some headline", + "description": "Some body text", + "font": "Outfit", + "titleSize": 48, + "maxTitleLines": 3 +} +``` + +```json +{ + "fits": true, + "title": { "lines": 2, "maxLines": 3, "overflow": false }, + "description": { "lines": 1, "maxLines": 4, "overflow": false }, + "computeTimeMs": 0.12 +} +``` + +### `POST /render/from-url` + +Zero-config image generation — OG Engine fetches the page at `url`, extracts its Open Graph tags, and renders a card automatically. + +```json +{ + "url": "https://myblog.com/posts/my-article", + "format": "og", + "style": { "gradient": "deep-sea" } +} +``` + +Optional `overrides` lets you override specific fields (e.g. `tag`) while keeping the scraped title and description. + +### `GET /health` + +```json +{ + "status": "ok", + "fonts": ["Outfit", "Inter", "Playfair Display", "Sora", "Space Grotesk", "JetBrains Mono", "Noto Sans JP", "Noto Sans AR"], + "formats": ["og", "twitter", "square", "linkedin", "story"], + "templates": ["default", "social-card", "blog-hero", "email-banner", "product-card", "event", "testimonial", "github-repo", "news-article", "pricing", "profile-card", "announcement"], + "version": "0.1.0" +} +``` + +## Formats + +| Format | Dimensions | Use case | +|--------|-----------|----------| +| `og` | 1200 × 630 | Open Graph / Facebook | +| `twitter` | 1200 × 675 | Twitter/X cards | +| `square` | 1080 × 1080 | Instagram / general social | +| `linkedin` | 1200 × 627 | LinkedIn posts | +| `story` | 1080 × 1920 | Instagram/TikTok stories | + +## Fonts + +8 fonts included out of the box, with full Unicode coverage: + +| Font | Weights | Script support | +|------|---------|---------------| +| Outfit | 400, 700, 800 | Latin | +| Inter | 400, 700, 800 | Latin | +| Playfair Display | 400, 700, 800 | Latin | +| Sora | 400, 700, 800 | Latin | +| Space Grotesk | 400, 700 | Latin | +| JetBrains Mono | 400, 700 | Latin (monospace) | +| Noto Sans JP | 400, 700 | Japanese / CJK | +| Noto Sans Arabic | 400, 700 | Arabic / RTL | + +## Style Options + +**6 built-in gradients:** `void` `deep-sea` `ember` `forest` `plum` `slate` + +**3 layout modes:** `left` `center` `bottom` + +**Full control over:** accent color, font, title/description size, overlay opacity, tag pill, author line. + +## Templates + +| Template | Description | +|----------|------------| +| `default` | Accent bar, grid background, tag pill — the classic OG card | +| `social-card` | Large centered title, minimal and clean | +| `blog-hero` | Background image focused with text overlay | +| `email-banner` | Horizontal CTA-style for email campaigns | +| `product-card` | Product name, price, and image highlight | +| `event` | Date, venue, and event title prominent | +| `testimonial` | Quote, author, and avatar layout | +| `github-repo` | Repo name, description, and stats | +| `news-article` | Publication, headline, and category badge | +| `pricing` | Plan name, price, and key features | +| `profile-card` | Avatar, name, title, and social handles | +| `announcement` | Large headline with accent, ideal for launches | + +## Integration Examples + +### Next.js App Router + +```typescript +// app/api/og/[slug]/route.ts +export async function GET(req: Request, { params }: { params: { slug: string } }) { + const post = await getPost(params.slug) + + const res = await fetch('http://localhost:3000/render', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + format: 'og', + title: post.title, + description: post.excerpt, + tag: post.category, + style: { accent: '#38ef7d', font: 'Outfit' } + }) + }) + + return new Response(res.body, { + headers: { 'Content-Type': 'image/png', 'Cache-Control': 'public, max-age=86400' } + }) +} +``` + +### Node.js / Express + +```typescript +import express from 'express' + +const app = express() + +app.get('/og/:slug', async (req, res) => { + const image = await fetch('http://localhost:3000/render', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + format: 'og', + title: `My Blog — ${req.params.slug}`, + style: { gradient: 'deep-sea' } + }) + }) + + res.set('Content-Type', 'image/png') + res.send(Buffer.from(await image.arrayBuffer())) +}) +``` + +### cURL + +```bash +# Generate an OG image +curl -X POST http://localhost:3000/render \ + -H "Content-Type: application/json" \ + -d '{ + "title": "How We Killed Puppeteer", + "description": "And saved 500MB of RAM per render.", + "format": "og", + "tag": "Engineering", + "style": { "accent": "#ff6b6b", "gradient": "ember", "font": "Playfair Display" } + }' \ + --output og-card.png + +# Check if text fits before rendering +curl -X POST http://localhost:3000/validate \ + -H "Content-Type: application/json" \ + -d '{"title": "Will this headline fit?", "format": "og", "titleSize": 48}' +``` + +## Benchmarks + +Measured on Apple M2, 8 cores, 24 GB RAM, Bun 1.3.11. 1,000 iterations per scenario with 50 warmup (discarded). Full report: [`benchmarks/results/2026-04-03-report.md`](benchmarks/results/2026-04-03-report.md). + +### OG Engine Results + +| Scenario | Text Measure (P50) | Canvas Draw (P50) | PNG Encode (P50) | Full Pipeline (P50) | Full Pipeline (P95) | +|---|---|---|---|---|---| +| Baseline (og, 1 line, Outfit) | 114µs | 50µs | 21.39ms | **21.57ms** | 22.79ms | +| Long text (og, overflow, Outfit) | 390µs | 78µs | 24.34ms | **24.83ms** | 26.41ms | +| Story format (1080×1920, Outfit) | 426µs | 98µs | 59.37ms | **59.96ms** | 65.02ms | +| CJK (og, Noto Sans JP) | 126µs | 79µs | 24.12ms | **24.34ms** | 26.92ms | + +### vs Puppeteer + +| Scenario | OG Engine (P50) | Puppeteer Warm (P50) | Puppeteer Cold (P50) | Speedup (warm) | +|---|---|---|---|---| +| Baseline | **21.57ms** | 128.75ms | 657.55ms | **6x** | +| Long text | **24.83ms** | 132.14ms | 634.03ms | **5x** | + +### Run it yourself + +```bash +bun run bench # OG Engine only +bun run bench:full # Includes Puppeteer comparison +``` + +## Self-Hosting + +OG Engine is designed to self-host. It's a single Bun/Node.js process with no external dependencies. + +**Requirements:** +- Bun 1.0+ (recommended) or Node.js 20+ +- ~50MB disk (fonts + binary) +- ~10MB RAM per concurrent render + +```bash +# Production +bun run start + +# Or with Node.js +npx tsx src/index.ts +``` + +### Environment Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `PORT` | `3000` | Server port | +| `HOST` | `0.0.0.0` | Bind address | + +## Tech Stack + +| Component | Technology | Why | +|-----------|-----------|-----| +| Runtime | [Bun](https://bun.sh) | Native TypeScript, fast startup | +| HTTP | [Hono](https://hono.dev) | Ultra-light, runs everywhere | +| Canvas | [@napi-rs/canvas](https://github.com/nicknisi/canvas) | Fastest server-side Canvas for Node/Bun | +| Validation | [Zod](https://zod.dev) | Type-safe request validation | +| Fonts | Google Fonts (TTF) | Downloaded locally, zero runtime fetching | + +## Project Structure + +``` +og-engine/ +├── src/ +│ ├── index.ts # Hono server +│ ├── api/ +│ │ ├── render.ts # POST /render — image generation +│ │ ├── validate.ts # POST /validate — text fit check +│ │ └── health.ts # GET /health — discovery +│ ├── engine/ +│ │ ├── text-measure.ts # Line breaking & text measurement +│ │ ├── renderer.ts # Canvas compositing & rendering +│ │ ├── fonts.ts # Font loading & registration +│ │ ├── formats.ts # Format definitions (og, twitter, etc.) +│ │ └── gradients.ts # Gradient presets +│ └── schemas/ +│ └── request.ts # Zod request schemas +├── fonts/ # TTF files (downloaded at build time) +├── benchmarks/ # Performance benchmark suite +└── tests/ # Vitest test suite +``` + +## Roadmap + +- [x] **Phase 1 — Core API** *(complete)* + - Hono server with render, validate, health endpoints + - Canvas-based text measurement engine + - 5 formats, 6 gradients, 8 fonts + - Zod validation, CORS, error handling + - Benchmark suite with statistical analysis + +- [ ] **Phase 2 — Production Features** + - All 12 templates (social-card, blog-hero, email-banner, product-card, event, testimonial, github-repo, news-article, pricing, profile-card, announcement) + - `variables` and `images` fields for template-level dynamic content + - `POST /render/from-url` — zero-config rendering from a URL's OG tags + - Background image upload (multipart) + - WebP output + - LRU text cache, batch endpoint, rate limiting + +- [ ] **Phase 3 — Scale & Polish** + - API key authentication & usage tracking + - Redis cache layer + - OpenAPI documentation + - TypeScript SDK (npm) + - Docker + Fly.io deployment + +- [ ] **Phase 4 — Growth** + - Custom template builder (JSON DSL) + - AI text fitting (auto font-size adjustment) + - Edge deployment (Cloudflare Workers) + - PDF output, webhook triggers + +## Comparison with Alternatives + +| Feature | OG Engine | @vercel/og | Puppeteer | Cloudinary | +|---------|-----------|-----------|-----------|------------| +| Render speed | ~22ms | ~50-200ms | ~129ms (warm) / ~658ms (cold) | ~500ms | +| Self-hostable | Yes | Vercel only | Yes | No | +| No browser needed | Yes | Yes (Satori) | No | N/A | +| CJK/Arabic/Emoji | Yes | Partial | Yes | Yes | +| Multiple formats | 5 | 1 | Any | Many | +| Custom fonts | 8 built-in | Manual setup | Any | Limited | +| Text validation | Yes | No | No | No | +| Batch rendering | Planned | No | Manual | Yes | +| Open source | Yes | Yes | Yes | No | + +## Contributing + +Contributions are welcome! Here's how to get started: + +```bash +git clone https://github.com/Atypical-Consulting/og-engine.git +cd og-engine +bun install +bun run fonts:download +bun run test +bun run dev +``` + +**Areas where help is needed:** +- Additional templates and gradient presets +- Font coverage (more scripts/languages) +- Integration examples (Astro, Nuxt, SvelteKit, etc.) +- Performance optimizations in the rendering pipeline +- Visual regression test suite + +## License + +OG Engine's server (`src/` and everything at the repo root) is licensed under +the [Functional Source License, Version 1.1, Apache 2.0 Future License](./LICENSE) +(FSL-1.1-Apache-2.0). You can read, modify, and self-host it for any purpose +**except** making it available to third parties as a hosted service or +embedding it in a commercial product you distribute. Every release +automatically converts to [Apache-2.0](./LICENSE-APACHE-2.0) two years after +its release date — see [`LICENSE-HISTORY.md`](./LICENSE-HISTORY.md). + +The SDK (`sdk/`) is licensed under [Apache-2.0](./sdk/LICENSE) — use it freely +in any project, commercial or not. + +**Using OG Engine inside a commercial product or SaaS?** +See [`COMMERCIAL-LICENSE.md`](./COMMERCIAL-LICENSE.md) or email +**philippe@atypical.consulting**. + +--- + +

+ If OG Engine saves you from running Puppeteer, consider giving it a star.
+ It helps others discover the project. +

+ +

+ + GitHub Stars + +

diff --git a/benchmarks/README.md b/benchmarks/README.md new file mode 100644 index 0000000..98812ec --- /dev/null +++ b/benchmarks/README.md @@ -0,0 +1,65 @@ +# OG Engine Benchmarks + +Reproducible performance benchmarks comparing OG Engine against Puppeteer. + +## Quick Start + +```bash +# Download fonts first (if not already done) +bun run fonts:download + +# Run the full benchmark suite +bun run bench:full +``` + +## What It Measures + +### OG Engine — Phase Breakdown + +Each render is broken into three phases: + +| Phase | What it does | +|-------|-------------| +| **Text measurement** | `measureLines()` — word-wrap computation | +| **Canvas draw** | Gradient, grid, glow, text, decorations | +| **PNG encode** | `canvas.toBuffer('image/png')` | +| **Full pipeline** | All three combined (= real API response time) | + +### Puppeteer Baseline + +Same content rendered via headless Chrome for apples-to-apples comparison: + +- **Warm:** Browser reused between renders (best case for Puppeteer) +- **Cold:** Fresh browser launch per render (realistic for serverless) + +## Methodology + +- **OG Engine:** 1000 iterations per scenario, 50 warmup (discarded) +- **Puppeteer warm:** 50 iterations, browser reused, 5 warmup +- **Puppeteer cold:** 10 iterations, fresh browser per render +- All times measured with `performance.now()` +- Machine info captured in every report + +## Scenarios + +| Scenario | Description | +|----------|-------------| +| Baseline | Short title, OG format (1200x630), Outfit font | +| Long text | 150+ char title causing overflow, OG format | +| Story | Long text, Story format (1080x1920) | +| CJK | Japanese text, OG format, Noto Sans JP | + +## Output + +- `results/YYYY-MM-DD-report.md` — human-readable report (committed) +- `results/YYYY-MM-DD-raw.json` — full raw timings (gitignored, for audit) + +## Running Individual Benchmarks + +```bash +# OG Engine only +bun run benchmarks/run.ts + +# Puppeteer only +bun run benchmarks/puppeteer-baseline.ts +``` diff --git a/benchmarks/machine-info.ts b/benchmarks/machine-info.ts new file mode 100644 index 0000000..4545f6c --- /dev/null +++ b/benchmarks/machine-info.ts @@ -0,0 +1,54 @@ +import { execSync } from 'child_process'; +import os from 'os'; + +export interface MachineInfo { + os: string; + cpu: string; + cores: number; + ram: string; + bunVersion: string; + nodeVersion: string; + canvasVersion: string; + puppeteerVersion: string; + timestamp: string; +} + +function getPackageVersion(pkg: string): string { + try { + const json = require(`${pkg}/package.json`); + return json.version ?? 'unknown'; + } catch { + try { + const result = execSync(`bun pm ls 2>/dev/null | grep ${pkg}`, { encoding: 'utf-8' }); + const match = result.match(/(\d+\.\d+\.\d+)/); + return match ? match[1] : 'unknown'; + } catch { + return 'unknown'; + } + } +} + +export function getMachineInfo(): MachineInfo { + const ramGB = (os.totalmem() / 1024 / 1024 / 1024).toFixed(0); + let bunVersion = 'unknown'; + try { + bunVersion = execSync('bun --version', { encoding: 'utf-8' }).trim(); + } catch {} + + let nodeVersion = 'unknown'; + try { + nodeVersion = execSync('node --version', { encoding: 'utf-8' }).trim(); + } catch {} + + return { + os: `${os.type()} ${os.release()}`, + cpu: os.cpus()[0]?.model ?? 'unknown', + cores: os.cpus().length, + ram: `${ramGB} GB`, + bunVersion, + nodeVersion, + canvasVersion: getPackageVersion('@napi-rs/canvas'), + puppeteerVersion: getPackageVersion('puppeteer'), + timestamp: new Date().toISOString(), + }; +} diff --git a/benchmarks/puppeteer-baseline.ts b/benchmarks/puppeteer-baseline.ts new file mode 100644 index 0000000..bcbc63d --- /dev/null +++ b/benchmarks/puppeteer-baseline.ts @@ -0,0 +1,98 @@ +import puppeteer, { type Browser } from 'puppeteer'; +import { SCENARIOS, puppeteerHtml } from './scenarios'; +import { computeStats, formatMs, type Stats } from './stats'; + +const WARMUP = 5; +const ITERATIONS = 50; + +export interface PuppeteerResult { + scenarioSlug: string; + scenarioName: string; + warm: { raw: number[]; stats: Stats }; + cold: { raw: number[]; stats: Stats }; +} + +async function runWarm(browser: Browser, html: string, iterations: number): Promise { + const times: number[] = []; + for (let i = 0; i < iterations; i++) { + const t0 = performance.now(); + const page = await browser.newPage(); + await page.setViewport({ width: 1200, height: 630 }); + await page.setContent(html, { waitUntil: 'load' }); + await page.screenshot({ type: 'png' }); + await page.close(); + times.push(performance.now() - t0); + } + return times; +} + +async function runCold(html: string, iterations: number): Promise { + const times: number[] = []; + for (let i = 0; i < iterations; i++) { + const t0 = performance.now(); + const browser = await puppeteer.launch({ headless: true }); + const page = await browser.newPage(); + await page.setViewport({ width: 1200, height: 630 }); + await page.setContent(html, { waitUntil: 'load' }); + await page.screenshot({ type: 'png' }); + await page.close(); + await browser.close(); + times.push(performance.now() - t0); + } + return times; +} + +export async function runPuppeteerBaseline(): Promise { + const results: PuppeteerResult[] = []; + + const scenarios = SCENARIOS.filter((s) => s.slug === 'baseline' || s.slug === 'long-text'); + + for (const scenario of scenarios) { + const html = puppeteerHtml(scenario.options.title, scenario.options.description); + + // Warm: reuse browser + process.stdout.write(` ${scenario.name} (warm)...`); + const browser = await puppeteer.launch({ headless: true }); + + // Warmup + for (let i = 0; i < WARMUP; i++) { + const p = await browser.newPage(); + await p.setViewport({ width: 1200, height: 630 }); + await p.setContent(html, { waitUntil: 'load' }); + await p.screenshot({ type: 'png' }); + await p.close(); + } + + const warmTimes = await runWarm(browser, html, ITERATIONS); + await browser.close(); + console.log(` ${formatMs(computeStats(warmTimes).p50)} (P50)`); + + // Cold: fresh browser each time (only 10 iterations) + process.stdout.write(` ${scenario.name} (cold)...`); + const coldTimes = await runCold(html, 10); + console.log(` ${formatMs(computeStats(coldTimes).p50)} (P50)`); + + results.push({ + scenarioSlug: scenario.slug, + scenarioName: scenario.name, + warm: { raw: warmTimes, stats: computeStats(warmTimes) }, + cold: { raw: coldTimes, stats: computeStats(coldTimes) }, + }); + } + + return results; +} + +// Allow direct execution +if (import.meta.url === `file://${process.argv[1]}`) { + console.log('\nPuppeteer Baseline Benchmark\n'); + const results = await runPuppeteerBaseline(); + + console.log('\n--- Results ---\n'); + for (const r of results) { + console.log(`${r.scenarioName}`); + console.log(` Warm: ${formatMs(r.warm.stats.p50)} (P50) ${formatMs(r.warm.stats.p95)} (P95)`); + console.log(` Cold: ${formatMs(r.cold.stats.p50)} (P50) ${formatMs(r.cold.stats.p95)} (P95)`); + console.log(''); + } +} diff --git a/benchmarks/report.ts b/benchmarks/report.ts new file mode 100644 index 0000000..d042d55 --- /dev/null +++ b/benchmarks/report.ts @@ -0,0 +1,135 @@ +import { writeFile, mkdir } from 'fs/promises'; +import { join, dirname } from 'path'; +import { fileURLToPath } from 'url'; +import type { ScenarioResult } from './run'; +import type { PuppeteerResult } from './puppeteer-baseline'; +import type { MachineInfo } from './machine-info'; +import { formatMs } from './stats'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const RESULTS_DIR = join(__dirname, 'results'); + +function today(): string { + return new Date().toISOString().slice(0, 10); +} + +export async function writeJsonReport( + machine: MachineInfo, + ogResults: ScenarioResult[], + puppeteerResults: PuppeteerResult[], +): Promise { + await mkdir(RESULTS_DIR, { recursive: true }); + + const data = { + machine, + methodology: { + ogEngine: { iterations: 1000, warmup: 50 }, + puppeteer: { warmIterations: 50, coldIterations: 10, warmup: 5 }, + }, + scenarios: Object.fromEntries( + ogResults.map((r) => [ + r.scenario.slug, + { + ogEngine: { + phases: { + textMeasure: r.raw.textMeasure, + canvasDraw: r.raw.canvasDraw, + pngEncode: r.raw.pngEncode, + }, + fullPipeline: r.raw.fullPipeline, + }, + puppeteer: puppeteerResults.find((p) => p.scenarioSlug === r.scenario.slug) ?? null, + }, + ]), + ), + }; + + const path = join(RESULTS_DIR, `${today()}-raw.json`); + await writeFile(path, JSON.stringify(data, null, 2)); + return path; +} + +export async function writeMarkdownReport( + machine: MachineInfo, + ogResults: ScenarioResult[], + puppeteerResults: PuppeteerResult[], +): Promise { + await mkdir(RESULTS_DIR, { recursive: true }); + + const lines: string[] = []; + lines.push(`# OG Engine Benchmark Report — ${today()}`); + lines.push(''); + lines.push('## Machine'); + lines.push(''); + lines.push(`| | |`); + lines.push(`|---|---|`); + lines.push(`| **OS** | ${machine.os} |`); + lines.push(`| **CPU** | ${machine.cpu} |`); + lines.push(`| **Cores** | ${machine.cores} |`); + lines.push(`| **RAM** | ${machine.ram} |`); + lines.push(`| **Bun** | ${machine.bunVersion} |`); + lines.push(`| **Node.js** | ${machine.nodeVersion} |`); + lines.push(`| **@napi-rs/canvas** | ${machine.canvasVersion} |`); + lines.push(`| **Puppeteer** | ${machine.puppeteerVersion} |`); + lines.push(''); + lines.push('## Methodology'); + lines.push(''); + lines.push('- OG Engine: 1000 iterations per scenario, 50 warmup (discarded)'); + lines.push('- Puppeteer warm: 50 iterations, browser reused, 5 warmup'); + lines.push('- Puppeteer cold: 10 iterations, fresh browser per render'); + lines.push('- All times in milliseconds'); + lines.push(''); + + lines.push('## OG Engine Results'); + lines.push(''); + lines.push('| Scenario | Text Measure (P50) | Canvas Draw (P50) | PNG Encode (P50) | Full Pipeline (P50) | Full Pipeline (P95) |'); + lines.push('|---|---|---|---|---|---|'); + + for (const r of ogResults) { + lines.push( + `| ${r.scenario.name} | ${formatMs(r.stats.textMeasure.p50)} | ${formatMs(r.stats.canvasDraw.p50)} | ${formatMs(r.stats.pngEncode.p50)} | **${formatMs(r.stats.fullPipeline.p50)}** | ${formatMs(r.stats.fullPipeline.p95)} |`, + ); + } + + lines.push(''); + + if (puppeteerResults.length > 0) { + lines.push('## Puppeteer Baseline'); + lines.push(''); + lines.push('| Scenario | Warm P50 | Warm P95 | Cold P50 | Cold P95 |'); + lines.push('|---|---|---|---|---|'); + + for (const r of puppeteerResults) { + lines.push( + `| ${r.scenarioName} | **${formatMs(r.warm.stats.p50)}** | ${formatMs(r.warm.stats.p95)} | ${formatMs(r.cold.stats.p50)} | ${formatMs(r.cold.stats.p95)} |`, + ); + } + + lines.push(''); + + lines.push('## Speedup Comparison'); + lines.push(''); + lines.push('| Scenario | OG Engine (P50) | Puppeteer Warm (P50) | Speedup |'); + lines.push('|---|---|---|---|'); + + for (const pr of puppeteerResults) { + const ogr = ogResults.find((o) => o.scenario.slug === pr.scenarioSlug); + if (ogr) { + const speedup = (pr.warm.stats.p50 / ogr.stats.fullPipeline.p50).toFixed(0); + lines.push( + `| ${pr.scenarioName} | **${formatMs(ogr.stats.fullPipeline.p50)}** | ${formatMs(pr.warm.stats.p50)} | **${speedup}x** |`, + ); + } + } + + lines.push(''); + } + + lines.push('---'); + lines.push(''); + lines.push('*Generated by `bun run bench:full`. See `benchmarks/README.md` for methodology.*'); + + const path = join(RESULTS_DIR, `${today()}-report.md`); + await writeFile(path, lines.join('\n')); + return path; +} diff --git a/benchmarks/results/.gitkeep b/benchmarks/results/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/benchmarks/results/2026-04-03-report.md b/benchmarks/results/2026-04-03-report.md new file mode 100644 index 0000000..849b706 --- /dev/null +++ b/benchmarks/results/2026-04-03-report.md @@ -0,0 +1,48 @@ +# OG Engine Benchmark Report — 2026-04-03 + +## Machine + +| | | +|---|---| +| **OS** | Darwin 25.3.0 | +| **CPU** | Apple M2 | +| **Cores** | 8 | +| **RAM** | 24 GB | +| **Bun** | 1.3.11 | +| **Node.js** | v25.9.0 | +| **@napi-rs/canvas** | 0.1.97 | +| **Puppeteer** | 24.40.0 | + +## Methodology + +- OG Engine: 1000 iterations per scenario, 50 warmup (discarded) +- Puppeteer warm: 50 iterations, browser reused, 5 warmup +- Puppeteer cold: 10 iterations, fresh browser per render +- All times in milliseconds + +## OG Engine Results + +| Scenario | Text Measure (P50) | Canvas Draw (P50) | PNG Encode (P50) | Full Pipeline (P50) | Full Pipeline (P95) | +|---|---|---|---|---|---| +| Baseline (og, 1 line, Outfit) | 114µs | 50µs | 21.39ms | **21.57ms** | 22.79ms | +| Long text (og, overflow, Outfit) | 390µs | 78µs | 24.34ms | **24.83ms** | 26.41ms | +| Story format (1080x1920, Outfit) | 426µs | 98µs | 59.37ms | **59.96ms** | 65.02ms | +| CJK (og, Noto Sans JP) | 126µs | 79µs | 24.12ms | **24.34ms** | 26.92ms | + +## Puppeteer Baseline + +| Scenario | Warm P50 | Warm P95 | Cold P50 | Cold P95 | +|---|---|---|---|---| +| Baseline (og, 1 line, Outfit) | **128.75ms** | 165.98ms | 657.55ms | 837.24ms | +| Long text (og, overflow, Outfit) | **132.14ms** | 153.42ms | 634.03ms | 788.19ms | + +## Speedup Comparison + +| Scenario | OG Engine (P50) | Puppeteer Warm (P50) | Speedup | +|---|---|---|---| +| Baseline (og, 1 line, Outfit) | **21.57ms** | 128.75ms | **6x** | +| Long text (og, overflow, Outfit) | **24.83ms** | 132.14ms | **5x** | + +--- + +*Generated by `bun run bench:full`. See `benchmarks/README.md` for methodology.* \ No newline at end of file diff --git a/benchmarks/run-full.ts b/benchmarks/run-full.ts new file mode 100644 index 0000000..ef74ed7 --- /dev/null +++ b/benchmarks/run-full.ts @@ -0,0 +1,51 @@ +import { runOgBenchmarks } from './run'; +import { runPuppeteerBaseline } from './puppeteer-baseline'; +import { writeJsonReport, writeMarkdownReport } from './report'; +import { getMachineInfo } from './machine-info'; + +console.log('╔══════════════════════════════════════╗'); +console.log('║ OG Engine Benchmark Suite ║'); +console.log('╚══════════════════════════════════════╝'); +console.log(''); + +const machine = getMachineInfo(); +console.log(`Machine: ${machine.os} / ${machine.cpu} / ${machine.ram}`); +console.log(`Bun: ${machine.bunVersion} | Node: ${machine.nodeVersion}`); +console.log(''); + +// Phase 1: OG Engine +console.log('── OG Engine ──'); +const ogResults = await runOgBenchmarks(); +console.log(''); + +// Phase 2: Puppeteer +console.log('── Puppeteer Baseline ──'); +const puppeteerResults = await runPuppeteerBaseline(); +console.log(''); + +// Phase 3: Reports +console.log('── Generating Reports ──'); +const jsonPath = await writeJsonReport(machine, ogResults, puppeteerResults); +const mdPath = await writeMarkdownReport(machine, ogResults, puppeteerResults); +console.log(` JSON: ${jsonPath}`); +console.log(` Markdown: ${mdPath}`); +console.log(''); + +// Summary +const baselineOg = ogResults.find((r) => r.scenario.slug === 'baseline'); +const baselinePp = puppeteerResults.find((r) => r.scenarioSlug === 'baseline'); +if (baselineOg && baselinePp) { + const ogP50 = baselineOg.stats.fullPipeline.p50; + const ppP50 = baselinePp.warm.stats.p50; + const speedup = (ppP50 / ogP50).toFixed(0); + console.log('── Summary ──'); + console.log(` OG Engine: ${ogP50.toFixed(2)}ms (P50 full pipeline)`); + console.log(` Puppeteer: ${ppP50.toFixed(2)}ms (P50 warm)`); + console.log(` Speedup: ${speedup}x`); + console.log(''); + console.log(` Text layout: ${baselineOg.stats.textMeasure.p50.toFixed(3)}ms`); + console.log(` Canvas draw: ${baselineOg.stats.canvasDraw.p50.toFixed(3)}ms`); + console.log(` PNG encode: ${baselineOg.stats.pngEncode.p50.toFixed(3)}ms`); +} + +console.log('\nDone.'); diff --git a/benchmarks/run.ts b/benchmarks/run.ts new file mode 100644 index 0000000..47dc43a --- /dev/null +++ b/benchmarks/run.ts @@ -0,0 +1,98 @@ +import { registerFonts } from '../src/engine/fonts'; +import { renderCard } from '../src/engine/renderer'; +import { SCENARIOS, type Scenario } from './scenarios'; +import { computeStats, formatMs, type Stats } from './stats'; +import { dirname, join } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const FONTS_DIR = join(__dirname, '..', 'fonts'); + +const WARMUP = 50; +const ITERATIONS = 1000; + +export interface PhaseResults { + textMeasure: number[]; + canvasDraw: number[]; + pngEncode: number[]; + fullPipeline: number[]; +} + +export interface ScenarioResult { + scenario: Scenario; + raw: PhaseResults; + stats: { + textMeasure: Stats; + canvasDraw: Stats; + pngEncode: Stats; + fullPipeline: Stats; + }; +} + +function runScenario(scenario: Scenario): ScenarioResult { + const opts = { ...scenario.options, timing: true }; + + // Warmup + for (let i = 0; i < WARMUP; i++) { + renderCard(opts); + } + + const raw: PhaseResults = { + textMeasure: [], + canvasDraw: [], + pngEncode: [], + fullPipeline: [], + }; + + for (let i = 0; i < ITERATIONS; i++) { + const result = renderCard(opts); + const p = result.phases!; + raw.textMeasure.push(p.textMeasureMs); + raw.canvasDraw.push(p.canvasDrawMs); + raw.pngEncode.push(p.pngEncodeMs); + raw.fullPipeline.push(p.totalMs); + } + + return { + scenario, + raw, + stats: { + textMeasure: computeStats(raw.textMeasure), + canvasDraw: computeStats(raw.canvasDraw), + pngEncode: computeStats(raw.pngEncode), + fullPipeline: computeStats(raw.fullPipeline), + }, + }; +} + +export async function runOgBenchmarks(): Promise { + await registerFonts(FONTS_DIR); + + const results: ScenarioResult[] = []; + + for (const scenario of SCENARIOS) { + process.stdout.write(` ${scenario.name}...`); + const result = runScenario(scenario); + const p50 = result.stats.fullPipeline.p50; + console.log(` ${formatMs(p50)} (P50)`); + results.push(result); + } + + return results; +} + +// Allow direct execution +if (import.meta.url === `file://${process.argv[1]}`) { + console.log(`\nOG Engine Benchmark (${ITERATIONS} iterations, ${WARMUP} warmup)\n`); + const results = await runOgBenchmarks(); + + console.log('\n--- Detailed Results ---\n'); + for (const r of results) { + console.log(`${r.scenario.name}`); + console.log(` Text measure: ${formatMs(r.stats.textMeasure.p50)} (P50) ${formatMs(r.stats.textMeasure.p95)} (P95)`); + console.log(` Canvas draw: ${formatMs(r.stats.canvasDraw.p50)} (P50) ${formatMs(r.stats.canvasDraw.p95)} (P95)`); + console.log(` PNG encode: ${formatMs(r.stats.pngEncode.p50)} (P50) ${formatMs(r.stats.pngEncode.p95)} (P95)`); + console.log(` Full pipeline: ${formatMs(r.stats.fullPipeline.p50)} (P50) ${formatMs(r.stats.fullPipeline.p95)} (P95)`); + console.log(''); + } +} diff --git a/benchmarks/scenarios.ts b/benchmarks/scenarios.ts new file mode 100644 index 0000000..12af513 --- /dev/null +++ b/benchmarks/scenarios.ts @@ -0,0 +1,101 @@ +import type { RenderOptions } from '../src/engine/renderer'; + +export interface Scenario { + name: string; + slug: string; + options: RenderOptions; +} + +const base: Omit = { + author: 'OG Engine', + tag: 'Benchmark', + accent: '#38ef7d', + layout: 'left', + titleSize: 48, + descSize: 22, + gradient: 'void', + bgImageBuffer: null, + overlayOpacity: 0.65, + timing: true, +}; + +export const SCENARIOS: Scenario[] = [ + { + name: 'Baseline (og, 1 line, Outfit)', + slug: 'baseline', + options: { + ...base, + title: 'Hello, OG Engine', + description: 'Generated without a browser.', + format: 'og', + fontName: 'Outfit', + }, + }, + { + name: 'Long text (og, overflow, Outfit)', + slug: 'long-text', + options: { + ...base, + title: 'Server-Side Text Layout Without a Browser Engine — How Pretext Measures Every Glyph to Compute Perfect Line Breaks in Under One Millisecond', + description: 'Pure JavaScript text measurement replaces Puppeteer and headless Chrome. Sub-millisecond layout for OG images, PDFs, and dynamic content. No DOM, no CSSOM, no paint cycle.', + format: 'og', + fontName: 'Outfit', + }, + }, + { + name: 'Story format (1080x1920, Outfit)', + slug: 'story', + options: { + ...base, + title: 'Server-Side Text Layout Without a Browser Engine — How Pretext Measures Every Glyph to Compute Perfect Line Breaks in Under One Millisecond', + description: 'Pure JavaScript text measurement replaces Puppeteer and headless Chrome. Sub-millisecond layout for OG images, PDFs, and dynamic content. No DOM, no CSSOM, no paint cycle.', + format: 'story', + fontName: 'Outfit', + }, + }, + { + name: 'CJK (og, Noto Sans JP)', + slug: 'cjk', + options: { + ...base, + title: 'ブラウザなしのサーバーサイドテキストレイアウト — Pretextがすべてのグリフを測定', + description: '純粋なJavaScriptテキスト測定がPuppeteerとヘッドレスChromeを置き換えます。OG画像の1ミリ秒未満のレイアウト。', + format: 'og', + fontName: 'Noto Sans JP', + }, + }, +]; + +export function puppeteerHtml(title: string, description: string): string { + return ` + + + + + + BENCHMARK +

${title}

+

${description}

+
OG Engine
+ +`; +} diff --git a/benchmarks/stats.ts b/benchmarks/stats.ts new file mode 100644 index 0000000..7191759 --- /dev/null +++ b/benchmarks/stats.ts @@ -0,0 +1,36 @@ +export interface Stats { + min: number; + p50: number; + p95: number; + p99: number; + max: number; + mean: number; + stddev: number; + count: number; +} + +export function computeStats(times: number[]): Stats { + if (times.length === 0) { + return { min: 0, p50: 0, p95: 0, p99: 0, max: 0, mean: 0, stddev: 0, count: 0 }; + } + + const sorted = [...times].sort((a, b) => a - b); + const count = sorted.length; + const mean = sorted.reduce((a, b) => a + b, 0) / count; + const variance = sorted.reduce((sum, v) => sum + (v - mean) ** 2, 0) / count; + + return { + min: sorted[0], + p50: sorted[Math.floor(count * 0.5)], + p95: sorted[Math.floor(count * 0.95)], + p99: sorted[Math.floor(count * 0.99)], + max: sorted[count - 1], + mean, + stddev: Math.sqrt(variance), + count, + }; +} + +export function formatMs(ms: number): string { + return ms < 1 ? `${(ms * 1000).toFixed(0)}µs` : `${ms.toFixed(2)}ms`; +} diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..a9ec6b8 --- /dev/null +++ b/biome.json @@ -0,0 +1,41 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.4.10/schema.json", + "assist": { + "actions": { + "source": { + "organizeImports": "on" + } + } + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true, + "style": { + "noNonNullAssertion": "off" + }, + "suspicious": { + "noExplicitAny": "off" + }, + "complexity": { + "noForEach": "off" + } + } + }, + "formatter": { + "enabled": true, + "indentStyle": "space", + "indentWidth": 2, + "lineWidth": 120 + }, + "javascript": { + "formatter": { + "quoteStyle": "single", + "semicolons": "always", + "trailingCommas": "all" + } + }, + "files": { + "includes": ["src/**", "tests/**", "!src/data/*.json"] + } +} diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..587770d --- /dev/null +++ b/bun.lock @@ -0,0 +1,610 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "og-engine", + "dependencies": { + "@napi-rs/canvas": "^0.1.97", + "better-sqlite3": "^12.8.0", + "cheerio": "^1.2.0", + "hono": "^4.12.10", + "resend": "^6.10.0", + "stripe": "^22.0.0", + "zod": "^4.3.6", + }, + "devDependencies": { + "@biomejs/biome": "^2.4.10", + "@types/better-sqlite3": "^7.6.13", + "@types/bun": "^1.3.11", + "lefthook": "^2.1.4", + "puppeteer": "^24.40.0", + "typescript": "^6.0.2", + "vitest": "^4.1.2", + }, + }, + }, + "packages": { + "@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], + + "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + + "@biomejs/biome": ["@biomejs/biome@2.4.10", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.4.10", "@biomejs/cli-darwin-x64": "2.4.10", "@biomejs/cli-linux-arm64": "2.4.10", "@biomejs/cli-linux-arm64-musl": "2.4.10", "@biomejs/cli-linux-x64": "2.4.10", "@biomejs/cli-linux-x64-musl": "2.4.10", "@biomejs/cli-win32-arm64": "2.4.10", "@biomejs/cli-win32-x64": "2.4.10" }, "bin": { "biome": "bin/biome" } }, "sha512-xxA3AphFQ1geij4JTHXv4EeSTda1IFn22ye9LdyVPoJU19fNVl0uzfEuhsfQ4Yue/0FaLs2/ccVi4UDiE7R30w=="], + + "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.4.10", "", { "os": "darwin", "cpu": "arm64" }, "sha512-vuzzI1cWqDVzOMIkYyHbKqp+AkQq4K7k+UCXWpkYcY/HDn1UxdsbsfgtVpa40shem8Kax4TLDLlx8kMAecgqiw=="], + + "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.4.10", "", { "os": "darwin", "cpu": "x64" }, "sha512-14fzASRo+BPotwp7nWULy2W5xeUyFnTaq1V13Etrrxkrih+ez/2QfgFm5Ehtf5vSjtgx/IJycMMpn5kPd5ZNaA=="], + + "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.4.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-7MH1CMW5uuxQ/s7FLST63qF8B3Hgu2HRdZ7tA1X1+mk+St4JOuIrqdhIBnnyqeyWJNI+Bww7Es5QZ0wIc1Cmkw=="], + + "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.4.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-WrJY6UuiSD/Dh+nwK2qOTu8kdMDlLV3dLMmychIghHPAysWFq1/DGC1pVZx8POE3ZkzKR3PUUnVrtZfMfaJjyQ=="], + + "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.4.10", "", { "os": "linux", "cpu": "x64" }, "sha512-tZLvEEi2u9Xu1zAqRjTcpIDGVtldigVvzug2fTuPG0ME/g8/mXpRPcNgLB22bGn6FvLJpHHnqLnwliOu8xjYrg=="], + + "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.4.10", "", { "os": "linux", "cpu": "x64" }, "sha512-kDTi3pI6PBN6CiczsWYOyP2zk0IJI08EWEQyDMQWW221rPaaEz6FvjLhnU07KMzLv8q3qSuoB93ua6inSQ55Tw=="], + + "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.4.10", "", { "os": "win32", "cpu": "arm64" }, "sha512-umwQU6qPzH+ISTf/eHyJ/QoQnJs3V9Vpjz2OjZXe9MVBZ7prgGafMy7yYeRGnlmDAn87AKTF3Q6weLoMGpeqdQ=="], + + "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.10", "", { "os": "win32", "cpu": "x64" }, "sha512-aW/JU5GuyH4uxMrNYpoC2kjaHlyJGLgIa3XkhPEZI0uKhZhJZU8BuEyJmvgzSPQNGozBwWjC972RaNdcJ9KyJg=="], + + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.7", "", { "os": "aix", "cpu": "ppc64" }, "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.27.7", "", { "os": "android", "cpu": "arm" }, "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ=="], + + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.7", "", { "os": "android", "cpu": "arm64" }, "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ=="], + + "@esbuild/android-x64": ["@esbuild/android-x64@0.27.7", "", { "os": "android", "cpu": "x64" }, "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg=="], + + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.7", "", { "os": "darwin", "cpu": "arm64" }, "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw=="], + + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.7", "", { "os": "darwin", "cpu": "x64" }, "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ=="], + + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.7", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w=="], + + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.7", "", { "os": "freebsd", "cpu": "x64" }, "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ=="], + + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.7", "", { "os": "linux", "cpu": "arm" }, "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA=="], + + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A=="], + + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.7", "", { "os": "linux", "cpu": "ia32" }, "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg=="], + + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.7", "", { "os": "linux", "cpu": "none" }, "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q=="], + + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.7", "", { "os": "linux", "cpu": "none" }, "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw=="], + + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.7", "", { "os": "linux", "cpu": "ppc64" }, "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ=="], + + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.7", "", { "os": "linux", "cpu": "none" }, "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ=="], + + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.7", "", { "os": "linux", "cpu": "s390x" }, "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw=="], + + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.7", "", { "os": "linux", "cpu": "x64" }, "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA=="], + + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.7", "", { "os": "none", "cpu": "arm64" }, "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w=="], + + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.7", "", { "os": "none", "cpu": "x64" }, "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw=="], + + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.7", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A=="], + + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.7", "", { "os": "openbsd", "cpu": "x64" }, "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg=="], + + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.7", "", { "os": "none", "cpu": "arm64" }, "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw=="], + + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.7", "", { "os": "sunos", "cpu": "x64" }, "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA=="], + + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.7", "", { "os": "win32", "cpu": "arm64" }, "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.7", "", { "os": "win32", "cpu": "ia32" }, "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.7", "", { "os": "win32", "cpu": "x64" }, "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + + "@napi-rs/canvas": ["@napi-rs/canvas@0.1.97", "", { "optionalDependencies": { "@napi-rs/canvas-android-arm64": "0.1.97", "@napi-rs/canvas-darwin-arm64": "0.1.97", "@napi-rs/canvas-darwin-x64": "0.1.97", "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.97", "@napi-rs/canvas-linux-arm64-gnu": "0.1.97", "@napi-rs/canvas-linux-arm64-musl": "0.1.97", "@napi-rs/canvas-linux-riscv64-gnu": "0.1.97", "@napi-rs/canvas-linux-x64-gnu": "0.1.97", "@napi-rs/canvas-linux-x64-musl": "0.1.97", "@napi-rs/canvas-win32-arm64-msvc": "0.1.97", "@napi-rs/canvas-win32-x64-msvc": "0.1.97" } }, "sha512-8cFniXvrIEnVwuNSRCW9wirRZbHvrD3JVujdS2P5n5xiJZNZMOZcfOvJ1pb66c7jXMKHHglJEDVJGbm8XWFcXQ=="], + + "@napi-rs/canvas-android-arm64": ["@napi-rs/canvas-android-arm64@0.1.97", "", { "os": "android", "cpu": "arm64" }, "sha512-V1c/WVw+NzH8vk7ZK/O8/nyBSCQimU8sfMsB/9qeSvdkGKNU7+mxy/bIF0gTgeBFmHpj30S4E9WHMSrxXGQuVQ=="], + + "@napi-rs/canvas-darwin-arm64": ["@napi-rs/canvas-darwin-arm64@0.1.97", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ok+SCEF4YejcxuJ9Rm+WWunHHpf2HmiPxfz6z1a/NFQECGXtsY7A4B8XocK1LmT1D7P174MzwPF9Wy3AUAwEPw=="], + + "@napi-rs/canvas-darwin-x64": ["@napi-rs/canvas-darwin-x64@0.1.97", "", { "os": "darwin", "cpu": "x64" }, "sha512-PUP6e6/UGlclUvAQNnuXCcnkpdUou6VYZfQOQxExLp86epOylmiwLkqXIvpFmjoTEDmPmXrI+coL/9EFU1gKPA=="], + + "@napi-rs/canvas-linux-arm-gnueabihf": ["@napi-rs/canvas-linux-arm-gnueabihf@0.1.97", "", { "os": "linux", "cpu": "arm" }, "sha512-XyXH2L/cic8eTNtbrXCcvqHtMX/nEOxN18+7rMrAM2XtLYC/EB5s0wnO1FsLMWmK+04ZSLN9FBGipo7kpIkcOw=="], + + "@napi-rs/canvas-linux-arm64-gnu": ["@napi-rs/canvas-linux-arm64-gnu@0.1.97", "", { "os": "linux", "cpu": "arm64" }, "sha512-Kuq/M3djq0K8ktgz6nPlK7Ne5d4uWeDxPpyKWOjWDK2RIOhHVtLtyLiJw2fuldw7Vn4mhw05EZXCEr4Q76rs9w=="], + + "@napi-rs/canvas-linux-arm64-musl": ["@napi-rs/canvas-linux-arm64-musl@0.1.97", "", { "os": "linux", "cpu": "arm64" }, "sha512-kKmSkQVnWeqg7qdsiXvYxKhAFuHz3tkBjW/zyQv5YKUPhotpaVhpBGv5LqCngzyuRV85SXoe+OFj+Tv0a0QXkQ=="], + + "@napi-rs/canvas-linux-riscv64-gnu": ["@napi-rs/canvas-linux-riscv64-gnu@0.1.97", "", { "os": "linux", "cpu": "none" }, "sha512-Jc7I3A51jnEOIAXeLsN/M/+Z28LUeakcsXs07FLq9prXc0eYOtVwsDEv913Gr+06IRo34gJJVgT0TXvmz+N2VA=="], + + "@napi-rs/canvas-linux-x64-gnu": ["@napi-rs/canvas-linux-x64-gnu@0.1.97", "", { "os": "linux", "cpu": "x64" }, "sha512-iDUBe7AilfuBSRbSa8/IGX38Mf+iCSBqoVKLSQ5XaY2JLOaqz1TVyPFEyIck7wT6mRQhQt5sN6ogfjIDfi74tg=="], + + "@napi-rs/canvas-linux-x64-musl": ["@napi-rs/canvas-linux-x64-musl@0.1.97", "", { "os": "linux", "cpu": "x64" }, "sha512-AKLFd/v0Z5fvgqBDqhvqtAdx+fHMJ5t9JcUNKq4FIZ5WH+iegGm8HPdj00NFlCSnm83Fp3Ln8I2f7uq1aIiWaA=="], + + "@napi-rs/canvas-win32-arm64-msvc": ["@napi-rs/canvas-win32-arm64-msvc@0.1.97", "", { "os": "win32", "cpu": "arm64" }, "sha512-u883Yr6A6fO7Vpsy9YE4FVCIxzzo5sO+7pIUjjoDLjS3vQaNMkVzx5bdIpEL+ob+gU88WDK4VcxYMZ6nmnoX9A=="], + + "@napi-rs/canvas-win32-x64-msvc": ["@napi-rs/canvas-win32-x64-msvc@0.1.97", "", { "os": "win32", "cpu": "x64" }, "sha512-sWtD2EE3fV0IzN+iiQUqr/Q1SwqWhs2O1FKItFlxtdDkikpEj5g7DKQpY3x55H/MAOnL8iomnlk3mcEeGiUMoQ=="], + + "@puppeteer/browsers": ["@puppeteer/browsers@2.13.0", "", { "dependencies": { "debug": "^4.4.3", "extract-zip": "^2.0.1", "progress": "^2.0.3", "proxy-agent": "^6.5.0", "semver": "^7.7.4", "tar-fs": "^3.1.1", "yargs": "^17.7.2" }, "bin": { "browsers": "lib/cjs/main-cli.js" } }, "sha512-46BZJYJjc/WwmKjsvDFykHtXrtomsCIrwYQPOP7VfMJoZY2bsDF9oROBABR3paDjDcmkUye1Pb1BqdcdiipaWA=="], + + "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.60.1", "", { "os": "android", "cpu": "arm" }, "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA=="], + + "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.60.1", "", { "os": "android", "cpu": "arm64" }, "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA=="], + + "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.60.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw=="], + + "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.60.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew=="], + + "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.60.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w=="], + + "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.60.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g=="], + + "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.60.1", "", { "os": "linux", "cpu": "arm" }, "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g=="], + + "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.60.1", "", { "os": "linux", "cpu": "arm" }, "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg=="], + + "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.60.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ=="], + + "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.60.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA=="], + + "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.60.1", "", { "os": "linux", "cpu": "none" }, "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ=="], + + "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.60.1", "", { "os": "linux", "cpu": "none" }, "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw=="], + + "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.60.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw=="], + + "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.60.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg=="], + + "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.60.1", "", { "os": "linux", "cpu": "none" }, "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg=="], + + "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.60.1", "", { "os": "linux", "cpu": "none" }, "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg=="], + + "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.60.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ=="], + + "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.60.1", "", { "os": "linux", "cpu": "x64" }, "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg=="], + + "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.60.1", "", { "os": "linux", "cpu": "x64" }, "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w=="], + + "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.60.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw=="], + + "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.60.1", "", { "os": "none", "cpu": "arm64" }, "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA=="], + + "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.60.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g=="], + + "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.60.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg=="], + + "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.60.1", "", { "os": "win32", "cpu": "x64" }, "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg=="], + + "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.60.1", "", { "os": "win32", "cpu": "x64" }, "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ=="], + + "@stablelib/base64": ["@stablelib/base64@1.0.1", "", {}, "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ=="], + + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + + "@tootallnate/quickjs-emscripten": ["@tootallnate/quickjs-emscripten@0.23.0", "", {}, "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA=="], + + "@types/better-sqlite3": ["@types/better-sqlite3@7.6.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA=="], + + "@types/bun": ["@types/bun@1.3.11", "", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="], + + "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], + + "@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="], + + "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], + + "@types/node": ["@types/node@25.5.1", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-lgrR3HRNQdTEeeXBnLURFO4JIIbpcVcMlLM9IG0jsNRTRNSbMkm9S2hyhxhnokke1NM25Dr9QghgeB5PQKolrw=="], + + "@types/yauzl": ["@types/yauzl@2.10.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q=="], + + "@vitest/expect": ["@vitest/expect@4.1.2", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.2", "@vitest/utils": "4.1.2", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-gbu+7B0YgUJ2nkdsRJrFFW6X7NTP44WlhiclHniUhxADQJH5Szt9mZ9hWnJPJ8YwOK5zUOSSlSvyzRf0u1DSBQ=="], + + "@vitest/mocker": ["@vitest/mocker@4.1.2", "", { "dependencies": { "@vitest/spy": "4.1.2", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-Ize4iQtEALHDttPRCmN+FKqOl2vxTiNUhzobQFFt/BM1lRUTG7zRCLOykG/6Vo4E4hnUdfVLo5/eqKPukcWW7Q=="], + + "@vitest/pretty-format": ["@vitest/pretty-format@4.1.2", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-dwQga8aejqeuB+TvXCMzSQemvV9hNEtDDpgUKDzOmNQayl2OG241PSWeJwKRH3CiC+sESrmoFd49rfnq7T4RnA=="], + + "@vitest/runner": ["@vitest/runner@4.1.2", "", { "dependencies": { "@vitest/utils": "4.1.2", "pathe": "^2.0.3" } }, "sha512-Gr+FQan34CdiYAwpGJmQG8PgkyFVmARK8/xSijia3eTFgVfpcpztWLuP6FttGNfPLJhaZVP/euvujeNYar36OQ=="], + + "@vitest/snapshot": ["@vitest/snapshot@4.1.2", "", { "dependencies": { "@vitest/pretty-format": "4.1.2", "@vitest/utils": "4.1.2", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-g7yfUmxYS4mNxk31qbOYsSt2F4m1E02LFqO53Xpzg3zKMhLAPZAjjfyl9e6z7HrW6LvUdTwAQR3HHfLjpko16A=="], + + "@vitest/spy": ["@vitest/spy@4.1.2", "", {}, "sha512-DU4fBnbVCJGNBwVA6xSToNXrkZNSiw59H8tcuUspVMsBDBST4nfvsPsEHDHGtWRRnqBERBQu7TrTKskmjqTXKA=="], + + "@vitest/utils": ["@vitest/utils@4.1.2", "", { "dependencies": { "@vitest/pretty-format": "4.1.2", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-xw2/TiX82lQHA06cgbqRKFb5lCAy3axQ4H4SoUFhUsg+wztiet+co86IAMDtF6Vm1hc7J6j09oh/rgDn+JdKIQ=="], + + "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], + + "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + + "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + + "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + + "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], + + "ast-types": ["ast-types@0.13.4", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w=="], + + "b4a": ["b4a@1.8.0", "", { "peerDependencies": { "react-native-b4a": "*" }, "optionalPeers": ["react-native-b4a"] }, "sha512-qRuSmNSkGQaHwNbM7J78Wwy+ghLEYF1zNrSeMxj4Kgw6y33O3mXcQ6Ie9fRvfU/YnxWkOchPXbaLb73TkIsfdg=="], + + "bare-events": ["bare-events@2.8.2", "", { "peerDependencies": { "bare-abort-controller": "*" }, "optionalPeers": ["bare-abort-controller"] }, "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ=="], + + "bare-fs": ["bare-fs@4.6.0", "", { "dependencies": { "bare-events": "^2.5.4", "bare-path": "^3.0.0", "bare-stream": "^2.6.4", "bare-url": "^2.2.2", "fast-fifo": "^1.3.2" }, "peerDependencies": { "bare-buffer": "*" }, "optionalPeers": ["bare-buffer"] }, "sha512-2YkS7NuiJceSEbyEOdSNLE9tsGd+f4+f7C+Nik/MCk27SYdwIMPT/yRKvg++FZhQXgk0KWJKJyXX9RhVV0RGqA=="], + + "bare-os": ["bare-os@3.8.7", "", {}, "sha512-G4Gr1UsGeEy2qtDTZwL7JFLo2wapUarz7iTMcYcMFdS89AIQuBoyjgXZz0Utv7uHs3xA9LckhVbeBi8lEQrC+w=="], + + "bare-path": ["bare-path@3.0.0", "", { "dependencies": { "bare-os": "^3.0.1" } }, "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw=="], + + "bare-stream": ["bare-stream@2.12.0", "", { "dependencies": { "streamx": "^2.25.0", "teex": "^1.0.1" }, "peerDependencies": { "bare-abort-controller": "*", "bare-buffer": "*", "bare-events": "*" }, "optionalPeers": ["bare-abort-controller", "bare-buffer", "bare-events"] }, "sha512-w28i8lkBgREV3rPXGbgK+BO66q+ZpKqRWrZLiCdmmUlLPrQ45CzkvRhN+7lnv00Gpi2zy5naRxnUFAxCECDm9g=="], + + "bare-url": ["bare-url@2.4.0", "", { "dependencies": { "bare-path": "^3.0.0" } }, "sha512-NSTU5WN+fy/L0DDenfE8SXQna4voXuW0FHM7wH8i3/q9khUSchfPbPezO4zSFMnDGIf9YE+mt/RWhZgNRKRIXA=="], + + "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], + + "basic-ftp": ["basic-ftp@5.2.0", "", {}, "sha512-VoMINM2rqJwJgfdHq6RiUudKt2BV+FY5ZFezP/ypmwayk68+NzzAQy4XXLlqsGD4MCzq3DrmNFD/uUmBJuGoXw=="], + + "better-sqlite3": ["better-sqlite3@12.8.0", "", { "dependencies": { "bindings": "^1.5.0", "prebuild-install": "^7.1.1" } }, "sha512-RxD2Vd96sQDjQr20kdP+F+dK/1OUNiVOl200vKBZY8u0vTwysfolF6Hq+3ZK2+h8My9YvZhHsF+RSGZW2VYrPQ=="], + + "bindings": ["bindings@1.5.0", "", { "dependencies": { "file-uri-to-path": "1.0.0" } }, "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ=="], + + "bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="], + + "boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="], + + "buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="], + + "buffer-crc32": ["buffer-crc32@0.2.13", "", {}, "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ=="], + + "bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="], + + "callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], + + "chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], + + "cheerio": ["cheerio@1.2.0", "", { "dependencies": { "cheerio-select": "^2.1.0", "dom-serializer": "^2.0.0", "domhandler": "^5.0.3", "domutils": "^3.2.2", "encoding-sniffer": "^0.2.1", "htmlparser2": "^10.1.0", "parse5": "^7.3.0", "parse5-htmlparser2-tree-adapter": "^7.1.0", "parse5-parser-stream": "^7.1.2", "undici": "^7.19.0", "whatwg-mimetype": "^4.0.0" } }, "sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg=="], + + "cheerio-select": ["cheerio-select@2.1.0", "", { "dependencies": { "boolbase": "^1.0.0", "css-select": "^5.1.0", "css-what": "^6.1.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.0.1" } }, "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g=="], + + "chownr": ["chownr@1.1.4", "", {}, "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg=="], + + "chromium-bidi": ["chromium-bidi@14.0.0", "", { "dependencies": { "mitt": "^3.0.1", "zod": "^3.24.1" }, "peerDependencies": { "devtools-protocol": "*" } }, "sha512-9gYlLtS6tStdRWzrtXaTMnqcM4dudNegMXJxkR0I/CXObHalYeYcAMPrL19eroNZHtJ8DQmu1E+ZNOYu/IXMXw=="], + + "cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="], + + "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], + + "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + + "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], + + "cosmiconfig": ["cosmiconfig@9.0.1", "", { "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", "js-yaml": "^4.1.0", "parse-json": "^5.2.0" }, "peerDependencies": { "typescript": ">=4.9.5" }, "optionalPeers": ["typescript"] }, "sha512-hr4ihw+DBqcvrsEDioRO31Z17x71pUYoNe/4h6Z0wB72p7MU7/9gH8Q3s12NFhHPfYBBOV3qyfUxmr/Yn3shnQ=="], + + "css-select": ["css-select@5.2.2", "", { "dependencies": { "boolbase": "^1.0.0", "css-what": "^6.1.0", "domhandler": "^5.0.2", "domutils": "^3.0.1", "nth-check": "^2.0.1" } }, "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw=="], + + "css-what": ["css-what@6.2.2", "", {}, "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA=="], + + "data-uri-to-buffer": ["data-uri-to-buffer@6.0.2", "", {}, "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "decompress-response": ["decompress-response@6.0.0", "", { "dependencies": { "mimic-response": "^3.1.0" } }, "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ=="], + + "deep-extend": ["deep-extend@0.6.0", "", {}, "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA=="], + + "degenerator": ["degenerator@5.0.1", "", { "dependencies": { "ast-types": "^0.13.4", "escodegen": "^2.1.0", "esprima": "^4.0.1" } }, "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ=="], + + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + + "devtools-protocol": ["devtools-protocol@0.0.1581282", "", {}, "sha512-nv7iKtNZQshSW2hKzYNr46nM/Cfh5SEvE2oV0/SEGgc9XupIY5ggf84Cz8eJIkBce7S3bmTAauFD6aysMpnqsQ=="], + + "dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="], + + "domelementtype": ["domelementtype@2.3.0", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="], + + "domhandler": ["domhandler@5.0.3", "", { "dependencies": { "domelementtype": "^2.3.0" } }, "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w=="], + + "domutils": ["domutils@3.2.2", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="], + + "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + + "encoding-sniffer": ["encoding-sniffer@0.2.1", "", { "dependencies": { "iconv-lite": "^0.6.3", "whatwg-encoding": "^3.1.1" } }, "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw=="], + + "end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="], + + "entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], + + "env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="], + + "error-ex": ["error-ex@1.3.4", "", { "dependencies": { "is-arrayish": "^0.2.1" } }, "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ=="], + + "es-module-lexer": ["es-module-lexer@2.0.0", "", {}, "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw=="], + + "esbuild": ["esbuild@0.27.7", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.7", "@esbuild/android-arm": "0.27.7", "@esbuild/android-arm64": "0.27.7", "@esbuild/android-x64": "0.27.7", "@esbuild/darwin-arm64": "0.27.7", "@esbuild/darwin-x64": "0.27.7", "@esbuild/freebsd-arm64": "0.27.7", "@esbuild/freebsd-x64": "0.27.7", "@esbuild/linux-arm": "0.27.7", "@esbuild/linux-arm64": "0.27.7", "@esbuild/linux-ia32": "0.27.7", "@esbuild/linux-loong64": "0.27.7", "@esbuild/linux-mips64el": "0.27.7", "@esbuild/linux-ppc64": "0.27.7", "@esbuild/linux-riscv64": "0.27.7", "@esbuild/linux-s390x": "0.27.7", "@esbuild/linux-x64": "0.27.7", "@esbuild/netbsd-arm64": "0.27.7", "@esbuild/netbsd-x64": "0.27.7", "@esbuild/openbsd-arm64": "0.27.7", "@esbuild/openbsd-x64": "0.27.7", "@esbuild/openharmony-arm64": "0.27.7", "@esbuild/sunos-x64": "0.27.7", "@esbuild/win32-arm64": "0.27.7", "@esbuild/win32-ia32": "0.27.7", "@esbuild/win32-x64": "0.27.7" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w=="], + + "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + + "escodegen": ["escodegen@2.1.0", "", { "dependencies": { "esprima": "^4.0.1", "estraverse": "^5.2.0", "esutils": "^2.0.2" }, "optionalDependencies": { "source-map": "~0.6.1" }, "bin": { "esgenerate": "bin/esgenerate.js", "escodegen": "bin/escodegen.js" } }, "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w=="], + + "esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="], + + "estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="], + + "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], + + "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], + + "events-universal": ["events-universal@1.0.1", "", { "dependencies": { "bare-events": "^2.7.0" } }, "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw=="], + + "expand-template": ["expand-template@2.0.3", "", {}, "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg=="], + + "expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="], + + "extract-zip": ["extract-zip@2.0.1", "", { "dependencies": { "debug": "^4.1.1", "get-stream": "^5.1.0", "yauzl": "^2.10.0" }, "optionalDependencies": { "@types/yauzl": "^2.9.1" }, "bin": { "extract-zip": "cli.js" } }, "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg=="], + + "fast-fifo": ["fast-fifo@1.3.2", "", {}, "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ=="], + + "fast-sha256": ["fast-sha256@1.3.0", "", {}, "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ=="], + + "fd-slicer": ["fd-slicer@1.1.0", "", { "dependencies": { "pend": "~1.2.0" } }, "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g=="], + + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + + "file-uri-to-path": ["file-uri-to-path@1.0.0", "", {}, "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw=="], + + "fs-constants": ["fs-constants@1.0.0", "", {}, "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow=="], + + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], + + "get-stream": ["get-stream@5.2.0", "", { "dependencies": { "pump": "^3.0.0" } }, "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA=="], + + "get-uri": ["get-uri@6.0.5", "", { "dependencies": { "basic-ftp": "^5.0.2", "data-uri-to-buffer": "^6.0.2", "debug": "^4.3.4" } }, "sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg=="], + + "github-from-package": ["github-from-package@0.0.0", "", {}, "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw=="], + + "hono": ["hono@4.12.10", "", {}, "sha512-mx/p18PLy5og9ufies2GOSUqep98Td9q4i/EF6X7yJgAiIopxqdfIO3jbqsi3jRgTgw88jMDEzVKi+V2EF+27w=="], + + "htmlparser2": ["htmlparser2@10.1.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.2.2", "entities": "^7.0.1" } }, "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ=="], + + "http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="], + + "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], + + "iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], + + "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], + + "import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], + + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + + "ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="], + + "ip-address": ["ip-address@10.1.0", "", {}, "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q=="], + + "is-arrayish": ["is-arrayish@0.2.1", "", {}, "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg=="], + + "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], + + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + + "js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], + + "json-parse-even-better-errors": ["json-parse-even-better-errors@2.3.1", "", {}, "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w=="], + + "lefthook": ["lefthook@2.1.4", "", { "optionalDependencies": { "lefthook-darwin-arm64": "2.1.4", "lefthook-darwin-x64": "2.1.4", "lefthook-freebsd-arm64": "2.1.4", "lefthook-freebsd-x64": "2.1.4", "lefthook-linux-arm64": "2.1.4", "lefthook-linux-x64": "2.1.4", "lefthook-openbsd-arm64": "2.1.4", "lefthook-openbsd-x64": "2.1.4", "lefthook-windows-arm64": "2.1.4", "lefthook-windows-x64": "2.1.4" }, "bin": { "lefthook": "bin/index.js" } }, "sha512-JNfJ5gAn0KADvJ1I6/xMcx70+/6TL6U9gqGkKvPw5RNMfatC7jIg0Evl97HN846xmfz959BV70l8r3QsBJk30w=="], + + "lefthook-darwin-arm64": ["lefthook-darwin-arm64@2.1.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-BUAAE9+rUrjr39a+wH/1zHmGrDdwUQ2Yq/z6BQbM/yUb9qtXBRcQ5eOXxApqWW177VhGBpX31aqIlfAZ5Q7wzw=="], + + "lefthook-darwin-x64": ["lefthook-darwin-x64@2.1.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-K1ncIMEe84fe+ss1hQNO7rIvqiKy2TJvTFpkypvqFodT7mJXZn7GLKYTIXdIuyPAYthRa9DwFnx5uMoHwD2F1Q=="], + + "lefthook-freebsd-arm64": ["lefthook-freebsd-arm64@2.1.4", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-PVUhjOhVN71YaYsVdQyNbFZ4a2jFB2Tg5hKrrn9kaWpx64aLz/XivLjwr8sEuTaP1GRlEWBpW6Bhrcsyo39qFw=="], + + "lefthook-freebsd-x64": ["lefthook-freebsd-x64@2.1.4", "", { "os": "freebsd", "cpu": "x64" }, "sha512-ZWV9o/LeyWNEBoVO+BhLqxH3rGTba05nkm5NvMjEFSj7LbUNUDbQmupZwtHl1OMGJO66eZP0CalzRfUH6GhBxQ=="], + + "lefthook-linux-arm64": ["lefthook-linux-arm64@2.1.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-iWN0pGnTjrIvNIcSI1vQBJXUbybTqJ5CLMniPA0olabMXQfPDrdMKVQe+mgdwHK+E3/Y0H0ZNL3lnOj6Sk6szA=="], + + "lefthook-linux-x64": ["lefthook-linux-x64@2.1.4", "", { "os": "linux", "cpu": "x64" }, "sha512-96bTBE/JdYgqWYAJDh+/e/0MaxJ25XTOAk7iy/fKoZ1ugf6S0W9bEFbnCFNooXOcxNVTan5xWKfcjJmPIKtsJA=="], + + "lefthook-openbsd-arm64": ["lefthook-openbsd-arm64@2.1.4", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-oYUoK6AIJNEr9lUSpIMj6g7sWzotvtc3ryw7yoOyQM6uqmEduw73URV/qGoUcm4nqqmR93ZalZwR2r3Gd61zvw=="], + + "lefthook-openbsd-x64": ["lefthook-openbsd-x64@2.1.4", "", { "os": "openbsd", "cpu": "x64" }, "sha512-i/Dv9Jcm68y9cggr1PhyUhOabBGP9+hzQPoiyOhKks7y9qrJl79A8XfG6LHekSuYc2VpiSu5wdnnrE1cj2nfTg=="], + + "lefthook-windows-arm64": ["lefthook-windows-arm64@2.1.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-hSww7z+QX4YMnw2lK7DMrs3+w7NtxksuMKOkCKGyxUAC/0m1LAICo0ZbtdDtZ7agxRQQQ/SEbzFRhU5ysNcbjA=="], + + "lefthook-windows-x64": ["lefthook-windows-x64@2.1.4", "", { "os": "win32", "cpu": "x64" }, "sha512-eE68LwnogxwcPgGsbVGPGxmghyMGmU9SdGwcc+uhGnUxPz1jL89oECMWJNc36zjVK24umNeDAzB5KA3lw1MuWw=="], + + "lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="], + + "lru-cache": ["lru-cache@7.18.3", "", {}, "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA=="], + + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + + "mimic-response": ["mimic-response@3.1.0", "", {}, "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="], + + "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], + + "mitt": ["mitt@3.0.1", "", {}, "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw=="], + + "mkdirp-classic": ["mkdirp-classic@0.5.3", "", {}, "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + + "napi-build-utils": ["napi-build-utils@2.0.0", "", {}, "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA=="], + + "netmask": ["netmask@2.0.2", "", {}, "sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg=="], + + "node-abi": ["node-abi@3.89.0", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-6u9UwL0HlAl21+agMN3YAMXcKByMqwGx+pq+P76vii5f7hTPtKDp08/H9py6DY+cfDw7kQNTGEj/rly3IgbNQA=="], + + "nth-check": ["nth-check@2.1.1", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="], + + "obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="], + + "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], + + "pac-proxy-agent": ["pac-proxy-agent@7.2.0", "", { "dependencies": { "@tootallnate/quickjs-emscripten": "^0.23.0", "agent-base": "^7.1.2", "debug": "^4.3.4", "get-uri": "^6.0.1", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.6", "pac-resolver": "^7.0.1", "socks-proxy-agent": "^8.0.5" } }, "sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA=="], + + "pac-resolver": ["pac-resolver@7.0.1", "", { "dependencies": { "degenerator": "^5.0.0", "netmask": "^2.0.2" } }, "sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg=="], + + "parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="], + + "parse-json": ["parse-json@5.2.0", "", { "dependencies": { "@babel/code-frame": "^7.0.0", "error-ex": "^1.3.1", "json-parse-even-better-errors": "^2.3.0", "lines-and-columns": "^1.1.6" } }, "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg=="], + + "parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], + + "parse5-htmlparser2-tree-adapter": ["parse5-htmlparser2-tree-adapter@7.1.0", "", { "dependencies": { "domhandler": "^5.0.3", "parse5": "^7.0.0" } }, "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g=="], + + "parse5-parser-stream": ["parse5-parser-stream@7.1.2", "", { "dependencies": { "parse5": "^7.0.0" } }, "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow=="], + + "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + + "pend": ["pend@1.2.0", "", {}, "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], + + "postal-mime": ["postal-mime@2.7.4", "", {}, "sha512-0WdnFQYUrPGGTFu1uOqD2s7omwua8xaeYGdO6rb88oD5yJ/4pPHDA4sdWqfD8wQVfCny563n/HQS7zTFft+f/g=="], + + "postcss": ["postcss@8.5.8", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="], + + "prebuild-install": ["prebuild-install@7.1.3", "", { "dependencies": { "detect-libc": "^2.0.0", "expand-template": "^2.0.3", "github-from-package": "0.0.0", "minimist": "^1.2.3", "mkdirp-classic": "^0.5.3", "napi-build-utils": "^2.0.0", "node-abi": "^3.3.0", "pump": "^3.0.0", "rc": "^1.2.7", "simple-get": "^4.0.0", "tar-fs": "^2.0.0", "tunnel-agent": "^0.6.0" }, "bin": { "prebuild-install": "bin.js" } }, "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug=="], + + "progress": ["progress@2.0.3", "", {}, "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA=="], + + "proxy-agent": ["proxy-agent@6.5.0", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "^4.3.4", "http-proxy-agent": "^7.0.1", "https-proxy-agent": "^7.0.6", "lru-cache": "^7.14.1", "pac-proxy-agent": "^7.1.0", "proxy-from-env": "^1.1.0", "socks-proxy-agent": "^8.0.5" } }, "sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A=="], + + "proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="], + + "pump": ["pump@3.0.4", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA=="], + + "puppeteer": ["puppeteer@24.40.0", "", { "dependencies": { "@puppeteer/browsers": "2.13.0", "chromium-bidi": "14.0.0", "cosmiconfig": "^9.0.0", "devtools-protocol": "0.0.1581282", "puppeteer-core": "24.40.0", "typed-query-selector": "^2.12.1" }, "bin": { "puppeteer": "lib/cjs/puppeteer/node/cli.js" } }, "sha512-IxQbDq93XHVVLWHrAkFP7F7iHvb9o0mgfsSIMlhHb+JM+JjM1V4v4MNSQfcRWJopx9dsNOr9adYv0U5fm9BJBQ=="], + + "puppeteer-core": ["puppeteer-core@24.40.0", "", { "dependencies": { "@puppeteer/browsers": "2.13.0", "chromium-bidi": "14.0.0", "debug": "^4.4.3", "devtools-protocol": "0.0.1581282", "typed-query-selector": "^2.12.1", "webdriver-bidi-protocol": "0.4.1", "ws": "^8.19.0" } }, "sha512-MWL3XbUCfVgGR0gRsidzT6oKJT2QydPLhMITU6HoVWiiv4gkb6gJi3pcdAa8q4HwjBTbqISOWVP4aJiiyUJvag=="], + + "rc": ["rc@1.2.8", "", { "dependencies": { "deep-extend": "^0.6.0", "ini": "~1.3.0", "minimist": "^1.2.0", "strip-json-comments": "~2.0.1" }, "bin": { "rc": "./cli.js" } }, "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw=="], + + "readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], + + "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], + + "resend": ["resend@6.10.0", "", { "dependencies": { "postal-mime": "2.7.4", "svix": "1.88.0" }, "peerDependencies": { "@react-email/render": "*" }, "optionalPeers": ["@react-email/render"] }, "sha512-i7CwZpYj4Oho1RxsTpLcCUkO08+HiL4NXrm6jLJ2WzJ89UGI8eROSieLONJA3hnUrf1OYnCyfq5F6POnHUMv1Q=="], + + "resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], + + "rollup": ["rollup@4.60.1", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.60.1", "@rollup/rollup-android-arm64": "4.60.1", "@rollup/rollup-darwin-arm64": "4.60.1", "@rollup/rollup-darwin-x64": "4.60.1", "@rollup/rollup-freebsd-arm64": "4.60.1", "@rollup/rollup-freebsd-x64": "4.60.1", "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", "@rollup/rollup-linux-arm-musleabihf": "4.60.1", "@rollup/rollup-linux-arm64-gnu": "4.60.1", "@rollup/rollup-linux-arm64-musl": "4.60.1", "@rollup/rollup-linux-loong64-gnu": "4.60.1", "@rollup/rollup-linux-loong64-musl": "4.60.1", "@rollup/rollup-linux-ppc64-gnu": "4.60.1", "@rollup/rollup-linux-ppc64-musl": "4.60.1", "@rollup/rollup-linux-riscv64-gnu": "4.60.1", "@rollup/rollup-linux-riscv64-musl": "4.60.1", "@rollup/rollup-linux-s390x-gnu": "4.60.1", "@rollup/rollup-linux-x64-gnu": "4.60.1", "@rollup/rollup-linux-x64-musl": "4.60.1", "@rollup/rollup-openbsd-x64": "4.60.1", "@rollup/rollup-openharmony-arm64": "4.60.1", "@rollup/rollup-win32-arm64-msvc": "4.60.1", "@rollup/rollup-win32-ia32-msvc": "4.60.1", "@rollup/rollup-win32-x64-gnu": "4.60.1", "@rollup/rollup-win32-x64-msvc": "4.60.1", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w=="], + + "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + + "semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + + "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], + + "simple-concat": ["simple-concat@1.0.1", "", {}, "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q=="], + + "simple-get": ["simple-get@4.0.1", "", { "dependencies": { "decompress-response": "^6.0.0", "once": "^1.3.1", "simple-concat": "^1.0.0" } }, "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA=="], + + "smart-buffer": ["smart-buffer@4.2.0", "", {}, "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg=="], + + "socks": ["socks@2.8.7", "", { "dependencies": { "ip-address": "^10.0.1", "smart-buffer": "^4.2.0" } }, "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A=="], + + "socks-proxy-agent": ["socks-proxy-agent@8.0.5", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "^4.3.4", "socks": "^2.8.3" } }, "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw=="], + + "source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], + + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + + "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], + + "standardwebhooks": ["standardwebhooks@1.0.0", "", { "dependencies": { "@stablelib/base64": "^1.0.0", "fast-sha256": "^1.3.0" } }, "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg=="], + + "std-env": ["std-env@4.0.0", "", {}, "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ=="], + + "streamx": ["streamx@2.25.0", "", { "dependencies": { "events-universal": "^1.0.0", "fast-fifo": "^1.3.2", "text-decoder": "^1.1.0" } }, "sha512-0nQuG6jf1w+wddNEEXCF4nTg3LtufWINB5eFEN+5TNZW7KWJp6x87+JFL43vaAUPyCfH1wID+mNVyW6OHtFamg=="], + + "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + + "string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], + + "strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "strip-json-comments": ["strip-json-comments@2.0.1", "", {}, "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ=="], + + "stripe": ["stripe@22.0.0", "", { "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-q1UgXXpSfZCmkyzZEh3vFEWT7+ajuaFGqaP9Tsi2NMtwlkigIWNr+KBIUQqtNeNEsreDKgdn+BP5HRW9JDj22Q=="], + + "svix": ["svix@1.88.0", "", { "dependencies": { "standardwebhooks": "1.0.0", "uuid": "^10.0.0" } }, "sha512-vm/JrrUd3bVyBE+3L33TIyVSs8gS5fYx7lrISvKlDJXTYX1ACH4REX8P1tHxsSKoZi/rvifM1t0XRc5Vc45THw=="], + + "tar-fs": ["tar-fs@3.1.2", "", { "dependencies": { "pump": "^3.0.0", "tar-stream": "^3.1.5" }, "optionalDependencies": { "bare-fs": "^4.0.1", "bare-path": "^3.0.0" } }, "sha512-QGxxTxxyleAdyM3kpFs14ymbYmNFrfY+pHj7Z8FgtbZ7w2//VAgLMac7sT6nRpIHjppXO2AwwEOg0bPFVRcmXw=="], + + "tar-stream": ["tar-stream@3.1.8", "", { "dependencies": { "b4a": "^1.6.4", "bare-fs": "^4.5.5", "fast-fifo": "^1.2.0", "streamx": "^2.15.0" } }, "sha512-U6QpVRyCGHva435KoNWy9PRoi2IFYCgtEhq9nmrPPpbRacPs9IH4aJ3gbrFC8dPcXvdSZ4XXfXT5Fshbp2MtlQ=="], + + "teex": ["teex@1.0.1", "", { "dependencies": { "streamx": "^2.12.5" } }, "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg=="], + + "text-decoder": ["text-decoder@1.2.7", "", { "dependencies": { "b4a": "^1.6.4" } }, "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ=="], + + "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], + + "tinyexec": ["tinyexec@1.0.4", "", {}, "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw=="], + + "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], + + "tinyrainbow": ["tinyrainbow@3.1.0", "", {}, "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw=="], + + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "tunnel-agent": ["tunnel-agent@0.6.0", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w=="], + + "typed-query-selector": ["typed-query-selector@2.12.1", "", {}, "sha512-uzR+FzI8qrUEIu96oaeBJmd9E7CFEiQ3goA5qCVgc4s5llSubcfGHq9yUstZx/k4s9dXHVKsE35YWoFyvEqEHA=="], + + "typescript": ["typescript@6.0.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ=="], + + "undici": ["undici@7.24.7", "", {}, "sha512-H/nlJ/h0ggGC+uRL3ovD+G0i4bqhvsDOpbDv7At5eFLlj2b41L8QliGbnl2H7SnDiYhENphh1tQFJZf+MyfLsQ=="], + + "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + + "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], + + "uuid": ["uuid@10.0.0", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ=="], + + "vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="], + + "vitest": ["vitest@4.1.2", "", { "dependencies": { "@vitest/expect": "4.1.2", "@vitest/mocker": "4.1.2", "@vitest/pretty-format": "4.1.2", "@vitest/runner": "4.1.2", "@vitest/snapshot": "4.1.2", "@vitest/spy": "4.1.2", "@vitest/utils": "4.1.2", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.2", "@vitest/browser-preview": "4.1.2", "@vitest/browser-webdriverio": "4.1.2", "@vitest/ui": "4.1.2", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-xjR1dMTVHlFLh98JE3i/f/WePqJsah4A0FK9cc8Ehp9Udk0AZk6ccpIZhh1qJ/yxVWRZ+Q54ocnD8TXmkhspGg=="], + + "webdriver-bidi-protocol": ["webdriver-bidi-protocol@0.4.1", "", {}, "sha512-ARrjNjtWRRs2w4Tk7nqrf2gBI0QXWuOmMCx2hU+1jUt6d00MjMxURrhxhGbrsoiZKJrhTSTzbIrc554iKI10qw=="], + + "whatwg-encoding": ["whatwg-encoding@3.1.1", "", { "dependencies": { "iconv-lite": "0.6.3" } }, "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ=="], + + "whatwg-mimetype": ["whatwg-mimetype@4.0.0", "", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="], + + "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], + + "wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], + + "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], + + "ws": ["ws@8.20.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA=="], + + "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], + + "yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="], + + "yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], + + "yauzl": ["yauzl@2.10.0", "", { "dependencies": { "buffer-crc32": "~0.2.3", "fd-slicer": "~1.1.0" } }, "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g=="], + + "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], + + "chromium-bidi/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "htmlparser2/entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], + + "parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], + + "prebuild-install/tar-fs": ["tar-fs@2.1.4", "", { "dependencies": { "chownr": "^1.1.1", "mkdirp-classic": "^0.5.2", "pump": "^3.0.0", "tar-stream": "^2.1.4" } }, "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ=="], + + "prebuild-install/tar-fs/tar-stream": ["tar-stream@2.2.0", "", { "dependencies": { "bl": "^4.0.3", "end-of-stream": "^1.4.1", "fs-constants": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^3.1.1" } }, "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ=="], + } +} diff --git a/data/.gitkeep b/data/.gitkeep new file mode 100644 index 0000000..e166442 --- /dev/null +++ b/data/.gitkeep @@ -0,0 +1 @@ +.gitkeep diff --git a/docs/analysis/FEATURES-IDEAS.md b/docs/analysis/FEATURES-IDEAS.md new file mode 100644 index 0000000..c6fd666 --- /dev/null +++ b/docs/analysis/FEATURES-IDEAS.md @@ -0,0 +1,358 @@ +# OG Engine — Idées de fonctionnalités à forte valeur + +## Comment lire ce document + +Chaque feature est évaluée sur 3 axes : +- **Valeur utilisateur** : à quel point ça résout un vrai problème (★ à ★★★) +- **Potentiel de revenu** : est-ce que ça justifie un upgrade ou un prix plus élevé (€ à €€€) +- **Effort** : complexité de développement (S, M, L, XL) + +--- + +## 1 · Intelligence artificielle + +### 1.1 — Auto-fit : le texte qui s'adapte tout seul +Le dev envoie un titre de n'importe quelle longueur. L'API trouve automatiquement la taille de police optimale pour que le texte tienne parfaitement dans le format choisi, sans troncature, sans espace perdu. + +Techniquement : recherche binaire sur le titleSize, en s'appuyant sur le layout Pretext pour tester chaque taille en microsecondes. + +**Valeur :** ★★★ — Élimine le problème n°1 des OG images : le texte qui déborde +**Revenu :** €€ — Feature premium qui justifie le Starter +**Effort :** S — C'est un algorithme de 20 lignes grâce à Pretext + +--- + +### 1.2 — Smart crop : titre intelligent +L'API reçoit un titre trop long (ex: un titre d'article de 200 caractères). Au lieu de tronquer bêtement avec "…", elle utilise un LLM pour résumer le titre en une version courte qui tient dans l'image tout en gardant le sens. + +Exemple : +- Input : "How We Migrated Our Entire Infrastructure From AWS to Google Cloud Platform in 6 Months Without Any Downtime" +- Output : "AWS to GCP in 6 Months — Zero Downtime" + +**Valeur :** ★★★ — Les titres tronqués sont un problème universel +**Revenu :** €€€ — Feature premium, facturable en supplément (coût LLM) +**Effort :** M — Intégration API LLM + cache des résumés + +--- + +### 1.3 — Génération de description automatique +Le dev envoie juste une URL. L'API fetch la page, extrait le titre et la description (meta tags ou contenu), et génère l'image automatiquement. Zero config. + +``` +POST /render +{ "url": "https://myblog.com/my-article" } +``` + +**Valeur :** ★★★ — Réduit l'intégration à une seule ligne +**Revenu :** €€ — Feature qui accélère l'adoption +**Effort :** M — Scraping + extraction de meta tags + +--- + +### 1.4 — Style transfer : imiter un style visuel +Le dev uploade un screenshot d'une image OG qu'il aime. L'API analyse les couleurs, la disposition, et les polices, puis applique un style similaire à ses propres images. + +**Valeur :** ★★ — Cool mais niche +**Revenu :** €€ — Feature différenciante pour les agences +**Effort :** L — Analyse d'image + mapping de style + +--- + +## 2 · Analytics & insights + +### 2.1 — Dashboard d'usage visuel +Un dashboard web (og-engine.com/dashboard) qui montre : +- Nombre d'images générées par jour/semaine/mois +- Temps de rendu moyen +- Top 10 des contenus les plus générés +- Quota restant avec projection de fin de mois +- Alertes quand on approche de la limite + +**Valeur :** ★★★ — Les devs adorent les dashboards +**Revenu :** €€ — Rend le produit "sticky", augmente la rétention +**Effort :** M — Interface web + API d'analytics + +--- + +### 2.2 — A/B testing d'images OG +Le dev crée 2 variantes (titres différents, couleurs différentes, layouts différents). L'API sert alternativement la variante A ou B. Combiné avec un tracker de clics (UTM ou pixel), le dev peut voir quelle variante performe le mieux sur les réseaux sociaux. + +``` +POST /render +{ + "variants": [ + { "title": "10 Tips for Better Code", "style": { "accent": "#38ef7d" } }, + { "title": "Write Better Code Today", "style": { "accent": "#fb7185" } } + ], + "split": 50 +} +``` + +**Valeur :** ★★★ — Personne ne fait ça. Avantage compétitif énorme. +**Revenu :** €€€ — Feature enterprise, justifie le plan Scale +**Effort :** L — Routing A/B + tracking + reporting + +--- + +### 2.3 — Preview social multi-plateforme +Avant de publier, le dev voit un aperçu de comment son image apparaîtra sur Twitter, LinkedIn, Facebook, Slack, Discord, iMessage — chacun crop et affiche différemment. + +``` +GET /preview?url=https://myblog.com/article +→ Retourne un JSON avec les previews simulées pour chaque plateforme +``` + +**Valeur :** ★★★ — Problème réel que tout le monde a +**Revenu :** €€ — Feature gratuite pour l'acquisition, convertit vers payant +**Effort :** M — Simuler les viewports de chaque plateforme + +--- + +## 3 · Personnalisation avancée + +### 3.1 — Variables dynamiques dans les templates +Le template contient des placeholders ({{title}}, {{price}}, {{date}}) et le dev envoie juste les valeurs. Parfait pour l'e-commerce et l'email. + +``` +POST /render +{ + "template": "product-card", + "variables": { + "title": "Nike Air Max 90", + "price": "129€", + "badge": "-20%", + "image_url": "https://..." + } +} +``` + +**Valeur :** ★★★ — Transformer OG Engine en outil de templating visuel +**Revenu :** €€€ — Ouvre le segment e-commerce et email marketing +**Effort :** M — Parser de variables + injection dans le renderer + +--- + +### 3.2 — Éditeur visuel de templates (no-code) +Une interface web drag-and-drop pour créer des templates custom sans écrire de JSON. Le marketer (pas le dev) peut créer et modifier ses propres templates. + +**Valeur :** ★★★ — Ouvre le produit aux non-devs +**Revenu :** €€€ — Justifie un plan Enterprise à 299€+ +**Effort :** XL — C'est un mini design tool à construire + +--- + +### 3.3 — Thème de marque global +Le dev configure UNE FOIS son branding (couleurs, police, logo) et toutes les images générées respectent automatiquement la charte. Plus besoin de passer les styles à chaque appel. + +``` +POST /brand +{ + "accent": "#38ef7d", + "font": "Outfit", + "logo_url": "https://...", + "layout": "left" +} + +POST /render +{ "title": "Mon article" } +← L'image utilise automatiquement le branding +``` + +**Valeur :** ★★★ — Réduit la friction à chaque appel +**Revenu :** €€ — Feature sticky qui rend difficile de quitter +**Effort :** S — Une table de config + merge avec les defaults + +--- + +### 3.4 — Logo / watermark automatique +Inclure automatiquement le logo de l'entreprise sur chaque image générée. Position configurable (coin, en-tête, filigrane). + +**Valeur :** ★★ — Demande fréquente pour le branding +**Revenu :** € — Feature standard attendue +**Effort :** S — Charger et positionner une image sur le canvas + +--- + +## 4 · Automatisation & workflows + +### 4.1 — Intégration CMS : auto-génération au publish +Plugin pour les CMS populaires (WordPress, Ghost, Sanity, Contentful, Strapi, Notion). Quand un article est publié, l'image OG est générée automatiquement. + +**Valeur :** ★★★ — Zero effort pour l'utilisateur final +**Revenu :** €€€ — Chaque plugin est un canal d'acquisition +**Effort :** M par plugin — Webhook CMS → appel API + +--- + +### 4.2 — GitHub Action : OG images dans le CI/CD +Une GitHub Action qui génère les images OG au moment du deploy. Le dev ajoute un workflow YAML et c'est fini. + +```yaml +- uses: og-engine/generate@v1 + with: + api-key: ${{ secrets.OG_ENGINE_KEY }} + pages-dir: ./content + output-dir: ./public/og +``` + +**Valeur :** ★★★ — S'intègre dans le workflow existant des devs +**Revenu :** €€ — Augmente l'usage (= plus d'appels = upgrade) +**Effort :** M — GitHub Action + CLI tool + +--- + +### 4.3 — Scheduled regeneration +Programmer la régénération automatique des images à intervalles réguliers. Utile pour les contenus qui changent (prix, scores, données live). + +**Valeur :** ★★ — Niche mais haute valeur pour l'e-commerce +**Revenu :** €€ — Feature Pro/Scale +**Effort :** M — Cron scheduler + storage des configs + +--- + +### 4.4 — Zapier / Make integration +Connecter OG Engine à 5000+ apps via Zapier. Exemples : +- Nouveau post WordPress → générer OG image → uploader sur Cloudinary +- Nouvelle ligne Google Sheet → générer bannière → envoyer par email +- Nouveau produit Shopify → générer fiche visuelle + +**Valeur :** ★★★ — Ouvre les non-devs sans construire d'UI +**Revenu :** €€ — Augmente la base utilisateurs non-techniques +**Effort :** M — Créer une app Zapier + triggers/actions + +--- + +## 5 · Rendu avancé + +### 5.1 — QR code intégré +Ajouter un QR code automatique sur l'image, pointant vers l'URL du contenu. Utile pour les supports print et les présentations. + +``` +POST /render +{ + "title": "Mon événement", + "qr": { "url": "https://event.com/register", "position": "bottom-right" } +} +``` + +**Valeur :** ★★ — Niche mais différenciant +**Revenu :** € — Feature à inclure dans tous les plans payants +**Effort :** S — Librairie QR code + positionnement canvas + +--- + +### 5.2 — Animated OG images (GIF/WebP animé) +Générer des images OG animées : texte qui apparaît progressivement, fond en mouvement subtil, compteur animé. Certaines plateformes (Twitter, Slack) supportent les GIF dans les previews. + +**Valeur :** ★★ — Effet "wow" garanti +**Revenu :** €€ — Feature premium, haute valeur perçue +**Effort :** L — Frame-by-frame rendering + encodage GIF/WebP + +--- + +### 5.3 — Screenshot API +Au-delà des OG images : capturer un screenshot d'un composant web. Le dev envoie du HTML/CSS, l'API rend l'image. Concurrent direct de Puppeteer sur son terrain. + +**Valeur :** ★★★ — Élargit considérablement le marché adressable +**Revenu :** €€€ — Nouveau produit à part entière +**Effort :** XL — Nécessite un moteur de rendu HTML (ou headless léger) + +--- + +### 5.4 — PDF export +Même contenu, mais exporté en PDF au lieu de PNG. Utile pour les certificats, les factures, les rapports. + +**Valeur :** ★★ — Marché adjacent +**Revenu :** €€ — Feature Pro +**Effort :** M — Canvas to PDF + mise en page + +--- + +## 6 · Expérience développeur + +### 6.1 — Playground interactif dans les docs +Un playground web où le dev peut modifier le JSON de la requête et voir l'image se mettre à jour en temps réel. Comme le POC qu'on a construit, mais connecté à la vraie API. + +**Valeur :** ★★★ — Réduit drastiquement le time-to-first-call +**Revenu :** €€ — Convertit les visiteurs en utilisateurs +**Effort :** M — Adapter le POC existant + connecter à l'API + +--- + +### 6.2 — Logs et debug en temps réel +Un flux de logs en direct (type Vercel logs) montrant chaque appel API, son temps de rendu, et les éventuelles erreurs. Accessible dans le dashboard. + +**Valeur :** ★★ — Essentiel pour le debugging en production +**Revenu :** € — Feature attendue, pas différenciante +**Effort :** M — Streaming de logs + UI + +--- + +### 6.3 — Environnements staging/production +Deux clés API par compte : une pour le dev/staging (pas de limite, watermark "PREVIEW"), une pour la production. Le dev peut tester sans consommer son quota. + +**Valeur :** ★★★ — Résout une friction réelle +**Revenu :** €€ — Augmente la confiance → accélère l'adoption +**Effort :** S — Deux clés + flag "preview" dans le renderer + +--- + +### 6.4 — Webhook de notification +Notifier le dev quand une erreur récurrente est détectée, quand le quota approche, ou quand un rendu échoue. Via webhook, email, ou Slack. + +**Valeur :** ★★ — Proactivité appréciée +**Revenu :** € — Feature Pro +**Effort :** S — Event system + notification dispatch + +--- + +## 7 · Social & collaboration + +### 7.1 — Galerie de templates communautaire +Les utilisateurs peuvent publier leurs templates custom dans une galerie publique. Les autres peuvent les utiliser (fork) en un clic. Crée un effet réseau. + +**Valeur :** ★★ — Effet communauté + contenu gratuit +**Revenu :** €€ — Augmente l'adoption et la rétention +**Effort :** L — Galerie web + système de partage + +--- + +### 7.2 — Équipes et permissions +Plusieurs membres d'une équipe partagent le même quota et les mêmes templates, avec des rôles (admin, member, viewer). + +**Valeur :** ★★ — Nécessaire pour les entreprises +**Revenu :** €€€ — Débloquer un plan Team/Enterprise +**Effort :** L — Auth multi-user + permissions + +--- + +## Matrice de priorisation + +### Quick wins (haute valeur, faible effort) +| Feature | Valeur | Effort | +|---------|--------|--------| +| 1.1 Auto-fit | ★★★ | S | +| 3.3 Thème de marque | ★★★ | S | +| 3.4 Logo/watermark | ★★ | S | +| 5.1 QR code | ★★ | S | +| 6.3 Env staging/prod | ★★★ | S | + +### High impact (haute valeur, effort moyen) +| Feature | Valeur | Effort | +|---------|--------|--------| +| 1.3 Render depuis URL | ★★★ | M | +| 2.1 Dashboard usage | ★★★ | M | +| 2.3 Preview multi-plateforme | ★★★ | M | +| 3.1 Variables dynamiques | ★★★ | M | +| 4.1 Plugins CMS | ★★★ | M | +| 4.2 GitHub Action | ★★★ | M | +| 6.1 Playground | ★★★ | M | + +### Game changers (transformative, effort important) +| Feature | Valeur | Effort | +|---------|--------|--------| +| 1.2 Smart crop (LLM) | ★★★ | M | +| 2.2 A/B testing | ★★★ | L | +| 3.2 Éditeur no-code | ★★★ | XL | +| 4.4 Zapier/Make | ★★★ | M | +| 5.3 Screenshot API | ★★★ | XL | diff --git a/docs/analysis/USER-STORIES.md b/docs/analysis/USER-STORIES.md new file mode 100644 index 0000000..59b7e4e --- /dev/null +++ b/docs/analysis/USER-STORIES.md @@ -0,0 +1,474 @@ +# OG Engine — User Stories + +## Personas + +| Persona | Description | +|---------|-------------| +| **Dev** | Développeur intégrant l'API dans son produit (SaaS, blog, e-commerce) | +| **Marketer** | Marketeur utilisant l'API pour générer du contenu visuel à grande échelle | +| **Visitor** | Visiteur de la landing page, pas encore client | +| **Admin** | Administrateur de son compte OG Engine (facturation, clés) | + +## Priorités + +- **P0** — MVP, indispensable pour le lancement +- **P1** — Important, à livrer dans les 2 semaines post-lancement +- **P2** — Nice-to-have, itération post-lancement + +--- + +## Epic 1 — Inscription & Authentification + +### US-1.1 · Inscription gratuite sans carte bancaire +**Persona:** Visitor → Dev +**Priorité:** P0 + +> En tant que visiteur, je veux créer un compte gratuit avec juste mon email, afin de tester l'API sans friction ni engagement financier. + +**Critères d'acceptation:** +- Le visiteur envoie `POST /auth/register` avec `{ "email": "..." }` +- Le système génère une clé API au format `oge_sk_` + 32 caractères aléatoires +- La clé est envoyée par email en moins de 30 secondes +- L'email contient la clé, un exemple curl, et un lien vers la documentation +- Le plan est automatiquement "free" avec 500 appels/mois +- Si l'email existe déjà, renvoyer la clé existante (pas de doublon) + +--- + +### US-1.2 · Souscription payante via Stripe +**Persona:** Dev +**Priorité:** P0 + +> En tant que développeur, je veux souscrire à un plan payant via un lien de paiement Stripe, afin d'obtenir plus d'appels API et de fonctionnalités avancées. + +**Critères d'acceptation:** +- Chaque plan (Starter, Pro, Scale) a un Stripe Payment Link sur la landing page +- Après paiement, un webhook Stripe déclenche la création d'une clé API +- La clé est envoyée par email avec les détails du plan souscrit +- Si le dev a déjà un compte free, le plan est upgradé (pas de nouvelle clé) +- Le compteur d'appels est remis à zéro au moment de l'upgrade + +--- + +### US-1.3 · Changement de plan +**Persona:** Admin +**Priorité:** P1 + +> En tant qu'admin de mon compte, je veux changer de plan (upgrade ou downgrade), afin d'adapter mon abonnement à mon usage réel. + +**Critères d'acceptation:** +- Le portail Stripe (lien dans l'email de bienvenue) permet de changer de plan +- Le webhook `customer.subscription.updated` met à jour la limite d'appels +- L'upgrade est effectif immédiatement +- Le downgrade prend effet au prochain cycle de facturation +- Le dev reçoit un email de confirmation du changement + +--- + +### US-1.4 · Annulation d'abonnement +**Persona:** Admin +**Priorité:** P1 + +> En tant qu'admin, je veux pouvoir annuler mon abonnement, afin de ne plus être facturé. + +**Critères d'acceptation:** +- L'annulation se fait via le portail Stripe +- Le webhook `customer.subscription.deleted` rétrograde le plan à "free" +- La clé API reste active avec la limite free (500 appels/mois) +- Aucune donnée n'est supprimée + +--- + +## Epic 2 — Génération d'images (/render) + +### US-2.1 · Générer une image OG basique +**Persona:** Dev +**Priorité:** P0 + +> En tant que développeur, je veux envoyer un titre et une description à l'API et recevoir un PNG en retour, afin de générer automatiquement des images OG pour mon site. + +**Critères d'acceptation:** +- `POST /render` avec `{ "format": "og", "title": "...", "description": "..." }` retourne un PNG +- Le Content-Type de la réponse est `image/png` +- Les headers incluent `X-Render-Time-Ms`, `X-Title-Lines`, `X-Desc-Lines`, `X-Layout-Overflow` +- Le temps de rendu est inférieur à 10ms (hors réseau) +- L'image fait exactement 1200×630 pixels +- Le titre est tronqué avec "…" s'il dépasse 3 lignes +- La description est tronquée avec "…" si elle dépasse 4 lignes + +--- + +### US-2.2 · Choisir un format de sortie +**Persona:** Dev +**Priorité:** P0 + +> En tant que développeur, je veux choisir le format de l'image (OG, Twitter, Square, LinkedIn, Story), afin de générer des visuels adaptés à chaque plateforme. + +**Critères d'acceptation:** +- Le champ `format` accepte: `og` (1200×630), `twitter` (1200×675), `square` (1080×1080), `linkedin` (1200×627), `story` (1080×1920) +- Chaque format ajuste le nombre max de lignes titre/description +- Le format Story autorise 5 lignes de titre et 6 de description +- Si le format est absent ou invalide, retourner une erreur 400 + +--- + +### US-2.3 · Personnaliser le style +**Persona:** Dev +**Priorité:** P0 + +> En tant que développeur, je veux personnaliser la couleur d'accent, la police, la taille du texte et le layout, afin que les images générées correspondent à ma marque. + +**Critères d'acceptation:** +- `style.accent` accepte un code couleur hex (ex: "#38ef7d") +- `style.font` accepte le nom d'une police disponible (ex: "Outfit") +- `style.titleSize` accepte un nombre entre 28 et 72 +- `style.descSize` accepte un nombre entre 14 et 32 +- `style.layout` accepte: "left", "center", "bottom" +- Les valeurs par défaut sont appliquées si des champs sont omis +- `GET /health` retourne la liste des polices et layouts disponibles + +--- + +### US-2.4 · Ajouter un tag/catégorie +**Persona:** Dev / Marketer +**Priorité:** P1 + +> En tant qu'utilisateur, je veux ajouter un tag (ex: "Open Source", "Tutorial") qui apparaît comme un badge sur l'image, afin de catégoriser visuellement mon contenu. + +**Critères d'acceptation:** +- Le champ `tag` est optionnel +- S'il est présent, il s'affiche comme une pilule arrondie au-dessus du titre +- Le texte du tag est affiché en majuscules +- La couleur de la pilule est dérivée de la couleur d'accent + +--- + +### US-2.5 · Utiliser une image de fond +**Persona:** Dev / Marketer +**Priorité:** P1 + +> En tant qu'utilisateur, je veux uploader une image de fond pour mon visuel, afin de créer des images plus riches et personnalisées. + +**Critères d'acceptation:** +- L'endpoint accepte un upload multipart avec un champ `backgroundImage` +- Les formats acceptés sont: JPEG, PNG, WebP +- L'image est redimensionnée pour couvrir le canvas (object-fit: cover) +- Un overlay sombre est appliqué par défaut (opacité 0.65) +- Le champ `style.overlayOpacity` (0.2 à 0.9) permet de contrôler l'assombrissement +- La taille maximale du fichier est de 5MB +- Si l'image est invalide ou trop lourde, retourner une erreur 400 + +--- + +### US-2.6 · Choisir le format de sortie (PNG/WebP) +**Persona:** Dev +**Priorité:** P1 + +> En tant que développeur, je veux choisir entre PNG et WebP comme format de sortie, afin d'optimiser la taille des fichiers selon mes besoins. + +**Critères d'acceptation:** +- Le champ `output.format` accepte "png" (défaut) et "webp" +- Le champ `output.quality` (1-100) s'applique au WebP +- Le WebP est disponible à partir du plan Starter +- Si un utilisateur free demande du WebP, retourner une erreur 402 `plan_required` avec un message d'upgrade + +--- + +### US-2.7 · Choisir un template +**Persona:** Dev +**Priorité:** P1 + +> En tant que développeur, je veux choisir parmi plusieurs templates prédéfinis, afin de varier le style de mes visuels sans tout configurer manuellement. + +**Critères d'acceptation:** +- Le champ `template` accepte: "default", "social-card", "blog-hero", "email-banner" +- Chaque template a son propre agencement (positions du texte, décorations, style) +- Le template "default" est utilisé si le champ est absent +- `GET /health` retourne la liste des templates disponibles + +--- + +## Epic 3 — Validation de texte (/validate) + +### US-3.1 · Vérifier si un texte tient dans un format +**Persona:** Dev / Marketer +**Priorité:** P0 + +> En tant que développeur, je veux vérifier si mon titre et ma description tiennent dans un format donné sans générer d'image, afin de valider mes contenus rapidement et gratuitement. + +**Critères d'acceptation:** +- `POST /validate` avec `{ "format": "og", "title": "...", "description": "..." }` retourne un JSON +- La réponse contient: `fits` (boolean), `title.lines`, `title.overflow`, `description.lines`, `description.overflow` +- Le temps de calcul est retourné dans `computeTimeMs` +- L'endpoint est gratuit et illimité sur tous les plans (y compris free) +- L'endpoint n'incrémente PAS le compteur d'appels +- Le temps de réponse est inférieur à 5ms + +--- + +### US-3.2 · Valider avec des contraintes custom +**Persona:** Dev +**Priorité:** P1 + +> En tant que développeur, je veux spécifier un nombre max de lignes custom pour le titre et la description, afin de tester des contraintes spécifiques à mon UI. + +**Critères d'acceptation:** +- Les champs `maxTitleLines` et `maxDescLines` sont optionnels +- Par défaut: 3 lignes titre, 4 lignes description +- La réponse indique `overflow: true` si le texte dépasse la limite spécifiée +- On peut tester des polices et tailles différentes dans la même requête + +--- + +## Epic 4 — Batch Processing + +### US-4.1 · Générer plusieurs images en une requête +**Persona:** Dev / Marketer +**Priorité:** P1 + +> En tant que développeur, je veux envoyer une liste de contenus et recevoir toutes les images en une seule requête, afin de générer des visuels en masse efficacement. + +**Critères d'acceptation:** +- `POST /render/batch` accepte `{ "items": [...] }` avec jusqu'à 100 éléments +- Chaque élément a la même structure qu'une requête `/render` individuelle +- La réponse est un ZIP contenant les images nommées `0.png`, `1.png`, etc. +- Le temps total est retourné dans le header `X-Total-Render-Time-Ms` +- Chaque image du batch compte comme 1 appel API +- L'endpoint est réservé aux plans Pro et Scale +- Si un utilisateur Starter tente un batch, retourner 402 `plan_required` avec message d'upgrade + +--- + +### US-4.2 · Erreurs partielles dans un batch +**Persona:** Dev +**Priorité:** P2 + +> En tant que développeur, je veux que le batch continue même si un élément échoue, afin de ne pas perdre tout le traitement à cause d'une seule erreur. + +**Critères d'acceptation:** +- Si un élément du batch échoue (champ manquant, police invalide), les autres sont quand même générés +- Le ZIP contient un fichier `errors.json` listant les indices et messages d'erreur +- Le header `X-Batch-Errors` indique le nombre d'erreurs + +--- + +## Epic 5 — Usage & Limites + +### US-5.1 · Contrôle des limites d'appels +**Persona:** Dev +**Priorité:** P0 + +> En tant que développeur, je veux que l'API refuse mes appels quand j'ai atteint ma limite mensuelle, avec un message clair, afin de comprendre pourquoi et comment upgrader. + +**Critères d'acceptation:** +- Quand `calls_used >= calls_limit`, retourner HTTP 429 +- Le body contient: `error`, `limit`, `used`, `plan`, `upgrade_url` +- Les headers contiennent: `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset` +- Le compteur se remet à zéro au début de chaque cycle de facturation + +--- + +### US-5.2 · Consulter mon usage +**Persona:** Dev / Admin +**Priorité:** P1 + +> En tant qu'admin, je veux consulter mon usage actuel (appels consommés, limite, date de reset), afin de suivre ma consommation. + +**Critères d'acceptation:** +- `GET /usage` retourne: `plan`, `calls_used`, `calls_limit`, `period_start`, `period_end`, `days_remaining` +- L'endpoint nécessite une clé API valide +- L'endpoint ne compte PAS comme un appel API + +--- + +### US-5.3 · Reset mensuel automatique +**Persona:** Système +**Priorité:** P0 + +> En tant que système, je dois remettre le compteur d'appels à zéro à chaque nouveau cycle de facturation, afin que les utilisateurs récupèrent leur quota. + +**Critères d'acceptation:** +- Le webhook Stripe `invoice.paid` déclenche le reset +- `calls_used` est remis à 0 +- `period_start` est mis à jour avec la date courante +- Pour les comptes free (pas de webhook Stripe), un cron mensuel reset le compteur + +--- + +## Epic 6 — Gestion des erreurs + +### US-6.1 · Erreurs structurées et actionnables +**Persona:** Dev +**Priorité:** P0 + +> En tant que développeur, je veux que les erreurs de l'API soient structurées et claires, afin de pouvoir les traiter programmatiquement. + +**Critères d'acceptation:** +- Toutes les erreurs retournent du JSON: `{ "error": "code", "message": "description lisible", "details": {...} }` +- Codes d'erreur: `invalid_request`, `missing_field`, `invalid_font`, `invalid_format`, `unauthorized`, `rate_limited`, `plan_required`, `server_error` +- Les erreurs de validation listent tous les champs invalides (pas juste le premier) +- Les erreurs 4xx incluent un lien vers la documentation pertinente + +--- + +### US-6.2 · Clé API invalide ou manquante +**Persona:** Dev +**Priorité:** P0 + +> En tant que développeur, je veux un message d'erreur clair si ma clé API est manquante ou invalide, afin de pouvoir corriger rapidement. + +**Critères d'acceptation:** +- Pas de header Authorization → 401 `{ "error": "unauthorized", "message": "Missing API key. Include 'Authorization: Bearer oge_sk_...' header." }` +- Clé invalide → 401 `{ "error": "unauthorized", "message": "Invalid API key." }` +- Clé désactivée → 401 `{ "error": "unauthorized", "message": "API key has been deactivated." }` + +--- + +## Epic 7 — Health & Discovery + +### US-7.1 · Endpoint de santé +**Persona:** Dev +**Priorité:** P0 + +> En tant que développeur, je veux un endpoint de santé qui me donne les capacités de l'API, afin de découvrir les polices, formats et templates disponibles. + +**Critères d'acceptation:** +- `GET /health` retourne: `status`, `version`, `fonts[]`, `formats[]`, `templates[]` +- L'endpoint est public (pas de clé API requise) +- Le temps de réponse est inférieur à 50ms + +--- + +## Epic 8 — Landing Page & Conversion + +### US-8.1 · Page de vente avec pricing +**Persona:** Visitor +**Priorité:** P0 + +> En tant que visiteur, je veux comprendre le produit, voir les prix et m'inscrire en moins de 2 minutes, afin de commencer à utiliser l'API rapidement. + +**Critères d'acceptation:** +- La page contient: hero, stats, exemples de code, comparaison Puppeteer, pricing, FAQ, CTA +- Chaque bouton de pricing renvoie vers un Stripe Payment Link +- Le bouton "Free" renvoie vers un formulaire d'inscription par email +- La page est responsive (mobile-first) +- Le temps de chargement est inférieur à 2 secondes + +--- + +### US-8.2 · Démo interactive sur la landing page +**Persona:** Visitor +**Priorité:** P2 + +> En tant que visiteur, je veux tester le rendu en live sur la landing page, afin de voir la qualité avant de m'inscrire. + +**Critères d'acceptation:** +- Une section "Try it" permet de saisir un titre et une description +- Le rendu est calculé côté client (Canvas) en temps réel +- Un bouton "Generate via API" montre la requête curl correspondante +- Le visuel se met à jour à chaque frappe + +--- + +## Epic 9 — SDK & Documentation + +### US-9.1 · SDK TypeScript +**Persona:** Dev +**Priorité:** P1 + +> En tant que développeur, je veux un SDK TypeScript officiel, afin d'intégrer l'API sans écrire de fetch manuellement. + +**Critères d'acceptation:** +- Package npm: `@atypical-consulting/og-engine-sdk` +- Méthodes: `render()`, `validate()`, `batch()`, `usage()`, `health()` +- Types TypeScript pour toutes les requêtes et réponses +- Gestion automatique de l'authentification (clé passée au constructeur) +- Retry automatique sur erreur 5xx (3 retries, exponential backoff 200ms/400ms/800ms) +- README avec exemples + +--- + +### US-9.2 · Documentation OpenAPI +**Persona:** Dev +**Priorité:** P1 + +> En tant que développeur, je veux une documentation OpenAPI/Swagger de l'API, afin de comprendre tous les endpoints et paramètres disponibles. + +**Critères d'acceptation:** +- Spec OpenAPI 3.1 disponible à `/docs/openapi.json` +- Interface Swagger UI disponible à `/docs` +- Chaque endpoint documenté avec exemples de requête et réponse +- Les codes d'erreur sont documentés + +--- + +## Epic 10 — Fonctionnalités avancées + +### US-10.1 · Templates custom (JSON DSL) +**Persona:** Dev +**Priorité:** P2 (Plan Scale uniquement) + +> En tant que développeur Scale, je veux définir mes propres templates en JSON, afin de créer des visuels totalement sur mesure. + +**Critères d'acceptation:** +- `POST /templates` crée un template custom avec un nom et une définition JSON +- La définition JSON spécifie les zones de texte (position, taille, police, couleur), les éléments décoratifs et les contraintes +- Le template est référençable dans `/render` via son nom +- Maximum 10 templates custom par compte +- Réservé au plan Scale + +--- + +### US-10.2 · Webhook de régénération +**Persona:** Dev +**Priorité:** P2 (Plan Pro+) + +> En tant que développeur, je veux configurer un webhook qui régénère automatiquement mes images quand mon contenu change, afin de garder mes visuels à jour sans intervention manuelle. + +**Critères d'acceptation:** +- `POST /webhooks` configure une URL de callback +- Quand le dev appelle `/render` avec un `content_id`, l'image est associée à cet ID +- Un appel à `POST /regenerate` avec le `content_id` et le nouveau contenu régénère l'image +- Le webhook notifie l'URL configurée avec l'URL de la nouvelle image +- Réservé aux plans Pro et Scale + +--- + +### US-10.3 · Cache CDN +**Persona:** Dev +**Priorité:** P2 (Plan Pro+) + +> En tant que développeur, je veux que mes images générées soient servies depuis un CDN, afin d'avoir des temps de réponse ultra-rapides pour les utilisateurs finaux. + +**Critères d'acceptation:** +- Chaque image générée est cachée avec un hash du contenu +- Si le même contenu est demandé à nouveau, l'image est servie depuis le cache +- Header `X-Cache: HIT` ou `X-Cache: MISS` dans la réponse +- Le cache expire après 7 jours ou sur régénération manuelle +- Le cache est inclus dans les plans Pro et Scale + +--- + +### US-10.4 · AI text fitting +**Persona:** Dev / Marketer +**Priorité:** P2 + +> En tant qu'utilisateur, je veux que l'API ajuste automatiquement la taille du texte pour que tout rentre dans le format choisi, afin de ne jamais avoir de texte tronqué. + +**Critères d'acceptation:** +- Le champ `style.autoFit: true` active l'ajustement automatique +- L'algorithme réduit progressivement `titleSize` jusqu'à ce que le titre tienne en max N lignes +- La taille minimale est 24px pour le titre, 12px pour la description +- La réponse indique la taille finale utilisée dans les headers + +--- + +## Résumé par priorité + +| Priorité | Stories | Epic | +|----------|---------|------| +| **P0** | US-1.1, 1.2, 2.1, 2.2, 2.3, 3.1, 5.1, 5.3, 6.1, 6.2, 7.1, 8.1 | MVP — 12 stories | +| **P1** | US-1.3, 1.4, 2.4, 2.5, 2.6, 2.7, 3.2, 4.1, 5.2, 9.1, 9.2 | Post-launch — 11 stories | +| **P2** | US-4.2, 8.2, 10.1, 10.2, 10.3, 10.4 | Itération — 6 stories | + +**Total: 29 user stories across 10 epics.** diff --git a/docs/analysis/landing-page.jsx b/docs/analysis/landing-page.jsx new file mode 100644 index 0000000..2666bf8 --- /dev/null +++ b/docs/analysis/landing-page.jsx @@ -0,0 +1,311 @@ +import { useState, useEffect, useRef } from "react"; + +const ACCENT = "#38ef7d"; + +function Counter({ target, suffix, duration = 1200 }) { + const [val, setVal] = useState(0); + const ref = useRef(null); + useEffect(() => { + const obs = new IntersectionObserver(([e]) => { + if (e.isIntersecting) { + const start = performance.now(); + const tick = () => { + const p = Math.min(1, (performance.now() - start) / duration); + setVal(Math.round(target * (1 - Math.pow(1 - p, 3)))); + if (p < 1) requestAnimationFrame(tick); + }; + requestAnimationFrame(tick); + obs.disconnect(); + } + }, { threshold: 0.3 }); + if (ref.current) obs.observe(ref.current); + return () => obs.disconnect(); + }, [target, duration]); + return {val.toLocaleString()}{suffix}; +} + +function Section({ children, id, style = {} }) { + return
{children}
; +} +function Label({ children }) { + return
{children}
; +} +function H2({ children }) { + return

{children}

; +} + +function Code({ children, lang }) { + return ( +
+ {lang &&
{lang}
} +
{children}
+
+ ); +} + +function PricingCard({ name, price, calls, features, cta, popular, color }) { + return ( +
+
+ {popular &&
POPULAIRE
} +
{name}
+
+ {price} + /mois +
+
{calls}
+ {features.map((f, i) => ( +
+ {f} +
+ ))} + +
+
+ ); +} + +function FAQ({ q, a }) { + const [open, setOpen] = useState(false); + return ( +
+ + {open &&
{a}
} +
+ ); +} + +const CURL_EXAMPLE = `curl -X POST https://api.og-engine.com/render \\ + -H "Authorization: Bearer YOUR_API_KEY" \\ + -H "Content-Type: application/json" \\ + -d '{ + "format": "og", + "title": "Mon article de blog", + "description": "Une description captivante.", + "author": "Mon Entreprise", + "style": { + "accent": "#38ef7d", + "font": "Outfit" + } + }' --output og-image.png`; + +const RESPONSE_EXAMPLE = `HTTP/1.1 200 OK +Content-Type: image/png +X-Render-Time-Ms: 2.34 +X-Title-Lines: 1 +X-Layout-Overflow: false + +[binary PNG — 42kb]`; + +const JS_EXAMPLE = `const og = new OGEngine("YOUR_API_KEY") + +const image = await og.render({ + format: "og", + title: post.title, + description: post.excerpt, + author: post.author, + style: { accent: "#38ef7d", font: "Outfit" } +}) + +await Bun.write("og.png", image)`; + +const NEXTJS_EXAMPLE = `// app/api/og/[slug]/route.ts +export async function GET(req, { params }) { + const post = await getPost(params.slug) + + const res = await fetch( + "https://api.og-engine.com/render", + { + method: "POST", + headers: { + "Authorization": "Bearer " + API_KEY, + "Content-Type": "application/json" + }, + body: JSON.stringify({ + format: "og", + title: post.title, + description: post.excerpt + }) + } + ) + + return new Response(res.body, { + headers: { "Content-Type": "image/png" } + }) +}`; + +export default function App() { + return ( +
+
+ + {/* HERO */} +
+
+ + API disponible +
+

+ Générez des images
sans navigateur. +

+

+ Une API qui remplace Puppeteer pour créer des images OG, bannières et visuels dynamiques. + 500x plus rapide. Zéro Chrome. +

+ +
+ + {/* STATS */} +
+ {[{ val: 500, suffix: "x", label: "plus rapide" }, { val: 2, suffix: "ms", label: "par image" }, { val: 0, suffix: "", label: "navigateur" }].map((s, i) => ( +
+
+
{s.label}
+
+ ))} +
+ + {/* HOW IT WORKS */} +
+ +

Un POST, une image.

+

+ Envoyez votre contenu en JSON, recevez un PNG en retour. Pas de Chrome, pas de Puppeteer. +

+ {CURL_EXAMPLE} + {RESPONSE_EXAMPLE} +
+ + {/* USE CASES */} +
+ +

Remplacez votre infra.

+
+ {[ + { icon: "🔗", title: "Images OG / Social Cards", desc: "Générez une miniature unique pour chaque page. SEO et partage optimisés." }, + { icon: "📧", title: "Bannières email dynamiques", desc: "Images personnalisées par destinataire. Prénom, offre, date — à la volée." }, + { icon: "🛒", title: "E-commerce", desc: "Visuels produits avec prix et badges promo. Le texte s'adapte toujours." }, + { icon: "✅", title: "Validation de texte", desc: "Endpoint /validate : vérifiez si votre copie tient dans un format. Gratuit et illimité." }, + ].map((c, i) => ( +
+
{c.icon}
+
{c.title}
+
{c.desc}
+
+ ))} +
+
+ + {/* COMPARISON */} +
+ +

Puppeteer vs OG Engine

+
+ {[ + ["", "Puppeteer", "OG Engine"], + ["Rendu", "~130ms (warm)", "~22ms"], + ["Mémoire", "300-500MB", "~10MB"], + ["Concurrence", "5-10/inst.", "500+/inst."], + ["Cold start", "2-5 sec", "50ms"], + ["Infra", "Chrome+Xvfb", "Node.js"], + ["Coût", "€€€", "€"], + ].map((row, i) => ( +
+ {row.map((cell, j) => ( +
0 ? ACCENT : j === 1 && i > 0 ? "#ef4444" : undefined, fontWeight: j === 0 ? 600 : 400, background: j === 2 && i > 0 ? `${ACCENT}05` : "transparent" }}>{cell}
+ ))} +
+ ))} +
+
+ + {/* PRICING */} +
+ +

Simple. Prévisible.

+

+ Pas de frais cachés. Pas d'engagement. +

+
+ + + + +
+
+ Besoin de plus ? Contactez-nous +
L'endpoint /validate est gratuit et illimité. +
+
+ + {/* INTEGRATION */} +
+ +

5 minutes pour intégrer.

+ {JS_EXAMPLE} + {NEXTJS_EXAMPLE} +
+ + {/* FAQ */} +
+ +

FAQ

+
+ + + + + + +
+
+ + {/* CTA */} +
+

+ Prêt à tuer Puppeteer ? +

+

500 appels gratuits. Aucune carte requise.

+ + Créer mon compte gratuitement + +
+ +
+ OG Engine · Propulsé par Pretext · 2026 +
+ + +
+ ); +} diff --git a/docs/analysis/og-engine.jsx b/docs/analysis/og-engine.jsx new file mode 100644 index 0000000..da6da72 --- /dev/null +++ b/docs/analysis/og-engine.jsx @@ -0,0 +1,568 @@ +import { useState, useEffect, useRef, useCallback } from "react"; + +// ─── Text measurement (Pretext principle) ──────────────────────────────────── +let _ctx = null; +function getCtx() { + if (!_ctx) { const c = document.createElement("canvas"); _ctx = c.getContext("2d"); } + return _ctx; +} +function measureLines(text, font, maxW) { + if (!text || maxW <= 0) return []; + const ctx = getCtx(); ctx.font = font; + const lines = []; + for (const para of text.split("\n")) { + if (!para.trim()) { lines.push({ text: "", w: 0 }); continue; } + let cur = "", curW = 0; + for (const word of para.split(/\s+/)) { + if (!word) continue; + const ww = ctx.measureText(word).width; + const sp = cur ? ctx.measureText(" ").width : 0; + if (curW + sp + ww > maxW && cur) { lines.push({ text: cur, w: curW }); cur = word; curW = ww; } + else { cur += (cur ? " " : "") + word; curW += sp + ww; } + } + if (cur) lines.push({ text: cur, w: curW }); + } + return lines; +} +function tw(text, font) { const ctx = getCtx(); ctx.font = font; return ctx.measureText(text).width; } + +// ─── Config ────────────────────────────────────────────────────────────────── +const FORMATS = { + og: { w: 1200, h: 630, label: "OG", ratio: "1200×630" }, + twitter: { w: 1200, h: 675, label: "Twitter", ratio: "1200×675" }, + square: { w: 1080, h: 1080, label: "Square", ratio: "1080²" }, + linkedin: { w: 1200, h: 627, label: "LinkedIn", ratio: "1200×627" }, + story: { w: 1080, h: 1920, label: "Story", ratio: "1080×1920" }, +}; +const ACCENTS = [ + "#38ef7d","#67e8f9","#c4b5fd","#fbbf24","#fb7185","#fb923c","#e2e8f0","#a3e635", +]; +const GRADIENTS = [ + { name: "Void", stops: ["#0c0f1a","#080a12"] }, + { name: "Deep Sea", stops: ["#0a1628","#061220"] }, + { name: "Ember", stops: ["#1a0a0a","#120808"] }, + { name: "Forest", stops: ["#0a1a10","#061208"] }, + { name: "Plum", stops: ["#150a1a","#0e0812"] }, + { name: "Slate", stops: ["#12141a","#0a0c10"] }, +]; +const FONTS = [ + { name: "System", family: "system-ui, -apple-system, sans-serif", google: null }, + { name: "Outfit", family: "'Outfit', sans-serif", google: "Outfit:wght@400;700;800" }, + { name: "Playfair", family: "'Playfair Display', serif", google: "Playfair+Display:wght@400;700;800" }, + { name: "Sora", family: "'Sora', sans-serif", google: "Sora:wght@400;600;800" }, + { name: "Space Grotesk", family: "'Space Grotesk', sans-serif", google: "Space+Grotesk:wght@400;600;700" }, + { name: "DM Serif", family: "'DM Serif Display', serif", google: "DM+Serif+Display" }, + { name: "Bricolage", family: "'Bricolage Grotesque', sans-serif", google: "Bricolage+Grotesque:wght@400;700;800" }, + { name: "Crimson Pro", family: "'Crimson Pro', serif", google: "Crimson+Pro:wght@400;600;800" }, +]; +const LAYOUTS = { left: "Left", center: "Center", bottom: "Bottom" }; + +// ─── Load Google Fonts ─────────────────────────────────────────────────────── +const loadedFonts = new Set(); +function loadGFont(entry) { + if (!entry.google || loadedFonts.has(entry.name)) return; + loadedFonts.add(entry.name); + const link = document.createElement("link"); + link.rel = "stylesheet"; + link.href = `https://fonts.googleapis.com/css2?family=${entry.google}&display=swap`; + document.head.appendChild(link); +} + +// ─── Canvas Render ─────────────────────────────────────────────────────────── +function renderCard(canvas, o) { + const { title, desc, author, tag, format, accent, layout, titleSize, descSize, + fontEntry, gradient, bgImage, overlayOpacity } = o; + const { w: W, h: H } = FORMATS[format]; + canvas.width = W; canvas.height = H; + const ctx = canvas.getContext("2d"); + const s = Math.max(W, H) / 1200; + const ff = fontEntry.family; + + // ── BG image or gradient + if (bgImage) { + ctx.drawImage(bgImage, 0, 0, W, H); + ctx.fillStyle = `rgba(0,0,0,${overlayOpacity})`; + ctx.fillRect(0, 0, W, H); + } else { + const bg = ctx.createLinearGradient(0, 0, W * 0.3, H); + bg.addColorStop(0, gradient.stops[0]); + bg.addColorStop(1, gradient.stops[1]); + ctx.fillStyle = bg; ctx.fillRect(0, 0, W, H); + } + + // ── Grid + ctx.strokeStyle = accent + "05"; ctx.lineWidth = 1; + const gs = 50 * s; + for (let x = 0; x < W; x += gs) { ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke(); } + for (let y = 0; y < H; y += gs) { ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(W, y); ctx.stroke(); } + + // ── Glow + const g1 = ctx.createRadialGradient(W * 0.15, H * 0.8, 0, W * 0.15, H * 0.8, W * 0.35); + g1.addColorStop(0, accent + "10"); g1.addColorStop(1, "transparent"); + ctx.fillStyle = g1; ctx.fillRect(0, 0, W, H); + + // ── Layout + const px = Math.round(64 * s); + const cW = W - px * 2; + const isC = layout === "center", isB = layout === "bottom"; + + // tag + const tagFont = `600 ${Math.round(14 * s)}px ${ff}`; + let tagH = 0; + if (tag) { tagH = 28 * s + 16 * s; } + + // title + const tFont = `800 ${Math.round(titleSize * s)}px ${ff}`; + const tLH = Math.round(titleSize * 1.2 * s); + const tLines = measureLines(title || "Untitled", tFont, cW); + const mxT = format === "story" ? 5 : 3; + const vT = tLines.slice(0, mxT); + + // desc + const dFont = `400 ${Math.round(descSize * s)}px ${ff}`; + const dLH = Math.round(descSize * 1.55 * s); + const dLines = measureLines(desc || "", dFont, cW); + const mxD = format === "story" ? 6 : 4; + const vD = dLines.slice(0, mxD); + + const aFont = `700 ${Math.round(18 * s)}px ${ff}`; + const aH = 24 * s; + const g2v = 16 * s, g3v = 20 * s, g4v = 28 * s; + const totalH = tagH + vT.length * tLH + g3v + vD.length * dLH + g4v + aH; + + let y = isB ? H - px - totalH : isC ? (H - totalH) / 2 : Math.round(px * 1.2); + const align = isC ? "center" : "left"; + const xP = isC ? W / 2 : px; + ctx.textAlign = align; ctx.textBaseline = "top"; + + // accent bar + if (!isC && !bgImage) { + ctx.fillStyle = accent; + ctx.fillRect(px, y, 4 * s, Math.min(vT.length * tLH + tagH, 80 * s)); + } + + // tag pill + if (tag) { + ctx.font = tagFont; + const tgW = tw(tag.toUpperCase(), tagFont); + const pW = tgW + 24 * s, pH = 28 * s; + const pX = isC ? (W - pW) / 2 : px; + ctx.fillStyle = accent + "18"; + ctx.beginPath(); ctx.roundRect(pX, y, pW, pH, pH / 2); ctx.fill(); + ctx.fillStyle = accent; ctx.font = tagFont; + ctx.textAlign = "center"; + ctx.fillText(tag.toUpperCase(), pX + pW / 2, y + pH / 2 - 7 * s); + ctx.textAlign = align; + y += tagH; + } + + // title + ctx.fillStyle = "#f1f5f9"; ctx.font = tFont; + for (let i = 0; i < vT.length; i++) { + let t = vT[i].text; + if (i === vT.length - 1 && tLines.length > mxT) t += "…"; + ctx.fillText(t, xP, y); y += tLH; + } + y += g3v; + + // desc + ctx.fillStyle = bgImage ? "#d1d5db" : "#94a3b8"; ctx.font = dFont; + for (let i = 0; i < vD.length; i++) { + let t = vD[i].text; + if (i === vD.length - 1 && dLines.length > mxD) t += "…"; + ctx.fillText(t, xP, y); y += dLH; + } + y += g4v; + + // author + ctx.fillStyle = accent; ctx.font = aFont; + ctx.fillText(author || "", xP, y); + + // badge + ctx.fillStyle = accent + "33"; + ctx.font = `500 ${Math.round(12 * s)}px ui-monospace, monospace`; + ctx.textAlign = "right"; + ctx.fillText("⚡ no browser required", W - px, H - px * 0.7); + ctx.textAlign = "left"; + + // frame + ctx.strokeStyle = accent + "12"; ctx.lineWidth = 1; + const fr = 24 * s; ctx.strokeRect(fr, fr, W - fr * 2, H - fr * 2); + + return { tTotal: tLines.length, tVis: vT.length, dTotal: dLines.length, dVis: vD.length }; +} + +// ─── Small UI ──────────────────────────────────────────────────────────────── +const Chip = ({ label, active, color, onClick, small }) => ( + +); + +const Dot = ({ hex, active, onClick }) => ( + +); + +const Field = ({ label, value, onChange, multiline, accent }) => { + const s = { + width: "100%", padding: "9px 11px", borderRadius: 8, + border: "1px solid rgba(255,255,255,0.08)", background: "rgba(255,255,255,0.03)", + color: "#e2e8f0", fontSize: 13, fontFamily: "inherit", outline: "none", lineHeight: 1.5, + }; + return ( +
+ + {multiline + ? +
+ + +
+ ` +} +``` + +- [ ] **Step 2: Create webhooks view** + +Create `src/dashboard/views/webhooks.ts`: + +```typescript +import { escapeHtml } from '../../utils/html' +import type { WebhookRecord } from '../../db/index' + +export function webhooksView(webhooks: WebhookRecord[]): string { + const listHtml = webhooks.length === 0 + ? '

No webhooks

Create a webhook to trigger renders automatically.

' + : webhooks.map((w) => ` + + ${escapeHtml(w.url)} + ${w.active ? 'Active' : 'Inactive'} + ${new Date(w.created_at).toLocaleString()} + + + + + + + `).join('') + + return ` + + +
+ + ${listHtml}
URLStatusCreatedActions
+
+ +
+

Create Webhook

+
+
+ + +
+ +
+
+ ` +} +``` + +- [ ] **Step 3: Create settings view** + +Create `src/dashboard/views/settings.ts`: + +```typescript +import { escapeHtml } from '../../utils/html' +import type { UserRecord } from '../../types' + +export function settingsView(user: UserRecord): string { + return ` + + +
+
+
+ +
${escapeHtml(user.email)}
+ To change your email, log in with a different address. +
+ +
+ +
${escapeHtml(user.id)}
+
+ +
+ +
${new Date(user.created_at).toLocaleDateString()}
+
+
+
+ +
+

Danger Zone

+
+

Permanently delete your account, all API keys, and render history. This cannot be undone.

+ +
+
+ ` +} +``` + +- [ ] **Step 4: Add routes for all three** + +In `src/dashboard/routes.ts`, add: + +```typescript +import { listCustomTemplatesByUser, createCustomTemplate, deleteCustomTemplate } from '../db/index' +import { listWebhooksByUser, createWebhook, deleteWebhook } from '../db/index' +import { templatesView } from './views/templates' +import { webhooksView } from './views/webhooks' +import { settingsView } from './views/settings' + +// Templates +dashboardRoutes.get('/dashboard/templates', (c) => { + const user = c.get('user' as never) as UserRecord + const templates = user.plan === 'scale' ? listCustomTemplatesByUser(user.id) : [] + return respond(c, 'Templates', '/dashboard/templates', templatesView(user, templates)) +}) + +dashboardRoutes.post('/dashboard/templates', async (c) => { + const user = c.get('user' as never) as UserRecord + if (user.plan !== 'scale') return c.text('Upgrade required', 402) + const form = await c.req.parseBody() + const name = form.name as string + const definition = form.definition as string + try { + const parsed = JSON.parse(definition) + createCustomTemplate(user.id, name, parsed) + } catch { + return c.html('
Invalid JSON
', 400) + } + const templates = listCustomTemplates(user.id) + return c.html(templatesView(user, templates)) +}) + +dashboardRoutes.delete('/dashboard/templates/:id', (c) => { + const user = c.get('user' as never) as UserRecord + const id = c.req.param('id') + deleteCustomTemplate(id) + return c.text('') +}) + +// Webhooks +dashboardRoutes.get('/dashboard/webhooks', (c) => { + const user = c.get('user' as never) as UserRecord + const webhooks = listWebhooksByUser(user.id) + return respond(c, 'Webhooks', '/dashboard/webhooks', webhooksView(webhooks)) +}) + +dashboardRoutes.post('/dashboard/webhooks', async (c) => { + const user = c.get('user' as never) as UserRecord + const form = await c.req.parseBody() + const url = form.url as string + createWebhook(user.id, url, {}) + const webhooks = listWebhooksByUser(user.id) + return c.html(webhooksView(webhooks)) +}) + +dashboardRoutes.delete('/dashboard/webhooks/:id', (c) => { + const id = c.req.param('id') + deleteWebhook(id) + return c.text('') +}) + +dashboardRoutes.post('/dashboard/webhooks/:id/test', async (c) => { + const id = c.req.param('id') + try { + const webhook = (await import('../db/index')).findWebhookById(id) + if (!webhook) return c.html('Not found') + const res = await fetch(webhook.url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ test: true, timestamp: new Date().toISOString() }), + }) + return c.html(`${res.status}`) + } catch { + return c.html('Failed') + } +}) + +// Settings +dashboardRoutes.get('/dashboard/settings', (c) => { + const user = c.get('user' as never) as UserRecord + return respond(c, 'Settings', '/dashboard/settings', settingsView(user)) +}) + +dashboardRoutes.delete('/dashboard/settings/account', async (c) => { + const user = c.get('user' as never) as UserRecord + // Cancel Stripe subscription if exists + if (user.stripe_subscription_id && process.env.STRIPE_SECRET_KEY) { + try { + const stripe = new (await import('stripe')).default(process.env.STRIPE_SECRET_KEY) + await stripe.subscriptions.cancel(user.stripe_subscription_id) + } catch { + // Best effort + } + } + // Deactivate user and all keys + getDb().prepare('UPDATE api_keys SET active = 0 WHERE user_id = ?').run(user.id) + getDb().prepare('UPDATE users SET active = 0 WHERE id = ?').run(user.id) + getDb().prepare('DELETE FROM sessions WHERE user_id = ?').run(user.id) + // Clear cookie and redirect + const { clearSessionCookie } = await import('../auth/middleware') + clearSessionCookie(c) + return c.redirect('/auth/login', 302) +}) +``` + +- [ ] **Step 5: Note on listCustomTemplates/listWebhooks** + +The existing `listCustomTemplates(apiKeyId)` and `listWebhooks(apiKeyId)` take an `apiKeyId`. These need to be updated to accept `userId` instead, since a user may have multiple keys. Update the WHERE clause from `api_key_id = ?` to `api_key_id IN (SELECT id FROM api_keys WHERE user_id = ?)` — or better, add a `user_id` column to `custom_templates` and `webhooks` tables during the migration task. + +For now, the simplest approach is to query by looking up all the user's key IDs first: + +```typescript +export function listCustomTemplatesByUser(userId: string): CustomTemplateRecord[] { + return getDb().prepare(` + SELECT ct.* FROM custom_templates ct + JOIN api_keys ak ON ct.api_key_id = ak.id + WHERE ak.user_id = ? + ORDER BY ct.updated_at DESC + `).all(userId) as CustomTemplateRecord[] +} + +export function listWebhooksByUser(userId: string): WebhookRecord[] { + return getDb().prepare(` + SELECT w.* FROM webhooks w + JOIN api_keys ak ON w.api_key_id = ak.id + WHERE ak.user_id = ? + ORDER BY w.created_at DESC + `).all(userId) as WebhookRecord[] +} +``` + +Update the route handlers to use these new functions. + +- [ ] **Step 6: Run full test suite** + +Run: `bun run test` +Expected: PASS + +- [ ] **Step 7: Commit** + +```bash +git add src/dashboard/views/ src/dashboard/routes.ts src/db/index.ts +git commit -m "feat(dashboard): add templates, webhooks, and settings views" +``` + +--- + +## Phase 4: OpenAPI / Swagger + +### Task 18: Verify Zod v4 compatibility and set up OpenAPI + +**Files:** +- Modify: `package.json` +- Create: `src/openapi/spec.ts` +- Create: `src/openapi/swagger.ts` +- Modify: `src/index.ts` +- Test: `tests/openapi.test.ts` + +- [ ] **Step 1: Test Zod v4 compatibility** + +Run: `bun add @hono/zod-openapi @hono/swagger-ui` + +Then create a quick test to check if it works with Zod v4: + +```typescript +// Quick check — run this in a temp file +import { z } from 'zod' +import { createRoute } from '@hono/zod-openapi' + +const testRoute = createRoute({ + method: 'get', + path: '/test', + responses: { + 200: { + description: 'OK', + content: { 'application/json': { schema: z.object({ ok: z.boolean() }) } }, + }, + }, +}) +console.log('Zod v4 + @hono/zod-openapi: compatible') +``` + +Run: `bun run /tmp/test-zod-compat.ts` + +If this fails, fall back to a static OpenAPI spec. See step 3b. + +- [ ] **Step 2: Write failing test** + +Create `tests/openapi.test.ts`: + +```typescript +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { Hono } from 'hono' + +describe('OpenAPI', () => { + let app: Hono + + beforeEach(async () => { + process.env.DATABASE_URL = ':memory:' + const { getDb, migrate } = await import('../src/db/index') + migrate(getDb()) + + // Use the full app for this test + const mod = await import('../src/index') + app = (mod as any).app ?? new Hono() + }) + + afterEach(() => { + const { closeDb } = require('../src/db/index') + closeDb() + delete process.env.DATABASE_URL + }) + + it('GET /openapi.json returns valid OpenAPI spec', async () => { + const res = await app.request('/openapi.json') + expect(res.status).toBe(200) + const spec = await res.json() + expect(spec.openapi).toMatch(/^3\./) + expect(spec.info.title).toBe('OG Engine API') + expect(spec.paths['/render']).toBeDefined() + expect(spec.paths['/validate']).toBeDefined() + expect(spec.paths['/health']).toBeDefined() + }) + + it('GET /docs returns Swagger UI HTML', async () => { + const res = await app.request('/docs') + expect(res.status).toBe(200) + const html = await res.text() + expect(html).toContain('swagger') + }) +}) +``` + +- [ ] **Step 3a: Implement with @hono/zod-openapi (if compatible)** + +Create `src/openapi/spec.ts`: + +```typescript +import { OpenAPIHono, createRoute, z } from '@hono/zod-openapi' +import { renderSchema, validateSchema, batchSchema } from '../schemas/request' + +export function createOpenApiSpec() { + const spec = { + openapi: '3.1.0', + info: { + title: 'OG Engine API', + description: 'Server-side image generation API. Send JSON, get back PNG/WebP.', + version: '0.1.0', + }, + servers: [{ url: '/' }], + paths: { + '/render': { + post: { + summary: 'Generate an image', + description: 'Generate an OG image from text + configuration.', + tags: ['Render'], + security: [{ bearerAuth: [] }], + requestBody: { + required: true, + content: { 'application/json': { schema: { $ref: '#/components/schemas/RenderRequest' } } }, + }, + responses: { + 200: { description: 'Rendered image', content: { 'image/png': {} } }, + 400: { description: 'Invalid request' }, + 401: { description: 'Unauthorized' }, + 429: { description: 'Quota exceeded' }, + }, + }, + }, + '/validate': { + post: { + summary: 'Check if text fits', + description: 'Check if text fits a given layout without generating an image.', + tags: ['Validate'], + requestBody: { + required: true, + content: { 'application/json': { schema: { $ref: '#/components/schemas/ValidateRequest' } } }, + }, + responses: { + 200: { description: 'Validation result', content: { 'application/json': {} } }, + }, + }, + }, + '/render/from-url': { + post: { + summary: 'Render from URL', + description: 'Fetch OG tags from a URL and render a card automatically.', + tags: ['Render'], + security: [{ bearerAuth: [] }], + requestBody: { + required: true, + content: { 'application/json': { schema: { type: 'object', properties: { url: { type: 'string', format: 'uri' }, format: { type: 'string' }, style: { type: 'object' }, overrides: { type: 'object' } }, required: ['url'] } } }, + }, + responses: { 200: { description: 'Rendered image' } }, + }, + }, + '/render/batch': { + post: { + summary: 'Batch render', + description: 'Render multiple images in one request (Pro+ only).', + tags: ['Render'], + security: [{ bearerAuth: [] }], + requestBody: { + required: true, + content: { 'application/json': { schema: { $ref: '#/components/schemas/BatchRequest' } } }, + }, + responses: { 200: { description: 'ZIP archive of images' }, 402: { description: 'Plan upgrade required' } }, + }, + }, + '/health': { + get: { + summary: 'Health check', + description: 'Service discovery — available fonts, formats, templates.', + tags: ['System'], + responses: { 200: { description: 'Service status', content: { 'application/json': {} } } }, + }, + }, + '/auth/register': { + post: { + summary: 'Register for an API key', + description: 'Create a free API key. Returns the key immediately and sends it via email.', + tags: ['Auth'], + requestBody: { + required: true, + content: { 'application/json': { schema: { type: 'object', properties: { email: { type: 'string', format: 'email' } }, required: ['email'] } } }, + }, + responses: { 201: { description: 'API key created' }, 200: { description: 'Key already exists' } }, + }, + }, + '/usage': { + get: { + summary: 'Get usage stats', + description: 'View current quota usage and statistics.', + tags: ['Account'], + security: [{ bearerAuth: [] }], + responses: { 200: { description: 'Usage statistics' } }, + }, + }, + '/billing/portal': { + get: { + summary: 'Billing portal', + description: 'Get a link to the Stripe Customer Portal.', + tags: ['Account'], + security: [{ bearerAuth: [] }], + responses: { 200: { description: 'Portal URL' } }, + }, + }, + }, + components: { + securitySchemes: { + bearerAuth: { type: 'http', scheme: 'bearer', description: 'API key (oge_sk_...)' }, + }, + schemas: { + RenderRequest: { type: 'object', description: 'See /docs for full schema' }, + ValidateRequest: { type: 'object', description: 'See /docs for full schema' }, + BatchRequest: { type: 'object', description: 'See /docs for full schema' }, + }, + }, + } + return spec +} +``` + +Note: If `@hono/zod-openapi` works with Zod v4, convert this to use `createRoute()` with proper Zod schema references for auto-generation. If not, this static spec is the fallback. + +- [ ] **Step 3b: Create Swagger UI route** + +Create `src/openapi/swagger.ts`: + +```typescript +import { Hono } from 'hono' +import { createOpenApiSpec } from './spec' + +export const openapiRoutes = new Hono() + +openapiRoutes.get('/openapi.json', (c) => { + return c.json(createOpenApiSpec()) +}) + +openapiRoutes.get('/docs', (c) => { + return c.html(` + + + + OG Engine API — Swagger + + + +
+ + + +`) +}) +``` + +- [ ] **Step 4: Register in index.ts** + +In `src/index.ts`, add: + +```typescript +import { openapiRoutes } from './openapi/swagger' +app.route('/', openapiRoutes) +``` + +- [ ] **Step 5: Run test** + +Run: `bun run test -- tests/openapi.test.ts` +Expected: PASS + +- [ ] **Step 6: Run full test suite** + +Run: `bun run test` +Expected: PASS + +- [ ] **Step 7: Commit** + +```bash +git add src/openapi/ tests/openapi.test.ts src/index.ts package.json bun.lockb +git commit -m "feat(openapi): add OpenAPI spec and Swagger UI at /docs" +``` + +--- + +## Phase 5: Integration & Polish + +### Task 19: End-to-end smoke test + +**Files:** +- Test: `tests/e2e.test.ts` + +- [ ] **Step 1: Write full flow test** + +Create `tests/e2e.test.ts`: + +```typescript +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { getDb, migrate, findMagicLinkByToken, createMagicLinkToken } from '../src/db/index' + +describe('end-to-end: register → login → dashboard', () => { + beforeEach(() => { + process.env.DATABASE_URL = ':memory:' + process.env.AUTH_ENABLED = 'true' + migrate(getDb()) + }) + + afterEach(() => { + const { closeDb } = require('../src/db/index') + closeDb() + delete process.env.DATABASE_URL + }) + + it('full auth flow: register → magic link → dashboard → logout', async () => { + // Dynamically import to get a fresh app instance + const { authRoutes } = await import('../src/auth/routes') + const { dashboardRoutes } = await import('../src/dashboard/routes') + const { sessionMiddleware, csrfMiddleware } = await import('../src/auth/middleware') + const { Hono } = await import('hono') + + const app = new Hono() + app.route('/', authRoutes) + app.use('/dashboard/*', sessionMiddleware()) + app.use('/dashboard/*', csrfMiddleware()) + app.route('/', dashboardRoutes) + + // 1. Login page renders + const loginRes = await app.request('/auth/login') + expect(loginRes.status).toBe(200) + + // 2. Send magic link + const sendRes = await app.request('/auth/send-link', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: 'e2e@example.com' }), + }) + expect(sendRes.status).toBe(200) + + // 3. Simulate clicking magic link (get token from DB since email is mocked) + const { createMagicLinkToken: _ } = await import('../src/auth/magic-link') + // The token was created via createMagicLinkToken in the route handler + // We need to find it — query the DB directly + const db = getDb() + const link = db.prepare('SELECT * FROM magic_links ORDER BY created_at DESC LIMIT 1').get() as any + expect(link).not.toBeNull() + + // We can't easily get the raw token from the DB (it's hashed) + // So let's create a fresh magic link for the test + const { token } = (await import('../src/auth/magic-link')).createMagicLinkToken('e2e@example.com') + + const verifyRes = await app.request(`/auth/verify?token=${token}`) + expect(verifyRes.status).toBe(302) + expect(verifyRes.headers.get('Location')).toBe('/dashboard') + const setCookie = verifyRes.headers.get('Set-Cookie')! + expect(setCookie).toContain('oge_session=') + + // Extract session token from cookie + const sessionToken = setCookie.match(/oge_session=([^;]+)/)![1] + + // 4. Dashboard loads with session + const dashRes = await app.request('/dashboard', { + headers: { Cookie: `oge_session=${sessionToken}` }, + }) + expect(dashRes.status).toBe(200) + const html = await dashRes.text() + expect(html).toContain('Overview') + expect(html).toContain('e2e@example.com') + + // 5. Logout + const logoutRes = await app.request('/auth/logout', { + method: 'POST', + headers: { Cookie: `oge_session=${sessionToken}` }, + }) + expect(logoutRes.status).toBe(302) + + // 6. Dashboard now redirects to login + const afterLogoutRes = await app.request('/dashboard', { + headers: { Cookie: `oge_session=${sessionToken}` }, + }) + expect(afterLogoutRes.status).toBe(302) + expect(afterLogoutRes.headers.get('Location')).toContain('/auth/login') + }) +}) +``` + +- [ ] **Step 2: Run test** + +Run: `bun run test -- tests/e2e.test.ts` +Expected: PASS + +- [ ] **Step 3: Run full test suite** + +Run: `bun run test` +Expected: ALL PASS + +- [ ] **Step 4: Commit** + +```bash +git add tests/e2e.test.ts +git commit -m "test: add end-to-end smoke test for auth → dashboard flow" +``` + +--- + +### Task 20: Final integration in index.ts and type-check + +**Files:** +- Modify: `src/index.ts` + +- [ ] **Step 1: Verify all routes are registered** + +Review `src/index.ts` and ensure these route groups are all registered: + +1. CORS middleware (existing) +2. Rate limiting (existing) +3. Auth middleware for API routes (existing) +4. Static file serving (`/static/*`) +5. Auth routes (`/auth/*`) +6. Session + CSRF middleware for dashboard (`/dashboard/*`) +7. Dashboard routes (`/dashboard/*`) +8. OpenAPI routes (`/openapi.json`, `/docs`) +9. Existing API routes (unchanged) + +- [ ] **Step 2: Export app for testing** + +Make sure `src/index.ts` exports the `app` instance for test use: + +```typescript +export { app } +``` + +- [ ] **Step 3: Run type-check** + +Run: `bun run type-check` +Expected: No errors + +- [ ] **Step 4: Run linter** + +Run: `bun run lint` +Fix any issues. + +- [ ] **Step 5: Run full test suite** + +Run: `bun run test` +Expected: ALL PASS + +- [ ] **Step 6: Final commit** + +```bash +git add . +git commit -m "feat: complete dashboard, auth, and swagger integration" +``` + +--- + +## Summary + +| Phase | Tasks | What it delivers | +|---|---|---| +| Phase 1: Database | Tasks 1-5 | Users table, per-user quotas, sessions, magic_links, render_history, migration script | +| Phase 2: Auth | Tasks 6-9 | Magic link flow, session middleware, CSRF protection, auth routes | +| Phase 3: Dashboard | Tasks 10-17 | htmx shell, all 8 dashboard sections, static assets | +| Phase 4: OpenAPI | Task 18 | OpenAPI spec at `/openapi.json`, Swagger UI at `/docs` | +| Phase 5: Integration | Tasks 19-20 | E2E test, final wiring, type-check | diff --git a/docs/superpowers/plans/2026-04-10-license.md b/docs/superpowers/plans/2026-04-10-license.md new file mode 100644 index 0000000..4db7068 --- /dev/null +++ b/docs/superpowers/plans/2026-04-10-license.md @@ -0,0 +1,808 @@ +# License Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add FSL-1.1-Apache-2.0 to the OG Engine server and Apache-2.0 to the SDK, plus supporting docs, so the project is unambiguously licensed and companies have a clear commercial-licensing path. + +**Architecture:** Root `LICENSE` (FSL-1.1-Apache-2.0) covers the whole repo. `sdk/LICENSE` (Apache-2.0) carves out the SDK so corporate customers can freely integrate it. Supporting files (`LICENSE-APACHE-2.0`, `COMMERCIAL-LICENSE.md`, `LICENSE-HISTORY.md`) make the terms discoverable. All file changes land in a single atomic commit so the repo is never half-licensed. + +**Tech Stack:** Plain Markdown + text files. No code changes, no tests to run (nothing functional to verify), but every file has a concrete content-check step. + +**Spec:** [`docs/superpowers/specs/2026-04-10-license-design.md`](../specs/2026-04-10-license-design.md) + +**Key decisions locked in from the spec:** +- Licensor: `Atypical Consulting SRL` +- Software: `OG Engine` +- Change License: `Apache License, Version 2.0` +- Change Date: release date + 2 years (recorded per release in `LICENSE-HISTORY.md`) +- SDK license: `Apache-2.0` (overrides the stale `"MIT"` currently in `sdk/package.json:28`) +- Commercial contact: `philippe@atypical.consulting` +- Pricing page for API plans: `https://og-engine.com/pricing/` +- Release checklist note goes in `CLAUDE.md` (no existing `RELEASING.md`) +- `LICENSE-HISTORY.md` first row uses `TBD` placeholders until `v0.1.0` actually ships + +**Current repo state to be aware of:** +- No `LICENSE` file exists anywhere in the repo. +- Root `package.json` has **no** `license` field. +- `sdk/package.json:28` declares `"license": "MIT"` (to be overridden to `Apache-2.0`). +- `README.md:412-414` has a "License" section that just says `MIT` — it must be **replaced**, not appended to. + +--- + +## File Structure + +Files created or modified by this plan: + +| Path | Action | Responsibility | +|---|---|---| +| `LICENSE` | Create | FSL-1.1-Apache-2.0 text, filled in for OG Engine. Root-level → applies to everything without its own LICENSE. | +| `LICENSE-APACHE-2.0` | Create | Canonical Apache-2.0 text. Referenced by the FSL conversion clause so readers don't have to leave the repo. | +| `sdk/LICENSE` | Create | Apache-2.0 text with our copyright notice. Scoped to `sdk/` — overrides root `LICENSE` for SDK consumers. | +| `COMMERCIAL-LICENSE.md` | Create | Plain-English explainer: who needs a commercial license, who doesn't, how to get one. | +| `LICENSE-HISTORY.md` | Create | Per-release table recording each release's Change Date (when it auto-converts to Apache-2.0). FSL requires this to be discoverable. | +| `README.md` | Modify (replace §License) | Replace the current one-line `MIT` section with a proper explainer linking to `LICENSE`, `sdk/LICENSE`, `COMMERCIAL-LICENSE.md`. | +| `package.json` | Modify (add field) | Add `"license": "SEE LICENSE IN LICENSE"` — npm's convention for non-SPDX licenses. | +| `sdk/package.json` | Modify (line 28) | Change `"license": "MIT"` to `"license": "Apache-2.0"`. | +| `CLAUDE.md` | Modify (append) | Add release-checklist note about updating `LICENSE-HISTORY.md` on each release. | + +All changes land in **one commit** at the end (Task 10). Do not commit mid-plan — the repo must transition directly from "no license" to "fully licensed." + +--- + +## Task 1: Create root `LICENSE` (FSL-1.1-Apache-2.0) + +**Files:** +- Create: `LICENSE` + +The FSL canonical template is at . The text below is the filled-in version with `$YEAR`, `$LICENSOR`, and `$SOFTWARE` substituted. + +- [ ] **Step 1: Write the file** + +Create `LICENSE` at repo root with exactly this content: + +``` +# Functional Source License, Version 1.1, Apache 2.0 Future License + +## Abbreviation + +FSL-1.1-Apache-2.0 + +## Notice + +Copyright 2026 Atypical Consulting SRL + +## Terms and Conditions + +### Licensor ("We") + +The party offering the Software under these Terms and Conditions. + +### The Software + +The "Software" is each version of the software that we make available under +these Terms and Conditions, as indicated by our inclusion of these Terms and +Conditions with the Software. + +### License Grant + +Subject to your compliance with this License Grant and the Patents, +Redistribution, and Trademark clauses below, we hereby grant you the right to +use, copy, modify, create derivative works, publish, and redistribute the +Software for any Permitted Purpose identified below. + +### Permitted Purpose + +A Permitted Purpose is any purpose other than a Competing Use. A Competing +Use means making the Software available to others in a commercial product or +service that: + +1. substitutes for the use of the Software; + +2. substitutes for any other product or service we offer using the Software + that exists as of the date we make the Software available; or + +3. offers the same or substantially similar functionality as the Software. + +Permitted Purposes specifically include using the Software: + +1. for your internal use and access; + +2. for non-commercial education; + +3. for non-commercial research; and + +4. in connection with professional services that you provide to a licensee + using the Software in accordance with these Terms and Conditions. + +### Patents + +To the extent your use for a Permitted Purpose would necessarily infringe our +patents, the license grant above includes a license under our patents. If you +make a claim against any party that the Software infringes or contributes to +the infringement of any patent, then your patent license to the Software ends +immediately. + +### Redistribution + +The Terms and Conditions apply to all copies, modifications and derivatives +of the Software. + +If you redistribute any copies, modifications or derivatives of the Software, +you must include a copy of or a link to these Terms and Conditions and not +remove any copyright notices provided in or with the Software. + +### Disclaimer + +THE SOFTWARE IS PROVIDED "AS IS" AND WITHOUT WARRANTIES OR CONDITIONS OF ANY +KIND, INCLUDING WITHOUT LIMITATION WARRANTIES OF MERCHANTABILITY, FITNESS FOR +A PARTICULAR PURPOSE, NON-INFRINGEMENT OR TITLE. OUR TOTAL LIABILITY TO YOU +FOR ANY CLAIMS ARISING OUT OF OR RELATING TO THIS LICENSE OR THE SOFTWARE, +WHETHER IN CONTRACT, TORT, STRICT LIABILITY OR OTHERWISE, IS LIMITED TO +$50. + +### Trademarks + +Except for displaying the License Details and identifying us as the origin of +the Software, you have no right under these Terms and Conditions to use our +trademarks, trade names, service marks, or product names. + +## Grant of Future License + +We hereby irrevocably grant you an additional license to use the Software +under the Apache License, Version 2.0 that is effective on the second +anniversary of the date we make the Software available. On or after that +date, you may use the Software under the Apache License, Version 2.0, in +which case the following will apply: + +Licensed under the Apache License, Version 2.0 (the "License"); you may not +use this file except in compliance with the License. You may obtain a copy +of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +License for the specific language governing permissions and limitations +under the License. +``` + +- [ ] **Step 2: Verify the file exists and contains the FSL header** + +Run: +```bash +head -1 LICENSE +``` +Expected output: +``` +# Functional Source License, Version 1.1, Apache 2.0 Future License +``` + +- [ ] **Step 3: Verify licensor and copyright year are correct** + +Run: +```bash +grep "Copyright 2026 Atypical Consulting SRL" LICENSE +``` +Expected: the grep matches one line. If it doesn't, the placeholders weren't filled in correctly. + +- [ ] **Step 4: Sanity-check against the canonical template** + +Fetch the upstream template and diff the non-fill-in lines to catch any transcription errors: +```bash +curl -s https://fsl.software/FSL-1.1-Apache-2.0.template.md -o /tmp/fsl-template.md && diff <(grep -v '\$YEAR\|\$LICENSOR\|Copyright' /tmp/fsl-template.md) <(grep -v 'Copyright 2026 Atypical Consulting SRL' LICENSE) | head -40 +``` +Expected: no differences, or only whitespace differences. If `curl` is unavailable or offline, skip this step and proceed — the text above is the authoritative fill-in. + +**Do NOT commit yet.** All changes commit together in Task 10. + +--- + +## Task 2: Create `LICENSE-APACHE-2.0` + +**Files:** +- Create: `LICENSE-APACHE-2.0` + +This is the canonical Apache License 2.0 text, included in the repo because the FSL's "Grant of Future License" clause points to it. Readers reviewing the license should be able to see exactly what Apache-2.0 says without leaving the repo. + +- [ ] **Step 1: Fetch the canonical Apache-2.0 text** + +Run from repo root: +```bash +curl -sSL https://www.apache.org/licenses/LICENSE-2.0.txt -o LICENSE-APACHE-2.0 +``` + +- [ ] **Step 2: Verify the file was written and starts with the expected header** + +Run: +```bash +head -3 LICENSE-APACHE-2.0 +``` +Expected output (the first 3 lines of the canonical Apache-2.0 text): +``` + + Apache License + Version 2.0, January 2004 +``` + +- [ ] **Step 3: Verify the file length is roughly correct** + +Run: +```bash +wc -l LICENSE-APACHE-2.0 +``` +Expected: approximately **202 lines** (give or take a couple — the canonical text is 202 lines). If you get 0 or something wildly different, the `curl` failed. + +- [ ] **Step 4: Verify no trailing HTML or error page** + +Run: +```bash +tail -3 LICENSE-APACHE-2.0 +``` +Expected: the tail should contain `END OF TERMS AND CONDITIONS` or the optional appendix boilerplate — NOT HTML tags. If you see `` or `404`, the download failed and you got an error page. Re-run Step 1. + +**Do NOT commit yet.** + +--- + +## Task 3: Create `sdk/LICENSE` (Apache-2.0 for the SDK) + +**Files:** +- Create: `sdk/LICENSE` + +The SDK gets its own copy of the Apache-2.0 text so that consumers vendoring or inspecting just the `sdk/` directory see the license that actually applies to it. Without this, a consumer who grabs only `sdk/` would fall back to the root `LICENSE` (FSL), which is wrong for the SDK. + +- [ ] **Step 1: Copy the canonical Apache-2.0 text into the SDK** + +Run from repo root: +```bash +cp LICENSE-APACHE-2.0 sdk/LICENSE +``` + +This reuses the file we just downloaded in Task 2 instead of fetching twice. The Apache-2.0 text is identical everywhere — there is no per-project fill-in in Apache-2.0 itself (the copyright notice goes in individual source files, not in the LICENSE file). + +- [ ] **Step 2: Verify the file exists and has the right content** + +Run: +```bash +diff LICENSE-APACHE-2.0 sdk/LICENSE && echo "identical" +``` +Expected output: `identical` + +- [ ] **Step 3: Verify the file is non-empty** + +Run: +```bash +wc -l sdk/LICENSE +``` +Expected: approximately 202 lines (same as `LICENSE-APACHE-2.0`). + +**Do NOT commit yet.** + +--- + +## Task 4: Create `COMMERCIAL-LICENSE.md` + +**Files:** +- Create: `COMMERCIAL-LICENSE.md` + +Plain-English explainer for prospective commercial customers. Two escape paths: the pricing page for "I just want to call the hosted API" customers, and a direct email for "I need to self-host or embed" customers. + +- [ ] **Step 1: Write the file** + +Create `COMMERCIAL-LICENSE.md` at repo root with exactly this content: + +```markdown +# Commercial License + +OG Engine's server is free to use, modify, and self-host for most purposes +under the [Functional Source License](./LICENSE). **You need a commercial +license only if:** + +- You host OG Engine as a service that your own users call (even + internally-marketed, even free-to-your-users). +- You embed OG Engine's server code inside a product you sell, license, or + distribute. +- You operate a hosted OG-image-generation service that competes with OG + Engine's own hosted API. + +**You do NOT need a commercial license if:** + +- You're calling OG Engine's hosted API at `api.og-engine.com` — that's what + your subscription plan at covers. +- You're a developer using it for personal projects, side projects, or your + own learning. +- You're an open-source project self-hosting it as part of your own OSS + stack. +- You're a company using it purely internally — e.g. rendering OG images for + your own marketing site — without exposing it to your users or customers + as a feature. + +## I just want to call the hosted API + +You're in the right place: . No commercial +license needed — your plan covers it. + +## I need to self-host or embed + +Email **philippe@atypical.consulting** with: + +- Your company name +- A one-line description of how you plan to use OG Engine +- Expected render volume per month + +We'll get back to you within 2 business days with terms. + +## Not sure which side of the line you're on? + +Email **philippe@atypical.consulting** with your use case. We'll tell you +for free. No gotchas. + +## Why this license model? + +OG Engine is built and maintained by one person. The Functional Source +License lets individuals and open-source projects use the software freely +while asking commercial users who build on top of it to help sustain +development. If you're here, thank you for reading — and for doing the right +thing. +``` + +- [ ] **Step 2: Verify the contact email is present** + +Run: +```bash +grep "philippe@atypical.consulting" COMMERCIAL-LICENSE.md | wc -l +``` +Expected: `2` (appears twice — once for self-host/embed, once for the "not sure" escape hatch). + +- [ ] **Step 3: Verify the pricing page link is present** + +Run: +```bash +grep "og-engine.com/pricing" COMMERCIAL-LICENSE.md +``` +Expected: at least one matching line. + +**Do NOT commit yet.** + +--- + +## Task 5: Create `LICENSE-HISTORY.md` + +**Files:** +- Create: `LICENSE-HISTORY.md` + +FSL requires the Change Date of each release to be discoverable. We centralize that in one file so legal reviewers can find it without crawling GitHub Releases. + +- [ ] **Step 1: Write the file** + +Create `LICENSE-HISTORY.md` at repo root with exactly this content: + +```markdown +# License History + +Every release of OG Engine ships under +[FSL-1.1-Apache-2.0](./LICENSE) and automatically converts to +[Apache License 2.0](./LICENSE-APACHE-2.0) **two years after its release +date**. This table records the converted-on date for each release. + +| Version | Release Date | Converts to Apache-2.0 on | +|---------|--------------|---------------------------| +| 0.1.0 | TBD | TBD (release date + 2 years) | + +## How this gets updated + +When a release is cut, append a new row with: + +- `Version` — the semver tag (e.g. `0.2.0`) +- `Release Date` — the date the release tag was pushed, as `YYYY-MM-DD` +- `Converts to Apache-2.0 on` — `Release Date + 2 years`, as `YYYY-MM-DD` + +See `CLAUDE.md` release checklist. +``` + +- [ ] **Step 2: Verify the TBD placeholder row is present** + +Run: +```bash +grep -E "^\| 0\.1\.0" LICENSE-HISTORY.md +``` +Expected: one matching row containing `TBD`. + +- [ ] **Step 3: Verify the file links to both licenses** + +Run: +```bash +grep -E "LICENSE|LICENSE-APACHE-2.0" LICENSE-HISTORY.md +``` +Expected: at least 2 matching lines. + +**Do NOT commit yet.** + +--- + +## Task 6: Update `README.md` — replace the `MIT` License section + +**Files:** +- Modify: `README.md:412-414` (the current one-line `MIT` section) + +The current README says the project is MIT-licensed. That is no longer true, so it must be **replaced**, not appended to. + +- [ ] **Step 1: Read the current License section to confirm lines** + +Run: +```bash +sed -n '410,416p' README.md +``` +Expected output: +``` +- Visual regression test suite + +## License + +MIT + +--- +``` + +If the content doesn't match, the file has drifted since this plan was written — stop and re-read the file around the `## License` heading before editing. + +- [ ] **Step 2: Replace the 3-line `MIT` block with the new License section** + +Use the Edit tool to replace this exact text: + +``` +## License + +MIT +``` + +with: + +``` +## License + +OG Engine's server (`src/` and everything at the repo root) is licensed under +the [Functional Source License, Version 1.1, Apache 2.0 Future License](./LICENSE) +(FSL-1.1-Apache-2.0). You can read, modify, and self-host it for any purpose +**except** making it available to third parties as a hosted service or +embedding it in a commercial product you distribute. Every release +automatically converts to [Apache-2.0](./LICENSE-APACHE-2.0) two years after +its release date — see [`LICENSE-HISTORY.md`](./LICENSE-HISTORY.md). + +The SDK (`sdk/`) is licensed under [Apache-2.0](./sdk/LICENSE) — use it freely +in any project, commercial or not. + +**Using OG Engine inside a commercial product or SaaS?** +See [`COMMERCIAL-LICENSE.md`](./COMMERCIAL-LICENSE.md) or email +**philippe@atypical.consulting**. +``` + +- [ ] **Step 3: Verify the old `MIT` line is gone** + +Run: +```bash +awk '/^## License$/,/^---$/' README.md | grep -x "MIT" +``` +Expected: no output (exit code 1). If you see `MIT` in the output, the old block wasn't fully removed. + +- [ ] **Step 4: Verify the new section links to all four files** + +Run: +```bash +awk '/^## License$/,/^---$/' README.md | grep -Eo '\./[A-Z-]+(\.md)?|./sdk/LICENSE' | sort -u +``` +Expected output (order may vary): +``` +./COMMERCIAL-LICENSE.md +./LICENSE +./LICENSE-APACHE-2.0 +./LICENSE-HISTORY.md +./sdk/LICENSE +``` + +**Do NOT commit yet.** + +--- + +## Task 7: Update root `package.json` — add `license` field + +**Files:** +- Modify: `package.json` (add a `"license"` field) + +npm needs a `license` field for sane tooling behavior. FSL is not in the SPDX list, so we use npm's documented convention: `"SEE LICENSE IN "`. + +- [ ] **Step 1: Verify the root `package.json` currently has no `license` field** + +Run: +```bash +grep -E '"license"' package.json || echo "no license field" +``` +Expected: `no license field`. If there IS one, stop and reconcile with the spec before proceeding. + +- [ ] **Step 2: Add the `license` field after `"type": "module"`** + +Use the Edit tool to replace: +```json + "type": "module", + "scripts": { +``` +with: +```json + "type": "module", + "license": "SEE LICENSE IN LICENSE", + "scripts": { +``` + +- [ ] **Step 3: Verify the field is present and parseable** + +Run: +```bash +node -e 'console.log(JSON.parse(require("fs").readFileSync("package.json")).license)' +``` +Expected output: +``` +SEE LICENSE IN LICENSE +``` + +If you see `undefined` or a JSON parse error, the edit went wrong — re-read `package.json` and fix it. + +**Do NOT commit yet.** + +--- + +## Task 8: Update `sdk/package.json` — switch from MIT to Apache-2.0 + +**Files:** +- Modify: `sdk/package.json:28` + +The SDK currently declares `"license": "MIT"` with no accompanying LICENSE file. We now have `sdk/LICENSE` (Apache-2.0 from Task 3), so update the declaration to match. + +- [ ] **Step 1: Verify the current declaration** + +Run: +```bash +grep '"license"' sdk/package.json +``` +Expected output: +``` + "license": "MIT", +``` + +If it's already `Apache-2.0`, skip to Step 3. If it's something else, stop and reconcile. + +- [ ] **Step 2: Replace `"MIT"` with `"Apache-2.0"`** + +Use the Edit tool to replace: +```json + "license": "MIT", +``` +with: +```json + "license": "Apache-2.0", +``` + +- [ ] **Step 3: Verify the field is present and parseable** + +Run: +```bash +node -e 'console.log(JSON.parse(require("fs").readFileSync("sdk/package.json")).license)' +``` +Expected output: +``` +Apache-2.0 +``` + +**Do NOT commit yet.** + +--- + +## Task 9: Add release-checklist note to `CLAUDE.md` + +**Files:** +- Modify: `CLAUDE.md` (append a new section) + +Prevents future-Philippe from shipping a release without recording its Change Date. + +- [ ] **Step 1: Find a good insertion point** + +Run: +```bash +grep -n "^## " CLAUDE.md | tail -5 +``` +Identify the last top-level section. The new section should go at the very end of the file (after whatever currently appears last). + +- [ ] **Step 2: Append the release checklist section** + +Use the Edit tool or write the content to append. Target: append to end of `CLAUDE.md`. The new block is: + +```markdown + +--- + +## 11. Release Checklist (License) + +Every time a release tag is pushed, append a row to +[`LICENSE-HISTORY.md`](./LICENSE-HISTORY.md): + +``` +| | | | +``` + +This is required so that the FSL Change Date for each release is +discoverable. Without it, the license conversion clause is legally vague. + +Example for a release cut today (2026-04-10): + +``` +| 0.2.0 | 2026-04-10 | 2028-04-10 | +``` + +Then update the placeholder row for `0.1.0` when it ships (currently `TBD | +TBD`). +``` + +Use the Edit tool to append — locate the very last line of `CLAUDE.md` (read the file end first) and edit so the new content follows it. If the file ends with a code block or paragraph, add a blank line before the new `---` separator. + +- [ ] **Step 3: Verify the new section is present** + +Run: +```bash +grep -c "Release Checklist (License)" CLAUDE.md +``` +Expected: `1` + +- [ ] **Step 4: Verify the reference to LICENSE-HISTORY.md is present** + +Run: +```bash +grep "LICENSE-HISTORY.md" CLAUDE.md +``` +Expected: at least one matching line. + +**Do NOT commit yet.** + +--- + +## Task 10: Final verification and atomic commit + +**Files:** +- All of the above. + +This is the commit task. Before committing, run a full verification sweep to catch any file that was missed or any half-finished edit. + +- [ ] **Step 1: Verify all new files exist** + +Run: +```bash +ls -1 LICENSE LICENSE-APACHE-2.0 sdk/LICENSE COMMERCIAL-LICENSE.md LICENSE-HISTORY.md +``` +Expected: all 5 filenames printed, no "No such file" errors. + +- [ ] **Step 2: Verify modified files have the expected changes** + +Run: +```bash +grep -q '"license": "SEE LICENSE IN LICENSE"' package.json && echo "root package.json OK" || echo "FAIL" +grep -q '"license": "Apache-2.0"' sdk/package.json && echo "sdk package.json OK" || echo "FAIL" +grep -q "Functional Source License" README.md && echo "README OK" || echo "FAIL" +grep -q "Release Checklist (License)" CLAUDE.md && echo "CLAUDE.md OK" || echo "FAIL" +``` +Expected: 4 lines, all ending in `OK`. If any say `FAIL`, go back to the corresponding task and fix it. + +- [ ] **Step 3: Verify README no longer claims `MIT`** + +Run: +```bash +awk '/^## License$/,/^---$/' README.md | grep -x "MIT" +``` +Expected: no output. If `MIT` appears, Task 6 wasn't applied cleanly. + +- [ ] **Step 4: Review the full git diff** + +Run: +```bash +git status +git diff --stat +``` +Expected `git status` output: +``` +On branch dev +Untracked files: + COMMERCIAL-LICENSE.md + LICENSE + LICENSE-APACHE-2.0 + LICENSE-HISTORY.md + sdk/LICENSE +Changes not staged for commit: + modified: CLAUDE.md + modified: README.md + modified: package.json + modified: sdk/package.json +``` +(Plus whatever other files were already in progress at the start of the session — those are **not** part of this commit.) + +- [ ] **Step 5: Stage exactly the license-related files** + +Run: +```bash +git add LICENSE LICENSE-APACHE-2.0 sdk/LICENSE COMMERCIAL-LICENSE.md LICENSE-HISTORY.md README.md package.json sdk/package.json CLAUDE.md +``` + +Then verify only those files are staged: +```bash +git diff --cached --name-only +``` +Expected output: +``` +CLAUDE.md +COMMERCIAL-LICENSE.md +LICENSE +LICENSE-APACHE-2.0 +LICENSE-HISTORY.md +README.md +package.json +sdk/LICENSE +sdk/package.json +``` + +If any other files appear, unstage them with `git restore --staged ` and retry. + +- [ ] **Step 6: Commit** + +Run: +```bash +git commit -m "$(cat <<'EOF' +chore(license): add FSL-1.1-Apache-2.0 for server, Apache-2.0 for SDK + +Adds the project's first formal license: + +- Root LICENSE is FSL-1.1-Apache-2.0 (Functional Source License with + Apache-2.0 as the Future License). Individuals and OSS projects can + self-host and modify freely; commercial Competing Uses require a + commercial license. Every release auto-converts to Apache-2.0 two years + after its release date. +- sdk/LICENSE is Apache-2.0 so the SDK can be freely integrated by any + consumer, including commercial customers calling the hosted API. +- COMMERCIAL-LICENSE.md explains who needs a commercial license and how to + get one (philippe@atypical.consulting). +- LICENSE-HISTORY.md tracks each release's Change Date. +- README license section updated from "MIT" to point at the new files. +- package.json adds "license": "SEE LICENSE IN LICENSE". +- sdk/package.json updated from "MIT" to "Apache-2.0". +- CLAUDE.md adds a release-checklist note for updating LICENSE-HISTORY.md. + +Spec: docs/superpowers/specs/2026-04-10-license-design.md +EOF +)" +``` + +- [ ] **Step 7: Verify the commit landed cleanly** + +Run: +```bash +git log -1 --stat +``` +Expected: one new commit touching exactly the 9 files listed in Step 5. If lefthook rejects the commit (lint / type-check), it's because one of the pre-commit hooks touched something — **do not** use `--no-verify`. Read the hook output, fix the underlying issue, and create a new commit. Do not amend. + +- [ ] **Step 8: Verify working tree is clean for the licensing changes** + +Run: +```bash +git status +``` +Expected: the 9 license-related files should no longer appear. Other pre-existing in-progress files (`docs/site/...`, `src/engine/templates.ts`, etc.) may still be listed — those are **not** part of this plan and should be left alone. + +--- + +## Done + +At this point: + +- The repo has an unambiguous license. +- Companies have a clear commercial path. +- The SDK is permissively licensed for corporate adoption. +- Every release will auto-convert to Apache-2.0 two years after shipping. +- Future releases have a checklist reminder to record their Change Date. + +No tests are added because no code changed. Functional verification is the content-check steps inside each task. diff --git a/docs/superpowers/plans/2026-04-10-template-expansion.md b/docs/superpowers/plans/2026-04-10-template-expansion.md new file mode 100644 index 0000000..3122c19 --- /dev/null +++ b/docs/superpowers/plans/2026-04-10-template-expansion.md @@ -0,0 +1,183 @@ +# Template Expansion Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Expand OG Engine from 4 to 12 built-in templates, each serving a real-world use case and leveraging the new variables + namedImages system. + +**Architecture:** Split monolithic `src/engine/templates.ts` into individual files under `src/engine/templates/`, then add 8 new template functions. Each template reads custom data from `input.variables` and images from `input.namedImages`. + +**Tech Stack:** @napi-rs/canvas, Vitest. + +--- + +## Task 1: Refactor templates.ts into individual files + +**Files:** +- Create: `src/engine/templates/helpers.ts` — shared drawing helpers +- Create: `src/engine/templates/index.ts` — registry + type exports +- Create: `src/engine/templates/default.ts` +- Create: `src/engine/templates/social-card.ts` +- Create: `src/engine/templates/blog-hero.ts` +- Create: `src/engine/templates/email-banner.ts` +- Modify: `src/engine/templates.ts` — becomes re-export barrel + +- [ ] **Step 1:** Read the current `src/engine/templates.ts` fully +- [ ] **Step 2:** Create `src/engine/templates/` directory +- [ ] **Step 3:** Extract shared helpers into `src/engine/templates/helpers.ts`: `hexToRgb`, `rgba`, `paintBackgroundMesh`, `drawBgImage`, `fitTitleLines` +- [ ] **Step 4:** Extract each template into its own file, importing helpers +- [ ] **Step 5:** Create `src/engine/templates/index.ts` with the TEMPLATES registry, type exports +- [ ] **Step 6:** Replace `src/engine/templates.ts` with a re-export barrel: +```typescript +export { TEMPLATES, TEMPLATE_NAMES, getTemplate } from './templates/index'; +export type { TemplateFn, TemplateInput, TemplateResult } from './templates/index'; +``` +- [ ] **Step 7:** Run all tests: `npx vitest run` — must be 168/168 passing (zero behavioral changes) +- [ ] **Step 8:** Commit: `refactor(engine): split templates into individual files` + +--- + +## Task 2: Add product-card template + +**Files:** +- Create: `src/engine/templates/product-card.ts` +- Modify: `src/engine/templates/index.ts` (register) +- Modify: `tests/engine/templates.test.ts` (add test) + +- [ ] **Step 1:** Write test: +```typescript +it('renders product-card template', async () => { + const result = await renderCard(defaultOptions({ + template: 'product-card', + variables: { price: '€129', badge: '-20%', brand: 'Nike' }, + })); + expect(result.buffer.length).toBeGreaterThan(0); + expect(result.contentType).toBe('image/png'); +}); +``` +- [ ] **Step 2:** Implement `product-card.ts`: Split layout — left side: brand tag, title (product name), price large bold, badge pill in accent. Right side: product image area (from `namedImages.product`). Falls back to full-width text if no product image. +- [ ] **Step 3:** Register in index.ts +- [ ] **Step 4:** Run tests, commit: `feat(templates): add product-card template` + +--- + +## Task 3: Add event template + +**Files:** +- Create: `src/engine/templates/event.ts` +- Modify: `src/engine/templates/index.ts` +- Modify: `tests/engine/templates.test.ts` + +- [ ] **Step 1:** Write test: +```typescript +it('renders event template', async () => { + const result = await renderCard(defaultOptions({ + template: 'event', + variables: { date: 'June 15, 2026', location: 'Amsterdam', speaker: 'Dan Abramov' }, + })); + expect(result.buffer.length).toBeGreaterThan(0); +}); +``` +- [ ] **Step 2:** Implement: Background image with gradient overlay. Title large center-bottom. Date + location horizontal bar. Speaker name. Logo corner. +- [ ] **Step 3:** Register, run tests, commit: `feat(templates): add event template` + +--- + +## Task 4: Add testimonial template + +**Files:** +- Create: `src/engine/templates/testimonial.ts` +- Modify: `src/engine/templates/index.ts` +- Modify: `tests/engine/templates.test.ts` + +- [ ] **Step 1:** Write test: +```typescript +it('renders testimonial template', async () => { + const result = await renderCard(defaultOptions({ + template: 'testimonial', + variables: { quote: 'This product changed our workflow completely.', name: 'Jane Doe', company: 'Acme Corp', role: 'CTO' }, + })); + expect(result.buffer.length).toBeGreaterThan(0); +}); +``` +- [ ] **Step 2:** Implement: Large opening quote mark in accent. Quote text centered. Author line below (name, role, company). Avatar circle if available from `namedImages.avatar`. +- [ ] **Step 3:** Register, run tests, commit: `feat(templates): add testimonial template` + +--- + +## Task 5: Add github-repo template + +**Files:** +- Create: `src/engine/templates/github-repo.ts` +- Modify: `src/engine/templates/index.ts` +- Modify: `tests/engine/templates.test.ts` + +- [ ] **Step 1:** Write test: +```typescript +it('renders github-repo template', async () => { + const result = await renderCard(defaultOptions({ + template: 'github-repo', + variables: { owner: 'vercel', stars: '12.4k', language: 'TypeScript' }, + })); + expect(result.buffer.length).toBeGreaterThan(0); +}); +``` +- [ ] **Step 2:** Implement: Dark forced gradient. Owner/name at top (monospace or bold). Description below. Bottom bar: colored language dot + star count. Avatar circle for owner. +- [ ] **Step 3:** Register, run tests, commit: `feat(templates): add github-repo template` + +--- + +## Task 6: Add news-article template + +**Files:** +- Create: `src/engine/templates/news-article.ts` +- Modify: `src/engine/templates/index.ts` +- Modify: `tests/engine/templates.test.ts` + +- [ ] **Step 1:** Write test, **Step 2:** Implement: Full-bleed background with strong bottom gradient. Category pill. Large title. Source + date at bottom. Logo corner. **Step 3:** Register, test, commit: `feat(templates): add news-article template` + +--- + +## Task 7: Add pricing template + +**Files:** +- Create: `src/engine/templates/pricing.ts` +- Modify: `src/engine/templates/index.ts` +- Modify: `tests/engine/templates.test.ts` + +- [ ] **Step 1:** Write test with variables: `plan`, `price`, `period`, `features` (comma-separated), `cta` +- [ ] **Step 2:** Implement: Centered card. Plan name in accent at top. Large price. Feature list with check marks. CTA button. Logo at top. +- [ ] **Step 3:** Register, test, commit: `feat(templates): add pricing template` + +--- + +## Task 8: Add profile-card template + +**Files:** +- Create: `src/engine/templates/profile-card.ts` +- Modify: `src/engine/templates/index.ts` +- Modify: `tests/engine/templates.test.ts` + +- [ ] **Step 1:** Write test with variables: `name`, `role`, `company`, `bio` +- [ ] **Step 2:** Implement: Large avatar circle. Name large below. Role + company muted. Bio as body text. Company logo small in corner. +- [ ] **Step 3:** Register, test, commit: `feat(templates): add profile-card template` + +--- + +## Task 9: Add announcement template + +**Files:** +- Create: `src/engine/templates/announcement.ts` +- Modify: `src/engine/templates/index.ts` +- Modify: `tests/engine/templates.test.ts` + +- [ ] **Step 1:** Write test with variables: `subtitle`, `cta` +- [ ] **Step 2:** Implement: Dramatic. Background with heavy overlay. Tag pill. Title very large centered. Subtitle. CTA button in accent. Logo corner. +- [ ] **Step 3:** Register, test, commit: `feat(templates): add announcement template` + +--- + +## Task 10: Final verification + update health endpoint + +- [ ] **Step 1:** Run full test suite +- [ ] **Step 2:** Verify `/health` endpoint now lists all 12 templates +- [ ] **Step 3:** Commit any fixes diff --git a/docs/superpowers/specs/2026-04-03-benchmark-suite-design.md b/docs/superpowers/specs/2026-04-03-benchmark-suite-design.md new file mode 100644 index 0000000..d133ab2 --- /dev/null +++ b/docs/superpowers/specs/2026-04-03-benchmark-suite-design.md @@ -0,0 +1,220 @@ +# Reproducible Benchmark Suite — Design Spec + +**Goal:** Build a rigorous, reproducible benchmark suite that measures OG Engine performance with phase-level granularity, compares against real Puppeteer runs, and produces defensible numbers for marketing. Then update all documentation to match measured reality. + +**Why:** Current docs claim "~2ms" renders. Actual measured time is ~23ms (including PNG encoding). Every performance number across 10+ files is wrong. Marketing depends on credible, reproducible claims. + +--- + +## 1. What We Measure + +### OG Engine — Phase Breakdown + +Each render is instrumented into three phases: + +| Phase | What it does | Expected range | +|-------|-------------|----------------| +| **Text measurement** | `measureLines()` for title + description | <1ms | +| **Canvas draw** | Gradient, grid, glow, accent bar, tag, title, description, author, badge, frame | 2-5ms | +| **PNG encode** | `canvas.toBuffer('image/png')` | 15-20ms | +| **Full pipeline** | All three combined | 20-25ms | + +The full pipeline is what a real `POST /render` API call returns. That's the honest number. + +### Puppeteer Baseline + +Same content rendered via Puppeteer for apples-to-apples comparison: + +1. Launch browser (or reuse from pool) +2. Create page, set viewport to 1200x630 +3. Set HTML content with inline CSS (matching OG Engine's visual output) +4. Screenshot as PNG +5. Close page + +Puppeteer is measured two ways: +- **Cold** — fresh browser launch per render (worst case) +- **Warm** — browser reused, new page per render (best case for Puppeteer) + +### Scenarios + +| Scenario | Title | Format | Font | +|----------|-------|--------|------| +| **Baseline** | "Hello, OG Engine" (1 line) | og (1200x630) | Outfit | +| **Long text** | 150+ chars, forces 5+ line overflow | og | Outfit | +| **Story format** | Same long text | story (1080x1920) | Outfit | +| **CJK** | Japanese text | og | Noto Sans JP | +| **All formats** | Baseline title, all 5 formats | og/twitter/square/linkedin/story | Outfit | + +--- + +## 2. Statistical Methodology + +- **1000 iterations** per scenario after **50 warmup** iterations (discarded) +- **3 complete runs** of the full suite; report the **median run** by P50 full-pipeline time +- Prevents cherry-picking the best or worst run + +**Reported metrics per scenario:** +- Min, P50 (median), P95, P99, Max +- Mean, Standard deviation +- Speedup ratio vs Puppeteer P50 + +**Machine context captured:** +- OS name + version +- CPU model + core count +- Total RAM +- Bun version +- Node.js version (for Puppeteer) +- `@napi-rs/canvas` version +- `puppeteer` version +- Timestamp (ISO 8601) + +--- + +## 3. Renderer Instrumentation + +Add an optional `timing` flag to `RenderOptions`. When true, the returned `RenderResult` includes a `phases` object: + +```typescript +interface RenderPhases { + textMeasureMs: number; // measureLines() calls + canvasDrawMs: number; // all ctx.* calls + pngEncodeMs: number; // canvas.toBuffer() + totalMs: number; // sum of above +} + +interface RenderResult { + // ... existing fields ... + phases?: RenderPhases; // only present when timing: true +} +``` + +Implementation: wrap each phase with `performance.now()` calls inside `renderCard()`. The `timing` flag defaults to `false` so production performance is unaffected (no measurement overhead in hot path). + +--- + +## 4. File Structure + +``` +benchmarks/ +├── run.ts # Main benchmark runner +├── puppeteer-baseline.ts # Puppeteer comparison runner +├── scenarios.ts # Scenario definitions +├── report.ts # Markdown + JSON report generator +├── results/ # Output directory (gitignored except reports) +│ └── .gitkeep +└── README.md # How to run, methodology explanation +``` + +**Changes to existing files:** +- `src/engine/renderer.ts` — add `timing` flag + phase measurement +- `package.json` — add `puppeteer` as devDependency, add `bench:full` script +- `.gitignore` — add `benchmarks/results/*.json` (raw data is large) + +--- + +## 5. Output Artifacts + +### Console output (when run interactively) + +``` +OG Engine Benchmark Suite +Machine: macOS 15.4 / Apple M2 Pro / 16GB / Bun 1.2.x + +Scenario: Baseline (og, 1 line, Outfit) + Text measure: 0.08ms (P50) 0.12ms (P95) + Canvas draw: 3.21ms (P50) 4.10ms (P95) + PNG encode: 18.44ms (P50) 19.81ms (P95) + Full pipeline: 21.73ms (P50) 24.03ms (P95) + +Scenario: Long text (og, overflow, Outfit) + ... + +Puppeteer Baseline (warm): + Full pipeline: 847ms (P50) 1203ms (P95) + +Speedup: 39x (P50 vs P50) +``` + +### Markdown report (`benchmarks/results/YYYY-MM-DD-report.md`) + +Full tables, machine context, methodology description, all scenarios. This file IS committable — it's the proof. + +### JSON raw data (`benchmarks/results/YYYY-MM-DD-raw.json`) + +Every individual timing for every iteration. Gitignored (large) but available for external audit. Structure: + +```json +{ + "machine": { "os": "...", "cpu": "...", "ram": "...", "bun": "...", "node": "..." }, + "timestamp": "2026-04-03T...", + "methodology": { "iterations": 1000, "warmup": 50, "runs": 3, "selectedRun": 2 }, + "scenarios": { + "baseline": { + "ogEngine": { + "phases": { "textMeasure": [0.08, 0.07, ...], "canvasDraw": [...], "pngEncode": [...] }, + "fullPipeline": [21.7, 22.1, ...] + }, + "puppeteer": { "warm": [847, 851, ...], "cold": [2340, 2100, ...] } + } + } +} +``` + +--- + +## 6. Documentation Updates + +Once benchmark produces real numbers, update ALL performance claims. Files to update: + +| File | Claims to update | +|------|-----------------| +| `CLAUDE.md` | "Sub-5ms renders", "~1-3ms", "300-500x", comparison table | +| `docs/site/src/content/docs/index.mdx` | Hero tagline, benchmark table, FAQ, How It Works | +| `docs/site/src/content/docs/quick-start.mdx` | "Generated in 2ms" | +| `docs/site/src/content/docs/compare/puppeteer.mdx` | Full comparison table, migration guide | +| `docs/site/src/content/docs/blog/why-we-built-og-engine.mdx` | All benchmark numbers, architecture description | +| `docs/site/src/content/docs/blog/how-pretext-measures-text.mdx` | Sub-0.1ms claims, phase breakdowns | +| `docs/site/src/content/docs/api-reference/render.mdx` | "Sub-5ms renders" | +| `docs/site/astro.config.mjs` | JSON-LD schema featureList | +| `docs/analysis/GO-TO-MARKET.md` | HN post, Twitter thread, all speed claims | + +**Formatting rules for updated claims:** +- Headline number = **P50 full pipeline** (e.g., "~22ms") +- Phase breakdown where relevant (e.g., "text layout <1ms, full render ~22ms") +- Speedup = measured OG Engine P50 / measured Puppeteer P50 (e.g., "~39x faster") +- Never round in our favor — round conservatively or use ranges +- Add footnote/link to benchmark methodology page + +**New documentation page:** +- `docs/site/src/content/docs/benchmarks.mdx` — "How We Benchmark" page explaining methodology, linking to the benchmark script, showing how to reproduce. This replaces vague claims with verifiable ones. + +--- + +## 7. Marketing Narrative Adjustment + +The story changes from "2ms renders" to something like: + +> **~22ms full renders. 39x faster than Puppeteer. Text layout under 1ms.** + +The narrative pivot: OG Engine's advantage isn't just raw speed — it's the **architecture**. No browser, no Chrome, no Xvfb, 50x less memory, 500+ concurrent renders on a single Node process. The speed comparison is real (39x) even if the absolute number is higher than originally claimed. + +The phase breakdown lets us say: "The actual text layout and compositing takes <5ms. PNG encoding adds ~18ms — and that's the same encoding cost any solution pays, including Puppeteer." + +--- + +## 8. Scope Boundaries + +**In scope:** +- Renderer phase instrumentation +- Benchmark runner with scenarios +- Puppeteer baseline comparison +- Report generation (markdown + JSON) +- All documentation updates to match real numbers +- New "How We Benchmark" docs page + +**Out of scope:** +- WebP encoding benchmark (not yet implemented) +- Network latency simulation +- Concurrent request benchmarking (load testing) +- Memory profiling (separate concern) +- CI integration for regression tracking (future) diff --git a/docs/superpowers/specs/2026-04-03-documentation-design.md b/docs/superpowers/specs/2026-04-03-documentation-design.md index c5957e6..19d8c80 100644 --- a/docs/superpowers/specs/2026-04-03-documentation-design.md +++ b/docs/superpowers/specs/2026-04-03-documentation-design.md @@ -254,11 +254,11 @@ curl -X POST https://api.og-engine.com/validate \ ### Step 5 — Use the SDK (optional) ```bash -npm install og-engine-sdk +npm install @atypical-consulting/og-engine-sdk ``` ```typescript -import { OGEngine } from 'og-engine-sdk' +import { OGEngine } from '@atypical-consulting/og-engine-sdk' const og = new OGEngine('oge_sk_a1b2c3...') @@ -549,13 +549,13 @@ Every error response follows this structure: ### Installation ```bash -npm install og-engine-sdk # or bun add / pnpm add +npm install @atypical-consulting/og-engine-sdk # or bun add / pnpm add ``` ### Client Initialization ```typescript -import { OGEngine } from 'og-engine-sdk' +import { OGEngine } from '@atypical-consulting/og-engine-sdk' const og = new OGEngine('oge_sk_...') // or diff --git a/docs/superpowers/specs/2026-04-03-stripe-production-design.md b/docs/superpowers/specs/2026-04-03-stripe-production-design.md new file mode 100644 index 0000000..869f77e --- /dev/null +++ b/docs/superpowers/specs/2026-04-03-stripe-production-design.md @@ -0,0 +1,196 @@ +# Stripe Production Integration — Design Spec + +> **Status:** Approved +> **Date:** 2026-04-03 +> **Approach:** Payment Links + Webhooks (Approach A) +> **Scope:** Full production Stripe integration including Customer Portal, Resend emails, and free-tier cron reset + +--- + +## 1. Stripe Dashboard Setup + +Configure in Stripe Dashboard (test mode first, then live). + +### Products & Prices + +| Product | Price | Env var for Price ID | +|---------|-------|---------------------| +| OG Engine Starter | €10/mo recurring | `STRIPE_PRICE_STARTER` | +| OG Engine Pro | €39/mo recurring | `STRIPE_PRICE_PRO` | +| OG Engine Scale | €99/mo recurring | `STRIPE_PRICE_SCALE` | + +### Payment Links + +One per paid product (Starter, Pro). Scale uses `mailto:sales@og-engine.com`. + +- Collect email + create Stripe Customer +- Success URL: `https://og-engine.com/quick-start/?checkout=success` +- Real URLs replace placeholders in pricing page after creation + +### Customer Portal + +- Allow plan switching between Starter/Pro/Scale +- Allow cancellation +- No pause option + +### Webhook Endpoint + +- URL: `https://og-engine.com/webhooks/stripe` +- Events: `checkout.session.completed`, `customer.subscription.updated`, `customer.subscription.deleted`, `invoice.paid` + +--- + +## 2. Dependencies & Environment Variables + +### New npm dependencies + +- `stripe` — Stripe SDK for webhook verification and Customer Portal sessions +- `resend` — transactional email delivery + +### New environment variables + +| Variable | Purpose | +|----------|---------| +| `STRIPE_SECRET_KEY` | `sk_test_...` / `sk_live_...` | +| `STRIPE_WEBHOOK_SECRET` | `whsec_...` from webhook endpoint config | +| `STRIPE_PRICE_STARTER` | Price ID for Starter plan | +| `STRIPE_PRICE_PRO` | Price ID for Pro plan | +| `STRIPE_PRICE_SCALE` | Price ID for Scale plan | +| `RESEND_API_KEY` | `re_...` | +| `ADMIN_CRON_SECRET` | Shared secret for GitHub Action cron | + +No changes to existing env vars (`DATABASE_URL`, `PORT`, `AUTH_ENABLED`, etc.). + +--- + +## 3. Webhook Handler Rewrite + +**File:** `src/api/webhooks.ts` + +### Changes + +- Import `Stripe` from the `stripe` SDK +- Use `stripe.webhooks.constructEvent(body, signature, webhookSecret)` for cryptographic signature verification +- On `checkout.session.completed`: + - Retrieve the subscription via `stripe.subscriptions.retrieve()` to get the actual `price.id` + - Match email to existing free account — if found, upgrade in place (no new key); if not found, create new key with paid plan + - Call `updateStripeInfo()` with customer ID and subscription ID + - Send welcome email via Resend with API key + plan details +- On `customer.subscription.updated`: + - Same plan update logic as current, but with verified signature + - Send upgrade notification email +- On `customer.subscription.deleted`: + - Downgrade to free (same as current, verified) + - Send downgrade notification email +- On `invoice.paid`: + - Reset usage (same as current, verified) + +### Constraints + +- Raw body must be read via `c.req.text()` before any JSON parsing (already correct in current code) +- No body-parser middleware may touch this route + +--- + +## 4. Customer Portal Endpoint + +**New file:** `src/api/billing.ts` + +### Route: `GET /billing/portal` + +1. Requires `authMiddleware()` (existing) +2. Looks up `stripe_customer_id` from the API key record +3. If no `stripe_customer_id` (free user), returns 400: `"No billing account. Subscribe to a paid plan first."` +4. Calls `stripe.billingPortal.sessions.create({ customer, return_url: "https://og-engine.com/pricing" })` +5. Returns `{ url: "https://billing.stripe.com/session/..." }` + +Not metered. Protected by `authMiddleware()` same as `/usage`. + +--- + +## 5. Resend Email Integration + +**New file:** `src/email/send.ts` + +### Email functions + +| Function | Trigger | Content | +|----------|---------|---------| +| `sendWelcomeEmail(email, apiKey, plan)` | Free registration or paid checkout | API key, plan details, curl example, docs link | +| `sendUpgradeEmail(email, plan)` | `customer.subscription.updated` | New plan, new limits, portal link | +| `sendDowngradeEmail(email)` | `customer.subscription.deleted` | Downgrade confirmation, key still active | + +### Configuration + +- **Sender:** `OG Engine ` (requires verified domain in Resend) +- **Fallback:** If `RESEND_API_KEY` is not set, log a warning and skip email silently. Dev/test environments work without Resend. +- **No template engine** — inline HTML strings, simple transactional emails. + +### Integration points + +- `src/api/register.ts` — replace TODO with `sendWelcomeEmail()` call +- `src/api/webhooks.ts` — call appropriate email function after each webhook event + +--- + +## 6. Free-Tier Monthly Reset + +### GitHub Action + +**New file:** `.github/workflows/reset-free-quotas.yml` + +- **Schedule:** `cron: '5 0 1 * *'` (1st of each month, 00:05 UTC) +- **Action:** `POST https://og-engine.com/admin/reset-free-quotas` with `Authorization: Bearer ` +- **Secret:** `ADMIN_CRON_SECRET` stored in GitHub Actions secrets + +### Admin Endpoint + +**New file:** `src/api/admin.ts` + +**Route:** `POST /admin/reset-free-quotas` + +1. Checks `Authorization: Bearer` header against `ADMIN_CRON_SECRET` env var (separate from user API key auth) +2. Runs: `UPDATE api_keys SET calls_used = 0, period_start = WHERE plan = 'free'` +3. Returns `{ reset: , timestamp: "..." }` + +--- + +## 7. Pricing Page Updates + +**File:** `docs/site/src/content/docs/pricing.mdx` + +- Replace `https://buy.stripe.com/starter` → real Stripe Payment Link for Starter +- Replace `https://buy.stripe.com/pro` → real Stripe Payment Link for Pro +- Scale stays as `mailto:sales@og-engine.com` (unchanged) +- No structural changes — URL swaps only +- Marked as `STRIPE_PAYMENT_LINK_STARTER` / `STRIPE_PAYMENT_LINK_PRO` placeholders until links are created in dashboard + +--- + +## 8. File Change Summary + +| Action | File | What | +|--------|------|------| +| **Add** | `src/email/send.ts` | Resend integration, 3 email functions | +| **Add** | `src/api/billing.ts` | `GET /billing/portal` Customer Portal endpoint | +| **Add** | `src/api/admin.ts` | `POST /admin/reset-free-quotas` cron endpoint | +| **Add** | `.github/workflows/reset-free-quotas.yml` | Monthly cron for free-tier reset | +| **Modify** | `package.json` | Add `stripe` and `resend` dependencies | +| **Modify** | `src/api/webhooks.ts` | Real signature verification, Stripe SDK, email sends | +| **Modify** | `src/api/register.ts` | Send welcome email via Resend | +| **Modify** | `src/index.ts` | Register billing + admin routes | +| **Modify** | `docs/site/src/content/docs/pricing.mdx` | Replace placeholder Payment Link URLs | +| **Modify** | `fly.toml` | Document new env vars in comments | + +**No changes to:** database schema, auth middleware, engine code, templates, renderer, or any existing API behavior. + +**Total: 4 new files, 6 modified files.** + +--- + +## 9. References + +- **Canonical decisions:** `docs/analysis/DECISIONS.md` +- **Monetization architecture:** `docs/analysis/MONETIZATION.md` +- **User stories:** `docs/analysis/USER-STORIES.md` (US-1.2, US-1.3, US-1.4, US-5.3) +- **Go-to-market:** `docs/analysis/GO-TO-MARKET.md` diff --git a/docs/superpowers/specs/2026-04-09-playground-app-shell-design.md b/docs/superpowers/specs/2026-04-09-playground-app-shell-design.md new file mode 100644 index 0000000..a4cad00 --- /dev/null +++ b/docs/superpowers/specs/2026-04-09-playground-app-shell-design.md @@ -0,0 +1,177 @@ +# Spec B — Playground App Shell + +**Date:** 2026-04-09 +**Status:** Approved (pending user review of written spec) +**Author:** brainstorming session +**Part of:** 3-spec sequence (A → B → C) for playground improvements +**Builds on:** Spec A (playground polish), already shipped + +## Context + +After Spec A polished the existing playground in-place (sidebar reorder, hidden TOC, contrast fixes, Surprise me CTA, inline Auto-fit toggle), the playground still lives inside Starlight's standard documentation layout. That layout was designed for prose, not for a configurable application: the global docs sidebar eats ~280px on the left, the right TOC was previously eating another column (Spec A killed it), and the centered content max-width caps the preview at ~370px wide on a 1400px viewport. + +The product *is* the visual output. Anything that compresses the preview compresses the value. Spec B replaces the document-style layout with a real two-column app shell on `/playground/` only, large enough that the preview becomes the visual center of gravity, and reorganizes the supporting UI around it. + +## Goals + +1. Give the preview the dominant share of the viewport on desktop. +2. Make the playground feel like an application, not a doc page, while preserving the global Starlight header (brand consistency, search, theme toggle, GitHub link). +3. Hide the code output behind a deliberate user action so the preview never has to compete with it. +4. Hoist the format pills and the render-time HUD into a toolbar above the preview so they stop biting into the canvas. +5. Keep the playground usable on mobile by stacking preview-on-top, controls-below. + +## Non-goals + +- Font picker / Google Fonts catalog — handled in Spec C. +- Zoom toggle (`Fit / 50% / 100% / Actual`) — YAGNI; the existing FullscreenPreview already covers actual-pixel inspection. +- URL state synchronization, side-by-side format previews, "Fork as React component" export — not planned. +- Authentication-aware UI in a custom header — Spec B keeps the global Starlight header, which already handles whatever auth state exists. +- Functional changes to rendering, the API client, font loading, or any non-layout behavior. + +## Architecture + +### Layout shell + +A new Astro layout file dedicated to `/playground/`. It composes Starlight's global header with a custom body that gives the controls and preview their own columns and bypasses the standard `` wrapper that documentation pages use. + +``` +┌────────────────────────────────────────────────────────┐ +│ Starlight global header (logo · search · GH · theme) │ +├────────────────┬───────────────────────────────────────┤ +│ │ ┌─ toolbar ──────────────────────┐ │ +│ Controls │ │ format pills HUD chips │ │ +│ column │ ├─────────────────────────────────┤ │ +│ (380px │ │ │ │ +│ fixed, │ │ Preview canvas │ │ +│ scrollable) │ │ (fluid, centered, │ │ +│ │ │ aspect-ratio per format) │ │ +│ ▸ Surprise │ │ │ │ +│ me (sticky) │ │ │ │ +│ ▸ Format- │ │ │ │ +│ less form │ │ [View code ↑] │ │ +│ sections │ └─────────────────────────────────┘ │ +└────────────────┴───────────────────────────────────────┘ +``` + +When the user clicks "View code ↑", a drawer slides up from the bottom of the preview column containing the existing CodeOutput component: + +``` +┌────────────────┬───────────────────────────────────────┐ +│ │ ┌─ toolbar ──────────────────────┐ │ +│ Controls │ │ format pills HUD chips │ │ +│ │ ├─────────────────────────────────┤ │ +│ │ │ Preview canvas (compressed) │ │ +│ │ ├─ View code ↓ ───────────────────┤ │ +│ │ │ curl │ sdk │ json [copy] │ │ +│ │ │ ─────────────────────────────── │ │ +│ │ │ curl -X POST ... │ │ +│ │ │ ... │ │ +└────────────────┴─└─────────────────────────────────┘───┘ +``` + +### Breakpoints + +- **Desktop ≥1024px:** controls fixed 380px, preview fills the remainder. Preview column does not scroll; controls column scrolls independently. +- **Tablet 768–1023px:** controls fixed 340px, preview fills the remainder. Same scroll model. +- **Mobile <768px:** stack vertically. Preview on top (full width, capped at `aspect-ratio: 1200/630; max-height: 50vh`), controls below in a single scrollable column. The "View code" drawer still works, sliding up from the bottom of the viewport. + +### Header reuse, not rewrite + +The custom layout MUST reuse Starlight's existing global header rather than re-implementing one. This preserves: +- Brand consistency (logo, fonts) +- The search modal (Pagefind) +- The theme toggle +- The GitHub link +- Any nav links Starlight injects + +The implementation may reuse Starlight's `` / `
` components, or render the layout as a Starlight `splash` template that suppresses the standard content shell while keeping the header. Implementation must verify the cleanest option during planning. + +### Preview toolbar + +A new ~44px toolbar component sits directly above the preview canvas, replacing two pieces of currently-overlaid or in-form UI: + +1. **Format pills** (`OG 1200x630`, `Twitter 1200x675`, `Square 1080x1080`, `LinkedIn 1200x627`, `Story 1080x1920`) — moved out of the controls column. +2. **Render HUD chips** (`2.9ms`, `1L title`, `293x` etc.) — moved out of the bottom-overlay-on-canvas position. + +The existing `RenderHUD` and `FormatSelector` components are reused; only their parent container changes. + +### Code drawer + +The existing `CodeOutput` component (curl/SDK/JSON tabs + copy button) is moved out of the controls/preview tree and into a new drawer container that: + +- Is **collapsed by default** on first visit +- Has a small "View code ↑" pill button anchored to the bottom-center of the preview column (or fixed to the bottom of the viewport on mobile) +- When expanded, slides up from the bottom of the preview column, taking ~60% of its vertical space +- Closes via a "Hide code ↓" button on the drawer header, OR by pressing `Escape` +- Persists open/closed state in `localStorage` under key `pg-code-drawer-open` +- Animates open/close with a CSS transition (no animation library) + +The drawer's z-index is ordered so that `FullscreenPreview` (already in the codebase) renders above it. + +### Sticky CTA + +The `Surprise me` CTA from Spec A is currently the first item in the controls column. In Spec B's app shell, it becomes `position: sticky; top: 0` inside the controls column so it remains accessible even after the user scrolls deep into the form. + +## Files Touched (provisional) + +The implementation plan will verify exact paths and may add or remove files based on what Starlight allows. + +1. `docs/site/src/layouts/PlaygroundLayout.astro` — NEW custom layout +2. `docs/site/src/content/docs/playground.mdx` — switch to the new layout (via Starlight `template: splash` frontmatter, or by moving content to `src/pages/playground.astro` if cleaner) +3. `docs/site/src/components/Playground.tsx` — restructure JSX into the two-column shell, host the new drawer state, integrate the new toolbar +4. `docs/site/src/components/ui/PreviewToolbar.tsx` — NEW component composing FormatSelector + RenderHUD +5. `docs/site/src/components/ui/CodeDrawer.tsx` — NEW wrapper around CodeOutput with open/closed state, animation, Escape handler, localStorage persistence +6. `docs/site/src/components/playground.css` — new layout styles, drawer animation, breakpoint rules +7. `docs/site/src/components/ui/RenderHUD.tsx` — minor: support a "toolbar" rendering mode (no absolute positioning) in addition to its current overlay mode, OR be replaced by a new presentation in the toolbar +8. `docs/site/src/components/ui/CodeOutput.tsx` — minor: ensure scroll behavior works inside a fixed-height drawer container + +## Testing Approach + +- **Visual regression (manual):** screenshot-compare the playground at three viewport sizes (1440, 900, 375) before and after Spec B. The "after" should show: no left docs sidebar, dominant preview, sticky Surprise me, code drawer collapsed. +- **Functional smoke test:** + - Format pills in the toolbar still switch the preview format + - Render HUD chips update on every render + - "View code ↑" expands the drawer; clicking "Hide code" or pressing Escape collapses it + - Drawer state survives a page reload + - Surprise me sticky behavior: scroll deep into the form on desktop, button stays visible + - Mobile (375px): preview is on top and visible; scrolling shows controls below + - All Spec A behaviors still work: sidebar badge, contrast, R keyboard shortcut, Auto-fit inline toggle, Randomize +- **No new automated tests** — this is layout work without business logic changes. Existing rendering pipelines (tested elsewhere) are untouched. + +## Risks & Mitigations + +| Risk | Mitigation | +|---|---| +| Bypassing Starlight's content layout breaks the search modal, theme toggle, or GitHub link | Reuse Starlight's `` / header components directly rather than re-implementing them. Verify with manual smoke test before committing. | +| Drawer animation conflicts with the existing FullscreenPreview overlay | Explicit z-index ordering: `FullscreenPreview > Drawer > Toolbar > Preview canvas`. Test by opening fullscreen while the drawer is open. | +| Mobile preview-on-top is too tall and pushes controls below the fold on small phones | Cap mobile preview height at `min(aspect-ratio-natural, 50vh)`. Verify on a 375×667 viewport (iPhone SE). | +| `100vh` is broken on iOS Safari (the address bar makes it inaccurate) | Use `100dvh` (dynamic viewport height) for the layout shell height. Fall back to `100vh` for older browsers via `@supports`. | +| The new layout file gets served at `/playground/` and accidentally breaks the existing URL or sitemap | Verify the same URL still resolves, both during dev and after a production build. | +| Starlight version may not expose `` for reuse | Fall back to wrapping content with a minimal custom shell that imports Starlight's `
` component directly. Worst case: re-implement a thin header that matches Starlight's visual style. | +| Drawer + sticky Surprise me may have z-index collisions on mobile | Use a single z-index scale defined as CSS custom properties at the top of `playground.css`. | + +## Acceptance Criteria + +- [ ] `/playground/` renders without the docs sidebar on the left +- [ ] Starlight global header still renders at top with working search, theme toggle, GitHub link +- [ ] At desktop ≥1024px, controls column is exactly 380px wide and the preview column fills the rest +- [ ] At tablet 768–1023px, controls column is 340px wide +- [ ] At mobile <768px, preview stacks above controls and is the first thing visible +- [ ] Preview column does not scroll on desktop/tablet; controls column scrolls independently +- [ ] Format pills live in a toolbar above the preview, not in the controls column +- [ ] Render HUD chips render in the toolbar (not overlaying the canvas) +- [ ] Code drawer is collapsed by default +- [ ] "View code ↑" button toggles the drawer +- [ ] Pressing Escape closes the drawer +- [ ] Drawer open/closed state survives a page reload (`localStorage`) +- [ ] Surprise me CTA is `position: sticky` in the controls column +- [ ] All Spec A behaviors still work: contrast, R keyboard shortcut, Auto-fit inline toggle, sidebar Try it badge +- [ ] No console errors introduced +- [ ] Existing functionality intact: download PNG, fullscreen preview, drag-drop background images, accent picker, gradient picker, font picker, slider controls, all preset cards, API mode toggle (if visible) + +## Out of Scope (explicit reminders) + +- Font picker / Google Fonts → Spec C +- URL state, side-by-side previews, fork-as-React export → not planned +- Auth-aware nav, custom header rewrite → not needed +- Zoom toggle on the preview → YAGNI (FullscreenPreview already handles inspection) diff --git a/docs/superpowers/specs/2026-04-09-playground-polish-design.md b/docs/superpowers/specs/2026-04-09-playground-polish-design.md new file mode 100644 index 0000000..a126e7f --- /dev/null +++ b/docs/superpowers/specs/2026-04-09-playground-polish-design.md @@ -0,0 +1,166 @@ +# Spec A — Playground Polish + +**Date:** 2026-04-09 +**Status:** Approved (pending user review of written spec) +**Author:** brainstorming session +**Part of:** 3-spec sequence (A → B → C) for playground improvements + +## Context + +The OG Engine playground at `/playground/` is the product's most important conversion surface, but a Chrome DevTools audit of the live site surfaced several friction points: + +- The right-hand table-of-contents column shows only "Overview" because `playground.mdx` has no headings — pure dead space that compresses the rest of the layout. +- Multiple WCAG 2.1 AA contrast failures on form labels, placeholders, inactive code-format tabs, and preset card sublabels. +- The "Randomize" button is small and tucked top-right of the preset section, even though it produces the strongest "wow" interaction in the product. +- The sidebar buries the highest-engagement pages (Playground, Templates, Benchmarks, Pricing) below docs. +- The Auto-fit text checkbox lives at the bottom of the form, far from the title-size slider that would prompt a user to look for it. + +This spec ships the low-risk, high-leverage subset of fixes. Larger changes (custom layout for the playground, Google Fonts integration) are deferred to Specs B and C respectively. + +## Goals + +1. Eliminate WCAG AA contrast failures in the playground UI. +2. Recover horizontal space currently wasted on a single-item TOC. +3. Surface the highest-conversion pages in the global sidebar. +4. Promote the Randomize interaction to its rightful prominence. +5. Place the Auto-fit toggle where users will actually look for it. + +## Non-Goals (deferred) + +- Layout/canvas size changes — handled in Spec B. +- Custom Astro layout for `/playground/` — handled in Spec B. +- Font picker rework / Google Fonts catalog — handled in Spec C. +- URL state, side-by-side previews, fork-as-React export — deferred indefinitely (YAGNI until Specs B/C are validated). + +## Changes + +### A1. Sidebar reorder + +**File:** `docs/site/astro.config.mjs` + +The sidebar currently lists pages in roughly historical order. Reorder to put high-engagement pages at the top, group reference material, and surface Pricing. + +New order: + +``` +Home +Playground [badge: "Try it"] +Templates Gallery +Benchmarks +Quick Start +Guides ▸ +API Reference ▸ +SDK ▸ +Resources ▸ (NEW group) + Available Fonts + OG Engine vs Puppeteer + Self-Hosting (Docker) +Pricing +Changelog +Blog ▸ +``` + +Implementation notes: +- Dissolve the existing single-child `Compare` group; its sole entry moves into `Resources`. +- Move `Available Fonts` and `Self-Hosting (Docker)` from root into `Resources`. +- Add Starlight `badge` prop `{ text: 'Try it', variant: 'success' }` to the Playground entry. + +### A2. Hide TOC on `/playground/` + +**File:** `docs/site/src/content/docs/playground.mdx` + +Add `tableOfContents: false` to the frontmatter. The page has no headings, so the TOC shows only an auto-generated "Overview" item that occupies a full sidebar column for no information value. + +This single change recovers ~280px of horizontal space at desktop breakpoints. + +### A3. Contrast fixes + +**Primary file:** `docs/site/src/components/playground.css` +**Secondary:** `docs/site/src/styles/custom.css` (only if Starlight token overrides are needed) + +Replace each failing color with a value that clears WCAG AA (4.5:1 minimum for body text). All measurements are against the playground's `#050810`-ish background. + +| Element | Current | Computed ratio | New value | Notes | +|---|---|---|---|---| +| Section labels (`TAG`, `TITLE`, `AUTHOR`, slider labels) | `rgb(71, 85, 105)` (slate-600) | 2.64 | `#94a3b8` (slate-400) | ~7:1 | +| Search input placeholder + `⌘K` hint | `rgb(148, 161, 181)` | 2.62 | `#94a3b8` (slate-400) | | +| Fullscreen `⛶` button | `rgb(148, 163, 184)` | 2.56 | `#cbd5e1` (slate-300) | Icon-only buttons need stronger contrast | +| Inactive `curl / SDK / JSON` tabs | green-500 on `rgba(255,255,255,0.02)` | 1.52 | `#cbd5e1` (slate-300) | Reserve accent green for the active tab only | +| Preset card sublabels (`Launch Day`, `Deep Dive`, etc.) | `#fff` on `rgba(255,255,255,0.02)` | ~1.0 | sublabel `#94a3b8`; bump card bg to `rgba(255,255,255,0.05)` and border to `rgba(255,255,255,0.10)` | The sublabel is currently invisible due to near-transparent background | +| `Download PNG` button | reported text-on-bg ~1.0 by walker | needs verification | confirm bg is solid `--accent` and text is `#06080c` | Walker may have walked past a transparent ancestor; verify with explicit `background-color` rule | + +Add a CSS custom property `--pg-text-secondary: #94a3b8;` and a comment block at the top of `playground.css` documenting that any new "secondary text" element should use this token. This prevents future regressions. + +Verification: re-run the contrast scan via Chrome DevTools after the change; every element above must clear 4.5:1. + +### A4. Randomize CTA promotion + +**File:** `docs/site/src/components/Playground.tsx` (and the relevant child component for the Quick Start section — verify exact path during implementation; likely `ui/Presets.tsx` based on filename inventory) + +Current state: a small `Randomize` button sits in the top-right corner of the "Quick Start" section header. + +Target state: +- Replace it with a **full-width** button placed **above** the four preset cards. +- Label: `🎲 Surprise me` with a `kbd`-styled `R` hint at the right edge (`Press R`). +- Subtle hover treatment: `scale(1.01)` + accent-colored box-shadow glow. +- Wire a global keyboard shortcut: pressing `R` (case-insensitive, no modifiers) triggers the same action, scoped to `document.body` and skipped when an `input`/`textarea`/`contenteditable` element is focused. +- Keep the four preset cards immediately below as the alternative "starting points" entry. + +### A5. Auto-fit inline placement + +**File:** the form component containing the Title size slider — likely `docs/site/src/components/ui/StyleControls.tsx` (verify during implementation). + +Current state: an `Auto-fit text` checkbox sits at the bottom of the form, after the "Fine-tuning" section. Its description (`Shrinks title size automatically to prevent overflow`) is permanent helper text. + +Target state: +- Move the checkbox up to sit immediately to the right of the **Title size** slider's label (`TITLE SIZE Auto-fit ▢`). +- Remove the original checkbox from the Fine-tuning section. If Fine-tuning becomes empty as a result, remove the section header too. +- Convert the description text to a hover tooltip on the checkbox (native `title` attribute is sufficient — no new component needed). +- The checkbox must be wired to the same state value it currently uses; this is a UI relocation, not a behavior change. + +## Files Touched (final list) + +1. `docs/site/astro.config.mjs` — sidebar reorder (A1) +2. `docs/site/src/content/docs/playground.mdx` — frontmatter `tableOfContents: false` (A2) +3. `docs/site/src/components/playground.css` — contrast tokens + fixes (A3) +4. `docs/site/src/components/Playground.tsx` and/or `ui/Presets.tsx` — Randomize CTA + keyboard shortcut (A4) +5. `docs/site/src/components/ui/StyleControls.tsx` (path to confirm) — auto-fit inline (A5) + +Implementation must verify the exact paths for items 4 and 5 before editing — the inventory shows multiple candidate components. + +## Testing Approach + +- **Visual regression:** re-run Chrome DevTools contrast scan against `https://og-engine.com/playground/` (or local dev server) after the CSS pass; every element listed in A3 must clear 4.5:1. +- **Functional smoke test:** + - Confirm `R` keypress triggers Randomize while focus is on the page body. + - Confirm `R` keypress does **not** randomize when focus is in the Title/Description/Author/Tag input. + - Confirm Auto-fit checkbox still toggles the same state (use React DevTools or a small console log during dev). + - Confirm sidebar order matches the spec at desktop and mobile breakpoints. +- **No new automated tests** — this PR is CSS + reorganization with no business logic changes. + +## Risks & Mitigations + +| Risk | Mitigation | +|---|---| +| Starlight CSS specificity may require `!important` on label colors | If encountered, use the existing `playground.css` cascade rather than `!important`; only escalate if scoped selectors fail | +| `R` keyboard shortcut conflicts with browser refresh modifiers | Listener checks `event.key === 'r'` AND no `metaKey/ctrlKey/altKey/shiftKey` | +| Moving Auto-fit checkbox could break a wired-up event handler | Reuse the existing state binding; do not introduce a new prop or hook | +| `Compare` group dissolution may leave dangling links elsewhere in docs | grep for `/compare/puppeteer/` references; the page itself stays at the same URL, so no redirects needed | +| Badge `variant: 'success'` may not exist on the installed Starlight version | Fall back to `variant: 'tip'` or no variant if Starlight rejects the prop | + +## Acceptance Criteria + +- [ ] Sidebar matches the new order on desktop and mobile. +- [ ] `/playground/` page no longer renders a right-hand TOC column. +- [ ] All contrast failures listed in A3 clear WCAG AA (4.5:1) when measured against the live background. +- [ ] Randomize CTA is full-width, above preset cards, with `R` keyboard shortcut working. +- [ ] Auto-fit checkbox sits next to the Title size slider; the original Fine-tuning entry is removed. +- [ ] No console errors introduced. +- [ ] Existing playground functionality (rendering, downloading, code copy) unchanged. + +## Out of Scope (explicit reminders) + +- Canvas/preview sizing → Spec B +- Two-column app shell / custom Astro layout → Spec B +- Font picker / Google Fonts → Spec C +- URL state, side-by-side previews, fork-as-React → not planned diff --git a/docs/superpowers/specs/2026-04-09-unified-fonts-design.md b/docs/superpowers/specs/2026-04-09-unified-fonts-design.md new file mode 100644 index 0000000..7a0da6f --- /dev/null +++ b/docs/superpowers/specs/2026-04-09-unified-fonts-design.md @@ -0,0 +1,274 @@ +# Spec C — Unified Font System + Google Fonts in Playground + +**Date:** 2026-04-09 +**Status:** Approved (pending user review of written spec) +**Author:** brainstorming session +**Part of:** 3-spec sequence (A → B → C) for playground improvements +**Builds on:** Spec A (polish) and Spec B (app shell), both shipped + +## Context + +OG Engine currently has two font systems: + +1. **Playground (browser, `docs/site/src/components/engine/fonts.ts`)** — 8 hardcoded `FontEntry` records, each with a Google Fonts CSS URL loaded at runtime. +2. **API server (root `src/engine/fonts.ts`)** — the same 8 fonts, hardcoded again, but pointing to local `.ttf` files registered with `@napi-rs/canvas` `GlobalFonts.registerFromPath()`. + +This creates two friction points: + +- The 8 fonts are duplicated in two places. Adding a font requires editing both files and downloading the binary. +- Users can only ever pick from those 8. Designers expect to find Roboto, Open Sans, Bebas Neue, Bricolage Grotesque — the playground feels limited. + +Spec C unifies the two systems behind a single canonical catalog file, ships ~50 curated fonts physically inside the API server, and replaces the playground's 8-pill picker with a searchable virtualized combobox showing all ~1,800 Google Fonts. Non-curated fonts are clearly marked "Preview only — not yet API supported" and trigger a warning banner inside the code output drawer when selected. + +## Goals + +1. Single source of truth for the curated font catalog used by both API server and playground. +2. Curated 50 fonts physically downloaded and registered with the API server (zero regressions for existing playground users; expanded coverage for future users). +3. All ~1,800 Google Fonts surfaced in the playground combobox with clear "API ready" vs "Preview only" labeling. +4. Searchable, virtualized combobox UX with category filters, recents, and per-row in-font preview. +5. Honest communication of the API gap via a warning banner in the code drawer when a Preview-only font is selected. + +## Non-Goals (deferred) + +- API-side lazy font registration (downloading any Google Font on demand at the API server). Explicitly rejected: increases complexity, abuse surface, and cold-start latency. +- Variable font axis controls (weight/width/slant sliders). +- Font pairing suggestions ("usually paired with…"). +- BYO-font upload (user-supplied font files). +- Replacing the existing weight Slider UI. + +## Architecture + +### 1. Canonical catalog file + +**Location:** `src/engine/font-catalog.ts` + +A single TypeScript module that defines: + +```ts +export interface CuratedFontEntry { + name: string; // 'Outfit' — what users see in the picker + family: string; // 'Outfit' — CSS font-family value + slug: string; // 'outfit' — directory name under /fonts/ + weights: number[]; // [400, 700, 800] + category: 'sans-serif' | 'serif' | 'display' | 'handwriting' | 'monospace'; + subsets: string[]; // ['latin'] or ['latin', 'cjk'] etc. +} + +export const CURATED_FONTS: CuratedFontEntry[]; + +export function isCuratedFont(name: string): boolean; +``` + +Both the existing API-server `src/engine/fonts.ts` and the playground client `docs/site/src/components/engine/fonts.ts` are rewritten as thin wrappers that import `CURATED_FONTS` from this canonical file. Each side adds its environment-specific behavior (API server registers TTF paths; playground client loads Google CSS) without re-declaring the catalog. + +### 2. Full Google Fonts catalog dump + +**Location:** `src/data/google-fonts.json` + +A static JSON file containing ~1,800 entries, generated once from the public `gwfh.mranftl.com/api/fonts` mirror and committed to git. Shape (a subset of the upstream API): + +```json +[ + { + "family": "Roboto", + "category": "sans-serif", + "subsets": ["latin", "cyrillic", "greek"], + "variants": ["regular", "700", "italic"] + }, + ... +] +``` + +This file is the source of truth for the playground combobox's "all Google Fonts" view. It is NOT used by the API server. + +### 3. Refresh script + +**Location:** `scripts/refresh-google-fonts.ts` + +A one-shot script run manually (not in CI). Fetches the latest Google Fonts catalog from `gwfh.mranftl.com/api/fonts`, transforms to the shape above, writes `src/data/google-fonts.json`. Run quarterly. If the public mirror is unreachable, the script logs an error and exits non-zero — the existing JSON file is unchanged. + +### 4. Curated 50 fonts + +The list: + +| Category | Fonts | +|---|---| +| Sans-serif (22) | Inter, Roboto, Open Sans, Lato, Montserrat, Poppins, Outfit, Sora, Space Grotesk, DM Sans, Manrope, Plus Jakarta Sans, Figtree, Work Sans, Nunito, Nunito Sans, Source Sans 3, Karla, Rubik, Mulish, Onest, Albert Sans | +| Serif (10) | Playfair Display, Merriweather, Lora, Crimson Pro, EB Garamond, Cormorant Garamond, Source Serif 4, PT Serif, Bitter, Spectral | +| Display (9) | Bebas Neue, Anton, Oswald, Archivo Black, Fraunces, Syne, Unbounded, Bricolage Grotesque, Familjen Grotesk | +| Monospace (5) | JetBrains Mono, Fira Code, IBM Plex Mono, Geist Mono, Space Mono | +| Handwriting (3) | Caveat, Kalam, Pacifico | +| CJK + Arabic (4) | Noto Sans JP, Noto Sans Arabic, Noto Sans SC, Noto Sans KR | + +Total: 53 (the spec calls it "the curated 50" — the slight overshoot is fine; we round to "the curated set" in implementation). The 8 fonts currently shipped (Outfit, Inter, Playfair Display, Sora, Space Grotesk, JetBrains Mono, Noto Sans JP, Noto Sans Arabic) are all included → zero regression. + +Each curated font ships with at least weights `[400, 700]`. Display fonts that have a single weight ship that one. Variable fonts are treated as static-weight `400 / 700`. + +### 5. Curated font download script + +**Location:** `scripts/download-curated-fonts.ts` + +Run via `bun run fonts:download` from the repo root. Reads `CURATED_FONTS` from `font-catalog.ts`, iterates each entry × each weight, downloads the WOFF2 from Google Fonts CSS API (using the same User-Agent trick as the existing `docs/site/scripts/download-fonts.ts`), and writes to `fonts//-.woff2`. Idempotent — skips files that already exist on disk. Logs progress. + +After running, the API server's `src/engine/fonts.ts` registration loop picks up all the new files automatically (no code change needed beyond the catalog import). + +### 6. FontCombobox component + +**Location:** `docs/site/src/components/ui/FontCombobox.tsx` + +A new React component replacing the current `` (which renders 8 pill buttons). + +**Trigger button:** the currently-selected font name rendered in its own family. Click toggles the dropdown. Includes an `aria-haspopup="listbox"` and a small chevron icon. + +**Dropdown panel:** `position: absolute` below the trigger on desktop, `position: fixed` bottom-sheet on mobile. Max height `60vh` desktop, `80vh` mobile. Closes on outside click, Escape, or selection. + +**Search input:** auto-focused on open. Filters by family-name substring (case-insensitive). The `/` key from the document body opens the dropdown and focuses the search input. + +**Filter chips:** `All · Sans · Serif · Display · Mono · Handwriting · CJK · Arabic`. Multi-select OR semantics within categories, AND with the search query. + +**List sections (in order):** + +1. **Recent** — last 5 picked fonts from `localStorage.getItem('pg-recent-fonts')`. Hidden if empty. No section header if empty. +2. **API ready** — the curated 50, sorted alphabetically. Each row renders the family name in its own font. No badge. +3. **Preview only** — the remaining ~1,750 fonts, sorted alphabetically. Each row renders the family name in its own font (lazy-loaded on scroll into view). Each row has a small `Preview only` badge to the right. + +**Virtualization:** hand-rolled simple windowing using a `ref` on the scroll container, `getBoundingClientRect`, and `IntersectionObserver`. Only render rows that are within the viewport ± 10 rows of overscan. No external library — keeps the dependency footprint at zero. (Spec acknowledges this is more code than `react-window`; the trade-off is one less dependency and full control over the row markup.) + +**Lazy CSS loading:** when a row enters the viewport, append a `` to `` if not already loaded. Track loaded fonts in a module-level `Set`. When the user selects a font, the same loader runs synchronously to ensure the canvas re-render uses the correct font. + +**Keyboard navigation:** +- `↑` / `↓` move the highlighted row +- `Enter` selects the highlighted row +- `Escape` closes the dropdown +- `/` from page body opens the dropdown and focuses search +- `Tab` from search input moves focus to the first filter chip; subsequent Tab/Shift-Tab cycles chips and rows + +**Empty state:** "No fonts match ``" with a hint about category filters. + +**Selection:** +1. Sets the active font in Playground state +2. Prepends the font name to `pg-recent-fonts` localStorage (deduplicated, capped at 5) +3. Closes the dropdown +4. Triggers the playground re-render + +### 7. Preview-only warning banner + +When the active font is NOT in `CURATED_FONTS`, the `CodeOutput` component (inside the `CodeDrawer` from Spec B) renders a warning banner above the curl/SDK/JSON tabs: + +``` +⚠ "Bebas Neue" is preview-only — the API server doesn't have this font yet. + Pick an "API ready" font, or see the supported list at /fonts/available-fonts/. +``` + +The banner uses `--pg-text-secondary` for body text and the `accent` color for the link. Click → opens `/fonts/available-fonts/` in a new tab. + +The warning does not prevent code generation — users can still copy the curl/SDK example. They just see a clear signal that the example will fail at the API. + +### 8. /fonts/available-fonts/ page update + +**Location:** `docs/site/src/content/docs/fonts/available-fonts.mdx` + +The current page lists 8 fonts statically. Update it to import `CURATED_FONTS` from `font-catalog.ts` and render an alphabetical table of all 50, grouped by category. This keeps the documentation in sync with the code automatically — no more "8 fonts in the docs but the API has more / fewer". + +### 9. Documentation: how unified fonts work + +A short section added to the existing `docs/site/src/content/docs/fonts/available-fonts.mdx` (or a new sibling page) explaining: +- The curated 50 are server-side ready +- The playground also supports preview of any Google Font +- How to request a font be added to the curated list (link to GitHub issues) + +## Files Touched (provisional) + +| # | File | Type | Notes | +|---|---|---|---| +| 1 | `src/engine/font-catalog.ts` | NEW | canonical catalog + helpers | +| 2 | `src/data/google-fonts.json` | NEW | static dump (~200KB) | +| 3 | `src/engine/fonts.ts` | rewrite | imports CURATED_FONTS, no hardcoded list | +| 4 | `docs/site/src/components/engine/fonts.ts` | rewrite | imports CURATED_FONTS, no hardcoded list | +| 5 | `docs/site/src/components/ui/FontCombobox.tsx` | NEW | searchable combobox | +| 6 | `docs/site/src/components/ui/StyleControls.tsx` | modify | replace `` with ``, delete `FontPicker` function | +| 7 | `docs/site/src/components/Playground.tsx` | modify | wire CodeDrawer warning | +| 8 | `docs/site/src/components/ui/CodeOutput.tsx` | modify | render warning banner when font is preview-only | +| 9 | `scripts/refresh-google-fonts.ts` | NEW | manual refresh script | +| 10 | `scripts/download-curated-fonts.ts` | NEW | download all curated fonts to /fonts/ | +| 11 | `package.json` (root) | modify | add `fonts:download` and `fonts:refresh-catalog` scripts | +| 12 | `fonts/` (binary) | NEW files | ~42 new font directories, ~80 WOFF2 files, ~15-25 MB total | +| 13 | `docs/site/src/content/docs/fonts/available-fonts.mdx` | rewrite | auto-generated table from font-catalog.ts | + +The implementation plan will verify exact paths and may add or remove files based on what Bun/Astro tooling allows. + +## Testing Approach + +### Unit +- `font-catalog.ts`: `CURATED_FONTS` length is 50+; every entry has the required fields; `isCuratedFont('Inter')` returns true; `isCuratedFont('Comic Sans MS')` returns false. + +### API server smoke test +- Start the server: `bun run dev` from repo root +- For each of the 50 curated names: `POST /render` with that name as the font, expect HTTP 200 and a valid PNG body +- `POST /render` with `font: "Roboto Mono"` (a known non-curated mono) returns a clear 4xx error message naming the unsupported font + +### Playground smoke test (manual via dev server + Chrome MCP) +- Open `/playground/`. Confirm the FontCombobox replaces the 8 pills. +- Open the dropdown. Confirm sections render: Recent (empty), API ready (50), Preview only (~1,750). +- Search "Bebas". Confirm "Bebas Neue" appears in API ready. Search "Cinzel". Confirm it appears in Preview only. +- Filter by Mono. Confirm only mono fonts are shown. +- Select Bebas Neue. Confirm: + - The preview re-renders with the new font + - "Bebas Neue" appears in the trigger button rendered in its own font + - Recent now contains Bebas Neue +- Select Cinzel (a Preview-only font). Confirm: + - Preview re-renders correctly (browser CSS load) + - Open the CodeDrawer (View code) + - Confirm the warning banner appears with the Cinzel font name +- Reload the page. Confirm Recent persists. + +### No regressions +- All 8 existing presets still render with the same fonts after Spec C (since all 8 are in CURATED_FONTS) +- All Spec A and Spec B behaviors still work: contrast, sticky Surprise me, R shortcut, drawer, sidebar +- `bun run build` from `docs/site` passes +- Pre-commit hooks pass + +### Manual verification +- Run `bun run fonts:download` from repo root. Confirm all 50 fonts land in `/fonts//`. Confirm WOFF2 files are well-formed (open one in a browser). + +## Risks & Mitigations + +| Risk | Mitigation | +|---|---| +| Repo size grows by 15-25 MB | Acceptable: WOFF2 is small, the tradeoff buys runtime correctness. If size becomes a deploy concern later, move `/fonts/` to a release artifact pulled at API container startup. | +| docs/site cannot import from `../../src/engine/font-catalog.ts` due to tsconfig rootDir restrictions | Verify during planning. Fallback: a `prebuild` script in docs/site copies the canonical file into `docs/site/src/data/font-catalog.ts` so the import is local. The canonical source remains the root file. | +| Public mirror gwfh.mranftl.com goes down before refresh | Refresh script is manual, not in CI. Build never depends on network availability. The committed JSON file is always current enough. | +| 1,800 rows crash the DOM | Hand-rolled virtualization renders only ~30 visible rows + overscan. Test on a low-end device. If this is too risky, swap in `react-window` (acceptable single-purpose dependency). | +| Lazy CSS loading hammers Google Fonts CDN | Loaded fonts are tracked in a module-level Set; each font is loaded at most once. Cap concurrent loads to 10 via a simple queue. | +| Variable font support is different from per-weight | Treat all fonts as static-weight 400 / 700. Variable fonts are deferred. The download script always requests the static `:wght@400` and `:wght@700` URLs. | +| The 50 curated fonts include some I (the user) don't like | Edit the list before approving the spec; the curated set is a one-line edit to the catalog file. | +| `FontPicker` references in the codebase beyond StyleControls | Grep before deletion; rewrite call sites to `FontCombobox`. | +| Existing 8 hardcoded fonts have name/slug mismatches with what the catalog declares | Manually verify each of the 8 against the new catalog during implementation. The 8 currently-shipped names are: Outfit, Inter, Playfair Display, Sora, Space Grotesk, JetBrains Mono, Noto Sans JP, Noto Sans Arabic. | + +## Acceptance Criteria + +- [ ] `src/engine/font-catalog.ts` exists and exports `CURATED_FONTS` (>= 50 entries) and `isCuratedFont()` +- [ ] `src/engine/fonts.ts` imports its registration list from `font-catalog.ts` — no hardcoded duplicates remain +- [ ] `docs/site/src/components/engine/fonts.ts` imports from `font-catalog.ts` — no hardcoded duplicates remain +- [ ] `src/data/google-fonts.json` exists with at least 1,500 entries +- [ ] `scripts/refresh-google-fonts.ts` and `scripts/download-curated-fonts.ts` exist and work +- [ ] `bun run fonts:download` (from repo root) downloads all curated fonts to `/fonts/` +- [ ] All 50 curated fonts physically exist under `/fonts//-.woff2` after the script runs +- [ ] Playground replaces the 8 font pills with `` showing all ~1,800 fonts +- [ ] Combobox supports: search, filter chips, virtualized list, lazy CSS loading, recents, keyboard navigation +- [ ] Selecting a Preview-only font shows a warning banner in the CodeOutput drawer +- [ ] Selecting a curated font shows no banner +- [ ] All 8 existing presets still render with the correct fonts (zero visual regression) +- [ ] API server returns valid renders for all 50 curated font names +- [ ] API server returns a clear error for non-curated font names +- [ ] `available-fonts.mdx` reflects the catalog automatically +- [ ] No console errors introduced +- [ ] All Spec A and Spec B behaviors still work + +## Out of Scope (explicit reminders) + +- API-side lazy font registration → rejected +- Variable font axes → deferred +- Font pairing suggestions → not planned +- BYO font upload → not planned +- Replacing the existing weight Slider UI → not planned diff --git a/docs/superpowers/specs/2026-04-10-dashboard-auth-swagger-design.md b/docs/superpowers/specs/2026-04-10-dashboard-auth-swagger-design.md new file mode 100644 index 0000000..82508bd --- /dev/null +++ b/docs/superpowers/specs/2026-04-10-dashboard-auth-swagger-design.md @@ -0,0 +1,468 @@ +# Dashboard, Auth & Swagger — Design Spec + +**Date:** 2026-04-10 +**Status:** Approved +**Scope:** Add magic-link authentication, an htmx-powered dashboard, and OpenAPI/Swagger documentation to the existing Hono server. + +--- + +## 1. Overview + +OG Engine currently operates as an API-only service with a separate Astro/Starlight documentation site. Users register via `POST /auth/register`, receive an API key, and manage billing through Stripe's hosted portal. There is no user-facing dashboard, no login flow, and no OpenAPI spec. + +This spec adds three capabilities: + +1. **Magic-link authentication** — passwordless login via email (Resend) +2. **Dashboard** — an htmx-powered UI served from the Hono server for managing subscriptions, API keys, render history, and more +3. **OpenAPI/Swagger** — auto-generated spec from Zod schemas with Swagger UI + +All three live inside the existing Hono process as new route groups. + +--- + +## 2. Architecture + +### Route Strategy + +**Existing API endpoints stay where they are.** No route renaming — this avoids breaking the SDK (`sdk/index.ts` hardcodes paths like `/render`), the playground (`api-client.ts` hardcodes `/render`), and any external consumers. + +Existing routes (unchanged): + +- `POST /render`, `POST /validate`, `POST /render/from-url`, `POST /render/batch` +- `GET /health`, `GET /usage` +- `POST /auth/register` (existing registration endpoint — see Section 3 for changes) +- `POST /templates`, `GET /templates/:id`, `POST /triggers` +- `GET /billing/portal`, `POST /webhooks/stripe`, `POST /admin/reset-free-quotas` + +New route groups: + +- **`/auth/*`** — login page, magic link sending, token verification, logout (extends existing `/auth/register`) +- **`/dashboard/*`** — all 8 dashboard sections (HTML pages / htmx partials) +- **`/docs`** — Swagger UI +- **`/openapi.json`** — raw OpenAPI 3.1 spec + +### Single Process + +The dashboard, auth, and Swagger routes are added to the existing Hono server. No separate process or deployment. The dashboard accesses the render engine and SQLite database in-process — no internal HTTP calls needed for re-renders or data queries. + +--- + +## 3. Authentication + +### Magic Link Flow + +1. **`GET /auth/login`** — renders a page with an email input form +2. **`POST /auth/send-link`** — validates email, creates a token in `magic_links` table, sends email via Resend with a verification URL. Returns a "check your email" confirmation page. +3. **`GET /auth/verify?token=`** — validates token (not expired, not used), marks it as used, creates or retrieves the `users` row, creates a session in `sessions` table, sets an `HttpOnly` session cookie, redirects to `/dashboard` +4. **`POST /auth/logout`** — deletes the session from DB, clears the cookie, redirects to `/auth/login` + +### Magic Link Tokens + +- Generated with `crypto.randomUUID()` (128 bits of entropy) +- Stored as SHA-256 hashes in the database (never plain text) +- Expire after 15 minutes +- Single-use — marked `used = true` after verification +- Rate-limited: max 3 requests per email per 10 minutes + +### Session Management + +- Session token generated with `crypto.randomUUID()` +- Stored as SHA-256 hash in `sessions` table +- Set as `HttpOnly` / `Secure` / `SameSite=Lax` cookie +- `Secure` flag skipped in development (non-HTTPS) +- 30-day rolling expiry — `expires_at` refreshed on each authenticated request +- Session middleware reads cookie, looks up session in SQLite, attaches user to Hono context + +### Account Creation + +When a user verifies a magic link for the first time (no existing `users` row for that email): + +1. Create a `users` row with quota fields (`calls_limit`, `calls_used`, `period_start`) migrated from the API key level (see Section 6) +2. If an `api_keys` row exists with the same email, link it via `user_id` FK +3. If no `api_keys` row exists, create a free-tier API key automatically + +Returning users just get a new session — no account creation. + +### Existing `POST /auth/register` Endpoint + +The current `POST /auth/register` endpoint (returns an API key immediately + sends welcome email) **remains unchanged** for programmatic key creation. It continues to work without a dashboard login — this is important for developers who just want an API key via curl. + +When a user later logs into the dashboard via magic link using the same email, their existing API key is linked to the new user account. + +### Unauthenticated Dashboard Access + +Any request to `/dashboard/*` without a valid session cookie redirects to `GET /auth/login` with a `302`. The `returnTo` query param preserves the original URL so the user lands on the right page after login. + +--- + +## 4. Dashboard + +### Technology + +- **htmx** (vendored, ~14kb gzipped) — no build step, no bundler +- **Plain CSS** — single `dashboard.css` file served from `/static/` +- **Template literal functions** — each view is a TypeScript function returning an HTML string. No template engine dependency. + +### Shell Pattern + +A single full-page load delivers the shell layout: + +- Fixed sidebar with navigation links +- Header with page title +- `#main-content` div for page content +- User email and logout link at the bottom of the sidebar + +Subsequent navigation uses htmx partial swaps: + +```html + + Images + +``` + +The server detects htmx requests via the `HX-Request` header: +- **htmx request:** returns only the inner content HTML fragment +- **Direct navigation** (bookmark, refresh): returns the full page with shell + +### Dashboard Sections + +#### 4.1 Overview (`GET /dashboard`) + +- Plan name, price, and status +- Usage meter: calls used / calls limit with progress bar +- Average render time (last 7 days) +- Recent renders list (last 10) with "View all" link to Images section + +#### 4.2 Images (`GET /dashboard/images`) + +- Paginated table of render history entries +- Each row: title (from request payload), format, template, render time, timestamp +- **Re-render button** (`hx-post="/dashboard/images/:id/render"`) — calls the render engine in-process with the stored request payload, returns the image inline or as a download +- **Metadata only** — no images are stored. The `render_history` table holds the request payload (JSON). Thumbnails are generated on demand. +- Infinite scroll via `hx-trigger="revealed"` on the last row +- Filters: format, template, date range + +#### 4.3 API Keys (`GET /dashboard/api-keys`) + +- List of user's API keys with masked display (show last 8 chars) +- Copy-to-clipboard button (brief full reveal) +- Last used timestamp per key +- **Create new key** (`hx-post="/dashboard/api-keys"`) +- **Revoke key** (`hx-delete="/dashboard/api-keys/:id"` with `hx-confirm`) +- **Regenerate key** (`hx-post="/dashboard/api-keys/:id/regenerate"` with `hx-confirm`) + +#### 4.4 Billing (`GET /dashboard/billing`) + +- Current plan name, price, and next billing date +- Usage meter (same as overview) +- **"Manage Subscription" button** — regular link (not htmx) to Stripe Customer Portal via `GET /billing/portal`. Handles upgrade, downgrade, cancellation, and payment method updates. +- Recent invoices (fetched from Stripe API on page load) + +#### 4.5 Usage Analytics (`GET /dashboard/usage`) + +- Usage chart: renders per day over the selected period (server-rendered SVG bars or simple HTML/CSS bars) +- Date range picker (`hx-get` with query params swaps the chart area) +- Breakdown by endpoint, format, and template +- Quota warning when usage exceeds 80% + +#### 4.6 Custom Templates (`GET /dashboard/templates`) + +- List of user's custom templates (Scale tier only) +- **Create template** — JSON editor (textarea) with live preview (`hx-post` on save, preview via debounced `hx-trigger="keyup changed delay:500ms"`) +- **Edit / Delete** — inline editing with `hx-put` / `hx-delete` +- Plan gate: non-Scale users see the list with an upgrade prompt + +#### 4.7 Webhooks (`GET /dashboard/webhooks`) + +- Table of webhook triggers with status (active/inactive) +- **Create** (`hx-post`), **Edit** (`hx-put`), **Delete** (`hx-delete`) +- **Test button** (`hx-post="/dashboard/webhooks/:id/test"`) — sends a test payload and shows the response inline +- Delivery log: last 10 deliveries with status codes + +#### 4.8 Settings (`GET /dashboard/settings`) + +- Email display (read-only — changing email requires a new magic link verification) +- Notification preferences (email on quota warning at 80% and 100%) +- **Delete account** (`hx-delete="/dashboard/settings/account"` with `hx-confirm`) — deletes user, revokes all keys, cancels Stripe subscription + +### htmx Interaction Summary + +| Action | htmx attribute | Pattern | +|---|---|---| +| Navigate sections | `hx-get` + `hx-target="#main-content"` + `hx-push-url` | Partial swap | +| Create / update | `hx-post` / `hx-put` + `hx-target` | Replace element | +| Delete | `hx-delete` + `hx-confirm` + `hx-target` | Remove element | +| Infinite scroll | `hx-trigger="revealed"` | Append rows | +| Live preview | `hx-trigger="keyup changed delay:500ms"` | Debounced swap | +| Redirect (Stripe) | Regular `` | Full navigation | + +--- + +## 5. OpenAPI / Swagger + +### Approach + +Use `@hono/zod-openapi` to define API routes with Zod schemas that auto-generate an OpenAPI 3.1 spec. + +**Zod v4 compatibility:** The project uses Zod v4 (`^4.3.6`). Before implementation, verify that `@hono/zod-openapi` supports Zod v4. If it does not, the fallback approach is to write the OpenAPI spec manually as a JSON file (derived from the existing Zod schemas) and serve it statically — this avoids a Zod downgrade. + +### Routes + +- **`GET /docs`** — Swagger UI served via `@hono/swagger-ui` +- **`GET /openapi.json`** — raw OpenAPI 3.1 JSON spec + +### Scope + +Only the public API endpoints are documented (`/render`, `/validate`, `/health`, etc.). Dashboard (`/dashboard/*`) and auth (`/auth/*`) routes are internal HTML and excluded from the API spec. + +### Migration + +Existing route definitions are converted from: + +```typescript +app.post('/render', handler) +``` + +To: + +```typescript +const renderRoute = createRoute({ + method: 'post', + path: '/render', + request: { body: { content: { 'application/json': { schema: renderSchema } } } }, + responses: { 200: { description: 'Rendered image', content: { 'image/png': {} } } }, +}) +app.openapi(renderRoute, handler) +``` + +Business logic in handlers remains unchanged. This is a refactor of route wiring, not behavior. + +### New Dependencies + +- `@hono/zod-openapi` — route definitions with Zod schema integration (pending Zod v4 compatibility check) +- `@hono/swagger-ui` — Swagger UI middleware + +--- + +## 6. Database Schema Changes + +### Quota Model Change + +**Quotas move from per-key to per-user.** Currently each `api_keys` row has its own `calls_limit` / `calls_used` counters. With multiple keys per user, this would let users bypass quotas by creating extra keys. The new model stores quota fields on the `users` table, and all API keys for a user share a single quota. + +The `calls_limit`, `calls_used`, and `period_start` columns are removed from `api_keys` and added to `users`. The auth middleware now looks up the user (via `api_keys.user_id`) and checks `users.calls_used` instead of `api_keys.calls_used`. + +### Consolidating `usage_log` into `render_history` + +The existing `usage_log` table tracks `api_key_id`, `endpoint`, `render_time_ms`, `format`, `created_at`. The new `render_history` table tracks the same fields plus `request_payload` and `user_id`. Rather than maintaining two overlapping tables, **`usage_log` is replaced by `render_history`**. All existing usage queries are updated to read from `render_history`. + +### New Tables + +```sql +CREATE TABLE users ( + id TEXT PRIMARY KEY, + email TEXT UNIQUE NOT NULL, + plan TEXT NOT NULL DEFAULT 'free', + stripe_customer_id TEXT, + stripe_subscription_id TEXT, + calls_limit INTEGER NOT NULL DEFAULT 500, + calls_used INTEGER NOT NULL DEFAULT 0, + period_start TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + active INTEGER NOT NULL DEFAULT 1 +); + +CREATE TABLE sessions ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token_hash TEXT NOT NULL, + csrf_token TEXT NOT NULL, + expires_at TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE magic_links ( + id TEXT PRIMARY KEY, + email TEXT NOT NULL, + token_hash TEXT NOT NULL, + expires_at TEXT NOT NULL, + used INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE render_history ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + api_key_id TEXT REFERENCES api_keys(id) ON DELETE SET NULL, + endpoint TEXT NOT NULL, + request_payload TEXT NOT NULL, + format TEXT NOT NULL, + template TEXT, + render_time_ms REAL, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); +``` + +### Modified Tables + +```sql +-- Link keys to users +ALTER TABLE api_keys ADD COLUMN user_id TEXT REFERENCES users(id); + +-- Remove quota fields from api_keys (now on users table) +-- Note: SQLite doesn't support DROP COLUMN before 3.35.0. +-- Implementation should recreate the table without these columns. +-- Columns to remove: plan, stripe_customer_id, stripe_subscription_id, +-- calls_limit, calls_used, period_start + +-- Drop usage_log (replaced by render_history) +DROP TABLE IF EXISTS usage_log; +``` + +### Indexes + +```sql +CREATE INDEX idx_sessions_token_hash ON sessions(token_hash); +CREATE INDEX idx_sessions_user_id ON sessions(user_id); +CREATE INDEX idx_magic_links_token_hash ON magic_links(token_hash); +CREATE INDEX idx_magic_links_email ON magic_links(email); +CREATE INDEX idx_render_history_user_id ON render_history(user_id); +CREATE INDEX idx_render_history_created_at ON render_history(created_at); +CREATE INDEX idx_api_keys_user_id ON api_keys(user_id); +``` + +### Data Migration + +1. For each existing `api_keys` row, create a `users` row with the same email (deduplicate by email), copying `plan`, `stripe_customer_id`, `stripe_subscription_id`, `calls_limit`, `calls_used`, and `period_start` +2. Set `api_keys.user_id` to the new user's ID +3. Migrate existing `usage_log` rows into `render_history` (with `request_payload` set to `'{}'` for historical entries) +4. Drop `usage_log` table +5. Recreate `api_keys` table without the migrated quota/plan columns + +Existing API key auth continues working — the auth middleware is updated to look up `users` via the key's `user_id` for quota checks. + +### Session & Magic Link Cleanup + +Expired sessions and used/expired magic links accumulate over time. A cleanup function runs on a schedule: + +- **Trigger:** The existing `POST /admin/reset-free-quotas` monthly cron is extended to also purge expired sessions (older than 30 days) and magic links (older than 1 hour) +- **Opportunistic cleanup:** The session middleware deletes the current session if it's expired (on-read pruning), so active traffic self-cleans + +--- + +## 7. Project Structure (New Files) + +``` +src/ +├── auth/ +│ ├── magic-link.ts # Token generation, Resend email sending +│ ├── session.ts # Session CRUD, validation, rolling expiry +│ └── middleware.ts # Cookie-based session middleware for /dashboard/* +├── dashboard/ +│ ├── routes.ts # All /dashboard/* route definitions +│ ├── layouts/ +│ │ └── shell.ts # Base HTML (sidebar, , htmx script tag) +│ └── views/ +│ ├── overview.ts # Stats cards, recent renders +│ ├── images.ts # Render history table, re-render buttons +│ ├── api-keys.ts # Key list, create/revoke/regenerate +│ ├── billing.ts # Plan info, Stripe portal link, invoices +│ ├── usage.ts # Usage charts, breakdown tables +│ ├── templates.ts # Custom template CRUD with preview +│ ├── webhooks.ts # Webhook management, test, delivery log +│ └── settings.ts # Account preferences, delete account +├── openapi/ +│ ├── spec.ts # OpenAPI route definitions via @hono/zod-openapi +│ └── swagger.ts # Swagger UI route setup +├── db/ +│ ├── schema.ts # ← MODIFIED: add users, sessions, magic_links, render_history tables +│ └── migrate.ts # NEW: migration logic for existing api_keys data +└── static/ + ├── htmx.min.js # Vendored htmx (~14kb gzipped) + └── dashboard.css # Dashboard styles +``` + +### HTML Rendering + +Views are TypeScript functions returning HTML strings: + +```typescript +export function overviewPage(user: User, stats: Stats): string { + return ` +
+
+ Plan + ${escapeHtml(user.plan)} +
+ ... +
+ ` +} +``` + +No template engine dependency. Fully type-safe via function signatures. + +--- + +## 8. Security + +### Token Storage + +- Magic link tokens and session tokens are stored as SHA-256 hashes — never plain text +- Generated with `crypto.randomUUID()` (128 bits of entropy) + +### Cookie Configuration + +| Attribute | Value | +|---|---| +| `HttpOnly` | `true` | +| `Secure` | `true` (skipped in dev) | +| `SameSite` | `Lax` | +| `Max-Age` | 30 days | +| `Path` | `/` | + +### CSRF Protection + +- A per-session CSRF token is generated with `crypto.randomUUID()` when the session is created and stored in the `sessions` table +- The token is embedded in the shell layout as a `` tag and injected into every mutating htmx request via `hx-headers='{"X-CSRF-Token": "..."}'` on the `` element +- Server-side middleware validates the `X-CSRF-Token` header against the session's stored token on every `POST`, `PUT`, and `DELETE` request to `/dashboard/*` + +### XSS Prevention + +- All user-provided content is HTML-escaped via an `escapeHtml()` utility before rendering in template literals +- Handles `<`, `>`, `&`, `"`, `'` + +### Rate Limiting + +- Magic link requests: max 3 per email per 10 minutes +- Existing API rate limiting remains unchanged + +### Data Isolation + +- Every dashboard query filters by `user_id` from the authenticated session +- Users can only see/manage their own API keys, render history, templates, and webhooks + +--- + +## 9. New Dependencies + +| Package | Purpose | +|---|---| +| `@hono/zod-openapi` | Route definitions with OpenAPI spec generation (pending Zod v4 check) | +| `@hono/swagger-ui` | Swagger UI middleware | + +**Vendored (no npm dependency):** +- `htmx.min.js` — downloaded from htmx.org and committed to `src/static/`. No build step. + +--- + +## 10. Out of Scope + +- **OAuth providers** (GitHub, Google) — magic links only for now +- **Multi-user teams / organizations** — single user per account +- **Image storage** (S3, disk) — metadata only, re-render on demand +- **Real-time updates** (WebSocket, SSE) — standard request/response +- **Dashboard theming / customization** — single dark theme matching the docs site +- **Email change flow** — requires a new magic link to a new address (future) diff --git a/docs/superpowers/specs/2026-04-10-license-design.md b/docs/superpowers/specs/2026-04-10-license-design.md new file mode 100644 index 0000000..9af0f17 --- /dev/null +++ b/docs/superpowers/specs/2026-04-10-license-design.md @@ -0,0 +1,269 @@ +# License Design — OG Engine + +**Date:** 2026-04-10 +**Status:** Draft — awaiting review +**Author:** Philippe Matray (brainstormed with Claude) + +## 1. Context & Goal + +OG Engine currently ships with no `LICENSE` file at the repo root and no `license` +field in `package.json`. The `sdk/package.json` declares `"license": "MIT"` but +ships no `LICENSE` file alongside it. This leaves the project in an ambiguous +legal state: by default, "no license" means **all rights reserved**, which is +the opposite of what a would-be OSS-friendly project wants. + +Philippe is a solo developer running OG Engine as a bootstrapped SaaS via +Atypical Consulting SRL. The goal of adding a license is to make the project +**credibly open for individuals and OSS projects, while pushing companies +onto a paid path**. The license is the primary revenue-protection mechanism +for a one-person shop that cannot afford to have its work cloned and resold. + +## 2. Goals + +- Individual developers can read, modify, and self-host `src/` for their own + personal or side projects without paying or asking permission. +- Open-source projects can self-host `src/` as part of their own OSS stack. +- Companies that want to **host OG Engine as a service to their own users**, + or **embed OG Engine inside a product they sell**, must purchase a + commercial license. +- Any company can freely integrate the SDK (`sdk/`) to call OG Engine's + hosted API — the SDK must be permissively licensed so corporate legal teams + approve it without friction. +- The license must be **off-the-shelf**, not custom-drafted. A solo dev + should not be shipping custom license text. +- The project must have a **credible OSS promise**: every release should + eventually become fully open source on a fixed clock. + +## 3. Non-Goals + +- Charging companies for purely internal use of OG Engine (e.g. BigCo's + marketing team rendering 10 OG cards per month on their own infra). + Enforcement is infeasible and the revenue is negligible. +- Blocking individuals from doing freelance client work with OG Engine. + "Individual" is defined by the person doing the work, not the billing + relationship. +- Relicensing dependencies (`@chenglou/pretext`, `@napi-rs/canvas`, `hono`, + etc.). They keep their own licenses. +- Setting up a Contributor License Agreement (CLA). YAGNI until the first + non-trivial external contribution. +- Writing a trademark policy for the "OG Engine" name. +- Adding self-host pricing tiers to `og-engine.com/pricing/`. That is a + product decision, not a licensing decision. + +## 4. Decision: FSL-1.1-Apache-2.0 for `src/`, Apache-2.0 for `sdk/` + +### 4.1 `src/` and repo root → **FSL-1.1-Apache-2.0** + +The [Functional Source License](https://fsl.software), v1.1, with Apache-2.0 +as the Future License. This is an off-the-shelf license written by Sentry in +2023 for exactly this threat model (solo maintainers / small teams running an +OSS-credible SaaS and needing commercial protection). + +**Fill-in fields:** + +| Field | Value | +|---|---| +| Licensor | Atypical Consulting SRL | +| Software | OG Engine | +| Change Date | Release date + 2 years, per release | +| Change License | Apache License, Version 2.0 | + +**Permitted Purpose (FSL term):** any purpose other than a Competing Use. + +**Competing Use (FSL term):** making the software available to third parties +in a manner that substitutes for a commercial offering of the Licensor. + +**How this maps to the goals:** + +| Scenario | Permitted? | +|---|---| +| Solo dev using OG Engine on their personal blog | Yes | +| OSS project self-hosting OG Engine in its own stack | Yes | +| Freelancer using OG Engine to deliver a client project | Yes | +| Company running OG Engine internally to render its own marketing site's cards | Yes | +| Company self-hosting OG Engine to serve image generation to *its customers* | **No — Competing Use, needs commercial license** | +| Company embedding OG Engine's server inside a product it sells | **No — Competing Use, needs commercial license** | +| Anyone launching a rival hosted OG-image-generation service | **No — Competing Use, needs commercial license** | + +**2-year auto-conversion:** every release automatically relicenses to +Apache-2.0 two years after its release date. This is the community-credibility +mechanism: the code *will* become fully open source, just not the latest +version. Users who want the latest get it under FSL terms; users willing to +run 2-year-old code get Apache-2.0. + +### 4.2 `sdk/` → **Apache-2.0** + +The SDK must be freely usable by any company, because restricting the SDK +would prevent corporate customers from calling the hosted API — i.e. it would +block the primary revenue funnel. Apache-2.0 is preferred over MIT for the +explicit patent grant, which makes corporate legal teams happier. + +**Note:** `sdk/package.json` currently declares `"license": "MIT"` with no +accompanying `LICENSE` file. This spec overrides that declaration: +`sdk/package.json` will be updated to `"license": "Apache-2.0"` and a +`sdk/LICENSE` file added. Confirmed by Philippe during review. + +### 4.3 Alternatives considered and rejected + +| License | Why not | +|---|---| +| **PolyForm Noncommercial** | Blocks individuals doing freelance work. Contradicts "individuals free." | +| **PolyForm Small Business** | Gated on company size, not the goal. A €900k-revenue competitor could still self-host for free. | +| **Elastic License 2.0** | No time-delayed OSS conversion. Weaker community story. | +| **n8n Sustainable Use License** | Allows all internal business use even for large companies, no auto-conversion. Non-standard, no SPDX. | +| **BSL (Business Source License)** | Predecessor to FSL. Each release specifies its own Additional Use Grant — more error-prone for a solo maintainer. FSL strictly dominates. | +| **Custom license** | Needs a lawyer. Corporate legal teams balk at unfamiliar text. High risk of drafting bugs. | +| **MIT / Apache for `src/`** | No commercial protection at all. Any company could self-host and resell. | + +## 5. Files to Create + +### 5.1 `LICENSE` (repo root) + +Full text of FSL-1.1-Apache-2.0 with the fill-in fields from §4.1. The +canonical template lives at . +The root `LICENSE` applies to the entire repo **except** any subdirectory +that contains its own `LICENSE` file — which `sdk/` will. + +### 5.2 `LICENSE-APACHE-2.0` (repo root) + +The full Apache License 2.0 text, referenced by the FSL's conversion clause. +Required so that readers can see what the Future License actually says +without leaving the repo. + +### 5.3 `sdk/LICENSE` + +Full Apache-2.0 text, scoped to the SDK directory. Standard Apache-2.0 +boilerplate with `Copyright 2026 Atypical Consulting SRL`. + +### 5.4 `COMMERCIAL-LICENSE.md` (repo root) + +Plain-English explainer. Draft content: + +```markdown +# Commercial License + +OG Engine's server is free to use, modify, and self-host for most purposes +under the [Functional Source License](./LICENSE). **You need a commercial +license only if:** + +- You host OG Engine as a service that your own users call (even + internally-marketed, even free-to-your-users). +- You embed OG Engine's server code inside a product you sell, license, or + distribute. +- You operate a hosted OG-image-generation service that competes with OG + Engine's own hosted API. + +**You do NOT need a commercial license if:** + +- You're calling OG Engine's hosted API at `api.og-engine.com` — that's what + your subscription plan at covers. +- You're a developer using it for personal projects, side projects, or your + own learning. +- You're an open-source project self-hosting it as part of your own OSS stack. +- You're a company using it purely internally — e.g. rendering OG images for + your own marketing site — without exposing it to your users or customers + as a feature. + +## I just want to call the hosted API + +You're in the right place: . No commercial +license needed — your plan covers it. + +## I need to self-host or embed + +Email **philippe@atypical.consulting** with: + +- Your company name +- A one-line description of how you plan to use OG Engine +- Expected render volume per month + +We'll get back to you within 2 business days with terms. + +## Not sure which side of the line you're on? + +Email **philippe@atypical.consulting** with your use case. We'll tell you +for free. No gotchas. +``` + +### 5.5 `LICENSE-HISTORY.md` (repo root) + +Tracks, for each release, the date it converts to Apache-2.0. FSL requires +this to be discoverable. Format: + +```markdown +# License History + +Every release of OG Engine ships under FSL-1.1-Apache-2.0 and automatically +converts to Apache-2.0 two years after its release date. + +| Version | Release Date | Converts to Apache-2.0 on | +|---------|--------------|---------------------------| +| 0.1.0 | TBD | TBD (release date + 2 years) | +``` + +The first row uses `TBD` because `v0.1.0` has not yet shipped. The +release-checklist task in §6 ensures the row is filled in at release time. + +## 6. Files to Modify + +### 6.1 `README.md` + +Add a "License" section near the bottom with the blurb from brainstorming +Section 2. Links to `LICENSE`, `sdk/LICENSE`, and `COMMERCIAL-LICENSE.md`. + +### 6.2 `package.json` (root) + +Change `"license"` to `"SEE LICENSE IN LICENSE"` (npm's convention for +non-SPDX licenses — FSL is not yet registered with SPDX). Currently the +field is **absent**; this adds it. + +### 6.3 `sdk/package.json` + +Change `"license": "MIT"` to `"license": "Apache-2.0"`. + +### 6.4 `CLAUDE.md` (or new `RELEASING.md`) + +Add a release checklist note: + +> When cutting a release, append a row to `LICENSE-HISTORY.md` with: +> `| | | |` +> +> This is required by FSL for the Change Date to be discoverable. + +Placement decision deferred to implementation: if a `RELEASING.md` already +exists or is planned, the note goes there; otherwise append to the CLAUDE.md +"Build Priorities" section. + +## 7. Implementation Sequencing + +All changes land in a **single atomic commit** so the repo is never in a +half-licensed state. + +1. Create `LICENSE` (FSL-1.1-Apache-2.0, filled in). +2. Create `LICENSE-APACHE-2.0` (full Apache text). +3. Create `sdk/LICENSE` (Apache-2.0 boilerplate). +4. Create `COMMERCIAL-LICENSE.md`. +5. Create `LICENSE-HISTORY.md` with placeholder first row. +6. Modify `README.md` — add License section. +7. Modify `package.json` — add `license` field. +8. Modify `sdk/package.json` — update `license` field. +9. Add release-checklist note to `CLAUDE.md` or `RELEASING.md`. +10. Commit as one change: `chore(license): add FSL-1.1-Apache-2.0 for server, Apache-2.0 for SDK`. + +## 8. Risks & Mitigations + +| Risk | Mitigation | +|---|---| +| FSL is not SPDX-registered, so tooling (GitHub license detection, npm, dependency scanners) will report "unknown license" | Accepted cost. Sentry, Keygen, Gitpod all ship on FSL and survive it. The README and `package.json` `SEE LICENSE IN LICENSE` convention handle the human-readable case. | +| A company self-hosts, claims "internal use," and actually runs it as a feature for their customers | Out-of-scope for the license itself; this is a detection/enforcement question, not a drafting question. The license gives the legal grounds; enforcement is a business decision if/when it happens. | +| Philippe forgets to update `LICENSE-HISTORY.md` on a release and a release ships with no Change Date recorded | Mitigated by the release-checklist note in §6.4. Could be further mitigated by a lefthook pre-tag check in a future iteration — out of scope for this spec. | +| An OSS contributor submits a PR and there's no CLA, so the contribution rights are ambiguous | Accepted until the first non-trivial external contribution. FSL does not require a CLA. Revisit if/when it becomes a real problem. | +| Corporate legal reviewers reject FSL because it's unfamiliar | Mitigated by the `COMMERCIAL-LICENSE.md` file giving them a clear path to a commercial license and explicit answers to "do I need one?" | + +## 9. Open Questions for User Review + +1. **Release-checklist placement:** `CLAUDE.md` or a new `RELEASING.md`? + (§6.4) No existing `RELEASING.md` found. Default: append to `CLAUDE.md`. +2. **`LICENSE-HISTORY.md` first row:** leave as `TBD | TBD` placeholder, or + pre-fill `v0.1.0` with today's date? Default: `TBD` until the actual + release ships, to avoid a stale "released" date in an unreleased version. diff --git a/docs/superpowers/specs/2026-04-10-template-expansion-design.md b/docs/superpowers/specs/2026-04-10-template-expansion-design.md new file mode 100644 index 0000000..6e91083 --- /dev/null +++ b/docs/superpowers/specs/2026-04-10-template-expansion-design.md @@ -0,0 +1,115 @@ +# Template Expansion — Design Spec + +## Goal + +Expand OG Engine from 4 built-in templates to 12, covering real-world use cases that leverage the new `variables` and `namedImages` system. Refactor the monolithic `templates.ts` into individual files. + +## Architecture + +### File Structure + +Split `src/engine/templates.ts` (currently ~500 lines, 4 templates) into: + +``` +src/engine/templates/ + index.ts — TEMPLATES registry, exports TemplateFn, TemplateInput, TemplateResult, TEMPLATE_NAMES + helpers.ts — shared: paintBackgroundMesh, drawBgImage, fitTitleLines, hexToRgb, rgba + default.ts — existing default template + social-card.ts — existing social-card template + blog-hero.ts — existing blog-hero template + email-banner.ts — existing email-banner template + product-card.ts — NEW + event.ts — NEW + testimonial.ts — NEW + github-repo.ts — NEW + news-article.ts — NEW + pricing.ts — NEW + profile-card.ts — NEW + announcement.ts — NEW +``` + +`src/engine/templates.ts` becomes a re-export barrel: +```typescript +export { TEMPLATES, TEMPLATE_NAMES, getTemplate } from './templates/index'; +export type { TemplateFn, TemplateInput, TemplateResult } from './templates/index'; +``` + +This preserves all existing imports across the codebase. + +### Template Designs + +All templates: +- Accept the standard `TemplateInput` interface +- Read custom data from `input.variables` (with fallback to `input.content.*` for legacy fields) +- Read images from `input.namedImages` (with fallback to `input.bgImage`) +- Use existing helpers for background, text measurement, auto-shrink +- Support accent color, gradient, font, and layout options +- Return `TemplateResult` with line counts and overflow + +#### 1. `product-card` +- **Variables:** `title` (product name), `price`, `badge` (e.g. "-20%"), `brand` +- **Images:** `product` (main image, right side), `logo` (small, top-left) +- **Layout:** Split — left side has text content, right side has product image. Badge pill in accent color. Price in large bold. Brand name subtle at top. + +#### 2. `event` +- **Variables:** `title` (event name), `date`, `location`, `speaker` +- **Images:** `background`, `logo` (top-left corner) +- **Layout:** Full-width background image with gradient overlay. Title large at center-bottom. Date + location in a horizontal bar. Speaker name if provided. Logo in corner. + +#### 3. `testimonial` +- **Variables:** `quote` (maps from title), `name`, `company`, `role` +- **Images:** `avatar` (circular, centered above quote or left-aligned) +- **Layout:** Large opening quote mark in accent. Quote text centered, italic. Author line: name + role + company below. Avatar circular if provided. + +#### 4. `github-repo` +- **Variables:** `title` (repo name), `description`, `stars`, `language`, `owner` +- **Images:** `avatar` (owner avatar, circular) +- **Layout:** Dark theme (forced dark gradient). Repo icon + owner/name at top. Description below. Bottom bar: language dot + star count. Monospace font for repo name. + +#### 5. `news-article` +- **Variables:** `title`, `source` (publication name), `date`, `category` +- **Images:** `background` (article hero), `logo` (publication logo) +- **Layout:** Full-bleed background with strong bottom gradient. Category pill at top. Large title. Source + date line at bottom. Logo in corner. + +#### 6. `pricing` +- **Variables:** `plan` (plan name), `price`, `period` (e.g. "/mo"), `features` (comma-separated), `cta` (button text) +- **Images:** `logo` +- **Layout:** Centered card feel. Plan name at top in accent. Large price. Feature list with checkmarks. CTA button at bottom. Logo subtle at top. + +#### 7. `profile-card` +- **Variables:** `name`, `role`, `company`, `bio` +- **Images:** `avatar` (large, centered or left), `logo` (company logo, small) +- **Layout:** Avatar prominent (large circle). Name large below. Role + company in muted text. Bio as description. Logo in corner. + +#### 8. `announcement` +- **Variables:** `title`, `subtitle`, `cta` (call to action), `tag` +- **Images:** `background`, `logo` +- **Layout:** Dramatic. Background with heavy overlay. Tag pill at top. Title very large, centered. Subtitle below. CTA button at bottom in accent. Logo in corner. + +### Registration + +All templates registered in `src/engine/templates/index.ts`: +```typescript +export const TEMPLATES: Record = { + default: defaultTemplate, + 'social-card': socialCardTemplate, + 'blog-hero': blogHeroTemplate, + 'email-banner': emailBannerTemplate, + 'product-card': productCardTemplate, + 'event': eventTemplate, + 'testimonial': testimonialTemplate, + 'github-repo': githubRepoTemplate, + 'news-article': newsArticleTemplate, + 'pricing': pricingTemplate, + 'profile-card': profileCardTemplate, + 'announcement': announcementTemplate, +}; +``` + +### Testing + +Each new template gets a render test: call `renderCard()` with appropriate variables, verify it produces a valid PNG buffer without error. Tests in `tests/engine/templates.test.ts` (extend existing). + +### Client-Side Sync + +The Playground's `canvas-renderer.ts` also has template implementations. The new templates should be added there too so the playground can preview them client-side. This is a separate concern — can be done in a follow-up. diff --git a/fly.staging.toml b/fly.staging.toml new file mode 100644 index 0000000..1c82f83 --- /dev/null +++ b/fly.staging.toml @@ -0,0 +1,58 @@ +# Fly.io deployment config for OG Engine — STAGING +# Deploy: fly deploy --config fly.staging.toml +# Secrets (set via fly secrets set -a og-engine-staging): +# Same secret names as production — use test/sandbox values for Stripe etc. +# ADMIN_CRON_SECRET - Shared secret for GitHub Action cron +# ADMIN_EMAIL - Admin email address for backup failure alerts +# TIGRIS_ACCESS_KEY_ID - Tigris / S3-compatible access key +# TIGRIS_SECRET_ACCESS_KEY - Tigris / S3-compatible secret key +# TIGRIS_BUCKET_NAME - Separate staging bucket name +# STRIPE_SECRET_KEY - Stripe test-mode API key (sk_test_...) +# STRIPE_WEBHOOK_SECRET - Stripe test-mode webhook signing secret +# STRIPE_PRICE_STARTER - Stripe test-mode Price ID for Starter plan +# STRIPE_PRICE_PRO - Stripe test-mode Price ID for Pro plan +# STRIPE_PRICE_SCALE - Stripe test-mode Price ID for Scale plan +# RESEND_API_KEY - Resend API key (can reuse same key) + +app = "og-engine-staging" +primary_region = "cdg" + +[build] + dockerfile = "Dockerfile" + +[env] + PORT = "3000" + NODE_ENV = "production" + DATABASE_URL = "file:/data/og-engine-staging.db" + IMAGE_CACHE_MAX = "500" + RATE_LIMIT_MAX = "200" + RATE_LIMIT_WINDOW_MS = "60000" + +[http_service] + internal_port = 3000 + force_https = true + auto_stop_machines = "stop" + auto_start_machines = true + min_machines_running = 0 + processes = ["app"] + + [http_service.concurrency] + type = "requests" + hard_limit = 100 + soft_limit = 80 + +[[http_service.checks]] + grace_period = "5s" + interval = "30s" + method = "GET" + path = "/health" + timeout = "5s" + +[mounts] + source = "og_engine_staging_data" + destination = "/data" + +[[vm]] + size = "shared-cpu-1x" + memory = "256mb" + cpus = 1 diff --git a/fly.toml b/fly.toml new file mode 100644 index 0000000..7fe4297 --- /dev/null +++ b/fly.toml @@ -0,0 +1,53 @@ +# Fly.io deployment config for OG Engine +# Deploy: fly deploy +# Secrets (set via fly secrets set): +# STRIPE_SECRET_KEY - Stripe API secret key +# STRIPE_WEBHOOK_SECRET - Stripe webhook signing secret +# STRIPE_PRICE_STARTER - Stripe Price ID for Starter plan +# STRIPE_PRICE_PRO - Stripe Price ID for Pro plan +# STRIPE_PRICE_SCALE - Stripe Price ID for Scale plan +# RESEND_API_KEY - Resend transactional email API key +# ADMIN_CRON_SECRET - Shared secret for GitHub Action cron + +app = "og-engine" +primary_region = "cdg" + +[build] + dockerfile = "Dockerfile" + +[env] + PORT = "3000" + NODE_ENV = "production" + DATABASE_URL = "file:/data/og-engine.db" + IMAGE_CACHE_MAX = "1000" + RATE_LIMIT_MAX = "100" + RATE_LIMIT_WINDOW_MS = "60000" + +[http_service] + internal_port = 3000 + force_https = true + auto_stop_machines = "stop" + auto_start_machines = true + min_machines_running = 1 + processes = ["app"] + + [http_service.concurrency] + type = "requests" + hard_limit = 250 + soft_limit = 200 + +[[http_service.checks]] + grace_period = "5s" + interval = "15s" + method = "GET" + path = "/health" + timeout = "2s" + +[mounts] + source = "og_engine_data" + destination = "/data" + +[[vm]] + size = "shared-cpu-1x" + memory = "512mb" + cpus = 1 diff --git a/fonts/albert-sans/albert-sans-bold.ttf b/fonts/albert-sans/albert-sans-bold.ttf new file mode 100644 index 0000000..b1f1a54 Binary files /dev/null and b/fonts/albert-sans/albert-sans-bold.ttf differ diff --git a/fonts/albert-sans/albert-sans-regular.ttf b/fonts/albert-sans/albert-sans-regular.ttf new file mode 100644 index 0000000..d1e31c7 Binary files /dev/null and b/fonts/albert-sans/albert-sans-regular.ttf differ diff --git a/fonts/anton/anton-regular.ttf b/fonts/anton/anton-regular.ttf new file mode 100644 index 0000000..c5cbbbc Binary files /dev/null and b/fonts/anton/anton-regular.ttf differ diff --git a/fonts/archivo-black/archivo-black-regular.ttf b/fonts/archivo-black/archivo-black-regular.ttf new file mode 100644 index 0000000..37ead57 Binary files /dev/null and b/fonts/archivo-black/archivo-black-regular.ttf differ diff --git a/fonts/bebas-neue/bebas-neue-regular.ttf b/fonts/bebas-neue/bebas-neue-regular.ttf new file mode 100644 index 0000000..85c1805 Binary files /dev/null and b/fonts/bebas-neue/bebas-neue-regular.ttf differ diff --git a/fonts/bitter/bitter-bold.ttf b/fonts/bitter/bitter-bold.ttf new file mode 100644 index 0000000..f979a78 Binary files /dev/null and b/fonts/bitter/bitter-bold.ttf differ diff --git a/fonts/bitter/bitter-regular.ttf b/fonts/bitter/bitter-regular.ttf new file mode 100644 index 0000000..13be74f Binary files /dev/null and b/fonts/bitter/bitter-regular.ttf differ diff --git a/fonts/bricolage-grotesque/bricolage-grotesque-bold.ttf b/fonts/bricolage-grotesque/bricolage-grotesque-bold.ttf new file mode 100644 index 0000000..ea2465b Binary files /dev/null and b/fonts/bricolage-grotesque/bricolage-grotesque-bold.ttf differ diff --git a/fonts/bricolage-grotesque/bricolage-grotesque-extrabold.ttf b/fonts/bricolage-grotesque/bricolage-grotesque-extrabold.ttf new file mode 100644 index 0000000..74950f2 Binary files /dev/null and b/fonts/bricolage-grotesque/bricolage-grotesque-extrabold.ttf differ diff --git a/fonts/bricolage-grotesque/bricolage-grotesque-regular.ttf b/fonts/bricolage-grotesque/bricolage-grotesque-regular.ttf new file mode 100644 index 0000000..46c3918 Binary files /dev/null and b/fonts/bricolage-grotesque/bricolage-grotesque-regular.ttf differ diff --git a/fonts/caveat/caveat-bold.ttf b/fonts/caveat/caveat-bold.ttf new file mode 100644 index 0000000..d3a3f87 Binary files /dev/null and b/fonts/caveat/caveat-bold.ttf differ diff --git a/fonts/caveat/caveat-regular.ttf b/fonts/caveat/caveat-regular.ttf new file mode 100644 index 0000000..1bfe61a Binary files /dev/null and b/fonts/caveat/caveat-regular.ttf differ diff --git a/fonts/cormorant-garamond/cormorant-garamond-bold.ttf b/fonts/cormorant-garamond/cormorant-garamond-bold.ttf new file mode 100644 index 0000000..a3a6e21 Binary files /dev/null and b/fonts/cormorant-garamond/cormorant-garamond-bold.ttf differ diff --git a/fonts/cormorant-garamond/cormorant-garamond-regular.ttf b/fonts/cormorant-garamond/cormorant-garamond-regular.ttf new file mode 100644 index 0000000..3fab5e0 Binary files /dev/null and b/fonts/cormorant-garamond/cormorant-garamond-regular.ttf differ diff --git a/fonts/crimson-pro/crimson-pro-bold.ttf b/fonts/crimson-pro/crimson-pro-bold.ttf new file mode 100644 index 0000000..ddc5779 Binary files /dev/null and b/fonts/crimson-pro/crimson-pro-bold.ttf differ diff --git a/fonts/crimson-pro/crimson-pro-regular.ttf b/fonts/crimson-pro/crimson-pro-regular.ttf new file mode 100644 index 0000000..8d616b3 Binary files /dev/null and b/fonts/crimson-pro/crimson-pro-regular.ttf differ diff --git a/fonts/dm-sans/dm-sans-bold.ttf b/fonts/dm-sans/dm-sans-bold.ttf new file mode 100644 index 0000000..811136c Binary files /dev/null and b/fonts/dm-sans/dm-sans-bold.ttf differ diff --git a/fonts/dm-sans/dm-sans-regular.ttf b/fonts/dm-sans/dm-sans-regular.ttf new file mode 100644 index 0000000..6c789ea Binary files /dev/null and b/fonts/dm-sans/dm-sans-regular.ttf differ diff --git a/fonts/eb-garamond/eb-garamond-bold.ttf b/fonts/eb-garamond/eb-garamond-bold.ttf new file mode 100644 index 0000000..5957625 Binary files /dev/null and b/fonts/eb-garamond/eb-garamond-bold.ttf differ diff --git a/fonts/eb-garamond/eb-garamond-regular.ttf b/fonts/eb-garamond/eb-garamond-regular.ttf new file mode 100644 index 0000000..dd23e9e Binary files /dev/null and b/fonts/eb-garamond/eb-garamond-regular.ttf differ diff --git a/fonts/familjen-grotesk/familjen-grotesk-bold.ttf b/fonts/familjen-grotesk/familjen-grotesk-bold.ttf new file mode 100644 index 0000000..9749beb Binary files /dev/null and b/fonts/familjen-grotesk/familjen-grotesk-bold.ttf differ diff --git a/fonts/familjen-grotesk/familjen-grotesk-regular.ttf b/fonts/familjen-grotesk/familjen-grotesk-regular.ttf new file mode 100644 index 0000000..13d82d5 Binary files /dev/null and b/fonts/familjen-grotesk/familjen-grotesk-regular.ttf differ diff --git a/fonts/figtree/figtree-bold.ttf b/fonts/figtree/figtree-bold.ttf new file mode 100644 index 0000000..00e7fec Binary files /dev/null and b/fonts/figtree/figtree-bold.ttf differ diff --git a/fonts/figtree/figtree-regular.ttf b/fonts/figtree/figtree-regular.ttf new file mode 100644 index 0000000..dc4fa84 Binary files /dev/null and b/fonts/figtree/figtree-regular.ttf differ diff --git a/fonts/fira-code/fira-code-bold.ttf b/fonts/fira-code/fira-code-bold.ttf new file mode 100644 index 0000000..b4e1ed7 Binary files /dev/null and b/fonts/fira-code/fira-code-bold.ttf differ diff --git a/fonts/fira-code/fira-code-regular.ttf b/fonts/fira-code/fira-code-regular.ttf new file mode 100644 index 0000000..8ff1c40 Binary files /dev/null and b/fonts/fira-code/fira-code-regular.ttf differ diff --git a/fonts/fraunces/fraunces-bold.ttf b/fonts/fraunces/fraunces-bold.ttf new file mode 100644 index 0000000..dd7c873 Binary files /dev/null and b/fonts/fraunces/fraunces-bold.ttf differ diff --git a/fonts/fraunces/fraunces-regular.ttf b/fonts/fraunces/fraunces-regular.ttf new file mode 100644 index 0000000..2af0040 Binary files /dev/null and b/fonts/fraunces/fraunces-regular.ttf differ diff --git a/fonts/geist-mono/geist-mono-bold.ttf b/fonts/geist-mono/geist-mono-bold.ttf new file mode 100644 index 0000000..162f8f5 Binary files /dev/null and b/fonts/geist-mono/geist-mono-bold.ttf differ diff --git a/fonts/geist-mono/geist-mono-regular.ttf b/fonts/geist-mono/geist-mono-regular.ttf new file mode 100644 index 0000000..feabd60 Binary files /dev/null and b/fonts/geist-mono/geist-mono-regular.ttf differ diff --git a/fonts/ibm-plex-mono/ibm-plex-mono-bold.ttf b/fonts/ibm-plex-mono/ibm-plex-mono-bold.ttf new file mode 100644 index 0000000..a627d2b Binary files /dev/null and b/fonts/ibm-plex-mono/ibm-plex-mono-bold.ttf differ diff --git a/fonts/ibm-plex-mono/ibm-plex-mono-regular.ttf b/fonts/ibm-plex-mono/ibm-plex-mono-regular.ttf new file mode 100644 index 0000000..02d682c Binary files /dev/null and b/fonts/ibm-plex-mono/ibm-plex-mono-regular.ttf differ diff --git a/fonts/inter/inter-bold.ttf b/fonts/inter/inter-bold.ttf new file mode 100644 index 0000000..9c2f47d Binary files /dev/null and b/fonts/inter/inter-bold.ttf differ diff --git a/fonts/inter/inter-extrabold.ttf b/fonts/inter/inter-extrabold.ttf new file mode 100644 index 0000000..8a9a1bc Binary files /dev/null and b/fonts/inter/inter-extrabold.ttf differ diff --git a/fonts/inter/inter-regular.ttf b/fonts/inter/inter-regular.ttf new file mode 100644 index 0000000..399a6e0 Binary files /dev/null and b/fonts/inter/inter-regular.ttf differ diff --git a/fonts/jetbrains-mono/jetbrains-mono-bold.ttf b/fonts/jetbrains-mono/jetbrains-mono-bold.ttf new file mode 100644 index 0000000..1305efc Binary files /dev/null and b/fonts/jetbrains-mono/jetbrains-mono-bold.ttf differ diff --git a/fonts/jetbrains-mono/jetbrains-mono-regular.ttf b/fonts/jetbrains-mono/jetbrains-mono-regular.ttf new file mode 100644 index 0000000..129e882 Binary files /dev/null and b/fonts/jetbrains-mono/jetbrains-mono-regular.ttf differ diff --git a/fonts/kalam/kalam-bold.ttf b/fonts/kalam/kalam-bold.ttf new file mode 100644 index 0000000..bedadb6 Binary files /dev/null and b/fonts/kalam/kalam-bold.ttf differ diff --git a/fonts/kalam/kalam-regular.ttf b/fonts/kalam/kalam-regular.ttf new file mode 100644 index 0000000..e170f29 Binary files /dev/null and b/fonts/kalam/kalam-regular.ttf differ diff --git a/fonts/karla/karla-bold.ttf b/fonts/karla/karla-bold.ttf new file mode 100644 index 0000000..771eb6a Binary files /dev/null and b/fonts/karla/karla-bold.ttf differ diff --git a/fonts/karla/karla-regular.ttf b/fonts/karla/karla-regular.ttf new file mode 100644 index 0000000..9cc51fe Binary files /dev/null and b/fonts/karla/karla-regular.ttf differ diff --git a/fonts/lato/lato-bold.ttf b/fonts/lato/lato-bold.ttf new file mode 100644 index 0000000..ca5113e Binary files /dev/null and b/fonts/lato/lato-bold.ttf differ diff --git a/fonts/lato/lato-regular.ttf b/fonts/lato/lato-regular.ttf new file mode 100644 index 0000000..ec1ed55 Binary files /dev/null and b/fonts/lato/lato-regular.ttf differ diff --git a/fonts/lora/lora-bold.ttf b/fonts/lora/lora-bold.ttf new file mode 100644 index 0000000..007fdd3 Binary files /dev/null and b/fonts/lora/lora-bold.ttf differ diff --git a/fonts/lora/lora-regular.ttf b/fonts/lora/lora-regular.ttf new file mode 100644 index 0000000..bd2b7cb Binary files /dev/null and b/fonts/lora/lora-regular.ttf differ diff --git a/fonts/manrope/manrope-bold.ttf b/fonts/manrope/manrope-bold.ttf new file mode 100644 index 0000000..746764d Binary files /dev/null and b/fonts/manrope/manrope-bold.ttf differ diff --git a/fonts/manrope/manrope-extrabold.ttf b/fonts/manrope/manrope-extrabold.ttf new file mode 100644 index 0000000..d324b62 Binary files /dev/null and b/fonts/manrope/manrope-extrabold.ttf differ diff --git a/fonts/manrope/manrope-regular.ttf b/fonts/manrope/manrope-regular.ttf new file mode 100644 index 0000000..8594569 Binary files /dev/null and b/fonts/manrope/manrope-regular.ttf differ diff --git a/fonts/merriweather/merriweather-bold.ttf b/fonts/merriweather/merriweather-bold.ttf new file mode 100644 index 0000000..348d5ed Binary files /dev/null and b/fonts/merriweather/merriweather-bold.ttf differ diff --git a/fonts/merriweather/merriweather-regular.ttf b/fonts/merriweather/merriweather-regular.ttf new file mode 100644 index 0000000..45b2f53 Binary files /dev/null and b/fonts/merriweather/merriweather-regular.ttf differ diff --git a/fonts/montserrat/montserrat-bold.ttf b/fonts/montserrat/montserrat-bold.ttf new file mode 100644 index 0000000..7aac846 Binary files /dev/null and b/fonts/montserrat/montserrat-bold.ttf differ diff --git a/fonts/montserrat/montserrat-extrabold.ttf b/fonts/montserrat/montserrat-extrabold.ttf new file mode 100644 index 0000000..313eb4f Binary files /dev/null and b/fonts/montserrat/montserrat-extrabold.ttf differ diff --git a/fonts/montserrat/montserrat-regular.ttf b/fonts/montserrat/montserrat-regular.ttf new file mode 100644 index 0000000..e06c427 Binary files /dev/null and b/fonts/montserrat/montserrat-regular.ttf differ diff --git a/fonts/mulish/mulish-bold.ttf b/fonts/mulish/mulish-bold.ttf new file mode 100644 index 0000000..2f38601 Binary files /dev/null and b/fonts/mulish/mulish-bold.ttf differ diff --git a/fonts/mulish/mulish-regular.ttf b/fonts/mulish/mulish-regular.ttf new file mode 100644 index 0000000..2998d54 Binary files /dev/null and b/fonts/mulish/mulish-regular.ttf differ diff --git a/fonts/noto-sans-arabic/noto-sans-arabic-bold.ttf b/fonts/noto-sans-arabic/noto-sans-arabic-bold.ttf new file mode 100644 index 0000000..f772151 Binary files /dev/null and b/fonts/noto-sans-arabic/noto-sans-arabic-bold.ttf differ diff --git a/fonts/noto-sans-arabic/noto-sans-arabic-regular.ttf b/fonts/noto-sans-arabic/noto-sans-arabic-regular.ttf new file mode 100644 index 0000000..a49d6de Binary files /dev/null and b/fonts/noto-sans-arabic/noto-sans-arabic-regular.ttf differ diff --git a/fonts/noto-sans-jp/noto-sans-jp-bold.ttf b/fonts/noto-sans-jp/noto-sans-jp-bold.ttf new file mode 100644 index 0000000..4b52b19 Binary files /dev/null and b/fonts/noto-sans-jp/noto-sans-jp-bold.ttf differ diff --git a/fonts/noto-sans-jp/noto-sans-jp-regular.ttf b/fonts/noto-sans-jp/noto-sans-jp-regular.ttf new file mode 100644 index 0000000..bb2e137 Binary files /dev/null and b/fonts/noto-sans-jp/noto-sans-jp-regular.ttf differ diff --git a/fonts/noto-sans-kr/noto-sans-kr-bold.ttf b/fonts/noto-sans-kr/noto-sans-kr-bold.ttf new file mode 100644 index 0000000..549f5cd Binary files /dev/null and b/fonts/noto-sans-kr/noto-sans-kr-bold.ttf differ diff --git a/fonts/noto-sans-kr/noto-sans-kr-regular.ttf b/fonts/noto-sans-kr/noto-sans-kr-regular.ttf new file mode 100644 index 0000000..6dcd4e2 Binary files /dev/null and b/fonts/noto-sans-kr/noto-sans-kr-regular.ttf differ diff --git a/fonts/noto-sans-sc/noto-sans-sc-bold.ttf b/fonts/noto-sans-sc/noto-sans-sc-bold.ttf new file mode 100644 index 0000000..1decf2b Binary files /dev/null and b/fonts/noto-sans-sc/noto-sans-sc-bold.ttf differ diff --git a/fonts/noto-sans-sc/noto-sans-sc-regular.ttf b/fonts/noto-sans-sc/noto-sans-sc-regular.ttf new file mode 100644 index 0000000..9271486 Binary files /dev/null and b/fonts/noto-sans-sc/noto-sans-sc-regular.ttf differ diff --git a/fonts/nunito-sans/nunito-sans-bold.ttf b/fonts/nunito-sans/nunito-sans-bold.ttf new file mode 100644 index 0000000..62eda94 Binary files /dev/null and b/fonts/nunito-sans/nunito-sans-bold.ttf differ diff --git a/fonts/nunito-sans/nunito-sans-extrabold.ttf b/fonts/nunito-sans/nunito-sans-extrabold.ttf new file mode 100644 index 0000000..9dc6d02 Binary files /dev/null and b/fonts/nunito-sans/nunito-sans-extrabold.ttf differ diff --git a/fonts/nunito-sans/nunito-sans-regular.ttf b/fonts/nunito-sans/nunito-sans-regular.ttf new file mode 100644 index 0000000..3c8b0c4 Binary files /dev/null and b/fonts/nunito-sans/nunito-sans-regular.ttf differ diff --git a/fonts/nunito/nunito-bold.ttf b/fonts/nunito/nunito-bold.ttf new file mode 100644 index 0000000..063f39a Binary files /dev/null and b/fonts/nunito/nunito-bold.ttf differ diff --git a/fonts/nunito/nunito-extrabold.ttf b/fonts/nunito/nunito-extrabold.ttf new file mode 100644 index 0000000..8c4ffa4 Binary files /dev/null and b/fonts/nunito/nunito-extrabold.ttf differ diff --git a/fonts/nunito/nunito-regular.ttf b/fonts/nunito/nunito-regular.ttf new file mode 100644 index 0000000..6401d72 Binary files /dev/null and b/fonts/nunito/nunito-regular.ttf differ diff --git a/fonts/onest/onest-bold.ttf b/fonts/onest/onest-bold.ttf new file mode 100644 index 0000000..a2048e7 Binary files /dev/null and b/fonts/onest/onest-bold.ttf differ diff --git a/fonts/onest/onest-regular.ttf b/fonts/onest/onest-regular.ttf new file mode 100644 index 0000000..ce57509 Binary files /dev/null and b/fonts/onest/onest-regular.ttf differ diff --git a/fonts/open-sans/open-sans-bold.ttf b/fonts/open-sans/open-sans-bold.ttf new file mode 100644 index 0000000..6d95461 Binary files /dev/null and b/fonts/open-sans/open-sans-bold.ttf differ diff --git a/fonts/open-sans/open-sans-regular.ttf b/fonts/open-sans/open-sans-regular.ttf new file mode 100644 index 0000000..4afe506 Binary files /dev/null and b/fonts/open-sans/open-sans-regular.ttf differ diff --git a/fonts/oswald/oswald-bold.ttf b/fonts/oswald/oswald-bold.ttf new file mode 100644 index 0000000..86812a0 Binary files /dev/null and b/fonts/oswald/oswald-bold.ttf differ diff --git a/fonts/oswald/oswald-regular.ttf b/fonts/oswald/oswald-regular.ttf new file mode 100644 index 0000000..1b5cb18 Binary files /dev/null and b/fonts/oswald/oswald-regular.ttf differ diff --git a/fonts/outfit/outfit-bold.ttf b/fonts/outfit/outfit-bold.ttf new file mode 100644 index 0000000..0389ff2 Binary files /dev/null and b/fonts/outfit/outfit-bold.ttf differ diff --git a/fonts/outfit/outfit-extrabold.ttf b/fonts/outfit/outfit-extrabold.ttf new file mode 100644 index 0000000..6f0672b Binary files /dev/null and b/fonts/outfit/outfit-extrabold.ttf differ diff --git a/fonts/outfit/outfit-regular.ttf b/fonts/outfit/outfit-regular.ttf new file mode 100644 index 0000000..0b0a0d7 Binary files /dev/null and b/fonts/outfit/outfit-regular.ttf differ diff --git a/fonts/pacifico/pacifico-regular.ttf b/fonts/pacifico/pacifico-regular.ttf new file mode 100644 index 0000000..e47403a Binary files /dev/null and b/fonts/pacifico/pacifico-regular.ttf differ diff --git a/fonts/playfair-display/playfair-display-bold.ttf b/fonts/playfair-display/playfair-display-bold.ttf new file mode 100644 index 0000000..0af6372 Binary files /dev/null and b/fonts/playfair-display/playfair-display-bold.ttf differ diff --git a/fonts/playfair-display/playfair-display-extrabold.ttf b/fonts/playfair-display/playfair-display-extrabold.ttf new file mode 100644 index 0000000..6975ac7 Binary files /dev/null and b/fonts/playfair-display/playfair-display-extrabold.ttf differ diff --git a/fonts/playfair-display/playfair-display-regular.ttf b/fonts/playfair-display/playfair-display-regular.ttf new file mode 100644 index 0000000..4ee80fd Binary files /dev/null and b/fonts/playfair-display/playfair-display-regular.ttf differ diff --git a/fonts/plus-jakarta-sans/plus-jakarta-sans-bold.ttf b/fonts/plus-jakarta-sans/plus-jakarta-sans-bold.ttf new file mode 100644 index 0000000..91ab258 Binary files /dev/null and b/fonts/plus-jakarta-sans/plus-jakarta-sans-bold.ttf differ diff --git a/fonts/plus-jakarta-sans/plus-jakarta-sans-extrabold.ttf b/fonts/plus-jakarta-sans/plus-jakarta-sans-extrabold.ttf new file mode 100644 index 0000000..313780c Binary files /dev/null and b/fonts/plus-jakarta-sans/plus-jakarta-sans-extrabold.ttf differ diff --git a/fonts/plus-jakarta-sans/plus-jakarta-sans-regular.ttf b/fonts/plus-jakarta-sans/plus-jakarta-sans-regular.ttf new file mode 100644 index 0000000..b655eb4 Binary files /dev/null and b/fonts/plus-jakarta-sans/plus-jakarta-sans-regular.ttf differ diff --git a/fonts/poppins/poppins-bold.ttf b/fonts/poppins/poppins-bold.ttf new file mode 100644 index 0000000..89b46e7 Binary files /dev/null and b/fonts/poppins/poppins-bold.ttf differ diff --git a/fonts/poppins/poppins-extrabold.ttf b/fonts/poppins/poppins-extrabold.ttf new file mode 100644 index 0000000..320070d Binary files /dev/null and b/fonts/poppins/poppins-extrabold.ttf differ diff --git a/fonts/poppins/poppins-regular.ttf b/fonts/poppins/poppins-regular.ttf new file mode 100644 index 0000000..e48144e Binary files /dev/null and b/fonts/poppins/poppins-regular.ttf differ diff --git a/fonts/pt-serif/pt-serif-bold.ttf b/fonts/pt-serif/pt-serif-bold.ttf new file mode 100644 index 0000000..506ff25 Binary files /dev/null and b/fonts/pt-serif/pt-serif-bold.ttf differ diff --git a/fonts/pt-serif/pt-serif-regular.ttf b/fonts/pt-serif/pt-serif-regular.ttf new file mode 100644 index 0000000..4984a65 Binary files /dev/null and b/fonts/pt-serif/pt-serif-regular.ttf differ diff --git a/fonts/roboto/roboto-bold.ttf b/fonts/roboto/roboto-bold.ttf new file mode 100644 index 0000000..db17c0a Binary files /dev/null and b/fonts/roboto/roboto-bold.ttf differ diff --git a/fonts/roboto/roboto-regular.ttf b/fonts/roboto/roboto-regular.ttf new file mode 100644 index 0000000..60b906e Binary files /dev/null and b/fonts/roboto/roboto-regular.ttf differ diff --git a/fonts/rubik/rubik-bold.ttf b/fonts/rubik/rubik-bold.ttf new file mode 100644 index 0000000..d4e4c99 Binary files /dev/null and b/fonts/rubik/rubik-bold.ttf differ diff --git a/fonts/rubik/rubik-regular.ttf b/fonts/rubik/rubik-regular.ttf new file mode 100644 index 0000000..95b8485 Binary files /dev/null and b/fonts/rubik/rubik-regular.ttf differ diff --git a/fonts/sora/sora-bold.ttf b/fonts/sora/sora-bold.ttf new file mode 100644 index 0000000..ef4a017 Binary files /dev/null and b/fonts/sora/sora-bold.ttf differ diff --git a/fonts/sora/sora-extrabold.ttf b/fonts/sora/sora-extrabold.ttf new file mode 100644 index 0000000..d9c724c Binary files /dev/null and b/fonts/sora/sora-extrabold.ttf differ diff --git a/fonts/sora/sora-regular.ttf b/fonts/sora/sora-regular.ttf new file mode 100644 index 0000000..cc0103a Binary files /dev/null and b/fonts/sora/sora-regular.ttf differ diff --git a/fonts/source-sans-3/source-sans-3-bold.ttf b/fonts/source-sans-3/source-sans-3-bold.ttf new file mode 100644 index 0000000..bcbea46 Binary files /dev/null and b/fonts/source-sans-3/source-sans-3-bold.ttf differ diff --git a/fonts/source-sans-3/source-sans-3-regular.ttf b/fonts/source-sans-3/source-sans-3-regular.ttf new file mode 100644 index 0000000..8841373 Binary files /dev/null and b/fonts/source-sans-3/source-sans-3-regular.ttf differ diff --git a/fonts/source-serif-4/source-serif-4-bold.ttf b/fonts/source-serif-4/source-serif-4-bold.ttf new file mode 100644 index 0000000..016f516 Binary files /dev/null and b/fonts/source-serif-4/source-serif-4-bold.ttf differ diff --git a/fonts/source-serif-4/source-serif-4-regular.ttf b/fonts/source-serif-4/source-serif-4-regular.ttf new file mode 100644 index 0000000..010c5eb Binary files /dev/null and b/fonts/source-serif-4/source-serif-4-regular.ttf differ diff --git a/fonts/space-grotesk/space-grotesk-bold.ttf b/fonts/space-grotesk/space-grotesk-bold.ttf new file mode 100644 index 0000000..f4f8002 Binary files /dev/null and b/fonts/space-grotesk/space-grotesk-bold.ttf differ diff --git a/fonts/space-grotesk/space-grotesk-regular.ttf b/fonts/space-grotesk/space-grotesk-regular.ttf new file mode 100644 index 0000000..576f9b5 Binary files /dev/null and b/fonts/space-grotesk/space-grotesk-regular.ttf differ diff --git a/fonts/space-mono/space-mono-bold.ttf b/fonts/space-mono/space-mono-bold.ttf new file mode 100644 index 0000000..51cd703 Binary files /dev/null and b/fonts/space-mono/space-mono-bold.ttf differ diff --git a/fonts/space-mono/space-mono-regular.ttf b/fonts/space-mono/space-mono-regular.ttf new file mode 100644 index 0000000..edc2425 Binary files /dev/null and b/fonts/space-mono/space-mono-regular.ttf differ diff --git a/fonts/spectral/spectral-bold.ttf b/fonts/spectral/spectral-bold.ttf new file mode 100644 index 0000000..6f5d5fc Binary files /dev/null and b/fonts/spectral/spectral-bold.ttf differ diff --git a/fonts/spectral/spectral-regular.ttf b/fonts/spectral/spectral-regular.ttf new file mode 100644 index 0000000..3dc172a Binary files /dev/null and b/fonts/spectral/spectral-regular.ttf differ diff --git a/fonts/syne/syne-bold.ttf b/fonts/syne/syne-bold.ttf new file mode 100644 index 0000000..6d9d19c Binary files /dev/null and b/fonts/syne/syne-bold.ttf differ diff --git a/fonts/syne/syne-extrabold.ttf b/fonts/syne/syne-extrabold.ttf new file mode 100644 index 0000000..67a1f05 Binary files /dev/null and b/fonts/syne/syne-extrabold.ttf differ diff --git a/fonts/syne/syne-regular.ttf b/fonts/syne/syne-regular.ttf new file mode 100644 index 0000000..7917c91 Binary files /dev/null and b/fonts/syne/syne-regular.ttf differ diff --git a/fonts/unbounded/unbounded-bold.ttf b/fonts/unbounded/unbounded-bold.ttf new file mode 100644 index 0000000..d4bb398 Binary files /dev/null and b/fonts/unbounded/unbounded-bold.ttf differ diff --git a/fonts/unbounded/unbounded-regular.ttf b/fonts/unbounded/unbounded-regular.ttf new file mode 100644 index 0000000..1d45afe Binary files /dev/null and b/fonts/unbounded/unbounded-regular.ttf differ diff --git a/fonts/work-sans/work-sans-bold.ttf b/fonts/work-sans/work-sans-bold.ttf new file mode 100644 index 0000000..cc5eb0f Binary files /dev/null and b/fonts/work-sans/work-sans-bold.ttf differ diff --git a/fonts/work-sans/work-sans-regular.ttf b/fonts/work-sans/work-sans-regular.ttf new file mode 100644 index 0000000..14d4c8e Binary files /dev/null and b/fonts/work-sans/work-sans-regular.ttf differ diff --git a/lefthook.yml b/lefthook.yml new file mode 100644 index 0000000..5ac5936 --- /dev/null +++ b/lefthook.yml @@ -0,0 +1,7 @@ +pre-commit: + parallel: true + jobs: + - name: lint + run: bun run lint + - name: type-check + run: bun run type-check diff --git a/package.json b/package.json new file mode 100644 index 0000000..da1eb1e --- /dev/null +++ b/package.json @@ -0,0 +1,40 @@ +{ + "name": "og-engine", + "version": "0.1.0", + "type": "module", + "license": "SEE LICENSE IN LICENSE", + "scripts": { + "dev": "bun run --hot src/index.ts", + "start": "bun run src/index.ts", + "test": "vitest run", + "test:watch": "vitest", + "lint": "biome check src/ tests/", + "lint:fix": "biome check --write src/ tests/", + "format": "biome format --write src/ tests/", + "format:check": "biome format src/ tests/", + "type-check": "tsc --noEmit", + "bench": "bun run benchmarks/run.ts", + "fonts:download": "bun run scripts/download-fonts.ts", + "fonts:refresh-catalog": "bun run scripts/refresh-google-fonts.ts", + "bench:full": "bun run benchmarks/run-full.ts", + "prepare": "lefthook install" + }, + "dependencies": { + "@napi-rs/canvas": "^0.1.97", + "better-sqlite3": "^12.8.0", + "cheerio": "^1.2.0", + "hono": "^4.12.10", + "resend": "^6.10.0", + "stripe": "^22.0.0", + "zod": "^4.3.6" + }, + "devDependencies": { + "@biomejs/biome": "^2.4.10", + "@types/better-sqlite3": "^7.6.13", + "@types/bun": "^1.3.11", + "lefthook": "^2.1.4", + "puppeteer": "^24.40.0", + "typescript": "^6.0.2", + "vitest": "^4.1.2" + } +} diff --git a/renovate.json b/renovate.json new file mode 100644 index 0000000..5db72dd --- /dev/null +++ b/renovate.json @@ -0,0 +1,6 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": [ + "config:recommended" + ] +} diff --git a/scripts/download-fonts.ts b/scripts/download-fonts.ts new file mode 100644 index 0000000..bc7f211 --- /dev/null +++ b/scripts/download-fonts.ts @@ -0,0 +1,103 @@ +/** + * Download all CURATED_FONTS as TTF files into the repo-root fonts/ dir. + * + * The API server (src/engine/fonts.ts) registers .ttf files with + * @napi-rs/canvas. WOFF2 is not supported by the canvas backend. + * + * Trick: Google Fonts CSS API returns TTF URLs when the request comes + * from an old User-Agent that doesn't advertise WOFF2 support. We send + * a Safari 5 UA to trigger this. + * + * Run via: `bun run fonts:download` from the repo root. + * Idempotent — files that already exist are skipped. + */ + +import { mkdir, writeFile, stat } from 'node:fs/promises'; +import { join } from 'node:path'; +import { CURATED_FONTS } from '../src/engine/font-catalog'; + +const FONTS_DIR = join(import.meta.dir, '..', 'fonts'); + +const LEGACY_UA = + 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/534.54.16 (KHTML, like Gecko) Version/5.1.4 Safari/534.54.16'; + +const WEIGHT_NAMES: Record = { + 300: 'Light', + 400: 'Regular', + 500: 'Medium', + 600: 'SemiBold', + 700: 'Bold', + 800: 'ExtraBold', + 900: 'Black', +}; + +type Result = 'downloaded' | 'cached' | 'failed'; + +async function downloadFont(family: string, slug: string, weight: number): Promise { + const dir = join(FONTS_DIR, slug); + await mkdir(dir, { recursive: true }); + + const weightName = WEIGHT_NAMES[weight] ?? String(weight); + const filename = `${slug}-${weightName.toLowerCase()}.ttf`; + const filepath = join(dir, filename); + + try { + await stat(filepath); + return 'cached'; + } catch { + // doesn't exist + } + + const cssUrl = `https://fonts.googleapis.com/css2?family=${encodeURIComponent(family)}:wght@${weight}&display=swap`; + const cssRes = await fetch(cssUrl, { headers: { 'User-Agent': LEGACY_UA } }); + + if (!cssRes.ok) { + console.error(` ✗ ${family} ${weightName}: CSS fetch failed (${cssRes.status})`); + return 'failed'; + } + + const css = await cssRes.text(); + const urlMatch = css.match(/url\(([^)]+\.ttf)\)/); + if (!urlMatch) { + console.error(` ✗ ${family} ${weightName}: No TTF URL found in CSS`); + return 'failed'; + } + + const fontRes = await fetch(urlMatch[1]); + if (!fontRes.ok) { + console.error(` ✗ ${family} ${weightName}: Download failed (${fontRes.status})`); + return 'failed'; + } + + const buffer = await fontRes.arrayBuffer(); + await writeFile(filepath, Buffer.from(buffer)); + console.log(` ↓ ${family} ${weightName} (${(buffer.byteLength / 1024).toFixed(0)} KB)`); + return 'downloaded'; +} + +async function main() { + console.log(`Downloading ${CURATED_FONTS.length} curated fonts into ${FONTS_DIR}\n`); + + let downloaded = 0; + let cached = 0; + let failed = 0; + + for (const entry of CURATED_FONTS) { + console.log(entry.name); + for (const weight of entry.weights) { + const result = await downloadFont(entry.family, entry.slug, weight); + if (result === 'downloaded') downloaded++; + else if (result === 'cached') cached++; + else failed++; + } + console.log(''); + } + + console.log(`Done. ${downloaded} downloaded, ${cached} already present, ${failed} failed.`); + if (failed > 0) process.exit(1); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/refresh-google-fonts.ts b/scripts/refresh-google-fonts.ts new file mode 100644 index 0000000..283da56 --- /dev/null +++ b/scripts/refresh-google-fonts.ts @@ -0,0 +1,76 @@ +/** + * Refresh the static Google Fonts catalog dump. + * + * Fetches the full font list from gwfh.mranftl.com (the public Google + * Webfonts Helper mirror — no API key needed) and writes a trimmed JSON + * file to: + * + * src/data/google-fonts.json (canonical copy) + * docs/site/public/google-fonts.json (Astro static asset for the playground) + * + * Run manually: `bun run fonts:refresh-catalog` + * Refresh frequency: quarterly is fine; the catalog changes slowly. + */ + +import { mkdir, writeFile } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; + +const SOURCE_URL = 'https://gwfh.mranftl.com/api/fonts'; +const REPO_ROOT = join(import.meta.dir, '..'); +const CANONICAL_OUT = join(REPO_ROOT, 'src', 'data', 'google-fonts.json'); +const PUBLIC_OUT = join(REPO_ROOT, 'docs', 'site', 'public', 'google-fonts.json'); + +interface RawFont { + id: string; + family: string; + category: string; + subsets: string[]; + variants: string[]; + popularity: number; +} + +interface TrimmedFont { + family: string; + category: string; + subsets: string[]; + variants: string[]; + popularity: number; +} + +async function main() { + console.log(`Fetching Google Fonts catalog from ${SOURCE_URL}...`); + const res = await fetch(SOURCE_URL); + if (!res.ok) { + console.error(`Fetch failed: ${res.status} ${res.statusText}`); + process.exit(1); + } + + const raw = (await res.json()) as RawFont[]; + console.log(`Received ${raw.length} fonts.`); + + // Trim to the fields we actually use, sort by popularity (lower number = more popular) + const trimmed: TrimmedFont[] = raw + .map((f) => ({ + family: f.family, + category: f.category, + subsets: f.subsets, + variants: f.variants, + popularity: f.popularity, + })) + .sort((a, b) => a.popularity - b.popularity); + + const json = JSON.stringify(trimmed, null, 0); + + for (const out of [CANONICAL_OUT, PUBLIC_OUT]) { + await mkdir(dirname(out), { recursive: true }); + await writeFile(out, json); + console.log(` ✓ wrote ${out} (${(json.length / 1024).toFixed(0)} KB)`); + } + + console.log('Done.'); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/sdk/LICENSE b/sdk/LICENSE new file mode 100644 index 0000000..c5ca974 --- /dev/null +++ b/sdk/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2026 Atypical Consulting SRL + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/sdk/bun.lock b/sdk/bun.lock new file mode 100644 index 0000000..9be9105 --- /dev/null +++ b/sdk/bun.lock @@ -0,0 +1,20 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "og-engine-sdk", + "devDependencies": { + "@types/node": "^25.5.2", + "typescript": "^6.0.2", + }, + }, + }, + "packages": { + "@types/node": ["@types/node@25.5.2", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg=="], + + "typescript": ["typescript@6.0.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ=="], + + "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], + } +} diff --git a/sdk/index.ts b/sdk/index.ts new file mode 100644 index 0000000..2e72e62 --- /dev/null +++ b/sdk/index.ts @@ -0,0 +1,377 @@ +/** + * OG Engine TypeScript SDK + * + * Lightweight client for the OG Engine API. + * Zero dependencies — works in Node.js, Bun, Deno, and browsers. + * + * @example + * ```ts + * import { OGEngine } from '@atypical-consulting/og-engine-sdk'; + * + * const og = new OGEngine('oge_sk_...'); + * const image = await og.render({ format: 'og', title: 'Hello World' }); + * ``` + */ + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export type ImageFormat = 'og' | 'twitter' | 'square' | 'linkedin' | 'story'; +export type TemplateName = 'default' | 'social-card' | 'blog-hero' | 'email-banner'; +export type OutputFormat = 'png' | 'webp' | 'pdf'; +export type Layout = 'left' | 'center' | 'bottom'; + +export interface OGEngineOptions { + /** Override for self-hosted instances (default: https://og-engine.com) */ + baseUrl?: string; + /** Request timeout in ms (default: 10000) */ + timeout?: number; + /** Retry count for 5xx errors (default: 3) */ + retries?: number; +} + +export interface RenderStyle { + accent?: string; + layout?: Layout; + font?: string; + titleSize?: number; + descSize?: number; + gradient?: string; + overlayOpacity?: number; + autoFit?: boolean; +} + +export interface RenderRequest { + format: ImageFormat; + title: string; + template?: TemplateName | `custom:${string}`; + description?: string; + author?: string; + tag?: string; + /** Arbitrary key-value pairs for template interpolation. */ + variables?: Record; + /** Named image URLs to fetch server-side. */ + images?: Record; + style?: RenderStyle; + output?: { + format?: OutputFormat; + quality?: number; + }; + backgroundImage?: Buffer | Uint8Array; +} + +export interface RenderFromUrlRequest { + url: string; + format?: ImageFormat; + template?: TemplateName | `custom:${string}`; + style?: RenderStyle; + output?: { + format?: OutputFormat; + quality?: number; + }; +} + +export interface RenderMeta { + renderTimeMs: number; + titleLines: number; + descLines: number; + layoutOverflow: boolean; + cached: boolean; +} + +export interface ValidateRequest { + format: ImageFormat; + title: string; + description?: string; + font?: string; + titleSize?: number; + descSize?: number; + maxTitleLines?: number; + maxDescLines?: number; + autoFit?: boolean; +} + +export interface ValidateResult { + fits: boolean; + title: { lines: number; maxLines: number; overflow: boolean }; + description: { lines: number; maxLines: number; overflow: boolean }; + computeTimeMs: number; +} + +export interface HealthResult { + status: string; + version: string; + fonts: string[]; + formats: string[]; + templates: string[]; +} + +export interface UsageResult { + plan: 'free' | 'starter' | 'pro' | 'scale'; + limit: number; + used: number; + remaining: number; + resetAt: string; +} + +// --------------------------------------------------------------------------- +// Errors +// --------------------------------------------------------------------------- + +export class OGEngineConfigError extends Error { + constructor(message: string) { + super(message); + this.name = 'OGEngineConfigError'; + } +} + +export class OGEngineError extends Error { + code: string; + status: number; + details: Record | null; + + constructor(status: number, body: { error?: string; code?: string; message: string; details?: Record }) { + super(body.message); + this.name = 'OGEngineError'; + this.code = body.code ?? body.error ?? 'unknown'; + this.status = status; + this.details = body.details ?? null; + } +} + +// --------------------------------------------------------------------------- +// Buffer with metadata +// --------------------------------------------------------------------------- + +type BufferWithMeta = Buffer & { meta: RenderMeta }; + +function attachMeta(buf: Buffer, meta: RenderMeta): BufferWithMeta { + const out = buf as BufferWithMeta; + out.meta = meta; + return out; +} + +// --------------------------------------------------------------------------- +// Client +// --------------------------------------------------------------------------- + +const DEFAULT_BASE_URL = 'https://og-engine.com'; +const DEFAULT_TIMEOUT = 10_000; +const DEFAULT_RETRIES = 3; +const RETRY_BASE_MS = 200; + +export class OGEngine { + private apiKey: string; + private baseUrl: string; + private timeout: number; + private retries: number; + + constructor(apiKey: string, options?: OGEngineOptions) { + if (!apiKey) { + throw new OGEngineConfigError( + 'Missing API key. Pass your key as the first argument: new OGEngine("oge_sk_...")', + ); + } + this.apiKey = apiKey; + this.baseUrl = (options?.baseUrl ?? DEFAULT_BASE_URL).replace(/\/$/, ''); + this.timeout = options?.timeout ?? DEFAULT_TIMEOUT; + this.retries = options?.retries ?? DEFAULT_RETRIES; + } + + // ------------------------------------------------------------------------- + // Internal helpers + // ------------------------------------------------------------------------- + + private headers(): Record { + return { + Authorization: `Bearer ${this.apiKey}`, + 'Content-Type': 'application/json', + }; + } + + private async fetchWithRetry(url: string, init: RequestInit): Promise { + let lastError: Error | undefined; + + for (let attempt = 0; attempt <= this.retries; attempt++) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), this.timeout); + + try { + const res = await fetch(url, { ...init, signal: controller.signal }); + + // Don't retry client errors (4xx) + if (res.ok || (res.status >= 400 && res.status < 500)) { + return res; + } + + // 5xx — retry with exponential backoff + lastError = new Error(`HTTP ${res.status}`); + if (attempt < this.retries) { + await sleep(RETRY_BASE_MS * 2 ** attempt); + } + } catch (err) { + lastError = err instanceof Error ? err : new Error(String(err)); + if (attempt < this.retries) { + await sleep(RETRY_BASE_MS * 2 ** attempt); + } + } finally { + clearTimeout(timer); + } + } + + throw lastError ?? new Error('Request failed'); + } + + private async requestJSON(method: string, path: string, body?: unknown): Promise { + const res = await this.fetchWithRetry(`${this.baseUrl}${path}`, { + method, + headers: this.headers(), + body: body ? JSON.stringify(body) : undefined, + }); + + if (!res.ok) { + const err = (await res.json().catch(() => ({ + message: `HTTP ${res.status}`, + }))) as Record; + throw new OGEngineError(res.status, err as { message: string }); + } + + return res.json() as Promise; + } + + // ------------------------------------------------------------------------- + // Public API + // ------------------------------------------------------------------------- + + /** + * Generate an image from text + configuration. + * Returns a `Buffer` with a `.meta` property containing render diagnostics. + */ + async render(request: RenderRequest): Promise { + const res = await this.fetchWithRetry(`${this.baseUrl}/render`, { + method: 'POST', + headers: this.headers(), + body: JSON.stringify(request), + }); + + if (!res.ok) { + const err = (await res.json().catch(() => ({ + message: `HTTP ${res.status}`, + }))) as Record; + throw new OGEngineError(res.status, err as { message: string }); + } + + const arrayBuf = await res.arrayBuffer(); + const buf = Buffer.from(arrayBuf); + + return attachMeta(buf, { + renderTimeMs: Number.parseFloat(res.headers.get('X-Render-Time-Ms') ?? '0'), + titleLines: Number(res.headers.get('X-Title-Lines') ?? 0), + descLines: Number(res.headers.get('X-Desc-Lines') ?? 0), + layoutOverflow: res.headers.get('X-Layout-Overflow') === 'true', + cached: res.headers.get('X-Cache') === 'hit', + }); + } + + /** + * Render and save to file (Node.js/Bun only). + */ + async renderToFile(request: RenderRequest, filePath: string): Promise { + const result = await this.render(request); + const { writeFile } = await import('node:fs/promises'); + await writeFile(filePath, result); + return result; + } + + /** + * Fetch a URL, extract OG meta tags, and render an image. + * The server extracts title, description, author, and og:image automatically. + */ + async renderFromUrl(request: RenderFromUrlRequest): Promise { + const res = await this.fetchWithRetry(`${this.baseUrl}/render/from-url`, { + method: 'POST', + headers: this.headers(), + body: JSON.stringify(request), + }); + + if (!res.ok) { + const err = (await res.json().catch(() => ({ + message: `HTTP ${res.status}`, + }))) as Record; + throw new OGEngineError(res.status, err as { message: string }); + } + + const arrayBuf = await res.arrayBuffer(); + const buf = Buffer.from(arrayBuf); + + return attachMeta(buf, { + renderTimeMs: Number.parseFloat(res.headers.get('X-Render-Time-Ms') ?? '0'), + titleLines: Number(res.headers.get('X-Title-Lines') ?? 0), + descLines: Number(res.headers.get('X-Desc-Lines') ?? 0), + layoutOverflow: res.headers.get('X-Layout-Overflow') === 'true', + cached: res.headers.get('X-Cache') === 'hit', + }); + } + + /** + * Check if text fits a given layout without generating an image. + */ + async validate(request: ValidateRequest): Promise { + return this.requestJSON('POST', '/validate', request); + } + + /** + * Render multiple images in a single API call. + * Returns a `Buffer` containing a ZIP archive. + */ + async batch(items: RenderRequest[]): Promise { + const res = await this.fetchWithRetry(`${this.baseUrl}/render/batch`, { + method: 'POST', + headers: this.headers(), + body: JSON.stringify({ items }), + }); + + if (!res.ok) { + const err = (await res.json().catch(() => ({ + message: `HTTP ${res.status}`, + }))) as Record; + throw new OGEngineError(res.status, err as { message: string }); + } + + return Buffer.from(await res.arrayBuffer()); + } + + /** + * Check service status and available resources. + */ + async health(): Promise { + const res = await this.fetchWithRetry(`${this.baseUrl}/health`, { + method: 'GET', + headers: { 'Content-Type': 'application/json' }, + }); + + if (!res.ok) { + throw new OGEngineError(res.status, { message: `HTTP ${res.status}` }); + } + + return res.json() as Promise; + } + + /** + * Get current usage statistics for the authenticated user. + */ + async usage(): Promise { + return this.requestJSON('GET', '/usage'); + } +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export default OGEngine; diff --git a/sdk/package.json b/sdk/package.json new file mode 100644 index 0000000..4c42977 --- /dev/null +++ b/sdk/package.json @@ -0,0 +1,41 @@ +{ + "name": "@atypical-consulting/og-engine-sdk", + "version": "0.2.0", + "description": "TypeScript SDK for the OG Engine API — generate OG images, social cards, and banners without a browser", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "bun build ./index.ts --outdir ./dist --target node && bun run build:types", + "build:types": "tsc -p tsconfig.json --emitDeclarationOnly" + }, + "keywords": [ + "og-image", + "social-card", + "image-generation", + "opengraph", + "og-engine" + ], + "license": "Apache-2.0", + "publishConfig": { + "registry": "https://registry.npmjs.org", + "access": "public" + }, + "repository": { + "type": "git", + "url": "https://github.com/atypical-consulting/og-engine" + }, + "devDependencies": { + "@types/node": "^25.5.2", + "typescript": "^6.0.2" + } +} diff --git a/sdk/tsconfig.json b/sdk/tsconfig.json new file mode 100644 index 0000000..a33c26c --- /dev/null +++ b/sdk/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "outDir": "dist", + "rootDir": ".", + "declaration": true, + "declarationDir": "dist", + "types": ["node"] + }, + "include": ["index.ts"] +} diff --git a/src/api/admin.ts b/src/api/admin.ts new file mode 100644 index 0000000..9dac994 --- /dev/null +++ b/src/api/admin.ts @@ -0,0 +1,23 @@ +import { Hono } from 'hono'; +import { resetFreeQuotas } from '../db'; + +export const adminRoute = new Hono(); + +adminRoute.post('/admin/reset-free-quotas', async (c) => { + const cronSecret = process.env.ADMIN_CRON_SECRET; + if (!cronSecret) { + return c.json({ error: 'server_error', message: 'Admin cron secret not configured.' }, 500); + } + + const auth = c.req.header('Authorization'); + if (!auth?.startsWith('Bearer ') || auth.slice(7) !== cronSecret) { + return c.json({ error: 'unauthorized', message: 'Invalid admin secret.' }, 401); + } + + const count = resetFreeQuotas(); + + return c.json({ + reset: count, + timestamp: new Date().toISOString(), + }); +}); diff --git a/src/api/batch.ts b/src/api/batch.ts new file mode 100644 index 0000000..9845e4a --- /dev/null +++ b/src/api/batch.ts @@ -0,0 +1,175 @@ +import { Hono } from 'hono'; +import { renderCard } from '../engine/renderer'; +import { batchSchema } from '../schemas/request'; + +export const batchRoute = new Hono(); + +batchRoute.post('/render/batch', async (c) => { + const raw = await c.req.json().catch(() => null); + if (!raw) { + return c.json( + { + error: 'invalid_request', + message: 'Request body must be valid JSON.', + docs: 'https://og-engine.com/api-reference/errors#invalid_request', + }, + 400, + ); + } + + const parsed = batchSchema.safeParse(raw); + if (!parsed.success) { + const issues = parsed.error.issues.map((i) => ({ + field: i.path.join('.'), + message: i.message, + })); + return c.json( + { + error: 'invalid_request', + message: issues[0]?.message ?? 'Validation failed.', + details: { fields: issues }, + docs: 'https://og-engine.com/api-reference/errors#invalid_request', + }, + 400, + ); + } + + const { items } = parsed.data; + const t0 = performance.now(); + + // Render all items + const results = await Promise.all( + items.map(async (data) => { + const result = await renderCard({ + title: data.title, + description: data.description, + author: data.author, + tag: data.tag, + format: data.format, + template: data.template, + accent: data.style.accent, + layout: data.style.layout, + titleSize: data.style.titleSize, + descSize: data.style.descSize, + fontName: data.style.font, + gradient: data.style.gradient, + bgImageBuffer: null, + overlayOpacity: data.style.overlayOpacity, + autoFit: data.style.autoFit, + outputFormat: data.output.format, + outputQuality: data.output.quality, + }); + return result; + }), + ); + + // Build ZIP archive (minimal implementation — local file headers + central directory) + const files = results.map((r, i) => { + const ext = r.contentType === 'image/webp' ? 'webp' : r.contentType === 'application/pdf' ? 'pdf' : 'png'; + return { name: `image-${String(i + 1).padStart(3, '0')}.${ext}`, data: r.buffer }; + }); + + const zipBuffer = buildZip(files); + const totalMs = (performance.now() - t0).toFixed(2); + + return new Response(new Uint8Array(zipBuffer), { + status: 200, + headers: { + 'Content-Type': 'application/zip', + 'Content-Disposition': 'attachment; filename="og-engine-batch.zip"', + 'X-Render-Time-Ms': totalMs, + 'X-Batch-Count': String(results.length), + }, + }); +}); + +// ─── Minimal ZIP builder (no dependencies) ─────────────────── + +interface ZipEntry { + name: string; + data: Buffer; +} + +function buildZip(files: ZipEntry[]): Buffer { + const entries: { header: Buffer; offset: number; name: Buffer; data: Buffer }[] = []; + let offset = 0; + + // Build local file headers + data + const localParts: Buffer[] = []; + + for (const file of files) { + const nameBytes = Buffer.from(file.name, 'utf8'); + const crc = crc32(file.data); + const size = file.data.length; + + // Local file header (30 bytes + name + data) + const local = Buffer.alloc(30); + local.writeUInt32LE(0x04034b50, 0); // signature + local.writeUInt16LE(20, 4); // version needed + local.writeUInt16LE(0, 6); // flags + local.writeUInt16LE(0, 8); // compression (store) + local.writeUInt16LE(0, 10); // mod time + local.writeUInt16LE(0, 12); // mod date + local.writeUInt32LE(crc, 14); // crc32 + local.writeUInt32LE(size, 18); // compressed size + local.writeUInt32LE(size, 22); // uncompressed size + local.writeUInt16LE(nameBytes.length, 26); // filename length + local.writeUInt16LE(0, 28); // extra field length + + entries.push({ header: local, offset, name: nameBytes, data: file.data }); + localParts.push(local, nameBytes, file.data); + offset += 30 + nameBytes.length + size; + } + + // Central directory + const centralStart = offset; + const centralParts: Buffer[] = []; + + for (const entry of entries) { + const central = Buffer.alloc(46); + central.writeUInt32LE(0x02014b50, 0); // signature + central.writeUInt16LE(20, 4); // version made by + central.writeUInt16LE(20, 6); // version needed + central.writeUInt16LE(0, 8); // flags + central.writeUInt16LE(0, 10); // compression + central.writeUInt16LE(0, 12); // mod time + central.writeUInt16LE(0, 14); // mod date + // Copy crc, sizes from local header + entry.header.copy(central, 16, 14, 26); // crc + sizes (12 bytes) + central.writeUInt16LE(entry.name.length, 28); // filename length + central.writeUInt16LE(0, 30); // extra field length + central.writeUInt16LE(0, 32); // comment length + central.writeUInt16LE(0, 34); // disk number + central.writeUInt16LE(0, 36); // internal attrs + central.writeUInt32LE(0, 38); // external attrs + central.writeUInt32LE(entry.offset, 42); // local header offset + + centralParts.push(central, entry.name); + } + + const centralSize = centralParts.reduce((s, b) => s + b.length, 0); + + // End of central directory + const eocd = Buffer.alloc(22); + eocd.writeUInt32LE(0x06054b50, 0); // signature + eocd.writeUInt16LE(0, 4); // disk number + eocd.writeUInt16LE(0, 6); // central dir disk + eocd.writeUInt16LE(entries.length, 8); // entries on this disk + eocd.writeUInt16LE(entries.length, 10); // total entries + eocd.writeUInt32LE(centralSize, 12); // central dir size + eocd.writeUInt32LE(centralStart, 16); // central dir offset + eocd.writeUInt16LE(0, 20); // comment length + + return Buffer.concat([...localParts, ...centralParts, eocd]); +} + +function crc32(buf: Buffer): number { + let crc = 0xffffffff; + for (let i = 0; i < buf.length; i++) { + crc ^= buf[i]; + for (let j = 0; j < 8; j++) { + crc = (crc >>> 1) ^ (crc & 1 ? 0xedb88320 : 0); + } + } + return (crc ^ 0xffffffff) >>> 0; +} diff --git a/src/api/billing.ts b/src/api/billing.ts new file mode 100644 index 0000000..cd6fcc1 --- /dev/null +++ b/src/api/billing.ts @@ -0,0 +1,33 @@ +import { Hono } from 'hono'; +import Stripe from 'stripe'; +import type { ApiKeyRecord } from '../db'; + +export const billingRoute = new Hono(); + +billingRoute.get('/billing/portal', async (c) => { + const stripeSecretKey = process.env.STRIPE_SECRET_KEY; + if (!stripeSecretKey) { + return c.json({ error: 'server_error', message: 'Stripe is not configured.' }, 500); + } + + const record = c.get('apiKey' as never) as ApiKeyRecord; + + if (!record.stripe_customer_id) { + return c.json( + { + error: 'no_billing_account', + message: 'No billing account. Subscribe to a paid plan first.', + docs: 'https://og-engine.com/pricing', + }, + 400, + ); + } + + const stripe = new Stripe(stripeSecretKey); + const session = await stripe.billingPortal.sessions.create({ + customer: record.stripe_customer_id, + return_url: 'https://og-engine.com/pricing', + }); + + return c.json({ url: session.url }); +}); diff --git a/src/api/health.ts b/src/api/health.ts new file mode 100644 index 0000000..af3f130 --- /dev/null +++ b/src/api/health.ts @@ -0,0 +1,20 @@ +import { Hono } from 'hono'; +import { FONTS } from '../engine/fonts'; +import { FORMAT_KEYS } from '../engine/formats'; +import { TEMPLATE_NAMES } from '../engine/templates'; +import { getMeasureCacheStats } from '../engine/text-measure'; + +export const healthRoute = new Hono(); + +healthRoute.get('/health', (c) => { + return c.json({ + status: 'ok', + fonts: FONTS.map((f) => f.name), + formats: FORMAT_KEYS, + templates: TEMPLATE_NAMES, + version: '0.1.0', + cache: { + textMeasure: getMeasureCacheStats(), + }, + }); +}); diff --git a/src/api/register.ts b/src/api/register.ts new file mode 100644 index 0000000..911d74c --- /dev/null +++ b/src/api/register.ts @@ -0,0 +1,68 @@ +import { Hono } from 'hono'; +import { z } from 'zod'; +import { createApiKey, findApiKeyByEmail } from '../db'; +import { sendWelcomeEmail } from '../email/send'; + +export const registerRoute = new Hono(); + +const registerSchema = z.object({ + email: z.string().email('A valid email address is required.'), +}); + +registerRoute.post('/auth/register', async (c) => { + const raw = await c.req.json().catch(() => null); + if (!raw) { + return c.json( + { + error: 'invalid_request', + message: 'Request body must be valid JSON.', + docs: 'https://og-engine.com/api-reference/errors#invalid_request', + }, + 400, + ); + } + + const parsed = registerSchema.safeParse(raw); + if (!parsed.success) { + const issues = parsed.error.issues.map((i) => ({ + field: i.path.join('.'), + message: i.message, + })); + return c.json( + { + error: 'invalid_request', + message: issues[0]?.message ?? 'Validation failed.', + details: { fields: issues }, + docs: 'https://og-engine.com/api-reference/errors#invalid_request', + }, + 400, + ); + } + + const { email } = parsed.data; + + // Per DECISIONS.md Decision 4: duplicate registration returns existing key + const existing = findApiKeyByEmail(email); + if (existing) { + return c.json({ + apiKey: existing.key, + plan: existing.plan, + limit: existing.calls_limit, + message: `Existing API key returned. Also sent to ${email}.`, + }); + } + + const record = createApiKey(email, 'free'); + + await sendWelcomeEmail(email, record.key, record.plan); + + return c.json( + { + apiKey: record.key, + plan: record.plan, + limit: record.calls_limit, + message: `API key created. Also sent to ${email}.`, + }, + 201, + ); +}); diff --git a/src/api/render-from-url.ts b/src/api/render-from-url.ts new file mode 100644 index 0000000..96ef1d3 --- /dev/null +++ b/src/api/render-from-url.ts @@ -0,0 +1,208 @@ +import { Hono } from 'hono'; +import { z } from 'zod'; +import { FONTS } from '../engine/fonts'; +import { FORMAT_KEYS } from '../engine/formats'; +import { GRADIENTS } from '../engine/gradients'; +import { loadRemoteImages } from '../engine/image-loader'; +import { extractMeta } from '../engine/meta-extract'; +import { renderCard } from '../engine/renderer'; +import { TEMPLATE_NAMES } from '../engine/templates'; +import { assertNotPrivateHost, SsrfBlockedError } from '../utils/ssrf'; + +const fontNames = FONTS.map((f) => f.name); +const gradientSlugs = GRADIENTS.map((g) => g.slug); +const formatEnum = z.enum(FORMAT_KEYS as [string, ...string[]]); + +export const renderFromUrlSchema = z.object({ + url: z.string().url('A valid URL is required.'), + format: formatEnum.default('og'), + template: z + .string() + .refine((v) => TEMPLATE_NAMES.includes(v) || v.startsWith('custom:'), { + message: `Template must be one of: ${TEMPLATE_NAMES.join(', ')}, or "custom:"`, + }) + .default('default'), + style: z + .object({ + accent: z + .string() + .regex(/^#[0-9a-fA-F]{6}$/) + .default('#38ef7d'), + layout: z.enum(['left', 'center', 'bottom']).default('left'), + font: z + .string() + .refine((v) => fontNames.includes(v)) + .default('Outfit'), + titleSize: z.number().int().min(28).max(72).default(48), + descSize: z.number().int().min(14).max(32).default(22), + gradient: z + .string() + .refine((v) => gradientSlugs.includes(v)) + .default('void'), + overlayOpacity: z.number().min(0.2).max(0.9).default(0.65), + autoFit: z.boolean().default(true), + }) + .default({ + accent: '#38ef7d', + layout: 'left', + font: 'Outfit', + titleSize: 48, + descSize: 22, + gradient: 'void', + overlayOpacity: 0.65, + autoFit: true, + }), + output: z + .object({ + format: z.enum(['png', 'webp', 'pdf']).default('png'), + quality: z.number().int().min(1).max(100).default(90), + }) + .default({ + format: 'png', + quality: 90, + }), +}); + +export const renderFromUrlRoute = new Hono(); + +const FETCH_TIMEOUT_MS = 8_000; + +renderFromUrlRoute.post('/render/from-url', async (c) => { + const raw = await c.req.json().catch(() => null); + if (!raw) { + return c.json( + { + error: 'invalid_request', + message: 'Request body must be valid JSON.', + docs: 'https://og-engine.com/api-reference/errors#invalid_request', + }, + 400, + ); + } + + const parsed = renderFromUrlSchema.safeParse(raw); + if (!parsed.success) { + const issues = parsed.error.issues.map((i) => ({ + field: i.path.join('.'), + message: i.message, + })); + return c.json( + { + error: 'invalid_request', + message: issues[0]?.message ?? 'Validation failed.', + details: { fields: issues }, + docs: 'https://og-engine.com/api-reference/errors#invalid_request', + }, + 400, + ); + } + + const data = parsed.data; + + // SSRF protection: block private/reserved IPs before fetching + try { + const { hostname } = new URL(data.url); + await assertNotPrivateHost(hostname); + } catch (err) { + if (err instanceof SsrfBlockedError) { + return c.json( + { + error: 'ssrf_blocked', + message: err.message, + docs: 'https://og-engine.com/api-reference/errors#ssrf_blocked', + }, + 400, + ); + } + // URL parse failure is already caught by schema validation; rethrow unexpected errors + throw err; + } + + // Fetch the URL + let html: string; + try { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); + const res = await fetch(data.url, { + signal: controller.signal, + headers: { 'User-Agent': 'OGEngine/1.0 (og-engine.com)' }, + }); + clearTimeout(timer); + + if (!res.ok) { + return c.json( + { + error: 'fetch_failed', + message: `Could not fetch URL: HTTP ${res.status}`, + docs: 'https://og-engine.com/api-reference/errors#fetch_failed', + }, + 422, + ); + } + html = await res.text(); + } catch (err) { + return c.json( + { + error: 'fetch_failed', + message: `Could not fetch URL: ${err instanceof Error ? err.message : 'timeout or network error'}`, + docs: 'https://og-engine.com/api-reference/errors#fetch_failed', + }, + 422, + ); + } + + // Extract meta tags + const meta = extractMeta(html); + + if (!meta.variables.title) { + return c.json( + { + error: 'no_content', + message: 'No title found on the page (checked og:title, twitter:title, ).', + docs: 'https://og-engine.com/api-reference/errors#no_content', + }, + 422, + ); + } + + // Fetch images from extracted URLs + const namedImages = Object.keys(meta.images).length > 0 ? await loadRemoteImages(meta.images) : {}; + + const t0 = performance.now(); + + const result = await renderCard({ + title: meta.variables.title, + description: meta.variables.description ?? '', + author: meta.variables.author ?? '', + tag: meta.variables.tag ?? '', + variables: meta.variables, + namedImages, + format: data.format, + template: data.template, + accent: data.style.accent, + layout: data.style.layout, + titleSize: data.style.titleSize, + descSize: data.style.descSize, + fontName: data.style.font, + gradient: data.style.gradient, + bgImageBuffer: null, + overlayOpacity: data.style.overlayOpacity, + autoFit: data.style.autoFit, + outputFormat: data.output.format, + outputQuality: data.output.quality, + }); + + const renderTimeMs = (performance.now() - t0).toFixed(2); + + const headers: Record<string, string> = { + 'Content-Type': result.contentType, + 'X-Render-Time-Ms': renderTimeMs, + 'X-Title-Lines': String(result.titleVisibleLines), + 'X-Desc-Lines': String(result.descVisibleLines), + 'X-Layout-Overflow': String(result.overflow), + 'X-Source-URL': data.url, + 'X-Cache': 'miss', + }; + + return new Response(new Uint8Array(result.buffer), { status: 200, headers }); +}); diff --git a/src/api/render.ts b/src/api/render.ts new file mode 100644 index 0000000..f77909e --- /dev/null +++ b/src/api/render.ts @@ -0,0 +1,212 @@ +import { Hono } from 'hono'; +import { type ApiKeyRecord, findCustomTemplate, type Plan } from '../db'; +import type { CustomTemplateDefinition } from '../engine/custom-template'; +import { getCachedImage, hashRequest, setCachedImage } from '../engine/image-cache'; +import { loadRemoteImages } from '../engine/image-loader'; +import { renderCard } from '../engine/renderer'; +import { canAccessFeature } from '../middleware/auth'; +import { renderSchema } from '../schemas/request'; + +export const renderRoute = new Hono(); + +renderRoute.post('/render', async (c) => { + const contentType = c.req.header('content-type') ?? ''; + + let raw: Record<string, unknown> | null = null; + let bgImageBuffer: Buffer | null = null; + + if (contentType.includes('multipart/form-data')) { + // Multipart: supports background image upload + const form = await c.req.formData().catch(() => null); + if (!form) { + return c.json( + { + error: 'invalid_request', + message: 'Could not parse multipart form data.', + docs: 'https://og-engine.com/api-reference/errors#invalid_request', + }, + 400, + ); + } + + const jsonField = form.get('data') ?? form.get('json'); + if (!jsonField || typeof jsonField !== 'string') { + return c.json( + { + error: 'invalid_request', + message: 'Multipart request must include a "data" field with JSON.', + docs: 'https://og-engine.com/api-reference/errors#invalid_request', + }, + 400, + ); + } + + try { + raw = JSON.parse(jsonField); + } catch { + return c.json( + { + error: 'invalid_request', + message: 'The "data" field must contain valid JSON.', + docs: 'https://og-engine.com/api-reference/errors#invalid_request', + }, + 400, + ); + } + + const bgFile = form.get('backgroundImage') ?? form.get('background'); + if (bgFile && bgFile instanceof File) { + const ab = await bgFile.arrayBuffer(); + bgImageBuffer = Buffer.from(ab); + } + } else { + raw = await c.req.json().catch(() => null); + if (!raw) { + return c.json( + { + error: 'invalid_request', + message: 'Request body must be valid JSON.', + docs: 'https://og-engine.com/api-reference/errors#invalid_request', + }, + 400, + ); + } + } + + const parsed = renderSchema.safeParse(raw); + if (!parsed.success) { + const issues = parsed.error.issues.map((i) => ({ + field: i.path.join('.'), + message: i.message, + })); + return c.json( + { + error: 'invalid_request', + message: issues[0]?.message ?? 'Validation failed.', + details: { fields: issues }, + docs: 'https://og-engine.com/api-reference/errors#invalid_request', + }, + 400, + ); + } + + const data = parsed.data; + + // Plan gate: WebP requires Starter+ plan + if (data.output.format === 'webp') { + const apiKeyRecord = c.get('apiKey' as never) as ApiKeyRecord | undefined; + if (apiKeyRecord && !canAccessFeature(apiKeyRecord.plan as Plan, 'webp')) { + return c.json( + { + error: 'plan_required', + message: `This feature requires a higher plan. Your plan: ${apiKeyRecord.plan}.`, + details: { + feature: 'webp', + currentPlan: apiKeyRecord.plan, + requiredPlans: ['starter', 'pro', 'scale'], + upgradeUrl: 'https://og-engine.com/pricing', + }, + docs: 'https://og-engine.com/api-reference/errors#plan_required', + }, + 402, + ); + } + } + + // Merge legacy content fields into variables + const variables: Record<string, string> = { + title: data.title, + description: data.description, + author: data.author, + tag: data.tag, + ...data.variables, + }; + + // Fetch named remote images (in parallel) + const namedImages = Object.keys(data.images).length > 0 ? await loadRemoteImages(data.images) : {}; + + // Check image cache (only for JSON requests without background image or named images) + if (!bgImageBuffer && Object.keys(data.images).length === 0) { + const cacheKey = hashRequest(data); + const cached = getCachedImage(cacheKey); + if (cached) { + return new Response(new Uint8Array(cached.buffer), { + status: 200, + headers: { ...cached.headers, 'X-Cache': 'hit' }, + }); + } + } + + // Resolve custom template if template starts with "custom:" + let customTemplateDefinition: CustomTemplateDefinition | undefined; + if (data.template.startsWith('custom:')) { + const apiKeyRecord = c.get('apiKey' as never) as ApiKeyRecord | undefined; + if (!apiKeyRecord) { + return c.json( + { + error: 'unauthorized', + message: 'Custom templates require authentication.', + docs: 'https://og-engine.com/api-reference/errors#unauthorized', + }, + 401, + ); + } + const customName = data.template.slice(7); // strip "custom:" + const tmpl = findCustomTemplate(apiKeyRecord.id, customName); + if (!tmpl) { + return c.json( + { + error: 'not_found', + message: `Custom template "${customName}" not found.`, + docs: 'https://og-engine.com/api-reference/errors#not_found', + }, + 404, + ); + } + customTemplateDefinition = JSON.parse(tmpl.definition); + } + + const t0 = performance.now(); + + const result = await renderCard({ + title: data.title, + description: data.description, + author: data.author, + tag: data.tag, + variables, + namedImages, + format: data.format, + template: data.template, + accent: data.style.accent, + layout: data.style.layout, + titleSize: data.style.titleSize, + descSize: data.style.descSize, + fontName: data.style.font, + gradient: data.style.gradient, + bgImageBuffer, + overlayOpacity: data.style.overlayOpacity, + autoFit: data.style.autoFit, + customTemplateDefinition, + outputFormat: data.output.format, + outputQuality: data.output.quality, + }); + + const renderTimeMs = (performance.now() - t0).toFixed(2); + + const headers: Record<string, string> = { + 'Content-Type': result.contentType, + 'X-Render-Time-Ms': renderTimeMs, + 'X-Title-Lines': String(result.titleVisibleLines), + 'X-Desc-Lines': String(result.descVisibleLines), + 'X-Layout-Overflow': String(result.overflow), + 'X-Cache': 'miss', + }; + + // Cache the rendered image (only for JSON requests without background image or named images) + if (!bgImageBuffer && Object.keys(data.images).length === 0) { + const cacheKey = hashRequest(data); + setCachedImage(cacheKey, { buffer: result.buffer, contentType: result.contentType, headers }); + } + + return new Response(new Uint8Array(result.buffer), { status: 200, headers }); +}); diff --git a/src/api/templates.ts b/src/api/templates.ts new file mode 100644 index 0000000..508b00b --- /dev/null +++ b/src/api/templates.ts @@ -0,0 +1,106 @@ +import { Hono } from 'hono'; +import { + type ApiKeyRecord, + createCustomTemplate, + deleteCustomTemplate, + findCustomTemplate, + listCustomTemplates, + updateCustomTemplate, +} from '../db'; +import { customTemplateSchema } from '../engine/custom-template'; +import { authMiddleware } from '../middleware/auth'; + +export const templatesRoute = new Hono(); + +templatesRoute.post('/templates', authMiddleware(), async (c) => { + const record = c.get('apiKey' as never) as ApiKeyRecord | undefined; + if (!record) { + return c.json({ error: 'unauthorized', message: 'API key required.' }, 401); + } + + const raw = await c.req.json().catch(() => null); + if (!raw) { + return c.json( + { + error: 'invalid_request', + message: 'Request body must be valid JSON.', + docs: 'https://og-engine.com/api-reference/errors#invalid_request', + }, + 400, + ); + } + + const parsed = customTemplateSchema.safeParse(raw); + if (!parsed.success) { + const issues = parsed.error.issues.map((i) => ({ + field: i.path.join('.'), + message: i.message, + })); + return c.json( + { + error: 'invalid_request', + message: issues[0]?.message ?? 'Validation failed.', + details: { fields: issues }, + docs: 'https://og-engine.com/api-reference/errors#invalid_request', + }, + 400, + ); + } + + const { name, layers } = parsed.data; + + // Check if template name already exists for this user + const existing = findCustomTemplate(record.id, name); + if (existing) { + updateCustomTemplate(existing.id, { name, layers }); + return c.json({ + id: existing.id, + name, + layerCount: layers.length, + message: 'Template updated.', + }); + } + + const tmpl = createCustomTemplate(record.id, name, { name, layers }); + return c.json( + { + id: tmpl.id, + name, + layerCount: layers.length, + message: 'Template created.', + }, + 201, + ); +}); + +templatesRoute.get('/templates', authMiddleware(), async (c) => { + const record = c.get('apiKey' as never) as ApiKeyRecord | undefined; + if (!record) { + return c.json({ error: 'unauthorized', message: 'API key required.' }, 401); + } + + const templates = listCustomTemplates(record.id); + return c.json({ + templates: templates.map((t) => { + const def = JSON.parse(t.definition); + return { + id: t.id, + name: t.name, + layerCount: def.layers?.length ?? 0, + createdAt: t.created_at, + updatedAt: t.updated_at, + }; + }), + }); +}); + +templatesRoute.delete('/templates/:id', authMiddleware(), async (c) => { + const record = c.get('apiKey' as never) as ApiKeyRecord | undefined; + if (!record) { + return c.json({ error: 'unauthorized', message: 'API key required.' }, 401); + } + + const id = c.req.param('id') as string; + deleteCustomTemplate(id); + return c.json({ message: 'Template deleted.' }); +}); diff --git a/src/api/triggers.ts b/src/api/triggers.ts new file mode 100644 index 0000000..d639e76 --- /dev/null +++ b/src/api/triggers.ts @@ -0,0 +1,192 @@ +import { Hono } from 'hono'; +import { z } from 'zod'; +import { type ApiKeyRecord, createWebhook, deleteWebhook, findWebhookById, listWebhooks } from '../db'; +import { renderCard } from '../engine/renderer'; +import { renderSchema } from '../schemas/request'; + +export const triggersRoute = new Hono(); + +const createWebhookSchema = z.object({ + url: z.string().url('A valid callback URL is required.'), + renderConfig: renderSchema, +}); + +/** + * POST /triggers — register a webhook trigger. + * When the trigger fires (POST /triggers/:id/fire), we re-render using the saved config + * and POST the resulting image to the callback URL. + */ +triggersRoute.post('/triggers', async (c) => { + const record = c.get('apiKey' as never) as ApiKeyRecord | undefined; + if (!record) { + return c.json({ error: 'unauthorized', message: 'API key required.' }, 401); + } + + const raw = await c.req.json().catch(() => null); + if (!raw) { + return c.json( + { + error: 'invalid_request', + message: 'Request body must be valid JSON.', + docs: 'https://og-engine.com/api-reference/errors#invalid_request', + }, + 400, + ); + } + + const parsed = createWebhookSchema.safeParse(raw); + if (!parsed.success) { + const issues = parsed.error.issues.map((i) => ({ + field: i.path.join('.'), + message: i.message, + })); + return c.json( + { + error: 'invalid_request', + message: issues[0]?.message ?? 'Validation failed.', + details: { fields: issues }, + docs: 'https://og-engine.com/api-reference/errors#invalid_request', + }, + 400, + ); + } + + const { url, renderConfig } = parsed.data; + const webhook = createWebhook(record.id, url, renderConfig); + + return c.json( + { + id: webhook.id, + url: webhook.url, + secret: webhook.secret, + message: 'Webhook trigger created. Fire it with POST /triggers/:id/fire.', + }, + 201, + ); +}); + +/** + * GET /triggers — list all webhook triggers for the authenticated user. + */ +triggersRoute.get('/triggers', async (c) => { + const record = c.get('apiKey' as never) as ApiKeyRecord | undefined; + if (!record) { + return c.json({ error: 'unauthorized', message: 'API key required.' }, 401); + } + + const webhooks = listWebhooks(record.id); + return c.json({ + triggers: webhooks.map((w) => ({ + id: w.id, + url: w.url, + createdAt: w.created_at, + })), + }); +}); + +/** + * DELETE /triggers/:id — deactivate a webhook trigger. + */ +triggersRoute.delete('/triggers/:id', async (c) => { + const record = c.get('apiKey' as never) as ApiKeyRecord | undefined; + if (!record) { + return c.json({ error: 'unauthorized', message: 'API key required.' }, 401); + } + + const id = c.req.param('id'); + const webhook = findWebhookById(id); + if (!webhook || webhook.api_key_id !== record.id) { + return c.json({ error: 'not_found', message: 'Trigger not found.' }, 404); + } + + deleteWebhook(id); + return c.json({ message: 'Trigger deleted.' }); +}); + +/** + * POST /triggers/:id/fire — fire a webhook trigger. + * Re-renders the image using saved config (with optional content overrides) + * and POSTs the result to the callback URL. + * + * Body (optional): { "title": "new title", "description": "new desc" } + * These override the saved renderConfig values. + */ +triggersRoute.post('/triggers/:id/fire', async (c) => { + const record = c.get('apiKey' as never) as ApiKeyRecord | undefined; + if (!record) { + return c.json({ error: 'unauthorized', message: 'API key required.' }, 401); + } + + const id = c.req.param('id'); + const webhook = findWebhookById(id); + if (!webhook || webhook.api_key_id !== record.id || !webhook.active) { + return c.json({ error: 'not_found', message: 'Trigger not found.' }, 404); + } + + // Parse optional content overrides + const overrides = (await c.req.json().catch(() => ({}))) as Record<string, string>; + const savedConfig = JSON.parse(webhook.render_config); + + // Merge overrides + const config = { + ...savedConfig, + ...(overrides.title ? { title: overrides.title } : {}), + ...(overrides.description ? { description: overrides.description } : {}), + ...(overrides.author ? { author: overrides.author } : {}), + ...(overrides.tag ? { tag: overrides.tag } : {}), + }; + + // Render + const result = await renderCard({ + title: config.title, + description: config.description ?? '', + author: config.author ?? '', + tag: config.tag ?? '', + format: config.format, + template: config.template ?? 'default', + accent: config.style?.accent ?? '#38ef7d', + layout: config.style?.layout ?? 'left', + titleSize: config.style?.titleSize ?? 48, + descSize: config.style?.descSize ?? 22, + fontName: config.style?.font ?? 'Outfit', + gradient: config.style?.gradient ?? 'void', + bgImageBuffer: null, + overlayOpacity: config.style?.overlayOpacity ?? 0.65, + autoFit: config.style?.autoFit ?? false, + outputFormat: config.output?.format ?? 'png', + outputQuality: config.output?.quality ?? 90, + }); + + // Deliver to callback URL (fire-and-forget with error capture) + let deliveryStatus = 'delivered'; + let deliveryError: string | undefined; + + try { + const callbackRes = await fetch(webhook.url, { + method: 'POST', + headers: { + 'Content-Type': result.contentType, + 'X-Webhook-Id': webhook.id, + 'X-Webhook-Secret': webhook.secret, + 'X-Render-Time-Ms': '0', + }, + body: new Uint8Array(result.buffer), + }); + + if (!callbackRes.ok) { + deliveryStatus = 'failed'; + deliveryError = `Callback returned ${callbackRes.status}`; + } + } catch (err) { + deliveryStatus = 'failed'; + deliveryError = err instanceof Error ? err.message : 'Unknown error'; + } + + return c.json({ + triggered: true, + deliveryStatus, + ...(deliveryError ? { deliveryError } : {}), + imageSize: result.buffer.length, + contentType: result.contentType, + }); +}); diff --git a/src/api/usage.ts b/src/api/usage.ts new file mode 100644 index 0000000..d732c81 --- /dev/null +++ b/src/api/usage.ts @@ -0,0 +1,32 @@ +import { Hono } from 'hono'; +import { type ApiKeyRecord, getUsageStats } from '../db'; + +export const usageRoute = new Hono(); + +usageRoute.get('/usage', async (c) => { + const record = c.get('apiKey' as never) as ApiKeyRecord | undefined; + + if (!record) { + return c.json( + { + error: 'unauthorized', + message: 'API key required to view usage.', + docs: 'https://og-engine.com/api-reference/errors#unauthorized', + }, + 401, + ); + } + + const stats = getUsageStats(record.id); + + return c.json({ + plan: record.plan, + quota: { + limit: record.calls_limit, + used: record.calls_used, + remaining: Math.max(0, record.calls_limit - record.calls_used), + periodStart: record.period_start, + }, + usage: stats, + }); +}); diff --git a/src/api/validate.ts b/src/api/validate.ts new file mode 100644 index 0000000..807e844 --- /dev/null +++ b/src/api/validate.ts @@ -0,0 +1,122 @@ +import { Hono } from 'hono'; +import { autoFitCard } from '../engine/autofit'; +import { getFontByName } from '../engine/fonts'; +import { FORMATS } from '../engine/formats'; +import { measureLines } from '../engine/text-measure'; +import { validateSchema } from '../schemas/request'; + +export const validateRoute = new Hono(); + +validateRoute.post('/validate', async (c) => { + const raw = await c.req.json().catch(() => null); + if (!raw) { + return c.json( + { + error: 'invalid_request', + message: 'Request body must be valid JSON.', + docs: 'https://og-engine.com/api-reference/errors#invalid_request', + }, + 400, + ); + } + + const parsed = validateSchema.safeParse(raw); + if (!parsed.success) { + const issues = parsed.error.issues.map((i) => ({ + field: i.path.join('.'), + message: i.message, + })); + return c.json( + { + error: 'invalid_request', + message: issues[0]?.message ?? 'Validation failed.', + details: { fields: issues }, + docs: 'https://og-engine.com/api-reference/errors#invalid_request', + }, + 400, + ); + } + + const data = parsed.data; + const t0 = performance.now(); + + // If autoFit requested, return optimal sizes + if (data.autoFit) { + const fitted = autoFitCard({ + title: data.title, + description: data.description, + format: data.format, + fontName: data.font, + titleSizeRange: [28, data.titleSize], + descSizeRange: [14, data.descSize], + maxTitleLines: data.maxTitleLines, + maxDescLines: data.maxDescLines, + }); + + const computeTimeMs = Number((performance.now() - t0).toFixed(2)); + + return c.json({ + fits: true, + autoFit: { + titleSize: fitted.titleSize, + descSize: fitted.descSize, + }, + title: { + lines: fitted.titleLines, + maxLines: data.maxTitleLines ?? FORMATS[data.format].maxTitleLines, + overflow: false, + }, + ...(data.description + ? { + description: { + lines: fitted.descLines, + maxLines: data.maxDescLines ?? FORMATS[data.format].maxDescLines, + overflow: false, + }, + } + : {}), + computeTimeMs, + }); + } + + const fmt = FORMATS[data.format]; + const fontEntry = getFontByName(data.font); + const ff = fontEntry.family; + const s = Math.max(fmt.w, fmt.h) / 1200; + const px = Math.round(64 * s); + const cW = fmt.w - px * 2; + + // Title measurement + const tFont = `800 ${Math.round(data.titleSize * s)}px ${ff}`; + const tLines = measureLines(data.title, tFont, cW); + const maxT = data.maxTitleLines ?? fmt.maxTitleLines; + const titleOverflow = tLines.length > maxT; + + // Description measurement + let descResult: { lines: number; maxLines: number; overflow: boolean } | undefined; + if (data.description) { + const dFont = `400 ${Math.round(data.descSize * s)}px ${ff}`; + const dLines = measureLines(data.description, dFont, cW); + const maxD = data.maxDescLines ?? fmt.maxDescLines; + const descOverflow = dLines.length > maxD; + descResult = { + lines: dLines.length, + maxLines: maxD, + overflow: descOverflow, + }; + } + + const computeTimeMs = Number((performance.now() - t0).toFixed(2)); + const fits = !titleOverflow && !descResult?.overflow; + + return c.json({ + fits, + title: { + lines: tLines.length, + maxLines: maxT, + overflow: titleOverflow, + }, + ...(descResult ? { description: descResult } : {}), + computeTimeMs, + }); +}); diff --git a/src/api/webhooks.ts b/src/api/webhooks.ts new file mode 100644 index 0000000..a0e0994 --- /dev/null +++ b/src/api/webhooks.ts @@ -0,0 +1,120 @@ +import { Hono } from 'hono'; +import Stripe from 'stripe'; +import { + createApiKey, + findApiKeyByEmail, + findApiKeyByStripeSubscription, + type Plan, + resetUsage, + updatePlan, + updateStripeInfo, +} from '../db'; +import { sendDowngradeEmail, sendUpgradeEmail, sendWelcomeEmail } from '../email/send'; + +export const webhooksRoute = new Hono(); + +function getPlanFromPriceId(priceId: string): Plan | null { + const mapping: Record<string, Plan> = { + [process.env.STRIPE_PRICE_STARTER ?? '']: 'starter', + [process.env.STRIPE_PRICE_PRO ?? '']: 'pro', + [process.env.STRIPE_PRICE_SCALE ?? '']: 'scale', + }; + return mapping[priceId] ?? null; +} + +webhooksRoute.post('/webhooks/stripe', async (c) => { + const stripeSecretKey = process.env.STRIPE_SECRET_KEY; + const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET; + + if (!stripeSecretKey || !webhookSecret) { + return c.json({ error: 'server_error', message: 'Stripe is not configured.' }, 500); + } + + const signature = c.req.header('stripe-signature'); + if (!signature) { + return c.json({ error: 'invalid_request', message: 'Missing stripe-signature header.' }, 400); + } + + const body = await c.req.text(); + const stripe = new Stripe(stripeSecretKey); + + let event: Stripe.Event; + try { + event = await stripe.webhooks.constructEventAsync(body, signature, webhookSecret); + } catch (_err) { + return c.json({ error: 'invalid_request', message: 'Invalid webhook signature.' }, 400); + } + + switch (event.type) { + case 'checkout.session.completed': { + const session = event.data.object as Stripe.Checkout.Session; + const email = session.customer_email ?? session.customer_details?.email ?? null; + const customerId = session.customer as string; + const subscriptionId = session.subscription as string; + + if (!email || !subscriptionId) break; + + const sub = await stripe.subscriptions.retrieve(subscriptionId); + const priceId = sub.items.data[0]?.price?.id; + const plan = priceId ? getPlanFromPriceId(priceId) : null; + if (!plan) break; + + let record = findApiKeyByEmail(email); + if (record) { + updatePlan(record.id, plan); + updateStripeInfo(record.id, customerId, subscriptionId); + } else { + record = createApiKey(email, plan); + updateStripeInfo(record.id, customerId, subscriptionId); + } + + await sendWelcomeEmail(email, record.key, plan); + break; + } + + case 'customer.subscription.updated': { + const sub = event.data.object as Stripe.Subscription; + const subId = sub.id; + const priceId = sub.items?.data?.[0]?.price?.id; + + if (!subId || !priceId) break; + + const plan = getPlanFromPriceId(priceId); + if (!plan) break; + + const record = findApiKeyByStripeSubscription(subId); + if (record) { + updatePlan(record.id, plan); + await sendUpgradeEmail(record.email, plan); + } + break; + } + + case 'customer.subscription.deleted': { + const sub = event.data.object as Stripe.Subscription; + const subId = sub.id; + if (!subId) break; + + const record = findApiKeyByStripeSubscription(subId); + if (record) { + updatePlan(record.id, 'free'); + await sendDowngradeEmail(record.email); + } + break; + } + + case 'invoice.paid': { + const invoice = event.data.object as Stripe.Invoice; + const subId = (invoice.parent?.subscription_details?.subscription as string) ?? null; + if (!subId) break; + + const record = findApiKeyByStripeSubscription(subId); + if (record) { + resetUsage(record.id); + } + break; + } + } + + return c.text('ok'); +}); diff --git a/src/data/google-fonts.json b/src/data/google-fonts.json new file mode 100644 index 0000000..acff87d --- /dev/null +++ b/src/data/google-fonts.json @@ -0,0 +1 @@ +[{"family":"Roboto","category":"sans-serif","subsets":["cyrillic","cyrillic-ext","greek","greek-ext","latin","latin-ext","math","symbols","vietnamese"],"variants":["100","200","300","regular","500","600","700","800","900","100italic","200italic","300italic","italic","500italic","600italic","700italic","800italic","900italic"],"popularity":1},{"family":"Open Sans","category":"sans-serif","subsets":["cyrillic","cyrillic-ext","greek","greek-ext","hebrew","latin","latin-ext","math","symbols","vietnamese"],"variants":["300","regular","500","600","700","800","300italic","italic","500italic","600italic","700italic","800italic"],"popularity":2},{"family":"Google Sans","category":"sans-serif","subsets":["armenian","bengali","canadian-aboriginal","cyrillic","cyrillic-ext","devanagari","ethiopic","georgian","greek","greek-ext","gujarati","gurmukhi","hebrew","khmer","lao","latin","latin-ext","malayalam","oriya","sinhala","symbols","tamil","telugu","thai","vietnamese"],"variants":["regular","500","600","700","italic","500italic","600italic","700italic"],"popularity":3},{"family":"Inter","category":"sans-serif","subsets":["cyrillic","cyrillic-ext","greek","greek-ext","latin","latin-ext","vietnamese"],"variants":["100","200","300","regular","500","600","700","800","900","100italic","200italic","300italic","italic","500italic","600italic","700italic","800italic","900italic"],"popularity":4},{"family":"Montserrat","category":"sans-serif","subsets":["cyrillic","cyrillic-ext","latin","latin-ext","vietnamese"],"variants":["100","200","300","regular","500","600","700","800","900","100italic","200italic","300italic","italic","500italic","600italic","700italic","800italic","900italic"],"popularity":5},{"family":"Poppins","category":"sans-serif","subsets":["devanagari","latin","latin-ext"],"variants":["100","100italic","200","200italic","300","300italic","regular","italic","500","500italic","600","600italic","700","700italic","800","800italic","900","900italic"],"popularity":6},{"family":"Noto Sans JP","category":"sans-serif","subsets":["cyrillic","japanese","latin","latin-ext","vietnamese"],"variants":["100","200","300","regular","500","600","700","800","900"],"popularity":7},{"family":"Lato","category":"sans-serif","subsets":["latin","latin-ext"],"variants":["100","100italic","300","300italic","regular","italic","700","700italic","900","900italic"],"popularity":8},{"family":"Material Icons","category":"monospace","subsets":["latin"],"variants":["regular"],"popularity":9},{"family":"Roboto Condensed","category":"sans-serif","subsets":["cyrillic","cyrillic-ext","greek","greek-ext","latin","latin-ext","vietnamese"],"variants":["100","200","300","regular","500","600","700","800","900","100italic","200italic","300italic","italic","500italic","600italic","700italic","800italic","900italic"],"popularity":10},{"family":"Arimo","category":"sans-serif","subsets":["cyrillic","cyrillic-ext","greek","greek-ext","hebrew","latin","latin-ext","vietnamese"],"variants":["regular","500","600","700","italic","500italic","600italic","700italic"],"popularity":11},{"family":"Roboto Mono","category":"monospace","subsets":["cyrillic","cyrillic-ext","greek","latin","latin-ext","vietnamese"],"variants":["100","200","300","regular","500","600","700","100italic","200italic","300italic","italic","500italic","600italic","700italic"],"popularity":12},{"family":"Noto Sans","category":"sans-serif","subsets":["cyrillic","cyrillic-ext","devanagari","greek","greek-ext","latin","latin-ext","vietnamese"],"variants":["100","200","300","regular","500","600","700","800","900","100italic","200italic","300italic","italic","500italic","600italic","700italic","800italic","900italic"],"popularity":13},{"family":"Oswald","category":"sans-serif","subsets":["cyrillic","cyrillic-ext","latin","latin-ext","vietnamese"],"variants":["200","300","regular","500","600","700"],"popularity":14},{"family":"Raleway","category":"sans-serif","subsets":["cyrillic","cyrillic-ext","latin","latin-ext","vietnamese"],"variants":["100","200","300","regular","500","600","700","800","900","100italic","200italic","300italic","italic","500italic","600italic","700italic","800italic","900italic"],"popularity":15},{"family":"Nunito","category":"sans-serif","subsets":["cyrillic","cyrillic-ext","latin","latin-ext","vietnamese"],"variants":["200","300","regular","500","600","700","800","900","200italic","300italic","italic","500italic","600italic","700italic","800italic","900italic"],"popularity":16},{"family":"DM Sans","category":"sans-serif","subsets":["latin","latin-ext"],"variants":["100","200","300","regular","500","600","700","800","900","100italic","200italic","300italic","italic","500italic","600italic","700italic","800italic","900italic"],"popularity":17},{"family":"Playfair Display","category":"serif","subsets":["cyrillic","latin","latin-ext","vietnamese"],"variants":["regular","500","600","700","800","900","italic","500italic","600italic","700italic","800italic","900italic"],"popularity":18},{"family":"Nunito Sans","category":"sans-serif","subsets":["cyrillic","cyrillic-ext","latin","latin-ext","vietnamese"],"variants":["200","300","regular","500","600","700","800","900","200italic","300italic","italic","500italic","600italic","700italic","800italic","900italic"],"popularity":19},{"family":"Rubik","category":"sans-serif","subsets":["arabic","cyrillic","cyrillic-ext","hebrew","latin","latin-ext"],"variants":["300","regular","500","600","700","800","900","300italic","italic","500italic","600italic","700italic","800italic","900italic"],"popularity":20},{"family":"Roboto Slab","category":"serif","subsets":["cyrillic","cyrillic-ext","greek","greek-ext","latin","latin-ext","vietnamese"],"variants":["100","200","300","regular","500","600","700","800","900"],"popularity":21},{"family":"Ubuntu","category":"sans-serif","subsets":["cyrillic","cyrillic-ext","greek","greek-ext","latin","latin-ext"],"variants":["300","300italic","regular","italic","500","500italic","700","700italic"],"popularity":22},{"family":"Noto Sans KR","category":"sans-serif","subsets":["cyrillic","korean","latin","latin-ext","vietnamese"],"variants":["100","200","300","regular","500","600","700","800","900"],"popularity":23},{"family":"Merriweather","category":"serif","subsets":["cyrillic","cyrillic-ext","latin","latin-ext","vietnamese"],"variants":["300","regular","500","600","700","800","900","300italic","italic","500italic","600italic","700italic","800italic","900italic"],"popularity":24},{"family":"Fjalla One","category":"sans-serif","subsets":["cyrillic-ext","latin","latin-ext","vietnamese"],"variants":["regular"],"popularity":25},{"family":"Work Sans","category":"sans-serif","subsets":["latin","latin-ext","vietnamese"],"variants":["100","200","300","regular","500","600","700","800","900","100italic","200italic","300italic","italic","500italic","600italic","700italic","800italic","900italic"],"popularity":26},{"family":"Archivo Black","category":"sans-serif","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":27},{"family":"Material Symbols Outlined","category":"monospace","subsets":["latin"],"variants":["100","200","300","regular","500","600","700"],"popularity":28},{"family":"Outfit","category":"sans-serif","subsets":["latin","latin-ext"],"variants":["100","200","300","regular","500","600","700","800","900"],"popularity":29},{"family":"PT Sans","category":"sans-serif","subsets":["cyrillic","cyrillic-ext","latin","latin-ext"],"variants":["regular","italic","700","700italic"],"popularity":30},{"family":"Kanit","category":"sans-serif","subsets":["latin","latin-ext","thai","vietnamese"],"variants":["100","100italic","200","200italic","300","300italic","regular","italic","500","500italic","600","600italic","700","700italic","800","800italic","900","900italic"],"popularity":31},{"family":"Manrope","category":"sans-serif","subsets":["cyrillic","cyrillic-ext","greek","latin","latin-ext","vietnamese"],"variants":["200","300","regular","500","600","700","800"],"popularity":32},{"family":"Noto Sans TC","category":"sans-serif","subsets":["chinese-traditional","cyrillic","latin","latin-ext","vietnamese"],"variants":["100","200","300","regular","500","600","700","800","900"],"popularity":33},{"family":"Mulish","category":"sans-serif","subsets":["cyrillic","cyrillic-ext","latin","latin-ext","vietnamese"],"variants":["200","300","regular","500","600","700","800","900","200italic","300italic","italic","500italic","600italic","700italic","800italic","900italic"],"popularity":34},{"family":"Lora","category":"serif","subsets":["cyrillic","cyrillic-ext","latin","latin-ext","math","symbols","vietnamese"],"variants":["regular","500","600","700","italic","500italic","600italic","700italic"],"popularity":35},{"family":"Prompt","category":"sans-serif","subsets":["latin","latin-ext","thai","vietnamese"],"variants":["100","100italic","200","200italic","300","300italic","regular","italic","500","500italic","600","600italic","700","700italic","800","800italic","900","900italic"],"popularity":36},{"family":"Figtree","category":"sans-serif","subsets":["latin","latin-ext"],"variants":["300","regular","500","600","700","800","900","300italic","italic","500italic","600italic","700italic","800italic","900italic"],"popularity":37},{"family":"Saira","category":"sans-serif","subsets":["latin","latin-ext","vietnamese"],"variants":["100","200","300","regular","500","600","700","800","900","100italic","200italic","300italic","italic","500italic","600italic","700italic","800italic","900italic"],"popularity":38},{"family":"Bebas Neue","category":"sans-serif","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":39},{"family":"Bricolage Grotesque","category":"sans-serif","subsets":["latin","latin-ext","vietnamese"],"variants":["200","300","regular","500","600","700","800"],"popularity":40},{"family":"Barlow","category":"sans-serif","subsets":["latin","latin-ext","vietnamese"],"variants":["100","100italic","200","200italic","300","300italic","regular","italic","500","500italic","600","600italic","700","700italic","800","800italic","900","900italic"],"popularity":41},{"family":"Quicksand","category":"sans-serif","subsets":["latin","latin-ext","vietnamese"],"variants":["300","regular","500","600","700"],"popularity":42},{"family":"Fira Sans","category":"sans-serif","subsets":["cyrillic","cyrillic-ext","greek","greek-ext","latin","latin-ext","vietnamese"],"variants":["100","100italic","200","200italic","300","300italic","regular","italic","500","500italic","600","600italic","700","700italic","800","800italic","900","900italic"],"popularity":43},{"family":"Share Tech","category":"sans-serif","subsets":["latin"],"variants":["regular"],"popularity":44},{"family":"Smooch Sans","category":"sans-serif","subsets":["latin","latin-ext","vietnamese"],"variants":["100","200","300","regular","500","600","700","800","900"],"popularity":45},{"family":"IBM Plex Sans","category":"sans-serif","subsets":["cyrillic","cyrillic-ext","greek","latin","latin-ext","vietnamese"],"variants":["100","200","300","regular","500","600","700","100italic","200italic","300italic","italic","500italic","600italic","700italic"],"popularity":46},{"family":"Source Sans 3","category":"sans-serif","subsets":["cyrillic","cyrillic-ext","greek","greek-ext","latin","latin-ext","vietnamese"],"variants":["200","300","regular","500","600","700","800","900","200italic","300italic","italic","500italic","600italic","700italic","800italic","900italic"],"popularity":47},{"family":"Heebo","category":"sans-serif","subsets":["hebrew","latin","latin-ext","math","symbols"],"variants":["100","200","300","regular","500","600","700","800","900"],"popularity":48},{"family":"Plus Jakarta Sans","category":"sans-serif","subsets":["cyrillic-ext","latin","latin-ext","vietnamese"],"variants":["200","300","regular","500","600","700","800","200italic","300italic","italic","500italic","600italic","700italic","800italic"],"popularity":49},{"family":"Titillium Web","category":"sans-serif","subsets":["latin","latin-ext"],"variants":["200","200italic","300","300italic","regular","italic","600","600italic","700","700italic","900"],"popularity":50},{"family":"Jost","category":"sans-serif","subsets":["cyrillic","latin","latin-ext"],"variants":["100","200","300","regular","500","600","700","800","900","100italic","200italic","300italic","italic","500italic","600italic","700italic","800italic","900italic"],"popularity":51},{"family":"Karla","category":"sans-serif","subsets":["latin","latin-ext"],"variants":["200","300","regular","500","600","700","800","200italic","300italic","italic","500italic","600italic","700italic","800italic"],"popularity":52},{"family":"Noto Serif","category":"serif","subsets":["cyrillic","cyrillic-ext","greek","greek-ext","latin","latin-ext","math","vietnamese"],"variants":["100","200","300","regular","500","600","700","800","900","100italic","200italic","300italic","italic","500italic","600italic","700italic","800italic","900italic"],"popularity":53},{"family":"PT Serif","category":"serif","subsets":["cyrillic","cyrillic-ext","latin","latin-ext"],"variants":["regular","italic","700","700italic"],"popularity":54},{"family":"Archivo","category":"sans-serif","subsets":["latin","latin-ext","vietnamese"],"variants":["100","200","300","regular","500","600","700","800","900","100italic","200italic","300italic","italic","500italic","600italic","700italic","800italic","900italic"],"popularity":55},{"family":"Material Icons Outlined","category":"monospace","subsets":["latin"],"variants":["regular"],"popularity":56},{"family":"Inconsolata","category":"monospace","subsets":["latin","latin-ext","vietnamese"],"variants":["200","300","regular","500","600","700","800","900"],"popularity":57},{"family":"Noto Color Emoji","category":"sans-serif","subsets":["emoji"],"variants":["regular"],"popularity":58},{"family":"Libre Baskerville","category":"serif","subsets":["latin","latin-ext"],"variants":["regular","500","600","700","italic","500italic","600italic","700italic"],"popularity":59},{"family":"Cairo","category":"sans-serif","subsets":["arabic","latin","latin-ext"],"variants":["200","300","regular","500","600","700","800","900"],"popularity":60},{"family":"Changa One","category":"display","subsets":["latin"],"variants":["regular","italic"],"popularity":61},{"family":"Dancing Script","category":"handwriting","subsets":["latin","latin-ext","vietnamese"],"variants":["regular","500","600","700"],"popularity":62},{"family":"Lobster Two","category":"display","subsets":["latin"],"variants":["regular","italic","700","700italic"],"popularity":63},{"family":"Source Code Pro","category":"monospace","subsets":["cyrillic","cyrillic-ext","greek","greek-ext","latin","latin-ext","vietnamese"],"variants":["200","300","regular","500","600","700","800","900","200italic","300italic","italic","500italic","600italic","700italic","800italic","900italic"],"popularity":64},{"family":"Noto Serif JP","category":"serif","subsets":["cyrillic","japanese","latin","latin-ext","vietnamese"],"variants":["200","300","regular","500","600","700","800","900"],"popularity":65},{"family":"Josefin Sans","category":"sans-serif","subsets":["latin","latin-ext","vietnamese"],"variants":["100","200","300","regular","500","600","700","100italic","200italic","300italic","italic","500italic","600italic","700italic"],"popularity":66},{"family":"Barlow Condensed","category":"sans-serif","subsets":["latin","latin-ext","vietnamese"],"variants":["100","100italic","200","200italic","300","300italic","regular","italic","500","500italic","600","600italic","700","700italic","800","800italic","900","900italic"],"popularity":67},{"family":"Noto Sans SC","category":"sans-serif","subsets":["chinese-simplified","cyrillic","latin","latin-ext","vietnamese"],"variants":["100","200","300","regular","500","600","700","800","900"],"popularity":68},{"family":"EB Garamond","category":"serif","subsets":["cyrillic","cyrillic-ext","greek","greek-ext","latin","latin-ext","vietnamese"],"variants":["regular","500","600","700","800","italic","500italic","600italic","700italic","800italic"],"popularity":69},{"family":"Libre Franklin","category":"sans-serif","subsets":["cyrillic","cyrillic-ext","latin","latin-ext","vietnamese"],"variants":["100","200","300","regular","500","600","700","800","900","100italic","200italic","300italic","italic","500italic","600italic","700italic","800italic","900italic"],"popularity":70},{"family":"Anton","category":"sans-serif","subsets":["latin","latin-ext","vietnamese"],"variants":["regular"],"popularity":71},{"family":"Nanum Gothic","category":"sans-serif","subsets":["korean","latin"],"variants":["regular","700","800"],"popularity":72},{"family":"Public Sans","category":"sans-serif","subsets":["latin","latin-ext","vietnamese"],"variants":["100","200","300","regular","500","600","700","800","900","100italic","200italic","300italic","italic","500italic","600italic","700italic","800italic","900italic"],"popularity":73},{"family":"Dosis","category":"sans-serif","subsets":["latin","latin-ext","vietnamese"],"variants":["200","300","regular","500","600","700","800"],"popularity":74},{"family":"Roboto Flex","category":"sans-serif","subsets":["cyrillic","cyrillic-ext","greek","latin","latin-ext","vietnamese"],"variants":["regular"],"popularity":75},{"family":"Ramabhadra","category":"sans-serif","subsets":["latin","telugu"],"variants":["regular"],"popularity":76},{"family":"Alfa Slab One","category":"display","subsets":["latin","latin-ext","vietnamese"],"variants":["regular"],"popularity":77},{"family":"Noto Sans Telugu","category":"sans-serif","subsets":["latin","latin-ext","telugu"],"variants":["100","200","300","regular","500","600","700","800","900"],"popularity":78},{"family":"Space Grotesk","category":"sans-serif","subsets":["latin","latin-ext","vietnamese"],"variants":["300","regular","500","600","700"],"popularity":79},{"family":"Cabin","category":"sans-serif","subsets":["latin","latin-ext","vietnamese"],"variants":["regular","500","600","700","italic","500italic","600italic","700italic"],"popularity":80},{"family":"Anek Telugu","category":"sans-serif","subsets":["latin","latin-ext","telugu"],"variants":["100","200","300","regular","500","600","700","800"],"popularity":81},{"family":"Assistant","category":"sans-serif","subsets":["hebrew","latin","latin-ext"],"variants":["200","300","regular","500","600","700","800"],"popularity":82},{"family":"Cormorant Garamond","category":"serif","subsets":["cyrillic","cyrillic-ext","latin","latin-ext","vietnamese"],"variants":["300","regular","500","600","700","300italic","italic","500italic","600italic","700italic"],"popularity":83},{"family":"Material Symbols Rounded","category":"monospace","subsets":["latin"],"variants":["100","200","300","regular","500","600","700"],"popularity":84},{"family":"Schibsted Grotesk","category":"sans-serif","subsets":["latin","latin-ext"],"variants":["regular","500","600","700","800","900","italic","500italic","600italic","700italic","800italic","900italic"],"popularity":85},{"family":"M PLUS Rounded 1c","category":"sans-serif","subsets":["cyrillic","cyrillic-ext","greek","greek-ext","hebrew","japanese","latin","latin-ext","vietnamese"],"variants":["100","300","regular","500","700","800","900"],"popularity":86},{"family":"Bitter","category":"serif","subsets":["cyrillic","cyrillic-ext","latin","latin-ext","vietnamese"],"variants":["100","200","300","regular","500","600","700","800","900","100italic","200italic","300italic","italic","500italic","600italic","700italic","800italic","900italic"],"popularity":87},{"family":"Material Icons Round","category":"monospace","subsets":["latin"],"variants":["regular"],"popularity":88},{"family":"Tajawal","category":"sans-serif","subsets":["arabic","latin"],"variants":["200","300","regular","500","700","800","900"],"popularity":89},{"family":"Instrument Serif","category":"serif","subsets":["latin","latin-ext"],"variants":["regular","italic"],"popularity":90},{"family":"Gravitas One","category":"display","subsets":["latin"],"variants":["regular"],"popularity":91},{"family":"Slabo 27px","category":"serif","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":92},{"family":"Hind Siliguri","category":"sans-serif","subsets":["bengali","latin","latin-ext"],"variants":["300","regular","500","600","700"],"popularity":93},{"family":"Pacifico","category":"handwriting","subsets":["cyrillic","cyrillic-ext","latin","latin-ext","vietnamese"],"variants":["regular"],"popularity":94},{"family":"Hind","category":"sans-serif","subsets":["devanagari","latin","latin-ext"],"variants":["300","regular","500","600","700"],"popularity":95},{"family":"Red Hat Display","category":"sans-serif","subsets":["latin","latin-ext"],"variants":["300","regular","500","600","700","800","900","300italic","italic","500italic","600italic","700italic","800italic","900italic"],"popularity":96},{"family":"Lexend","category":"sans-serif","subsets":["latin","latin-ext","vietnamese"],"variants":["100","200","300","regular","500","600","700","800","900"],"popularity":97},{"family":"Exo 2","category":"sans-serif","subsets":["cyrillic","cyrillic-ext","latin","latin-ext","vietnamese"],"variants":["100","200","300","regular","500","600","700","800","900","100italic","200italic","300italic","italic","500italic","600italic","700italic","800italic","900italic"],"popularity":98},{"family":"Inter Tight","category":"sans-serif","subsets":["cyrillic","cyrillic-ext","greek","greek-ext","latin","latin-ext","vietnamese"],"variants":["100","200","300","regular","500","600","700","800","900","100italic","200italic","300italic","italic","500italic","600italic","700italic","800italic","900italic"],"popularity":99},{"family":"Oxygen","category":"sans-serif","subsets":["latin","latin-ext"],"variants":["300","regular","700"],"popularity":100},{"family":"Rajdhani","category":"sans-serif","subsets":["devanagari","latin","latin-ext"],"variants":["300","regular","500","600","700"],"popularity":101},{"family":"Lobster","category":"display","subsets":["cyrillic","cyrillic-ext","latin","latin-ext","vietnamese"],"variants":["regular"],"popularity":102},{"family":"Arvo","category":"serif","subsets":["latin"],"variants":["regular","italic","700","700italic"],"popularity":103},{"family":"Crimson Text","category":"serif","subsets":["latin","latin-ext","vietnamese"],"variants":["regular","italic","600","600italic","700","700italic"],"popularity":104},{"family":"Sora","category":"sans-serif","subsets":["latin","latin-ext"],"variants":["100","200","300","regular","500","600","700","800"],"popularity":105},{"family":"Google Sans Flex","category":"sans-serif","subsets":["canadian-aboriginal","cherokee","latin","latin-ext","math","nushu","symbols","syriac","tifinagh","vietnamese"],"variants":["100","200","300","regular","500","600","700","800","900"],"popularity":106},{"family":"PT Sans Narrow","category":"sans-serif","subsets":["cyrillic","cyrillic-ext","latin","latin-ext"],"variants":["regular","700"],"popularity":107},{"family":"Urbanist","category":"sans-serif","subsets":["latin","latin-ext"],"variants":["100","200","300","regular","500","600","700","800","900","100italic","200italic","300italic","italic","500italic","600italic","700italic","800italic","900italic"],"popularity":108},{"family":"Orbitron","category":"sans-serif","subsets":["latin"],"variants":["regular","500","600","700","800","900"],"popularity":109},{"family":"Comfortaa","category":"display","subsets":["cyrillic","cyrillic-ext","greek","latin","latin-ext","vietnamese"],"variants":["300","regular","500","600","700"],"popularity":110},{"family":"Bungee","category":"display","subsets":["latin","latin-ext","vietnamese"],"variants":["regular"],"popularity":111},{"family":"Mukta","category":"sans-serif","subsets":["devanagari","latin","latin-ext"],"variants":["200","300","regular","500","600","700","800"],"popularity":112},{"family":"Fredoka","category":"sans-serif","subsets":["hebrew","latin","latin-ext"],"variants":["300","regular","500","600","700"],"popularity":113},{"family":"Merriweather Sans","category":"sans-serif","subsets":["cyrillic-ext","latin","latin-ext","vietnamese"],"variants":["300","regular","500","600","700","800","300italic","italic","500italic","600italic","700italic","800italic"],"popularity":114},{"family":"Caveat","category":"handwriting","subsets":["cyrillic","cyrillic-ext","latin","latin-ext"],"variants":["regular","500","600","700"],"popularity":115},{"family":"Almarai","category":"sans-serif","subsets":["arabic","latin"],"variants":["300","regular","700","800"],"popularity":116},{"family":"JetBrains Mono","category":"monospace","subsets":["cyrillic","cyrillic-ext","greek","latin","latin-ext","vietnamese"],"variants":["100","200","300","regular","500","600","700","800","100italic","200italic","300italic","italic","500italic","600italic","700italic","800italic"],"popularity":117},{"family":"Source Serif 4","category":"serif","subsets":["cyrillic","cyrillic-ext","greek","latin","latin-ext","vietnamese"],"variants":["200","300","regular","500","600","700","800","900","200italic","300italic","italic","500italic","600italic","700italic","800italic","900italic"],"popularity":118},{"family":"Abel","category":"sans-serif","subsets":["latin"],"variants":["regular"],"popularity":119},{"family":"Teko","category":"sans-serif","subsets":["devanagari","latin","latin-ext"],"variants":["300","regular","500","600","700"],"popularity":120},{"family":"DM Serif Display","category":"serif","subsets":["latin","latin-ext"],"variants":["regular","italic"],"popularity":121},{"family":"M PLUS 1p","category":"sans-serif","subsets":["cyrillic","cyrillic-ext","greek","greek-ext","hebrew","japanese","latin","latin-ext","vietnamese"],"variants":["100","300","regular","500","700","800","900"],"popularity":122},{"family":"Barlow Semi Condensed","category":"sans-serif","subsets":["latin","latin-ext","vietnamese"],"variants":["100","100italic","200","200italic","300","300italic","regular","italic","500","500italic","600","600italic","700","700italic","800","800italic","900","900italic"],"popularity":123},{"family":"Google Sans Code","category":"monospace","subsets":["adlam","canadian-aboriginal","cherokee","latin","latin-ext","math","old-permic","symbols","symbols2","syriac","vietnamese"],"variants":["300","regular","500","600","700","800","300italic","italic","500italic","600italic","700italic","800italic"],"popularity":124},{"family":"Material Icons Sharp","category":"monospace","subsets":["latin"],"variants":["regular"],"popularity":125},{"family":"Lilita One","category":"display","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":126},{"family":"Chakra Petch","category":"sans-serif","subsets":["latin","latin-ext","thai","vietnamese"],"variants":["300","300italic","regular","italic","500","500italic","600","600italic","700","700italic"],"popularity":127},{"family":"Overpass","category":"sans-serif","subsets":["cyrillic","cyrillic-ext","latin","latin-ext","vietnamese"],"variants":["100","200","300","regular","500","600","700","800","900","100italic","200italic","300italic","italic","500italic","600italic","700italic","800italic","900italic"],"popularity":128},{"family":"Play","category":"sans-serif","subsets":["cyrillic","cyrillic-ext","greek","latin","latin-ext","vietnamese"],"variants":["regular","700"],"popularity":129},{"family":"IBM Plex Mono","category":"monospace","subsets":["cyrillic","cyrillic-ext","latin","latin-ext","vietnamese"],"variants":["100","100italic","200","200italic","300","300italic","regular","italic","500","500italic","600","600italic","700","700italic"],"popularity":130},{"family":"Bodoni Moda","category":"serif","subsets":["latin","latin-ext","math","symbols"],"variants":["regular","500","600","700","800","900","italic","500italic","600italic","700italic","800italic","900italic"],"popularity":131},{"family":"Noto Sans Arabic","category":"sans-serif","subsets":["arabic","latin","latin-ext","math","symbols"],"variants":["100","200","300","regular","500","600","700","800","900"],"popularity":132},{"family":"Cinzel","category":"serif","subsets":["latin","latin-ext"],"variants":["regular","500","600","700","800","900"],"popularity":133},{"family":"IBM Plex Sans Arabic","category":"sans-serif","subsets":["arabic","cyrillic-ext","latin","latin-ext"],"variants":["100","200","300","regular","500","600","700"],"popularity":134},{"family":"Asap","category":"sans-serif","subsets":["latin","latin-ext","vietnamese"],"variants":["100","200","300","regular","500","600","700","800","900","100italic","200italic","300italic","italic","500italic","600italic","700italic","800italic","900italic"],"popularity":135},{"family":"Lexend Deca","category":"sans-serif","subsets":["latin","latin-ext","vietnamese"],"variants":["100","200","300","regular","500","600","700","800","900"],"popularity":136},{"family":"Zen Kaku Gothic New","category":"sans-serif","subsets":["cyrillic","japanese","latin","latin-ext"],"variants":["300","regular","500","700","900"],"popularity":137},{"family":"Material Icons Two Tone","category":"monospace","subsets":["latin"],"variants":["regular"],"popularity":138},{"family":"DM Mono","category":"monospace","subsets":["latin","latin-ext"],"variants":["300","300italic","regular","italic","500","500italic"],"popularity":139},{"family":"Abril Fatface","category":"display","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":140},{"family":"Marcellus","category":"serif","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":141},{"family":"Noto Sans Thai","category":"sans-serif","subsets":["latin","latin-ext","thai"],"variants":["100","200","300","regular","500","600","700","800","900"],"popularity":142},{"family":"Maven Pro","category":"sans-serif","subsets":["latin","latin-ext","vietnamese"],"variants":["regular","500","600","700","800","900"],"popularity":143},{"family":"Indie Flower","category":"handwriting","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":144},{"family":"Varela Round","category":"sans-serif","subsets":["hebrew","latin","latin-ext","vietnamese"],"variants":["regular"],"popularity":145},{"family":"Exo","category":"sans-serif","subsets":["latin","latin-ext","vietnamese"],"variants":["100","200","300","regular","500","600","700","800","900","100italic","200italic","300italic","italic","500italic","600italic","700italic","800italic","900italic"],"popularity":146},{"family":"Domine","category":"serif","subsets":["latin","latin-ext"],"variants":["regular","500","600","700"],"popularity":147},{"family":"Shadows Into Light","category":"handwriting","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":148},{"family":"Saira Condensed","category":"sans-serif","subsets":["latin","latin-ext","vietnamese"],"variants":["100","200","300","regular","500","600","700","800","900"],"popularity":149},{"family":"Questrial","category":"sans-serif","subsets":["latin","latin-ext","vietnamese"],"variants":["regular"],"popularity":150},{"family":"Nanum Myeongjo","category":"serif","subsets":["korean","latin"],"variants":["regular","700","800"],"popularity":151},{"family":"Geist","category":"sans-serif","subsets":["cyrillic","latin","latin-ext"],"variants":["100","200","300","regular","500","600","700","800","900"],"popularity":152},{"family":"Instrument Sans","category":"sans-serif","subsets":["latin","latin-ext"],"variants":["regular","500","600","700","italic","500italic","600italic","700italic"],"popularity":153},{"family":"Zilla Slab","category":"serif","subsets":["latin","latin-ext"],"variants":["300","300italic","regular","italic","500","500italic","600","600italic","700","700italic"],"popularity":154},{"family":"Fira Sans Condensed","category":"sans-serif","subsets":["cyrillic","cyrillic-ext","greek","greek-ext","latin","latin-ext","vietnamese"],"variants":["100","100italic","200","200italic","300","300italic","regular","italic","500","500italic","600","600italic","700","700italic","800","800italic","900","900italic"],"popularity":155},{"family":"Albert Sans","category":"sans-serif","subsets":["latin","latin-ext"],"variants":["100","200","300","regular","500","600","700","800","900","100italic","200italic","300italic","italic","500italic","600italic","700italic","800italic","900italic"],"popularity":156},{"family":"IBM Plex Serif","category":"serif","subsets":["cyrillic","cyrillic-ext","latin","latin-ext","vietnamese"],"variants":["100","100italic","200","200italic","300","300italic","regular","italic","500","500italic","600","600italic","700","700italic"],"popularity":157},{"family":"Be Vietnam Pro","category":"sans-serif","subsets":["latin","latin-ext","vietnamese"],"variants":["100","100italic","200","200italic","300","300italic","regular","italic","500","500italic","600","600italic","700","700italic","800","800italic","900","900italic"],"popularity":158},{"family":"Archivo Narrow","category":"sans-serif","subsets":["latin","latin-ext","vietnamese"],"variants":["regular","500","600","700","italic","500italic","600italic","700italic"],"popularity":159},{"family":"Onest","category":"sans-serif","subsets":["cyrillic","cyrillic-ext","latin","latin-ext"],"variants":["100","200","300","regular","500","600","700","800","900"],"popularity":160},{"family":"Alumni Sans","category":"sans-serif","subsets":["cyrillic","cyrillic-ext","latin","latin-ext","vietnamese"],"variants":["100","200","300","regular","500","600","700","800","900","100italic","200italic","300italic","italic","500italic","600italic","700italic","800italic","900italic"],"popularity":161},{"family":"Cormorant","category":"serif","subsets":["cyrillic","cyrillic-ext","latin","latin-ext","vietnamese"],"variants":["300","regular","500","600","700","300italic","italic","500italic","600italic","700italic"],"popularity":162},{"family":"Noto Serif KR","category":"serif","subsets":["cyrillic","korean","latin","latin-ext","vietnamese"],"variants":["200","300","regular","500","600","700","800","900"],"popularity":163},{"family":"Geologica","category":"sans-serif","subsets":["cyrillic","cyrillic-ext","greek","latin","latin-ext","vietnamese"],"variants":["100","200","300","regular","500","600","700","800","900"],"popularity":164},{"family":"Satisfy","category":"handwriting","subsets":["latin"],"variants":["regular"],"popularity":165},{"family":"Noto Kufi Arabic","category":"sans-serif","subsets":["arabic","latin","latin-ext","math","symbols"],"variants":["100","200","300","regular","500","600","700","800","900"],"popularity":166},{"family":"League Spartan","category":"sans-serif","subsets":["latin","latin-ext","vietnamese"],"variants":["100","200","300","regular","500","600","700","800","900"],"popularity":167},{"family":"Space Mono","category":"monospace","subsets":["latin","latin-ext","vietnamese"],"variants":["regular","italic","700","700italic"],"popularity":168},{"family":"Unbounded","category":"sans-serif","subsets":["cyrillic","cyrillic-ext","latin","latin-ext","vietnamese"],"variants":["200","300","regular","500","600","700","800","900"],"popularity":169},{"family":"Rowdies","category":"display","subsets":["latin","latin-ext","vietnamese"],"variants":["300","regular","700"],"popularity":170},{"family":"Luckiest Guy","category":"display","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":171},{"family":"Epilogue","category":"sans-serif","subsets":["latin","latin-ext","vietnamese"],"variants":["100","200","300","regular","500","600","700","800","900","100italic","200italic","300italic","italic","500italic","600italic","700italic","800italic","900italic"],"popularity":172},{"family":"Great Vibes","category":"handwriting","subsets":["cyrillic","cyrillic-ext","greek-ext","latin","latin-ext","vietnamese"],"variants":["regular"],"popularity":173},{"family":"Kalam","category":"handwriting","subsets":["devanagari","latin","latin-ext"],"variants":["300","regular","700"],"popularity":174},{"family":"Spectral","category":"serif","subsets":["cyrillic","cyrillic-ext","latin","latin-ext","vietnamese"],"variants":["200","200italic","300","300italic","regular","italic","500","500italic","600","600italic","700","700italic","800","800italic"],"popularity":175},{"family":"Noto Serif TC","category":"serif","subsets":["chinese-traditional","cyrillic","latin","latin-ext","vietnamese"],"variants":["200","300","regular","500","600","700","800","900"],"popularity":176},{"family":"Geist Mono","category":"monospace","subsets":["cyrillic","latin","latin-ext"],"variants":["100","200","300","regular","500","600","700","800","900"],"popularity":177},{"family":"Zen Maru Gothic","category":"sans-serif","subsets":["cyrillic","greek","japanese","latin","latin-ext"],"variants":["300","regular","500","700","900"],"popularity":178},{"family":"Newsreader","category":"serif","subsets":["latin","latin-ext","vietnamese"],"variants":["200","300","regular","500","600","700","800","200italic","300italic","italic","500italic","600italic","700italic","800italic"],"popularity":179},{"family":"ABeeZee","category":"sans-serif","subsets":["latin","latin-ext"],"variants":["regular","italic"],"popularity":180},{"family":"Fraunces","category":"serif","subsets":["latin","latin-ext","vietnamese"],"variants":["100","200","300","regular","500","600","700","800","900","100italic","200italic","300italic","italic","500italic","600italic","700italic","800italic","900italic"],"popularity":181},{"family":"Vollkorn","category":"serif","subsets":["cyrillic","cyrillic-ext","greek","latin","latin-ext","vietnamese"],"variants":["regular","500","600","700","800","900","italic","500italic","600italic","700italic","800italic","900italic"],"popularity":182},{"family":"Sofia Sans","category":"sans-serif","subsets":["cyrillic","cyrillic-ext","greek","latin","latin-ext"],"variants":["100","200","300","regular","500","600","700","800","900","100italic","200italic","300italic","italic","500italic","600italic","700italic","800italic","900italic"],"popularity":183},{"family":"Permanent Marker","category":"handwriting","subsets":["latin"],"variants":["regular"],"popularity":184},{"family":"Frank Ruhl Libre","category":"serif","subsets":["hebrew","latin","latin-ext"],"variants":["300","regular","500","600","700","800","900"],"popularity":185},{"family":"Rubik Mono One","category":"sans-serif","subsets":["cyrillic","latin","latin-ext"],"variants":["regular"],"popularity":186},{"family":"News Cycle","category":"sans-serif","subsets":["cyrillic","cyrillic-ext","greek","greek-ext","latin","latin-ext","vietnamese"],"variants":["regular","700"],"popularity":187},{"family":"Signika","category":"sans-serif","subsets":["latin","latin-ext","vietnamese"],"variants":["300","regular","500","600","700"],"popularity":188},{"family":"Roboto Serif","category":"serif","subsets":["cyrillic","cyrillic-ext","latin","latin-ext","vietnamese"],"variants":["100","200","300","regular","500","600","700","800","900","100italic","200italic","300italic","italic","500italic","600italic","700italic","800italic","900italic"],"popularity":189},{"family":"Noto Serif SC","category":"serif","subsets":["chinese-simplified","cyrillic","latin","latin-ext","vietnamese"],"variants":["200","300","regular","500","600","700","800","900"],"popularity":190},{"family":"Antic Slab","category":"serif","subsets":["latin"],"variants":["regular"],"popularity":191},{"family":"Syne","category":"sans-serif","subsets":["greek","latin","latin-ext"],"variants":["regular","500","600","700","800"],"popularity":192},{"family":"Sarabun","category":"sans-serif","subsets":["latin","latin-ext","thai","vietnamese"],"variants":["100","100italic","200","200italic","300","300italic","regular","italic","500","500italic","600","600italic","700","700italic","800","800italic"],"popularity":193},{"family":"Rethink Sans","category":"sans-serif","subsets":["latin","latin-ext"],"variants":["regular","500","600","700","800","italic","500italic","600italic","700italic","800italic"],"popularity":194},{"family":"Alegreya","category":"serif","subsets":["cyrillic","cyrillic-ext","greek","greek-ext","latin","latin-ext","vietnamese"],"variants":["regular","500","600","700","800","900","italic","500italic","600italic","700italic","800italic","900italic"],"popularity":195},{"family":"Hammersmith One","category":"sans-serif","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":196},{"family":"Yellowtail","category":"handwriting","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":197},{"family":"Gothic A1","category":"sans-serif","subsets":["cyrillic","cyrillic-ext","greek","greek-ext","korean","latin","latin-ext","vietnamese"],"variants":["100","200","300","regular","500","600","700","800","900"],"popularity":198},{"family":"Amiri","category":"serif","subsets":["arabic","latin","latin-ext"],"variants":["regular","italic","700","700italic"],"popularity":199},{"family":"Righteous","category":"display","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":200},{"family":"Hind Madurai","category":"sans-serif","subsets":["latin","latin-ext","tamil"],"variants":["300","regular","500","600","700"],"popularity":201},{"family":"Catamaran","category":"sans-serif","subsets":["latin","latin-ext","tamil"],"variants":["100","200","300","regular","500","600","700","800","900"],"popularity":202},{"family":"Libre Barcode 39","category":"display","subsets":["latin"],"variants":["regular"],"popularity":203},{"family":"Montserrat Alternates","category":"sans-serif","subsets":["cyrillic","cyrillic-ext","latin","latin-ext","vietnamese"],"variants":["100","100italic","200","200italic","300","300italic","regular","italic","500","500italic","600","600italic","700","700italic","800","800italic","900","900italic"],"popularity":204},{"family":"Acme","category":"sans-serif","subsets":["latin"],"variants":["regular"],"popularity":205},{"family":"Titan One","category":"display","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":206},{"family":"Alegreya Sans","category":"sans-serif","subsets":["cyrillic","cyrillic-ext","greek","greek-ext","latin","latin-ext","vietnamese"],"variants":["100","100italic","300","300italic","regular","italic","500","500italic","700","700italic","800","800italic","900","900italic"],"popularity":207},{"family":"Libre Caslon Text","category":"serif","subsets":["latin","latin-ext"],"variants":["regular","italic","700"],"popularity":208},{"family":"Viga","category":"sans-serif","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":209},{"family":"Literata","category":"serif","subsets":["cyrillic","cyrillic-ext","greek","greek-ext","latin","latin-ext","vietnamese"],"variants":["200","300","regular","500","600","700","800","900","200italic","300italic","italic","500italic","600italic","700italic","800italic","900italic"],"popularity":210},{"family":"Bangers","category":"display","subsets":["latin","latin-ext","vietnamese"],"variants":["regular"],"popularity":211},{"family":"Noto Sans Display","category":"sans-serif","subsets":["cyrillic","cyrillic-ext","greek","greek-ext","latin","latin-ext","vietnamese"],"variants":["100","200","300","regular","500","600","700","800","900","100italic","200italic","300italic","italic","500italic","600italic","700italic","800italic","900italic"],"popularity":212},{"family":"Press Start 2P","category":"display","subsets":["cyrillic","cyrillic-ext","greek","latin","latin-ext"],"variants":["regular"],"popularity":213},{"family":"Red Hat Text","category":"sans-serif","subsets":["latin","latin-ext"],"variants":["300","regular","500","600","700","300italic","italic","500italic","600italic","700italic"],"popularity":214},{"family":"Tinos","category":"serif","subsets":["cyrillic","cyrillic-ext","greek","greek-ext","hebrew","latin","latin-ext","vietnamese"],"variants":["regular","italic","700","700italic"],"popularity":215},{"family":"Advent Pro","category":"sans-serif","subsets":["cyrillic","cyrillic-ext","greek","latin","latin-ext"],"variants":["100","200","300","regular","500","600","700","800","900","100italic","200italic","300italic","italic","500italic","600italic","700italic","800italic","900italic"],"popularity":216},{"family":"Cardo","category":"serif","subsets":["gothic","greek","greek-ext","hebrew","latin","latin-ext","old-italic","runic"],"variants":["regular","italic","700"],"popularity":217},{"family":"Hanken Grotesk","category":"sans-serif","subsets":["cyrillic-ext","latin","latin-ext","vietnamese"],"variants":["100","200","300","regular","500","600","700","800","900","100italic","200italic","300italic","italic","500italic","600italic","700italic","800italic","900italic"],"popularity":218},{"family":"Alata","category":"sans-serif","subsets":["latin","latin-ext","vietnamese"],"variants":["regular"],"popularity":219},{"family":"Bree Serif","category":"serif","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":220},{"family":"Russo One","category":"sans-serif","subsets":["cyrillic","latin","latin-ext"],"variants":["regular"],"popularity":221},{"family":"Encode Sans","category":"sans-serif","subsets":["latin","latin-ext","vietnamese"],"variants":["100","200","300","regular","500","600","700","800","900"],"popularity":222},{"family":"DM Serif Text","category":"serif","subsets":["latin","latin-ext"],"variants":["regular","italic"],"popularity":223},{"family":"Yanone Kaffeesatz","category":"sans-serif","subsets":["cyrillic","cyrillic-ext","latin","latin-ext","math","symbols","vietnamese"],"variants":["200","300","regular","500","600","700"],"popularity":224},{"family":"Changa","category":"sans-serif","subsets":["arabic","latin","latin-ext"],"variants":["200","300","regular","500","600","700","800"],"popularity":225},{"family":"Sawarabi Mincho","category":"serif","subsets":["braille","japanese","latin","latin-ext"],"variants":["regular"],"popularity":226},{"family":"Shippori Mincho","category":"serif","subsets":["japanese","latin","latin-ext"],"variants":["regular","500","600","700","800"],"popularity":227},{"family":"Chivo","category":"sans-serif","subsets":["latin","latin-ext","vietnamese"],"variants":["100","200","300","regular","500","600","700","800","900","100italic","200italic","300italic","italic","500italic","600italic","700italic","800italic","900italic"],"popularity":228},{"family":"Patua One","category":"display","subsets":["latin"],"variants":["regular"],"popularity":229},{"family":"Amatic SC","category":"handwriting","subsets":["cyrillic","hebrew","latin","latin-ext","vietnamese"],"variants":["regular","700"],"popularity":230},{"family":"Baskervville","category":"serif","subsets":["latin","latin-ext"],"variants":["regular","500","600","700","italic","500italic","600italic","700italic"],"popularity":231},{"family":"Tenor Sans","category":"sans-serif","subsets":["cyrillic","latin","latin-ext"],"variants":["regular"],"popularity":232},{"family":"Prata","category":"serif","subsets":["cyrillic","cyrillic-ext","latin","vietnamese"],"variants":["regular"],"popularity":233},{"family":"Crimson Pro","category":"serif","subsets":["latin","latin-ext","vietnamese"],"variants":["200","300","regular","500","600","700","800","900","200italic","300italic","italic","500italic","600italic","700italic","800italic","900italic"],"popularity":234},{"family":"Readex Pro","category":"sans-serif","subsets":["arabic","latin","latin-ext","vietnamese"],"variants":["200","300","regular","500","600","700"],"popularity":235},{"family":"Noto Sans Tamil","category":"sans-serif","subsets":["latin","latin-ext","tamil"],"variants":["100","200","300","regular","500","600","700","800","900"],"popularity":236},{"family":"Lexend Giga","category":"sans-serif","subsets":["latin","latin-ext","vietnamese"],"variants":["100","200","300","regular","500","600","700","800","900"],"popularity":237},{"family":"Creepster","category":"display","subsets":["latin"],"variants":["regular"],"popularity":238},{"family":"Kumbh Sans","category":"sans-serif","subsets":["latin","latin-ext","math","symbols"],"variants":["100","200","300","regular","500","600","700","800","900"],"popularity":239},{"family":"Passion One","category":"display","subsets":["latin","latin-ext"],"variants":["regular","700","900"],"popularity":240},{"family":"Signika Negative","category":"sans-serif","subsets":["latin","latin-ext","vietnamese"],"variants":["300","regular","500","600","700"],"popularity":241},{"family":"Noto Sans Devanagari","category":"sans-serif","subsets":["devanagari","latin","latin-ext"],"variants":["100","200","300","regular","500","600","700","800","900"],"popularity":242},{"family":"Courgette","category":"handwriting","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":243},{"family":"Courier Prime","category":"monospace","subsets":["latin","latin-ext"],"variants":["regular","italic","700","700italic"],"popularity":244},{"family":"Sawarabi Gothic","category":"sans-serif","subsets":["cyrillic","japanese","latin","latin-ext","vietnamese"],"variants":["regular"],"popularity":245},{"family":"Gruppo","category":"sans-serif","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":246},{"family":"Alexandria","category":"sans-serif","subsets":["arabic","latin","latin-ext","vietnamese"],"variants":["100","200","300","regular","500","600","700","800","900"],"popularity":247},{"family":"Comic Neue","category":"handwriting","subsets":["latin"],"variants":["300","300italic","regular","italic","700","700italic"],"popularity":248},{"family":"Mitr","category":"sans-serif","subsets":["latin","latin-ext","thai","vietnamese"],"variants":["200","300","regular","500","600","700"],"popularity":249},{"family":"Actor","category":"sans-serif","subsets":["latin"],"variants":["regular"],"popularity":250},{"family":"Unna","category":"serif","subsets":["latin","latin-ext"],"variants":["regular","italic","700","700italic"],"popularity":251},{"family":"Material Symbols Sharp","category":"monospace","subsets":["latin"],"variants":["100","200","300","regular","500","600","700"],"popularity":252},{"family":"League Gothic","category":"sans-serif","subsets":["latin","latin-ext","vietnamese"],"variants":["regular"],"popularity":253},{"family":"Francois One","category":"sans-serif","subsets":["latin","latin-ext","vietnamese"],"variants":["regular"],"popularity":254},{"family":"Cantarell","category":"sans-serif","subsets":["latin","latin-ext"],"variants":["regular","italic","700","700italic"],"popularity":255},{"family":"Sorts Mill Goudy","category":"serif","subsets":["latin","latin-ext"],"variants":["regular","italic"],"popularity":256},{"family":"Atkinson Hyperlegible","category":"sans-serif","subsets":["latin","latin-ext"],"variants":["regular","italic","700","700italic"],"popularity":257},{"family":"Philosopher","category":"sans-serif","subsets":["cyrillic","cyrillic-ext","latin","latin-ext","vietnamese"],"variants":["regular","italic","700","700italic"],"popularity":258},{"family":"PT Sans Caption","category":"sans-serif","subsets":["cyrillic","cyrillic-ext","latin","latin-ext"],"variants":["regular","700"],"popularity":259},{"family":"Noto Naskh Arabic","category":"serif","subsets":["arabic","latin","latin-ext","math","symbols"],"variants":["regular","500","600","700"],"popularity":260},{"family":"BIZ UDPGothic","category":"sans-serif","subsets":["cyrillic","greek-ext","japanese","latin","latin-ext"],"variants":["regular","700"],"popularity":261},{"family":"Baloo 2","category":"display","subsets":["devanagari","latin","latin-ext","vietnamese"],"variants":["regular","500","600","700","800"],"popularity":262},{"family":"Martel","category":"serif","subsets":["devanagari","latin","latin-ext"],"variants":["200","300","regular","600","700","800","900"],"popularity":263},{"family":"Special Elite","category":"display","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":264},{"family":"Noticia Text","category":"serif","subsets":["latin","latin-ext","vietnamese"],"variants":["regular","italic","700","700italic"],"popularity":265},{"family":"Old Standard TT","category":"serif","subsets":["cyrillic","cyrillic-ext","latin","latin-ext","vietnamese"],"variants":["regular","italic","700"],"popularity":266},{"family":"Aleo","category":"serif","subsets":["latin","latin-ext","vietnamese"],"variants":["100","200","300","regular","500","600","700","800","900","100italic","200italic","300italic","italic","500italic","600italic","700italic","800italic","900italic"],"popularity":267},{"family":"Paytone One","category":"sans-serif","subsets":["latin","latin-ext","vietnamese"],"variants":["regular"],"popularity":268},{"family":"LINE Seed JP","category":"sans-serif","subsets":["cyrillic","greek-ext","japanese","latin","latin-ext"],"variants":["100","regular","700","800"],"popularity":269},{"family":"Saira Extra Condensed","category":"sans-serif","subsets":["latin","latin-ext","vietnamese"],"variants":["100","200","300","regular","500","600","700","800","900"],"popularity":270},{"family":"Yantramanav","category":"sans-serif","subsets":["devanagari","latin","latin-ext"],"variants":["100","300","regular","500","700","900"],"popularity":271},{"family":"Kaushan Script","category":"handwriting","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":272},{"family":"Krub","category":"sans-serif","subsets":["latin","latin-ext","thai","vietnamese"],"variants":["200","200italic","300","300italic","regular","italic","500","500italic","600","600italic","700","700italic"],"popularity":273},{"family":"Fugaz One","category":"display","subsets":["latin"],"variants":["regular"],"popularity":274},{"family":"Josefin Slab","category":"serif","subsets":["latin"],"variants":["100","200","300","regular","500","600","700","100italic","200italic","300italic","italic","500italic","600italic","700italic"],"popularity":275},{"family":"Crete Round","category":"serif","subsets":["latin","latin-ext"],"variants":["regular","italic"],"popularity":276},{"family":"Amaranth","category":"sans-serif","subsets":["latin"],"variants":["regular","italic","700","700italic"],"popularity":277},{"family":"Libre Bodoni","category":"serif","subsets":["latin","latin-ext","vietnamese"],"variants":["regular","500","600","700","italic","500italic","600italic","700italic"],"popularity":278},{"family":"Asap Condensed","category":"sans-serif","subsets":["latin","latin-ext","vietnamese"],"variants":["200","200italic","300","300italic","regular","italic","500","500italic","600","600italic","700","700italic","800","800italic","900","900italic"],"popularity":279},{"family":"Allura","category":"handwriting","subsets":["latin","latin-ext","vietnamese"],"variants":["regular"],"popularity":280},{"family":"Berkshire Swash","category":"handwriting","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":281},{"family":"Commissioner","category":"sans-serif","subsets":["cyrillic","cyrillic-ext","greek","latin","latin-ext","vietnamese"],"variants":["100","200","300","regular","500","600","700","800","900"],"popularity":282},{"family":"STIX Two Text","category":"serif","subsets":["cyrillic","cyrillic-ext","greek","latin","latin-ext","vietnamese"],"variants":["regular","500","600","700","italic","500italic","600italic","700italic"],"popularity":283},{"family":"Sen","category":"sans-serif","subsets":["latin","latin-ext"],"variants":["regular","500","600","700","800"],"popularity":284},{"family":"Nanum Gothic Coding","category":"handwriting","subsets":["korean","latin"],"variants":["regular","700"],"popularity":285},{"family":"Sacramento","category":"handwriting","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":286},{"family":"Chango","category":"display","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":287},{"family":"Rokkitt","category":"serif","subsets":["latin","latin-ext","vietnamese"],"variants":["100","200","300","regular","500","600","700","800","900","100italic","200italic","300italic","italic","500italic","600italic","700italic","800italic","900italic"],"popularity":288},{"family":"Fira Code","category":"monospace","subsets":["cyrillic","cyrillic-ext","greek","greek-ext","latin","latin-ext","symbols2"],"variants":["300","regular","500","600","700"],"popularity":289},{"family":"Rammetto One","category":"display","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":290},{"family":"Golos Text","category":"sans-serif","subsets":["cyrillic","cyrillic-ext","latin","latin-ext"],"variants":["regular","500","600","700","800","900"],"popularity":291},{"family":"Sanchez","category":"serif","subsets":["latin","latin-ext"],"variants":["regular","italic"],"popularity":292},{"family":"Patrick Hand","category":"handwriting","subsets":["latin","latin-ext","vietnamese"],"variants":["regular"],"popularity":293},{"family":"Eater","category":"display","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":294},{"family":"Share Tech Mono","category":"monospace","subsets":["latin"],"variants":["regular"],"popularity":295},{"family":"Antonio","category":"sans-serif","subsets":["latin","latin-ext"],"variants":["100","200","300","regular","500","600","700"],"popularity":296},{"family":"Radio Canada","category":"sans-serif","subsets":["canadian-aboriginal","latin","latin-ext","vietnamese"],"variants":["300","regular","500","600","700","300italic","italic","500italic","600italic","700italic"],"popularity":297},{"family":"Didact Gothic","category":"sans-serif","subsets":["cyrillic","cyrillic-ext","greek","greek-ext","latin","latin-ext"],"variants":["regular"],"popularity":298},{"family":"IBM Plex Sans Condensed","category":"sans-serif","subsets":["cyrillic-ext","latin","latin-ext","vietnamese"],"variants":["100","100italic","200","200italic","300","300italic","regular","italic","500","500italic","600","600italic","700","700italic"],"popularity":299},{"family":"Khand","category":"sans-serif","subsets":["devanagari","latin","latin-ext"],"variants":["300","regular","500","600","700"],"popularity":300},{"family":"Playfair","category":"serif","subsets":["cyrillic","cyrillic-ext","latin","latin-ext","vietnamese"],"variants":["300","regular","500","600","700","800","900","300italic","italic","500italic","600italic","700italic","800italic","900italic"],"popularity":301},{"family":"Forum","category":"display","subsets":["cyrillic","cyrillic-ext","latin","latin-ext"],"variants":["regular"],"popularity":302},{"family":"IBM Plex Sans JP","category":"sans-serif","subsets":["cyrillic","japanese","latin","latin-ext"],"variants":["100","200","300","regular","500","600","700"],"popularity":303},{"family":"Zen Old Mincho","category":"serif","subsets":["cyrillic","greek","japanese","latin","latin-ext"],"variants":["regular","500","600","700","900"],"popularity":304},{"family":"Playfair Display SC","category":"serif","subsets":["cyrillic","latin","latin-ext","vietnamese"],"variants":["regular","italic","700","700italic","900","900italic"],"popularity":305},{"family":"Ubuntu Condensed","category":"sans-serif","subsets":["cyrillic","cyrillic-ext","greek","greek-ext","latin","latin-ext"],"variants":["regular"],"popularity":306},{"family":"Oleo Script","category":"display","subsets":["latin","latin-ext"],"variants":["regular","700"],"popularity":307},{"family":"Kosugi Maru","category":"sans-serif","subsets":["cyrillic","japanese","latin","latin-ext"],"variants":["regular"],"popularity":308},{"family":"Jura","category":"sans-serif","subsets":["cyrillic","cyrillic-ext","greek","greek-ext","kayah-li","latin","latin-ext","vietnamese"],"variants":["300","regular","500","600","700"],"popularity":309},{"family":"M PLUS 1","category":"sans-serif","subsets":["japanese","latin","latin-ext","vietnamese"],"variants":["100","200","300","regular","500","600","700","800","900"],"popularity":310},{"family":"Noto Sans Hebrew","category":"sans-serif","subsets":["cyrillic-ext","greek-ext","hebrew","latin","latin-ext"],"variants":["100","200","300","regular","500","600","700","800","900"],"popularity":311},{"family":"El Messiri","category":"sans-serif","subsets":["arabic","cyrillic","latin","latin-ext"],"variants":["regular","500","600","700"],"popularity":312},{"family":"Noto Sans Mono","category":"sans-serif","subsets":["cyrillic","cyrillic-ext","greek","greek-ext","latin","latin-ext","vietnamese"],"variants":["100","200","300","regular","500","600","700","800","900"],"popularity":313},{"family":"Encode Sans Condensed","category":"sans-serif","subsets":["latin","latin-ext","vietnamese"],"variants":["100","200","300","regular","500","600","700","800","900"],"popularity":314},{"family":"Lusitana","category":"serif","subsets":["latin"],"variants":["regular","700"],"popularity":315},{"family":"VT323","category":"monospace","subsets":["latin","latin-ext","vietnamese"],"variants":["regular"],"popularity":316},{"family":"Quattrocento","category":"serif","subsets":["latin","latin-ext"],"variants":["regular","700"],"popularity":317},{"family":"PT Mono","category":"monospace","subsets":["cyrillic","cyrillic-ext","latin","latin-ext"],"variants":["regular"],"popularity":318},{"family":"Staatliches","category":"display","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":319},{"family":"Gloria Hallelujah","category":"handwriting","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":320},{"family":"Concert One","category":"display","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":321},{"family":"Quantico","category":"sans-serif","subsets":["latin"],"variants":["regular","italic","700","700italic"],"popularity":322},{"family":"Audiowide","category":"display","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":323},{"family":"Sofia Sans Condensed","category":"sans-serif","subsets":["cyrillic","cyrillic-ext","greek","latin","latin-ext"],"variants":["100","200","300","regular","500","600","700","800","900","100italic","200italic","300italic","italic","500italic","600italic","700italic","800italic","900italic"],"popularity":324},{"family":"Playball","category":"display","subsets":["latin","latin-ext","vietnamese"],"variants":["regular"],"popularity":325},{"family":"Oxanium","category":"display","subsets":["latin","latin-ext"],"variants":["200","300","regular","500","600","700","800"],"popularity":326},{"family":"Italianno","category":"handwriting","subsets":["latin","latin-ext","vietnamese"],"variants":["regular"],"popularity":327},{"family":"Gilda Display","category":"serif","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":328},{"family":"Balsamiq Sans","category":"display","subsets":["cyrillic","cyrillic-ext","latin","latin-ext"],"variants":["regular","italic","700","700italic"],"popularity":329},{"family":"Biryani","category":"sans-serif","subsets":["devanagari","latin","latin-ext"],"variants":["200","300","regular","600","700","800","900"],"popularity":330},{"family":"Architects Daughter","category":"handwriting","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":331},{"family":"Rock Salt","category":"handwriting","subsets":["latin"],"variants":["regular"],"popularity":332},{"family":"Parisienne","category":"handwriting","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":333},{"family":"Tangerine","category":"handwriting","subsets":["latin"],"variants":["regular","700"],"popularity":334},{"family":"Bai Jamjuree","category":"sans-serif","subsets":["latin","latin-ext","thai","vietnamese"],"variants":["200","200italic","300","300italic","regular","italic","500","500italic","600","600italic","700","700italic"],"popularity":335},{"family":"Saira Semi Condensed","category":"sans-serif","subsets":["latin","latin-ext","vietnamese"],"variants":["100","200","300","regular","500","600","700","800","900"],"popularity":336},{"family":"Gelasio","category":"serif","subsets":["latin","latin-ext","vietnamese"],"variants":["regular","500","600","700","italic","500italic","600italic","700italic"],"popularity":337},{"family":"Pathway Gothic One","category":"sans-serif","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":338},{"family":"Quattrocento Sans","category":"sans-serif","subsets":["latin","latin-ext"],"variants":["regular","italic","700","700italic"],"popularity":339},{"family":"Merienda","category":"handwriting","subsets":["latin","latin-ext","vietnamese"],"variants":["300","regular","500","600","700","800","900"],"popularity":340},{"family":"Neuton","category":"serif","subsets":["latin","latin-ext"],"variants":["200","300","regular","italic","700","800"],"popularity":341},{"family":"Dela Gothic One","category":"display","subsets":["cyrillic","greek","japanese","latin","latin-ext","vietnamese"],"variants":["regular"],"popularity":342},{"family":"Poiret One","category":"display","subsets":["cyrillic","latin","latin-ext"],"variants":["regular"],"popularity":343},{"family":"Monoton","category":"display","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":344},{"family":"Noto Sans Bengali","category":"sans-serif","subsets":["bengali","latin","latin-ext"],"variants":["100","200","300","regular","500","600","700","800","900"],"popularity":345},{"family":"Gabarito","category":"display","subsets":["latin","latin-ext"],"variants":["regular","500","600","700","800","900"],"popularity":346},{"family":"Cookie","category":"handwriting","subsets":["latin"],"variants":["regular"],"popularity":347},{"family":"Cinzel Decorative","category":"display","subsets":["latin","latin-ext"],"variants":["regular","700","900"],"popularity":348},{"family":"Homemade Apple","category":"handwriting","subsets":["latin"],"variants":["regular"],"popularity":349},{"family":"Yeseva One","category":"display","subsets":["cyrillic","cyrillic-ext","latin","latin-ext","vietnamese"],"variants":["regular"],"popularity":350},{"family":"Abhaya Libre","category":"serif","subsets":["latin","latin-ext","sinhala"],"variants":["regular","500","600","700","800"],"popularity":351},{"family":"Calistoga","category":"display","subsets":["latin","latin-ext","vietnamese"],"variants":["regular"],"popularity":352},{"family":"Amita","category":"handwriting","subsets":["devanagari","latin","latin-ext"],"variants":["regular","700"],"popularity":353},{"family":"Zen Kaku Gothic Antique","category":"sans-serif","subsets":["cyrillic","japanese","latin","latin-ext"],"variants":["300","regular","500","700","900"],"popularity":354},{"family":"Mukta Malar","category":"sans-serif","subsets":["latin","latin-ext","tamil"],"variants":["200","300","regular","500","600","700","800"],"popularity":355},{"family":"Nanum Pen Script","category":"handwriting","subsets":["korean","latin"],"variants":["regular"],"popularity":356},{"family":"Arsenal","category":"sans-serif","subsets":["cyrillic","cyrillic-ext","latin","latin-ext","vietnamese"],"variants":["regular","italic","700","700italic"],"popularity":357},{"family":"Alex Brush","category":"handwriting","subsets":["latin","latin-ext","vietnamese"],"variants":["regular"],"popularity":358},{"family":"Istok Web","category":"sans-serif","subsets":["cyrillic","cyrillic-ext","latin","latin-ext"],"variants":["regular","italic","700","700italic"],"popularity":359},{"family":"Noto Nastaliq Urdu","category":"serif","subsets":["arabic","latin","latin-ext"],"variants":["regular","500","600","700"],"popularity":360},{"family":"Blinker","category":"sans-serif","subsets":["latin","latin-ext"],"variants":["100","200","300","regular","600","700","800","900"],"popularity":361},{"family":"Fira Sans Extra Condensed","category":"sans-serif","subsets":["cyrillic","cyrillic-ext","greek","greek-ext","latin","latin-ext","vietnamese"],"variants":["100","100italic","200","200italic","300","300italic","regular","italic","500","500italic","600","600italic","700","700italic","800","800italic","900","900italic"],"popularity":362},{"family":"Bad Script","category":"handwriting","subsets":["cyrillic","cyrillic-ext","latin","latin-ext","vietnamese"],"variants":["regular"],"popularity":363},{"family":"Faustina","category":"serif","subsets":["latin","latin-ext","vietnamese"],"variants":["300","regular","500","600","700","800","300italic","italic","500italic","600italic","700italic","800italic"],"popularity":364},{"family":"Mada","category":"sans-serif","subsets":["arabic","latin","latin-ext"],"variants":["200","300","regular","500","600","700","800","900"],"popularity":365},{"family":"Pinyon Script","category":"handwriting","subsets":["latin","latin-ext","vietnamese"],"variants":["regular"],"popularity":366},{"family":"Noto Serif Bengali","category":"serif","subsets":["bengali","latin","latin-ext"],"variants":["100","200","300","regular","500","600","700","800","900"],"popularity":367},{"family":"Cuprum","category":"sans-serif","subsets":["cyrillic","cyrillic-ext","latin","latin-ext","vietnamese"],"variants":["regular","500","600","700","italic","500italic","600italic","700italic"],"popularity":368},{"family":"Hind Guntur","category":"sans-serif","subsets":["latin","latin-ext","telugu"],"variants":["300","regular","500","600","700"],"popularity":369},{"family":"Reenie Beanie","category":"handwriting","subsets":["latin"],"variants":["regular"],"popularity":370},{"family":"Goldman","category":"display","subsets":["latin","latin-ext","vietnamese"],"variants":["regular","700"],"popularity":371},{"family":"Monda","category":"sans-serif","subsets":["latin","latin-ext","vietnamese"],"variants":["regular","500","600","700"],"popularity":372},{"family":"Material Symbols","category":"monospace","subsets":["latin"],"variants":["100","200","300","regular","500","600","700"],"popularity":373},{"family":"Bowlby One SC","category":"display","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":374},{"family":"Cormorant Infant","category":"serif","subsets":["cyrillic","cyrillic-ext","latin","latin-ext","vietnamese"],"variants":["300","regular","500","600","700","300italic","italic","500italic","600italic","700italic"],"popularity":375},{"family":"Lalezar","category":"sans-serif","subsets":["arabic","latin","latin-ext","vietnamese"],"variants":["regular"],"popularity":376},{"family":"Alice","category":"serif","subsets":["cyrillic","cyrillic-ext","latin","latin-ext"],"variants":["regular"],"popularity":377},{"family":"Belanosima","category":"sans-serif","subsets":["latin","latin-ext"],"variants":["regular","600","700"],"popularity":378},{"family":"Volkhov","category":"serif","subsets":["latin"],"variants":["regular","italic","700","700italic"],"popularity":379},{"family":"Black Han Sans","category":"sans-serif","subsets":["korean","latin"],"variants":["regular"],"popularity":380},{"family":"Delius","category":"handwriting","subsets":["latin"],"variants":["regular"],"popularity":381},{"family":"Sriracha","category":"handwriting","subsets":["latin","latin-ext","thai","vietnamese"],"variants":["regular"],"popularity":382},{"family":"Squada One","category":"display","subsets":["latin"],"variants":["regular"],"popularity":383},{"family":"Sofia Sans Extra Condensed","category":"sans-serif","subsets":["cyrillic","cyrillic-ext","greek","latin","latin-ext"],"variants":["100","200","300","regular","500","600","700","800","900","100italic","200italic","300italic","italic","500italic","600italic","700italic","800italic","900italic"],"popularity":384},{"family":"Lateef","category":"serif","subsets":["arabic","latin","latin-ext"],"variants":["200","300","regular","500","600","700","800"],"popularity":385},{"family":"Petrona","category":"serif","subsets":["latin","latin-ext","vietnamese"],"variants":["100","200","300","regular","500","600","700","800","900","100italic","200italic","300italic","italic","500italic","600italic","700italic","800italic","900italic"],"popularity":386},{"family":"Noto Sans HK","category":"sans-serif","subsets":["chinese-hongkong","cyrillic","latin","latin-ext","vietnamese"],"variants":["100","200","300","regular","500","600","700","800","900"],"popularity":387},{"family":"Noto Sans Symbols","category":"sans-serif","subsets":["latin","latin-ext","symbols"],"variants":["100","200","300","regular","500","600","700","800","900"],"popularity":388},{"family":"Anonymous Pro","category":"monospace","subsets":["cyrillic","greek","latin","latin-ext"],"variants":["regular","italic","700","700italic"],"popularity":389},{"family":"Racing Sans One","category":"display","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":390},{"family":"Handlee","category":"handwriting","subsets":["latin"],"variants":["regular"],"popularity":391},{"family":"Noto Serif Display","category":"serif","subsets":["cyrillic","cyrillic-ext","greek","greek-ext","latin","latin-ext","vietnamese"],"variants":["100","200","300","regular","500","600","700","800","900","100italic","200italic","300italic","italic","500italic","600italic","700italic","800italic","900italic"],"popularity":392},{"family":"Rakkas","category":"display","subsets":["arabic","latin","latin-ext"],"variants":["regular"],"popularity":393},{"family":"Afacad","category":"sans-serif","subsets":["cyrillic-ext","latin","latin-ext","math","symbols","vietnamese"],"variants":["regular","500","600","700","italic","500italic","600italic","700italic"],"popularity":394},{"family":"Mona Sans","category":"sans-serif","subsets":["latin","latin-ext","vietnamese"],"variants":["200","300","regular","500","600","700","800","900","200italic","300italic","italic","500italic","600italic","700italic","800italic","900italic"],"popularity":395},{"family":"Ropa Sans","category":"sans-serif","subsets":["latin","latin-ext"],"variants":["regular","italic"],"popularity":396},{"family":"Lustria","category":"serif","subsets":["latin"],"variants":["regular"],"popularity":397},{"family":"Vidaloka","category":"serif","subsets":["latin"],"variants":["regular"],"popularity":398},{"family":"Varela","category":"sans-serif","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":399},{"family":"Itim","category":"handwriting","subsets":["latin","latin-ext","thai","vietnamese"],"variants":["regular"],"popularity":400},{"family":"Zeyada","category":"handwriting","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":401},{"family":"Wix Madefor Text","category":"sans-serif","subsets":["cyrillic","cyrillic-ext","latin","latin-ext","vietnamese"],"variants":["regular","italic","500","500italic","600","600italic","700","700italic","800","800italic"],"popularity":402},{"family":"Unica One","category":"display","subsets":["latin","latin-ext","vietnamese"],"variants":["regular"],"popularity":403},{"family":"Reem Kufi","category":"sans-serif","subsets":["arabic","latin","latin-ext","vietnamese"],"variants":["regular","500","600","700"],"popularity":404},{"family":"Belleza","category":"sans-serif","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":405},{"family":"GFS Didot","category":"serif","subsets":["greek","greek-ext","latin","vietnamese"],"variants":["regular"],"popularity":406},{"family":"Caveat Brush","category":"handwriting","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":407},{"family":"Ubuntu Mono","category":"monospace","subsets":["cyrillic","cyrillic-ext","greek","greek-ext","latin","latin-ext"],"variants":["regular","italic","700","700italic"],"popularity":408},{"family":"Carter One","category":"display","subsets":["latin"],"variants":["regular"],"popularity":409},{"family":"Kaisei Decol","category":"serif","subsets":["cyrillic","japanese","latin","latin-ext"],"variants":["regular","500","700"],"popularity":410},{"family":"Andada Pro","category":"serif","subsets":["latin","latin-ext","vietnamese"],"variants":["regular","500","600","700","800","italic","500italic","600italic","700italic","800italic"],"popularity":411},{"family":"Taviraj","category":"serif","subsets":["latin","latin-ext","thai","vietnamese"],"variants":["100","100italic","200","200italic","300","300italic","regular","italic","500","500italic","600","600italic","700","700italic","800","800italic","900","900italic"],"popularity":412},{"family":"Eczar","category":"serif","subsets":["devanagari","greek","greek-ext","latin","latin-ext"],"variants":["regular","500","600","700","800"],"popularity":413},{"family":"Ultra","category":"serif","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":414},{"family":"M PLUS 2","category":"sans-serif","subsets":["japanese","latin","latin-ext","vietnamese"],"variants":["100","200","300","regular","500","600","700","800","900"],"popularity":415},{"family":"Pangolin","category":"handwriting","subsets":["cyrillic","cyrillic-ext","latin","latin-ext","vietnamese"],"variants":["regular"],"popularity":416},{"family":"Arapey","category":"serif","subsets":["latin"],"variants":["regular","italic"],"popularity":417},{"family":"Syncopate","category":"sans-serif","subsets":["latin","latin-ext"],"variants":["regular","700"],"popularity":418},{"family":"Hind Vadodara","category":"sans-serif","subsets":["gujarati","latin","latin-ext"],"variants":["300","regular","500","600","700"],"popularity":419},{"family":"Sofia","category":"handwriting","subsets":["latin"],"variants":["regular"],"popularity":420},{"family":"IBM Plex Sans Thai","category":"sans-serif","subsets":["cyrillic-ext","latin","latin-ext","thai"],"variants":["100","200","300","regular","500","600","700"],"popularity":421},{"family":"Murecho","category":"sans-serif","subsets":["cyrillic","cyrillic-ext","greek","japanese","latin","latin-ext"],"variants":["100","200","300","regular","500","600","700","800","900"],"popularity":422},{"family":"Alegreya Sans SC","category":"sans-serif","subsets":["cyrillic","cyrillic-ext","greek","greek-ext","latin","latin-ext","vietnamese"],"variants":["100","100italic","300","300italic","regular","italic","500","500italic","700","700italic","800","800italic","900","900italic"],"popularity":423},{"family":"Hachi Maru Pop","category":"handwriting","subsets":["cyrillic","japanese","latin","latin-ext"],"variants":["regular"],"popularity":424},{"family":"Michroma","category":"sans-serif","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":425},{"family":"Reddit Sans","category":"sans-serif","subsets":["latin","latin-ext","vietnamese"],"variants":["200","300","regular","500","600","700","800","900","200italic","300italic","italic","500italic","600italic","700italic","800italic","900italic"],"popularity":426},{"family":"Cedarville Cursive","category":"handwriting","subsets":["latin"],"variants":["regular"],"popularity":427},{"family":"Sansita","category":"sans-serif","subsets":["latin","latin-ext"],"variants":["regular","italic","700","700italic","800","800italic","900","900italic"],"popularity":428},{"family":"Akshar","category":"sans-serif","subsets":["devanagari","latin","latin-ext"],"variants":["300","regular","500","600","700"],"popularity":429},{"family":"ZCOOL QingKe HuangYou","category":"sans-serif","subsets":["chinese-simplified","latin"],"variants":["regular"],"popularity":430},{"family":"Black Ops One","category":"display","subsets":["cyrillic-ext","latin","latin-ext","vietnamese"],"variants":["regular"],"popularity":431},{"family":"Potta One","category":"display","subsets":["japanese","latin","latin-ext","vietnamese"],"variants":["regular"],"popularity":432},{"family":"Wix Madefor Display","category":"sans-serif","subsets":["cyrillic","cyrillic-ext","latin","latin-ext","vietnamese"],"variants":["regular","500","600","700","800"],"popularity":433},{"family":"Chewy","category":"display","subsets":["latin"],"variants":["regular"],"popularity":434},{"family":"Ruda","category":"sans-serif","subsets":["cyrillic","latin","latin-ext","vietnamese"],"variants":["regular","500","600","700","800","900"],"popularity":435},{"family":"Nothing You Could Do","category":"handwriting","subsets":["latin"],"variants":["regular"],"popularity":436},{"family":"Leckerli One","category":"handwriting","subsets":["latin"],"variants":["regular"],"popularity":437},{"family":"Fira Mono","category":"monospace","subsets":["cyrillic","cyrillic-ext","greek","greek-ext","latin","latin-ext","symbols2"],"variants":["regular","500","700"],"popularity":438},{"family":"Aboreto","category":"display","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":439},{"family":"Economica","category":"sans-serif","subsets":["latin","latin-ext"],"variants":["regular","italic","700","700italic"],"popularity":440},{"family":"Lemonada","category":"display","subsets":["arabic","latin","latin-ext","vietnamese"],"variants":["300","regular","500","600","700"],"popularity":441},{"family":"Cousine","category":"monospace","subsets":["cyrillic","cyrillic-ext","greek","greek-ext","hebrew","latin","latin-ext","vietnamese"],"variants":["regular","italic","700","700italic"],"popularity":442},{"family":"Nixie One","category":"display","subsets":["latin"],"variants":["regular"],"popularity":443},{"family":"Gudea","category":"sans-serif","subsets":["latin","latin-ext"],"variants":["regular","italic","700"],"popularity":444},{"family":"Shippori Mincho B1","category":"serif","subsets":["japanese","latin","latin-ext"],"variants":["regular","500","600","700","800"],"popularity":445},{"family":"Pontano Sans","category":"sans-serif","subsets":["latin","latin-ext"],"variants":["300","regular","500","600","700"],"popularity":446},{"family":"Bona Nova SC","category":"serif","subsets":["cyrillic","cyrillic-ext","greek","hebrew","latin","latin-ext","vietnamese"],"variants":["regular","italic","700"],"popularity":447},{"family":"Julius Sans One","category":"sans-serif","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":448},{"family":"Niramit","category":"sans-serif","subsets":["latin","latin-ext","thai","vietnamese"],"variants":["200","200italic","300","300italic","regular","italic","500","500italic","600","600italic","700","700italic"],"popularity":449},{"family":"Tilt Warp","category":"display","subsets":["latin","latin-ext","vietnamese"],"variants":["regular"],"popularity":450},{"family":"Bevan","category":"serif","subsets":["latin","latin-ext","vietnamese"],"variants":["regular","italic"],"popularity":451},{"family":"Pridi","category":"serif","subsets":["latin","latin-ext","thai","vietnamese"],"variants":["200","300","regular","500","600","700"],"popularity":452},{"family":"Martel Sans","category":"sans-serif","subsets":["devanagari","latin","latin-ext"],"variants":["200","300","regular","600","700","800","900"],"popularity":453},{"family":"Nanum Brush Script","category":"handwriting","subsets":["korean","latin"],"variants":["regular"],"popularity":454},{"family":"Anek Bangla","category":"sans-serif","subsets":["bengali","latin","latin-ext"],"variants":["100","200","300","regular","500","600","700","800"],"popularity":455},{"family":"Mr Dafoe","category":"handwriting","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":456},{"family":"Khula","category":"sans-serif","subsets":["devanagari","latin","latin-ext"],"variants":["300","regular","600","700","800"],"popularity":457},{"family":"Funnel Sans","category":"sans-serif","subsets":["latin","latin-ext"],"variants":["300","regular","500","600","700","800","300italic","italic","500italic","600italic","700italic","800italic"],"popularity":458},{"family":"Jua","category":"sans-serif","subsets":["korean","latin"],"variants":["regular"],"popularity":459},{"family":"Gochi Hand","category":"handwriting","subsets":["latin"],"variants":["regular"],"popularity":460},{"family":"Damion","category":"handwriting","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":461},{"family":"Pirata One","category":"display","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":462},{"family":"Adamina","category":"serif","subsets":["latin"],"variants":["regular"],"popularity":463},{"family":"MuseoModerno","category":"display","subsets":["latin","latin-ext","vietnamese"],"variants":["100","200","300","regular","500","600","700","800","900","100italic","200italic","300italic","italic","500italic","600italic","700italic","800italic","900italic"],"popularity":464},{"family":"Marck Script","category":"handwriting","subsets":["cyrillic","latin","latin-ext"],"variants":["regular"],"popularity":465},{"family":"Poller One","category":"display","subsets":["latin"],"variants":["regular"],"popularity":466},{"family":"Londrina Solid","category":"display","subsets":["latin"],"variants":["100","300","regular","900"],"popularity":467},{"family":"Yrsa","category":"serif","subsets":["latin","latin-ext","vietnamese"],"variants":["300","regular","500","600","700","300italic","italic","500italic","600italic","700italic"],"popularity":468},{"family":"Noto Sans Malayalam","category":"sans-serif","subsets":["latin","latin-ext","malayalam"],"variants":["100","200","300","regular","500","600","700","800","900"],"popularity":469},{"family":"Averia Serif Libre","category":"display","subsets":["latin"],"variants":["300","300italic","regular","italic","700","700italic"],"popularity":470},{"family":"Yuji Mai","category":"serif","subsets":["cyrillic","japanese","latin","latin-ext"],"variants":["regular"],"popularity":471},{"family":"Boogaloo","category":"display","subsets":["latin"],"variants":["regular"],"popularity":472},{"family":"Secular One","category":"sans-serif","subsets":["hebrew","latin","latin-ext"],"variants":["regular"],"popularity":473},{"family":"Kiwi Maru","category":"serif","subsets":["cyrillic","japanese","latin","latin-ext"],"variants":["300","regular","500"],"popularity":474},{"family":"Cabin Condensed","category":"sans-serif","subsets":["latin","latin-ext","vietnamese"],"variants":["regular","500","600","700"],"popularity":475},{"family":"Italiana","category":"sans-serif","subsets":["latin"],"variants":["regular"],"popularity":476},{"family":"Allerta Stencil","category":"sans-serif","subsets":["latin"],"variants":["regular"],"popularity":477},{"family":"Suez One","category":"serif","subsets":["hebrew","latin","latin-ext"],"variants":["regular"],"popularity":478},{"family":"Cormorant Upright","category":"serif","subsets":["latin","latin-ext","vietnamese"],"variants":["300","regular","500","600","700"],"popularity":479},{"family":"Limelight","category":"display","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":480},{"family":"Charm","category":"handwriting","subsets":["latin","latin-ext","thai","vietnamese"],"variants":["regular","700"],"popularity":481},{"family":"Do Hyeon","category":"sans-serif","subsets":["korean","latin"],"variants":["regular"],"popularity":482},{"family":"Judson","category":"serif","subsets":["latin","latin-ext","vietnamese"],"variants":["regular","italic","700"],"popularity":483},{"family":"Besley","category":"serif","subsets":["latin","latin-ext"],"variants":["regular","500","600","700","800","900","italic","500italic","600italic","700italic","800italic","900italic"],"popularity":484},{"family":"Basic","category":"sans-serif","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":485},{"family":"Bungee Spice","category":"display","subsets":["latin","latin-ext","vietnamese"],"variants":["regular"],"popularity":486},{"family":"Host Grotesk","category":"sans-serif","subsets":["latin","latin-ext"],"variants":["300","regular","500","600","700","800","300italic","italic","500italic","600italic","700italic","800italic"],"popularity":487},{"family":"K2D","category":"sans-serif","subsets":["latin","latin-ext","thai","vietnamese"],"variants":["100","100italic","200","200italic","300","300italic","regular","italic","500","500italic","600","600italic","700","700italic","800","800italic"],"popularity":488},{"family":"Mandali","category":"sans-serif","subsets":["latin","telugu"],"variants":["regular"],"popularity":489},{"family":"Shrikhand","category":"display","subsets":["gujarati","latin","latin-ext"],"variants":["regular"],"popularity":490},{"family":"BenchNine","category":"sans-serif","subsets":["latin","latin-ext"],"variants":["300","regular","700"],"popularity":491},{"family":"Kreon","category":"serif","subsets":["latin","latin-ext"],"variants":["300","regular","500","600","700"],"popularity":492},{"family":"Mrs Saint Delafield","category":"handwriting","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":493},{"family":"Just Another Hand","category":"handwriting","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":494},{"family":"Bowlby One","category":"display","subsets":["latin"],"variants":["regular"],"popularity":495},{"family":"Georama","category":"sans-serif","subsets":["latin","latin-ext","vietnamese"],"variants":["100","200","300","regular","500","600","700","800","900","100italic","200italic","300italic","italic","500italic","600italic","700italic","800italic","900italic"],"popularity":496},{"family":"Nova Square","category":"display","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":497},{"family":"REM","category":"sans-serif","subsets":["latin","latin-ext","vietnamese"],"variants":["100","200","300","regular","500","600","700","800","900","100italic","200italic","300italic","italic","500italic","600italic","700italic","800italic","900italic"],"popularity":498},{"family":"Caprasimo","category":"display","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":499},{"family":"Athiti","category":"sans-serif","subsets":["latin","latin-ext","thai","vietnamese"],"variants":["200","300","regular","500","600","700"],"popularity":500},{"family":"Ovo","category":"serif","subsets":["latin"],"variants":["regular"],"popularity":501},{"family":"Alef","category":"sans-serif","subsets":["hebrew","latin"],"variants":["regular","700"],"popularity":502},{"family":"Days One","category":"sans-serif","subsets":["cyrillic","latin"],"variants":["regular"],"popularity":503},{"family":"Laila","category":"serif","subsets":["devanagari","latin","latin-ext"],"variants":["300","regular","500","600","700"],"popularity":504},{"family":"Pragati Narrow","category":"sans-serif","subsets":["devanagari","latin","latin-ext"],"variants":["regular","700"],"popularity":505},{"family":"Covered By Your Grace","category":"handwriting","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":506},{"family":"Fredericka the Great","category":"display","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":507},{"family":"Grandstander","category":"display","subsets":["latin","latin-ext","vietnamese"],"variants":["100","200","300","regular","500","600","700","800","900","100italic","200italic","300italic","italic","500italic","600italic","700italic","800italic","900italic"],"popularity":508},{"family":"Spline Sans","category":"sans-serif","subsets":["latin","latin-ext"],"variants":["300","regular","500","600","700"],"popularity":509},{"family":"Palanquin Dark","category":"sans-serif","subsets":["devanagari","latin","latin-ext"],"variants":["regular","500","600","700"],"popularity":510},{"family":"Marcellus SC","category":"serif","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":511},{"family":"Neucha","category":"handwriting","subsets":["cyrillic","latin"],"variants":["regular"],"popularity":512},{"family":"Electrolize","category":"sans-serif","subsets":["latin"],"variants":["regular"],"popularity":513},{"family":"Rufina","category":"serif","subsets":["latin","latin-ext"],"variants":["regular","700"],"popularity":514},{"family":"Six Caps","category":"sans-serif","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":515},{"family":"Ma Shan Zheng","category":"handwriting","subsets":["chinese-simplified","latin"],"variants":["regular"],"popularity":516},{"family":"Averia Libre","category":"display","subsets":["latin"],"variants":["300","300italic","regular","italic","700","700italic"],"popularity":517},{"family":"Andika","category":"sans-serif","subsets":["cyrillic","cyrillic-ext","latin","latin-ext","vietnamese"],"variants":["regular","italic","700","700italic"],"popularity":518},{"family":"Overpass Mono","category":"monospace","subsets":["cyrillic","cyrillic-ext","latin","latin-ext","vietnamese"],"variants":["300","regular","500","600","700"],"popularity":519},{"family":"Gloock","category":"serif","subsets":["cyrillic-ext","latin","latin-ext"],"variants":["regular"],"popularity":520},{"family":"Kameron","category":"serif","subsets":["latin","latin-ext"],"variants":["regular","500","600","700"],"popularity":521},{"family":"Rozha One","category":"serif","subsets":["devanagari","latin","latin-ext"],"variants":["regular"],"popularity":522},{"family":"Fahkwang","category":"sans-serif","subsets":["latin","latin-ext","thai","vietnamese"],"variants":["200","200italic","300","300italic","regular","italic","500","500italic","600","600italic","700","700italic"],"popularity":523},{"family":"Young Serif","category":"serif","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":524},{"family":"Baloo Da 2","category":"display","subsets":["bengali","latin","latin-ext","vietnamese"],"variants":["regular","500","600","700","800"],"popularity":525},{"family":"Tiro Bangla","category":"serif","subsets":["bengali","latin","latin-ext"],"variants":["regular","italic"],"popularity":526},{"family":"Ms Madi","category":"handwriting","subsets":["latin","latin-ext","vietnamese"],"variants":["regular"],"popularity":527},{"family":"Karma","category":"serif","subsets":["devanagari","latin","latin-ext"],"variants":["300","regular","500","600","700"],"popularity":528},{"family":"Coda","category":"display","subsets":["latin","latin-ext"],"variants":["regular","800"],"popularity":529},{"family":"Aldrich","category":"sans-serif","subsets":["latin"],"variants":["regular"],"popularity":530},{"family":"Spinnaker","category":"sans-serif","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":531},{"family":"Protest Revolution","category":"display","subsets":["latin","latin-ext","math","symbols","vietnamese"],"variants":["regular"],"popularity":532},{"family":"La Belle Aurore","category":"handwriting","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":533},{"family":"Glory","category":"sans-serif","subsets":["latin","latin-ext","vietnamese"],"variants":["100","200","300","regular","500","600","700","800","100italic","200italic","300italic","italic","500italic","600italic","700italic","800italic"],"popularity":534},{"family":"BIZ UDGothic","category":"sans-serif","subsets":["cyrillic","greek-ext","japanese","latin","latin-ext"],"variants":["regular","700"],"popularity":535},{"family":"Parkinsans","category":"sans-serif","subsets":["latin","latin-ext"],"variants":["300","regular","500","600","700","800"],"popularity":536},{"family":"Castoro","category":"serif","subsets":["latin","latin-ext"],"variants":["regular","italic"],"popularity":537},{"family":"Aclonica","category":"sans-serif","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":538},{"family":"Cutive Mono","category":"monospace","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":539},{"family":"Ubuntu Sans","category":"sans-serif","subsets":["cyrillic","cyrillic-ext","greek","greek-ext","latin","latin-ext"],"variants":["100","200","300","regular","500","600","700","800","100italic","200italic","300italic","italic","500italic","600italic","700italic","800italic"],"popularity":540},{"family":"Ysabeau Office","category":"sans-serif","subsets":["cyrillic","cyrillic-ext","greek","latin","latin-ext","math","symbols","vietnamese"],"variants":["100","200","300","regular","500","600","700","800","900","100italic","200italic","300italic","italic","500italic","600italic","700italic","800italic","900italic"],"popularity":541},{"family":"Mate","category":"serif","subsets":["latin","latin-ext"],"variants":["regular","italic"],"popularity":542},{"family":"Tomorrow","category":"sans-serif","subsets":["latin","latin-ext"],"variants":["100","100italic","200","200italic","300","300italic","regular","italic","500","500italic","600","600italic","700","700italic","800","800italic","900","900italic"],"popularity":543},{"family":"Vina Sans","category":"display","subsets":["latin","latin-ext","vietnamese"],"variants":["regular"],"popularity":544},{"family":"Charis SIL","category":"serif","subsets":["cyrillic","cyrillic-ext","latin","latin-ext","vietnamese"],"variants":["regular","italic","700","700italic"],"popularity":545},{"family":"Palanquin","category":"sans-serif","subsets":["devanagari","latin","latin-ext"],"variants":["100","200","300","regular","500","600","700"],"popularity":546},{"family":"Livvic","category":"sans-serif","subsets":["latin","latin-ext","vietnamese"],"variants":["100","100italic","200","200italic","300","300italic","regular","italic","500","500italic","600","600italic","700","700italic","900","900italic"],"popularity":547},{"family":"Vazirmatn","category":"sans-serif","subsets":["arabic","latin","latin-ext"],"variants":["100","200","300","regular","500","600","700","800","900"],"popularity":548},{"family":"Gantari","category":"sans-serif","subsets":["latin","latin-ext"],"variants":["100","200","300","regular","500","600","700","800","900","100italic","200italic","300italic","italic","500italic","600italic","700italic","800italic","900italic"],"popularity":549},{"family":"Podkova","category":"serif","subsets":["cyrillic","cyrillic-ext","latin","latin-ext","vietnamese"],"variants":["regular","500","600","700","800"],"popularity":550},{"family":"Darker Grotesque","category":"sans-serif","subsets":["latin","latin-ext","vietnamese"],"variants":["300","regular","500","600","700","800","900"],"popularity":551},{"family":"Radley","category":"serif","subsets":["latin","latin-ext"],"variants":["regular","italic"],"popularity":552},{"family":"Oranienbaum","category":"serif","subsets":["cyrillic","cyrillic-ext","latin","latin-ext"],"variants":["regular"],"popularity":553},{"family":"Kosugi","category":"sans-serif","subsets":["cyrillic","japanese","latin","latin-ext"],"variants":["regular"],"popularity":554},{"family":"Corben","category":"display","subsets":["latin","latin-ext"],"variants":["regular","700"],"popularity":555},{"family":"Cairo Play","category":"sans-serif","subsets":["arabic","latin","latin-ext"],"variants":["200","300","regular","500","600","700","800","900"],"popularity":556},{"family":"Fustat","category":"sans-serif","subsets":["arabic","latin","latin-ext"],"variants":["200","300","regular","500","600","700","800"],"popularity":557},{"family":"Krona One","category":"sans-serif","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":558},{"family":"Rye","category":"display","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":559},{"family":"Inria Serif","category":"serif","subsets":["latin","latin-ext"],"variants":["300","300italic","regular","italic","700","700italic"],"popularity":560},{"family":"Allison","category":"handwriting","subsets":["latin","latin-ext","vietnamese"],"variants":["regular"],"popularity":561},{"family":"Sniglet","category":"display","subsets":["latin","latin-ext"],"variants":["regular","800"],"popularity":562},{"family":"Stardos Stencil","category":"display","subsets":["latin"],"variants":["regular","700"],"popularity":563},{"family":"Jersey 25","category":"display","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":564},{"family":"Pattaya","category":"sans-serif","subsets":["cyrillic","latin","latin-ext","thai","vietnamese"],"variants":["regular"],"popularity":565},{"family":"Anuphan","category":"sans-serif","subsets":["latin","latin-ext","thai","vietnamese"],"variants":["100","200","300","regular","500","600","700"],"popularity":566},{"family":"Libre Barcode 128","category":"display","subsets":["latin"],"variants":["regular"],"popularity":567},{"family":"Proza Libre","category":"sans-serif","subsets":["latin","latin-ext"],"variants":["regular","italic","500","500italic","600","600italic","700","700italic","800","800italic"],"popularity":568},{"family":"DotGothic16","category":"sans-serif","subsets":["cyrillic","japanese","latin","latin-ext"],"variants":["regular"],"popularity":569},{"family":"Lexend Exa","category":"sans-serif","subsets":["latin","latin-ext","vietnamese"],"variants":["100","200","300","regular","500","600","700","800","900"],"popularity":570},{"family":"ADLaM Display","category":"display","subsets":["adlam","latin","latin-ext"],"variants":["regular"],"popularity":571},{"family":"Herr Von Muellerhoff","category":"handwriting","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":572},{"family":"Sintony","category":"sans-serif","subsets":["latin","latin-ext"],"variants":["regular","700"],"popularity":573},{"family":"SUSE","category":"sans-serif","subsets":["latin","latin-ext","vietnamese"],"variants":["100","200","300","regular","500","600","700","800","900","100italic","200italic","300italic","italic","500italic","600italic","700italic","800italic","900italic"],"popularity":574},{"family":"Dongle","category":"sans-serif","subsets":["korean","latin","latin-ext","vietnamese"],"variants":["300","regular","700"],"popularity":575},{"family":"Metrophobic","category":"sans-serif","subsets":["latin","latin-ext","vietnamese"],"variants":["regular"],"popularity":576},{"family":"Alatsi","category":"sans-serif","subsets":["cyrillic-ext","latin","latin-ext","vietnamese"],"variants":["regular"],"popularity":577},{"family":"Caudex","category":"serif","subsets":["greek","greek-ext","latin","latin-ext","runic","vietnamese"],"variants":["regular","italic","700","700italic"],"popularity":578},{"family":"Nobile","category":"sans-serif","subsets":["cyrillic","latin","latin-ext"],"variants":["regular","italic","500","500italic","700","700italic"],"popularity":579},{"family":"Brygada 1918","category":"serif","subsets":["cyrillic","cyrillic-ext","greek","latin","latin-ext","vietnamese"],"variants":["regular","500","600","700","italic","500italic","600italic","700italic"],"popularity":580},{"family":"Amiko","category":"sans-serif","subsets":["devanagari","latin","latin-ext"],"variants":["regular","600","700"],"popularity":581},{"family":"Sarala","category":"sans-serif","subsets":["devanagari","latin","latin-ext"],"variants":["regular","700"],"popularity":582},{"family":"Montagu Slab","category":"serif","subsets":["latin","latin-ext","vietnamese"],"variants":["100","200","300","regular","500","600","700"],"popularity":583},{"family":"Shadows Into Light Two","category":"handwriting","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":584},{"family":"Geo","category":"sans-serif","subsets":["latin"],"variants":["regular","italic"],"popularity":585},{"family":"Candal","category":"sans-serif","subsets":["latin"],"variants":["regular"],"popularity":586},{"family":"Silkscreen","category":"display","subsets":["latin","latin-ext"],"variants":["regular","700"],"popularity":587},{"family":"Ephesis","category":"handwriting","subsets":["latin","latin-ext","vietnamese"],"variants":["regular"],"popularity":588},{"family":"Yatra One","category":"display","subsets":["devanagari","latin","latin-ext"],"variants":["regular"],"popularity":589},{"family":"Pathway Extreme","category":"sans-serif","subsets":["latin","latin-ext","vietnamese"],"variants":["100","200","300","regular","500","600","700","800","900","100italic","200italic","300italic","italic","500italic","600italic","700italic","800italic","900italic"],"popularity":590},{"family":"PT Serif Caption","category":"serif","subsets":["cyrillic","cyrillic-ext","latin","latin-ext"],"variants":["regular","italic"],"popularity":591},{"family":"Glegoo","category":"serif","subsets":["devanagari","latin","latin-ext"],"variants":["regular","700"],"popularity":592},{"family":"Gowun Batang","category":"serif","subsets":["korean","latin","latin-ext","vietnamese"],"variants":["regular","700"],"popularity":593},{"family":"Yusei Magic","category":"sans-serif","subsets":["japanese","latin","latin-ext"],"variants":["regular"],"popularity":594},{"family":"Anek Latin","category":"sans-serif","subsets":["latin","latin-ext","vietnamese"],"variants":["100","200","300","regular","500","600","700","800"],"popularity":595},{"family":"Graduate","category":"serif","subsets":["latin"],"variants":["regular"],"popularity":596},{"family":"Fondamento","category":"handwriting","subsets":["latin","latin-ext"],"variants":["regular","italic"],"popularity":597},{"family":"Atkinson Hyperlegible Next","category":"sans-serif","subsets":["latin","latin-ext"],"variants":["200","300","regular","500","600","700","800","200italic","300italic","italic","500italic","600italic","700italic","800italic"],"popularity":598},{"family":"Chonburi","category":"display","subsets":["latin","latin-ext","thai","vietnamese"],"variants":["regular"],"popularity":599},{"family":"Oooh Baby","category":"handwriting","subsets":["latin","latin-ext","vietnamese"],"variants":["regular"],"popularity":600},{"family":"Funnel Display","category":"display","subsets":["latin","latin-ext"],"variants":["300","regular","500","600","700","800"],"popularity":601},{"family":"RocknRoll One","category":"sans-serif","subsets":["japanese","latin","latin-ext"],"variants":["regular"],"popularity":602},{"family":"Wallpoet","category":"display","subsets":["latin"],"variants":["regular"],"popularity":603},{"family":"Bellefair","category":"serif","subsets":["hebrew","latin","latin-ext"],"variants":["regular"],"popularity":604},{"family":"Barriecito","category":"display","subsets":["latin","latin-ext","vietnamese"],"variants":["regular"],"popularity":605},{"family":"UnifrakturMaguntia","category":"display","subsets":["latin"],"variants":["regular"],"popularity":606},{"family":"Familjen Grotesk","category":"sans-serif","subsets":["latin","latin-ext","vietnamese"],"variants":["regular","500","600","700","italic","500italic","600italic","700italic"],"popularity":607},{"family":"Faster One","category":"display","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":608},{"family":"Fjord One","category":"serif","subsets":["latin"],"variants":["regular"],"popularity":609},{"family":"Rampart One","category":"display","subsets":["cyrillic","japanese","latin","latin-ext"],"variants":["regular"],"popularity":610},{"family":"Bellota Text","category":"display","subsets":["cyrillic","latin","latin-ext","vietnamese"],"variants":["300","300italic","regular","italic","700","700italic"],"popularity":611},{"family":"Playpen Sans","category":"handwriting","subsets":["cyrillic","cyrillic-ext","emoji","greek","latin","latin-ext","math","vietnamese"],"variants":["100","200","300","regular","500","600","700","800"],"popularity":612},{"family":"Arbutus Slab","category":"serif","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":613},{"family":"Hina Mincho","category":"serif","subsets":["cyrillic","japanese","latin","latin-ext","vietnamese"],"variants":["regular"],"popularity":614},{"family":"McLaren","category":"display","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":615},{"family":"Koulen","category":"display","subsets":["khmer","latin"],"variants":["regular"],"popularity":616},{"family":"Cabin Sketch","category":"display","subsets":["latin"],"variants":["regular","700"],"popularity":617},{"family":"Grand Hotel","category":"handwriting","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":618},{"family":"Cantata One","category":"serif","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":619},{"family":"Antic","category":"sans-serif","subsets":["latin"],"variants":["regular"],"popularity":620},{"family":"Afacad Flux","category":"sans-serif","subsets":["latin","latin-ext","vietnamese"],"variants":["100","200","300","regular","500","600","700","800","900"],"popularity":621},{"family":"Antic Didone","category":"serif","subsets":["latin"],"variants":["regular"],"popularity":622},{"family":"Annie Use Your Telescope","category":"handwriting","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":623},{"family":"Goudy Bookletter 1911","category":"serif","subsets":["latin"],"variants":["regular"],"popularity":624},{"family":"Arizonia","category":"handwriting","subsets":["latin","latin-ext","vietnamese"],"variants":["regular"],"popularity":625},{"family":"Klee One","category":"handwriting","subsets":["cyrillic","greek-ext","japanese","latin","latin-ext"],"variants":["regular","600"],"popularity":626},{"family":"Armata","category":"sans-serif","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":627},{"family":"Lexend Peta","category":"sans-serif","subsets":["latin","latin-ext","vietnamese"],"variants":["100","200","300","regular","500","600","700","800","900"],"popularity":628},{"family":"Sigmar One","category":"display","subsets":["latin","latin-ext","vietnamese"],"variants":["regular"],"popularity":629},{"family":"Turret Road","category":"display","subsets":["latin","latin-ext"],"variants":["200","300","regular","500","700","800"],"popularity":630},{"family":"Petit Formal Script","category":"handwriting","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":631},{"family":"Telex","category":"sans-serif","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":632},{"family":"ZCOOL XiaoWei","category":"sans-serif","subsets":["chinese-simplified","latin"],"variants":["regular"],"popularity":633},{"family":"Grenze Gotisch","category":"display","subsets":["latin","latin-ext","vietnamese"],"variants":["100","200","300","regular","500","600","700","800","900"],"popularity":634},{"family":"Mali","category":"handwriting","subsets":["latin","latin-ext","thai","vietnamese"],"variants":["200","200italic","300","300italic","regular","italic","500","500italic","600","600italic","700","700italic"],"popularity":635},{"family":"Trirong","category":"serif","subsets":["latin","latin-ext","thai","vietnamese"],"variants":["100","100italic","200","200italic","300","300italic","regular","italic","500","500italic","600","600italic","700","700italic","800","800italic","900","900italic"],"popularity":636},{"family":"Hepta Slab","category":"serif","subsets":["latin","latin-ext","vietnamese"],"variants":["100","200","300","regular","500","600","700","800","900"],"popularity":637},{"family":"Sometype Mono","category":"monospace","subsets":["latin","latin-ext"],"variants":["regular","500","600","700","italic","500italic","600italic","700italic"],"popularity":638},{"family":"Hahmlet","category":"serif","subsets":["korean","latin","latin-ext","vietnamese"],"variants":["100","200","300","regular","500","600","700","800","900"],"popularity":639},{"family":"Fragment Mono","category":"monospace","subsets":["cyrillic-ext","latin","latin-ext"],"variants":["regular","italic"],"popularity":640},{"family":"Marmelad","category":"sans-serif","subsets":["cyrillic","cyrillic-ext","latin","latin-ext","vietnamese"],"variants":["regular"],"popularity":641},{"family":"Niconne","category":"handwriting","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":642},{"family":"Radio Canada Big","category":"sans-serif","subsets":["latin","latin-ext"],"variants":["regular","500","600","700","italic","500italic","600italic","700italic"],"popularity":643},{"family":"Caladea","category":"serif","subsets":["latin","latin-ext"],"variants":["regular","italic","700","700italic"],"popularity":644},{"family":"Halant","category":"serif","subsets":["devanagari","latin","latin-ext"],"variants":["300","regular","500","600","700"],"popularity":645},{"family":"Knewave","category":"display","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":646},{"family":"Sofia Sans Semi Condensed","category":"sans-serif","subsets":["cyrillic","cyrillic-ext","greek","latin","latin-ext"],"variants":["100","200","300","regular","500","600","700","800","900","100italic","200italic","300italic","italic","500italic","600italic","700italic","800italic","900italic"],"popularity":647},{"family":"Schoolbell","category":"handwriting","subsets":["latin"],"variants":["regular"],"popularity":648},{"family":"Honk","category":"display","subsets":["latin","latin-ext","math","symbols","vietnamese"],"variants":["regular"],"popularity":649},{"family":"Libre Caslon Display","category":"serif","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":650},{"family":"BioRhyme","category":"serif","subsets":["latin","latin-ext"],"variants":["200","300","regular","500","600","700","800"],"popularity":651},{"family":"IM Fell English","category":"serif","subsets":["latin"],"variants":["regular","italic"],"popularity":652},{"family":"Allerta","category":"sans-serif","subsets":["latin"],"variants":["regular"],"popularity":653},{"family":"Cormorant Unicase","category":"serif","subsets":["cyrillic","cyrillic-ext","latin","latin-ext","vietnamese"],"variants":["300","regular","500","600","700"],"popularity":654},{"family":"AR One Sans","category":"sans-serif","subsets":["latin","latin-ext","vietnamese"],"variants":["regular","500","600","700"],"popularity":655},{"family":"Jockey One","category":"sans-serif","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":656},{"family":"IBM Plex Sans Hebrew","category":"sans-serif","subsets":["cyrillic-ext","hebrew","latin","latin-ext"],"variants":["100","200","300","regular","500","600","700"],"popularity":657},{"family":"Sansita Swashed","category":"display","subsets":["latin","latin-ext","vietnamese"],"variants":["300","regular","500","600","700","800","900"],"popularity":658},{"family":"Anton SC","category":"sans-serif","subsets":["latin","latin-ext","vietnamese"],"variants":["regular"],"popularity":659},{"family":"Major Mono Display","category":"monospace","subsets":["latin","latin-ext","vietnamese"],"variants":["regular"],"popularity":660},{"family":"Cormorant SC","category":"serif","subsets":["cyrillic","cyrillic-ext","latin","latin-ext","vietnamese"],"variants":["300","regular","500","600","700"],"popularity":661},{"family":"Doto","category":"sans-serif","subsets":["latin","latin-ext"],"variants":["100","200","300","regular","500","600","700","800","900"],"popularity":662},{"family":"Big Shoulders","category":"display","subsets":["latin","latin-ext","vietnamese"],"variants":["100","200","300","regular","500","600","700","800","900"],"popularity":663},{"family":"Kantumruy Pro","category":"sans-serif","subsets":["khmer","latin","latin-ext"],"variants":["100","200","300","regular","500","600","700","100italic","200italic","300italic","italic","500italic","600italic","700italic"],"popularity":664},{"family":"Norican","category":"handwriting","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":665},{"family":"Monomaniac One","category":"sans-serif","subsets":["japanese","latin","latin-ext"],"variants":["regular"],"popularity":666},{"family":"Chelsea Market","category":"display","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":667},{"family":"BIZ UDPMincho","category":"serif","subsets":["cyrillic","greek-ext","japanese","latin","latin-ext"],"variants":["regular","700"],"popularity":668},{"family":"Markazi Text","category":"serif","subsets":["arabic","latin","latin-ext","vietnamese"],"variants":["regular","500","600","700"],"popularity":669},{"family":"Oxygen Mono","category":"monospace","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":670},{"family":"Monsieur La Doulaise","category":"handwriting","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":671},{"family":"Gabriela","category":"serif","subsets":["cyrillic","cyrillic-ext","latin","latin-ext"],"variants":["regular"],"popularity":672},{"family":"Enriqueta","category":"serif","subsets":["latin","latin-ext"],"variants":["regular","500","600","700"],"popularity":673},{"family":"Zalando Sans Expanded","category":"sans-serif","subsets":["latin","latin-ext","vietnamese"],"variants":["200","300","regular","500","600","700","800","900","200italic","300italic","italic","500italic","600italic","700italic","800italic","900italic"],"popularity":674},{"family":"Edu SA Beginner","category":"handwriting","subsets":["latin"],"variants":["regular","500","600","700"],"popularity":675},{"family":"Agbalumo","category":"display","subsets":["cyrillic-ext","ethiopic","latin","latin-ext","vietnamese"],"variants":["regular"],"popularity":676},{"family":"Baloo Bhaijaan 2","category":"display","subsets":["arabic","latin","latin-ext","vietnamese"],"variants":["regular","500","600","700","800"],"popularity":677},{"family":"Ibarra Real Nova","category":"serif","subsets":["latin","latin-ext"],"variants":["regular","500","600","700","italic","500italic","600italic","700italic"],"popularity":678},{"family":"Irish Grover","category":"display","subsets":["latin"],"variants":["regular"],"popularity":679},{"family":"Mochiy Pop One","category":"sans-serif","subsets":["japanese","latin"],"variants":["regular"],"popularity":680},{"family":"Montez","category":"handwriting","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":681},{"family":"Style Script","category":"handwriting","subsets":["latin","latin-ext","vietnamese"],"variants":["regular"],"popularity":682},{"family":"Germania One","category":"display","subsets":["latin"],"variants":["regular"],"popularity":683},{"family":"Overlock","category":"display","subsets":["latin","latin-ext"],"variants":["regular","italic","700","700italic","900","900italic"],"popularity":684},{"family":"Chivo Mono","category":"monospace","subsets":["latin","latin-ext","vietnamese"],"variants":["100","200","300","regular","500","600","700","800","900","100italic","200italic","300italic","italic","500italic","600italic","700italic","800italic","900italic"],"popularity":685},{"family":"IBM Plex Sans KR","category":"sans-serif","subsets":["korean","latin","latin-ext"],"variants":["100","200","300","regular","500","600","700"],"popularity":686},{"family":"Flow Circular","category":"display","subsets":["cyrillic","cyrillic-ext","latin","latin-ext","vietnamese"],"variants":["regular"],"popularity":687},{"family":"Asul","category":"serif","subsets":["latin"],"variants":["regular","700"],"popularity":688},{"family":"Kristi","category":"handwriting","subsets":["latin"],"variants":["regular"],"popularity":689},{"family":"Rosario","category":"sans-serif","subsets":["latin","latin-ext","vietnamese"],"variants":["300","regular","500","600","700","300italic","italic","500italic","600italic","700italic"],"popularity":690},{"family":"Love Ya Like A Sister","category":"display","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":691},{"family":"Coming Soon","category":"handwriting","subsets":["latin"],"variants":["regular"],"popularity":692},{"family":"Noto Serif Khojki","category":"serif","subsets":["khojki","latin","latin-ext"],"variants":["regular","500","600","700"],"popularity":693},{"family":"Yesteryear","category":"handwriting","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":694},{"family":"Mallanna","category":"sans-serif","subsets":["latin","telugu"],"variants":["regular"],"popularity":695},{"family":"Calligraffitti","category":"handwriting","subsets":["latin"],"variants":["regular"],"popularity":696},{"family":"Rationale","category":"sans-serif","subsets":["latin"],"variants":["regular"],"popularity":697},{"family":"Red Rose","category":"display","subsets":["latin","latin-ext","vietnamese"],"variants":["300","regular","500","600","700"],"popularity":698},{"family":"Waiting for the Sunrise","category":"handwriting","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":699},{"family":"Azeret Mono","category":"monospace","subsets":["latin","latin-ext"],"variants":["100","200","300","regular","500","600","700","800","900","100italic","200italic","300italic","italic","500italic","600italic","700italic","800italic","900italic"],"popularity":700},{"family":"Saira Stencil One","category":"display","subsets":["latin","latin-ext","vietnamese"],"variants":["regular"],"popularity":701},{"family":"Bentham","category":"serif","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":702},{"family":"Average Sans","category":"sans-serif","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":703},{"family":"Pixelify Sans","category":"display","subsets":["cyrillic","latin","latin-ext"],"variants":["regular","500","600","700"],"popularity":704},{"family":"Croissant One","category":"display","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":705},{"family":"Quintessential","category":"handwriting","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":706},{"family":"Trocchi","category":"serif","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":707},{"family":"Noto Serif Hebrew","category":"serif","subsets":["hebrew","latin","latin-ext"],"variants":["100","200","300","regular","500","600","700","800","900"],"popularity":708},{"family":"IM Fell English SC","category":"serif","subsets":["latin"],"variants":["regular"],"popularity":709},{"family":"Zen Antique Soft","category":"serif","subsets":["cyrillic","greek","japanese","latin","latin-ext"],"variants":["regular"],"popularity":710},{"family":"Alegreya SC","category":"serif","subsets":["cyrillic","cyrillic-ext","greek","greek-ext","latin","latin-ext","vietnamese"],"variants":["regular","italic","500","500italic","700","700italic","800","800italic","900","900italic"],"popularity":711},{"family":"Qahiri","category":"sans-serif","subsets":["arabic","latin"],"variants":["regular"],"popularity":712},{"family":"Bayon","category":"sans-serif","subsets":["khmer","latin"],"variants":["regular"],"popularity":713},{"family":"Bungee Inline","category":"display","subsets":["latin","latin-ext","vietnamese"],"variants":["regular"],"popularity":714},{"family":"Noto Sans Myanmar","category":"sans-serif","subsets":["latin","latin-ext","myanmar"],"variants":["100","200","300","regular","500","600","700","800","900"],"popularity":715},{"family":"Maitree","category":"serif","subsets":["latin","latin-ext","thai","vietnamese"],"variants":["200","300","regular","500","600","700"],"popularity":716},{"family":"WindSong","category":"handwriting","subsets":["latin","latin-ext","vietnamese"],"variants":["regular","500"],"popularity":717},{"family":"Dawning of a New Day","category":"handwriting","subsets":["latin"],"variants":["regular"],"popularity":718},{"family":"Encode Sans Expanded","category":"sans-serif","subsets":["latin","latin-ext","vietnamese"],"variants":["100","200","300","regular","500","600","700","800","900"],"popularity":719},{"family":"Baloo Thambi 2","category":"display","subsets":["latin","latin-ext","tamil","vietnamese"],"variants":["regular","500","600","700","800"],"popularity":720},{"family":"Kadwa","category":"serif","subsets":["devanagari","latin"],"variants":["regular","700"],"popularity":721},{"family":"DynaPuff","category":"display","subsets":["cyrillic-ext","latin","latin-ext"],"variants":["regular","500","600","700"],"popularity":722},{"family":"Zen Antique","category":"serif","subsets":["cyrillic","greek","japanese","latin","latin-ext"],"variants":["regular"],"popularity":723},{"family":"ZCOOL KuaiLe","category":"sans-serif","subsets":["chinese-simplified","latin"],"variants":["regular"],"popularity":724},{"family":"Tektur","category":"display","subsets":["cyrillic","cyrillic-ext","greek","latin","latin-ext","vietnamese"],"variants":["regular","500","600","700","800","900"],"popularity":725},{"family":"Rancho","category":"handwriting","subsets":["latin"],"variants":["regular"],"popularity":726},{"family":"Birthstone","category":"handwriting","subsets":["latin","latin-ext","vietnamese"],"variants":["regular"],"popularity":727},{"family":"B612 Mono","category":"monospace","subsets":["latin"],"variants":["regular","italic","700","700italic"],"popularity":728},{"family":"Metamorphous","category":"display","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":729},{"family":"Yuji Syuku","category":"serif","subsets":["cyrillic","japanese","latin","latin-ext"],"variants":["regular"],"popularity":730},{"family":"Encode Sans Semi Condensed","category":"sans-serif","subsets":["latin","latin-ext","vietnamese"],"variants":["100","200","300","regular","500","600","700","800","900"],"popularity":731},{"family":"Kaisei Opti","category":"serif","subsets":["cyrillic","japanese","latin","latin-ext"],"variants":["regular","500","700"],"popularity":732},{"family":"Carlito","category":"sans-serif","subsets":["cyrillic","cyrillic-ext","greek","greek-ext","latin","latin-ext","vietnamese"],"variants":["regular","italic","700","700italic"],"popularity":733},{"family":"Mountains of Christmas","category":"display","subsets":["latin"],"variants":["regular","700"],"popularity":734},{"family":"Kufam","category":"sans-serif","subsets":["arabic","latin","latin-ext","vietnamese"],"variants":["regular","500","600","700","800","900","italic","500italic","600italic","700italic","800italic","900italic"],"popularity":735},{"family":"Mouse Memoirs","category":"sans-serif","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":736},{"family":"Share","category":"sans-serif","subsets":["latin","latin-ext"],"variants":["regular","italic","700","700italic"],"popularity":737},{"family":"Sedgwick Ave Display","category":"handwriting","subsets":["latin","latin-ext","vietnamese"],"variants":["regular"],"popularity":738},{"family":"Freeman","category":"display","subsets":["latin","latin-ext","vietnamese"],"variants":["regular"],"popularity":739},{"family":"Lacquer","category":"display","subsets":["latin"],"variants":["regular"],"popularity":740},{"family":"Magra","category":"sans-serif","subsets":["latin","latin-ext"],"variants":["regular","700"],"popularity":741},{"family":"Average","category":"serif","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":742},{"family":"Agdasima","category":"sans-serif","subsets":["latin","latin-ext"],"variants":["regular","700"],"popularity":743},{"family":"Denk One","category":"sans-serif","subsets":["cyrillic-ext","latin","latin-ext","vietnamese"],"variants":["regular"],"popularity":744},{"family":"Xanh Mono","category":"monospace","subsets":["latin","latin-ext","vietnamese"],"variants":["regular","italic"],"popularity":745},{"family":"Lekton","category":"monospace","subsets":["latin","latin-ext"],"variants":["regular","italic","700"],"popularity":746},{"family":"Balthazar","category":"serif","subsets":["latin"],"variants":["regular"],"popularity":747},{"family":"Mansalva","category":"handwriting","subsets":["greek","latin","latin-ext","vietnamese"],"variants":["regular"],"popularity":748},{"family":"Jomhuria","category":"display","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":749},{"family":"Fauna One","category":"serif","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":750},{"family":"Copse","category":"serif","subsets":["latin"],"variants":["regular"],"popularity":751},{"family":"Marvel","category":"sans-serif","subsets":["latin"],"variants":["regular","italic","700","700italic"],"popularity":752},{"family":"Voces","category":"sans-serif","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":753},{"family":"Hurricane","category":"handwriting","subsets":["latin","latin-ext","vietnamese"],"variants":["regular"],"popularity":754},{"family":"Lexend Zetta","category":"sans-serif","subsets":["latin","latin-ext","vietnamese"],"variants":["100","200","300","regular","500","600","700","800","900"],"popularity":755},{"family":"Inria Sans","category":"sans-serif","subsets":["latin","latin-ext"],"variants":["300","300italic","regular","italic","700","700italic"],"popularity":756},{"family":"MedievalSharp","category":"display","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":757},{"family":"Slackey","category":"display","subsets":["latin"],"variants":["regular"],"popularity":758},{"family":"Viaoda Libre","category":"display","subsets":["cyrillic","cyrillic-ext","latin","latin-ext","vietnamese"],"variants":["regular"],"popularity":759},{"family":"Sunflower","category":"sans-serif","subsets":["korean"],"variants":["300","500","700"],"popularity":760},{"family":"Voltaire","category":"sans-serif","subsets":["latin","latin-ext","vietnamese"],"variants":["regular"],"popularity":761},{"family":"Noto Emoji","category":"sans-serif","subsets":["emoji"],"variants":["300","regular","500","600","700"],"popularity":762},{"family":"Sarina","category":"display","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":763},{"family":"Amarante","category":"display","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":764},{"family":"Rubik Wet Paint","category":"display","subsets":["cyrillic","cyrillic-ext","hebrew","latin","latin-ext"],"variants":["regular"],"popularity":765},{"family":"Spectral SC","category":"serif","subsets":["cyrillic","cyrillic-ext","latin","latin-ext","vietnamese"],"variants":["200","200italic","300","300italic","regular","italic","500","500italic","600","600italic","700","700italic","800","800italic"],"popularity":766},{"family":"Baloo Paaji 2","category":"display","subsets":["gurmukhi","latin","latin-ext","vietnamese"],"variants":["regular","500","600","700","800"],"popularity":767},{"family":"Seaweed Script","category":"display","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":768},{"family":"Kalnia","category":"serif","subsets":["latin","latin-ext","math"],"variants":["100","200","300","regular","500","600","700"],"popularity":769},{"family":"David Libre","category":"serif","subsets":["hebrew","latin","latin-ext","math","symbols","vietnamese"],"variants":["regular","500","700"],"popularity":770},{"family":"Over the Rainbow","category":"handwriting","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":771},{"family":"Manjari","category":"sans-serif","subsets":["latin","latin-ext","malayalam"],"variants":["100","regular","700"],"popularity":772},{"family":"Scada","category":"sans-serif","subsets":["cyrillic","cyrillic-ext","latin","latin-ext"],"variants":["regular","italic","700","700italic"],"popularity":773},{"family":"Alike","category":"serif","subsets":["latin","latin-ext","math","symbols"],"variants":["regular"],"popularity":774},{"family":"Skranji","category":"display","subsets":["latin","latin-ext"],"variants":["regular","700"],"popularity":775},{"family":"Rochester","category":"handwriting","subsets":["latin"],"variants":["regular"],"popularity":776},{"family":"Amethysta","category":"serif","subsets":["latin"],"variants":["regular"],"popularity":777},{"family":"Gentium Plus","category":"serif","subsets":["cyrillic","cyrillic-ext","greek","greek-ext","latin","latin-ext","vietnamese"],"variants":["regular","italic","700","700italic"],"popularity":778},{"family":"Baloo Chettan 2","category":"display","subsets":["latin","latin-ext","malayalam","vietnamese"],"variants":["regular","500","600","700","800"],"popularity":779},{"family":"Carrois Gothic","category":"sans-serif","subsets":["latin"],"variants":["regular"],"popularity":780},{"family":"Miriam Libre","category":"sans-serif","subsets":["hebrew","latin","latin-ext"],"variants":["regular","500","600","700"],"popularity":781},{"family":"Odor Mean Chey","category":"serif","subsets":["khmer","latin"],"variants":["regular"],"popularity":782},{"family":"Meow Script","category":"handwriting","subsets":["latin","latin-ext","vietnamese"],"variants":["regular"],"popularity":783},{"family":"Cal Sans","category":"sans-serif","subsets":["latin","latin-ext","vietnamese"],"variants":["regular"],"popularity":784},{"family":"Julee","category":"handwriting","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":785},{"family":"Noto Sans Sinhala","category":"sans-serif","subsets":["latin","latin-ext","sinhala"],"variants":["100","200","300","regular","500","600","700","800","900"],"popularity":786},{"family":"Rambla","category":"sans-serif","subsets":["latin","latin-ext"],"variants":["regular","italic","700","700italic"],"popularity":787},{"family":"Meddon","category":"handwriting","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":788},{"family":"Special Gothic Expanded One","category":"sans-serif","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":789},{"family":"Macondo","category":"display","subsets":["latin"],"variants":["regular"],"popularity":790},{"family":"Corinthia","category":"handwriting","subsets":["latin","latin-ext","vietnamese"],"variants":["regular","700"],"popularity":791},{"family":"Megrim","category":"display","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":792},{"family":"Recursive","category":"sans-serif","subsets":["cyrillic-ext","latin","latin-ext","vietnamese"],"variants":["300","regular","500","600","700","800","900"],"popularity":793},{"family":"Noto Serif Devanagari","category":"serif","subsets":["devanagari","latin","latin-ext"],"variants":["100","200","300","regular","500","600","700","800","900"],"popularity":794},{"family":"Contrail One","category":"display","subsets":["latin"],"variants":["regular"],"popularity":795},{"family":"Syne Mono","category":"monospace","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":796},{"family":"Reggae One","category":"display","subsets":["cyrillic","japanese","latin","latin-ext"],"variants":["regular"],"popularity":797},{"family":"Tilt Neon","category":"display","subsets":["latin","latin-ext","vietnamese"],"variants":["regular"],"popularity":798},{"family":"Kurale","category":"serif","subsets":["cyrillic","cyrillic-ext","devanagari","latin","latin-ext"],"variants":["regular"],"popularity":799},{"family":"TikTok Sans","category":"sans-serif","subsets":["cyrillic","cyrillic-ext","greek","latin","latin-ext","vietnamese"],"variants":["300","regular","500","600","700","800","900"],"popularity":800},{"family":"Encode Sans Semi Expanded","category":"sans-serif","subsets":["latin","latin-ext","vietnamese"],"variants":["100","200","300","regular","500","600","700","800","900"],"popularity":801},{"family":"Prosto One","category":"display","subsets":["cyrillic","latin","latin-ext"],"variants":["regular"],"popularity":802},{"family":"Vesper Libre","category":"serif","subsets":["devanagari","latin","latin-ext"],"variants":["regular","500","700","900"],"popularity":803},{"family":"Almendra","category":"serif","subsets":["latin","latin-ext"],"variants":["regular","italic","700","700italic"],"popularity":804},{"family":"Hanuman","category":"serif","subsets":["khmer","latin"],"variants":["100","200","300","regular","500","600","700","800","900"],"popularity":805},{"family":"Solitreo","category":"handwriting","subsets":["hebrew","latin","latin-ext"],"variants":["regular"],"popularity":806},{"family":"Anybody","category":"display","subsets":["latin","latin-ext","vietnamese"],"variants":["100","200","300","regular","500","600","700","800","900","100italic","200italic","300italic","italic","500italic","600italic","700italic","800italic","900italic"],"popularity":807},{"family":"IM Fell DW Pica","category":"serif","subsets":["latin"],"variants":["regular","italic"],"popularity":808},{"family":"Uncial Antiqua","category":"display","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":809},{"family":"Bubblegum Sans","category":"display","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":810},{"family":"Piazzolla","category":"serif","subsets":["cyrillic","cyrillic-ext","greek","greek-ext","latin","latin-ext","vietnamese"],"variants":["100","200","300","regular","500","600","700","800","900","100italic","200italic","300italic","italic","500italic","600italic","700italic","800italic","900italic"],"popularity":811},{"family":"Delius Unicase","category":"handwriting","subsets":["latin"],"variants":["regular","700"],"popularity":812},{"family":"Oleo Script Swash Caps","category":"display","subsets":["latin","latin-ext"],"variants":["regular","700"],"popularity":813},{"family":"Molengo","category":"sans-serif","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":814},{"family":"Rouge Script","category":"handwriting","subsets":["latin"],"variants":["regular"],"popularity":815},{"family":"Buenard","category":"serif","subsets":["latin","latin-ext"],"variants":["regular","500","600","700"],"popularity":816},{"family":"KoHo","category":"sans-serif","subsets":["latin","latin-ext","thai","vietnamese"],"variants":["200","200italic","300","300italic","regular","italic","500","500italic","600","600italic","700","700italic"],"popularity":817},{"family":"Poetsen One","category":"display","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":818},{"family":"Noto Sans Khmer","category":"sans-serif","subsets":["khmer","latin","latin-ext"],"variants":["100","200","300","regular","500","600","700","800","900"],"popularity":819},{"family":"Quando","category":"serif","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":820},{"family":"Atma","category":"display","subsets":["bengali","latin","latin-ext"],"variants":["300","regular","500","600","700"],"popularity":821},{"family":"Aref Ruqaa","category":"serif","subsets":["arabic","latin","latin-ext"],"variants":["regular","700"],"popularity":822},{"family":"Boldonse","category":"display","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":823},{"family":"Cutive","category":"serif","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":824},{"family":"Coiny","category":"display","subsets":["latin","latin-ext","tamil","vietnamese"],"variants":["regular"],"popularity":825},{"family":"Cambay","category":"sans-serif","subsets":["devanagari","latin","latin-ext"],"variants":["regular","italic","700","700italic"],"popularity":826},{"family":"Tiro Devanagari Hindi","category":"serif","subsets":["devanagari","latin","latin-ext"],"variants":["regular","italic"],"popularity":827},{"family":"Akatab","category":"sans-serif","subsets":["latin","latin-ext","tifinagh"],"variants":["regular","500","600","700","800","900"],"popularity":828},{"family":"Inknut Antiqua","category":"serif","subsets":["devanagari","latin","latin-ext"],"variants":["300","regular","500","600","700","800","900"],"popularity":829},{"family":"Averia Sans Libre","category":"display","subsets":["latin"],"variants":["300","300italic","regular","italic","700","700italic"],"popularity":830},{"family":"Shantell Sans","category":"display","subsets":["cyrillic","cyrillic-ext","latin","latin-ext","vietnamese"],"variants":["300","regular","500","600","700","800","300italic","italic","500italic","600italic","700italic","800italic"],"popularity":831},{"family":"Nova Mono","category":"monospace","subsets":["greek","latin","latin-ext"],"variants":["regular"],"popularity":832},{"family":"Noto Sans Oriya","category":"sans-serif","subsets":["latin","latin-ext","oriya"],"variants":["100","200","300","regular","500","600","700","800","900"],"popularity":833},{"family":"Bungee Shade","category":"display","subsets":["latin","latin-ext","vietnamese"],"variants":["regular"],"popularity":834},{"family":"Geom","category":"sans-serif","subsets":["greek","latin","latin-ext"],"variants":["300","regular","500","600","700","800","900","300italic","italic","500italic","600italic","700italic","800italic","900italic"],"popularity":835},{"family":"Hubot Sans","category":"sans-serif","subsets":["latin","latin-ext","vietnamese"],"variants":["200","300","regular","500","600","700","800","900","200italic","300italic","italic","500italic","600italic","700italic","800italic","900italic"],"popularity":836},{"family":"Rasa","category":"serif","subsets":["gujarati","latin","latin-ext","vietnamese"],"variants":["300","regular","500","600","700","300italic","italic","500italic","600italic","700italic"],"popularity":837},{"family":"Fontdiner Swanky","category":"display","subsets":["latin"],"variants":["regular"],"popularity":838},{"family":"Iceland","category":"display","subsets":["latin"],"variants":["regular"],"popularity":839},{"family":"Jaldi","category":"sans-serif","subsets":["devanagari","latin","latin-ext"],"variants":["regular","700"],"popularity":840},{"family":"Gotu","category":"sans-serif","subsets":["devanagari","latin","latin-ext","vietnamese"],"variants":["regular"],"popularity":841},{"family":"Madimi One","category":"sans-serif","subsets":["latin","latin-ext","math","symbols"],"variants":["regular"],"popularity":842},{"family":"Aguafina Script","category":"handwriting","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":843},{"family":"Fanwood Text","category":"serif","subsets":["latin","latin-ext"],"variants":["regular","italic"],"popularity":844},{"family":"Anek Devanagari","category":"sans-serif","subsets":["devanagari","latin","latin-ext"],"variants":["100","200","300","regular","500","600","700","800"],"popularity":845},{"family":"Coustard","category":"serif","subsets":["latin","latin-ext"],"variants":["regular","900"],"popularity":846},{"family":"M PLUS 1 Code","category":"monospace","subsets":["japanese","latin","latin-ext","vietnamese"],"variants":["100","200","300","regular","500","600","700"],"popularity":847},{"family":"Square Peg","category":"handwriting","subsets":["latin","latin-ext","vietnamese"],"variants":["regular"],"popularity":848},{"family":"Sue Ellen Francisco","category":"handwriting","subsets":["latin"],"variants":["regular"],"popularity":849},{"family":"Finger Paint","category":"display","subsets":["latin"],"variants":["regular"],"popularity":850},{"family":"Gowun Dodum","category":"sans-serif","subsets":["korean","latin","latin-ext","vietnamese"],"variants":["regular"],"popularity":851},{"family":"Qwitcher Grypen","category":"handwriting","subsets":["latin","latin-ext","vietnamese"],"variants":["regular","700"],"popularity":852},{"family":"Expletus Sans","category":"display","subsets":["latin","latin-ext"],"variants":["regular","500","600","700","italic","500italic","600italic","700italic"],"popularity":853},{"family":"Arima","category":"display","subsets":["greek","greek-ext","latin","latin-ext","malayalam","tamil","vietnamese"],"variants":["100","200","300","regular","500","600","700"],"popularity":854},{"family":"Baloo Tamma 2","category":"display","subsets":["kannada","latin","latin-ext","vietnamese"],"variants":["regular","500","600","700","800"],"popularity":855},{"family":"Jaro","category":"sans-serif","subsets":["latin","latin-ext","vietnamese"],"variants":["regular"],"popularity":856},{"family":"Noto Sans Georgian","category":"sans-serif","subsets":["cyrillic-ext","georgian","greek-ext","latin","latin-ext","math","symbols"],"variants":["100","200","300","regular","500","600","700","800","900"],"popularity":857},{"family":"Mukta Mahee","category":"sans-serif","subsets":["gurmukhi","latin","latin-ext"],"variants":["200","300","regular","500","600","700","800"],"popularity":858},{"family":"Gurajada","category":"sans-serif","subsets":["latin","latin-ext","telugu"],"variants":["regular"],"popularity":859},{"family":"Supermercado One","category":"display","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":860},{"family":"Cherry Bomb One","category":"display","subsets":["japanese","latin","latin-ext","vietnamese"],"variants":["regular"],"popularity":861},{"family":"Bellota","category":"display","subsets":["cyrillic","latin","latin-ext","vietnamese"],"variants":["300","300italic","regular","italic","700","700italic"],"popularity":862},{"family":"Allan","category":"display","subsets":["latin","latin-ext"],"variants":["regular","700"],"popularity":863},{"family":"Peralta","category":"serif","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":864},{"family":"Orienta","category":"sans-serif","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":865},{"family":"Capriola","category":"sans-serif","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":866},{"family":"Scheherazade New","category":"serif","subsets":["arabic","latin","latin-ext"],"variants":["regular","500","600","700"],"popularity":867},{"family":"Noto Serif Georgian","category":"serif","subsets":["georgian","latin","latin-ext"],"variants":["100","200","300","regular","500","600","700","800","900"],"popularity":868},{"family":"Esteban","category":"serif","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":869},{"family":"Vast Shadow","category":"serif","subsets":["latin"],"variants":["regular"],"popularity":870},{"family":"B612","category":"sans-serif","subsets":["latin"],"variants":["regular","italic","700","700italic"],"popularity":871},{"family":"Vibur","category":"handwriting","subsets":["latin"],"variants":["regular"],"popularity":872},{"family":"Iceberg","category":"display","subsets":["latin"],"variants":["regular"],"popularity":873},{"family":"Bokor","category":"display","subsets":["khmer","latin"],"variants":["regular"],"popularity":874},{"family":"Modak","category":"display","subsets":["devanagari","latin","latin-ext"],"variants":["regular"],"popularity":875},{"family":"Euphoria Script","category":"handwriting","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":876},{"family":"Zen Dots","category":"display","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":877},{"family":"Meie Script","category":"handwriting","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":878},{"family":"Aladin","category":"display","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":879},{"family":"Hedvig Letters Serif","category":"serif","subsets":["latin","latin-ext","math","symbols"],"variants":["regular"],"popularity":880},{"family":"Tenali Ramakrishna","category":"sans-serif","subsets":["latin","telugu"],"variants":["regular"],"popularity":881},{"family":"Gamja Flower","category":"handwriting","subsets":["korean","latin"],"variants":["regular"],"popularity":882},{"family":"Moon Dance","category":"handwriting","subsets":["latin","latin-ext","vietnamese"],"variants":["regular"],"popularity":883},{"family":"Battambang","category":"display","subsets":["khmer","latin"],"variants":["100","300","regular","700","900"],"popularity":884},{"family":"Happy Monkey","category":"display","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":885},{"family":"Zalando Sans","category":"sans-serif","subsets":["latin","latin-ext","vietnamese"],"variants":["200","300","regular","500","600","700","800","900","200italic","300italic","italic","500italic","600italic","700italic","800italic","900italic"],"popularity":886},{"family":"Mohave","category":"sans-serif","subsets":["latin","latin-ext"],"variants":["300","regular","500","600","700","300italic","italic","500italic","600italic","700italic"],"popularity":887},{"family":"Platypi","category":"serif","subsets":["latin","latin-ext","vietnamese"],"variants":["300","regular","500","600","700","800","300italic","italic","500italic","600italic","700italic","800italic"],"popularity":888},{"family":"Doppio One","category":"sans-serif","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":889},{"family":"Zhi Mang Xing","category":"handwriting","subsets":["chinese-simplified","latin"],"variants":["regular"],"popularity":890},{"family":"Zen Kurenaido","category":"sans-serif","subsets":["cyrillic","greek","japanese","latin","latin-ext"],"variants":["regular"],"popularity":891},{"family":"Unkempt","category":"display","subsets":["latin"],"variants":["regular","700"],"popularity":892},{"family":"Artifika","category":"serif","subsets":["latin"],"variants":["regular"],"popularity":893},{"family":"Libre Barcode 39 Extended Text","category":"display","subsets":["latin"],"variants":["regular"],"popularity":894},{"family":"MonteCarlo","category":"handwriting","subsets":["latin","latin-ext","vietnamese"],"variants":["regular"],"popularity":895},{"family":"Sarpanch","category":"sans-serif","subsets":["devanagari","latin","latin-ext"],"variants":["regular","500","600","700","800","900"],"popularity":896},{"family":"Walter Turncoat","category":"handwriting","subsets":["latin"],"variants":["regular"],"popularity":897},{"family":"Lily Script One","category":"display","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":898},{"family":"Phudu","category":"display","subsets":["cyrillic-ext","latin","latin-ext","vietnamese"],"variants":["300","regular","500","600","700","800","900"],"popularity":899},{"family":"Kranky","category":"display","subsets":["latin"],"variants":["regular"],"popularity":900},{"family":"Della Respira","category":"serif","subsets":["latin"],"variants":["regular"],"popularity":901},{"family":"Lumanosimo","category":"handwriting","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":902},{"family":"Mako","category":"sans-serif","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":903},{"family":"Asar","category":"serif","subsets":["devanagari","latin","latin-ext"],"variants":["regular"],"popularity":904},{"family":"Qwigley","category":"handwriting","subsets":["latin","latin-ext","vietnamese"],"variants":["regular"],"popularity":905},{"family":"Just Me Again Down Here","category":"handwriting","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":906},{"family":"Waterfall","category":"handwriting","subsets":["latin","latin-ext","vietnamese"],"variants":["regular"],"popularity":907},{"family":"Kelly Slab","category":"display","subsets":["cyrillic","latin","latin-ext"],"variants":["regular"],"popularity":908},{"family":"Codystar","category":"display","subsets":["latin","latin-ext"],"variants":["300","regular"],"popularity":909},{"family":"Hi Melody","category":"handwriting","subsets":["korean","latin"],"variants":["regular"],"popularity":910},{"family":"Give You Glory","category":"handwriting","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":911},{"family":"Thasadith","category":"sans-serif","subsets":["latin","latin-ext","thai","vietnamese"],"variants":["regular","italic","700","700italic"],"popularity":912},{"family":"Fresca","category":"sans-serif","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":913},{"family":"Nosifer","category":"display","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":914},{"family":"Mukta Vaani","category":"sans-serif","subsets":["gujarati","latin","latin-ext"],"variants":["200","300","regular","500","600","700","800"],"popularity":915},{"family":"Brawler","category":"serif","subsets":["latin"],"variants":["regular","700"],"popularity":916},{"family":"Puritan","category":"sans-serif","subsets":["latin"],"variants":["regular","italic","700","700italic"],"popularity":917},{"family":"Stick No Bills","category":"sans-serif","subsets":["latin","latin-ext","sinhala"],"variants":["200","300","regular","500","600","700","800"],"popularity":918},{"family":"Ruslan Display","category":"display","subsets":["cyrillic","latin","latin-ext","math","symbols"],"variants":["regular"],"popularity":919},{"family":"Bakbak One","category":"display","subsets":["devanagari","latin","latin-ext"],"variants":["regular"],"popularity":920},{"family":"Orelega One","category":"display","subsets":["cyrillic","cyrillic-ext","latin","latin-ext"],"variants":["regular"],"popularity":921},{"family":"Mina","category":"sans-serif","subsets":["bengali","latin","latin-ext"],"variants":["regular","700"],"popularity":922},{"family":"Salsa","category":"display","subsets":["latin"],"variants":["regular"],"popularity":923},{"family":"Rubik Dirt","category":"display","subsets":["cyrillic","cyrillic-ext","hebrew","latin","latin-ext"],"variants":["regular"],"popularity":924},{"family":"Nova Round","category":"display","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":925},{"family":"Crafty Girls","category":"handwriting","subsets":["latin"],"variants":["regular"],"popularity":926},{"family":"Tienne","category":"serif","subsets":["latin"],"variants":["regular","700","900"],"popularity":927},{"family":"Bilbo Swash Caps","category":"handwriting","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":928},{"family":"Red Hat Mono","category":"monospace","subsets":["latin","latin-ext"],"variants":["300","regular","500","600","700","300italic","italic","500italic","600italic","700italic"],"popularity":929},{"family":"Jersey 10","category":"display","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":930},{"family":"Bigshot One","category":"display","subsets":["latin"],"variants":["regular"],"popularity":931},{"family":"Gluten","category":"display","subsets":["latin","latin-ext","vietnamese"],"variants":["100","200","300","regular","500","600","700","800","900"],"popularity":932},{"family":"Special Gothic Condensed One","category":"sans-serif","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":933},{"family":"Gaegu","category":"handwriting","subsets":["korean","latin"],"variants":["300","regular","700"],"popularity":934},{"family":"IM Fell Double Pica","category":"serif","subsets":["latin"],"variants":["regular","italic"],"popularity":935},{"family":"Original Surfer","category":"display","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":936},{"family":"Goblin One","category":"display","subsets":["latin"],"variants":["regular"],"popularity":937},{"family":"Wendy One","category":"sans-serif","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":938},{"family":"Truculenta","category":"sans-serif","subsets":["latin","latin-ext","vietnamese"],"variants":["100","200","300","regular","500","600","700","800","900"],"popularity":939},{"family":"Road Rage","category":"display","subsets":["latin","latin-ext","vietnamese"],"variants":["regular"],"popularity":940},{"family":"Imprima","category":"sans-serif","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":941},{"family":"Benne","category":"serif","subsets":["kannada","latin","latin-ext"],"variants":["regular"],"popularity":942},{"family":"Padauk","category":"sans-serif","subsets":["latin","latin-ext","myanmar"],"variants":["regular","700"],"popularity":943},{"family":"Zain","category":"sans-serif","subsets":["arabic","latin"],"variants":["200","300","300italic","regular","italic","700","800","900"],"popularity":944},{"family":"Vollkorn SC","category":"serif","subsets":["cyrillic","cyrillic-ext","latin","latin-ext","vietnamese"],"variants":["regular","600","700","900"],"popularity":945},{"family":"Cherry Cream Soda","category":"display","subsets":["latin"],"variants":["regular"],"popularity":946},{"family":"Arya","category":"sans-serif","subsets":["devanagari","latin","latin-ext"],"variants":["regular","700"],"popularity":947},{"family":"Martian Mono","category":"monospace","subsets":["cyrillic","cyrillic-ext","latin","latin-ext"],"variants":["100","200","300","regular","500","600","700","800"],"popularity":948},{"family":"Slabo 13px","category":"serif","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":949},{"family":"Gulzar","category":"serif","subsets":["arabic","latin","latin-ext"],"variants":["regular"],"popularity":950},{"family":"Holtwood One SC","category":"serif","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":951},{"family":"Cambo","category":"serif","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":952},{"family":"Mochiy Pop P One","category":"sans-serif","subsets":["japanese","latin"],"variants":["regular"],"popularity":953},{"family":"Mr De Haviland","category":"handwriting","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":954},{"family":"Pompiere","category":"display","subsets":["latin"],"variants":["regular"],"popularity":955},{"family":"Oregano","category":"display","subsets":["latin","latin-ext"],"variants":["regular","italic"],"popularity":956},{"family":"Sevillana","category":"display","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":957},{"family":"Monofett","category":"monospace","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":958},{"family":"Libre Barcode 39 Text","category":"display","subsets":["latin"],"variants":["regular"],"popularity":959},{"family":"Frijole","category":"display","subsets":["latin"],"variants":["regular"],"popularity":960},{"family":"Noto Serif HK","category":"serif","subsets":["chinese-hongkong","cyrillic","latin","latin-ext","vietnamese"],"variants":["200","300","regular","500","600","700","800","900"],"popularity":961},{"family":"Kodchasan","category":"sans-serif","subsets":["latin","latin-ext","thai","vietnamese"],"variants":["200","200italic","300","300italic","regular","italic","500","500italic","600","600italic","700","700italic"],"popularity":962},{"family":"New Rocker","category":"display","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":963},{"family":"League Script","category":"handwriting","subsets":["latin"],"variants":["regular"],"popularity":964},{"family":"Poly","category":"serif","subsets":["latin","latin-ext"],"variants":["regular","italic"],"popularity":965},{"family":"Fuzzy Bubbles","category":"handwriting","subsets":["latin","latin-ext","vietnamese"],"variants":["regular","700"],"popularity":966},{"family":"Sunshiney","category":"handwriting","subsets":["latin"],"variants":["regular"],"popularity":967},{"family":"Noto Sans Gujarati","category":"sans-serif","subsets":["gujarati","latin","latin-ext","math","symbols"],"variants":["100","200","300","regular","500","600","700","800","900"],"popularity":968},{"family":"Freehand","category":"display","subsets":["khmer","latin"],"variants":["regular"],"popularity":969},{"family":"Shippori Antique","category":"sans-serif","subsets":["japanese","latin","latin-ext"],"variants":["regular"],"popularity":970},{"family":"Inder","category":"sans-serif","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":971},{"family":"NTR","category":"sans-serif","subsets":["latin","telugu"],"variants":["regular"],"popularity":972},{"family":"Sumana","category":"serif","subsets":["devanagari","latin","latin-ext"],"variants":["regular","700"],"popularity":973},{"family":"Charmonman","category":"handwriting","subsets":["latin","latin-ext","thai","vietnamese"],"variants":["regular","700"],"popularity":974},{"family":"Prociono","category":"serif","subsets":["latin"],"variants":["regular"],"popularity":975},{"family":"Abyssinica SIL","category":"serif","subsets":["ethiopic","latin","latin-ext"],"variants":["regular"],"popularity":976},{"family":"Wire One","category":"sans-serif","subsets":["latin"],"variants":["regular"],"popularity":977},{"family":"Rubik Glitch","category":"display","subsets":["cyrillic","cyrillic-ext","hebrew","latin","latin-ext"],"variants":["regular"],"popularity":978},{"family":"Farro","category":"sans-serif","subsets":["latin","latin-ext"],"variants":["300","regular","500","700"],"popularity":979},{"family":"Shanti","category":"sans-serif","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":980},{"family":"Gayathri","category":"sans-serif","subsets":["latin","malayalam"],"variants":["100","regular","700"],"popularity":981},{"family":"Aoboshi One","category":"serif","subsets":["japanese","latin","latin-ext"],"variants":["regular"],"popularity":982},{"family":"Sour Gummy","category":"sans-serif","subsets":["latin","latin-ext"],"variants":["100","200","300","regular","500","600","700","800","900","100italic","200italic","300italic","italic","500italic","600italic","700italic","800italic","900italic"],"popularity":983},{"family":"Galada","category":"display","subsets":["bengali","latin"],"variants":["regular"],"popularity":984},{"family":"Sedgwick Ave","category":"handwriting","subsets":["latin","latin-ext","vietnamese"],"variants":["regular"],"popularity":985},{"family":"Elsie","category":"display","subsets":["latin","latin-ext"],"variants":["regular","900"],"popularity":986},{"family":"The Girl Next Door","category":"handwriting","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":987},{"family":"Redressed","category":"handwriting","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":988},{"family":"Shojumaru","category":"display","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":989},{"family":"Noto Sans Kannada","category":"sans-serif","subsets":["kannada","latin","latin-ext"],"variants":["100","200","300","regular","500","600","700","800","900"],"popularity":990},{"family":"Mirza","category":"serif","subsets":["arabic","latin","latin-ext"],"variants":["regular","500","600","700"],"popularity":991},{"family":"Long Cang","category":"handwriting","subsets":["chinese-simplified","latin"],"variants":["regular"],"popularity":992},{"family":"Modern Antiqua","category":"display","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":993},{"family":"Emilys Candy","category":"display","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":994},{"family":"Alike Angular","category":"serif","subsets":["latin","latin-ext","math","symbols"],"variants":["regular"],"popularity":995},{"family":"Mooli","category":"sans-serif","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":996},{"family":"Protest Strike","category":"display","subsets":["latin","latin-ext","math","symbols","vietnamese"],"variants":["regular"],"popularity":997},{"family":"Clicker Script","category":"handwriting","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":998},{"family":"Vujahday Script","category":"handwriting","subsets":["latin","latin-ext","vietnamese"],"variants":["regular"],"popularity":999},{"family":"Solway","category":"serif","subsets":["latin"],"variants":["300","regular","500","700","800"],"popularity":1000},{"family":"Train One","category":"display","subsets":["cyrillic","japanese","latin","latin-ext"],"variants":["regular"],"popularity":1001},{"family":"Kaisei Tokumin","category":"serif","subsets":["cyrillic","japanese","latin","latin-ext"],"variants":["regular","500","700","800"],"popularity":1002},{"family":"Borel","category":"handwriting","subsets":["latin","latin-ext","math","symbols","vietnamese"],"variants":["regular"],"popularity":1003},{"family":"Kdam Thmor Pro","category":"sans-serif","subsets":["khmer","latin","latin-ext"],"variants":["regular"],"popularity":1004},{"family":"Teachers","category":"sans-serif","subsets":["greek-ext","latin","latin-ext"],"variants":["regular","500","600","700","800","italic","500italic","600italic","700italic","800italic"],"popularity":1005},{"family":"Song Myung","category":"serif","subsets":["korean"],"variants":["regular"],"popularity":1006},{"family":"Anek Malayalam","category":"sans-serif","subsets":["latin","latin-ext","malayalam"],"variants":["100","200","300","regular","500","600","700","800"],"popularity":1007},{"family":"Akaya Kanadaka","category":"display","subsets":["kannada","latin","latin-ext"],"variants":["regular"],"popularity":1008},{"family":"Federo","category":"sans-serif","subsets":["latin"],"variants":["regular"],"popularity":1009},{"family":"Trade Winds","category":"display","subsets":["latin"],"variants":["regular"],"popularity":1010},{"family":"Life Savers","category":"display","subsets":["latin","latin-ext"],"variants":["regular","700","800"],"popularity":1011},{"family":"Harmattan","category":"sans-serif","subsets":["arabic","latin","latin-ext"],"variants":["regular","500","600","700"],"popularity":1012},{"family":"Loved by the King","category":"handwriting","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":1013},{"family":"Metal Mania","category":"display","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":1014},{"family":"Carattere","category":"handwriting","subsets":["latin","latin-ext","vietnamese"],"variants":["regular"],"popularity":1015},{"family":"Noto Sans Armenian","category":"sans-serif","subsets":["armenian","latin","latin-ext"],"variants":["100","200","300","regular","500","600","700","800","900"],"popularity":1016},{"family":"Nokora","category":"sans-serif","subsets":["khmer","latin"],"variants":["100","200","300","regular","500","600","700","800","900"],"popularity":1017},{"family":"Numans","category":"sans-serif","subsets":["latin"],"variants":["regular"],"popularity":1018},{"family":"Genos","category":"sans-serif","subsets":["cherokee","latin","latin-ext","vietnamese"],"variants":["100","200","300","regular","500","600","700","800","900","100italic","200italic","300italic","italic","500italic","600italic","700italic","800italic","900italic"],"popularity":1019},{"family":"Kavoon","category":"display","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":1020},{"family":"Suranna","category":"serif","subsets":["latin","telugu"],"variants":["regular"],"popularity":1021},{"family":"Sail","category":"display","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":1022},{"family":"Lemon","category":"display","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":1023},{"family":"Noto Sans Thai Looped","category":"sans-serif","subsets":["latin","latin-ext","thai"],"variants":["100","200","300","regular","500","600","700","800","900"],"popularity":1024},{"family":"Anaheim","category":"sans-serif","subsets":["latin","latin-ext","vietnamese"],"variants":["regular","500","600","700","800"],"popularity":1025},{"family":"Nata Sans","category":"sans-serif","subsets":["cyrillic","cyrillic-ext","latin","latin-ext","vietnamese"],"variants":["100","200","300","regular","500","600","700","800","900"],"popularity":1026},{"family":"Crushed","category":"display","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":1027},{"family":"Noto Sans Math","category":"sans-serif","subsets":["cyrillic","latin","math"],"variants":["regular"],"popularity":1028},{"family":"SN Pro","category":"sans-serif","subsets":["cyrillic","cyrillic-ext","latin","latin-ext","vietnamese"],"variants":["200","300","regular","500","600","700","800","900","200italic","300italic","italic","500italic","600italic","700italic","800italic","900italic"],"popularity":1029},{"family":"Patrick Hand SC","category":"handwriting","subsets":["latin","latin-ext","vietnamese"],"variants":["regular"],"popularity":1030},{"family":"Licorice","category":"handwriting","subsets":["latin","latin-ext","vietnamese"],"variants":["regular"],"popularity":1031},{"family":"IM Fell DW Pica SC","category":"serif","subsets":["latin"],"variants":["regular"],"popularity":1032},{"family":"Noto Serif Malayalam","category":"serif","subsets":["latin","latin-ext","malayalam"],"variants":["100","200","300","regular","500","600","700","800","900"],"popularity":1033},{"family":"Comic Relief","category":"display","subsets":["cyrillic","greek","latin","latin-ext"],"variants":["regular","700"],"popularity":1034},{"family":"Fascinate","category":"display","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":1035},{"family":"Rubik Doodle Shadow","category":"display","subsets":["cyrillic","cyrillic-ext","hebrew","latin","latin-ext","math","symbols"],"variants":["regular"],"popularity":1036},{"family":"Ribeye","category":"display","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":1037},{"family":"Rosarivo","category":"serif","subsets":["latin","latin-ext"],"variants":["regular","italic"],"popularity":1038},{"family":"Kablammo","category":"display","subsets":["cyrillic","cyrillic-ext","emoji","latin","latin-ext","vietnamese"],"variants":["regular"],"popularity":1039},{"family":"Manuale","category":"serif","subsets":["latin","latin-ext","vietnamese"],"variants":["300","regular","500","600","700","800","300italic","italic","500italic","600italic","700italic","800italic"],"popularity":1040},{"family":"Noto Serif Thai","category":"serif","subsets":["latin","latin-ext","thai"],"variants":["100","200","300","regular","500","600","700","800","900"],"popularity":1041},{"family":"Beth Ellen","category":"handwriting","subsets":["latin"],"variants":["regular"],"popularity":1042},{"family":"Ceviche One","category":"display","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":1043},{"family":"Ledger","category":"serif","subsets":["cyrillic","latin","latin-ext"],"variants":["regular"],"popularity":1044},{"family":"Maiden Orange","category":"serif","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":1045},{"family":"Gemunu Libre","category":"sans-serif","subsets":["latin","latin-ext","sinhala"],"variants":["200","300","regular","500","600","700","800"],"popularity":1046},{"family":"Angkor","category":"display","subsets":["khmer","latin"],"variants":["regular"],"popularity":1047},{"family":"Montaga","category":"serif","subsets":["latin"],"variants":["regular"],"popularity":1048},{"family":"IM Fell Double Pica SC","category":"serif","subsets":["latin"],"variants":["regular"],"popularity":1049},{"family":"Bona Nova","category":"serif","subsets":["cyrillic","cyrillic-ext","greek","hebrew","latin","latin-ext","vietnamese"],"variants":["regular","italic","700"],"popularity":1050},{"family":"Swanky and Moo Moo","category":"handwriting","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":1051},{"family":"Inclusive Sans","category":"sans-serif","subsets":["latin","latin-ext","vietnamese"],"variants":["300","regular","500","600","700","300italic","italic","500italic","600italic","700italic"],"popularity":1052},{"family":"IM Fell French Canon","category":"serif","subsets":["latin"],"variants":["regular","italic"],"popularity":1053},{"family":"Timmana","category":"sans-serif","subsets":["latin","telugu"],"variants":["regular"],"popularity":1054},{"family":"Noto Sans Meetei Mayek","category":"sans-serif","subsets":["latin","latin-ext","meetei-mayek"],"variants":["100","200","300","regular","500","600","700","800","900"],"popularity":1055},{"family":"Baloo Bhai 2","category":"display","subsets":["gujarati","latin","latin-ext","vietnamese"],"variants":["regular","500","600","700","800"],"popularity":1056},{"family":"Dokdo","category":"display","subsets":["korean","latin"],"variants":["regular"],"popularity":1057},{"family":"Delicious Handrawn","category":"handwriting","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":1058},{"family":"Scope One","category":"serif","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":1059},{"family":"Katibeh","category":"display","subsets":["arabic","latin","latin-ext"],"variants":["regular"],"popularity":1060},{"family":"Moul","category":"display","subsets":["khmer","latin"],"variants":["regular"],"popularity":1061},{"family":"Sancreek","category":"display","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":1062},{"family":"Anek Tamil","category":"sans-serif","subsets":["latin","latin-ext","tamil"],"variants":["100","200","300","regular","500","600","700","800"],"popularity":1063},{"family":"Edu TAS Beginner","category":"handwriting","subsets":["latin"],"variants":["regular","500","600","700"],"popularity":1064},{"family":"Ysabeau SC","category":"sans-serif","subsets":["cyrillic","cyrillic-ext","greek","latin","latin-ext","math","symbols","vietnamese"],"variants":["100","200","300","regular","500","600","700","800","900"],"popularity":1065},{"family":"UnifrakturCook","category":"display","subsets":["latin"],"variants":["700"],"popularity":1066},{"family":"Notable","category":"sans-serif","subsets":["latin"],"variants":["regular"],"popularity":1067},{"family":"Akronim","category":"display","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":1068},{"family":"Darumadrop One","category":"display","subsets":["japanese","latin","latin-ext"],"variants":["regular"],"popularity":1069},{"family":"IM Fell Great Primer","category":"serif","subsets":["latin"],"variants":["regular","italic"],"popularity":1070},{"family":"Duru Sans","category":"sans-serif","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":1071},{"family":"Carme","category":"sans-serif","subsets":["latin"],"variants":["regular"],"popularity":1072},{"family":"Comme","category":"sans-serif","subsets":["latin","latin-ext"],"variants":["100","200","300","regular","500","600","700","800","900"],"popularity":1073},{"family":"Nova Slim","category":"display","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":1074},{"family":"Macondo Swash Caps","category":"display","subsets":["latin"],"variants":["regular"],"popularity":1075},{"family":"Rhodium Libre","category":"serif","subsets":["devanagari","latin","latin-ext"],"variants":["regular"],"popularity":1076},{"family":"Sulphur Point","category":"sans-serif","subsets":["latin","latin-ext"],"variants":["300","regular","700"],"popularity":1077},{"family":"Anta","category":"sans-serif","subsets":["latin","latin-ext","math","symbols"],"variants":["regular"],"popularity":1078},{"family":"Spicy Rice","category":"display","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":1079},{"family":"Caesar Dressing","category":"display","subsets":["latin"],"variants":["regular"],"popularity":1080},{"family":"Baumans","category":"display","subsets":["latin"],"variants":["regular"],"popularity":1081},{"family":"Nova Flat","category":"display","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":1082},{"family":"Stick","category":"sans-serif","subsets":["cyrillic","japanese","latin","latin-ext"],"variants":["regular"],"popularity":1083},{"family":"Smythe","category":"display","subsets":["latin"],"variants":["regular"],"popularity":1084},{"family":"Dynalight","category":"display","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":1085},{"family":"Cactus Classical Serif","category":"serif","subsets":["chinese-traditional","cyrillic","latin","latin-ext","vietnamese"],"variants":["regular"],"popularity":1086},{"family":"Whisper","category":"handwriting","subsets":["latin","latin-ext","vietnamese"],"variants":["regular"],"popularity":1087},{"family":"Yeon Sung","category":"display","subsets":["korean","latin"],"variants":["regular"],"popularity":1088},{"family":"Delius Swash Caps","category":"handwriting","subsets":["latin"],"variants":["regular"],"popularity":1089},{"family":"Liu Jian Mao Cao","category":"handwriting","subsets":["chinese-simplified","latin"],"variants":["regular"],"popularity":1090},{"family":"Alkalami","category":"serif","subsets":["arabic","latin","latin-ext"],"variants":["regular"],"popularity":1091},{"family":"Eagle Lake","category":"handwriting","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":1092},{"family":"Spline Sans Mono","category":"monospace","subsets":["latin","latin-ext"],"variants":["300","regular","500","600","700","300italic","italic","500italic","600italic","700italic"],"popularity":1093},{"family":"Special Gothic","category":"sans-serif","subsets":["latin","latin-ext"],"variants":["regular","500","600","700"],"popularity":1094},{"family":"Gentium Book Plus","category":"serif","subsets":["cyrillic","cyrillic-ext","greek","greek-ext","latin","latin-ext","vietnamese"],"variants":["regular","italic","700","700italic"],"popularity":1095},{"family":"Zalando Sans SemiExpanded","category":"sans-serif","subsets":["latin","latin-ext","vietnamese"],"variants":["200","300","regular","500","600","700","800","900","200italic","300italic","italic","500italic","600italic","700italic","800italic","900italic"],"popularity":1096},{"family":"Yomogi","category":"handwriting","subsets":["cyrillic","japanese","latin","latin-ext","vietnamese"],"variants":["regular"],"popularity":1097},{"family":"Tauri","category":"sans-serif","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":1098},{"family":"Rubik Bubbles","category":"display","subsets":["cyrillic","cyrillic-ext","hebrew","latin","latin-ext"],"variants":["regular"],"popularity":1099},{"family":"Ruthie","category":"handwriting","subsets":["latin","latin-ext","vietnamese"],"variants":["regular"],"popularity":1100},{"family":"Gugi","category":"display","subsets":["korean","latin"],"variants":["regular"],"popularity":1101},{"family":"Homenaje","category":"sans-serif","subsets":["latin"],"variants":["regular"],"popularity":1102},{"family":"Convergence","category":"sans-serif","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":1103},{"family":"Birthstone Bounce","category":"handwriting","subsets":["latin","latin-ext","vietnamese"],"variants":["regular","500"],"popularity":1104},{"family":"Sigmar","category":"display","subsets":["latin","latin-ext","vietnamese"],"variants":["regular"],"popularity":1105},{"family":"Barrio","category":"display","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":1106},{"family":"Asset","category":"display","subsets":["cyrillic-ext","latin","latin-ext","math","symbols"],"variants":["regular"],"popularity":1107},{"family":"Kenia","category":"display","subsets":["latin"],"variants":["regular"],"popularity":1108},{"family":"Poltawski Nowy","category":"serif","subsets":["latin","latin-ext","vietnamese"],"variants":["regular","500","600","700","italic","500italic","600italic","700italic"],"popularity":1109},{"family":"Imbue","category":"serif","subsets":["latin","latin-ext","vietnamese"],"variants":["100","200","300","regular","500","600","700","800","900"],"popularity":1110},{"family":"Stalemate","category":"handwriting","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":1111},{"family":"Fuggles","category":"handwriting","subsets":["latin","latin-ext","vietnamese"],"variants":["regular"],"popularity":1112},{"family":"Atomic Age","category":"display","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":1113},{"family":"Vampiro One","category":"display","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":1114},{"family":"Shalimar","category":"handwriting","subsets":["latin","latin-ext","vietnamese"],"variants":["regular"],"popularity":1115},{"family":"Henny Penny","category":"display","subsets":["latin"],"variants":["regular"],"popularity":1116},{"family":"Kode Mono","category":"monospace","subsets":["latin","latin-ext"],"variants":["regular","500","600","700"],"popularity":1117},{"family":"Baloo Tammudu 2","category":"display","subsets":["latin","latin-ext","telugu","vietnamese"],"variants":["regular","500","600","700","800"],"popularity":1118},{"family":"Protest Riot","category":"display","subsets":["latin","latin-ext","math","symbols","vietnamese"],"variants":["regular"],"popularity":1119},{"family":"Chau Philomene One","category":"sans-serif","subsets":["latin","latin-ext"],"variants":["regular","italic"],"popularity":1120},{"family":"Noto Sans Lao","category":"sans-serif","subsets":["lao","latin","latin-ext"],"variants":["100","200","300","regular","500","600","700","800","900"],"popularity":1121},{"family":"IBM Plex Sans Devanagari","category":"sans-serif","subsets":["cyrillic-ext","devanagari","latin","latin-ext"],"variants":["100","200","300","regular","500","600","700"],"popularity":1122},{"family":"Chicle","category":"display","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":1123},{"family":"Freckle Face","category":"display","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":1124},{"family":"IM Fell French Canon SC","category":"serif","subsets":["latin"],"variants":["regular"],"popularity":1125},{"family":"Badeen Display","category":"display","subsets":["arabic","latin","latin-ext"],"variants":["regular"],"popularity":1126},{"family":"Overlock SC","category":"display","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":1127},{"family":"Gafata","category":"sans-serif","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":1128},{"family":"Akaya Telivigala","category":"display","subsets":["latin","latin-ext","telugu"],"variants":["regular"],"popularity":1129},{"family":"Raleway Dots","category":"display","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":1130},{"family":"Astloch","category":"display","subsets":["latin"],"variants":["regular","700"],"popularity":1131},{"family":"Baloo Bhaina 2","category":"display","subsets":["latin","latin-ext","oriya","vietnamese"],"variants":["regular","500","600","700","800"],"popularity":1132},{"family":"IM Fell Great Primer SC","category":"serif","subsets":["latin"],"variants":["regular"],"popularity":1133},{"family":"Jolly Lodger","category":"display","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":1134},{"family":"Mystery Quest","category":"display","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":1135},{"family":"Medula One","category":"display","subsets":["latin"],"variants":["regular"],"popularity":1136},{"family":"Nerko One","category":"handwriting","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":1137},{"family":"Miltonian","category":"display","subsets":["latin"],"variants":["regular"],"popularity":1138},{"family":"Stylish","category":"sans-serif","subsets":["korean"],"variants":["regular"],"popularity":1139},{"family":"Karantina","category":"display","subsets":["hebrew","latin","latin-ext"],"variants":["300","regular","700"],"popularity":1140},{"family":"Imperial Script","category":"handwriting","subsets":["latin","latin-ext","vietnamese"],"variants":["regular"],"popularity":1141},{"family":"Cantora One","category":"sans-serif","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":1142},{"family":"Grenze","category":"serif","subsets":["latin","latin-ext","vietnamese"],"variants":["100","200","300","regular","500","600","700","800","900","100italic","200italic","300italic","italic","500italic","600italic","700italic","800italic","900italic"],"popularity":1143},{"family":"Beau Rivage","category":"handwriting","subsets":["latin","latin-ext","vietnamese"],"variants":["regular"],"popularity":1144},{"family":"Noto Sans Meroitic","category":"sans-serif","subsets":["latin","latin-ext","meroitic","meroitic-cursive","meroitic-hieroglyphs"],"variants":["regular"],"popularity":1145},{"family":"Cherry Swash","category":"display","subsets":["latin","latin-ext"],"variants":["regular","700"],"popularity":1146},{"family":"Luxurious Script","category":"handwriting","subsets":["latin","latin-ext","vietnamese"],"variants":["regular"],"popularity":1147},{"family":"East Sea Dokdo","category":"handwriting","subsets":["korean","latin"],"variants":["regular"],"popularity":1148},{"family":"BhuTuka Expanded One","category":"serif","subsets":["gurmukhi","latin","latin-ext"],"variants":["regular"],"popularity":1149},{"family":"Noto Sans Ethiopic","category":"sans-serif","subsets":["ethiopic","latin","latin-ext"],"variants":["100","200","300","regular","500","600","700","800","900"],"popularity":1150},{"family":"Pavanam","category":"sans-serif","subsets":["latin","latin-ext","tamil"],"variants":["regular"],"popularity":1151},{"family":"Miltonian Tattoo","category":"display","subsets":["latin"],"variants":["regular"],"popularity":1152},{"family":"Sura","category":"serif","subsets":["devanagari","latin","latin-ext"],"variants":["regular","700"],"popularity":1153},{"family":"Nova Cut","category":"display","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":1154},{"family":"Underdog","category":"display","subsets":["cyrillic","latin","latin-ext"],"variants":["regular"],"popularity":1155},{"family":"Hedvig Letters Sans","category":"sans-serif","subsets":["latin","latin-ext","math","symbols"],"variants":["regular"],"popularity":1156},{"family":"Noto Sans Symbols 2","category":"sans-serif","subsets":["braille","latin","latin-ext","math","mayan-numerals","symbols"],"variants":["regular"],"popularity":1157},{"family":"Reddit Sans Condensed","category":"sans-serif","subsets":["latin","latin-ext","vietnamese"],"variants":["200","300","regular","500","600","700","800","900"],"popularity":1158},{"family":"Alkatra","category":"display","subsets":["bengali","devanagari","latin","latin-ext","oriya"],"variants":["regular","500","600","700"],"popularity":1159},{"family":"Passions Conflict","category":"handwriting","subsets":["latin","latin-ext","vietnamese"],"variants":["regular"],"popularity":1160},{"family":"Nova Script","category":"display","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":1161},{"family":"Jomolhari","category":"serif","subsets":["latin","tibetan"],"variants":["regular"],"popularity":1162},{"family":"Unlock","category":"display","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":1163},{"family":"Miniver","category":"display","subsets":["latin"],"variants":["regular"],"popularity":1164},{"family":"Sono","category":"sans-serif","subsets":["latin","latin-ext","vietnamese"],"variants":["200","300","regular","500","600","700","800"],"popularity":1165},{"family":"Bonheur Royale","category":"handwriting","subsets":["latin","latin-ext","vietnamese"],"variants":["regular"],"popularity":1166},{"family":"Libre Barcode 128 Text","category":"display","subsets":["latin"],"variants":["regular"],"popularity":1167},{"family":"Nova Oval","category":"display","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":1168},{"family":"Odibee Sans","category":"display","subsets":["latin"],"variants":["regular"],"popularity":1169},{"family":"Braah One","category":"sans-serif","subsets":["gurmukhi","latin","latin-ext","vietnamese"],"variants":["regular"],"popularity":1170},{"family":"Headland One","category":"serif","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":1171},{"family":"Srisakdi","category":"display","subsets":["latin","latin-ext","thai","vietnamese"],"variants":["regular","700"],"popularity":1172},{"family":"Kulim Park","category":"sans-serif","subsets":["latin","latin-ext"],"variants":["200","200italic","300","300italic","regular","italic","600","600italic","700","700italic"],"popularity":1173},{"family":"Suwannaphum","category":"serif","subsets":["khmer","latin"],"variants":["100","300","regular","700","900"],"popularity":1174},{"family":"Margarine","category":"display","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":1175},{"family":"Content","category":"display","subsets":["khmer"],"variants":["regular","700"],"popularity":1176},{"family":"Single Day","category":"display","subsets":["korean"],"variants":["regular"],"popularity":1177},{"family":"Noto Sans Osmanya","category":"sans-serif","subsets":["latin","latin-ext","osmanya"],"variants":["regular"],"popularity":1178},{"family":"Chocolate Classical Sans","category":"sans-serif","subsets":["chinese-traditional","cyrillic","latin","latin-ext","vietnamese"],"variants":["regular"],"popularity":1179},{"family":"Lavishly Yours","category":"handwriting","subsets":["latin","latin-ext","vietnamese"],"variants":["regular"],"popularity":1180},{"family":"Grape Nuts","category":"handwriting","subsets":["latin","latin-ext","vietnamese"],"variants":["regular"],"popularity":1181},{"family":"Flamenco","category":"display","subsets":["latin"],"variants":["300","regular"],"popularity":1182},{"family":"Zilla Slab Highlight","category":"serif","subsets":["latin","latin-ext"],"variants":["regular","700"],"popularity":1183},{"family":"Sedan SC","category":"serif","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":1184},{"family":"Alan Sans","category":"sans-serif","subsets":["latin","latin-ext"],"variants":["300","regular","500","600","700","800","900"],"popularity":1185},{"family":"Trispace","category":"sans-serif","subsets":["latin","latin-ext","vietnamese"],"variants":["100","200","300","regular","500","600","700","800"],"popularity":1186},{"family":"Asta Sans","category":"sans-serif","subsets":["korean","latin"],"variants":["300","regular","500","600","700","800"],"popularity":1187},{"family":"Short Stack","category":"handwriting","subsets":["latin"],"variants":["regular"],"popularity":1188},{"family":"Winky Sans","category":"sans-serif","subsets":["latin","latin-ext"],"variants":["300","regular","500","600","700","800","900","300italic","italic","500italic","600italic","700italic","800italic","900italic"],"popularity":1189},{"family":"Shippori Antique B1","category":"sans-serif","subsets":["japanese","latin","latin-ext"],"variants":["regular"],"popularity":1190},{"family":"Texturina","category":"serif","subsets":["latin","latin-ext","vietnamese"],"variants":["100","200","300","regular","500","600","700","800","900","100italic","200italic","300italic","italic","500italic","600italic","700italic","800italic","900italic"],"popularity":1191},{"family":"Belgrano","category":"serif","subsets":["latin"],"variants":["regular"],"popularity":1192},{"family":"Varta","category":"sans-serif","subsets":["latin","latin-ext","vietnamese"],"variants":["300","regular","500","600","700"],"popularity":1193},{"family":"Sansation","category":"sans-serif","subsets":["cyrillic","greek","latin","latin-ext"],"variants":["300","300italic","regular","italic","700","700italic"],"popularity":1194},{"family":"Londrina Outline","category":"display","subsets":["latin"],"variants":["regular"],"popularity":1195},{"family":"Kavivanar","category":"handwriting","subsets":["latin","latin-ext","tamil"],"variants":["regular"],"popularity":1196},{"family":"Lugrasimo","category":"handwriting","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":1197},{"family":"BJ Cree","category":"serif","subsets":["canadian-aboriginal","latin"],"variants":["regular","500","600","700"],"popularity":1198},{"family":"Edu NSW ACT Cursive","category":"handwriting","subsets":["latin","latin-ext"],"variants":["regular","500","600","700"],"popularity":1199},{"family":"Habibi","category":"serif","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":1200},{"family":"Finlandica","category":"sans-serif","subsets":["cyrillic","cyrillic-ext","latin","latin-ext"],"variants":["regular","500","600","700","italic","500italic","600italic","700italic"],"popularity":1201},{"family":"BIZ UDMincho","category":"serif","subsets":["cyrillic","greek-ext","japanese","latin","latin-ext"],"variants":["regular","700"],"popularity":1202},{"family":"Gasoek One","category":"sans-serif","subsets":["korean","latin","latin-ext"],"variants":["regular"],"popularity":1203},{"family":"Gupter","category":"serif","subsets":["latin"],"variants":["regular","500","700"],"popularity":1204},{"family":"Handjet","category":"display","subsets":["arabic","armenian","cyrillic","cyrillic-ext","greek","hebrew","latin","latin-ext","vietnamese"],"variants":["100","200","300","regular","500","600","700","800","900"],"popularity":1205},{"family":"Mea Culpa","category":"handwriting","subsets":["latin","latin-ext","vietnamese"],"variants":["regular"],"popularity":1206},{"family":"Khmer","category":"sans-serif","subsets":["khmer"],"variants":["regular"],"popularity":1207},{"family":"The Nautigal","category":"handwriting","subsets":["latin","latin-ext","vietnamese"],"variants":["regular","700"],"popularity":1208},{"family":"Bagel Fat One","category":"display","subsets":["korean","latin","latin-ext"],"variants":["regular"],"popularity":1209},{"family":"Playwrite US Trad","category":"handwriting","subsets":["latin"],"variants":["100","200","300","regular"],"popularity":1210},{"family":"Mynerve","category":"handwriting","subsets":["greek","latin","latin-ext","vietnamese"],"variants":["regular"],"popularity":1211},{"family":"Playwrite IE","category":"handwriting","subsets":["latin"],"variants":["100","200","300","regular"],"popularity":1212},{"family":"Fasthand","category":"display","subsets":["khmer","latin"],"variants":["regular"],"popularity":1213},{"family":"Redacted","category":"display","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":1214},{"family":"Federant","category":"display","subsets":["latin"],"variants":["regular"],"popularity":1215},{"family":"My Soul","category":"handwriting","subsets":["latin","latin-ext","vietnamese"],"variants":["regular"],"popularity":1216},{"family":"Ranchers","category":"display","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":1217},{"family":"Zen Tokyo Zoo","category":"display","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":1218},{"family":"IBM Plex Sans Thai Looped","category":"sans-serif","subsets":["cyrillic-ext","latin","latin-ext","thai"],"variants":["100","200","300","regular","500","600","700"],"popularity":1219},{"family":"Momo Signature","category":"sans-serif","subsets":["latin","latin-ext","vietnamese"],"variants":["regular"],"popularity":1220},{"family":"Kaisei HarunoUmi","category":"serif","subsets":["cyrillic","japanese","latin","latin-ext"],"variants":["regular","500","700"],"popularity":1221},{"family":"Englebert","category":"sans-serif","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":1222},{"family":"Strait","category":"sans-serif","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":1223},{"family":"Dangrek","category":"display","subsets":["khmer","latin"],"variants":["regular"],"popularity":1224},{"family":"Kotta One","category":"serif","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":1225},{"family":"Dorsa","category":"sans-serif","subsets":["latin"],"variants":["regular"],"popularity":1226},{"family":"Fenix","category":"serif","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":1227},{"family":"Reem Kufi Fun","category":"sans-serif","subsets":["arabic","latin","latin-ext","vietnamese"],"variants":["regular","500","600","700"],"popularity":1228},{"family":"Rubik Scribble","category":"display","subsets":["cyrillic","cyrillic-ext","hebrew","latin","latin-ext","math","symbols"],"variants":["regular"],"popularity":1229},{"family":"Yuji Boku","category":"serif","subsets":["cyrillic","japanese","latin","latin-ext"],"variants":["regular"],"popularity":1230},{"family":"Junge","category":"serif","subsets":["latin"],"variants":["regular"],"popularity":1231},{"family":"Anek Gujarati","category":"sans-serif","subsets":["gujarati","latin","latin-ext"],"variants":["100","200","300","regular","500","600","700","800"],"popularity":1232},{"family":"Engagement","category":"handwriting","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":1233},{"family":"Noto Sans Gothic","category":"sans-serif","subsets":["gothic","latin","latin-ext"],"variants":["regular"],"popularity":1234},{"family":"Festive","category":"handwriting","subsets":["latin","latin-ext","vietnamese"],"variants":["regular"],"popularity":1235},{"family":"Chathura","category":"sans-serif","subsets":["latin","telugu"],"variants":["100","300","regular","700","800"],"popularity":1236},{"family":"Noto Serif Telugu","category":"serif","subsets":["latin","latin-ext","telugu"],"variants":["100","200","300","regular","500","600","700","800","900"],"popularity":1237},{"family":"Koh Santepheap","category":"serif","subsets":["khmer","latin"],"variants":["100","300","regular","700","900"],"popularity":1238},{"family":"Sonsie One","category":"display","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":1239},{"family":"Devonshire","category":"handwriting","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":1240},{"family":"Lovers Quarrel","category":"handwriting","subsets":["latin","latin-ext","vietnamese"],"variants":["regular"],"popularity":1241},{"family":"Arbutus","category":"serif","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":1242},{"family":"Girassol","category":"display","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":1243},{"family":"Ballet","category":"handwriting","subsets":["latin","latin-ext","vietnamese"],"variants":["regular"],"popularity":1244},{"family":"Jacquard 12","category":"display","subsets":["latin","latin-ext","math","symbols"],"variants":["regular"],"popularity":1245},{"family":"Saira Stencil","category":"display","subsets":["latin","latin-ext","vietnamese"],"variants":["100","200","300","regular","500","600","700","800","900","100italic","200italic","300italic","italic","500italic","600italic","700italic","800italic","900italic"],"popularity":1246},{"family":"Cute Font","category":"display","subsets":["korean","latin"],"variants":["regular"],"popularity":1247},{"family":"Liter","category":"sans-serif","subsets":["cyrillic","latin","latin-ext"],"variants":["regular"],"popularity":1248},{"family":"Island Moments","category":"handwriting","subsets":["latin","latin-ext","vietnamese"],"variants":["regular"],"popularity":1249},{"family":"Rum Raisin","category":"sans-serif","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":1250},{"family":"Stint Ultra Condensed","category":"serif","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":1251},{"family":"Noto Sans Mahajani","category":"sans-serif","subsets":["latin","latin-ext","mahajani"],"variants":["regular"],"popularity":1252},{"family":"Seymour One","category":"sans-serif","subsets":["cyrillic","latin","latin-ext"],"variants":["regular"],"popularity":1253},{"family":"Reddit Mono","category":"monospace","subsets":["latin","latin-ext","vietnamese"],"variants":["200","300","regular","500","600","700","800","900"],"popularity":1254},{"family":"Bubbler One","category":"sans-serif","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":1255},{"family":"Libre Barcode EAN13 Text","category":"display","subsets":["latin"],"variants":["regular"],"popularity":1256},{"family":"Nuosu SIL","category":"sans-serif","subsets":["latin","latin-ext","yi"],"variants":["regular"],"popularity":1257},{"family":"New Tegomin","category":"serif","subsets":["japanese","latin","latin-ext"],"variants":["regular"],"popularity":1258},{"family":"Glass Antiqua","category":"display","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":1259},{"family":"Miranda Sans","category":"sans-serif","subsets":["latin","latin-ext"],"variants":["regular","500","600","700","italic","500italic","600italic","700italic"],"popularity":1260},{"family":"Spirax","category":"display","subsets":["latin"],"variants":["regular"],"popularity":1261},{"family":"Chiron GoRound TC","category":"sans-serif","subsets":["chinese-traditional","cyrillic","cyrillic-ext","latin","latin-ext","vietnamese"],"variants":["200","300","regular","500","600","700","800","900"],"popularity":1262},{"family":"Mate SC","category":"serif","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":1263},{"family":"Jersey 15","category":"display","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":1264},{"family":"Keania One","category":"display","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":1265},{"family":"Port Lligat Slab","category":"serif","subsets":["latin"],"variants":["regular"],"popularity":1266},{"family":"Noto Music","category":"sans-serif","subsets":["latin","latin-ext","music"],"variants":["regular"],"popularity":1267},{"family":"Noto Serif Ahom","category":"serif","subsets":["ahom","latin","latin-ext"],"variants":["regular"],"popularity":1268},{"family":"Ysabeau Infant","category":"sans-serif","subsets":["cyrillic","cyrillic-ext","greek","latin","latin-ext","math","symbols","vietnamese"],"variants":["100","200","300","regular","500","600","700","800","900","100italic","200italic","300italic","italic","500italic","600italic","700italic","800italic","900italic"],"popularity":1269},{"family":"Ruwudu","category":"serif","subsets":["arabic","latin","latin-ext"],"variants":["regular","500","600","700"],"popularity":1270},{"family":"Gwendolyn","category":"handwriting","subsets":["latin","latin-ext","vietnamese"],"variants":["regular","700"],"popularity":1271},{"family":"Smooch","category":"handwriting","subsets":["latin","latin-ext","vietnamese"],"variants":["regular"],"popularity":1272},{"family":"Tac One","category":"sans-serif","subsets":["latin","latin-ext","math","symbols","vietnamese"],"variants":["regular"],"popularity":1273},{"family":"National Park","category":"sans-serif","subsets":["latin","latin-ext","vietnamese"],"variants":["200","300","regular","500","600","700","800"],"popularity":1274},{"family":"Kapakana","category":"handwriting","subsets":["japanese","latin","latin-ext"],"variants":["300","regular"],"popularity":1275},{"family":"Playwrite DE Grund","category":"handwriting","subsets":["latin"],"variants":["100","200","300","regular"],"popularity":1276},{"family":"Noto Sans Buhid","category":"sans-serif","subsets":["buhid","latin","latin-ext"],"variants":["regular"],"popularity":1277},{"family":"Comforter","category":"handwriting","subsets":["cyrillic","latin","latin-ext","vietnamese"],"variants":["regular"],"popularity":1278},{"family":"Sahitya","category":"serif","subsets":["devanagari","latin"],"variants":["regular","700"],"popularity":1279},{"family":"Climate Crisis","category":"display","subsets":["cyrillic","cyrillic-ext","latin","latin-ext"],"variants":["regular"],"popularity":1280},{"family":"Lexend Mega","category":"sans-serif","subsets":["latin","latin-ext","vietnamese"],"variants":["100","200","300","regular","500","600","700","800","900"],"popularity":1281},{"family":"Playwrite IS","category":"handwriting","subsets":["latin"],"variants":["100","200","300","regular"],"popularity":1282},{"family":"Almendra SC","category":"serif","subsets":["latin"],"variants":["regular"],"popularity":1283},{"family":"Text Me One","category":"sans-serif","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":1284},{"family":"Faculty Glyphic","category":"sans-serif","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":1285},{"family":"Stint Ultra Expanded","category":"serif","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":1286},{"family":"Noto Sans Batak","category":"sans-serif","subsets":["batak","latin","latin-ext"],"variants":["regular"],"popularity":1287},{"family":"Erica One","category":"display","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":1288},{"family":"Londrina Shadow","category":"display","subsets":["latin"],"variants":["regular"],"popularity":1289},{"family":"Gorditas","category":"display","subsets":["latin"],"variants":["regular","700"],"popularity":1290},{"family":"Paprika","category":"display","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":1291},{"family":"Mozilla Text","category":"sans-serif","subsets":["latin","latin-ext"],"variants":["200","300","regular","500","600","700"],"popularity":1292},{"family":"Alumni Sans Collegiate One","category":"sans-serif","subsets":["cyrillic","latin","latin-ext","vietnamese"],"variants":["regular","italic"],"popularity":1293},{"family":"Sree Krushnadevaraya","category":"serif","subsets":["latin","telugu"],"variants":["regular"],"popularity":1294},{"family":"Carrois Gothic SC","category":"sans-serif","subsets":["latin"],"variants":["regular"],"popularity":1295},{"family":"Bruno Ace","category":"display","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":1296},{"family":"Dekko","category":"handwriting","subsets":["devanagari","latin","latin-ext"],"variants":["regular"],"popularity":1297},{"family":"Marhey","category":"display","subsets":["arabic","latin","latin-ext"],"variants":["300","regular","500","600","700"],"popularity":1298},{"family":"Ysabeau","category":"sans-serif","subsets":["cyrillic","cyrillic-ext","greek","latin","latin-ext","math","symbols","vietnamese"],"variants":["100","200","300","regular","500","600","700","800","900","100italic","200italic","300italic","italic","500italic","600italic","700italic","800italic","900italic"],"popularity":1299},{"family":"Bilbo","category":"handwriting","subsets":["latin","latin-ext","vietnamese"],"variants":["regular"],"popularity":1300},{"family":"Tourney","category":"display","subsets":["latin","latin-ext","vietnamese"],"variants":["100","200","300","regular","500","600","700","800","900","100italic","200italic","300italic","italic","500italic","600italic","700italic","800italic","900italic"],"popularity":1301},{"family":"Baskervville SC","category":"serif","subsets":["latin","latin-ext"],"variants":["regular","500","600","700"],"popularity":1302},{"family":"Ramaraja","category":"serif","subsets":["latin","telugu"],"variants":["regular"],"popularity":1303},{"family":"Alumni Sans Pinstripe","category":"sans-serif","subsets":["cyrillic","cyrillic-ext","latin","latin-ext","vietnamese"],"variants":["regular","italic"],"popularity":1304},{"family":"Joan","category":"serif","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":1305},{"family":"Noto Serif Kannada","category":"serif","subsets":["kannada","latin","latin-ext"],"variants":["100","200","300","regular","500","600","700","800","900"],"popularity":1306},{"family":"Noto Sans Gunjala Gondi","category":"sans-serif","subsets":["gunjala-gondi","latin","latin-ext"],"variants":["regular","500","600","700"],"popularity":1307},{"family":"Noto Sans Tagalog","category":"sans-serif","subsets":["latin","latin-ext","tagalog"],"variants":["regular"],"popularity":1308},{"family":"Averia Gruesa Libre","category":"display","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":1309},{"family":"Noto Sans Gurmukhi","category":"sans-serif","subsets":["gurmukhi","latin","latin-ext"],"variants":["100","200","300","regular","500","600","700","800","900"],"popularity":1310},{"family":"Noto Sans Thaana","category":"sans-serif","subsets":["latin","latin-ext","thaana"],"variants":["100","200","300","regular","500","600","700","800","900"],"popularity":1311},{"family":"Marko One","category":"serif","subsets":["latin"],"variants":["regular"],"popularity":1312},{"family":"Orbit","category":"sans-serif","subsets":["korean","latin","latin-ext"],"variants":["regular"],"popularity":1313},{"family":"Montserrat Underline","category":"sans-serif","subsets":["cyrillic","cyrillic-ext","latin","latin-ext","vietnamese"],"variants":["100","200","300","regular","500","600","700","800","900","100italic","200italic","300italic","italic","500italic","600italic","700italic","800italic","900italic"],"popularity":1314},{"family":"Ravi Prakash","category":"display","subsets":["latin","telugu"],"variants":["regular"],"popularity":1315},{"family":"Stoke","category":"serif","subsets":["latin","latin-ext"],"variants":["300","regular"],"popularity":1316},{"family":"Rubik Moonrocks","category":"display","subsets":["cyrillic","cyrillic-ext","hebrew","latin","latin-ext"],"variants":["regular"],"popularity":1317},{"family":"TASA Orbiter","category":"sans-serif","subsets":["latin","latin-ext"],"variants":["regular","500","600","700","800"],"popularity":1318},{"family":"Farsan","category":"display","subsets":["gujarati","latin","latin-ext","vietnamese"],"variants":["regular"],"popularity":1319},{"family":"Science Gothic","category":"sans-serif","subsets":["cyrillic","cyrillic-ext","latin","latin-ext","vietnamese"],"variants":["100","200","300","regular","500","600","700","800","900"],"popularity":1320},{"family":"Noto Sans Canadian Aboriginal","category":"sans-serif","subsets":["canadian-aboriginal","latin","latin-ext","math","symbols"],"variants":["100","200","300","regular","500","600","700","800","900"],"popularity":1321},{"family":"Stack Sans Text","category":"sans-serif","subsets":["latin","latin-ext"],"variants":["200","300","regular","500","600","700"],"popularity":1322},{"family":"LXGW WenKai TC","category":"handwriting","subsets":["chinese-traditional","cyrillic","cyrillic-ext","greek","greek-ext","latin","latin-ext","lisu","vietnamese"],"variants":["300","regular","700"],"popularity":1323},{"family":"Romanesco","category":"handwriting","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":1324},{"family":"Tiny5","category":"sans-serif","subsets":["cyrillic","cyrillic-ext","greek","latin","latin-ext"],"variants":["regular"],"popularity":1325},{"family":"Offside","category":"display","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":1326},{"family":"Milonga","category":"display","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":1327},{"family":"Noto Sans Linear A","category":"sans-serif","subsets":["latin","latin-ext","linear-a"],"variants":["regular"],"popularity":1328},{"family":"Uchen","category":"serif","subsets":["latin","tibetan"],"variants":["regular"],"popularity":1329},{"family":"Poor Story","category":"display","subsets":["korean","latin"],"variants":["regular"],"popularity":1330},{"family":"Noto Sans Samaritan","category":"sans-serif","subsets":["latin","latin-ext","samaritan"],"variants":["regular"],"popularity":1331},{"family":"Amiri Quran","category":"serif","subsets":["arabic","latin"],"variants":["regular"],"popularity":1332},{"family":"Tillana","category":"display","subsets":["devanagari","latin","latin-ext"],"variants":["regular","500","600","700","800"],"popularity":1333},{"family":"Tiro Devanagari Marathi","category":"serif","subsets":["devanagari","latin","latin-ext"],"variants":["regular","italic"],"popularity":1334},{"family":"Anek Kannada","category":"sans-serif","subsets":["kannada","latin","latin-ext"],"variants":["100","200","300","regular","500","600","700","800"],"popularity":1335},{"family":"Castoro Titling","category":"display","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":1336},{"family":"Emblema One","category":"display","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":1337},{"family":"Condiment","category":"handwriting","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":1338},{"family":"Beiruti","category":"sans-serif","subsets":["arabic","latin","latin-ext","vietnamese"],"variants":["200","300","regular","500","600","700","800","900"],"popularity":1339},{"family":"Simonetta","category":"display","subsets":["latin","latin-ext"],"variants":["regular","italic","900","900italic"],"popularity":1340},{"family":"Playwrite IN","category":"handwriting","subsets":["latin"],"variants":["100","200","300","regular"],"popularity":1341},{"family":"Bruno Ace SC","category":"display","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":1342},{"family":"Victor Mono","category":"monospace","subsets":["cyrillic","cyrillic-ext","greek","latin","latin-ext","vietnamese"],"variants":["100","200","300","regular","500","600","700","100italic","200italic","300italic","italic","500italic","600italic","700italic"],"popularity":1343},{"family":"Rubik Iso","category":"display","subsets":["cyrillic","cyrillic-ext","hebrew","latin","latin-ext"],"variants":["regular"],"popularity":1344},{"family":"Mogra","category":"display","subsets":["gujarati","latin","latin-ext"],"variants":["regular"],"popularity":1345},{"family":"Dai Banna SIL","category":"serif","subsets":["latin","latin-ext","new-tai-lue"],"variants":["300","300italic","regular","italic","500","500italic","600","600italic","700","700italic"],"popularity":1346},{"family":"Metal","category":"display","subsets":["khmer","latin"],"variants":["regular"],"popularity":1347},{"family":"Cagliostro","category":"sans-serif","subsets":["latin"],"variants":["regular"],"popularity":1348},{"family":"Moderustic","category":"sans-serif","subsets":["cyrillic","cyrillic-ext","greek","latin","latin-ext"],"variants":["300","regular","500","600","700","800"],"popularity":1349},{"family":"Plaster","category":"display","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":1350},{"family":"Noto Sans Sora Sompeng","category":"sans-serif","subsets":["latin","latin-ext","sora-sompeng"],"variants":["regular","500","600","700"],"popularity":1351},{"family":"Inika","category":"serif","subsets":["latin","latin-ext"],"variants":["regular","700"],"popularity":1352},{"family":"Siemreap","category":"sans-serif","subsets":["khmer"],"variants":["regular"],"popularity":1353},{"family":"Smokum","category":"display","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":1354},{"family":"Kite One","category":"sans-serif","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":1355},{"family":"Joti One","category":"display","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":1356},{"family":"Iosevka Charon","category":"monospace","subsets":["armenian","braille","cyrillic","cyrillic-ext","greek","greek-ext","latin","latin-ext","math","symbols","symbols2","vietnamese"],"variants":["300","300italic","regular","italic","500","500italic","700","700italic"],"popularity":1357},{"family":"Anek Gurmukhi","category":"sans-serif","subsets":["gurmukhi","latin","latin-ext"],"variants":["100","200","300","regular","500","600","700","800"],"popularity":1358},{"family":"Wittgenstein","category":"serif","subsets":["latin","latin-ext"],"variants":["regular","500","600","700","800","900","italic","500italic","600italic","700italic","800italic","900italic"],"popularity":1359},{"family":"Trykker","category":"serif","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":1360},{"family":"Noto Serif Sinhala","category":"serif","subsets":["latin","latin-ext","sinhala"],"variants":["100","200","300","regular","500","600","700","800","900"],"popularity":1361},{"family":"Dhurjati","category":"sans-serif","subsets":["latin","telugu"],"variants":["regular"],"popularity":1362},{"family":"Yaldevi","category":"sans-serif","subsets":["latin","latin-ext","sinhala"],"variants":["200","300","regular","500","600","700"],"popularity":1363},{"family":"Jacques Francois","category":"serif","subsets":["latin"],"variants":["regular"],"popularity":1364},{"family":"Noto Serif Lao","category":"serif","subsets":["lao","latin","latin-ext"],"variants":["100","200","300","regular","500","600","700","800","900"],"popularity":1365},{"family":"Kumar One","category":"display","subsets":["gujarati","latin","latin-ext"],"variants":["regular"],"popularity":1366},{"family":"Micro 5","category":"display","subsets":["latin","latin-ext","math","symbols"],"variants":["regular"],"popularity":1367},{"family":"Ruluko","category":"sans-serif","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":1368},{"family":"Playwrite CU","category":"handwriting","subsets":["latin"],"variants":["100","200","300","regular"],"popularity":1369},{"family":"Playwrite AT","category":"handwriting","subsets":["latin"],"variants":["100","200","300","regular","100italic","200italic","300italic","italic"],"popularity":1370},{"family":"Comforter Brush","category":"handwriting","subsets":["cyrillic","latin","latin-ext","vietnamese"],"variants":["regular"],"popularity":1371},{"family":"Tilt Prism","category":"display","subsets":["latin","latin-ext","vietnamese"],"variants":["regular"],"popularity":1372},{"family":"Donegal One","category":"serif","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":1373},{"family":"Playwrite VN Guides","category":"handwriting","subsets":["latin"],"variants":["regular"],"popularity":1374},{"family":"Grey Qo","category":"handwriting","subsets":["latin","latin-ext","vietnamese"],"variants":["regular"],"popularity":1375},{"family":"Bonbon","category":"handwriting","subsets":["latin"],"variants":["regular"],"popularity":1376},{"family":"Bitcount Single","category":"display","subsets":["latin","latin-ext"],"variants":["100","200","300","regular","500","600","700","800","900"],"popularity":1377},{"family":"Libre Barcode 39 Extended","category":"display","subsets":["latin"],"variants":["regular"],"popularity":1378},{"family":"Port Lligat Sans","category":"sans-serif","subsets":["latin"],"variants":["regular"],"popularity":1379},{"family":"Buda","category":"display","subsets":["latin"],"variants":["300"],"popularity":1380},{"family":"Jacques Francois Shadow","category":"display","subsets":["latin"],"variants":["regular"],"popularity":1381},{"family":"Bungee Hairline","category":"display","subsets":["latin","latin-ext","vietnamese"],"variants":["regular"],"popularity":1382},{"family":"Monomakh","category":"display","subsets":["cyrillic","cyrillic-ext","latin","latin-ext"],"variants":["regular"],"popularity":1383},{"family":"Elsie Swash Caps","category":"display","subsets":["latin","latin-ext"],"variants":["regular","900"],"popularity":1384},{"family":"Tiro Devanagari Sanskrit","category":"serif","subsets":["devanagari","latin","latin-ext"],"variants":["regular","italic"],"popularity":1385},{"family":"Stalinist One","category":"display","subsets":["cyrillic","latin","latin-ext"],"variants":["regular"],"popularity":1386},{"family":"Encode Sans SC","category":"sans-serif","subsets":["latin","latin-ext","vietnamese"],"variants":["100","200","300","regular","500","600","700","800","900"],"popularity":1387},{"family":"Edu AU VIC WA NT Hand","category":"handwriting","subsets":["latin","latin-ext"],"variants":["regular","500","600","700"],"popularity":1388},{"family":"Water Brush","category":"handwriting","subsets":["latin","latin-ext","vietnamese"],"variants":["regular"],"popularity":1389},{"family":"Tiro Gurmukhi","category":"serif","subsets":["gurmukhi","latin","latin-ext"],"variants":["regular","italic"],"popularity":1390},{"family":"LXGW WenKai Mono TC","category":"monospace","subsets":["chinese-traditional","cyrillic","cyrillic-ext","greek","greek-ext","latin","latin-ext","lisu","vietnamese"],"variants":["300","regular","700"],"popularity":1391},{"family":"Lakki Reddy","category":"handwriting","subsets":["latin","telugu"],"variants":["regular"],"popularity":1392},{"family":"Galindo","category":"display","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":1393},{"family":"Autour One","category":"display","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":1394},{"family":"Ewert","category":"display","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":1395},{"family":"Stack Sans Headline","category":"sans-serif","subsets":["latin","latin-ext"],"variants":["200","300","regular","500","600","700"],"popularity":1396},{"family":"Fascinate Inline","category":"display","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":1397},{"family":"Risque","category":"display","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":1398},{"family":"Blaka","category":"display","subsets":["arabic","latin","latin-ext"],"variants":["regular"],"popularity":1399},{"family":"Libertinus Sans","category":"sans-serif","subsets":["cyrillic","cyrillic-ext","greek","greek-ext","latin","latin-ext","vietnamese"],"variants":["regular","italic","700"],"popularity":1400},{"family":"Sirin Stencil","category":"display","subsets":["latin"],"variants":["regular"],"popularity":1401},{"family":"Galdeano","category":"sans-serif","subsets":["latin"],"variants":["regular"],"popularity":1402},{"family":"Bungee Outline","category":"display","subsets":["latin","latin-ext","vietnamese"],"variants":["regular"],"popularity":1403},{"family":"Fleur De Leah","category":"handwriting","subsets":["latin","latin-ext","vietnamese"],"variants":["regular"],"popularity":1404},{"family":"Noto Serif Tamil","category":"serif","subsets":["latin","latin-ext","tamil"],"variants":["100","200","300","regular","500","600","700","800","900","100italic","200italic","300italic","italic","500italic","600italic","700italic","800italic","900italic"],"popularity":1405},{"family":"Piedra","category":"display","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":1406},{"family":"Sedan","category":"serif","subsets":["latin","latin-ext"],"variants":["regular","italic"],"popularity":1407},{"family":"Splash","category":"handwriting","subsets":["latin","latin-ext","vietnamese"],"variants":["regular"],"popularity":1408},{"family":"Revalia","category":"display","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":1409},{"family":"Manufacturing Consent","category":"display","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":1410},{"family":"Nabla","category":"display","subsets":["cyrillic-ext","latin","latin-ext","math","vietnamese"],"variants":["regular"],"popularity":1411},{"family":"Playwrite AU NSW","category":"handwriting","subsets":["latin"],"variants":["100","200","300","regular"],"popularity":1412},{"family":"Bodoni Moda SC","category":"serif","subsets":["latin","latin-ext","math","symbols"],"variants":["regular","500","600","700","800","900","italic","500italic","600italic","700italic","800italic","900italic"],"popularity":1413},{"family":"Jersey 20","category":"display","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":1414},{"family":"Noto Rashi Hebrew","category":"serif","subsets":["greek-ext","hebrew","latin","latin-ext"],"variants":["100","200","300","regular","500","600","700","800","900"],"popularity":1415},{"family":"Rubik Distressed","category":"display","subsets":["cyrillic","cyrillic-ext","hebrew","latin","latin-ext"],"variants":["regular"],"popularity":1416},{"family":"Momo Trust Display","category":"sans-serif","subsets":["latin","latin-ext","vietnamese"],"variants":["regular"],"popularity":1417},{"family":"Meera Inimai","category":"sans-serif","subsets":["latin","tamil"],"variants":["regular"],"popularity":1418},{"family":"Felipa","category":"handwriting","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":1419},{"family":"Bigelow Rules","category":"display","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":1420},{"family":"Tiro Telugu","category":"serif","subsets":["latin","latin-ext","telugu"],"variants":["regular","italic"],"popularity":1421},{"family":"Preahvihear","category":"sans-serif","subsets":["khmer","latin"],"variants":["regular"],"popularity":1422},{"family":"Linden Hill","category":"serif","subsets":["latin","latin-ext"],"variants":["regular","italic"],"popularity":1423},{"family":"New Amsterdam","category":"sans-serif","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":1424},{"family":"Playwrite AU QLD","category":"handwriting","subsets":["latin"],"variants":["100","200","300","regular"],"popularity":1425},{"family":"Arsenal SC","category":"sans-serif","subsets":["cyrillic","cyrillic-ext","latin","latin-ext","vietnamese"],"variants":["regular","italic","700","700italic"],"popularity":1426},{"family":"Chilanka","category":"handwriting","subsets":["latin","latin-ext","malayalam"],"variants":["regular"],"popularity":1427},{"family":"Fruktur","category":"display","subsets":["cyrillic-ext","latin","latin-ext","vietnamese"],"variants":["regular","italic"],"popularity":1428},{"family":"Ribeye Marrow","category":"display","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":1429},{"family":"Konkhmer Sleokchher","category":"display","subsets":["khmer","latin","latin-ext"],"variants":["regular"],"popularity":1430},{"family":"Geostar Fill","category":"display","subsets":["latin"],"variants":["regular"],"popularity":1431},{"family":"Sixtyfour","category":"monospace","subsets":["latin","latin-ext","math","symbols"],"variants":["regular"],"popularity":1432},{"family":"Luxurious Roman","category":"display","subsets":["latin","latin-ext","vietnamese"],"variants":["regular"],"popularity":1433},{"family":"Lexend Tera","category":"sans-serif","subsets":["latin","latin-ext","vietnamese"],"variants":["100","200","300","regular","500","600","700","800","900"],"popularity":1434},{"family":"Tapestry","category":"handwriting","subsets":["latin","latin-ext","vietnamese"],"variants":["regular"],"popularity":1435},{"family":"Tulpen One","category":"display","subsets":["latin"],"variants":["regular"],"popularity":1436},{"family":"Rubik Pixels","category":"display","subsets":["cyrillic","cyrillic-ext","hebrew","latin","latin-ext"],"variants":["regular"],"popularity":1437},{"family":"Diplomata","category":"display","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":1438},{"family":"Londrina Sketch","category":"display","subsets":["latin"],"variants":["regular"],"popularity":1439},{"family":"Big Shoulders Stencil","category":"display","subsets":["latin","latin-ext","vietnamese"],"variants":["100","200","300","regular","500","600","700","800","900"],"popularity":1440},{"family":"Ojuju","category":"sans-serif","subsets":["latin","latin-ext","math","symbols","vietnamese"],"variants":["200","300","regular","500","600","700","800"],"popularity":1441},{"family":"Noto Sans Anatolian Hieroglyphs","category":"sans-serif","subsets":["anatolian-hieroglyphs","latin","latin-ext"],"variants":["regular"],"popularity":1442},{"family":"Lancelot","category":"display","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":1443},{"family":"Noto Serif Armenian","category":"serif","subsets":["armenian","latin","latin-ext"],"variants":["100","200","300","regular","500","600","700","800","900"],"popularity":1444},{"family":"Bacasime Antique","category":"serif","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":1445},{"family":"Caramel","category":"handwriting","subsets":["latin","latin-ext","vietnamese"],"variants":["regular"],"popularity":1446},{"family":"Almendra Display","category":"display","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":1447},{"family":"Ranga","category":"display","subsets":["devanagari","latin","latin-ext"],"variants":["regular","700"],"popularity":1448},{"family":"Noto Serif Gujarati","category":"serif","subsets":["gujarati","latin","latin-ext","math","symbols"],"variants":["100","200","300","regular","500","600","700","800","900"],"popularity":1449},{"family":"BioRhyme Expanded","category":"serif","subsets":["latin","latin-ext"],"variants":["200","300","regular","700","800"],"popularity":1450},{"family":"Noto Sans Pahawh Hmong","category":"sans-serif","subsets":["latin","latin-ext","pahawh-hmong"],"variants":["regular"],"popularity":1451},{"family":"Playpen Sans Arabic","category":"handwriting","subsets":["arabic","emoji","latin","latin-ext","math"],"variants":["100","200","300","regular","500","600","700","800"],"popularity":1452},{"family":"Playwrite US Modern","category":"handwriting","subsets":["latin"],"variants":["100","200","300","regular"],"popularity":1453},{"family":"Playwrite AU SA","category":"handwriting","subsets":["latin"],"variants":["100","200","300","regular"],"popularity":1454},{"family":"Jacquard 24","category":"display","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":1455},{"family":"Mozilla Headline","category":"sans-serif","subsets":["latin","latin-ext"],"variants":["200","300","regular","500","600","700"],"popularity":1456},{"family":"Neonderthaw","category":"handwriting","subsets":["latin","latin-ext","vietnamese"],"variants":["regular"],"popularity":1457},{"family":"Sofadi One","category":"display","subsets":["latin"],"variants":["regular"],"popularity":1458},{"family":"Noto Sans Tai Viet","category":"sans-serif","subsets":["latin","latin-ext","tai-viet"],"variants":["regular"],"popularity":1459},{"family":"Anek Odia","category":"sans-serif","subsets":["latin","latin-ext","oriya"],"variants":["100","200","300","regular","500","600","700","800"],"popularity":1460},{"family":"Mr Bedfort","category":"handwriting","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":1461},{"family":"Gidugu","category":"sans-serif","subsets":["latin","latin-ext","telugu"],"variants":["regular"],"popularity":1462},{"family":"Ponomar","category":"display","subsets":["cyrillic","cyrillic-ext","latin"],"variants":["regular"],"popularity":1463},{"family":"Chiron Hei HK","category":"sans-serif","subsets":["chinese-traditional","cyrillic","cyrillic-ext","greek","greek-ext","latin","latin-ext","symbols2","vietnamese"],"variants":["200","300","regular","500","600","700","800","900","200italic","300italic","italic","500italic","600italic","700italic","800italic","900italic"],"popularity":1464},{"family":"Playwrite GB S","category":"handwriting","subsets":["latin"],"variants":["100","200","300","regular","100italic","200italic","300italic","italic"],"popularity":1465},{"family":"Wellfleet","category":"serif","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":1466},{"family":"Alumni Sans Inline One","category":"display","subsets":["latin","latin-ext","vietnamese"],"variants":["regular","italic"],"popularity":1467},{"family":"Noto Serif Khmer","category":"serif","subsets":["khmer","latin","latin-ext"],"variants":["100","200","300","regular","500","600","700","800","900"],"popularity":1468},{"family":"GFS Neohellenic","category":"sans-serif","subsets":["greek","greek-ext","latin","vietnamese"],"variants":["regular","italic","700","700italic"],"popularity":1469},{"family":"Playwrite PL","category":"handwriting","subsets":["latin"],"variants":["100","200","300","regular"],"popularity":1470},{"family":"Ga Maamli","category":"display","subsets":["latin","latin-ext","vietnamese"],"variants":["regular"],"popularity":1471},{"family":"Jim Nightshade","category":"handwriting","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":1472},{"family":"Epunda Sans","category":"sans-serif","subsets":["latin","latin-ext"],"variants":["300","regular","500","600","700","800","900","300italic","italic","500italic","600italic","700italic","800italic","900italic"],"popularity":1473},{"family":"Updock","category":"handwriting","subsets":["latin","latin-ext","vietnamese"],"variants":["regular"],"popularity":1474},{"family":"Praise","category":"handwriting","subsets":["latin","latin-ext","vietnamese"],"variants":["regular"],"popularity":1475},{"family":"Babylonica","category":"handwriting","subsets":["latin","latin-ext","vietnamese"],"variants":["regular"],"popularity":1476},{"family":"Noto Sans Hanunoo","category":"sans-serif","subsets":["hanunoo","latin","latin-ext"],"variants":["regular"],"popularity":1477},{"family":"WDXL Lubrifont JP N","category":"sans-serif","subsets":["cyrillic","japanese","latin","latin-ext","symbols2"],"variants":["regular"],"popularity":1478},{"family":"Griffy","category":"display","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":1479},{"family":"Bahiana","category":"display","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":1480},{"family":"Huninn","category":"sans-serif","subsets":["chinese-traditional","cyrillic","latin","latin-ext","vietnamese"],"variants":["regular"],"popularity":1481},{"family":"Reem Kufi Ink","category":"sans-serif","subsets":["arabic","latin","latin-ext","vietnamese"],"variants":["regular"],"popularity":1482},{"family":"Noto Sans Lao Looped","category":"sans-serif","subsets":["lao","latin","latin-ext"],"variants":["100","200","300","regular","500","600","700","800","900"],"popularity":1483},{"family":"Miss Fajardose","category":"handwriting","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":1484},{"family":"Hubballi","category":"sans-serif","subsets":["kannada","latin","latin-ext"],"variants":["regular"],"popularity":1485},{"family":"Gideon Roman","category":"display","subsets":["latin","latin-ext","vietnamese"],"variants":["regular"],"popularity":1486},{"family":"Are You Serious","category":"handwriting","subsets":["latin","latin-ext","vietnamese"],"variants":["regular"],"popularity":1487},{"family":"Kirang Haerang","category":"display","subsets":["korean","latin"],"variants":["regular"],"popularity":1488},{"family":"Rubik Glitch Pop","category":"display","subsets":["cyrillic","cyrillic-ext","hebrew","latin","latin-ext","math","symbols"],"variants":["regular"],"popularity":1489},{"family":"Vend Sans","category":"sans-serif","subsets":["latin","latin-ext"],"variants":["300","regular","500","600","700","300italic","italic","500italic","600italic","700italic"],"popularity":1490},{"family":"Libertinus Math","category":"display","subsets":["cyrillic","cyrillic-ext","greek","greek-ext","latin","latin-ext","math","vietnamese"],"variants":["regular"],"popularity":1491},{"family":"Cascadia Code","category":"sans-serif","subsets":["arabic","braille","cyrillic","cyrillic-ext","greek","hebrew","latin","latin-ext","symbols2","vietnamese"],"variants":["200","300","regular","500","600","700","200italic","300italic","italic","500italic","600italic","700italic"],"popularity":1492},{"family":"BBH Bartle","category":"sans-serif","subsets":["latin"],"variants":["regular"],"popularity":1493},{"family":"Dr Sugiyama","category":"handwriting","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":1494},{"family":"Rubik Gemstones","category":"display","subsets":["cyrillic","cyrillic-ext","hebrew","latin","latin-ext"],"variants":["regular"],"popularity":1495},{"family":"Ancizar Serif","category":"serif","subsets":["greek","latin","latin-ext"],"variants":["300","regular","500","600","700","800","900","300italic","italic","500italic","600italic","700italic","800italic","900italic"],"popularity":1496},{"family":"Sixtyfour Convergence","category":"monospace","subsets":["latin","latin-ext","math","symbols"],"variants":["regular"],"popularity":1497},{"family":"Story Script","category":"sans-serif","subsets":["latin","latin-ext","vietnamese"],"variants":["regular"],"popularity":1498},{"family":"Jacquarda Bastarda 9","category":"display","subsets":["latin","latin-ext","math","symbols"],"variants":["regular"],"popularity":1499},{"family":"Grechen Fuemen","category":"handwriting","subsets":["latin","latin-ext","vietnamese"],"variants":["regular"],"popularity":1500},{"family":"Love Light","category":"handwriting","subsets":["latin","latin-ext","vietnamese"],"variants":["regular"],"popularity":1501},{"family":"Diplomata SC","category":"display","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":1502},{"family":"Passero One","category":"display","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":1503},{"family":"Send Flowers","category":"handwriting","subsets":["latin","latin-ext","vietnamese"],"variants":["regular"],"popularity":1504},{"family":"Flavors","category":"display","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":1505},{"family":"WDXL Lubrifont TC","category":"sans-serif","subsets":["chinese-traditional","cyrillic","latin","latin-ext","symbols2"],"variants":["regular"],"popularity":1506},{"family":"Tsukimi Rounded","category":"sans-serif","subsets":["japanese","latin","latin-ext"],"variants":["300","regular","500","600","700"],"popularity":1507},{"family":"Kumar One Outline","category":"display","subsets":["gujarati","latin","latin-ext"],"variants":["regular"],"popularity":1508},{"family":"Molle","category":"handwriting","subsets":["latin","latin-ext"],"variants":["italic"],"popularity":1509},{"family":"Butcherman","category":"display","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":1510},{"family":"Kings","category":"handwriting","subsets":["latin","latin-ext","vietnamese"],"variants":["regular"],"popularity":1511},{"family":"Noto Sans Tangsa","category":"sans-serif","subsets":["latin","latin-ext","tangsa"],"variants":["regular","500","600","700"],"popularity":1512},{"family":"Shizuru","category":"display","subsets":["japanese","latin"],"variants":["regular"],"popularity":1513},{"family":"Noto Sans Javanese","category":"sans-serif","subsets":["javanese","latin","latin-ext"],"variants":["regular","500","600","700"],"popularity":1514},{"family":"Aref Ruqaa Ink","category":"serif","subsets":["arabic","latin","latin-ext"],"variants":["regular","700"],"popularity":1515},{"family":"Princess Sofia","category":"handwriting","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":1516},{"family":"Momo Trust Sans","category":"sans-serif","subsets":["latin","latin-ext","vietnamese"],"variants":["200","300","regular","500","600","700","800"],"popularity":1517},{"family":"Peddana","category":"serif","subsets":["latin","telugu"],"variants":["regular"],"popularity":1518},{"family":"Libertinus Serif","category":"serif","subsets":["cyrillic","cyrillic-ext","greek","greek-ext","hebrew","latin","latin-ext","vietnamese"],"variants":["regular","italic","600","600italic","700","700italic"],"popularity":1519},{"family":"Noto Sans Adlam","category":"sans-serif","subsets":["adlam","latin","latin-ext"],"variants":["regular","500","600","700"],"popularity":1520},{"family":"Bitcount Prop Single","category":"display","subsets":["latin","latin-ext"],"variants":["100","200","300","regular","500","600","700","800","900"],"popularity":1521},{"family":"Oi","category":"display","subsets":["arabic","cyrillic","cyrillic-ext","greek","latin","latin-ext","tamil","vietnamese"],"variants":["regular"],"popularity":1522},{"family":"Rubik Vinyl","category":"display","subsets":["cyrillic","cyrillic-ext","hebrew","latin","latin-ext"],"variants":["regular"],"popularity":1523},{"family":"Chela One","category":"display","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":1524},{"family":"Ancizar Sans","category":"sans-serif","subsets":["greek","latin","latin-ext"],"variants":["100","200","300","regular","500","600","700","800","900","100italic","200italic","300italic","italic","500italic","600italic","700italic","800italic","900italic"],"popularity":1525},{"family":"Rubik Burned","category":"display","subsets":["cyrillic","cyrillic-ext","hebrew","latin","latin-ext"],"variants":["regular"],"popularity":1526},{"family":"Purple Purse","category":"display","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":1527},{"family":"Flow Rounded","category":"display","subsets":["cyrillic","cyrillic-ext","latin","latin-ext","vietnamese"],"variants":["regular"],"popularity":1528},{"family":"Oldenburg","category":"display","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":1529},{"family":"Annapurna SIL","category":"serif","subsets":["devanagari","latin","latin-ext","math","symbols"],"variants":["regular","700"],"popularity":1530},{"family":"Diphylleia","category":"serif","subsets":["korean","latin","latin-ext"],"variants":["regular"],"popularity":1531},{"family":"Foldit","category":"display","subsets":["latin","latin-ext","vietnamese"],"variants":["100","200","300","regular","500","600","700","800","900"],"popularity":1532},{"family":"Twinkle Star","category":"handwriting","subsets":["latin","latin-ext","vietnamese"],"variants":["regular"],"popularity":1533},{"family":"Mrs Sheppards","category":"handwriting","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":1534},{"family":"Iansui","category":"handwriting","subsets":["chinese-traditional","latin","latin-ext","symbols2"],"variants":["regular"],"popularity":1535},{"family":"Lunasima","category":"sans-serif","subsets":["cyrillic","cyrillic-ext","greek","greek-ext","hebrew","latin","latin-ext","vietnamese"],"variants":["regular","700"],"popularity":1536},{"family":"Geostar","category":"display","subsets":["latin"],"variants":["regular"],"popularity":1537},{"family":"Gidole","category":"sans-serif","subsets":["cyrillic","greek","latin","latin-ext","vietnamese"],"variants":["regular"],"popularity":1538},{"family":"Tiro Kannada","category":"serif","subsets":["kannada","latin","latin-ext"],"variants":["regular","italic"],"popularity":1539},{"family":"Atkinson Hyperlegible Mono","category":"sans-serif","subsets":["latin","latin-ext"],"variants":["200","300","regular","500","600","700","800","200italic","300italic","italic","500italic","600italic","700italic","800italic"],"popularity":1540},{"family":"Labrada","category":"serif","subsets":["latin","latin-ext","vietnamese"],"variants":["100","200","300","regular","500","600","700","800","900","100italic","200italic","300italic","italic","500italic","600italic","700italic","800italic","900italic"],"popularity":1541},{"family":"Noto Sans Nag Mundari","category":"sans-serif","subsets":["latin","latin-ext","nag-mundari"],"variants":["regular","500","600","700"],"popularity":1542},{"family":"Rubik Spray Paint","category":"display","subsets":["cyrillic","cyrillic-ext","hebrew","latin","latin-ext"],"variants":["regular"],"popularity":1543},{"family":"Snippet","category":"sans-serif","subsets":["latin"],"variants":["regular"],"popularity":1544},{"family":"Noto Serif Vithkuqi","category":"serif","subsets":["latin","latin-ext","vithkuqi"],"variants":["regular","500","600","700"],"popularity":1545},{"family":"TASA Explorer","category":"sans-serif","subsets":["latin","latin-ext"],"variants":["regular","500","600","700","800"],"popularity":1546},{"family":"Noto Serif Tangut","category":"serif","subsets":["latin","latin-ext","tangut"],"variants":["regular"],"popularity":1547},{"family":"Chenla","category":"display","subsets":["khmer"],"variants":["regular"],"popularity":1548},{"family":"Playwrite HU","category":"handwriting","subsets":["latin"],"variants":["100","200","300","regular"],"popularity":1549},{"family":"Edu VIC WA NT Beginner","category":"handwriting","subsets":["latin"],"variants":["regular","500","600","700"],"popularity":1550},{"family":"Bitcount Grid Single","category":"display","subsets":["latin","latin-ext"],"variants":["100","200","300","regular","500","600","700","800","900"],"popularity":1551},{"family":"Noto Sans Syriac","category":"sans-serif","subsets":["latin","latin-ext","syriac"],"variants":["100","200","300","regular","500","600","700","800","900"],"popularity":1552},{"family":"Ruge Boogie","category":"handwriting","subsets":["latin","latin-ext","vietnamese"],"variants":["regular"],"popularity":1553},{"family":"Edu AU VIC WA NT Dots","category":"handwriting","subsets":["latin","latin-ext"],"variants":["regular","500","600","700"],"popularity":1554},{"family":"Hanalei Fill","category":"display","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":1555},{"family":"Tiro Tamil","category":"serif","subsets":["latin","latin-ext","tamil"],"variants":["regular","italic"],"popularity":1556},{"family":"Noto Sans Coptic","category":"sans-serif","subsets":["coptic","latin","latin-ext"],"variants":["regular"],"popularity":1557},{"family":"Ubuntu Sans Mono","category":"monospace","subsets":["cyrillic","cyrillic-ext","greek","greek-ext","latin","latin-ext"],"variants":["regular","500","600","700","italic","500italic","600italic","700italic"],"popularity":1558},{"family":"Matemasie","category":"sans-serif","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":1559},{"family":"Playwrite DK Loopet","category":"handwriting","subsets":["latin"],"variants":["100","200","300","regular"],"popularity":1560},{"family":"WDXL Lubrifont SC","category":"sans-serif","subsets":["chinese-simplified","cyrillic","latin","latin-ext","symbols2"],"variants":["regular"],"popularity":1561},{"family":"Tai Heritage Pro","category":"serif","subsets":["latin","latin-ext","tai-viet","vietnamese"],"variants":["regular","700"],"popularity":1562},{"family":"Noto Sans Sunuwar","category":"sans-serif","subsets":["latin","latin-ext","sunuwar"],"variants":["regular"],"popularity":1563},{"family":"Rubik 80s Fade","category":"display","subsets":["cyrillic","cyrillic-ext","hebrew","latin","latin-ext"],"variants":["regular"],"popularity":1564},{"family":"Playwrite RO","category":"handwriting","subsets":["latin"],"variants":["100","200","300","regular"],"popularity":1565},{"family":"Elms Sans","category":"sans-serif","subsets":["latin","latin-ext","vietnamese"],"variants":["100","200","300","regular","500","600","700","800","900","100italic","200italic","300italic","italic","500italic","600italic","700italic","800italic","900italic"],"popularity":1566},{"family":"Trochut","category":"display","subsets":["latin"],"variants":["regular","italic","700"],"popularity":1567},{"family":"Playwrite MX Guides","category":"handwriting","subsets":["latin"],"variants":["regular"],"popularity":1568},{"family":"Suravaram","category":"serif","subsets":["latin","telugu"],"variants":["regular"],"popularity":1569},{"family":"Inspiration","category":"handwriting","subsets":["latin","latin-ext","vietnamese"],"variants":["regular"],"popularity":1570},{"family":"Sassy Frass","category":"handwriting","subsets":["latin","latin-ext","vietnamese"],"variants":["regular"],"popularity":1571},{"family":"Noto Serif Tibetan","category":"serif","subsets":["latin","latin-ext","tibetan"],"variants":["100","200","300","regular","500","600","700","800","900"],"popularity":1572},{"family":"Bungee Tint","category":"display","subsets":["latin","latin-ext","vietnamese"],"variants":["regular"],"popularity":1573},{"family":"Rubik Beastly","category":"display","subsets":["cyrillic","cyrillic-ext","hebrew","latin","latin-ext"],"variants":["regular"],"popularity":1574},{"family":"Taprom","category":"display","subsets":["khmer","latin"],"variants":["regular"],"popularity":1575},{"family":"Agu Display","category":"display","subsets":["latin","latin-ext","vietnamese"],"variants":["regular"],"popularity":1576},{"family":"Noto Serif Ethiopic","category":"serif","subsets":["ethiopic","latin","latin-ext"],"variants":["100","200","300","regular","500","600","700","800","900"],"popularity":1577},{"family":"Edu NSW ACT Foundation","category":"handwriting","subsets":["latin"],"variants":["regular","500","600","700"],"popularity":1578},{"family":"Noto Sans Carian","category":"sans-serif","subsets":["carian","latin","latin-ext"],"variants":["regular"],"popularity":1579},{"family":"Bitcount Grid Double","category":"display","subsets":["latin","latin-ext"],"variants":["100","200","300","regular","500","600","700","800","900"],"popularity":1580},{"family":"Protest Guerrilla","category":"display","subsets":["latin","latin-ext","math","symbols","vietnamese"],"variants":["regular"],"popularity":1581},{"family":"M PLUS Code Latin","category":"sans-serif","subsets":["latin","latin-ext","vietnamese"],"variants":["100","200","300","regular","500","600","700"],"popularity":1582},{"family":"Cossette Titre","category":"sans-serif","subsets":["latin","latin-ext"],"variants":["regular","700"],"popularity":1583},{"family":"Sekuya","category":"display","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":1584},{"family":"Edu AU VIC WA NT Pre","category":"handwriting","subsets":["latin","latin-ext"],"variants":["regular","500","600","700"],"popularity":1585},{"family":"Zen Loop","category":"display","subsets":["latin","latin-ext"],"variants":["regular","italic"],"popularity":1586},{"family":"Playwrite DK Uloopet Guides","category":"handwriting","subsets":["latin"],"variants":["regular"],"popularity":1587},{"family":"Black And White Picture","category":"display","subsets":["korean","latin"],"variants":["regular"],"popularity":1588},{"family":"Alumni Sans SC","category":"sans-serif","subsets":["cyrillic","cyrillic-ext","latin","latin-ext","vietnamese"],"variants":["100","200","300","regular","500","600","700","800","900","100italic","200italic","300italic","italic","500italic","600italic","700italic","800italic","900italic"],"popularity":1589},{"family":"Rubik Puddles","category":"display","subsets":["cyrillic","cyrillic-ext","hebrew","latin","latin-ext"],"variants":["regular"],"popularity":1590},{"family":"LXGW Marker Gothic","category":"sans-serif","subsets":["chinese-traditional","cyrillic","cyrillic-ext","greek","latin","latin-ext","symbols2","vietnamese"],"variants":["regular"],"popularity":1591},{"family":"Butterfly Kids","category":"handwriting","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":1592},{"family":"Noto Sans Syloti Nagri","category":"sans-serif","subsets":["latin","latin-ext","syloti-nagri"],"variants":["regular"],"popularity":1593},{"family":"Moulpali","category":"sans-serif","subsets":["khmer","latin"],"variants":["regular"],"popularity":1594},{"family":"Explora","category":"handwriting","subsets":["cherokee","latin","latin-ext","vietnamese"],"variants":["regular"],"popularity":1595},{"family":"Langar","category":"display","subsets":["gurmukhi","latin","latin-ext"],"variants":["regular"],"popularity":1596},{"family":"Combo","category":"display","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":1597},{"family":"Iosevka Charon Mono","category":"monospace","subsets":["armenian","braille","cyrillic","cyrillic-ext","greek","greek-ext","latin","latin-ext","math","symbols","symbols2","vietnamese"],"variants":["300","300italic","regular","italic","500","500italic","700","700italic"],"popularity":1598},{"family":"Triodion","category":"display","subsets":["cyrillic","cyrillic-ext","latin"],"variants":["regular"],"popularity":1599},{"family":"Noto Sans Duployan","category":"sans-serif","subsets":["duployan","latin","latin-ext"],"variants":["regular","700"],"popularity":1600},{"family":"Aubrey","category":"display","subsets":["latin"],"variants":["regular"],"popularity":1601},{"family":"Workbench","category":"monospace","subsets":["latin","math","symbols"],"variants":["regular"],"popularity":1602},{"family":"Vibes","category":"display","subsets":["arabic","latin"],"variants":["regular"],"popularity":1603},{"family":"Chiron Sung HK","category":"serif","subsets":["chinese-hongkong","cyrillic","cyrillic-ext","greek","latin","latin-ext","symbols2","vietnamese"],"variants":["200","300","regular","500","600","700","800","900","200italic","300italic","italic","500italic","600italic","700italic","800italic","900italic"],"popularity":1604},{"family":"Cascadia Mono","category":"sans-serif","subsets":["arabic","braille","cyrillic","cyrillic-ext","greek","hebrew","latin","latin-ext","symbols2","vietnamese"],"variants":["200","300","regular","500","600","700","200italic","300italic","italic","500italic","600italic","700italic"],"popularity":1605},{"family":"Savate","category":"sans-serif","subsets":["latin","latin-ext"],"variants":["200","300","regular","500","600","700","800","900","200italic","300italic","italic","500italic","600italic","700italic","800italic","900italic"],"popularity":1606},{"family":"Hind Mysuru","category":"sans-serif","subsets":["kannada","latin","latin-ext"],"variants":["300","regular","500","600","700"],"popularity":1607},{"family":"Chokokutai","category":"display","subsets":["japanese","latin","latin-ext","vietnamese"],"variants":["regular"],"popularity":1608},{"family":"Playpen Sans Hebrew","category":"handwriting","subsets":["emoji","hebrew","latin","latin-ext","math"],"variants":["100","200","300","regular","500","600","700","800"],"popularity":1609},{"family":"Flow Block","category":"display","subsets":["cyrillic","cyrillic-ext","latin","latin-ext","vietnamese"],"variants":["regular"],"popularity":1610},{"family":"Playwrite DE SAS","category":"handwriting","subsets":["latin"],"variants":["100","200","300","regular"],"popularity":1611},{"family":"Tagesschrift","category":"display","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":1612},{"family":"Stack Sans Notch","category":"sans-serif","subsets":["latin","latin-ext"],"variants":["200","300","regular","500","600","700"],"popularity":1613},{"family":"Noto Sans New Tai Lue","category":"sans-serif","subsets":["latin","latin-ext","new-tai-lue"],"variants":["regular","500","600","700"],"popularity":1614},{"family":"Kolker Brush","category":"handwriting","subsets":["latin","latin-ext","vietnamese"],"variants":["regular"],"popularity":1615},{"family":"Moirai One","category":"display","subsets":["korean","latin","latin-ext"],"variants":["regular"],"popularity":1616},{"family":"Playpen Sans Deva","category":"handwriting","subsets":["devanagari","emoji","latin","latin-ext"],"variants":["100","200","300","regular","500","600","700","800"],"popularity":1617},{"family":"Gajraj One","category":"display","subsets":["devanagari","latin","latin-ext"],"variants":["regular"],"popularity":1618},{"family":"Playwrite CA","category":"handwriting","subsets":["latin"],"variants":["100","200","300","regular"],"popularity":1619},{"family":"Noto Sans Cypro Minoan","category":"sans-serif","subsets":["cypro-minoan","latin","latin-ext"],"variants":["regular"],"popularity":1620},{"family":"Playwrite ZA","category":"handwriting","subsets":["latin"],"variants":["100","200","300","regular"],"popularity":1621},{"family":"Estonia","category":"handwriting","subsets":["latin","latin-ext","vietnamese"],"variants":["regular"],"popularity":1622},{"family":"Bahianita","category":"display","subsets":["latin","latin-ext","vietnamese"],"variants":["regular"],"popularity":1623},{"family":"Noto Serif Toto","category":"serif","subsets":["latin","latin-ext","toto"],"variants":["regular","500","600","700"],"popularity":1624},{"family":"Noto Sans Multani","category":"sans-serif","subsets":["latin","latin-ext","multani"],"variants":["regular"],"popularity":1625},{"family":"Jaini","category":"display","subsets":["devanagari","latin","latin-ext"],"variants":["regular"],"popularity":1626},{"family":"Cherish","category":"handwriting","subsets":["latin","latin-ext","vietnamese"],"variants":["regular"],"popularity":1627},{"family":"Kalnia Glaze","category":"display","subsets":["latin","latin-ext"],"variants":["100","200","300","regular","500","600","700"],"popularity":1628},{"family":"Playwrite NL","category":"handwriting","subsets":["latin"],"variants":["100","200","300","regular"],"popularity":1629},{"family":"Libertinus Mono","category":"monospace","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":1630},{"family":"Playwrite PE","category":"handwriting","subsets":["latin"],"variants":["100","200","300","regular"],"popularity":1631},{"family":"Bitcount","category":"display","subsets":["latin","latin-ext"],"variants":["100","200","300","regular","500","600","700","800","900"],"popularity":1632},{"family":"BBH Hegarty","category":"sans-serif","subsets":["latin"],"variants":["regular"],"popularity":1633},{"family":"Danfo","category":"serif","subsets":["latin","latin-ext","vietnamese"],"variants":["regular"],"popularity":1634},{"family":"Noto Serif Balinese","category":"serif","subsets":["balinese","latin","latin-ext"],"variants":["regular"],"popularity":1635},{"family":"Noto Sans Warang Citi","category":"sans-serif","subsets":["latin","latin-ext","warang-citi"],"variants":["regular"],"popularity":1636},{"family":"Noto Sans Kaithi","category":"sans-serif","subsets":["kaithi","latin","latin-ext"],"variants":["regular"],"popularity":1637},{"family":"Redacted Script","category":"display","subsets":["latin","latin-ext"],"variants":["300","regular","700"],"popularity":1638},{"family":"Playwrite FR Moderne","category":"handwriting","subsets":["latin"],"variants":["100","200","300","regular"],"popularity":1639},{"family":"Winky Rough","category":"sans-serif","subsets":["latin","latin-ext"],"variants":["300","regular","500","600","700","800","900","300italic","italic","500italic","600italic","700italic","800italic","900italic"],"popularity":1640},{"family":"Menbere","category":"sans-serif","subsets":["ethiopic","latin","latin-ext","vietnamese"],"variants":["100","200","300","regular","500","600","700"],"popularity":1641},{"family":"Noto Sans Glagolitic","category":"sans-serif","subsets":["cyrillic-ext","glagolitic","latin","latin-ext","math","symbols"],"variants":["regular"],"popularity":1642},{"family":"Slackside One","category":"handwriting","subsets":["japanese","latin","latin-ext"],"variants":["regular"],"popularity":1643},{"family":"Snowburst One","category":"display","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":1644},{"family":"Noto Serif Yezidi","category":"serif","subsets":["latin","latin-ext","yezidi"],"variants":["regular","500","600","700"],"popularity":1645},{"family":"Noto Sans Adlam Unjoined","category":"sans-serif","subsets":["adlam","latin","latin-ext"],"variants":["regular","500","600","700"],"popularity":1646},{"family":"Rubik Microbe","category":"display","subsets":["cyrillic","cyrillic-ext","hebrew","latin","latin-ext"],"variants":["regular"],"popularity":1647},{"family":"Noto Sans Old North Arabian","category":"sans-serif","subsets":["latin","latin-ext","old-north-arabian"],"variants":["regular"],"popularity":1648},{"family":"Puppies Play","category":"handwriting","subsets":["latin","latin-ext","vietnamese"],"variants":["regular"],"popularity":1649},{"family":"Mingzat","category":"sans-serif","subsets":["latin","latin-ext","lepcha"],"variants":["regular"],"popularity":1650},{"family":"Asimovian","category":"sans-serif","subsets":["latin","latin-ext","vietnamese"],"variants":["regular"],"popularity":1651},{"family":"Noto Sans Shavian","category":"sans-serif","subsets":["latin","latin-ext","shavian"],"variants":["regular"],"popularity":1652},{"family":"Palette Mosaic","category":"display","subsets":["japanese","latin"],"variants":["regular"],"popularity":1653},{"family":"Rock 3D","category":"display","subsets":["japanese","latin"],"variants":["regular"],"popularity":1654},{"family":"Playwrite ES","category":"handwriting","subsets":["latin"],"variants":["100","200","300","regular"],"popularity":1655},{"family":"Petemoss","category":"handwriting","subsets":["latin","latin-ext","vietnamese"],"variants":["regular"],"popularity":1656},{"family":"Playwrite NZ Basic","category":"handwriting","subsets":["latin"],"variants":["100","200","300","regular"],"popularity":1657},{"family":"Noto Sans Syriac Eastern","category":"sans-serif","subsets":["latin","latin-ext","syriac"],"variants":["100","200","300","regular","500","600","700","800","900"],"popularity":1658},{"family":"Noto Sans Old Italic","category":"sans-serif","subsets":["latin","latin-ext","old-italic"],"variants":["regular"],"popularity":1659},{"family":"SUSE Mono","category":"sans-serif","subsets":["latin","latin-ext","vietnamese"],"variants":["100","200","300","regular","500","600","700","800","100italic","200italic","300italic","italic","500italic","600italic","700italic","800italic"],"popularity":1660},{"family":"Phetsarath","category":"sans-serif","subsets":["lao"],"variants":["regular","700"],"popularity":1661},{"family":"Playwrite IT Moderna","category":"handwriting","subsets":["latin"],"variants":["100","200","300","regular"],"popularity":1662},{"family":"UoqMunThenKhung","category":"serif","subsets":["chinese-traditional","cyrillic","latin","symbols2"],"variants":["regular"],"popularity":1663},{"family":"Syne Tactile","category":"display","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":1664},{"family":"Noto Serif Gurmukhi","category":"serif","subsets":["gurmukhi","latin","latin-ext"],"variants":["100","200","300","regular","500","600","700","800","900"],"popularity":1665},{"family":"Coral Pixels","category":"display","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":1666},{"family":"Noto Sans Takri","category":"sans-serif","subsets":["latin","latin-ext","takri"],"variants":["regular"],"popularity":1667},{"family":"Noto Sans Mongolian","category":"sans-serif","subsets":["latin","latin-ext","math","mongolian","symbols"],"variants":["regular"],"popularity":1668},{"family":"Alyamama","category":"serif","subsets":["arabic","greek","latin","latin-ext"],"variants":["300","regular","500","600","700","800","900"],"popularity":1669},{"family":"Noto Serif Makasar","category":"serif","subsets":["latin","latin-ext","makasar"],"variants":["regular"],"popularity":1670},{"family":"Noto Serif Khitan Small Script","category":"serif","subsets":["khitan-small-script","latin","latin-ext"],"variants":["regular"],"popularity":1671},{"family":"Epunda Slab","category":"serif","subsets":["latin","latin-ext"],"variants":["300","regular","500","600","700","800","900","300italic","italic","500italic","600italic","700italic","800italic","900italic"],"popularity":1672},{"family":"Edu SA Hand","category":"handwriting","subsets":["latin","latin-ext"],"variants":["regular","500","600","700"],"popularity":1673},{"family":"Lilex","category":"monospace","subsets":["cyrillic","cyrillic-ext","greek","latin","latin-ext","symbols2","vietnamese"],"variants":["100","200","300","regular","500","600","700","100italic","200italic","300italic","italic","500italic","600italic","700italic"],"popularity":1674},{"family":"Playpen Sans Thai","category":"handwriting","subsets":["emoji","latin","latin-ext","math","thai"],"variants":["100","200","300","regular","500","600","700","800"],"popularity":1675},{"family":"Noto Sans Lisu","category":"sans-serif","subsets":["latin","latin-ext","lisu"],"variants":["regular","500","600","700"],"popularity":1676},{"family":"Playwrite BE WAL","category":"handwriting","subsets":["latin"],"variants":["100","200","300","regular"],"popularity":1677},{"family":"Noto Sans Palmyrene","category":"sans-serif","subsets":["latin","latin-ext","palmyrene"],"variants":["regular"],"popularity":1678},{"family":"Noto Serif Oriya","category":"serif","subsets":["latin","latin-ext","oriya"],"variants":["regular","500","600","700"],"popularity":1679},{"family":"Noto Sans Cherokee","category":"sans-serif","subsets":["cherokee","latin","latin-ext"],"variants":["100","200","300","regular","500","600","700","800","900"],"popularity":1680},{"family":"Noto Serif NP Hmong","category":"serif","subsets":["latin","nyiakeng-puachue-hmong"],"variants":["regular","500","600","700"],"popularity":1681},{"family":"Grandiflora One","category":"serif","subsets":["korean","latin","latin-ext"],"variants":["regular"],"popularity":1682},{"family":"Noto Traditional Nushu","category":"sans-serif","subsets":["latin","latin-ext","nushu"],"variants":["300","regular","500","600","700"],"popularity":1683},{"family":"Ole","category":"handwriting","subsets":["latin","latin-ext","vietnamese"],"variants":["regular"],"popularity":1684},{"family":"Noto Sans Bamum","category":"sans-serif","subsets":["bamum","latin","latin-ext"],"variants":["regular","500","600","700"],"popularity":1685},{"family":"Rubik Marker Hatch","category":"display","subsets":["cyrillic","cyrillic-ext","hebrew","latin","latin-ext"],"variants":["regular"],"popularity":1686},{"family":"Linefont","category":"display","subsets":["latin"],"variants":["100","200","300","regular","500","600","700","800","900"],"popularity":1687},{"family":"Playwrite HR","category":"handwriting","subsets":["latin"],"variants":["100","200","300","regular"],"popularity":1688},{"family":"Bytesized","category":"sans-serif","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":1689},{"family":"Playwrite BE VLG","category":"handwriting","subsets":["latin"],"variants":["100","200","300","regular"],"popularity":1690},{"family":"Parastoo","category":"serif","subsets":["arabic","latin","latin-ext","vietnamese"],"variants":["regular","500","600","700"],"popularity":1691},{"family":"Noto Sans Old Hungarian","category":"sans-serif","subsets":["latin","latin-ext","old-hungarian"],"variants":["regular"],"popularity":1692},{"family":"Intel One Mono","category":"monospace","subsets":["latin","latin-ext","symbols2","vietnamese"],"variants":["300","300italic","regular","italic","500","500italic","600","600italic","700","700italic"],"popularity":1693},{"family":"Datatype","category":"monospace","subsets":["latin","latin-ext"],"variants":["100","200","300","regular","500","600","700","800","900"],"popularity":1694},{"family":"Noto Sans Egyptian Hieroglyphs","category":"sans-serif","subsets":["egyptian-hieroglyphs","latin","latin-ext"],"variants":["regular"],"popularity":1695},{"family":"Playwrite NO","category":"handwriting","subsets":["latin"],"variants":["100","200","300","regular"],"popularity":1696},{"family":"Noto Sans Elbasan","category":"sans-serif","subsets":["elbasan","latin","latin-ext"],"variants":["regular"],"popularity":1697},{"family":"Rubik Broken Fax","category":"display","subsets":["cyrillic","cyrillic-ext","hebrew","latin","latin-ext","math","symbols"],"variants":["regular"],"popularity":1698},{"family":"Shafarik","category":"display","subsets":["cyrillic","cyrillic-ext","glagolitic","latin","latin-ext"],"variants":["regular"],"popularity":1699},{"family":"Karla Tamil Inclined","category":"sans-serif","subsets":["tamil"],"variants":["regular","700"],"popularity":1700},{"family":"Playwrite MX","category":"handwriting","subsets":["latin"],"variants":["100","200","300","regular"],"popularity":1701},{"family":"Noto Sans Yi","category":"sans-serif","subsets":["latin","latin-ext","yi"],"variants":["regular"],"popularity":1702},{"family":"Bitcount Single Ink","category":"display","subsets":["latin","latin-ext"],"variants":["100","200","300","regular","500","600","700","800","900"],"popularity":1703},{"family":"Moo Lah Lah","category":"display","subsets":["latin","latin-ext","vietnamese"],"variants":["regular"],"popularity":1704},{"family":"BBH Bogle","category":"sans-serif","subsets":["latin"],"variants":["regular"],"popularity":1705},{"family":"Playwrite AU TAS","category":"handwriting","subsets":["latin"],"variants":["100","200","300","regular"],"popularity":1706},{"family":"Noto Sans Vithkuqi","category":"sans-serif","subsets":["latin","latin-ext","vithkuqi"],"variants":["regular","500","600","700"],"popularity":1707},{"family":"Bitcount Grid Single Ink","category":"display","subsets":["latin","latin-ext"],"variants":["100","200","300","regular","500","600","700","800","900"],"popularity":1708},{"family":"Playwrite PT","category":"handwriting","subsets":["latin"],"variants":["100","200","300","regular"],"popularity":1709},{"family":"Bitcount Prop Double Ink","category":"display","subsets":["latin","latin-ext"],"variants":["100","200","300","regular","500","600","700","800","900"],"popularity":1710},{"family":"Bitcount Grid Double Ink","category":"display","subsets":["latin","latin-ext"],"variants":["100","200","300","regular","500","600","700","800","900"],"popularity":1711},{"family":"Noto Serif Dogra","category":"serif","subsets":["dogra","latin","latin-ext"],"variants":["regular"],"popularity":1712},{"family":"Noto Sans Nandinagari","category":"sans-serif","subsets":["latin","latin-ext","nandinagari"],"variants":["regular"],"popularity":1713},{"family":"Matangi","category":"sans-serif","subsets":["devanagari","latin","latin-ext"],"variants":["300","regular","500","600","700","800","900"],"popularity":1714},{"family":"Hanalei","category":"display","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":1715},{"family":"Noto Serif Myanmar","category":"serif","subsets":["myanmar"],"variants":["100","200","300","regular","500","600","700","800","900"],"popularity":1716},{"family":"Yuji Hentaigana Akari","category":"handwriting","subsets":["japanese","latin","latin-ext"],"variants":["regular"],"popularity":1717},{"family":"Maname","category":"serif","subsets":["latin","latin-ext","sinhala","vietnamese"],"variants":["regular"],"popularity":1718},{"family":"Noto Sans Marchen","category":"sans-serif","subsets":["latin","latin-ext","marchen"],"variants":["regular"],"popularity":1719},{"family":"Rubik Storm","category":"display","subsets":["cyrillic","cyrillic-ext","hebrew","latin","latin-ext"],"variants":["regular"],"popularity":1720},{"family":"Ingrid Darling","category":"handwriting","subsets":["latin","latin-ext","vietnamese"],"variants":["regular"],"popularity":1721},{"family":"Playwrite HR Lijeva","category":"handwriting","subsets":["latin"],"variants":["100","200","300","regular"],"popularity":1722},{"family":"Playwrite CO Guides","category":"handwriting","subsets":["latin"],"variants":["regular"],"popularity":1723},{"family":"Bitcount Prop Single Ink","category":"display","subsets":["latin","latin-ext"],"variants":["100","200","300","regular","500","600","700","800","900"],"popularity":1724},{"family":"Bitcount Prop Double","category":"display","subsets":["latin","latin-ext"],"variants":["100","200","300","regular","500","600","700","800","900"],"popularity":1725},{"family":"Playwrite AR","category":"handwriting","subsets":["latin"],"variants":["100","200","300","regular"],"popularity":1726},{"family":"Rubik Maps","category":"display","subsets":["cyrillic","cyrillic-ext","hebrew","latin","latin-ext","math","symbols"],"variants":["regular"],"popularity":1727},{"family":"Bitcount Ink","category":"display","subsets":["latin","latin-ext"],"variants":["100","200","300","regular","500","600","700","800","900"],"popularity":1728},{"family":"Noto Sans Avestan","category":"sans-serif","subsets":["avestan","latin","latin-ext"],"variants":["regular"],"popularity":1729},{"family":"Lisu Bosa","category":"serif","subsets":["latin","latin-ext","lisu"],"variants":["200","200italic","300","300italic","regular","italic","500","500italic","600","600italic","700","700italic","800","800italic","900","900italic"],"popularity":1730},{"family":"Playwrite VN","category":"handwriting","subsets":["latin"],"variants":["100","200","300","regular"],"popularity":1731},{"family":"Rubik Doodle Triangles","category":"display","subsets":["cyrillic","cyrillic-ext","hebrew","latin","latin-ext","math","symbols"],"variants":["regular"],"popularity":1732},{"family":"Big Shoulders Inline","category":"display","subsets":["latin","latin-ext","vietnamese"],"variants":["100","200","300","regular","500","600","700","800","900"],"popularity":1733},{"family":"Jaini Purva","category":"display","subsets":["devanagari","latin","latin-ext"],"variants":["regular"],"popularity":1734},{"family":"Sirivennela","category":"sans-serif","subsets":["latin","telugu"],"variants":["regular"],"popularity":1735},{"family":"Noto Sans Indic Siyaq Numbers","category":"sans-serif","subsets":["indic-siyaq-numbers","latin","latin-ext"],"variants":["regular"],"popularity":1736},{"family":"Noto Sans Lydian","category":"sans-serif","subsets":["latin","latin-ext","lydian"],"variants":["regular"],"popularity":1737},{"family":"Noto Znamenny Musical Notation","category":"sans-serif","subsets":["latin","latin-ext","math","symbols","znamenny"],"variants":["regular"],"popularity":1738},{"family":"Edu QLD Beginner","category":"handwriting","subsets":["latin"],"variants":["regular","500","600","700"],"popularity":1739},{"family":"Playwrite SK","category":"handwriting","subsets":["latin"],"variants":["100","200","300","regular"],"popularity":1740},{"family":"Rubik Lines","category":"display","subsets":["cyrillic","cyrillic-ext","hebrew","latin","latin-ext","math","symbols"],"variants":["regular"],"popularity":1741},{"family":"Noto Sans Medefaidrin","category":"sans-serif","subsets":["latin","latin-ext","medefaidrin"],"variants":["regular","500","600","700"],"popularity":1742},{"family":"Noto Serif Grantha","category":"serif","subsets":["grantha","latin","latin-ext"],"variants":["regular"],"popularity":1743},{"family":"Noto Sans Ol Chiki","category":"sans-serif","subsets":["latin","latin-ext","ol-chiki"],"variants":["regular","500","600","700"],"popularity":1744},{"family":"Narnoor","category":"sans-serif","subsets":["gunjala-gondi","latin","latin-ext","math","symbols"],"variants":["regular","500","600","700","800"],"popularity":1745},{"family":"Noto Sans Cuneiform","category":"sans-serif","subsets":["cuneiform","latin","latin-ext"],"variants":["regular"],"popularity":1746},{"family":"Noto Sans NKo","category":"sans-serif","subsets":["latin","latin-ext","nko"],"variants":["regular"],"popularity":1747},{"family":"Tuffy","category":"sans-serif","subsets":["cyrillic","cyrillic-ext","greek","greek-ext","latin","latin-ext","phoenician"],"variants":["regular","italic","700","700italic"],"popularity":1748},{"family":"Bpmf Zihi Kai Std","category":"sans-serif","subsets":["chinese-traditional","latin","latin-ext"],"variants":["regular"],"popularity":1749},{"family":"Noto Sans Newa","category":"sans-serif","subsets":["latin","latin-ext","newa"],"variants":["regular"],"popularity":1750},{"family":"Noto Sans Brahmi","category":"sans-serif","subsets":["brahmi","latin","latin-ext","math","symbols"],"variants":["regular"],"popularity":1751},{"family":"Blaka Ink","category":"display","subsets":["arabic","latin","latin-ext"],"variants":["regular"],"popularity":1752},{"family":"Noto Sans Osage","category":"sans-serif","subsets":["latin","latin-ext","osage"],"variants":["regular"],"popularity":1753},{"family":"Amarna","category":"sans-serif","subsets":["latin","latin-ext"],"variants":["100","200","300","regular","500","600","700","100italic","200italic","300italic","italic","500italic","600italic","700italic"],"popularity":1754},{"family":"Playwrite CO","category":"handwriting","subsets":["latin"],"variants":["100","200","300","regular"],"popularity":1755},{"family":"Rubik Maze","category":"display","subsets":["cyrillic","cyrillic-ext","hebrew","latin","latin-ext"],"variants":["regular"],"popularity":1756},{"family":"Noto Sans Balinese","category":"sans-serif","subsets":["balinese","latin","latin-ext"],"variants":["regular","500","600","700"],"popularity":1757},{"family":"Ponnala","category":"display","subsets":["latin","telugu"],"variants":["regular"],"popularity":1758},{"family":"Playwrite DK Uloopet","category":"handwriting","subsets":["latin"],"variants":["100","200","300","regular"],"popularity":1759},{"family":"Edu NSW ACT Hand Pre","category":"handwriting","subsets":["latin","latin-ext"],"variants":["regular","500","600","700"],"popularity":1760},{"family":"Playwrite DE LA","category":"handwriting","subsets":["latin"],"variants":["100","200","300","regular"],"popularity":1761},{"family":"Noto Sans Tai Le","category":"sans-serif","subsets":["latin","latin-ext","tai-le"],"variants":["regular"],"popularity":1762},{"family":"Noto Sans Tifinagh","category":"sans-serif","subsets":["latin","latin-ext","tifinagh"],"variants":["regular"],"popularity":1763},{"family":"Blaka Hollow","category":"display","subsets":["arabic","latin","latin-ext"],"variants":["regular"],"popularity":1764},{"family":"Exile","category":"display","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":1765},{"family":"Libertinus Keyboard","category":"display","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":1766},{"family":"Noto Sans Khojki","category":"sans-serif","subsets":["khojki","latin","latin-ext"],"variants":["regular"],"popularity":1767},{"family":"Noto Serif Ottoman Siyaq","category":"serif","subsets":["latin","latin-ext","ottoman-siyaq-numbers"],"variants":["regular"],"popularity":1768},{"family":"Noto Sans Inscriptional Pahlavi","category":"sans-serif","subsets":["inscriptional-pahlavi","latin","latin-ext"],"variants":["regular"],"popularity":1769},{"family":"Warnes","category":"display","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":1770},{"family":"Playwrite GB J","category":"handwriting","subsets":["latin"],"variants":["100","200","300","regular","100italic","200italic","300italic","italic"],"popularity":1771},{"family":"Jacquard 12 Charted","category":"display","subsets":["latin","latin-ext","math","symbols"],"variants":["regular"],"popularity":1772},{"family":"Playwrite AU VIC","category":"handwriting","subsets":["latin"],"variants":["100","200","300","regular"],"popularity":1773},{"family":"Playwrite NZ","category":"handwriting","subsets":["latin"],"variants":["100","200","300","regular"],"popularity":1774},{"family":"Noto Serif Todhri","category":"serif","subsets":["latin","latin-ext","todhri"],"variants":["regular"],"popularity":1775},{"family":"Sankofa Display","category":"sans-serif","subsets":["latin","latin-ext","vietnamese"],"variants":["regular"],"popularity":1776},{"family":"Karla Tamil Upright","category":"sans-serif","subsets":["tamil"],"variants":["regular","700"],"popularity":1777},{"family":"Bpmf Huninn","category":"sans-serif","subsets":["chinese-traditional","latin","latin-ext"],"variants":["regular"],"popularity":1778},{"family":"Namdhinggo","category":"serif","subsets":["latin","latin-ext","limbu"],"variants":["regular","500","600","700","800"],"popularity":1779},{"family":"Edu AU VIC WA NT Guides","category":"handwriting","subsets":["latin","latin-ext"],"variants":["regular","500","600","700"],"popularity":1780},{"family":"Wavefont","category":"display","subsets":["latin"],"variants":["100","200","300","regular","500","600","700","800","900"],"popularity":1781},{"family":"Playwrite NG Modern","category":"handwriting","subsets":["latin"],"variants":["100","200","300","regular"],"popularity":1782},{"family":"Noto Sans Old Persian","category":"sans-serif","subsets":["latin","latin-ext","old-persian"],"variants":["regular"],"popularity":1783},{"family":"Noto Sans Zanabazar Square","category":"sans-serif","subsets":["latin","latin-ext","zanabazar-square"],"variants":["regular"],"popularity":1784},{"family":"Noto Sans Cham","category":"sans-serif","subsets":["cham","latin","latin-ext"],"variants":["100","200","300","regular","500","600","700","800","900"],"popularity":1785},{"family":"Noto Sans Old Permic","category":"sans-serif","subsets":["cyrillic-ext","latin","latin-ext","old-permic"],"variants":["regular"],"popularity":1786},{"family":"Noto Serif Hentaigana","category":"serif","subsets":["kana-extended","latin","latin-ext"],"variants":["200","300","regular","500","600","700","800","900"],"popularity":1787},{"family":"Playwrite CZ","category":"handwriting","subsets":["latin"],"variants":["100","200","300","regular"],"popularity":1788},{"family":"Edu QLD Hand","category":"handwriting","subsets":["latin","latin-ext","vietnamese"],"variants":["regular","500","600","700"],"popularity":1789},{"family":"Idiqlat","category":"serif","subsets":["latin","syriac"],"variants":["200","300","regular"],"popularity":1790},{"family":"Noto Sans Imperial Aramaic","category":"sans-serif","subsets":["imperial-aramaic","latin","latin-ext"],"variants":["regular"],"popularity":1791},{"family":"Allkin","category":"display","subsets":["latin"],"variants":["regular"],"popularity":1792},{"family":"Edu AU VIC WA NT Arrows","category":"handwriting","subsets":["latin","latin-ext"],"variants":["regular","500","600","700"],"popularity":1793},{"family":"Libertinus Serif Display","category":"display","subsets":["cyrillic","cyrillic-ext","greek","greek-ext","latin","latin-ext","vietnamese"],"variants":["regular"],"popularity":1794},{"family":"Bpmf Iansui","category":"handwriting","subsets":["chinese-traditional","latin","latin-ext"],"variants":["regular"],"popularity":1795},{"family":"Noto Sans NKo Unjoined","category":"sans-serif","subsets":["latin","latin-ext","nko"],"variants":["regular","500","600","700"],"popularity":1796},{"family":"Noto Sans Sundanese","category":"sans-serif","subsets":["latin","latin-ext","sundanese"],"variants":["regular","500","600","700"],"popularity":1797},{"family":"Gveret Levin","category":"handwriting","subsets":["hebrew","latin"],"variants":["regular"],"popularity":1798},{"family":"Noto Sans Sharada","category":"sans-serif","subsets":["latin","latin-ext","sharada"],"variants":["regular"],"popularity":1799},{"family":"Kay Pho Du","category":"serif","subsets":["kayah-li","latin","latin-ext"],"variants":["regular","500","600","700"],"popularity":1800},{"family":"Pochaevsk","category":"display","subsets":["cyrillic","cyrillic-ext","latin"],"variants":["regular"],"popularity":1801},{"family":"Padyakke Expanded One","category":"serif","subsets":["kannada","latin","latin-ext"],"variants":["regular"],"popularity":1802},{"family":"Playwrite CL","category":"handwriting","subsets":["latin"],"variants":["100","200","300","regular"],"popularity":1803},{"family":"Noto Sans Chakma","category":"sans-serif","subsets":["chakma","latin","latin-ext"],"variants":["regular"],"popularity":1804},{"family":"Noto Sans Runic","category":"sans-serif","subsets":["latin","latin-ext","runic"],"variants":["regular"],"popularity":1805},{"family":"Noto Sans Tagbanwa","category":"sans-serif","subsets":["latin","latin-ext","tagbanwa"],"variants":["regular"],"popularity":1806},{"family":"Playwrite US Trad Guides","category":"handwriting","subsets":["latin"],"variants":["regular"],"popularity":1807},{"family":"Noto Sans Wancho","category":"sans-serif","subsets":["latin","latin-ext","wancho"],"variants":["regular"],"popularity":1808},{"family":"Jacquard 24 Charted","category":"display","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":1809},{"family":"Noto Sans Miao","category":"sans-serif","subsets":["latin","latin-ext","miao"],"variants":["regular"],"popularity":1810},{"family":"Noto Sans Bassa Vah","category":"sans-serif","subsets":["bassa-vah","latin","latin-ext"],"variants":["regular","500","600","700"],"popularity":1811},{"family":"Playwrite ES Deco","category":"handwriting","subsets":["latin"],"variants":["100","200","300","regular"],"popularity":1812},{"family":"Noto Sans Tamil Supplement","category":"sans-serif","subsets":["latin","latin-ext","tamil-supplement"],"variants":["regular"],"popularity":1813},{"family":"Noto Serif Old Uyghur","category":"serif","subsets":["latin","latin-ext","old-uyghur"],"variants":["regular"],"popularity":1814},{"family":"Noto Sans Tai Tham","category":"sans-serif","subsets":["latin","latin-ext","tai-tham"],"variants":["regular","500","600","700"],"popularity":1815},{"family":"Playwrite TZ","category":"handwriting","subsets":["latin"],"variants":["100","200","300","regular"],"popularity":1816},{"family":"Betania Patmos","category":"handwriting","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":1817},{"family":"Noto Sans Old South Arabian","category":"sans-serif","subsets":["latin","latin-ext","old-south-arabian"],"variants":["regular"],"popularity":1818},{"family":"Playwrite NZ Basic Guides","category":"handwriting","subsets":["latin"],"variants":["regular"],"popularity":1819},{"family":"Cause","category":"handwriting","subsets":["latin","latin-ext"],"variants":["100","200","300","regular","500","600","700","800","900"],"popularity":1820},{"family":"Noto Sans Vai","category":"sans-serif","subsets":["latin","latin-ext","vai"],"variants":["regular"],"popularity":1821},{"family":"Noto Sans Buginese","category":"sans-serif","subsets":["buginese","latin","latin-ext"],"variants":["regular"],"popularity":1822},{"family":"Kedebideri","category":"sans-serif","subsets":["beria-erfe","latin"],"variants":["regular","500","600","700","800","900"],"popularity":1823},{"family":"Noto Sans Sogdian","category":"sans-serif","subsets":["latin","latin-ext","sogdian"],"variants":["regular"],"popularity":1824},{"family":"Noto Sans Mayan Numerals","category":"sans-serif","subsets":["latin","latin-ext","mayan-numerals"],"variants":["regular"],"popularity":1825},{"family":"Micro 5 Charted","category":"display","subsets":["latin","latin-ext","math","symbols"],"variants":["regular"],"popularity":1826},{"family":"Betania Patmos In GDL","category":"handwriting","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":1827},{"family":"Edu VIC WA NT Hand Pre","category":"handwriting","subsets":["latin","latin-ext"],"variants":["regular","500","600","700"],"popularity":1828},{"family":"Kanchenjunga","category":"sans-serif","subsets":["kirat-rai","latin"],"variants":["regular","500","600","700"],"popularity":1829},{"family":"Betania Patmos GDL","category":"handwriting","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":1830},{"family":"Betania Patmos In","category":"handwriting","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":1831},{"family":"Noto Sans Linear B","category":"sans-serif","subsets":["latin","latin-ext","linear-b"],"variants":["regular"],"popularity":1832},{"family":"Jersey 25 Charted","category":"display","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":1833},{"family":"Noto Sans Grantha","category":"sans-serif","subsets":["grantha","latin","latin-ext"],"variants":["regular"],"popularity":1834},{"family":"Noto Sans Rejang","category":"sans-serif","subsets":["latin","latin-ext","rejang"],"variants":["regular"],"popularity":1835},{"family":"Noto Sans Modi","category":"sans-serif","subsets":["latin","latin-ext","modi"],"variants":["regular"],"popularity":1836},{"family":"Ramsina","category":"serif","subsets":["latin","syriac"],"variants":["regular"],"popularity":1837},{"family":"Playwrite PL Guides","category":"handwriting","subsets":["latin"],"variants":["regular"],"popularity":1838},{"family":"Noto Sans Mro","category":"sans-serif","subsets":["latin","latin-ext","mro"],"variants":["regular"],"popularity":1839},{"family":"Cossette Texte","category":"sans-serif","subsets":["latin","latin-ext"],"variants":["regular","700"],"popularity":1840},{"family":"Noto Sans Hatran","category":"sans-serif","subsets":["hatran","latin","latin-ext"],"variants":["regular"],"popularity":1841},{"family":"Noto Sans Bhaiksuki","category":"sans-serif","subsets":["bhaiksuki","latin","latin-ext"],"variants":["regular"],"popularity":1842},{"family":"Noto Sans Elymaic","category":"sans-serif","subsets":["elymaic","latin","latin-ext"],"variants":["regular"],"popularity":1843},{"family":"Playwrite PT Guides","category":"handwriting","subsets":["latin"],"variants":["regular"],"popularity":1844},{"family":"Jersey 15 Charted","category":"display","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":1845},{"family":"Playwrite PE Guides","category":"handwriting","subsets":["latin"],"variants":["regular"],"popularity":1846},{"family":"Noto Sans Inscriptional Parthian","category":"sans-serif","subsets":["inscriptional-parthian","latin","latin-ext"],"variants":["regular"],"popularity":1847},{"family":"Noto Sans Hanifi Rohingya","category":"sans-serif","subsets":["hanifi-rohingya","latin","latin-ext"],"variants":["regular","500","600","700"],"popularity":1848},{"family":"Noto Sans Psalter Pahlavi","category":"sans-serif","subsets":["latin","latin-ext","psalter-pahlavi"],"variants":["regular"],"popularity":1849},{"family":"Noto Sans Syriac Western","category":"sans-serif","subsets":["latin","latin-ext","syriac"],"variants":["100","200","300","regular","500","600","700","800","900"],"popularity":1850},{"family":"Jersey 10 Charted","category":"display","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":1851},{"family":"Edu VIC WA NT Hand","category":"handwriting","subsets":["latin","latin-ext"],"variants":["regular","500","600","700"],"popularity":1852},{"family":"Noto Sans Old Turkic","category":"sans-serif","subsets":["latin","latin-ext","old-turkic"],"variants":["regular"],"popularity":1853},{"family":"Noto Sans Kayah Li","category":"sans-serif","subsets":["kayah-li","latin","latin-ext"],"variants":["regular","500","600","700"],"popularity":1854},{"family":"Playwrite CU Guides","category":"handwriting","subsets":["latin"],"variants":["regular"],"popularity":1855},{"family":"Noto Sans Caucasian Albanian","category":"sans-serif","subsets":["caucasian-albanian","latin","latin-ext"],"variants":["regular"],"popularity":1856},{"family":"Noto Sans Phoenician","category":"sans-serif","subsets":["latin","latin-ext","phoenician"],"variants":["regular"],"popularity":1857},{"family":"Noto Sans Kawi","category":"sans-serif","subsets":["kawi","latin","latin-ext"],"variants":["regular","500","600","700"],"popularity":1858},{"family":"Noto Serif Dives Akuru","category":"serif","subsets":["dives-akuru","latin","latin-ext"],"variants":["regular"],"popularity":1859},{"family":"Playwrite ID","category":"handwriting","subsets":["latin"],"variants":["100","200","300","regular"],"popularity":1860},{"family":"Noto Sans Tirhuta","category":"sans-serif","subsets":["latin","latin-ext","tirhuta"],"variants":["regular"],"popularity":1861},{"family":"Noto Sans Khudawadi","category":"sans-serif","subsets":["khudawadi","latin","latin-ext"],"variants":["regular"],"popularity":1862},{"family":"Jersey 20 Charted","category":"display","subsets":["latin","latin-ext"],"variants":["regular"],"popularity":1863},{"family":"Playwrite BR","category":"handwriting","subsets":["latin"],"variants":["100","200","300","regular"],"popularity":1864},{"family":"Noto Sans Nushu","category":"sans-serif","subsets":["latin","latin-ext","nushu"],"variants":["regular"],"popularity":1865},{"family":"Yarndings 20","category":"display","subsets":["latin","math","symbols"],"variants":["regular"],"popularity":1866},{"family":"Noto Sans Saurashtra","category":"sans-serif","subsets":["latin","latin-ext","saurashtra"],"variants":["regular"],"popularity":1867},{"family":"Tirra","category":"sans-serif","subsets":["latin","latin-ext","tifinagh"],"variants":["regular","500","600","700","800","900"],"popularity":1868},{"family":"Noto Sans Mandaic","category":"sans-serif","subsets":["latin","latin-ext","mandaic"],"variants":["regular"],"popularity":1869},{"family":"Playwrite IT Trad","category":"handwriting","subsets":["latin"],"variants":["100","200","300","regular"],"popularity":1870},{"family":"Noto Sans Nabataean","category":"sans-serif","subsets":["latin","latin-ext","nabataean"],"variants":["regular"],"popularity":1871},{"family":"Yuji Hentaigana Akebono","category":"handwriting","subsets":["japanese","latin","latin-ext"],"variants":["regular"],"popularity":1872},{"family":"Playwrite DE VA","category":"handwriting","subsets":["latin"],"variants":["100","200","300","regular"],"popularity":1873},{"family":"Noto Sans Old Sogdian","category":"sans-serif","subsets":["latin","latin-ext","old-sogdian"],"variants":["regular"],"popularity":1874},{"family":"Noto Sans Cypriot","category":"sans-serif","subsets":["cypriot","latin","latin-ext"],"variants":["regular"],"popularity":1875},{"family":"Noto Sans Mende Kikakui","category":"sans-serif","subsets":["latin","latin-ext","mende-kikakui"],"variants":["regular"],"popularity":1876},{"family":"Noto Sans Ogham","category":"sans-serif","subsets":["latin","latin-ext","ogham"],"variants":["regular"],"popularity":1877},{"family":"Yarndings 12","category":"display","subsets":["latin","math","symbols"],"variants":["regular"],"popularity":1878},{"family":"Playwrite FR Trad","category":"handwriting","subsets":["latin"],"variants":["100","200","300","regular"],"popularity":1879},{"family":"Noto Sans Limbu","category":"sans-serif","subsets":["latin","latin-ext","limbu"],"variants":["regular"],"popularity":1880},{"family":"Noto Sans Ugaritic","category":"sans-serif","subsets":["latin","latin-ext","ugaritic"],"variants":["regular"],"popularity":1881},{"family":"Noto Sans Deseret","category":"sans-serif","subsets":["deseret","latin","latin-ext"],"variants":["regular"],"popularity":1882},{"family":"Jacquarda Bastarda 9 Charted","category":"display","subsets":["latin","latin-ext","math","symbols"],"variants":["regular"],"popularity":1883},{"family":"Noto Sans Chorasmian","category":"sans-serif","subsets":["chorasmian","latin","latin-ext","math","symbols"],"variants":["regular"],"popularity":1884},{"family":"Playwrite IE Guides","category":"handwriting","subsets":["latin"],"variants":["regular"],"popularity":1885},{"family":"Playwrite TZ Guides","category":"handwriting","subsets":["latin"],"variants":["regular"],"popularity":1886},{"family":"Noto Sans Pau Cin Hau","category":"sans-serif","subsets":["latin","latin-ext","pau-cin-hau"],"variants":["regular"],"popularity":1887},{"family":"Noto Sans Manichaean","category":"sans-serif","subsets":["latin","latin-ext","manichaean"],"variants":["regular"],"popularity":1888},{"family":"Noto Sans Lepcha","category":"sans-serif","subsets":["latin","latin-ext","lepcha"],"variants":["regular"],"popularity":1889},{"family":"Playwrite DE Grund Guides","category":"handwriting","subsets":["latin"],"variants":["regular"],"popularity":1890},{"family":"Playwrite BE WAL Guides","category":"handwriting","subsets":["latin"],"variants":["regular"],"popularity":1891},{"family":"Yarndings 20 Charted","category":"display","subsets":["latin","math","symbols"],"variants":["regular"],"popularity":1892},{"family":"Noto Sans Soyombo","category":"sans-serif","subsets":["latin","latin-ext","soyombo"],"variants":["regular"],"popularity":1893},{"family":"Yarndings 12 Charted","category":"display","subsets":["latin","math","symbols"],"variants":["regular"],"popularity":1894},{"family":"Noto Sans Masaram Gondi","category":"sans-serif","subsets":["latin","latin-ext","masaram-gondi"],"variants":["regular"],"popularity":1895},{"family":"Noto Sans SignWriting","category":"sans-serif","subsets":["latin","latin-ext","signwriting"],"variants":["regular"],"popularity":1896},{"family":"Playwrite AU VIC Guides","category":"handwriting","subsets":["latin"],"variants":["regular"],"popularity":1897},{"family":"Noto Sans Lycian","category":"sans-serif","subsets":["lycian"],"variants":["regular"],"popularity":1898},{"family":"Noto Sans Siddham","category":"sans-serif","subsets":["latin","latin-ext","siddham"],"variants":["regular"],"popularity":1899},{"family":"Noto Sans Kharoshthi","category":"sans-serif","subsets":["kharoshthi","latin","latin-ext"],"variants":["regular"],"popularity":1900},{"family":"Playwrite IN Guides","category":"handwriting","subsets":["latin"],"variants":["regular"],"popularity":1901},{"family":"Playwrite GB J Guides","category":"handwriting","subsets":["latin"],"variants":["regular","italic"],"popularity":1902},{"family":"Playwrite ZA Guides","category":"handwriting","subsets":["latin"],"variants":["regular"],"popularity":1903},{"family":"Noto Sans PhagsPa","category":"sans-serif","subsets":["latin","latin-ext","math","phags-pa","symbols"],"variants":["regular"],"popularity":1904},{"family":"Playwrite DE VA Guides","category":"handwriting","subsets":["latin"],"variants":["regular"],"popularity":1905},{"family":"Playwrite NL Guides","category":"handwriting","subsets":["latin"],"variants":["regular"],"popularity":1906},{"family":"Playwrite ES Deco Guides","category":"handwriting","subsets":["latin"],"variants":["regular"],"popularity":1907},{"family":"Playwrite FR Moderne Guides","category":"handwriting","subsets":["latin"],"variants":["regular"],"popularity":1908},{"family":"Playwrite FR Trad Guides","category":"handwriting","subsets":["latin"],"variants":["regular"],"popularity":1909},{"family":"Playwrite NZ Guides","category":"handwriting","subsets":["latin"],"variants":["regular"],"popularity":1910},{"family":"Playwrite AU NSW Guides","category":"handwriting","subsets":["latin"],"variants":["regular"],"popularity":1911},{"family":"Playwrite IT Moderna Guides","category":"handwriting","subsets":["latin"],"variants":["regular"],"popularity":1912},{"family":"Playwrite US Modern Guides","category":"handwriting","subsets":["latin"],"variants":["regular"],"popularity":1913},{"family":"Playwrite IT Trad Guides","category":"handwriting","subsets":["latin"],"variants":["regular"],"popularity":1914},{"family":"Playwrite AR Guides","category":"handwriting","subsets":["latin"],"variants":["regular"],"popularity":1915},{"family":"Playwrite AT Guides","category":"handwriting","subsets":["latin"],"variants":["regular","italic"],"popularity":1916},{"family":"Playwrite AU SA Guides","category":"handwriting","subsets":["latin"],"variants":["regular"],"popularity":1917},{"family":"Playwrite NG Modern Guides","category":"handwriting","subsets":["latin"],"variants":["regular"],"popularity":1918},{"family":"Playwrite AU TAS Guides","category":"handwriting","subsets":["latin"],"variants":["regular"],"popularity":1919},{"family":"Playwrite HR Guides","category":"handwriting","subsets":["latin"],"variants":["regular"],"popularity":1920},{"family":"Playwrite ID Guides","category":"handwriting","subsets":["latin"],"variants":["regular"],"popularity":1921},{"family":"Playwrite HU Guides","category":"handwriting","subsets":["latin"],"variants":["regular"],"popularity":1922},{"family":"Playwrite CL Guides","category":"handwriting","subsets":["latin"],"variants":["regular"],"popularity":1923},{"family":"Playwrite HR Lijeva Guides","category":"handwriting","subsets":["latin"],"variants":["regular"],"popularity":1924},{"family":"Playwrite DK Loopet Guides","category":"handwriting","subsets":["latin"],"variants":["regular"],"popularity":1925},{"family":"Playwrite DE LA Guides","category":"handwriting","subsets":["latin"],"variants":["regular"],"popularity":1926},{"family":"Playwrite BR Guides","category":"handwriting","subsets":["latin"],"variants":["regular"],"popularity":1927},{"family":"Playwrite GB S Guides","category":"handwriting","subsets":["latin"],"variants":["regular","italic"],"popularity":1928},{"family":"Playwrite ES Guides","category":"handwriting","subsets":["latin"],"variants":["regular"],"popularity":1929},{"family":"Playwrite AU QLD Guides","category":"handwriting","subsets":["latin"],"variants":["regular"],"popularity":1930},{"family":"Playwrite RO Guides","category":"handwriting","subsets":["latin"],"variants":["regular"],"popularity":1931},{"family":"Playwrite BE VLG Guides","category":"handwriting","subsets":["latin"],"variants":["regular"],"popularity":1932},{"family":"Playwrite CA Guides","category":"handwriting","subsets":["latin"],"variants":["regular"],"popularity":1933},{"family":"Playwrite SK Guides","category":"handwriting","subsets":["latin"],"variants":["regular"],"popularity":1934},{"family":"Playwrite IS Guides","category":"handwriting","subsets":["latin"],"variants":["regular"],"popularity":1935},{"family":"Playwrite CZ Guides","category":"handwriting","subsets":["latin"],"variants":["regular"],"popularity":1936},{"family":"Playwrite DE SAS Guides","category":"handwriting","subsets":["latin"],"variants":["regular"],"popularity":1937},{"family":"Playwrite NO Guides","category":"handwriting","subsets":["latin"],"variants":["regular"],"popularity":1938}] \ No newline at end of file diff --git a/src/db/index.ts b/src/db/index.ts new file mode 100644 index 0000000..c23ca4e --- /dev/null +++ b/src/db/index.ts @@ -0,0 +1,339 @@ +import { join } from 'node:path'; +import { openDatabase, type SqliteDatabase } from './sqlite'; + +export type Plan = 'free' | 'starter' | 'pro' | 'scale'; + +export const PLAN_LIMITS: Record<Plan, number> = { + free: 500, + starter: 10_000, + pro: 50_000, + scale: 200_000, +}; + +export interface ApiKeyRecord { + id: string; + key: string; + email: string; + stripe_customer_id: string | null; + stripe_subscription_id: string | null; + plan: Plan; + calls_limit: number; + calls_used: number; + period_start: string; + created_at: string; + active: number; +} + +export interface UsageLogRecord { + id: number; + api_key_id: string; + endpoint: string; + render_time_ms: number | null; + format: string | null; + created_at: string; +} + +let db: SqliteDatabase | null = null; + +export function getDb(): SqliteDatabase { + if (!db) { + const raw = process.env.DATABASE_URL?.replace('file:', '') ?? join(process.cwd(), 'data', 'og-engine.db'); + db = openDatabase(raw); + migrate(db); + } + return db; +} + +function migrate(d: SqliteDatabase): void { + d.exec(` + CREATE TABLE IF NOT EXISTS api_keys ( + id TEXT PRIMARY KEY, + key TEXT UNIQUE NOT NULL, + email TEXT NOT NULL, + stripe_customer_id TEXT, + stripe_subscription_id TEXT, + plan TEXT NOT NULL DEFAULT 'free', + calls_limit INTEGER NOT NULL DEFAULT 500, + calls_used INTEGER NOT NULL DEFAULT 0, + period_start TEXT NOT NULL, + created_at TEXT NOT NULL, + active INTEGER NOT NULL DEFAULT 1 + ); + + CREATE TABLE IF NOT EXISTS usage_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + api_key_id TEXT NOT NULL, + endpoint TEXT NOT NULL, + render_time_ms REAL, + format TEXT, + created_at TEXT NOT NULL + ); + + CREATE INDEX IF NOT EXISTS idx_api_keys_key ON api_keys(key); + CREATE INDEX IF NOT EXISTS idx_api_keys_email ON api_keys(email); + CREATE INDEX IF NOT EXISTS idx_api_keys_stripe_sub ON api_keys(stripe_subscription_id); + CREATE INDEX IF NOT EXISTS idx_usage_log_api_key ON usage_log(api_key_id); + + CREATE TABLE IF NOT EXISTS custom_templates ( + id TEXT PRIMARY KEY, + api_key_id TEXT NOT NULL, + name TEXT NOT NULL, + definition TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + + CREATE INDEX IF NOT EXISTS idx_custom_templates_api_key ON custom_templates(api_key_id); + CREATE UNIQUE INDEX IF NOT EXISTS idx_custom_templates_name_owner ON custom_templates(api_key_id, name); + + CREATE TABLE IF NOT EXISTS webhooks ( + id TEXT PRIMARY KEY, + api_key_id TEXT NOT NULL, + url TEXT NOT NULL, + render_config TEXT NOT NULL, + secret TEXT NOT NULL, + active INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL + ); + + CREATE INDEX IF NOT EXISTS idx_webhooks_api_key ON webhooks(api_key_id); + `); +} + +// ─── API Key CRUD ──────────────────────────────────────────── + +export function generateApiKey(): string { + return `oge_sk_${crypto.randomUUID().replace(/-/g, '')}`; +} + +export function createApiKey(email: string, plan: Plan = 'free'): ApiKeyRecord { + const d = getDb(); + const record: ApiKeyRecord = { + id: crypto.randomUUID(), + key: generateApiKey(), + email, + stripe_customer_id: null, + stripe_subscription_id: null, + plan, + calls_limit: PLAN_LIMITS[plan], + calls_used: 0, + period_start: new Date().toISOString(), + created_at: new Date().toISOString(), + active: 1, + }; + + d.prepare(` + INSERT INTO api_keys (id, key, email, stripe_customer_id, stripe_subscription_id, plan, calls_limit, calls_used, period_start, created_at, active) + VALUES ($id, $key, $email, $stripe_customer_id, $stripe_subscription_id, $plan, $calls_limit, $calls_used, $period_start, $created_at, $active) + `).run(record); + + return record; +} + +export function findApiKeyByKey(key: string): ApiKeyRecord | null { + const d = getDb(); + return (d.prepare('SELECT * FROM api_keys WHERE key = ?').get(key) as ApiKeyRecord) ?? null; +} + +export function findApiKeyByEmail(email: string): ApiKeyRecord | null { + const d = getDb(); + return (d.prepare('SELECT * FROM api_keys WHERE email = ? AND active = 1').get(email) as ApiKeyRecord) ?? null; +} + +export function findApiKeyByStripeSubscription(subscriptionId: string): ApiKeyRecord | null { + const d = getDb(); + return ( + (d + .prepare('SELECT * FROM api_keys WHERE stripe_subscription_id = ? AND active = 1') + .get(subscriptionId) as ApiKeyRecord) ?? null + ); +} + +export function incrementUsage(id: string): void { + const d = getDb(); + d.prepare('UPDATE api_keys SET calls_used = calls_used + 1 WHERE id = ?').run(id); +} + +export function updatePlan(id: string, plan: Plan): void { + const d = getDb(); + d.prepare('UPDATE api_keys SET plan = ?, calls_limit = ? WHERE id = ?').run(plan, PLAN_LIMITS[plan], id); +} + +export function resetUsage(id: string): void { + const d = getDb(); + d.prepare('UPDATE api_keys SET calls_used = 0, period_start = ? WHERE id = ?').run(new Date().toISOString(), id); +} + +export function resetFreeQuotas(): number { + const d = getDb(); + const result = d + .prepare('UPDATE api_keys SET calls_used = 0, period_start = ? WHERE plan = ? AND active = 1') + .run(new Date().toISOString(), 'free'); + return result.changes; +} + +export function updateStripeInfo(id: string, customerId: string, subscriptionId: string): void { + const d = getDb(); + d.prepare('UPDATE api_keys SET stripe_customer_id = ?, stripe_subscription_id = ? WHERE id = ?').run( + customerId, + subscriptionId, + id, + ); +} + +// ─── Usage Log ─────────────────────────────────────────────── + +export function logUsage(apiKeyId: string, endpoint: string, renderTimeMs?: number, format?: string): void { + const d = getDb(); + d.prepare(` + INSERT INTO usage_log (api_key_id, endpoint, render_time_ms, format, created_at) + VALUES (?, ?, ?, ?, ?) + `).run(apiKeyId, endpoint, renderTimeMs ?? null, format ?? null, new Date().toISOString()); +} + +export function getUsageStats(apiKeyId: string): { + total: number; + byEndpoint: Record<string, number>; + byFormat: Record<string, number>; +} { + const d = getDb(); + + const total = ( + d.prepare('SELECT COUNT(*) as count FROM usage_log WHERE api_key_id = ?').get(apiKeyId) as { count: number } + ).count; + + const byEndpoint = d + .prepare('SELECT endpoint, COUNT(*) as count FROM usage_log WHERE api_key_id = ? GROUP BY endpoint') + .all(apiKeyId) as { endpoint: string; count: number }[]; + + const byFormat = d + .prepare( + 'SELECT format, COUNT(*) as count FROM usage_log WHERE api_key_id = ? AND format IS NOT NULL GROUP BY format', + ) + .all(apiKeyId) as { format: string; count: number }[]; + + return { + total, + byEndpoint: Object.fromEntries(byEndpoint.map((r) => [r.endpoint, r.count])), + byFormat: Object.fromEntries(byFormat.map((r) => [r.format, r.count])), + }; +} + +// ─── Custom Templates ──────────────────────────────────────── + +export interface CustomTemplateRecord { + id: string; + api_key_id: string; + name: string; + definition: string; // JSON string + created_at: string; + updated_at: string; +} + +export function createCustomTemplate(apiKeyId: string, name: string, definition: object): CustomTemplateRecord { + const d = getDb(); + const now = new Date().toISOString(); + const record: CustomTemplateRecord = { + id: crypto.randomUUID(), + api_key_id: apiKeyId, + name, + definition: JSON.stringify(definition), + created_at: now, + updated_at: now, + }; + + d.prepare(` + INSERT INTO custom_templates (id, api_key_id, name, definition, created_at, updated_at) + VALUES ($id, $api_key_id, $name, $definition, $created_at, $updated_at) + `).run(record); + + return record; +} + +export function findCustomTemplate(apiKeyId: string, name: string): CustomTemplateRecord | null { + const d = getDb(); + return ( + (d + .prepare('SELECT * FROM custom_templates WHERE api_key_id = ? AND name = ?') + .get(apiKeyId, name) as CustomTemplateRecord) ?? null + ); +} + +export function listCustomTemplates(apiKeyId: string): CustomTemplateRecord[] { + const d = getDb(); + return d + .prepare('SELECT * FROM custom_templates WHERE api_key_id = ? ORDER BY created_at DESC') + .all(apiKeyId) as CustomTemplateRecord[]; +} + +export function updateCustomTemplate(id: string, definition: object): void { + const d = getDb(); + d.prepare('UPDATE custom_templates SET definition = ?, updated_at = ? WHERE id = ?').run( + JSON.stringify(definition), + new Date().toISOString(), + id, + ); +} + +export function deleteCustomTemplate(id: string): void { + const d = getDb(); + d.prepare('DELETE FROM custom_templates WHERE id = ?').run(id); +} + +// ─── Webhooks ──────────────────────────────────────────────── + +export interface WebhookRecord { + id: string; + api_key_id: string; + url: string; + render_config: string; // JSON string + secret: string; + active: number; + created_at: string; +} + +export function createWebhook(apiKeyId: string, url: string, renderConfig: object): WebhookRecord { + const d = getDb(); + const record: WebhookRecord = { + id: crypto.randomUUID(), + api_key_id: apiKeyId, + url, + render_config: JSON.stringify(renderConfig), + secret: `whsec_${crypto.randomUUID().replace(/-/g, '')}`, + active: 1, + created_at: new Date().toISOString(), + }; + + d.prepare(` + INSERT INTO webhooks (id, api_key_id, url, render_config, secret, active, created_at) + VALUES ($id, $api_key_id, $url, $render_config, $secret, $active, $created_at) + `).run(record); + + return record; +} + +export function findWebhookById(id: string): WebhookRecord | null { + const d = getDb(); + return (d.prepare('SELECT * FROM webhooks WHERE id = ?').get(id) as WebhookRecord) ?? null; +} + +export function listWebhooks(apiKeyId: string): WebhookRecord[] { + const d = getDb(); + return d + .prepare('SELECT * FROM webhooks WHERE api_key_id = ? AND active = 1 ORDER BY created_at DESC') + .all(apiKeyId) as WebhookRecord[]; +} + +export function deleteWebhook(id: string): void { + const d = getDb(); + d.prepare('UPDATE webhooks SET active = 0 WHERE id = ?').run(id); +} + +// ─── Cleanup (for tests) ──────────────────────────────────── + +export function closeDb(): void { + if (db) { + db.close(); + db = null; + } +} diff --git a/src/db/sqlite.ts b/src/db/sqlite.ts new file mode 100644 index 0000000..f9cb208 --- /dev/null +++ b/src/db/sqlite.ts @@ -0,0 +1,88 @@ +/** + * SQLite compatibility layer. + * Uses bun:sqlite when running in Bun, better-sqlite3 otherwise (vitest/Node). + */ + +export interface SqliteDatabase { + exec(sql: string): void; + prepare(sql: string): SqliteStatement; + close(): void; +} + +export interface SqliteStatement { + run(...params: unknown[]): { changes: number }; + get(...params: unknown[]): unknown; + all(...params: unknown[]): unknown[]; +} + +const isBun = typeof globalThis.Bun !== 'undefined'; + +export function openDatabase(path: string): SqliteDatabase { + if (isBun) { + return openBunSqlite(path); + } + return openBetterSqlite(path); +} + +function openBunSqlite(path: string): SqliteDatabase { + const { Database } = require('bun:sqlite'); + const db = new Database(path, { create: true }); + db.exec('PRAGMA journal_mode=WAL'); + db.exec('PRAGMA foreign_keys=ON'); + + return { + exec: (sql: string) => db.exec(sql), + prepare: (sql: string) => { + const stmt = db.prepare(sql); + // bun:sqlite named params need $-prefixed keys in the object + const prefixKeys = (obj: Record<string, unknown>): Record<string, unknown> => { + const result: Record<string, unknown> = {}; + for (const [k, v] of Object.entries(obj)) { + result[k.startsWith('$') ? k : `$${k}`] = v; + } + return result; + }; + const isObj = (p: unknown): p is Record<string, unknown> => + typeof p === 'object' && p !== null && !Array.isArray(p); + return { + run: (...params: unknown[]): { changes: number } => { + const r = params.length === 1 && isObj(params[0]) ? stmt.run(prefixKeys(params[0])) : stmt.run(...params); + return { changes: (r as { changes?: number }).changes ?? 0 }; + }, + get: (...params: unknown[]) => { + if (params.length === 1 && isObj(params[0])) return stmt.get(prefixKeys(params[0])); + return stmt.get(...params); + }, + all: (...params: unknown[]) => { + if (params.length === 1 && isObj(params[0])) return stmt.all(prefixKeys(params[0])); + return stmt.all(...params); + }, + }; + }, + close: () => db.close(), + }; +} + +function openBetterSqlite(path: string): SqliteDatabase { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const BetterSqlite = require('better-sqlite3'); + const db = new BetterSqlite(path); + db.pragma('journal_mode = WAL'); + db.pragma('foreign_keys = ON'); + + return { + exec: (sql: string) => db.exec(sql), + prepare: (sql: string) => { + const stmt = db.prepare(sql); + return { + run: (...params: unknown[]): { changes: number } => { + const r = stmt.run(...params) as { changes?: number }; + return { changes: r.changes ?? 0 }; + }, + get: (...params: unknown[]) => stmt.get(...params), + all: (...params: unknown[]) => stmt.all(...params), + }; + }, + close: () => db.close(), + }; +} diff --git a/src/email/send.ts b/src/email/send.ts new file mode 100644 index 0000000..3d3082c --- /dev/null +++ b/src/email/send.ts @@ -0,0 +1,85 @@ +import { Resend } from 'resend'; +import { PLAN_LIMITS, type Plan } from '../db'; + +function getResend(): Resend | null { + const apiKey = process.env.RESEND_API_KEY; + if (!apiKey) { + return null; + } + return new Resend(apiKey); +} + +const FROM = process.env.EMAIL_FROM ?? 'OG Engine <delivered@resend.dev>'; + +export async function sendWelcomeEmail(email: string, apiKey: string, plan: Plan): Promise<void> { + const resend = getResend(); + if (!resend) { + console.warn('[email] RESEND_API_KEY not set — skipping welcome email'); + return; + } + + const limit = PLAN_LIMITS[plan]; + + await resend.emails.send({ + from: FROM, + to: email, + subject: 'Your OG Engine API Key', + html: ` + <h2>Welcome to OG Engine!</h2> + <p>Your API key (plan: <strong>${plan}</strong>, ${limit.toLocaleString()} renders/month):</p> + <code style="background:#f0f0f0;padding:8px 16px;border-radius:4px;font-size:16px;display:inline-block;margin:8px 0;"> + ${apiKey} + </code> + <p>Quick start:</p> + <pre style="background:#f0f0f0;padding:12px;border-radius:4px;overflow-x:auto;">curl -X POST https://og-engine.com/render \\ + -H "Authorization: Bearer ${apiKey}" \\ + -H "Content-Type: application/json" \\ + -d '{"format":"og","title":"Hello World"}'</pre> + <p><a href="https://og-engine.com/quick-start/">Read the docs →</a></p> + `, + }); +} + +export async function sendUpgradeEmail(email: string, plan: Plan): Promise<void> { + const resend = getResend(); + if (!resend) { + console.warn('[email] RESEND_API_KEY not set — skipping upgrade email'); + return; + } + + const limit = PLAN_LIMITS[plan]; + + await resend.emails.send({ + from: FROM, + to: email, + subject: `You're now on OG Engine ${plan.charAt(0).toUpperCase() + plan.slice(1)}`, + html: ` + <h2>Plan upgraded!</h2> + <p>Your plan is now <strong>${plan}</strong> with <strong>${limit.toLocaleString()}</strong> renders/month.</p> + <p>Manage your subscription anytime via the billing portal:</p> + <pre style="background:#f0f0f0;padding:12px;border-radius:4px;overflow-x:auto;">curl https://og-engine.com/billing/portal \\ + -H "Authorization: Bearer YOUR_API_KEY"</pre> + <p><a href="https://og-engine.com/pricing">View all plans →</a></p> + `, + }); +} + +export async function sendDowngradeEmail(email: string): Promise<void> { + const resend = getResend(); + if (!resend) { + console.warn('[email] RESEND_API_KEY not set — skipping downgrade email'); + return; + } + + await resend.emails.send({ + from: FROM, + to: email, + subject: 'OG Engine subscription cancelled', + html: ` + <h2>Subscription cancelled</h2> + <p>Your plan has been downgraded to <strong>Free</strong> (500 renders/month).</p> + <p>Your API key is still active — you can keep using OG Engine on the free tier.</p> + <p>Changed your mind? <a href="https://og-engine.com/pricing">Resubscribe anytime →</a></p> + `, + }); +} diff --git a/src/engine/autofit.ts b/src/engine/autofit.ts new file mode 100644 index 0000000..a90dc6d --- /dev/null +++ b/src/engine/autofit.ts @@ -0,0 +1,129 @@ +import { getFontByName } from './fonts'; +import { FORMATS, type FormatKey } from './formats'; +import { measureLines } from './text-measure'; + +export interface AutoFitOptions { + text: string; + format: FormatKey; + fontName: string; + fontWeight: string; + maxLines: number; + minSize: number; + maxSize: number; +} + +export interface AutoFitResult { + fontSize: number; + lines: number; + overflow: boolean; +} + +/** + * Binary search for the largest font size where text fits within maxLines. + * Returns the optimal size between minSize and maxSize. + */ +export function autoFitText(options: AutoFitOptions): AutoFitResult { + const { text, format, fontName, fontWeight, maxLines, minSize, maxSize } = options; + + const fmt = FORMATS[format]; + if (!fmt) throw new Error(`Unknown format: ${format}`); + + const fontEntry = getFontByName(fontName); + const ff = fontEntry.family; + const s = Math.max(fmt.w, fmt.h) / 1200; + const px = Math.round(64 * s); + const contentWidth = fmt.w - px * 2; + + // Test if a given font size fits + function fitsAt(size: number): { lines: number; fits: boolean } { + const font = `${fontWeight} ${Math.round(size * s)}px ${ff}`; + const lines = measureLines(text, font, contentWidth); + return { lines: lines.length, fits: lines.length <= maxLines }; + } + + // Binary search: find largest size that fits + let lo = minSize; + let hi = maxSize; + let bestSize = minSize; + let bestLines = 0; + + // Quick check: if max size fits, use it + const atMax = fitsAt(maxSize); + if (atMax.fits) { + return { fontSize: maxSize, lines: atMax.lines, overflow: false }; + } + + // Quick check: if min size doesn't fit, return min with overflow + const atMin = fitsAt(minSize); + if (!atMin.fits) { + return { fontSize: minSize, lines: atMin.lines, overflow: true }; + } + + // Binary search + while (lo <= hi) { + const mid = Math.floor((lo + hi) / 2); + const result = fitsAt(mid); + + if (result.fits) { + bestSize = mid; + bestLines = result.lines; + lo = mid + 1; + } else { + hi = mid - 1; + } + } + + return { fontSize: bestSize, lines: bestLines, overflow: false }; +} + +/** + * Auto-fit both title and description sizes for a render request. + */ +export function autoFitCard(options: { + title: string; + description: string; + format: FormatKey; + fontName: string; + titleSizeRange?: [number, number]; + descSizeRange?: [number, number]; + maxTitleLines?: number; + maxDescLines?: number; +}): { titleSize: number; descSize: number; titleLines: number; descLines: number } { + const { title, description, format, fontName, titleSizeRange = [28, 72], descSizeRange = [14, 32] } = options; + + const fmt = FORMATS[format]; + if (!fmt) throw new Error(`Unknown format: ${format}`); + + const maxTitleLines = options.maxTitleLines ?? fmt.maxTitleLines; + const maxDescLines = options.maxDescLines ?? fmt.maxDescLines; + + const titleFit = autoFitText({ + text: title, + format, + fontName, + fontWeight: '800', + maxLines: maxTitleLines, + minSize: titleSizeRange[0], + maxSize: titleSizeRange[1], + }); + + let descFit = { fontSize: descSizeRange[1], lines: 0, overflow: false }; + if (description) { + descFit = autoFitText({ + text: description, + format, + fontName, + fontWeight: '400', + maxLines: maxDescLines, + minSize: descSizeRange[0], + maxSize: descSizeRange[1], + }); + } + + return { + titleSize: titleFit.fontSize, + descSize: descFit.fontSize, + titleLines: titleFit.lines, + descLines: descFit.lines, + }; +} diff --git a/src/engine/cache.ts b/src/engine/cache.ts new file mode 100644 index 0000000..840cbcf --- /dev/null +++ b/src/engine/cache.ts @@ -0,0 +1,37 @@ +export class LRUCache<K, V> { + private map = new Map<K, V>(); + private readonly max: number; + + constructor(max = 1000) { + this.max = max; + } + + get(key: K): V | undefined { + const val = this.map.get(key); + if (val !== undefined) { + // Move to end (most recently used) + this.map.delete(key); + this.map.set(key, val); + } + return val; + } + + set(key: K, val: V): void { + if (this.map.has(key)) { + this.map.delete(key); + } else if (this.map.size >= this.max) { + // Evict oldest + const first = this.map.keys().next().value!; + this.map.delete(first); + } + this.map.set(key, val); + } + + get size(): number { + return this.map.size; + } + + clear(): void { + this.map.clear(); + } +} diff --git a/src/engine/custom-template.ts b/src/engine/custom-template.ts new file mode 100644 index 0000000..d86896b --- /dev/null +++ b/src/engine/custom-template.ts @@ -0,0 +1,296 @@ +import type { Image, SKRSContext2D } from '@napi-rs/canvas'; +import { z } from 'zod'; +import { getGradientBySlug } from './gradients'; +import type { TemplateResult } from './templates'; +import { measureLines } from './text-measure'; + +/** + * Custom Template JSON DSL + * + * A template definition is an array of layers rendered in order. + * Each layer has a type and type-specific properties. + * + * Variables are interpolated using {{variable}} syntax: + * {{title}}, {{description}}, {{author}}, {{tag}}, {{accent}} + */ + +// ─── Schema for validation ─────────────────────────────────── + +const colorSchema = z.string(); // hex, rgba, or variable like "{{accent}}" + +const layerBase = z.object({ + type: z.string(), + x: z.union([z.number(), z.string()]).optional(), // number or "center", "left", "right" + y: z.union([z.number(), z.string()]).optional(), + width: z.union([z.number(), z.string()]).optional(), + height: z.union([z.number(), z.string()]).optional(), + opacity: z.number().min(0).max(1).optional(), +}); + +const fillLayer = layerBase.extend({ + type: z.literal('fill'), + color: colorSchema, +}); + +const gradientLayer = layerBase.extend({ + type: z.literal('gradient'), + gradient: z.string(), // gradient slug +}); + +const rectLayer = layerBase.extend({ + type: z.literal('rect'), + color: colorSchema, + radius: z.number().optional(), +}); + +const textLayer = layerBase.extend({ + type: z.literal('text'), + content: z.string(), // supports {{title}}, {{description}}, etc. + font: z.string().optional(), + fontSize: z.number(), + fontWeight: z.union([z.number(), z.string()]).default(400), + color: colorSchema.default('#ffffff'), + align: z.enum(['left', 'center', 'right']).default('left'), + maxLines: z.number().int().min(1).max(20).optional(), + lineHeight: z.number().default(1.2), + ellipsis: z.boolean().default(true), +}); + +const imageLayer = layerBase.extend({ + type: z.literal('image'), + source: z.string().optional(), // named image key, e.g. "logo", "avatar" + fit: z.enum(['cover', 'contain', 'fill']).default('cover'), +}); + +const lineLayer = layerBase.extend({ + type: z.literal('line'), + x2: z.number(), + y2: z.number(), + color: colorSchema, + lineWidth: z.number().default(1), +}); + +const layerSchema = z.discriminatedUnion('type', [ + fillLayer, + gradientLayer, + rectLayer, + textLayer, + imageLayer, + lineLayer, +]); + +export const customTemplateSchema = z.object({ + name: z.string().min(1).max(64), + layers: z.array(layerSchema).min(1).max(50), +}); + +export type CustomTemplateDefinition = z.infer<typeof customTemplateSchema>; +export type Layer = z.infer<typeof layerSchema>; + +// ─── Rendering engine ──────────────────────────────────────── + +interface RenderContext { + ctx: SKRSContext2D; + W: number; + H: number; + s: number; + vars: Record<string, string>; + fontFamily: string; + bgImage: Image | null; + namedImages: Record<string, Image | null>; +} + +function interpolate(str: string, vars: Record<string, string>): string { + return str.replace(/\{\{(\w+)\}\}/g, (_, key) => vars[key] ?? ''); +} + +function resolveX(val: number | string | undefined, W: number, elW?: number): number { + if (val === undefined) return 0; + if (typeof val === 'number') return val; + if (val === 'center') return (W - (elW ?? 0)) / 2; + if (val === 'right') return W - (elW ?? 0); + return 0; +} + +function resolveY(val: number | string | undefined, H: number, elH?: number): number { + if (val === undefined) return 0; + if (typeof val === 'number') return val; + if (val === 'center') return (H - (elH ?? 0)) / 2; + if (val === 'bottom') return H - (elH ?? 0); + return 0; +} + +function resolveDim(val: number | string | undefined, full: number): number { + if (val === undefined) return full; + if (typeof val === 'number') return val; + if (val === 'full') return full; + if (typeof val === 'string' && val.endsWith('%')) { + return (parseFloat(val) / 100) * full; + } + return full; +} + +export function renderCustomTemplate( + definition: CustomTemplateDefinition, + ctx: SKRSContext2D, + W: number, + H: number, + variables: Record<string, string>, + style: { accent: string; fontFamily: string }, + bgImage: Image | null, + namedImages: Record<string, Image | null> = {}, +): TemplateResult { + const s = Math.max(W, H) / 1200; + const vars: Record<string, string> = { + ...variables, + accent: style.accent, + }; + + const rc: RenderContext = { ctx, W, H, s, vars, fontFamily: style.fontFamily, bgImage, namedImages }; + + let titleLines = 0; + let titleVisible = 0; + let descLines = 0; + let descVisible = 0; + let overflow = false; + + for (const layer of definition.layers) { + if (layer.opacity !== undefined) { + ctx.globalAlpha = layer.opacity; + } + + switch (layer.type) { + case 'fill': { + ctx.fillStyle = interpolate(layer.color, vars); + ctx.fillRect(0, 0, W, H); + break; + } + + case 'gradient': { + const grad = getGradientBySlug(layer.gradient); + const bg = ctx.createLinearGradient(0, 0, W * 0.3, H); + bg.addColorStop(0, grad.stops[0]); + bg.addColorStop(1, grad.stops[1]); + ctx.fillStyle = bg; + ctx.fillRect(0, 0, W, H); + break; + } + + case 'rect': { + const w = resolveDim(layer.width, W); + const h = resolveDim(layer.height, H); + const x = resolveX(layer.x, W, w); + const y = resolveY(layer.y, H, h); + ctx.fillStyle = interpolate(layer.color, vars); + if (layer.radius) { + ctx.beginPath(); + ctx.roundRect(x, y, w, h, layer.radius); + ctx.fill(); + } else { + ctx.fillRect(x, y, w, h); + } + break; + } + + case 'text': { + const text = interpolate(layer.content, vars); + if (!text) break; + + const ff = layer.font ?? rc.fontFamily; + const fontSize = Math.round(layer.fontSize * s); + const font = `${layer.fontWeight} ${fontSize}px ${ff}`; + const lh = Math.round(layer.fontSize * layer.lineHeight * s); + const maxW = resolveDim(layer.width, W); + + const lines = measureLines(text, font, maxW); + const maxL = layer.maxLines ?? lines.length; + const visible = lines.slice(0, maxL); + + const x = resolveX(layer.x, W); + let y = resolveY(layer.y, H); + + ctx.fillStyle = interpolate(layer.color, vars); + ctx.font = font; + ctx.textAlign = layer.align; + ctx.textBaseline = 'top'; + + for (let i = 0; i < visible.length; i++) { + let t = visible[i].text; + if (layer.ellipsis && i === visible.length - 1 && lines.length > maxL) t += '\u2026'; + const drawX = layer.align === 'center' ? x + maxW / 2 : layer.align === 'right' ? x + maxW : x; + ctx.fillText(t, drawX, y); + y += lh; + } + + // Track title/desc lines + if (layer.content.includes('{{title}}')) { + titleLines = lines.length; + titleVisible = visible.length; + if (lines.length > maxL) overflow = true; + } + if (layer.content.includes('{{description}}')) { + descLines = lines.length; + descVisible = visible.length; + if (lines.length > maxL) overflow = true; + } + + ctx.textAlign = 'left'; + break; + } + + case 'image': { + const sourceImg = layer.source ? (rc.namedImages[layer.source] ?? null) : rc.bgImage; + if (!sourceImg) break; + const w = resolveDim(layer.width, W); + const h = resolveDim(layer.height, H); + const x = resolveX(layer.x, W, w); + const y = resolveY(layer.y, H, h); + + if (layer.fit === 'cover') { + const imgW = sourceImg.width; + const imgH = sourceImg.height; + const scale = Math.max(w / imgW, h / imgH); + const dw = imgW * scale; + const dh = imgH * scale; + const dx = x + (w - dw) / 2; + const dy = y + (h - dh) / 2; + ctx.drawImage(sourceImg, dx, dy, dw, dh); + } else if (layer.fit === 'contain') { + const imgW = sourceImg.width; + const imgH = sourceImg.height; + const scale = Math.min(w / imgW, h / imgH); + const dw = imgW * scale; + const dh = imgH * scale; + const dx = x + (w - dw) / 2; + const dy = y + (h - dh) / 2; + ctx.drawImage(sourceImg, dx, dy, dw, dh); + } else { + ctx.drawImage(sourceImg, x, y, w, h); + } + break; + } + + case 'line': { + ctx.strokeStyle = interpolate(layer.color, vars); + ctx.lineWidth = layer.lineWidth; + const x = resolveX(layer.x, W); + const y = resolveY(layer.y, H); + ctx.beginPath(); + ctx.moveTo(x, y); + ctx.lineTo(layer.x2, layer.y2); + ctx.stroke(); + break; + } + } + + ctx.globalAlpha = 1; + } + + return { + titleTotalLines: titleLines, + titleVisibleLines: titleVisible, + descTotalLines: descLines, + descVisibleLines: descVisible, + overflow, + }; +} diff --git a/src/engine/font-catalog.ts b/src/engine/font-catalog.ts new file mode 100644 index 0000000..95f43d8 --- /dev/null +++ b/src/engine/font-catalog.ts @@ -0,0 +1,432 @@ +/** + * Canonical font catalog for OG Engine. + * + * This file is the single source of truth for which fonts the API server + * has on disk and the playground exposes as "API ready". Both the server + * (src/engine/fonts.ts) and the playground client + * (docs/site/src/components/engine/fonts.ts) import CURATED_FONTS from + * here. Add a font in one place, both sides pick it up after running + * `bun run fonts:download`. + */ + +export type FontCategory = 'sans-serif' | 'serif' | 'display' | 'handwriting' | 'monospace'; + +export interface CuratedFontEntry { + /** Display name shown in the picker */ + name: string; + /** CSS font-family value (often equal to name; differs for Noto Sans Arabic) */ + family: string; + /** Directory name under /fonts/ */ + slug: string; + /** Weights physically available on disk */ + weights: number[]; + /** Coarse category for filter chips */ + category: FontCategory; + /** Unicode subsets covered (latin, cjk, arabic, cyrillic, etc.) */ + subsets: string[]; +} + +export const CURATED_FONTS: CuratedFontEntry[] = [ + // ── Sans-serif (22) ────────────────────────────────────────────── + { + name: 'Inter', + family: 'Inter', + slug: 'inter', + weights: [400, 700, 800], + category: 'sans-serif', + subsets: ['latin'], + }, + { name: 'Roboto', family: 'Roboto', slug: 'roboto', weights: [400, 700], category: 'sans-serif', subsets: ['latin'] }, + { + name: 'Open Sans', + family: 'Open Sans', + slug: 'open-sans', + weights: [400, 700], + category: 'sans-serif', + subsets: ['latin'], + }, + { name: 'Lato', family: 'Lato', slug: 'lato', weights: [400, 700], category: 'sans-serif', subsets: ['latin'] }, + { + name: 'Montserrat', + family: 'Montserrat', + slug: 'montserrat', + weights: [400, 700, 800], + category: 'sans-serif', + subsets: ['latin'], + }, + { + name: 'Poppins', + family: 'Poppins', + slug: 'poppins', + weights: [400, 700, 800], + category: 'sans-serif', + subsets: ['latin'], + }, + { + name: 'Outfit', + family: 'Outfit', + slug: 'outfit', + weights: [400, 700, 800], + category: 'sans-serif', + subsets: ['latin'], + }, + { name: 'Sora', family: 'Sora', slug: 'sora', weights: [400, 700, 800], category: 'sans-serif', subsets: ['latin'] }, + { + name: 'Space Grotesk', + family: 'Space Grotesk', + slug: 'space-grotesk', + weights: [400, 700], + category: 'sans-serif', + subsets: ['latin'], + }, + { + name: 'DM Sans', + family: 'DM Sans', + slug: 'dm-sans', + weights: [400, 700], + category: 'sans-serif', + subsets: ['latin'], + }, + { + name: 'Manrope', + family: 'Manrope', + slug: 'manrope', + weights: [400, 700, 800], + category: 'sans-serif', + subsets: ['latin'], + }, + { + name: 'Plus Jakarta Sans', + family: 'Plus Jakarta Sans', + slug: 'plus-jakarta-sans', + weights: [400, 700, 800], + category: 'sans-serif', + subsets: ['latin'], + }, + { + name: 'Figtree', + family: 'Figtree', + slug: 'figtree', + weights: [400, 700], + category: 'sans-serif', + subsets: ['latin'], + }, + { + name: 'Work Sans', + family: 'Work Sans', + slug: 'work-sans', + weights: [400, 700], + category: 'sans-serif', + subsets: ['latin'], + }, + { + name: 'Nunito', + family: 'Nunito', + slug: 'nunito', + weights: [400, 700, 800], + category: 'sans-serif', + subsets: ['latin'], + }, + { + name: 'Nunito Sans', + family: 'Nunito Sans', + slug: 'nunito-sans', + weights: [400, 700, 800], + category: 'sans-serif', + subsets: ['latin'], + }, + { + name: 'Source Sans 3', + family: 'Source Sans 3', + slug: 'source-sans-3', + weights: [400, 700], + category: 'sans-serif', + subsets: ['latin'], + }, + { name: 'Karla', family: 'Karla', slug: 'karla', weights: [400, 700], category: 'sans-serif', subsets: ['latin'] }, + { name: 'Rubik', family: 'Rubik', slug: 'rubik', weights: [400, 700], category: 'sans-serif', subsets: ['latin'] }, + { name: 'Mulish', family: 'Mulish', slug: 'mulish', weights: [400, 700], category: 'sans-serif', subsets: ['latin'] }, + { name: 'Onest', family: 'Onest', slug: 'onest', weights: [400, 700], category: 'sans-serif', subsets: ['latin'] }, + { + name: 'Albert Sans', + family: 'Albert Sans', + slug: 'albert-sans', + weights: [400, 700], + category: 'sans-serif', + subsets: ['latin'], + }, + + // ── Serif (10) ─────────────────────────────────────────────────── + { + name: 'Playfair Display', + family: 'Playfair Display', + slug: 'playfair-display', + weights: [400, 700, 800], + category: 'serif', + subsets: ['latin'], + }, + { + name: 'Merriweather', + family: 'Merriweather', + slug: 'merriweather', + weights: [400, 700], + category: 'serif', + subsets: ['latin'], + }, + { name: 'Lora', family: 'Lora', slug: 'lora', weights: [400, 700], category: 'serif', subsets: ['latin'] }, + { + name: 'Crimson Pro', + family: 'Crimson Pro', + slug: 'crimson-pro', + weights: [400, 700], + category: 'serif', + subsets: ['latin'], + }, + { + name: 'EB Garamond', + family: 'EB Garamond', + slug: 'eb-garamond', + weights: [400, 700], + category: 'serif', + subsets: ['latin'], + }, + { + name: 'Cormorant Garamond', + family: 'Cormorant Garamond', + slug: 'cormorant-garamond', + weights: [400, 700], + category: 'serif', + subsets: ['latin'], + }, + { + name: 'Source Serif 4', + family: 'Source Serif 4', + slug: 'source-serif-4', + weights: [400, 700], + category: 'serif', + subsets: ['latin'], + }, + { + name: 'PT Serif', + family: 'PT Serif', + slug: 'pt-serif', + weights: [400, 700], + category: 'serif', + subsets: ['latin'], + }, + { name: 'Bitter', family: 'Bitter', slug: 'bitter', weights: [400, 700], category: 'serif', subsets: ['latin'] }, + { + name: 'Spectral', + family: 'Spectral', + slug: 'spectral', + weights: [400, 700], + category: 'serif', + subsets: ['latin'], + }, + + // ── Display (9) ────────────────────────────────────────────────── + { + name: 'Bebas Neue', + family: 'Bebas Neue', + slug: 'bebas-neue', + weights: [400], + category: 'display', + subsets: ['latin'], + }, + { name: 'Anton', family: 'Anton', slug: 'anton', weights: [400], category: 'display', subsets: ['latin'] }, + { name: 'Oswald', family: 'Oswald', slug: 'oswald', weights: [400, 700], category: 'display', subsets: ['latin'] }, + { + name: 'Archivo Black', + family: 'Archivo Black', + slug: 'archivo-black', + weights: [400], + category: 'display', + subsets: ['latin'], + }, + { + name: 'Fraunces', + family: 'Fraunces', + slug: 'fraunces', + weights: [400, 700], + category: 'display', + subsets: ['latin'], + }, + { name: 'Syne', family: 'Syne', slug: 'syne', weights: [400, 700, 800], category: 'display', subsets: ['latin'] }, + { + name: 'Unbounded', + family: 'Unbounded', + slug: 'unbounded', + weights: [400, 700], + category: 'display', + subsets: ['latin'], + }, + { + name: 'Bricolage Grotesque', + family: 'Bricolage Grotesque', + slug: 'bricolage-grotesque', + weights: [400, 700, 800], + category: 'display', + subsets: ['latin'], + }, + { + name: 'Familjen Grotesk', + family: 'Familjen Grotesk', + slug: 'familjen-grotesk', + weights: [400, 700], + category: 'display', + subsets: ['latin'], + }, + + // ── Monospace (5) ──────────────────────────────────────────────── + { + name: 'JetBrains Mono', + family: 'JetBrains Mono', + slug: 'jetbrains-mono', + weights: [400, 700], + category: 'monospace', + subsets: ['latin'], + }, + { + name: 'Fira Code', + family: 'Fira Code', + slug: 'fira-code', + weights: [400, 700], + category: 'monospace', + subsets: ['latin'], + }, + { + name: 'IBM Plex Mono', + family: 'IBM Plex Mono', + slug: 'ibm-plex-mono', + weights: [400, 700], + category: 'monospace', + subsets: ['latin'], + }, + { + name: 'Geist Mono', + family: 'Geist Mono', + slug: 'geist-mono', + weights: [400, 700], + category: 'monospace', + subsets: ['latin'], + }, + { + name: 'Space Mono', + family: 'Space Mono', + slug: 'space-mono', + weights: [400, 700], + category: 'monospace', + subsets: ['latin'], + }, + + // ── Handwriting (3) ────────────────────────────────────────────── + { + name: 'Caveat', + family: 'Caveat', + slug: 'caveat', + weights: [400, 700], + category: 'handwriting', + subsets: ['latin'], + }, + { name: 'Kalam', family: 'Kalam', slug: 'kalam', weights: [400, 700], category: 'handwriting', subsets: ['latin'] }, + { + name: 'Pacifico', + family: 'Pacifico', + slug: 'pacifico', + weights: [400], + category: 'handwriting', + subsets: ['latin'], + }, + + // ── CJK + Arabic (4) ───────────────────────────────────────────── + { + name: 'Noto Sans JP', + family: 'Noto Sans JP', + slug: 'noto-sans-jp', + weights: [400, 700], + category: 'sans-serif', + subsets: ['latin', 'cjk'], + }, + { + name: 'Noto Sans Arabic', + family: 'Noto Sans Arabic', + slug: 'noto-sans-arabic', + weights: [400, 700], + category: 'sans-serif', + subsets: ['latin', 'arabic'], + }, + { + name: 'Noto Sans SC', + family: 'Noto Sans SC', + slug: 'noto-sans-sc', + weights: [400, 700], + category: 'sans-serif', + subsets: ['latin', 'cjk'], + }, + { + name: 'Noto Sans KR', + family: 'Noto Sans KR', + slug: 'noto-sans-kr', + weights: [400, 700], + category: 'sans-serif', + subsets: ['latin', 'cjk'], + }, +]; + +const CURATED_NAMES = new Set(CURATED_FONTS.map((f) => f.name)); + +export function isCuratedFont(name: string): boolean { + return CURATED_NAMES.has(name); +} + +/** + * A tighter, tasteful subset surfaced in the playground font picker. + * The full CURATED_FONTS list remains accepted by the API; this is + * purely a UI narrowing so first-time users don't drown in choice. + * + * Rules for inclusion: + * - Distinctive voice (skip "also a sans" clones) + * - Good at display sizes + * - At least one bold weight available + * - Covers the typographic range: modern/neo-grotesk, friendly, geometric, + * editorial serif, display, mono, script — one of each, no filler. + */ +const FEATURED_NAMES = new Set<string>([ + // Modern / neo-grotesk sans — the workhorses + 'Inter', + 'Space Grotesk', + 'DM Sans', + 'Plus Jakarta Sans', + 'Manrope', + 'Onest', + // Friendly / humanist sans + 'Outfit', + 'Sora', + 'Figtree', + // Editorial serif + 'Playfair Display', + 'Fraunces', + 'Cormorant Garamond', + 'EB Garamond', + 'Source Serif 4', + // Display / headline + 'Bebas Neue', + 'Anton', + 'Archivo Black', + 'Bricolage Grotesque', + 'Syne', + 'Unbounded', + // Monospace (for tech/dev feel) + 'JetBrains Mono', + 'IBM Plex Mono', + 'Geist Mono', + // Handwriting / script (for personality) + 'Caveat', + 'Pacifico', +]); + +export const FEATURED_FONTS: CuratedFontEntry[] = CURATED_FONTS.filter((f) => FEATURED_NAMES.has(f.name)); + +export function isFeaturedFont(name: string): boolean { + return FEATURED_NAMES.has(name); +} diff --git a/src/engine/fonts.ts b/src/engine/fonts.ts new file mode 100644 index 0000000..07b8c66 --- /dev/null +++ b/src/engine/fonts.ts @@ -0,0 +1,64 @@ +import { readdir, stat } from 'node:fs/promises'; +import { join } from 'node:path'; +import { GlobalFonts } from '@napi-rs/canvas'; +import { CURATED_FONTS, type CuratedFontEntry, isCuratedFont } from './font-catalog'; + +/** + * Legacy alias for backward compatibility. Use CURATED_FONTS directly in new code. + * + * The shape changed slightly (added `slug`, `category`, `subsets`; removed `scripts`) + * but the legacy `scripts` field is derived from `subsets` for any caller that + * still needs it. + */ +export type FontEntry = CuratedFontEntry & { scripts: string[] }; + +/** All fonts the API server is configured to support. */ +export const FONTS: FontEntry[] = CURATED_FONTS.map((f) => ({ + ...f, + scripts: f.subsets.map((s) => (s === 'latin' ? 'Latin' : s === 'cjk' ? 'CJK' : s === 'arabic' ? 'Arabic' : s)), +})); + +const FONT_NAMES = FONTS.map((f) => f.name); + +let registered = false; + +export async function registerFonts(fontsDir: string): Promise<string[]> { + if (registered) return FONT_NAMES; + + const loaded: string[] = []; + + for (const entry of FONTS) { + const dir = join(fontsDir, entry.slug); + + try { + await stat(dir); + } catch { + console.warn(`Font directory missing: ${dir} — skipping ${entry.name}`); + continue; + } + + const files = await readdir(dir); + const ttfFiles = files.filter((f) => f.endsWith('.ttf')); + + for (const file of ttfFiles) { + const filepath = join(dir, file); + GlobalFonts.registerFromPath(filepath, entry.family); + } + + if (ttfFiles.length > 0) { + loaded.push(entry.name); + } + } + + registered = true; + console.log(`Registered ${loaded.length} font families: ${loaded.join(', ')}`); + return loaded; +} + +export function getFontByName(name: string): FontEntry { + return FONTS.find((f) => f.name === name) ?? FONTS[0]; +} + +export function isValidFont(name: string): boolean { + return isCuratedFont(name); +} diff --git a/src/engine/formats.ts b/src/engine/formats.ts new file mode 100644 index 0000000..ca01fd0 --- /dev/null +++ b/src/engine/formats.ts @@ -0,0 +1,19 @@ +export interface Format { + w: number; + h: number; + label: string; + ratio: string; + maxTitleLines: number; + maxDescLines: number; +} + +export const FORMATS: Record<string, Format> = { + og: { w: 1200, h: 630, label: 'OG', ratio: '1200x630', maxTitleLines: 3, maxDescLines: 4 }, + twitter: { w: 1200, h: 675, label: 'Twitter', ratio: '1200x675', maxTitleLines: 3, maxDescLines: 4 }, + square: { w: 1080, h: 1080, label: 'Square', ratio: '1080x1080', maxTitleLines: 4, maxDescLines: 5 }, + linkedin: { w: 1200, h: 627, label: 'LinkedIn', ratio: '1200x627', maxTitleLines: 3, maxDescLines: 4 }, + story: { w: 1080, h: 1920, label: 'Story', ratio: '1080x1920', maxTitleLines: 5, maxDescLines: 6 }, +}; + +export type FormatKey = keyof typeof FORMATS; +export const FORMAT_KEYS = Object.keys(FORMATS) as FormatKey[]; diff --git a/src/engine/gradients.ts b/src/engine/gradients.ts new file mode 100644 index 0000000..2059ecc --- /dev/null +++ b/src/engine/gradients.ts @@ -0,0 +1,20 @@ +export interface Gradient { + name: string; + slug: string; + stops: [string, string]; +} + +export const GRADIENTS: Gradient[] = [ + { name: 'Void', slug: 'void', stops: ['#0c0f1a', '#080a12'] }, + { name: 'Deep Sea', slug: 'deep-sea', stops: ['#0a1628', '#061220'] }, + { name: 'Ember', slug: 'ember', stops: ['#1a0a0a', '#120808'] }, + { name: 'Forest', slug: 'forest', stops: ['#0a1a10', '#061208'] }, + { name: 'Plum', slug: 'plum', stops: ['#150a1a', '#0e0812'] }, + { name: 'Slate', slug: 'slate', stops: ['#12141a', '#0a0c10'] }, +]; + +export const ACCENTS = ['#38ef7d', '#67e8f9', '#c4b5fd', '#fbbf24', '#fb7185', '#fb923c', '#e2e8f0', '#a3e635']; + +export function getGradientBySlug(slug: string): Gradient { + return GRADIENTS.find((g) => g.slug === slug) ?? GRADIENTS[0]; +} diff --git a/src/engine/image-cache.ts b/src/engine/image-cache.ts new file mode 100644 index 0000000..dc30382 --- /dev/null +++ b/src/engine/image-cache.ts @@ -0,0 +1,31 @@ +import { createHash } from 'node:crypto'; +import { LRUCache } from './cache'; + +interface CachedImage { + buffer: Buffer; + contentType: string; + headers: Record<string, string>; +} + +const cache = new LRUCache<string, CachedImage>(Number(process.env.IMAGE_CACHE_MAX ?? 500)); + +export function hashRequest(body: unknown): string { + const str = JSON.stringify(body); + return createHash('sha256').update(str).digest('hex').slice(0, 32); +} + +export function getCachedImage(hash: string): CachedImage | undefined { + return cache.get(hash); +} + +export function setCachedImage(hash: string, image: CachedImage): void { + cache.set(hash, image); +} + +export function getImageCacheSize(): number { + return cache.size; +} + +export function clearImageCache(): void { + cache.clear(); +} diff --git a/src/engine/image-loader.ts b/src/engine/image-loader.ts new file mode 100644 index 0000000..4c9440f --- /dev/null +++ b/src/engine/image-loader.ts @@ -0,0 +1,65 @@ +import { type Image, loadImage } from '@napi-rs/canvas'; + +const FETCH_TIMEOUT_MS = 5_000; +const MAX_IMAGE_BYTES = 10 * 1024 * 1024; // 10 MB + +const ALLOWED_TYPES = new Set([ + 'image/png', + 'image/jpeg', + 'image/jpg', + 'image/webp', + 'image/gif', + 'image/svg+xml', + 'image/x-icon', + 'image/vnd.microsoft.icon', +]); + +/** + * Fetch a single remote image. Returns null on any failure (timeout, + * bad content type, decode error, etc.) — never throws. + */ +export async function loadRemoteImage(url: string): Promise<Image | null> { + try { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); + + const res = await fetch(url, { + signal: controller.signal, + headers: { 'User-Agent': 'OGEngine/1.0' }, + }); + clearTimeout(timer); + + if (!res.ok) return null; + + const ct = (res.headers.get('content-type') ?? '').split(';')[0].trim().toLowerCase(); + if (!ALLOWED_TYPES.has(ct)) return null; + + const contentLength = Number(res.headers.get('content-length') ?? '0'); + if (contentLength > MAX_IMAGE_BYTES) return null; + + const buf = Buffer.from(await res.arrayBuffer()); + if (buf.length > MAX_IMAGE_BYTES) return null; + + return await loadImage(buf); + } catch { + return null; + } +} + +/** + * Fetch multiple named images in parallel. + * Returns a map with the same keys — values are Image | null. + */ +export async function loadRemoteImages(urls: Record<string, string>): Promise<Record<string, Image | null>> { + const entries = Object.entries(urls); + if (entries.length === 0) return {}; + + const results = await Promise.all( + entries.map(async ([name, url]) => { + const img = await loadRemoteImage(url); + return [name, img] as const; + }), + ); + + return Object.fromEntries(results); +} diff --git a/src/engine/meta-extract.ts b/src/engine/meta-extract.ts new file mode 100644 index 0000000..0525312 --- /dev/null +++ b/src/engine/meta-extract.ts @@ -0,0 +1,44 @@ +import * as cheerio from 'cheerio'; + +export interface MetaResult { + variables: Record<string, string>; + images: Record<string, string>; +} + +/** + * Extract OG/meta tags from an HTML string and return them as + * variables + images suitable for the render pipeline. + */ +export function extractMeta(html: string): MetaResult { + const $ = cheerio.load(html); + + const og = (prop: string): string => $(`meta[property="${prop}"]`).attr('content') ?? ''; + + const meta = (name: string): string => $(`meta[name="${name}"]`).attr('content') ?? ''; + + const title = og('og:title') || meta('twitter:title') || $('title').text().trim(); + + const description = og('og:description') || meta('twitter:description') || meta('description'); + + const author = meta('author') || og('article:author') || meta('twitter:creator'); + + const tag = og('article:tag') || og('article:section') || meta('keywords')?.split(',')[0]?.trim() || ''; + + const siteName = og('og:site_name'); + + const ogImage = og('og:image') || meta('twitter:image'); + + const variables: Record<string, string> = { + title, + description, + author, + tag, + }; + + if (siteName) variables.siteName = siteName; + + const images: Record<string, string> = {}; + if (ogImage) images.background = ogImage; + + return { variables, images }; +} diff --git a/src/engine/pdf.ts b/src/engine/pdf.ts new file mode 100644 index 0000000..b1195be --- /dev/null +++ b/src/engine/pdf.ts @@ -0,0 +1,151 @@ +/** + * Minimal PDF builder — embeds a PNG image in a single-page PDF. + * Zero dependencies. Produces valid PDF 1.4 files. + */ +export function buildPdf(pngBuffer: Buffer, width: number, height: number): Buffer { + // Convert pixel dimensions to PDF points (72 dpi) + // OG images are typically displayed at screen resolution; use 1:1 pixel-to-point + const pageW = width; + const pageH = height; + + const _pngB64 = pngBuffer.toString('base64'); + + // PDF objects + const objects: string[] = []; + const offsets: number[] = []; + + function addObj(content: string): number { + const num = objects.length + 1; + objects.push(content); + return num; + } + + // 1: Catalog + const pagesRef = 2; + addObj(`<< /Type /Catalog /Pages ${pagesRef} 0 R >>`); + + // 2: Pages + const pageRef = 3; + addObj(`<< /Type /Pages /Kids [${pageRef} 0 R] /Count 1 >>`); + + // 3: Page + const contentsRef = 4; + const resourcesRef = 5; + addObj( + `<< /Type /Page /Parent ${pagesRef} 0 R /MediaBox [0 0 ${pageW} ${pageH}] /Contents ${contentsRef} 0 R /Resources ${resourcesRef} 0 R >>`, + ); + + // 4: Contents (draw image full-page) + const contentStream = `q ${pageW} 0 0 ${pageH} 0 0 cm /Img Do Q`; + addObj(`<< /Length ${contentStream.length} >>\nstream\n${contentStream}\nendstream`); + + // 5: Resources + const imgRef = 6; + addObj(`<< /XObject << /Img ${imgRef} 0 R >> >>`); + + // 6: Image XObject (PNG embedded via FlateDecode of raw stream, or as-is with DCT) + // For simplicity and correctness, embed the PNG directly using the /Filter approach + // PDF supports embedding PNG data via /FlateDecode with /DecodeParms for Predictor + // But the simplest correct approach: embed as raw RGB pixels + // Actually, the cleanest no-dep approach: use ASCII85 or raw hex. Let's use raw binary. + + // PNG-in-PDF: We'll reference the PNG as an inline image in content stream. + // Better approach: embed PNG bytes as the image stream with proper filter chain. + // Simplest valid approach: embed the raw PNG buffer with proper PDF image XObject headers. + + // Use FlateDecode — PNG data minus the header can be used if we strip correctly. + // But that's complex. Instead, just embed the full PNG buffer and let PDF readers handle it. + // Wait — PDF doesn't natively understand PNG containers. We need to extract raw image data. + + // Simplest correct approach for zero-dep: embed as uncompressed RGB from canvas. + // But we only have PNG buffer. Let's decode PNG minimally or use a different strategy. + + // Actually the cleanest approach: create the image XObject using the PNG's compressed + // IDAT chunks with FlateDecode + PNG predictor params. But parsing PNG is non-trivial. + + // Pragmatic solution: encode PNG buffer as hex stream with /ASCIIHexDecode then + // reference it as an opaque XObject. BUT PDF image XObjects need pixel data, not PNG. + + // Final approach: Use the PNG as a raw byte stream embedded directly. + // The trick: We can use /Filter [/ASCIIHexDecode /FlateDecode] with PNG predictor, + // but we need to strip the PNG container. + + // OK - simplest correct implementation: create a proper PDF by extracting + // the raw compressed data from the PNG and setting up proper DecodeParms. + const pngData = extractPngIDAT(pngBuffer); + + const imgDict = [ + `/Type /XObject`, + `/Subtype /Image`, + `/Width ${width}`, + `/Height ${height}`, + `/ColorSpace /DeviceRGB`, + `/BitsPerComponent 8`, + `/Filter /FlateDecode`, + `/DecodeParms << /Predictor 15 /Colors 3 /BitsPerComponent 8 /Columns ${width} >>`, + `/Length ${pngData.length}`, + ].join(' '); + + // Build PDF manually to control byte offsets + let pdf = '%PDF-1.4\n%\xE2\xE3\xCF\xD3\n'; + + // Write objects 1-5 + for (let i = 0; i < 5; i++) { + offsets[i] = pdf.length; + pdf += `${i + 1} 0 obj\n${objects[i]}\nendobj\n`; + } + + // Write image object (6) — binary data, need Buffer concat + const imgHeaderStr = `6 0 obj\n<< ${imgDict} >>\nstream\n`; + const imgFooterStr = '\nendstream\nendobj\n'; + + const pdfHeader = Buffer.from(pdf, 'binary'); + const imgHeader = Buffer.from(imgHeaderStr, 'binary'); + const imgFooter = Buffer.from(imgFooterStr, 'binary'); + + offsets[5] = pdfHeader.length; + + // Cross-reference table + const xrefOffset = pdfHeader.length + imgHeader.length + pngData.length + imgFooter.length; + + let xref = 'xref\n'; + xref += `0 7\n`; + xref += `0000000000 65535 f \n`; + for (let i = 0; i < 6; i++) { + xref += `${String(offsets[i]).padStart(10, '0')} 00000 n \n`; + } + + let trailer = 'trailer\n'; + trailer += `<< /Size 7 /Root 1 0 R >>\n`; + trailer += 'startxref\n'; + trailer += `${xrefOffset}\n`; + trailer += '%%EOF\n'; + + const xrefBuf = Buffer.from(xref + trailer, 'binary'); + + return Buffer.concat([pdfHeader, imgHeader, pngData, imgFooter, xrefBuf]); +} + +/** + * Extract concatenated IDAT chunk data from a PNG buffer. + * This gives us the raw zlib-compressed image data that PDF can use + * with /FlateDecode and PNG predictor parameters. + */ +function extractPngIDAT(png: Buffer): Buffer { + // PNG structure: 8-byte signature, then chunks (length + type + data + crc) + const chunks: Buffer[] = []; + let offset = 8; // Skip PNG signature + + while (offset < png.length) { + const length = png.readUInt32BE(offset); + const type = png.slice(offset + 4, offset + 8).toString('ascii'); + + if (type === 'IDAT') { + chunks.push(png.slice(offset + 8, offset + 8 + length)); + } + + offset += 12 + length; // 4 (length) + 4 (type) + data + 4 (crc) + } + + return Buffer.concat(chunks); +} diff --git a/src/engine/renderer.ts b/src/engine/renderer.ts new file mode 100644 index 0000000..9a85103 --- /dev/null +++ b/src/engine/renderer.ts @@ -0,0 +1,181 @@ +import { createCanvas, type Image, loadImage } from '@napi-rs/canvas'; +import { autoFitCard } from './autofit'; +import { type CustomTemplateDefinition, renderCustomTemplate } from './custom-template'; +import { getFontByName } from './fonts'; +import { FORMATS, type FormatKey } from './formats'; +import { buildPdf } from './pdf'; +import { getTemplate } from './templates'; + +export interface RenderOptions { + title: string; + description: string; + author: string; + tag: string; + format: FormatKey; + template: string; + accent: string; + layout: 'left' | 'center' | 'bottom'; + titleSize: number; + descSize: number; + fontName: string; + gradient: string; + bgImageBuffer: Buffer | null; + overlayOpacity: number; + autoFit: boolean; + customTemplateDefinition?: CustomTemplateDefinition; + outputFormat: 'png' | 'webp' | 'pdf'; + variables?: Record<string, string>; + namedImages?: Record<string, import('@napi-rs/canvas').Image | null>; + outputQuality: number; + timing?: boolean; +} + +export interface RenderPhases { + textMeasureMs: number; + canvasDrawMs: number; + pngEncodeMs: number; + totalMs: number; +} + +export interface RenderResult { + buffer: Buffer; + contentType: string; + width: number; + height: number; + titleTotalLines: number; + titleVisibleLines: number; + descTotalLines: number; + descVisibleLines: number; + overflow: boolean; + phases?: RenderPhases; +} + +export async function renderCard(options: RenderOptions): Promise<RenderResult> { + const { + title, + description, + author, + tag, + format, + template, + accent, + layout, + fontName, + gradient: gradientSlug, + bgImageBuffer, + overlayOpacity, + autoFit, + outputFormat, + outputQuality, + timing, + } = options; + + // Auto-fit font sizes if requested + let { titleSize, descSize } = options; + if (autoFit) { + const fitted = autoFitCard({ title, description, format, fontName }); + titleSize = fitted.titleSize; + descSize = fitted.descSize; + } + + const fmt = FORMATS[format]; + if (!fmt) throw new Error(`Unknown format: ${format}`); + + const W = fmt.w; + const H = fmt.h; + const canvas = createCanvas(W, H); + const ctx = canvas.getContext('2d'); + const fontEntry = getFontByName(fontName); + const t = timing ? { t0: performance.now(), t1: 0, t2: 0, t3: 0 } : null; + + // Load background image if provided + let bgImage: Image | null = null; + if (bgImageBuffer) { + bgImage = await loadImage(bgImageBuffer); + } + + if (t) t.t1 = performance.now(); + + const variables: Record<string, string> = { + title, + description, + author, + tag, + ...options.variables, + }; + + // Run template — custom DSL or built-in + let result: import('./templates').TemplateResult; + if (options.customTemplateDefinition) { + result = renderCustomTemplate( + options.customTemplateDefinition, + ctx, + W, + H, + variables, + { accent, fontFamily: fontEntry.family }, + bgImage, + options.namedImages ?? {}, + ); + } else { + const templateFn = getTemplate(template); + result = templateFn({ + canvas, + ctx, + width: W, + height: H, + format: fmt, + content: { title, description, author, tag }, + style: { + accent, + layout, + fontFamily: fontEntry.family, + titleSize, + descSize, + gradient: gradientSlug, + }, + bgImage, + overlayOpacity, + variables, + namedImages: options.namedImages ?? {}, + }); + } + + if (t) t.t2 = performance.now(); + + // Encode output + let buffer: Buffer; + let contentType: string; + + if (outputFormat === 'pdf') { + const pngBuffer = canvas.toBuffer('image/png'); + buffer = buildPdf(pngBuffer, W, H); + contentType = 'application/pdf'; + } else if (outputFormat === 'webp') { + buffer = canvas.toBuffer('image/webp', outputQuality); + contentType = 'image/webp'; + } else { + buffer = canvas.toBuffer('image/png'); + contentType = 'image/png'; + } + + let phases: RenderPhases | undefined; + if (t) { + t.t3 = performance.now(); + phases = { + textMeasureMs: Number((t.t1 - t.t0).toFixed(3)), + canvasDrawMs: Number((t.t2 - t.t1).toFixed(3)), + pngEncodeMs: Number((t.t3 - t.t2).toFixed(3)), + totalMs: Number((t.t3 - t.t0).toFixed(3)), + }; + } + + return { + buffer, + contentType, + width: W, + height: H, + ...result, + phases, + }; +} diff --git a/src/engine/templates.ts b/src/engine/templates.ts new file mode 100644 index 0000000..3e6e415 --- /dev/null +++ b/src/engine/templates.ts @@ -0,0 +1,2 @@ +export type { TemplateFn, TemplateInput, TemplateResult } from './templates/index'; +export { getTemplate, TEMPLATE_NAMES, TEMPLATES } from './templates/index'; diff --git a/src/engine/templates/announcement.ts b/src/engine/templates/announcement.ts new file mode 100644 index 0000000..5f2a8c2 --- /dev/null +++ b/src/engine/templates/announcement.ts @@ -0,0 +1,170 @@ +import { measureTextWidth } from '../text-measure'; +import { drawBgImage, fitTitleLines, paintBackgroundMesh, rgba } from './helpers'; +import type { TemplateFn } from './types'; + +export const announcementTemplate: TemplateFn = (input) => { + const { + ctx, + width: W, + height: H, + format: fmt, + content, + style, + bgImage, + overlayOpacity, + variables, + namedImages, + } = input; + const { title: contentTitle, tag: contentTag } = content; + const { accent, fontFamily: ff, titleSize, descSize, gradient: gradientSlug } = style; + const s = Math.max(W, H) / 1200; + + const title = contentTitle || ''; + const subtitle = variables.subtitle ?? content.description ?? ''; + const cta = variables.cta ?? ''; + const tag = variables.tag ?? contentTag ?? ''; + const logo = namedImages.logo ?? null; + + // --- Background --- + if (bgImage) { + drawBgImage(ctx, bgImage, W, H, Math.max(overlayOpacity, 0.62)); + } else { + paintBackgroundMesh(ctx, W, H, gradientSlug, accent); + } + + // Heavy dramatic overlay for high-contrast feel + const dramaticOverlay = ctx.createLinearGradient(0, 0, 0, H); + dramaticOverlay.addColorStop(0, 'rgba(4,6,12,0.38)'); + dramaticOverlay.addColorStop(0.5, 'rgba(4,6,12,0.18)'); + dramaticOverlay.addColorStop(1, 'rgba(4,6,12,0.55)'); + ctx.fillStyle = dramaticOverlay; + ctx.fillRect(0, 0, W, H); + + // Accent glow in center + const glow = ctx.createRadialGradient(W / 2, H * 0.44, 0, W / 2, H * 0.44, W * 0.55); + glow.addColorStop(0, rgba(accent, 0.18)); + glow.addColorStop(0.6, rgba(accent, 0.06)); + glow.addColorStop(1, 'rgba(0,0,0,0)'); + ctx.fillStyle = glow; + ctx.fillRect(0, 0, W, H); + + const px = Math.round(80 * s); + const cW = W - px * 2; + + ctx.textBaseline = 'top'; + + // --- Logo (top-left) --- + if (logo) { + const maxLogoH = Math.round(40 * s); + const logoScale = maxLogoH / logo.height; + const logoW = logo.width * logoScale; + const logoPad = Math.round(44 * s); + ctx.drawImage(logo, logoPad, logoPad, logoW, maxLogoH); + } + + // --- Tag pill (top, centered) --- + if (tag) { + const tagFont = `700 ${Math.round(13 * s)}px ${ff}`; + ctx.font = tagFont; + const tagText = tag.toUpperCase(); + const tagW = measureTextWidth(tagText, tagFont); + const pillPadX = Math.round(18 * s); + const pillPadY = Math.round(8 * s); + const pillH = Math.round(13 * s) + pillPadY * 2; + const pillW = tagW + pillPadX * 2; + const pillX = W / 2 - pillW / 2; + const pillY = Math.round(48 * s); + + // Pill with accent border and translucent bg + ctx.fillStyle = rgba(accent, 0.18); + ctx.beginPath(); + ctx.roundRect(pillX, pillY, pillW, pillH, pillH / 2); + ctx.fill(); + + ctx.strokeStyle = accent; + ctx.lineWidth = Math.round(1.5 * s); + ctx.beginPath(); + ctx.roundRect(pillX, pillY, pillW, pillH, pillH / 2); + ctx.stroke(); + + ctx.fillStyle = accent; + ctx.textAlign = 'left'; + ctx.fillText(tagText, pillX + pillPadX, pillY + pillPadY); + } + + // --- Center area: title + subtitle --- + const { lines: tLines, fontSize: eff } = fitTitleLines( + title, + ff, + Math.round(titleSize * 1.1), + 900, + cW, + fmt.maxTitleLines, + s, + ); + const tFont = `900 ${Math.round(eff * s)}px ${ff}`; + const tLH = Math.round(eff * 1.06 * s); + const visibleT = tLines.slice(0, fmt.maxTitleLines); + + const subFont = `400 ${Math.round(descSize * s)}px ${ff}`; + const subLH = Math.round(descSize * 1.5 * s); + const hasSubtitle = !!subtitle; + const subGap = hasSubtitle ? Math.round(18 * s) : 0; + + const totalTextH = visibleT.length * tLH + subGap + (hasSubtitle ? subLH : 0); + const textStartY = (H - totalTextH) / 2; + let yPos = textStartY; + + // Title — centered, bold, white + ctx.font = tFont; + ctx.fillStyle = '#ffffff'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'top'; + for (let i = 0; i < visibleT.length; i++) { + let t = visibleT[i].text; + if (i === visibleT.length - 1 && tLines.length > fmt.maxTitleLines) t += '\u2026'; + ctx.fillText(t, W / 2, yPos); + yPos += tLH; + } + + // Subtitle + if (hasSubtitle) { + yPos += subGap; + ctx.font = subFont; + ctx.fillStyle = 'rgba(203,213,225,0.82)'; + ctx.fillText(subtitle, W / 2, yPos); + } + + // --- CTA button (bottom-center) --- + if (cta) { + const ctaFont = `700 ${Math.round(18 * s)}px ${ff}`; + ctx.font = ctaFont; + const ctaTextW = measureTextWidth(cta, ctaFont); + const ctaPadX = Math.round(36 * s); + const ctaPadY = Math.round(14 * s); + const ctaH = Math.round(18 * s) + ctaPadY * 2; + const ctaW = ctaTextW + ctaPadX * 2; + const ctaX = W / 2 - ctaW / 2; + const ctaY = H - Math.round(72 * s) - ctaH; + + // Accent filled button + ctx.fillStyle = accent; + ctx.beginPath(); + ctx.roundRect(ctaX, ctaY, ctaW, ctaH, ctaH / 2); + ctx.fill(); + + ctx.fillStyle = 'rgba(6,8,12,0.92)'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(cta, W / 2, ctaY + ctaH / 2); + } + + const overflow = tLines.length > fmt.maxTitleLines; + return { + titleTotalLines: tLines.length, + titleVisibleLines: visibleT.length, + descTotalLines: 0, + descVisibleLines: 0, + overflow, + }; +}; diff --git a/src/engine/templates/blog-hero.ts b/src/engine/templates/blog-hero.ts new file mode 100644 index 0000000..237d287 --- /dev/null +++ b/src/engine/templates/blog-hero.ts @@ -0,0 +1,95 @@ +import { measureLines, measureTextWidth } from '../text-measure'; +import { drawBgImage, fitTitleLines, paintBackgroundMesh } from './helpers'; +import type { TemplateFn } from './types'; + +export const blogHeroTemplate: TemplateFn = (input) => { + const { ctx, width: W, height: H, format: fmt, content, style, bgImage, overlayOpacity } = input; + const { title, description, author, tag } = content; + const { accent, fontFamily: ff, titleSize, descSize, gradient: gradientSlug } = style; + const s = Math.max(W, H) / 1200; + + if (bgImage) { + drawBgImage(ctx, bgImage, W, H, 0); + } else { + paintBackgroundMesh(ctx, W, H, gradientSlug, accent); + } + + const bottomGrad = ctx.createLinearGradient(0, H * 0.25, 0, H); + bottomGrad.addColorStop(0, 'rgba(0,0,0,0)'); + bottomGrad.addColorStop(0.55, `rgba(0,0,0,${overlayOpacity * 0.7})`); + bottomGrad.addColorStop(1, `rgba(0,0,0,${Math.min(0.92, overlayOpacity + 0.18)})`); + ctx.fillStyle = bottomGrad; + ctx.fillRect(0, 0, W, H); + + const px = Math.round(84 * s); + const cW = W - px * 2; + + const { lines: tLines, fontSize: eff } = fitTitleLines(title || '', ff, titleSize, 800, cW, fmt.maxTitleLines, s); + const tFont = `800 ${Math.round(eff * s)}px ${ff}`; + const tLH = Math.round(eff * 1.08 * s); + const visibleT = tLines.slice(0, fmt.maxTitleLines); + + const dFont = `400 ${Math.round(descSize * s)}px ${ff}`; + const dLH = Math.round(descSize * 1.5 * s); + const dLines = measureLines(description || '', dFont, cW); + const visibleD = dLines.slice(0, fmt.maxDescLines); + + const tagH = tag ? 24 * s : 0; + const tagGap = tag ? 22 * s : 0; + const descGap = description ? 20 * s : 0; + const authorGap = author ? 32 * s : 0; + const authorH = author ? 20 * s : 0; + const totalH = tagH + tagGap + visibleT.length * tLH + descGap + visibleD.length * dLH + authorGap + authorH; + let yPos = H - px - totalH; + + ctx.textAlign = 'left'; + ctx.textBaseline = 'top'; + + if (tag) { + ctx.font = `500 ${Math.round(14 * s)}px ${ff}`; + ctx.fillStyle = accent; + ctx.fillText(`\u25cf ${tag}`, px, yPos); + yPos += tagH + tagGap; + } + + ctx.fillStyle = '#ffffff'; + ctx.font = tFont; + for (let i = 0; i < visibleT.length; i++) { + let t = visibleT[i].text; + if (i === visibleT.length - 1 && tLines.length > fmt.maxTitleLines) t += '\u2026'; + ctx.fillText(t, px, yPos); + yPos += tLH; + } + yPos += descGap; + + if (description) { + ctx.fillStyle = 'rgba(226,232,240,0.82)'; + ctx.font = dFont; + for (let i = 0; i < visibleD.length; i++) { + let t = visibleD[i].text; + if (i === visibleD.length - 1 && dLines.length > fmt.maxDescLines) t += '\u2026'; + ctx.fillText(t, px, yPos); + yPos += dLH; + } + yPos += authorGap; + } + + if (author) { + const aFont = `500 ${Math.round(15 * s)}px ${ff}`; + ctx.font = aFont; + ctx.fillStyle = accent; + ctx.fillText('\u2014 ', px, yPos); + const dashW = measureTextWidth('\u2014 ', aFont); + ctx.fillStyle = 'rgba(255,255,255,0.72)'; + ctx.fillText(author, px + dashW, yPos); + } + + const overflow = tLines.length > fmt.maxTitleLines || dLines.length > fmt.maxDescLines; + return { + titleTotalLines: tLines.length, + titleVisibleLines: visibleT.length, + descTotalLines: dLines.length, + descVisibleLines: visibleD.length, + overflow, + }; +}; diff --git a/src/engine/templates/default.ts b/src/engine/templates/default.ts new file mode 100644 index 0000000..96899a7 --- /dev/null +++ b/src/engine/templates/default.ts @@ -0,0 +1,121 @@ +import { measureLines, measureTextWidth } from '../text-measure'; +import { drawBgImage, fitTitleLines, paintBackgroundMesh, rgba } from './helpers'; +import type { TemplateFn } from './types'; + +export const defaultTemplate: TemplateFn = (input) => { + const { ctx, width: W, height: H, format: fmt, content, style, bgImage, overlayOpacity } = input; + const { title, description, author, tag } = content; + const { accent, layout, fontFamily: ff, titleSize, descSize, gradient: gradientSlug } = style; + const s = Math.max(W, H) / 1200; + + if (bgImage) { + drawBgImage(ctx, bgImage, W, H, overlayOpacity); + ctx.fillStyle = rgba(accent, 0.05); + ctx.fillRect(0, 0, W, H); + } else { + paintBackgroundMesh(ctx, W, H, gradientSlug, accent); + } + + const px = Math.round(84 * s); + const cW = W - px * 2; + const isCenter = layout === 'center'; + const isBottom = layout === 'bottom'; + + const { lines: tLines, fontSize: eff } = fitTitleLines(title || '', ff, titleSize, 800, cW, fmt.maxTitleLines, s); + const tFont = `800 ${Math.round(eff * s)}px ${ff}`; + const tLH = Math.round(eff * 1.08 * s); + const visibleT = tLines.slice(0, fmt.maxTitleLines); + + const dFont = `400 ${Math.round(descSize * s)}px ${ff}`; + const dLH = Math.round(descSize * 1.5 * s); + const dLines = measureLines(description || '', dFont, cW); + const visibleD = dLines.slice(0, fmt.maxDescLines); + + const tagFont = `600 ${Math.round(14 * s)}px ${ff}`; + const tagH = tag ? 26 * s : 0; + const tagGap = tag ? 24 * s : 0; + const descGap = visibleD.length ? 24 * s : 0; + const authorGap = author ? 40 * s : 0; + const authorH = author ? 20 * s : 0; + const totalH = tagH + tagGap + visibleT.length * tLH + descGap + visibleD.length * dLH + authorGap + authorH; + + let yPos = isBottom ? H - px - totalH : isCenter ? (H - totalH) / 2 : Math.round(px * 1.1); + const align: CanvasTextAlign = isCenter ? 'center' : 'left'; + const xP = isCenter ? W / 2 : px; + ctx.textAlign = align; + ctx.textBaseline = 'top'; + + if (tag) { + ctx.font = tagFont; + const dot = '\u25cf'; + const tagText = tag; + if (isCenter) { + const dotW = measureTextWidth(`${dot} `, tagFont); + const tgW = measureTextWidth(tagText, tagFont); + const startX = W / 2 - (dotW + tgW) / 2; + ctx.textAlign = 'left'; + ctx.fillStyle = accent; + ctx.fillText(dot, startX, yPos + 2 * s); + ctx.fillStyle = 'rgba(255,255,255,0.72)'; + ctx.fillText(tagText, startX + dotW, yPos + 2 * s); + ctx.textAlign = align; + } else { + ctx.fillStyle = accent; + ctx.fillText(dot, xP, yPos + 2 * s); + ctx.fillStyle = 'rgba(255,255,255,0.72)'; + ctx.fillText(tagText, xP + measureTextWidth(`${dot} `, tagFont), yPos + 2 * s); + } + yPos += tagH + tagGap; + } + + ctx.fillStyle = '#f8fafc'; + ctx.font = tFont; + for (let i = 0; i < visibleT.length; i++) { + let t = visibleT[i].text; + if (i === visibleT.length - 1 && tLines.length > fmt.maxTitleLines) t += '\u2026'; + ctx.fillText(t, xP, yPos); + yPos += tLH; + } + yPos += descGap; + + ctx.fillStyle = bgImage ? 'rgba(226,232,240,0.88)' : 'rgba(203,213,225,0.78)'; + ctx.font = dFont; + for (let i = 0; i < visibleD.length; i++) { + let t = visibleD[i].text; + if (i === visibleD.length - 1 && dLines.length > fmt.maxDescLines) t += '\u2026'; + ctx.fillText(t, xP, yPos); + yPos += dLH; + } + yPos += authorGap; + + if (author) { + const aFont = `500 ${Math.round(16 * s)}px ${ff}`; + ctx.font = aFont; + const bullet = '\u2014'; + const name = ` ${author}`; + if (isCenter) { + const bw = measureTextWidth(bullet + name, aFont); + const startX = W / 2 - bw / 2; + ctx.textAlign = 'left'; + ctx.fillStyle = accent; + ctx.fillText(bullet, startX, yPos); + ctx.fillStyle = 'rgba(255,255,255,0.72)'; + ctx.fillText(name, startX + measureTextWidth(bullet, aFont), yPos); + ctx.textAlign = align; + } else { + ctx.fillStyle = accent; + ctx.fillText(bullet, xP, yPos); + ctx.fillStyle = 'rgba(255,255,255,0.72)'; + ctx.fillText(name, xP + measureTextWidth(bullet, aFont), yPos); + } + } + + const overflow = tLines.length > fmt.maxTitleLines || dLines.length > fmt.maxDescLines; + return { + titleTotalLines: tLines.length, + titleVisibleLines: visibleT.length, + descTotalLines: dLines.length, + descVisibleLines: visibleD.length, + overflow, + }; +}; diff --git a/src/engine/templates/email-banner.ts b/src/engine/templates/email-banner.ts new file mode 100644 index 0000000..39cba3b --- /dev/null +++ b/src/engine/templates/email-banner.ts @@ -0,0 +1,100 @@ +import { measureLines, measureTextWidth } from '../text-measure'; +import { drawBgImage, fitTitleLines, paintBackgroundMesh, rgba } from './helpers'; +import type { TemplateFn } from './types'; + +export const emailBannerTemplate: TemplateFn = (input) => { + const { ctx, width: W, height: H, format: fmt, content, style, bgImage, overlayOpacity } = input; + const { title, description, author, tag } = content; + const { accent, fontFamily: ff, titleSize, descSize, gradient: gradientSlug } = style; + const s = Math.max(W, H) / 1200; + + if (bgImage) { + drawBgImage(ctx, bgImage, W, H, overlayOpacity); + } else { + paintBackgroundMesh(ctx, W, H, gradientSlug, accent); + } + + const rightGlow = ctx.createRadialGradient(W * 0.88, H * 0.5, 0, W * 0.88, H * 0.5, W * 0.55); + rightGlow.addColorStop(0, rgba(accent, 0.14)); + rightGlow.addColorStop(1, 'rgba(0,0,0,0)'); + ctx.fillStyle = rightGlow; + ctx.fillRect(0, 0, W, H); + + const px = Math.round(88 * s); + const cW = W - px * 2; + + const maxT = Math.min(fmt.maxTitleLines, 2); + const { lines: tLines, fontSize: eff } = fitTitleLines(title || '', ff, titleSize, 800, cW, maxT, s); + const tFont = `800 ${Math.round(eff * s)}px ${ff}`; + const tLH = Math.round(eff * 1.1 * s); + const visibleT = tLines.slice(0, maxT); + + const dFont = `400 ${Math.round(descSize * s)}px ${ff}`; + const dLH = Math.round(descSize * 1.5 * s); + const dLines = measureLines(description || '', dFont, cW); + const maxD = Math.min(fmt.maxDescLines, 2); + const visibleD = dLines.slice(0, maxD); + + const tagH = tag ? 22 * s : 0; + const tagGap = tag ? 18 * s : 0; + const descGap = 16 * s; + const ctaGap = author ? 30 * s : 0; + const ctaH = author ? 52 * s : 0; + const totalH = tagH + tagGap + visibleT.length * tLH + descGap + visibleD.length * dLH + ctaGap + ctaH; + let yPos = (H - totalH) / 2; + + ctx.textAlign = 'left'; + ctx.textBaseline = 'top'; + + if (tag) { + ctx.font = `500 ${Math.round(14 * s)}px ${ff}`; + ctx.fillStyle = accent; + ctx.fillText(`\u25cf ${tag}`, px, yPos); + yPos += tagH + tagGap; + } + + ctx.fillStyle = '#f8fafc'; + ctx.font = tFont; + for (let i = 0; i < visibleT.length; i++) { + let t = visibleT[i].text; + if (i === visibleT.length - 1 && tLines.length > maxT) t += '\u2026'; + ctx.fillText(t, px, yPos); + yPos += tLH; + } + yPos += descGap; + + ctx.fillStyle = 'rgba(203,213,225,0.8)'; + ctx.font = dFont; + for (let i = 0; i < visibleD.length; i++) { + let t = visibleD[i].text; + if (i === visibleD.length - 1 && dLines.length > maxD) t += '\u2026'; + ctx.fillText(t, px, yPos); + yPos += dLH; + } + + if (author) { + yPos += ctaGap; + const ctaFont = `700 ${Math.round(16 * s)}px ${ff}`; + ctx.font = ctaFont; + const label = author; + const ctaW = measureTextWidth(label, ctaFont) + 64 * s; + const ctaH2 = 52 * s; + ctx.fillStyle = accent; + ctx.beginPath(); + ctx.roundRect(px, yPos, ctaW, ctaH2, 10 * s); + ctx.fill(); + ctx.fillStyle = '#06080c'; + ctx.textAlign = 'left'; + ctx.fillText(label, px + 24 * s, yPos + ctaH2 / 2 - 8 * s); + ctx.fillText('\u2192', px + ctaW - 30 * s, yPos + ctaH2 / 2 - 8 * s); + } + + const overflow = tLines.length > maxT || dLines.length > maxD; + return { + titleTotalLines: tLines.length, + titleVisibleLines: visibleT.length, + descTotalLines: dLines.length, + descVisibleLines: visibleD.length, + overflow, + }; +}; diff --git a/src/engine/templates/event.ts b/src/engine/templates/event.ts new file mode 100644 index 0000000..69527bb --- /dev/null +++ b/src/engine/templates/event.ts @@ -0,0 +1,168 @@ +import { measureTextWidth } from '../text-measure'; +import { drawBgImage, fitTitleLines, paintBackgroundMesh, rgba } from './helpers'; +import type { TemplateFn } from './types'; + +export const eventTemplate: TemplateFn = (input) => { + const { + ctx, + width: W, + height: H, + format: fmt, + content, + style, + bgImage, + overlayOpacity, + variables, + namedImages, + } = input; + const { title, tag } = content; + const { accent, fontFamily: ff, titleSize, descSize, gradient: gradientSlug } = style; + const s = Math.max(W, H) / 1200; + + const date = variables.date ?? ''; + const location = variables.location ?? ''; + const speaker = variables.speaker ?? ''; + + // --- Background --- + if (bgImage) { + drawBgImage(ctx, bgImage, W, H, 0); + } else { + paintBackgroundMesh(ctx, W, H, gradientSlug, accent); + } + + // Strong bottom gradient overlay + const bottomGrad = ctx.createLinearGradient(0, H * 0.15, 0, H); + bottomGrad.addColorStop(0, 'rgba(0,0,0,0)'); + bottomGrad.addColorStop(0.45, `rgba(0,0,0,${overlayOpacity * 0.65})`); + bottomGrad.addColorStop(1, `rgba(0,0,0,${Math.min(0.94, overlayOpacity + 0.22)})`); + ctx.fillStyle = bottomGrad; + ctx.fillRect(0, 0, W, H); + + const px = Math.round(72 * s); + const cW = W - px * 2; + + ctx.textAlign = 'left'; + ctx.textBaseline = 'top'; + + // --- Logo (top-left) --- + const logo = namedImages.logo ?? namedImages.background ?? null; + const logoSize = Math.round(56 * s); + const logoPad = Math.round(44 * s); + if (logo) { + const lw = logo.width; + const lh = logo.height; + const scale = Math.min(logoSize / lw, logoSize / lh); + const dw = lw * scale; + const dh = lh * scale; + ctx.drawImage(logo, logoPad, logoPad, dw, dh); + } + + // --- Tag pill (top area) --- + if (tag) { + const tagFont = `600 ${Math.round(13 * s)}px ${ff}`; + ctx.font = tagFont; + const tagText = tag.toUpperCase(); + const tagW = measureTextWidth(tagText, tagFont); + const pillPadX = Math.round(16 * s); + const pillPadY = Math.round(7 * s); + const pillH = Math.round(13 * s) + pillPadY * 2; + const pillW = tagW + pillPadX * 2; + const pillX = logo ? logoPad + logoSize + Math.round(20 * s) : logoPad; + const pillY = logoPad + (logoSize - pillH) / 2; + + // pill background + ctx.fillStyle = rgba(accent, 0.18); + ctx.beginPath(); + ctx.roundRect(pillX, pillY, pillW, pillH, pillH / 2); + ctx.fill(); + + // pill border + ctx.strokeStyle = rgba(accent, 0.55); + ctx.lineWidth = Math.round(1.2 * s); + ctx.beginPath(); + ctx.roundRect(pillX, pillY, pillW, pillH, pillH / 2); + ctx.stroke(); + + ctx.fillStyle = accent; + ctx.fillText(tagText, pillX + pillPadX, pillY + pillPadY); + } + + // --- Compute heights for bottom layout --- + // Info bar: date + location + const infoFont = `500 ${Math.round(descSize * s)}px ${ff}`; + const infoLH = Math.round(descSize * 1.4 * s); + const hasInfo = date || location; + + // Speaker line + const speakerFont = `400 ${Math.round(14 * s)}px ${ff}`; + const speakerH = speaker ? Math.round(14 * 1.6 * s) : 0; + const speakerGap = speaker ? Math.round(18 * s) : 0; + + // Title + const { lines: tLines, fontSize: eff } = fitTitleLines(title || '', ff, titleSize, 800, cW, fmt.maxTitleLines, s); + const tFont = `800 ${Math.round(eff * s)}px ${ff}`; + const tLH = Math.round(eff * 1.1 * s); + const visibleT = tLines.slice(0, fmt.maxTitleLines); + + const infoH = hasInfo ? infoLH : 0; + const infoGap = hasInfo ? Math.round(20 * s) : 0; + const titleToInfo = Math.round(22 * s); + + const totalH = visibleT.length * tLH + titleToInfo + infoH + infoGap + speakerGap + speakerH; + + const bottomPad = Math.round(64 * s); + let yPos = H - bottomPad - totalH; + + // --- Title --- + ctx.fillStyle = '#ffffff'; + ctx.font = tFont; + for (let i = 0; i < visibleT.length; i++) { + let t = visibleT[i].text; + if (i === visibleT.length - 1 && tLines.length > fmt.maxTitleLines) t += '\u2026'; + ctx.fillText(t, px, yPos); + yPos += tLH; + } + yPos += titleToInfo; + + // --- Info bar: calendar emoji + date, pin emoji + location --- + if (hasInfo) { + ctx.font = infoFont; + + let infoX = px; + + if (date) { + const calLabel = `\uD83D\uDCC5 ${date}`; + ctx.fillStyle = 'rgba(255,255,255,0.85)'; + ctx.fillText(calLabel, infoX, yPos); + const calW = measureTextWidth(calLabel, infoFont); + infoX += calW + Math.round(36 * s); + } + + if (location) { + const pinLabel = `\uD83D\uDCCD ${location}`; + ctx.fillStyle = 'rgba(255,255,255,0.85)'; + ctx.fillText(pinLabel, infoX, yPos); + } + + yPos += infoH + infoGap; + } + + // --- Speaker --- + if (speaker) { + ctx.font = speakerFont; + ctx.fillStyle = rgba(accent, 0.9); + ctx.fillText('\u2605 ', px, yPos); + const starW = measureTextWidth('\u2605 ', speakerFont); + ctx.fillStyle = 'rgba(255,255,255,0.65)'; + ctx.fillText(speaker, px + starW, yPos); + } + + const overflow = tLines.length > fmt.maxTitleLines; + return { + titleTotalLines: tLines.length, + titleVisibleLines: visibleT.length, + descTotalLines: 0, + descVisibleLines: 0, + overflow, + }; +}; diff --git a/src/engine/templates/github-repo.ts b/src/engine/templates/github-repo.ts new file mode 100644 index 0000000..6781228 --- /dev/null +++ b/src/engine/templates/github-repo.ts @@ -0,0 +1,167 @@ +import { measureLines, measureTextWidth } from '../text-measure'; +import { fitTitleLines, paintBackgroundMesh, rgba } from './helpers'; +import type { TemplateFn } from './types'; + +export const githubRepoTemplate: TemplateFn = (input) => { + const { ctx, width: W, height: H, format: fmt, content, style, namedImages } = input; + const { title, description } = content; + const { accent, fontFamily: ff } = style; + // Force dark GitHub aesthetic — always use void gradient regardless of user choice + const s = Math.max(W, H) / 1200; + + const stars = input.variables?.stars ?? ''; + const language = input.variables?.language ?? ''; + const owner = input.variables?.owner ?? ''; + + const avatar = namedImages?.avatar ?? null; + + // Background — force void dark theme + paintBackgroundMesh(ctx, W, H, 'void', accent); + + // Subtle grid lines overlay for GitHub-like feel + ctx.strokeStyle = 'rgba(255,255,255,0.03)'; + ctx.lineWidth = 1; + const gridStep = Math.round(48 * s); + for (let x = 0; x < W; x += gridStep) { + ctx.beginPath(); + ctx.moveTo(x, 0); + ctx.lineTo(x, H); + ctx.stroke(); + } + for (let y = 0; y < H; y += gridStep) { + ctx.beginPath(); + ctx.moveTo(0, y); + ctx.lineTo(W, y); + ctx.stroke(); + } + + // Layout constants + const px = Math.round(80 * s); + const cW = W - px * 2; + const topY = Math.round(72 * s); + + // --- Avatar (circular) + owner/repo line --- + const avatarRadius = Math.round(28 * s); + const avatarDiameter = avatarRadius * 2; + let avatarEndX = px; + + if (avatar) { + const cx = px + avatarRadius; + const cy = topY + avatarRadius; + ctx.save(); + ctx.beginPath(); + ctx.arc(cx, cy, avatarRadius, 0, Math.PI * 2); + ctx.closePath(); + ctx.clip(); + ctx.drawImage(avatar, cx - avatarRadius, cy - avatarRadius, avatarDiameter, avatarDiameter); + ctx.restore(); + avatarEndX = cx + avatarRadius + Math.round(16 * s); + } + + // Owner label (muted) next to avatar + if (owner) { + const ownerFont = `500 ${Math.round(18 * s)}px ${ff}`; + ctx.font = ownerFont; + ctx.textBaseline = 'middle'; + ctx.fillStyle = 'rgba(139,148,158,0.9)'; + ctx.textAlign = 'left'; + ctx.fillText(owner, avatarEndX, topY + avatarRadius); + } + + // --- Repo name (large, bold, monospace-feel via heavy weight) --- + const titleTopY = topY + avatarDiameter + Math.round(32 * s); + const { lines: tLines, fontSize: eff } = fitTitleLines( + title || '', + ff, + style.titleSize, + 900, + cW, + fmt.maxTitleLines, + s, + ); + const tFont = `900 ${Math.round(eff * s)}px ${ff}`; + const tLH = Math.round(eff * 1.1 * s); + const visibleT = tLines.slice(0, fmt.maxTitleLines); + + ctx.textAlign = 'left'; + ctx.textBaseline = 'top'; + ctx.fillStyle = '#e6edf3'; + ctx.font = tFont; + let yPos = titleTopY; + for (let i = 0; i < visibleT.length; i++) { + let t = visibleT[i].text; + if (i === visibleT.length - 1 && tLines.length > fmt.maxTitleLines) t += '\u2026'; + ctx.fillText(t, px, yPos); + yPos += tLH; + } + + // --- Description --- + const descGap = Math.round(20 * s); + const dFont = `400 ${Math.round(style.descSize * s)}px ${ff}`; + const dLH = Math.round(style.descSize * 1.55 * s); + const dLines = measureLines(description || '', dFont, cW); + const visibleD = dLines.slice(0, fmt.maxDescLines); + + yPos += descGap; + ctx.font = dFont; + ctx.fillStyle = 'rgba(139,148,158,0.88)'; + for (let i = 0; i < visibleD.length; i++) { + let t = visibleD[i].text; + if (i === visibleD.length - 1 && dLines.length > fmt.maxDescLines) t += '\u2026'; + ctx.fillText(t, px, yPos); + yPos += dLH; + } + + // --- Bottom bar: language dot + name, star icon + count --- + const bottomY = H - Math.round(72 * s); + const barFont = `500 ${Math.round(17 * s)}px ${ff}`; + ctx.font = barFont; + ctx.textBaseline = 'middle'; + ctx.textAlign = 'left'; + + // Accent-colored separator line above bottom bar + ctx.strokeStyle = rgba(accent, 0.18); + ctx.lineWidth = Math.round(1 * s); + ctx.beginPath(); + ctx.moveTo(px, bottomY - Math.round(20 * s)); + ctx.lineTo(W - px, bottomY - Math.round(20 * s)); + ctx.stroke(); + + let barX = px; + + // Language dot + label + if (language) { + const dotRadius = Math.round(7 * s); + ctx.beginPath(); + ctx.arc(barX + dotRadius, bottomY, dotRadius, 0, Math.PI * 2); + ctx.fillStyle = accent; + ctx.fill(); + barX += dotRadius * 2 + Math.round(8 * s); + + ctx.fillStyle = 'rgba(230,237,243,0.9)'; + ctx.fillText(language, barX, bottomY); + barX += measureTextWidth(language, barFont) + Math.round(36 * s); + } + + // Star icon (unicode ★) + count + if (stars) { + const starFont = `500 ${Math.round(17 * s)}px ${ff}`; + ctx.font = starFont; + ctx.fillStyle = rgba(accent, 0.85); + const starChar = '\u2605'; // ★ + ctx.fillText(starChar, barX, bottomY); + barX += measureTextWidth(starChar, starFont) + Math.round(6 * s); + + ctx.fillStyle = 'rgba(230,237,243,0.9)'; + ctx.fillText(stars, barX, bottomY); + } + + const overflow = tLines.length > fmt.maxTitleLines || dLines.length > fmt.maxDescLines; + return { + titleTotalLines: tLines.length, + titleVisibleLines: visibleT.length, + descTotalLines: dLines.length, + descVisibleLines: visibleD.length, + overflow, + }; +}; diff --git a/src/engine/templates/helpers.ts b/src/engine/templates/helpers.ts new file mode 100644 index 0000000..328c881 --- /dev/null +++ b/src/engine/templates/helpers.ts @@ -0,0 +1,83 @@ +import type { Image, SKRSContext2D } from '@napi-rs/canvas'; +import { getGradientBySlug } from '../gradients'; +import { measureLines } from '../text-measure'; + +export function hexToRgb(hex: string): [number, number, number] { + const h = hex.replace('#', ''); + const n = + h.length === 3 + ? h + .split('') + .map((c) => c + c) + .join('') + : h; + const i = Number.parseInt(n, 16); + return [(i >> 16) & 255, (i >> 8) & 255, i & 255]; +} + +export function rgba(hex: string, a: number): string { + const [r, g, b] = hexToRgb(hex); + return `rgba(${r},${g},${b},${a})`; +} + +export function paintBackgroundMesh(ctx: SKRSContext2D, W: number, H: number, gradientSlug: string, accent: string) { + const grad = getGradientBySlug(gradientSlug); + const base = ctx.createLinearGradient(0, 0, 0, H); + base.addColorStop(0, grad.stops[0]); + base.addColorStop(1, grad.stops[1]); + ctx.fillStyle = base; + ctx.fillRect(0, 0, W, H); + + const g1 = ctx.createRadialGradient(W * 0.2, H * 0.18, 0, W * 0.2, H * 0.18, Math.max(W, H) * 0.6); + g1.addColorStop(0, rgba(accent, 0.22)); + g1.addColorStop(0.5, rgba(accent, 0.06)); + g1.addColorStop(1, 'rgba(0,0,0,0)'); + ctx.fillStyle = g1; + ctx.fillRect(0, 0, W, H); + + const g2 = ctx.createRadialGradient(W * 0.88, H * 0.95, 0, W * 0.88, H * 0.95, W * 0.55); + g2.addColorStop(0, rgba(accent, 0.1)); + g2.addColorStop(1, 'rgba(0,0,0,0)'); + ctx.fillStyle = g2; + ctx.fillRect(0, 0, W, H); + + const sheen = ctx.createLinearGradient(0, 0, W, H); + sheen.addColorStop(0, 'rgba(255,255,255,0.02)'); + sheen.addColorStop(1, 'rgba(0,0,0,0)'); + ctx.fillStyle = sheen; + ctx.fillRect(0, 0, W, H); +} + +export function drawBgImage(ctx: SKRSContext2D, img: Image, W: number, H: number, opacity: number) { + const iw = img.width; + const ih = img.height; + const scale = Math.max(W / iw, H / ih); + const dw = iw * scale; + const dh = ih * scale; + const dx = (W - dw) / 2; + const dy = (H - dh) / 2; + ctx.drawImage(img, dx, dy, dw, dh); + ctx.fillStyle = `rgba(6,8,12,${opacity})`; + ctx.fillRect(0, 0, W, H); +} + +export function fitTitleLines( + text: string, + family: string, + baseSize: number, + weight: number, + cW: number, + maxLines: number, + scale: number, +): { lines: ReturnType<typeof measureLines>; fontSize: number } { + let size = baseSize; + const min = Math.max(20, Math.round(baseSize * 0.7)); + while (size > min) { + const font = `${weight} ${Math.round(size * scale)}px ${family}`; + const lines = measureLines(text, font, cW); + if (lines.length <= maxLines) return { lines, fontSize: size }; + size -= 2; + } + const font = `${weight} ${Math.round(size * scale)}px ${family}`; + return { lines: measureLines(text, font, cW), fontSize: size }; +} diff --git a/src/engine/templates/index.ts b/src/engine/templates/index.ts new file mode 100644 index 0000000..236a9b9 --- /dev/null +++ b/src/engine/templates/index.ts @@ -0,0 +1,36 @@ +import { announcementTemplate } from './announcement'; +import { blogHeroTemplate } from './blog-hero'; +import { defaultTemplate } from './default'; +import { emailBannerTemplate } from './email-banner'; +import { eventTemplate } from './event'; +import { githubRepoTemplate } from './github-repo'; +import { newsArticleTemplate } from './news-article'; +import { pricingTemplate } from './pricing'; +import { productCardTemplate } from './product-card'; +import { profileCardTemplate } from './profile-card'; +import { socialCardTemplate } from './social-card'; +import { testimonialTemplate } from './testimonial'; +import type { TemplateFn } from './types'; + +export type { TemplateFn, TemplateInput, TemplateResult } from './types'; + +export const TEMPLATES: Record<string, TemplateFn> = { + default: defaultTemplate, + 'social-card': socialCardTemplate, + 'blog-hero': blogHeroTemplate, + 'email-banner': emailBannerTemplate, + event: eventTemplate, + 'github-repo': githubRepoTemplate, + 'product-card': productCardTemplate, + testimonial: testimonialTemplate, + 'news-article': newsArticleTemplate, + pricing: pricingTemplate, + 'profile-card': profileCardTemplate, + announcement: announcementTemplate, +}; + +export const TEMPLATE_NAMES = Object.keys(TEMPLATES); + +export function getTemplate(name: string): TemplateFn { + return TEMPLATES[name] ?? TEMPLATES.default; +} diff --git a/src/engine/templates/news-article.ts b/src/engine/templates/news-article.ts new file mode 100644 index 0000000..f3d9b32 --- /dev/null +++ b/src/engine/templates/news-article.ts @@ -0,0 +1,152 @@ +import { measureTextWidth } from '../text-measure'; +import { drawBgImage, fitTitleLines, paintBackgroundMesh } from './helpers'; +import type { TemplateFn } from './types'; + +export const newsArticleTemplate: TemplateFn = (input) => { + const { + ctx, + width: W, + height: H, + format: fmt, + content, + style, + bgImage, + overlayOpacity, + variables, + namedImages, + } = input; + const { title, tag } = content; + const { accent, fontFamily: ff, titleSize, descSize, gradient: gradientSlug } = style; + const s = Math.max(W, H) / 1200; + + const source = variables.source ?? ''; + const date = variables.date ?? ''; + const category = variables.category ?? tag ?? ''; + const logo = namedImages.logo ?? null; + + // --- Background --- + if (bgImage) { + drawBgImage(ctx, bgImage, W, H, overlayOpacity); + } else { + paintBackgroundMesh(ctx, W, H, gradientSlug, accent); + } + + // Strong bottom gradient for text legibility + const bottomGrad = ctx.createLinearGradient(0, H * 0.25, 0, H); + bottomGrad.addColorStop(0, 'rgba(0,0,0,0)'); + bottomGrad.addColorStop(0.4, `rgba(0,0,0,${overlayOpacity * 0.7})`); + bottomGrad.addColorStop(1, `rgba(0,0,0,${Math.min(0.96, overlayOpacity + 0.28)})`); + ctx.fillStyle = bottomGrad; + ctx.fillRect(0, 0, W, H); + + const px = Math.round(72 * s); + ctx.textAlign = 'left'; + ctx.textBaseline = 'top'; + + // --- Logo (top-left) --- + const logoSize = Math.round(48 * s); + const logoPad = Math.round(44 * s); + if (logo) { + const lw = logo.width; + const lh = logo.height; + const scale = Math.min(logoSize / lw, logoSize / lh); + const dw = lw * scale; + const dh = lh * scale; + ctx.drawImage(logo, logoPad, logoPad, dw, dh); + } + + // --- Category pill (top area, accent) --- + if (category) { + const catFont = `700 ${Math.round(13 * s)}px ${ff}`; + ctx.font = catFont; + const catText = category.toUpperCase(); + const catW = measureTextWidth(catText, catFont); + const pillPadX = Math.round(16 * s); + const pillPadY = Math.round(7 * s); + const pillH = Math.round(13 * s) + pillPadY * 2; + const pillW = catW + pillPadX * 2; + const pillX = logo ? logoPad + logoSize + Math.round(20 * s) : logoPad; + const pillY = logoPad + (logoSize - pillH) / 2; + + // Solid accent pill + ctx.fillStyle = accent; + ctx.beginPath(); + ctx.roundRect(pillX, pillY, pillW, pillH, pillH / 2); + ctx.fill(); + + ctx.fillStyle = 'rgba(6,8,12,0.92)'; + ctx.fillText(catText, pillX + pillPadX, pillY + pillPadY); + } + + // --- Bottom layout: title + source/date bar --- + const metaFont = `500 ${Math.round(descSize * s)}px ${ff}`; + const metaH = Math.round(descSize * 1.4 * s); + const hasMeta = source || date; + + const { lines: tLines, fontSize: eff } = fitTitleLines( + title || '', + ff, + titleSize, + 800, + W - px * 2, + fmt.maxTitleLines, + s, + ); + const tFont = `800 ${Math.round(eff * s)}px ${ff}`; + const tLH = Math.round(eff * 1.1 * s); + const visibleT = tLines.slice(0, fmt.maxTitleLines); + + const metaGap = hasMeta ? Math.round(20 * s) : 0; + const totalH = visibleT.length * tLH + metaGap + (hasMeta ? metaH : 0); + const bottomPad = Math.round(56 * s); + + let yPos = H - bottomPad - totalH; + + // --- Title --- + ctx.fillStyle = '#ffffff'; + ctx.font = tFont; + for (let i = 0; i < visibleT.length; i++) { + let t = visibleT[i].text; + if (i === visibleT.length - 1 && tLines.length > fmt.maxTitleLines) t += '\u2026'; + ctx.fillText(t, px, yPos); + yPos += tLH; + } + + yPos += metaGap; + + // --- Source + date bar --- + if (hasMeta) { + ctx.font = metaFont; + let metaX = px; + + if (source) { + ctx.fillStyle = accent; + ctx.fillText(source, metaX, yPos); + metaX += measureTextWidth(source, metaFont) + Math.round(24 * s); + } + + if (source && date) { + // Separator dot + const sepFont = `400 ${Math.round(descSize * s)}px ${ff}`; + ctx.font = sepFont; + ctx.fillStyle = 'rgba(255,255,255,0.4)'; + ctx.fillText('\u00B7', metaX, yPos); + metaX += measureTextWidth('\u00B7 ', sepFont); + ctx.font = metaFont; + } + + if (date) { + ctx.fillStyle = 'rgba(255,255,255,0.7)'; + ctx.fillText(date, metaX, yPos); + } + } + + const overflow = tLines.length > fmt.maxTitleLines; + return { + titleTotalLines: tLines.length, + titleVisibleLines: visibleT.length, + descTotalLines: 0, + descVisibleLines: 0, + overflow, + }; +}; diff --git a/src/engine/templates/pricing.ts b/src/engine/templates/pricing.ts new file mode 100644 index 0000000..6d2f0e5 --- /dev/null +++ b/src/engine/templates/pricing.ts @@ -0,0 +1,175 @@ +import { measureLines, measureTextWidth } from '../text-measure'; +import { drawBgImage, paintBackgroundMesh, rgba } from './helpers'; +import type { TemplateFn } from './types'; + +export const pricingTemplate: TemplateFn = (input) => { + const { ctx, width: W, height: H, content, style, bgImage, overlayOpacity, variables, namedImages } = input; + const { accent, fontFamily: ff, gradient: gradientSlug } = style; + const s = Math.max(W, H) / 1200; + + const plan = variables.plan || content.title || ''; + const price = variables.price ?? ''; + const period = variables.period ?? ''; + const featuresRaw = variables.features ?? ''; + const cta = variables.cta ?? ''; + const logo = namedImages.logo ?? null; + + const features = featuresRaw + .split(',') + .map((f) => f.trim()) + .filter(Boolean); + + // --- Background --- + if (bgImage) { + drawBgImage(ctx, bgImage, W, H, overlayOpacity); + } else { + paintBackgroundMesh(ctx, W, H, gradientSlug, accent); + } + + // Centered card panel + const cardW = Math.round(W * 0.52); + const cardH = Math.round(H * 0.82); + const cardX = (W - cardW) / 2; + const cardY = (H - cardH) / 2; + const cardRadius = Math.round(20 * s); + + ctx.save(); + ctx.beginPath(); + ctx.roundRect(cardX, cardY, cardW, cardH, cardRadius); + ctx.fillStyle = 'rgba(15,18,28,0.78)'; + ctx.fill(); + + // Card border in accent + ctx.strokeStyle = rgba(accent, 0.35); + ctx.lineWidth = Math.round(1.5 * s); + ctx.stroke(); + ctx.restore(); + + const innerPad = Math.round(52 * s); + const contentX = cardX + innerPad; + const contentW = cardW - innerPad * 2; + + ctx.textAlign = 'center'; + ctx.textBaseline = 'top'; + + let yPos = cardY + innerPad; + + // --- Logo --- + if (logo) { + const maxLogoH = Math.round(38 * s); + const logoScale = maxLogoH / logo.height; + const logoW = logo.width * logoScale; + ctx.drawImage(logo, W / 2 - logoW / 2, yPos, logoW, maxLogoH); + yPos += maxLogoH + Math.round(20 * s); + } + + // --- Plan name --- + if (plan) { + const planFont = `700 ${Math.round(16 * s)}px ${ff}`; + ctx.font = planFont; + ctx.fillStyle = accent; + ctx.fillText(plan.toUpperCase(), W / 2, yPos); + yPos += Math.round(16 * 1.4 * s) + Math.round(10 * s); + } + + // --- Price --- + if (price) { + const priceSize = Math.round(80 * s); + const periodSize = Math.round(26 * s); + const priceFont = `800 ${priceSize}px ${ff}`; + const periodFont = `400 ${periodSize}px ${ff}`; + + ctx.font = priceFont; + const priceW = measureTextWidth(price, priceFont); + ctx.font = periodFont; + const periodW = period ? measureTextWidth(period, periodFont) : 0; + + const totalPriceW = priceW + periodW; + const priceX = W / 2 - totalPriceW / 2; + + // Price value + ctx.font = priceFont; + ctx.fillStyle = '#ffffff'; + ctx.textAlign = 'left'; + ctx.fillText(price, priceX, yPos); + + // Period + if (period) { + ctx.font = periodFont; + ctx.fillStyle = 'rgba(255,255,255,0.55)'; + ctx.textBaseline = 'bottom'; + ctx.fillText(period, priceX + priceW + Math.round(4 * s), yPos + priceSize); + ctx.textBaseline = 'top'; + } + + ctx.textAlign = 'center'; + yPos += priceSize + Math.round(24 * s); + } + + // --- Divider --- + const divY = yPos; + ctx.strokeStyle = rgba(accent, 0.25); + ctx.lineWidth = Math.round(1 * s); + ctx.beginPath(); + ctx.moveTo(contentX, divY); + ctx.lineTo(contentX + contentW, divY); + ctx.stroke(); + yPos += Math.round(20 * s); + + // --- Feature list --- + const featureFont = `400 ${Math.round(16 * s)}px ${ff}`; + const featureLH = Math.round(16 * 1.7 * s); + ctx.font = featureFont; + ctx.textAlign = 'left'; + + const checkW = measureTextWidth('\u2713 ', featureFont); + for (const feature of features) { + const featureLines = measureLines(feature, featureFont, contentW - checkW); + for (let li = 0; li < featureLines.length; li++) { + if (li === 0) { + // Checkmark in accent + ctx.fillStyle = accent; + ctx.fillText('\u2713', contentX, yPos); + ctx.fillStyle = 'rgba(226,232,240,0.9)'; + ctx.fillText(featureLines[0].text, contentX + checkW, yPos); + } else { + ctx.fillStyle = 'rgba(226,232,240,0.9)'; + ctx.fillText(featureLines[li].text, contentX + checkW, yPos); + } + yPos += featureLH; + } + } + + // --- CTA button --- + if (cta) { + const ctaFont = `700 ${Math.round(17 * s)}px ${ff}`; + ctx.font = ctaFont; + const ctaTextW = measureTextWidth(cta, ctaFont); + const ctaPadX = Math.round(28 * s); + const ctaPadY = Math.round(13 * s); + const ctaH = Math.round(17 * s) + ctaPadY * 2; + const ctaW = ctaTextW + ctaPadX * 2; + const ctaX = W / 2 - ctaW / 2; + // Position near card bottom + const ctaBotY = cardY + cardH - innerPad - ctaH; + + ctx.fillStyle = accent; + ctx.beginPath(); + ctx.roundRect(ctaX, ctaBotY, ctaW, ctaH, ctaH / 2); + ctx.fill(); + + ctx.fillStyle = 'rgba(6,8,12,0.92)'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(cta, W / 2, ctaBotY + ctaH / 2); + } + + const hasContent = !!(plan || price); + return { + titleTotalLines: hasContent ? 1 : 0, + titleVisibleLines: hasContent ? 1 : 0, + descTotalLines: 0, + descVisibleLines: 0, + overflow: false, + }; +}; diff --git a/src/engine/templates/product-card.ts b/src/engine/templates/product-card.ts new file mode 100644 index 0000000..e3a3367 --- /dev/null +++ b/src/engine/templates/product-card.ts @@ -0,0 +1,175 @@ +import { measureTextWidth } from '../text-measure'; +import { drawBgImage, fitTitleLines, paintBackgroundMesh, rgba } from './helpers'; +import type { TemplateFn } from './types'; + +export const productCardTemplate: TemplateFn = (input) => { + const { + ctx, + width: W, + height: H, + format: fmt, + content, + style, + bgImage, + overlayOpacity, + variables, + namedImages, + } = input; + const { title } = content; + const { accent, fontFamily: ff, titleSize, gradient: gradientSlug } = style; + const s = Math.max(W, H) / 1200; + + const price = variables?.price ?? ''; + const badge = variables?.badge ?? ''; + const brand = variables?.brand ?? ''; + const productImage = namedImages?.product ?? null; + const logoImage = namedImages?.logo ?? null; + + // ── Layout dimensions ──────────────────────────────────────────────────── + const hasProduct = productImage !== null; + const px = Math.round(72 * s); + + // Split: left 60%, right 40% + const rightColW = hasProduct ? Math.round(W * 0.4) : 0; + const leftColW = W - rightColW; + const contentW = leftColW - px * 2; + + // ── Background ──────────────────────────────────────────────────────────── + if (!hasProduct && bgImage) { + drawBgImage(ctx, bgImage, W, H, overlayOpacity); + ctx.fillStyle = rgba(accent, 0.05); + ctx.fillRect(0, 0, W, H); + } else { + paintBackgroundMesh(ctx, W, H, gradientSlug, accent); + } + + // ── Product image (right side) ──────────────────────────────────────────── + if (hasProduct && productImage) { + const imgX = leftColW; + const imgW = rightColW; + const imgH = H; + + // Cover-fit + const iw = productImage.width; + const ih = productImage.height; + const scale = Math.max(imgW / iw, imgH / ih); + const dw = iw * scale; + const dh = ih * scale; + const dx = imgX + (imgW - dw) / 2; + const dy = (imgH - dh) / 2; + + ctx.save(); + ctx.beginPath(); + ctx.rect(imgX, 0, imgW, imgH); + ctx.clip(); + ctx.drawImage(productImage, dx, dy, dw, dh); + ctx.restore(); + + // Subtle left-edge vignette blending into bg + const fade = ctx.createLinearGradient(imgX, 0, imgX + 80 * s, 0); + // Get approximate bg color from gradient — use a dark overlay + fade.addColorStop(0, 'rgba(10,12,18,1)'); + fade.addColorStop(1, 'rgba(10,12,18,0)'); + ctx.fillStyle = fade; + ctx.fillRect(imgX, 0, 80 * s, H); + } + + // ── Top-left: logo or brand name ────────────────────────────────────────── + const topY = Math.round(52 * s); + ctx.textAlign = 'left'; + ctx.textBaseline = 'top'; + + if (logoImage) { + // Draw small logo; max height 36px scaled + const maxLogoH = Math.round(36 * s); + const logoScale = maxLogoH / logoImage.height; + const logoW = logoImage.width * logoScale; + ctx.drawImage(logoImage, px, topY, logoW, maxLogoH); + } else if (brand) { + const brandFont = `500 ${Math.round(14 * s)}px ${ff}`; + ctx.font = brandFont; + ctx.fillStyle = 'rgba(148,163,184,0.8)'; + ctx.fillText(brand.toUpperCase(), px, topY + 2 * s); + } + + // ── Badge pill ──────────────────────────────────────────────────────────── + const badgeY = hasProduct ? Math.round(H * 0.28) : Math.round(H * 0.22); + let afterBadgeY = badgeY; + + if (badge) { + const badgeFont = `700 ${Math.round(13 * s)}px ${ff}`; + ctx.font = badgeFont; + const badgeText = badge; + const badgeTW = measureTextWidth(badgeText, badgeFont); + const badgePadX = 14 * s; + const badgePadY = 6 * s; + const badgeW = badgeTW + badgePadX * 2; + const badgeH = Math.round(13 * s) + badgePadY * 2; + const badgeRadius = badgeH / 2; + + // Pill background + ctx.fillStyle = accent; + ctx.beginPath(); + ctx.roundRect(px, badgeY, badgeW, badgeH, badgeRadius); + ctx.fill(); + + // Badge text — dark for readability on accent + ctx.fillStyle = 'rgba(10,12,18,0.92)'; + ctx.textBaseline = 'middle'; + ctx.fillText(badgeText, px + badgePadX, badgeY + badgeH / 2); + ctx.textBaseline = 'top'; + + afterBadgeY = badgeY + badgeH + 20 * s; + } + + // ── Title (product name) ────────────────────────────────────────────────── + const { lines: tLines, fontSize: eff } = fitTitleLines( + title || '', + ff, + titleSize, + 800, + contentW, + fmt.maxTitleLines, + s, + ); + const tFont = `800 ${Math.round(eff * s)}px ${ff}`; + const tLH = Math.round(eff * 1.1 * s); + const visibleT = tLines.slice(0, fmt.maxTitleLines); + + let yPos = afterBadgeY; + ctx.fillStyle = '#f8fafc'; + ctx.font = tFont; + ctx.textAlign = 'left'; + ctx.textBaseline = 'top'; + + for (let i = 0; i < visibleT.length; i++) { + let t = visibleT[i].text; + if (i === visibleT.length - 1 && tLines.length > fmt.maxTitleLines) t += '\u2026'; + ctx.fillText(t, px, yPos); + yPos += tLH; + } + + // ── Price ───────────────────────────────────────────────────────────────── + if (price) { + yPos += 18 * s; + const priceSize = Math.round(titleSize * 1.35 * s); + const priceFont = `800 ${priceSize}px ${ff}`; + ctx.font = priceFont; + ctx.fillStyle = accent; + ctx.fillText(price, px, yPos); + } + + // ── Bottom accent bar ───────────────────────────────────────────────────── + const barH = Math.round(4 * s); + ctx.fillStyle = accent; + ctx.fillRect(px, H - Math.round(48 * s), Math.round(48 * s), barH); + + const overflow = tLines.length > fmt.maxTitleLines; + return { + titleTotalLines: tLines.length, + titleVisibleLines: visibleT.length, + descTotalLines: 0, + descVisibleLines: 0, + overflow, + }; +}; diff --git a/src/engine/templates/profile-card.ts b/src/engine/templates/profile-card.ts new file mode 100644 index 0000000..bad8723 --- /dev/null +++ b/src/engine/templates/profile-card.ts @@ -0,0 +1,165 @@ +import { measureLines } from '../text-measure'; +import { drawBgImage, paintBackgroundMesh, rgba } from './helpers'; +import type { TemplateFn } from './types'; + +export const profileCardTemplate: TemplateFn = (input) => { + const { + ctx, + width: W, + height: H, + format: fmt, + content, + style, + bgImage, + overlayOpacity, + variables, + namedImages, + } = input; + const { accent, fontFamily: ff, titleSize, descSize, gradient: gradientSlug } = style; + const s = Math.max(W, H) / 1200; + + // Resolve fields from variables or content + const name = variables.name || content.title || ''; + const role = variables.role || ''; + const company = variables.company || content.author || ''; + const bio = variables.bio || content.description || ''; + + const avatar = namedImages.avatar ?? null; + const logo = namedImages.logo ?? null; + + // --- Background --- + if (bgImage) { + drawBgImage(ctx, bgImage, W, H, overlayOpacity); + } else { + paintBackgroundMesh(ctx, W, H, gradientSlug, accent); + } + + // Subtle center glow + const centerGlow = ctx.createRadialGradient(W / 2, H * 0.38, 0, W / 2, H * 0.38, W * 0.48); + centerGlow.addColorStop(0, rgba(accent, 0.1)); + centerGlow.addColorStop(1, 'rgba(0,0,0,0)'); + ctx.fillStyle = centerGlow; + ctx.fillRect(0, 0, W, H); + + ctx.textAlign = 'center'; + ctx.textBaseline = 'top'; + + // --- Avatar (large centered circle) --- + const avatarRadius = Math.round(90 * s); + const avatarDiameter = avatarRadius * 2; + const avatarCX = W / 2; + const avatarTopY = Math.round(62 * s); + const avatarCY = avatarTopY + avatarRadius; + + if (avatar) { + // Clip to circle + ctx.save(); + ctx.beginPath(); + ctx.arc(avatarCX, avatarCY, avatarRadius, 0, Math.PI * 2); + ctx.closePath(); + ctx.clip(); + ctx.drawImage(avatar, avatarCX - avatarRadius, avatarCY - avatarRadius, avatarDiameter, avatarDiameter); + ctx.restore(); + + // Accent ring + ctx.save(); + ctx.beginPath(); + ctx.arc(avatarCX, avatarCY, avatarRadius + Math.round(3 * s), 0, Math.PI * 2); + ctx.strokeStyle = accent; + ctx.lineWidth = Math.round(3 * s); + ctx.stroke(); + ctx.restore(); + } else { + // Placeholder circle + ctx.save(); + ctx.beginPath(); + ctx.arc(avatarCX, avatarCY, avatarRadius, 0, Math.PI * 2); + ctx.fillStyle = rgba(accent, 0.15); + ctx.fill(); + ctx.strokeStyle = rgba(accent, 0.5); + ctx.lineWidth = Math.round(3 * s); + ctx.stroke(); + ctx.restore(); + + // Initials + const initials = name + .split(' ') + .slice(0, 2) + .map((w: string) => w[0] ?? '') + .join('') + .toUpperCase(); + if (initials) { + const initialsSize = Math.round(60 * s); + ctx.font = `700 ${initialsSize}px ${ff}`; + ctx.fillStyle = accent; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(initials, avatarCX, avatarCY); + } + } + + let yPos = avatarTopY + avatarDiameter + Math.round(28 * s); + + // --- Name --- + if (name) { + const nameSize = Math.round(titleSize * s); + const nameFont = `800 ${nameSize}px ${ff}`; + ctx.font = nameFont; + ctx.fillStyle = '#f8fafc'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'top'; + ctx.fillText(name, W / 2, yPos); + yPos += Math.round(nameSize * 1.2) + Math.round(8 * s); + } + + // --- Role + Company --- + const roleCompanyParts = [role, company].filter(Boolean); + if (roleCompanyParts.length > 0) { + const roleFont = `500 ${Math.round(descSize * s)}px ${ff}`; + ctx.font = roleFont; + ctx.fillStyle = 'rgba(203,213,225,0.85)'; + ctx.fillText(roleCompanyParts.join(' · '), W / 2, yPos); + yPos += Math.round(descSize * 1.4 * s) + Math.round(20 * s); + } + + // --- Bio text --- + if (bio) { + const bioSize = Math.round((descSize - 2) * s); + const bioFont = `400 ${bioSize}px ${ff}`; + const bioLH = Math.round(bioSize * 1.55); + const bioW = W * 0.58; + const bioLines = measureLines(bio, bioFont, bioW); + const maxBioLines = fmt.maxDescLines; + const visibleBioLines = bioLines.slice(0, maxBioLines); + + ctx.font = bioFont; + ctx.fillStyle = 'rgba(148,163,184,0.9)'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'top'; + + for (let i = 0; i < visibleBioLines.length; i++) { + let t = visibleBioLines[i].text; + if (i === visibleBioLines.length - 1 && bioLines.length > maxBioLines) t += '\u2026'; + ctx.fillText(t, W / 2, yPos); + yPos += bioLH; + } + } + + // --- Logo (bottom-right corner) --- + if (logo) { + const maxLogoH = Math.round(36 * s); + const logoScale = maxLogoH / logo.height; + const logoW = logo.width * logoScale; + const logoPad = Math.round(36 * s); + ctx.drawImage(logo, W - logoPad - logoW, H - logoPad - maxLogoH, logoW, maxLogoH); + } + + const overflow = false; + return { + titleTotalLines: name ? 1 : 0, + titleVisibleLines: name ? 1 : 0, + descTotalLines: 0, + descVisibleLines: 0, + overflow, + }; +}; diff --git a/src/engine/templates/social-card.ts b/src/engine/templates/social-card.ts new file mode 100644 index 0000000..c9087e3 --- /dev/null +++ b/src/engine/templates/social-card.ts @@ -0,0 +1,67 @@ +import { drawBgImage, fitTitleLines, paintBackgroundMesh, rgba } from './helpers'; +import type { TemplateFn } from './types'; + +export const socialCardTemplate: TemplateFn = (input) => { + const { ctx, width: W, height: H, format: fmt, content, style, bgImage, overlayOpacity } = input; + const { title, author, tag } = content; + const { accent, fontFamily: ff, titleSize, gradient: gradientSlug } = style; + const s = Math.max(W, H) / 1200; + + if (bgImage) { + drawBgImage(ctx, bgImage, W, H, overlayOpacity); + } else { + paintBackgroundMesh(ctx, W, H, gradientSlug, accent); + } + + const gcenter = ctx.createRadialGradient(W / 2, H / 2, 0, W / 2, H / 2, W * 0.55); + gcenter.addColorStop(0, rgba(accent, 0.14)); + gcenter.addColorStop(1, 'rgba(0,0,0,0)'); + ctx.fillStyle = gcenter; + ctx.fillRect(0, 0, W, H); + + const px = Math.round(110 * s); + const cW = W - px * 2; + + const bigSize = Math.round(titleSize * 1.3); + const { lines: tLines, fontSize: eff } = fitTitleLines(title || '', ff, bigSize, 800, cW, fmt.maxTitleLines, s); + const tFont = `800 ${Math.round(eff * s)}px ${ff}`; + const tLH = Math.round(eff * 1.06 * s); + const visibleT = tLines.slice(0, fmt.maxTitleLines); + const totalTextH = visibleT.length * tLH; + + if (tag) { + ctx.font = `500 ${Math.round(15 * s)}px ${ff}`; + ctx.fillStyle = accent; + ctx.textAlign = 'center'; + ctx.textBaseline = 'top'; + ctx.fillText(`\u25cf ${tag}`, W / 2, (H - totalTextH) / 2 - 52 * s); + } + + let yPos = (H - totalTextH) / 2; + ctx.textAlign = 'center'; + ctx.textBaseline = 'top'; + ctx.fillStyle = '#ffffff'; + ctx.font = tFont; + for (let i = 0; i < visibleT.length; i++) { + let t = visibleT[i].text; + if (i === visibleT.length - 1 && tLines.length > fmt.maxTitleLines) t += '\u2026'; + ctx.fillText(t, W / 2, yPos); + yPos += tLH; + } + + if (author) { + ctx.font = `500 ${Math.round(17 * s)}px ${ff}`; + ctx.fillStyle = 'rgba(255,255,255,0.68)'; + ctx.textAlign = 'center'; + ctx.fillText(`\u2014 ${author}`, W / 2, H - px * 0.6); + } + + const overflow = tLines.length > fmt.maxTitleLines; + return { + titleTotalLines: tLines.length, + titleVisibleLines: visibleT.length, + descTotalLines: 0, + descVisibleLines: 0, + overflow, + }; +}; diff --git a/src/engine/templates/testimonial.ts b/src/engine/templates/testimonial.ts new file mode 100644 index 0000000..ecd702c --- /dev/null +++ b/src/engine/templates/testimonial.ts @@ -0,0 +1,151 @@ +import { measureLines } from '../text-measure'; +import { paintBackgroundMesh, rgba } from './helpers'; +import type { TemplateFn } from './types'; + +export const testimonialTemplate: TemplateFn = (input) => { + const { ctx, width: W, height: H, format: fmt, content, style, bgImage, namedImages } = input; + const { accent, fontFamily: ff, titleSize, gradient: gradientSlug } = style; + const s = Math.max(W, H) / 1200; + + // Resolve quote and author fields from variables or content + const quoteText = input.variables.quote || content.title || ''; + const name = input.variables.name || content.author || ''; + const company = input.variables.company || ''; + const role = input.variables.role || ''; + + // Background — gradient mesh, no image typically needed + if (bgImage) { + // If somehow a bg image is provided, still use mesh for clean look + paintBackgroundMesh(ctx, W, H, gradientSlug, accent); + } else { + paintBackgroundMesh(ctx, W, H, gradientSlug, accent); + } + + // Subtle center glow for elegance + const centerGlow = ctx.createRadialGradient(W / 2, H * 0.42, 0, W / 2, H * 0.42, W * 0.5); + centerGlow.addColorStop(0, rgba(accent, 0.08)); + centerGlow.addColorStop(1, 'rgba(0,0,0,0)'); + ctx.fillStyle = centerGlow; + ctx.fillRect(0, 0, W, H); + + const px = Math.round(110 * s); + const cW = W - px * 2; + + // --- Decorative opening quotation mark --- + const quoteMarkSize = Math.round(180 * s); + ctx.font = `700 ${quoteMarkSize}px ${ff}`; + ctx.fillStyle = rgba(accent, 0.18); + ctx.textAlign = 'center'; + ctx.textBaseline = 'top'; + ctx.fillText('\u201C', W / 2, Math.round(30 * s)); + + // --- Quote text (centered, weight 500, slightly larger) --- + const quoteSize = Math.round(titleSize * s); + const quoteFont = `500 ${quoteSize}px ${ff}`; + const quoteLH = Math.round(quoteSize * 1.45); + const quoteLines = measureLines(quoteText, quoteFont, cW); + const maxQuoteLines = fmt.maxTitleLines; + const visibleQuoteLines = quoteLines.slice(0, maxQuoteLines); + + // --- Attribution row: avatar + name + role + company --- + const avatarRadius = Math.round(28 * s); + const avatarDiameter = avatarRadius * 2; + const attrFont = `600 ${Math.round(17 * s)}px ${ff}`; + const subFont = `400 ${Math.round(15 * s)}px ${ff}`; + const attrLH = Math.round(22 * s); + const subLH = Math.round(20 * s); + + // Measure attribution height + const hasAvatar = !!namedImages.avatar; + const hasRoleOrCompany = role || company; + const attrBlockH = attrLH + (hasRoleOrCompany ? subLH + Math.round(4 * s) : 0); + const attrBlockWithAvatarH = Math.max(avatarDiameter, attrBlockH); + const attrGap = Math.round(48 * s); + + // Total content height + const quoteBlockH = visibleQuoteLines.length * quoteLH; + const totalH = quoteBlockH + attrGap + attrBlockWithAvatarH; + + // Center vertically (shift slightly above center for breathing room under quote mark) + const topOffset = Math.round(quoteMarkSize * 0.55 + 20 * s); + const availableH = H - topOffset - Math.round(px * 0.8); + let yPos = topOffset + Math.max(0, (availableH - totalH) / 2); + + // --- Draw quote lines --- + ctx.textAlign = 'center'; + ctx.textBaseline = 'top'; + ctx.fillStyle = '#f8fafc'; + ctx.font = quoteFont; + for (let i = 0; i < visibleQuoteLines.length; i++) { + let t = visibleQuoteLines[i].text; + if (i === visibleQuoteLines.length - 1 && quoteLines.length > maxQuoteLines) t += '\u2026'; + ctx.fillText(t, W / 2, yPos); + yPos += quoteLH; + } + + yPos += attrGap; + + // --- Attribution row --- + const avatar = namedImages.avatar ?? null; + + // Measure text widths to calculate total row width for centering + ctx.font = attrFont; + const nameWidth = ctx.measureText(name).width; + ctx.font = subFont; + const subText = [role, company].filter(Boolean).join(' · '); + const subWidth = subText ? ctx.measureText(subText).width : 0; + const textBlockW = Math.max(nameWidth, subWidth); + const rowWidth = hasAvatar ? avatarDiameter + Math.round(14 * s) + textBlockW : textBlockW; + const rowStartX = W / 2 - rowWidth / 2; + + // Draw avatar circle + if (avatar) { + const cx = rowStartX + avatarRadius; + const cy = yPos + attrBlockWithAvatarH / 2; + ctx.save(); + ctx.beginPath(); + ctx.arc(cx, cy, avatarRadius, 0, Math.PI * 2); + ctx.closePath(); + ctx.clip(); + ctx.drawImage(avatar, cx - avatarRadius, cy - avatarRadius, avatarDiameter, avatarDiameter); + ctx.restore(); + + // Accent ring around avatar + ctx.save(); + ctx.beginPath(); + ctx.arc(cx, cy, avatarRadius + Math.round(2 * s), 0, Math.PI * 2); + ctx.strokeStyle = rgba(accent, 0.6); + ctx.lineWidth = Math.round(2 * s); + ctx.stroke(); + ctx.restore(); + } + + // Draw name and role/company text + const textX = hasAvatar ? rowStartX + avatarDiameter + Math.round(14 * s) : W / 2; + const textAlign: CanvasTextAlign = hasAvatar ? 'left' : 'center'; + const nameY = yPos + (attrBlockWithAvatarH - attrBlockH) / 2; + + ctx.textAlign = textAlign; + ctx.textBaseline = 'top'; + + if (name) { + ctx.font = attrFont; + ctx.fillStyle = '#ffffff'; + ctx.fillText(name, textX, nameY); + } + + if (subText) { + ctx.font = subFont; + ctx.fillStyle = rgba(accent, 0.85); + ctx.fillText(subText, textX, nameY + attrLH + Math.round(4 * s)); + } + + const overflow = quoteLines.length > maxQuoteLines; + return { + titleTotalLines: quoteLines.length, + titleVisibleLines: visibleQuoteLines.length, + descTotalLines: 0, + descVisibleLines: 0, + overflow, + }; +}; diff --git a/src/engine/templates/types.ts b/src/engine/templates/types.ts new file mode 100644 index 0000000..1932f8a --- /dev/null +++ b/src/engine/templates/types.ts @@ -0,0 +1,38 @@ +import type { Canvas, Image, SKRSContext2D } from '@napi-rs/canvas'; +import type { Format } from '../formats'; + +export interface TemplateInput { + canvas: Canvas; + ctx: SKRSContext2D; + width: number; + height: number; + format: Format; + content: { + title: string; + description: string; + author: string; + tag: string; + }; + style: { + accent: string; + layout: 'left' | 'center' | 'bottom'; + fontFamily: string; + titleSize: number; + descSize: number; + gradient: string; + }; + bgImage: Image | null; + overlayOpacity: number; + variables: Record<string, string>; + namedImages: Record<string, Image | null>; +} + +export interface TemplateResult { + titleTotalLines: number; + titleVisibleLines: number; + descTotalLines: number; + descVisibleLines: number; + overflow: boolean; +} + +export type TemplateFn = (input: TemplateInput) => TemplateResult; diff --git a/src/engine/text-measure.ts b/src/engine/text-measure.ts new file mode 100644 index 0000000..0e422d8 --- /dev/null +++ b/src/engine/text-measure.ts @@ -0,0 +1,85 @@ +import { createCanvas } from '@napi-rs/canvas'; +import { LRUCache } from './cache'; + +export interface MeasuredLine { + text: string; + width: number; +} + +const measureCanvas = createCanvas(1, 1); +const measureCtx = measureCanvas.getContext('2d'); + +const lineCache = new LRUCache<string, MeasuredLine[]>(2000); +let cacheHits = 0; +let cacheMisses = 0; + +export function measureLines(text: string, font: string, maxWidth: number): MeasuredLine[] { + if (!text || maxWidth <= 0) return []; + + const cacheKey = `${text}\0${font}\0${maxWidth}`; + const cached = lineCache.get(cacheKey); + if (cached) { + cacheHits++; + return cached; + } + cacheMisses++; + + measureCtx.font = font; + const lines: MeasuredLine[] = []; + + for (const para of text.split('\n')) { + if (!para.trim()) { + lines.push({ text: '', width: 0 }); + continue; + } + + let cur = ''; + let curW = 0; + + for (const word of para.split(/\s+/)) { + if (!word) continue; + const ww = measureCtx.measureText(word).width; + const sp = cur ? measureCtx.measureText(' ').width : 0; + + if (curW + sp + ww > maxWidth && cur) { + lines.push({ text: cur, width: curW }); + cur = word; + curW = ww; + } else { + cur += (cur ? ' ' : '') + word; + curW += sp + ww; + } + } + + if (cur) lines.push({ text: cur, width: curW }); + } + + lineCache.set(cacheKey, lines); + return lines; +} + +export function clearMeasureCache(): void { + lineCache.clear(); + cacheHits = 0; + cacheMisses = 0; +} + +export function getMeasureCacheSize(): number { + return lineCache.size; +} + +export function getMeasureCacheStats(): { hits: number; misses: number; size: number; hitRate: number } { + const total = cacheHits + cacheMisses; + return { + hits: cacheHits, + misses: cacheMisses, + size: lineCache.size, + hitRate: total === 0 ? 0 : Math.round((cacheHits / total) * 10000) / 100, + }; +} + +export function measureTextWidth(text: string, font: string): number { + if (!text) return 0; + measureCtx.font = font; + return measureCtx.measureText(text).width; +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..216fed0 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,165 @@ +import { join } from 'node:path'; +import { Hono } from 'hono'; +import { serveStatic } from 'hono/bun'; +import { cors } from 'hono/cors'; +import { adminRoute } from './api/admin'; +import { batchRoute } from './api/batch'; +import { billingRoute } from './api/billing'; +import { healthRoute } from './api/health'; +import { registerRoute } from './api/register'; +import { renderRoute } from './api/render'; +import { renderFromUrlRoute } from './api/render-from-url'; +import { templatesRoute } from './api/templates'; +import { triggersRoute } from './api/triggers'; +import { usageRoute } from './api/usage'; +import { validateRoute } from './api/validate'; +import { webhooksRoute } from './api/webhooks'; +import { registerFonts } from './engine/fonts'; +import { authMiddleware, optionalAuthMiddleware, planGate, usageTracking } from './middleware/auth'; +import { rateLimit } from './middleware/rate-limit'; + +const app = new Hono(); + +// CORS — allow playground and external clients +app.use( + '*', + cors({ + origin: '*', + allowHeaders: ['Content-Type', 'Authorization'], + exposeHeaders: [ + 'X-Render-Time-Ms', + 'X-Title-Lines', + 'X-Desc-Lines', + 'X-Layout-Overflow', + 'X-Batch-Count', + 'X-Cache', + 'X-Source-URL', + 'X-RateLimit-Limit', + 'X-RateLimit-Remaining', + 'X-RateLimit-Reset', + ], + }), +); + +// Rate limiting on render endpoints +app.use('/render', rateLimit()); +app.use('/render/batch', rateLimit()); +app.use('/render/from-url', rateLimit()); +app.use('/validate', rateLimit()); + +// Auth middleware — conditionally applied based on AUTH_ENABLED env var +// This allows running without a database in development +const authEnabled = process.env.AUTH_ENABLED !== 'false'; + +if (authEnabled) { + // Protected endpoints — require API key + track usage + app.use('/render', authMiddleware(), usageTracking('/render')); + app.use('/render/batch', authMiddleware(), planGate('batch'), usageTracking('/render/batch')); + app.use('/render/from-url', authMiddleware(), usageTracking('/render/from-url')); + + // Optional auth for /validate (per DECISIONS.md Decision 3) + app.use('/validate', optionalAuthMiddleware()); + + // Usage endpoint — requires auth + app.use('/usage', authMiddleware()); + + // Custom templates — auth is applied per-route in src/api/templates.ts + // so it doesn't shadow the /templates/gallery/ docs page. planGate stays + // path-wide: it no-ops when no apiKey is set in context, so docs requests + // fall through to the static handler. + app.use('/templates', planGate('custom_templates')); + app.use('/templates/*', planGate('custom_templates')); + + // Webhook triggers — requires auth + app.use('/triggers', authMiddleware()); + app.use('/triggers/*', authMiddleware()); + + // Billing portal — requires auth + app.use('/billing/*', authMiddleware()); +} + +// ─── Public routes ─────────────────────────────────────────── +app.route('/', healthRoute); +app.route('/', registerRoute); +app.route('/', webhooksRoute); +app.route('/', adminRoute); + +// ─── API routes ────────────────────────────────────────────── +app.route('/', validateRoute); +app.route('/', renderRoute); +app.route('/', renderFromUrlRoute); +app.route('/', batchRoute); +app.route('/', usageRoute); +app.route('/', templatesRoute); +app.route('/', triggersRoute); +app.route('/', billingRoute); + +// ─── Static docs site (Astro build output) ───────────────── +const DOCS_DIR = join(import.meta.dir, '..', 'docs-dist'); + +app.use( + '*', + serveStatic({ + root: './docs-dist', + onFound: (_path, c) => { + if (_path.includes('/_astro/')) { + c.header('Cache-Control', 'public, immutable, max-age=31536000'); + } else { + c.header('Cache-Control', 'public, max-age=3600'); + } + }, + }), +); + +// 404 fallback — serve docs 404 page for browsers, JSON for API clients +app.notFound(async (c) => { + const accepts = c.req.header('Accept') ?? ''; + if (accepts.includes('text/html')) { + const notFoundPage = Bun.file(join(DOCS_DIR, '404.html')); + if (await notFoundPage.exists()) { + return c.html(await notFoundPage.text(), 404); + } + } + return c.json( + { + error: 'not_found', + message: `No route matches ${c.req.method} ${c.req.path}`, + docs: 'https://og-engine.com/api-reference/overview', + }, + 404, + ); +}); + +// Global error handler +app.onError((err, c) => { + console.error('Unhandled error:', err); + return c.json( + { + error: 'server_error', + message: 'An unexpected error occurred.', + docs: 'https://og-engine.com/api-reference/errors#server_error', + }, + 500, + ); +}); + +// Start +const PORT = Number(process.env.PORT ?? 3000); +const FONTS_DIR = join(import.meta.dir, '..', 'fonts'); + +async function start() { + await registerFonts(FONTS_DIR); + console.log(`OG Engine listening on http://localhost:${PORT}`); + if (authEnabled) { + console.log('Auth: enabled (set AUTH_ENABLED=false to disable)'); + } else { + console.log('Auth: disabled (development mode)'); + } +} + +start(); + +export default { + port: PORT, + fetch: app.fetch, +}; diff --git a/src/middleware/auth.ts b/src/middleware/auth.ts new file mode 100644 index 0000000..4887e37 --- /dev/null +++ b/src/middleware/auth.ts @@ -0,0 +1,145 @@ +import type { Context, Next } from 'hono'; +import { type ApiKeyRecord, findApiKeyByKey, incrementUsage, logUsage, type Plan } from '../db'; + +// Plan feature access per DECISIONS.md +const FEATURE_GATES: Record<string, Plan[]> = { + webp: ['starter', 'pro', 'scale'], + batch: ['pro', 'scale'], + cdn: ['pro', 'scale'], + custom_templates: ['scale'], +}; + +export function canAccessFeature(plan: Plan, feature: string): boolean { + const allowed = FEATURE_GATES[feature]; + if (!allowed) return true; // no gate = everyone + return allowed.includes(plan); +} + +/** + * Required auth middleware — rejects unauthenticated requests. + * Checks quota and increments usage. + */ +export function authMiddleware() { + return async (c: Context, next: Next) => { + const auth = c.req.header('Authorization'); + if (!auth?.startsWith('Bearer ')) { + return c.json( + { + error: 'unauthorized', + message: 'Missing or malformed API key. Use: Authorization: Bearer oge_sk_...', + docs: 'https://og-engine.com/api-reference/errors#unauthorized', + }, + 401, + ); + } + + const key = auth.slice(7); + const record = findApiKeyByKey(key); + + if (!record?.active) { + return c.json( + { + error: 'unauthorized', + message: 'Invalid or deactivated API key.', + docs: 'https://og-engine.com/api-reference/errors#unauthorized', + }, + 401, + ); + } + + if (record.calls_used >= record.calls_limit) { + return c.json( + { + error: 'quota_exceeded', + message: 'Monthly render quota exceeded. Upgrade your plan or wait for reset.', + details: { + limit: record.calls_limit, + used: record.calls_used, + plan: record.plan, + periodStart: record.period_start, + upgradeUrl: 'https://og-engine.com/pricing', + }, + docs: 'https://og-engine.com/api-reference/errors#quota_exceeded', + }, + 429, + ); + } + + c.set('apiKey', record); + await next(); + }; +} + +/** + * Optional auth middleware — validates key if present, but allows unauthenticated. + * Per DECISIONS.md Decision 3: /validate accepts auth optionally, never metered. + */ +export function optionalAuthMiddleware() { + return async (c: Context, next: Next) => { + const auth = c.req.header('Authorization'); + if (auth?.startsWith('Bearer ')) { + const key = auth.slice(7); + const record = findApiKeyByKey(key); + if (record?.active) { + c.set('apiKey', record); + } else if (record) { + return c.json( + { + error: 'unauthorized', + message: 'Invalid or deactivated API key.', + docs: 'https://og-engine.com/api-reference/errors#unauthorized', + }, + 401, + ); + } + // If key not found, proceed without auth (per Decision 3) + } + await next(); + }; +} + +/** + * Plan gate middleware — returns 402 if the user's plan doesn't include a feature. + * Per DECISIONS.md Decision 8: use 402 Payment Required. + */ +export function planGate(feature: string) { + return async (c: Context, next: Next) => { + const record = c.get('apiKey' as never) as ApiKeyRecord | undefined; + if (record && !canAccessFeature(record.plan as Plan, feature)) { + return c.json( + { + error: 'plan_required', + message: `This feature requires a higher plan. Your plan: ${record.plan}.`, + details: { + feature, + currentPlan: record.plan, + requiredPlans: FEATURE_GATES[feature], + upgradeUrl: 'https://og-engine.com/pricing', + }, + docs: 'https://og-engine.com/api-reference/errors#plan_required', + }, + 402, + ); + } + await next(); + }; +} + +/** + * Usage tracking middleware — increments counter and logs usage after response. + * Applied AFTER auth, only on metered endpoints (/render, /render/batch). + */ +export function usageTracking(endpoint: string) { + return async (c: Context, next: Next) => { + const record = c.get('apiKey' as never) as ApiKeyRecord | undefined; + + await next(); + + // Only track on successful responses + if (record && c.res.status >= 200 && c.res.status < 300) { + incrementUsage(record.id); + const renderTimeMs = c.res.headers.get('X-Render-Time-Ms'); + logUsage(record.id, endpoint, renderTimeMs ? parseFloat(renderTimeMs) : undefined); + } + }; +} diff --git a/src/middleware/rate-limit.ts b/src/middleware/rate-limit.ts new file mode 100644 index 0000000..dc41418 --- /dev/null +++ b/src/middleware/rate-limit.ts @@ -0,0 +1,58 @@ +import type { Context, Next } from 'hono'; + +interface RateLimitEntry { + count: number; + resetAt: number; +} + +const store = new Map<string, RateLimitEntry>(); + +// Clean up expired entries periodically +setInterval(() => { + const now = Date.now(); + for (const [key, entry] of store) { + if (entry.resetAt <= now) store.delete(key); + } +}, 60_000); + +export interface RateLimitOptions { + windowMs: number; + max: number; +} + +export function rateLimit(opts?: Partial<RateLimitOptions>) { + const windowMs = opts?.windowMs ?? Number(process.env.RATE_LIMIT_WINDOW_MS ?? 60_000); + const max = opts?.max ?? Number(process.env.RATE_LIMIT_MAX ?? 100); + + return async (c: Context, next: Next) => { + const ip = c.req.header('x-forwarded-for')?.split(',')[0]?.trim() ?? c.req.header('x-real-ip') ?? 'unknown'; + + const now = Date.now(); + let entry = store.get(ip); + + if (!entry || entry.resetAt <= now) { + entry = { count: 0, resetAt: now + windowMs }; + store.set(ip, entry); + } + + entry.count++; + + c.header('X-RateLimit-Limit', String(max)); + c.header('X-RateLimit-Remaining', String(Math.max(0, max - entry.count))); + c.header('X-RateLimit-Reset', String(Math.ceil(entry.resetAt / 1000))); + + if (entry.count > max) { + return c.json( + { + error: 'rate_limit_exceeded', + message: `Too many requests. Limit: ${max} per ${windowMs / 1000}s.`, + retryAfterMs: entry.resetAt - now, + docs: 'https://og-engine.com/api-reference/errors#rate_limit_exceeded', + }, + 429, + ); + } + + await next(); + }; +} diff --git a/src/schemas/request.ts b/src/schemas/request.ts new file mode 100644 index 0000000..21f6c76 --- /dev/null +++ b/src/schemas/request.ts @@ -0,0 +1,94 @@ +import { z } from 'zod'; +import { FONTS } from '../engine/fonts'; +import { FORMAT_KEYS } from '../engine/formats'; +import { GRADIENTS } from '../engine/gradients'; +import { TEMPLATE_NAMES } from '../engine/templates'; + +const formatEnum = z.enum(FORMAT_KEYS as [string, ...string[]]); +const layoutEnum = z.enum(['left', 'center', 'bottom']); +const fontNames = FONTS.map((f) => f.name); +const gradientSlugs = GRADIENTS.map((g) => g.slug); + +export const renderSchema = z.object({ + format: formatEnum, + template: z + .string() + .refine((v) => TEMPLATE_NAMES.includes(v) || v.startsWith('custom:'), { + message: `Template must be one of: ${TEMPLATE_NAMES.join(', ')}, or "custom:<name>"`, + }) + .default('default'), + title: z.string().min(1, "The 'title' field is required."), + description: z.string().default(''), + author: z.string().default(''), + tag: z.string().default(''), + variables: z.record(z.string(), z.string()).default({}), + images: z.record(z.string(), z.string().url('Each image value must be a valid URL.')).default({}), + style: z + .object({ + accent: z + .string() + .regex(/^#[0-9a-fA-F]{6}$/, 'Accent must be a 6-digit hex color (e.g. "#38ef7d").') + .default('#38ef7d'), + layout: layoutEnum.default('left'), + font: z + .string() + .refine((v) => fontNames.includes(v), { + message: `Font must be one of: ${fontNames.join(', ')}`, + }) + .default('Outfit'), + titleSize: z.number().int().min(28).max(72).default(48), + descSize: z.number().int().min(14).max(32).default(22), + gradient: z + .string() + .refine((v) => gradientSlugs.includes(v), { + message: `Gradient must be one of: ${gradientSlugs.join(', ')}`, + }) + .default('void'), + overlayOpacity: z.number().min(0.2).max(0.9).default(0.65), + autoFit: z.boolean().default(false), + }) + .default({ + accent: '#38ef7d', + layout: 'left', + font: 'Outfit', + titleSize: 48, + descSize: 22, + gradient: 'void', + overlayOpacity: 0.65, + autoFit: false, + }), + output: z + .object({ + format: z.enum(['png', 'webp', 'pdf']).default('png'), + quality: z.number().int().min(1).max(100).default(90), + }) + .default({ + format: 'png', + quality: 90, + }), +}); + +export const validateSchema = z.object({ + format: formatEnum, + title: z.string().min(1, "The 'title' field is required."), + description: z.string().default(''), + font: z + .string() + .refine((v) => fontNames.includes(v), { + message: `Font must be one of: ${fontNames.join(', ')}`, + }) + .default('Outfit'), + titleSize: z.number().int().min(28).max(72).default(48), + descSize: z.number().int().min(14).max(32).default(22), + maxTitleLines: z.number().int().min(1).max(10).optional(), + maxDescLines: z.number().int().min(1).max(10).optional(), + autoFit: z.boolean().default(false), +}); + +export const batchSchema = z.object({ + items: z.array(renderSchema).min(1).max(50), +}); + +export type RenderRequest = z.infer<typeof renderSchema>; +export type ValidateRequest = z.infer<typeof validateSchema>; +export type BatchRequest = z.infer<typeof batchSchema>; diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..5fbe232 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,7 @@ +import type { ApiKeyRecord } from './db'; + +export type AppEnv = { + Variables: { + apiKey: ApiKeyRecord; + }; +}; diff --git a/src/utils/ssrf.ts b/src/utils/ssrf.ts new file mode 100644 index 0000000..2eec004 --- /dev/null +++ b/src/utils/ssrf.ts @@ -0,0 +1,82 @@ +import { lookup } from 'node:dns/promises'; + +interface CidrBlock { + base: number; + mask: number; +} + +function ipv4ToInt(ip: string): number | null { + const parts = ip.split('.'); + if (parts.length !== 4) return null; + let n = 0; + for (const part of parts) { + const byte = parseInt(part, 10); + if (Number.isNaN(byte) || byte < 0 || byte > 255) return null; + n = (n << 8) | byte; + } + return n >>> 0; +} + +function cidr(ip: string, prefix: number): CidrBlock { + const base = ipv4ToInt(ip)!; + const mask = prefix === 0 ? 0 : (~0 << (32 - prefix)) >>> 0; + return { base: base & mask, mask }; +} + +const BLOCKED_IPV4: CidrBlock[] = [ + cidr('127.0.0.0', 8), // loopback + cidr('10.0.0.0', 8), // RFC 1918 private + cidr('172.16.0.0', 12), // RFC 1918 private + cidr('192.168.0.0', 16), // RFC 1918 private + cidr('169.254.0.0', 16), // link-local (AWS/Fly metadata) + cidr('100.64.0.0', 10), // CGNAT shared address space + cidr('0.0.0.0', 8), // "this" network +]; + +const BLOCKED_IPV6_PREFIXES = [ + '::1', // loopback + 'fc', // unique local (fc00::/7) + 'fd', // unique local (fd00::/7) + 'fe80', // link-local (fe80::/10) + '::ffff:', // IPv4-mapped (::ffff:0:0/96) +]; + +function isBlockedIPv4(ip: string): boolean { + const n = ipv4ToInt(ip); + if (n === null) return false; + return BLOCKED_IPV4.some(({ base, mask }) => (n & mask) === base); +} + +function isBlockedIPv6(ip: string): boolean { + const lower = ip.toLowerCase().replace(/^\[|\]$/g, ''); + return BLOCKED_IPV6_PREFIXES.some((prefix) => lower === prefix || lower.startsWith(prefix)); +} + +export class SsrfBlockedError extends Error { + constructor(hostname: string, ip: string) { + super(`Blocked: "${hostname}" resolves to a private/reserved IP (${ip})`); + this.name = 'SsrfBlockedError'; + } +} + +/** + * Resolve `hostname` to an IP and throw SsrfBlockedError if it falls in a + * private or reserved range. Call this before fetching any user-supplied URL. + */ +export async function assertNotPrivateHost(hostname: string): Promise<void> { + let ip: string; + try { + const result = await lookup(hostname, { verbatim: false }); + ip = result.address; + } catch { + // DNS failure — let the subsequent fetch() surface the real error + return; + } + + const family = ip.includes(':') ? 6 : 4; + const blocked = family === 4 ? isBlockedIPv4(ip) : isBlockedIPv6(ip); + + if (blocked) { + throw new SsrfBlockedError(hostname, ip); + } +} diff --git a/tests/api/admin.test.ts b/tests/api/admin.test.ts new file mode 100644 index 0000000..a829a36 --- /dev/null +++ b/tests/api/admin.test.ts @@ -0,0 +1,70 @@ +import { Hono } from 'hono'; +import { afterAll, beforeEach, describe, expect, it } from 'vitest'; +import { closeDb, createApiKey, findApiKeyByEmail, getDb } from '../../src/db'; + +beforeEach(() => { + closeDb(); + process.env.DATABASE_URL = 'file::memory:'; + process.env.ADMIN_CRON_SECRET = 'test_admin_secret'; +}); + +afterAll(() => { + closeDb(); + delete process.env.DATABASE_URL; + delete process.env.ADMIN_CRON_SECRET; +}); + +async function createApp() { + const { adminRoute } = await import('../../src/api/admin'); + const app = new Hono(); + app.route('/', adminRoute); + return app; +} + +function postReset(app: Hono, secret?: string) { + const headers: Record<string, string> = { 'Content-Type': 'application/json' }; + if (secret) headers.Authorization = `Bearer ${secret}`; + return app.request('/admin/reset-free-quotas', { method: 'POST', headers }); +} + +describe('POST /admin/reset-free-quotas', () => { + it('resets usage for all free users', async () => { + const free1 = createApiKey('free1@example.com', 'free'); + const free2 = createApiKey('free2@example.com', 'free'); + const paid = createApiKey('paid@example.com', 'pro'); + + const db = getDb(); + db.prepare('UPDATE api_keys SET calls_used = 100 WHERE id = ?').run(free1.id); + db.prepare('UPDATE api_keys SET calls_used = 200 WHERE id = ?').run(free2.id); + db.prepare('UPDATE api_keys SET calls_used = 300 WHERE id = ?').run(paid.id); + + const app = await createApp(); + const res = await postReset(app, 'test_admin_secret'); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.reset).toBe(2); + + expect(findApiKeyByEmail('free1@example.com')!.calls_used).toBe(0); + expect(findApiKeyByEmail('free2@example.com')!.calls_used).toBe(0); + expect(findApiKeyByEmail('paid@example.com')!.calls_used).toBe(300); + }); + + it('returns 401 without admin secret', async () => { + const app = await createApp(); + const res = await postReset(app); + expect(res.status).toBe(401); + }); + + it('returns 401 with wrong secret', async () => { + const app = await createApp(); + const res = await postReset(app, 'wrong_secret'); + expect(res.status).toBe(401); + }); + + it('returns 500 when ADMIN_CRON_SECRET not configured', async () => { + delete process.env.ADMIN_CRON_SECRET; + const app = await createApp(); + const res = await postReset(app, 'anything'); + expect(res.status).toBe(500); + }); +}); diff --git a/tests/api/batch.test.ts b/tests/api/batch.test.ts new file mode 100644 index 0000000..ecf66dc --- /dev/null +++ b/tests/api/batch.test.ts @@ -0,0 +1,74 @@ +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { Hono } from 'hono'; +import { beforeAll, describe, expect, it } from 'vitest'; +import { batchRoute } from '../../src/api/batch'; +import { registerFonts } from '../../src/engine/fonts'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const app = new Hono(); +app.route('/', batchRoute); + +beforeAll(async () => { + await registerFonts(join(__dirname, '..', '..', 'fonts')); +}); + +function post(body: unknown) { + return app.request('/render/batch', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); +} + +describe('POST /render/batch', () => { + it('returns a ZIP archive for valid batch request', async () => { + const res = await post({ + items: [ + { format: 'og', title: 'Image 1' }, + { format: 'twitter', title: 'Image 2' }, + ], + }); + expect(res.status).toBe(200); + expect(res.headers.get('Content-Type')).toBe('application/zip'); + expect(res.headers.get('X-Batch-Count')).toBe('2'); + + const buf = Buffer.from(await res.arrayBuffer()); + // ZIP magic bytes: PK\x03\x04 + expect(buf[0]).toBe(0x50); + expect(buf[1]).toBe(0x4b); + expect(buf[2]).toBe(0x03); + expect(buf[3]).toBe(0x04); + }); + + it('returns 400 for empty items array', async () => { + const res = await post({ items: [] }); + expect(res.status).toBe(400); + }); + + it('returns 400 for invalid item in batch', async () => { + const res = await post({ + items: [ + { format: 'og', title: 'Valid' }, + { format: 'invalid', title: 'Invalid format' }, + ], + }); + expect(res.status).toBe(400); + }); + + it('returns 400 for missing body', async () => { + const res = await app.request('/render/batch', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: 'not json', + }); + expect(res.status).toBe(400); + }); + + it('returns render time header', async () => { + const res = await post({ + items: [{ format: 'og', title: 'Timed' }], + }); + expect(res.headers.get('X-Render-Time-Ms')).toBeTruthy(); + }); +}); diff --git a/tests/api/billing.test.ts b/tests/api/billing.test.ts new file mode 100644 index 0000000..0685565 --- /dev/null +++ b/tests/api/billing.test.ts @@ -0,0 +1,85 @@ +import { Hono } from 'hono'; +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { closeDb, createApiKey, updateStripeInfo } from '../../src/db'; +import { authMiddleware } from '../../src/middleware/auth'; + +// Mock stripe — must use function() not arrow for new Stripe() to work +vi.mock('stripe', () => { + return { + // biome-ignore lint/complexity/useArrowFunction: Vitest mock requires function for new + default: vi.fn().mockImplementation(function () { + return { + billingPortal: { + sessions: { + create: vi.fn().mockResolvedValue({ + url: 'https://billing.stripe.com/session/test_session', + }), + }, + }, + }; + }), + }; +}); + +beforeEach(() => { + closeDb(); + process.env.DATABASE_URL = 'file::memory:'; + process.env.STRIPE_SECRET_KEY = 'sk_test_123'; +}); + +afterAll(() => { + closeDb(); + delete process.env.DATABASE_URL; + delete process.env.STRIPE_SECRET_KEY; +}); + +async function createApp() { + const { billingRoute } = await import('../../src/api/billing'); + const app = new Hono(); + app.use('/billing/*', authMiddleware()); + app.route('/', billingRoute); + return app; +} + +describe('GET /billing/portal', () => { + it('returns portal URL for paid user with stripe_customer_id', async () => { + const record = createApiKey('paid@example.com', 'pro'); + updateStripeInfo(record.id, 'cus_test_123', 'sub_test_123'); + const app = await createApp(); + const res = await app.request('/billing/portal', { + headers: { Authorization: `Bearer ${record.key}` }, + }); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.url).toBe('https://billing.stripe.com/session/test_session'); + }); + + it('returns 400 for free user without stripe_customer_id', async () => { + const record = createApiKey('free@example.com', 'free'); + const app = await createApp(); + const res = await app.request('/billing/portal', { + headers: { Authorization: `Bearer ${record.key}` }, + }); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toBe('no_billing_account'); + }); + + it('returns 401 without auth', async () => { + const app = await createApp(); + const res = await app.request('/billing/portal'); + expect(res.status).toBe(401); + }); + + it('returns 500 when Stripe is not configured', async () => { + delete process.env.STRIPE_SECRET_KEY; + const record = createApiKey('nostripe@example.com', 'pro'); + updateStripeInfo(record.id, 'cus_ns', 'sub_ns'); + vi.resetModules(); + const app = await createApp(); + const res = await app.request('/billing/portal', { + headers: { Authorization: `Bearer ${record.key}` }, + }); + expect(res.status).toBe(500); + }); +}); diff --git a/tests/api/health.test.ts b/tests/api/health.test.ts new file mode 100644 index 0000000..d26ea25 --- /dev/null +++ b/tests/api/health.test.ts @@ -0,0 +1,46 @@ +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { Hono } from 'hono'; +import { beforeAll, describe, expect, it } from 'vitest'; +import { healthRoute } from '../../src/api/health'; +import { registerFonts } from '../../src/engine/fonts'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const app = new Hono(); +app.route('/', healthRoute); + +beforeAll(async () => { + await registerFonts(join(__dirname, '..', '..', 'fonts')); +}); + +describe('GET /health', () => { + it('returns 200 with status ok', async () => { + const res = await app.request('/health'); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.status).toBe('ok'); + }); + + it('returns fonts, formats, templates, and version', async () => { + const res = await app.request('/health'); + const body = await res.json(); + expect(body.fonts).toBeInstanceOf(Array); + expect(body.fonts.length).toBeGreaterThan(0); + expect(body.formats).toEqual(['og', 'twitter', 'square', 'linkedin', 'story']); + expect(body.templates).toEqual([ + 'default', + 'social-card', + 'blog-hero', + 'email-banner', + 'event', + 'github-repo', + 'product-card', + 'testimonial', + 'news-article', + 'pricing', + 'profile-card', + 'announcement', + ]); + expect(body.version).toBe('0.1.0'); + }); +}); diff --git a/tests/api/register.test.ts b/tests/api/register.test.ts new file mode 100644 index 0000000..850f57e --- /dev/null +++ b/tests/api/register.test.ts @@ -0,0 +1,73 @@ +import { Hono } from 'hono'; +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { registerRoute } from '../../src/api/register'; +import { closeDb } from '../../src/db'; + +vi.mock('../../src/email/send', () => ({ + sendWelcomeEmail: vi.fn().mockResolvedValue(undefined), +})); + +beforeEach(() => { + closeDb(); + process.env.DATABASE_URL = 'file::memory:'; +}); + +afterAll(() => { + closeDb(); + delete process.env.DATABASE_URL; +}); + +const app = new Hono(); +app.route('/', registerRoute); + +function post(body: unknown) { + return app.request('/auth/register', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); +} + +describe('POST /auth/register', () => { + it('creates a free tier API key', async () => { + const res = await post({ email: 'new@example.com' }); + expect(res.status).toBe(201); + const body = await res.json(); + expect(body.apiKey).toMatch(/^oge_sk_/); + expect(body.plan).toBe('free'); + expect(body.limit).toBe(500); + }); + + it('returns existing key for duplicate email', async () => { + await post({ email: 'dup@example.com' }); + const res = await post({ email: 'dup@example.com' }); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.apiKey).toMatch(/^oge_sk_/); + }); + + it('returns 400 for invalid email', async () => { + const res = await post({ email: 'not-an-email' }); + expect(res.status).toBe(400); + }); + + it('returns 400 for missing email', async () => { + const res = await post({}); + expect(res.status).toBe(400); + }); + + it('sends welcome email on new registration', async () => { + const { sendWelcomeEmail } = await import('../../src/email/send'); + await post({ email: 'welcome@example.com' }); + expect(sendWelcomeEmail).toHaveBeenCalledWith('welcome@example.com', expect.stringMatching(/^oge_sk_/), 'free'); + }); + + it('returns 400 for invalid JSON', async () => { + const res = await app.request('/auth/register', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: 'not json', + }); + expect(res.status).toBe(400); + }); +}); diff --git a/tests/api/render-from-url.test.ts b/tests/api/render-from-url.test.ts new file mode 100644 index 0000000..d147a7d --- /dev/null +++ b/tests/api/render-from-url.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from 'vitest'; +import { renderFromUrlSchema } from '../../src/api/render-from-url'; +import { assertNotPrivateHost, SsrfBlockedError } from '../../src/utils/ssrf'; + +describe('renderFromUrlSchema', () => { + it('accepts a valid URL request', () => { + const result = renderFromUrlSchema.safeParse({ + url: 'https://example.com/blog/my-post', + }); + expect(result.success).toBe(true); + }); + + it('accepts URL with format and template overrides', () => { + const result = renderFromUrlSchema.safeParse({ + url: 'https://example.com/blog/my-post', + format: 'twitter', + template: 'social-card', + style: { accent: '#fb7185' }, + }); + expect(result.success).toBe(true); + }); + + it('rejects missing URL', () => { + const result = renderFromUrlSchema.safeParse({}); + expect(result.success).toBe(false); + }); + + it('rejects invalid URL', () => { + const result = renderFromUrlSchema.safeParse({ url: 'not-a-url' }); + expect(result.success).toBe(false); + }); + + it('defaults format to og and template to default', () => { + const result = renderFromUrlSchema.safeParse({ + url: 'https://example.com', + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.format).toBe('og'); + expect(result.data.template).toBe('default'); + expect(result.data.style.autoFit).toBe(true); + } + }); +}); + +describe('assertNotPrivateHost (SSRF protection)', () => { + it('throws SsrfBlockedError for loopback 127.0.0.1', async () => { + await expect(assertNotPrivateHost('127.0.0.1')).rejects.toThrow(SsrfBlockedError); + }); + + it('throws SsrfBlockedError for localhost resolving to 127.0.0.1', async () => { + // localhost always resolves to 127.0.0.1 or ::1 — both are blocked + await expect(assertNotPrivateHost('localhost')).rejects.toThrow(SsrfBlockedError); + }); + + it('throws SsrfBlockedError for RFC 1918 range 10.0.0.1', async () => { + await expect(assertNotPrivateHost('10.0.0.1')).rejects.toThrow(SsrfBlockedError); + }); + + it('throws SsrfBlockedError for RFC 1918 range 172.16.0.1', async () => { + await expect(assertNotPrivateHost('172.16.0.1')).rejects.toThrow(SsrfBlockedError); + }); + + it('throws SsrfBlockedError for RFC 1918 range 192.168.1.1', async () => { + await expect(assertNotPrivateHost('192.168.1.1')).rejects.toThrow(SsrfBlockedError); + }); + + it('throws SsrfBlockedError for link-local / metadata endpoint 169.254.169.254', async () => { + await expect(assertNotPrivateHost('169.254.169.254')).rejects.toThrow(SsrfBlockedError); + }); + + it('throws SsrfBlockedError for IPv6 loopback ::1', async () => { + await expect(assertNotPrivateHost('::1')).rejects.toThrow(SsrfBlockedError); + }); + + it('does not throw for a public IP (8.8.8.8)', async () => { + await expect(assertNotPrivateHost('8.8.8.8')).resolves.toBeUndefined(); + }); +}); diff --git a/tests/api/render.test.ts b/tests/api/render.test.ts new file mode 100644 index 0000000..b18c4a4 --- /dev/null +++ b/tests/api/render.test.ts @@ -0,0 +1,276 @@ +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { Hono } from 'hono'; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { renderRoute } from '../../src/api/render'; +import { closeDb, createApiKey, updatePlan } from '../../src/db'; +import { registerFonts } from '../../src/engine/fonts'; +import { authMiddleware } from '../../src/middleware/auth'; +import { renderSchema } from '../../src/schemas/request'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const app = new Hono(); +app.route('/', renderRoute); + +beforeAll(async () => { + await registerFonts(join(__dirname, '..', '..', 'fonts')); +}); + +function post(body: unknown) { + return app.request('/render', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); +} + +describe('POST /render', () => { + it('returns a PNG image with correct Content-Type', async () => { + const res = await post({ format: 'og', title: 'Hello, OG Engine' }); + expect(res.status).toBe(200); + expect(res.headers.get('Content-Type')).toBe('image/png'); + }); + + it('returns render metadata in headers', async () => { + const res = await post({ format: 'og', title: 'Test Title' }); + expect(res.headers.get('X-Render-Time-Ms')).toBeTruthy(); + expect(res.headers.get('X-Title-Lines')).toBeTruthy(); + expect(res.headers.get('X-Desc-Lines')).toBeTruthy(); + expect(res.headers.get('X-Layout-Overflow')).toBeTruthy(); + }); + + it('returns PNG binary data', async () => { + const res = await post({ format: 'og', title: 'Test Title' }); + const buf = Buffer.from(await res.arrayBuffer()); + expect(buf[0]).toBe(0x89); + expect(buf[1]).toBe(0x50); + expect(buf[2]).toBe(0x4e); + expect(buf[3]).toBe(0x47); + }); + + it('returns 400 for missing format', async () => { + const res = await post({ title: 'Hello' }); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toBe('invalid_request'); + }); + + it('returns 400 for missing title', async () => { + const res = await post({ format: 'og' }); + expect(res.status).toBe(400); + }); + + it('returns 400 for invalid font', async () => { + const res = await post({ + format: 'og', + title: 'Hello', + style: { font: 'NonexistentFont' }, + }); + expect(res.status).toBe(400); + }); + + it('accepts full style customization', async () => { + const res = await post({ + format: 'og', + title: 'Styled Image', + description: 'With custom styles.', + author: 'Author', + tag: 'Tag', + style: { + accent: '#67e8f9', + layout: 'center', + font: 'Inter', + titleSize: 56, + descSize: 24, + gradient: 'deep-sea', + }, + }); + expect(res.status).toBe(200); + }); + + it('renders with a specific template', async () => { + const res = await post({ + format: 'og', + title: 'Social Card Test', + template: 'social-card', + }); + expect(res.status).toBe(200); + }); + + it('returns 400 for invalid template', async () => { + const res = await post({ + format: 'og', + title: 'Hello', + template: 'nonexistent', + }); + expect(res.status).toBe(400); + }); + + it('returns WebP when output format is webp', async () => { + const res = await post({ + format: 'og', + title: 'WebP Test', + output: { format: 'webp' }, + }); + expect(res.status).toBe(200); + expect(res.headers.get('Content-Type')).toBe('image/webp'); + }); +}); + +describe('POST /render with variables', () => { + it('accepts variables alongside legacy fields', async () => { + const res = await post({ + format: 'og', + title: 'Product Name', + variables: { price: '€129', badge: '-20%' }, + }); + expect(res.status).toBe(200); + expect(res.headers.get('Content-Type')).toBe('image/png'); + }); +}); + +describe('renderSchema with variables', () => { + it('accepts a request with variables', () => { + const result = renderSchema.safeParse({ + format: 'og', + title: 'My Product', + variables: { + price: '€129', + badge: '-20%', + rating: '4.8', + }, + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.variables).toEqual({ + price: '€129', + badge: '-20%', + rating: '4.8', + }); + } + }); + + it('defaults variables to empty object when omitted', () => { + const result = renderSchema.safeParse({ + format: 'og', + title: 'Test', + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.variables).toEqual({}); + } + }); + + it('rejects non-string variable values', () => { + const result = renderSchema.safeParse({ + format: 'og', + title: 'Test', + variables: { count: 42 }, + }); + expect(result.success).toBe(false); + }); +}); + +describe('POST /render WebP plan gating', () => { + beforeEach(() => { + closeDb(); + process.env.DATABASE_URL = 'file::memory:'; + }); + + afterAll(() => { + closeDb(); + delete process.env.DATABASE_URL; + }); + + function createAuthApp() { + const app = new Hono(); + app.use('/render', authMiddleware()); + app.route('/', renderRoute); + return app; + } + + it('returns 402 for free-tier users requesting WebP', async () => { + const record = createApiKey('free@example.com'); // free plan by default + const app = createAuthApp(); + const res = await app.request('/render', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${record.key}`, + }, + body: JSON.stringify({ format: 'og', title: 'WebP Test', output: { format: 'webp' } }), + }); + expect(res.status).toBe(402); + const body = await res.json(); + expect(body.error).toBe('plan_required'); + expect(body.details.feature).toBe('webp'); + }); + + it('returns 200 for starter-plan users requesting WebP', async () => { + const record = createApiKey('starter@example.com'); + updatePlan(record.id, 'starter'); + const app = createAuthApp(); + const res = await app.request('/render', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${record.key}`, + }, + body: JSON.stringify({ format: 'og', title: 'WebP Test', output: { format: 'webp' } }), + }); + expect(res.status).toBe(200); + expect(res.headers.get('Content-Type')).toBe('image/webp'); + }); + + it('allows unauthenticated WebP requests (auth disabled context)', async () => { + const app = new Hono(); + app.route('/', renderRoute); + const res = await app.request('/render', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ format: 'og', title: 'WebP Test', output: { format: 'webp' } }), + }); + expect(res.status).toBe(200); + expect(res.headers.get('Content-Type')).toBe('image/webp'); + }); +}); + +describe('renderSchema with images', () => { + it('accepts a request with named image URLs', () => { + const result = renderSchema.safeParse({ + format: 'og', + title: 'Product', + images: { + logo: 'https://example.com/logo.png', + avatar: 'https://example.com/avatar.jpg', + }, + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.images).toEqual({ + logo: 'https://example.com/logo.png', + avatar: 'https://example.com/avatar.jpg', + }); + } + }); + + it('defaults images to empty object when omitted', () => { + const result = renderSchema.safeParse({ + format: 'og', + title: 'Test', + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.images).toEqual({}); + } + }); + + it('rejects non-URL image values', () => { + const result = renderSchema.safeParse({ + format: 'og', + title: 'Test', + images: { logo: 'not-a-url' }, + }); + expect(result.success).toBe(false); + }); +}); diff --git a/tests/api/templates.test.ts b/tests/api/templates.test.ts new file mode 100644 index 0000000..4bce731 --- /dev/null +++ b/tests/api/templates.test.ts @@ -0,0 +1,125 @@ +import { Hono } from 'hono'; +import { afterAll, beforeEach, describe, expect, it } from 'vitest'; +import { templatesRoute } from '../../src/api/templates'; +import { closeDb, createApiKey } from '../../src/db'; +import { authMiddleware } from '../../src/middleware/auth'; + +beforeEach(() => { + closeDb(); + process.env.DATABASE_URL = 'file::memory:'; +}); + +afterAll(() => { + closeDb(); + delete process.env.DATABASE_URL; +}); + +function createApp() { + const app = new Hono(); + app.use('/templates', authMiddleware()); + app.use('/templates/*', authMiddleware()); + app.route('/', templatesRoute); + return app; +} + +describe('POST /templates', () => { + it('creates a custom template', async () => { + const record = createApiKey('tmpl@example.com'); + const app = createApp(); + + const res = await app.request('/templates', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${record.key}`, + }, + body: JSON.stringify({ + name: 'my-card', + layers: [ + { type: 'fill', color: '#000000' }, + { type: 'text', content: '{{title}}', fontSize: 48, x: 64, y: 100, width: 1072 }, + ], + }), + }); + + expect(res.status).toBe(201); + const body = await res.json(); + expect(body.name).toBe('my-card'); + expect(body.layerCount).toBe(2); + }); + + it('updates existing template on duplicate name', async () => { + const record = createApiKey('tmpl2@example.com'); + const app = createApp(); + const headers = { 'Content-Type': 'application/json', Authorization: `Bearer ${record.key}` }; + + await app.request('/templates', { + method: 'POST', + headers, + body: JSON.stringify({ name: 'dup', layers: [{ type: 'fill', color: '#000' }] }), + }); + + const res = await app.request('/templates', { + method: 'POST', + headers, + body: JSON.stringify({ + name: 'dup', + layers: [ + { type: 'fill', color: '#fff' }, + { type: 'fill', color: '#000' }, + ], + }), + }); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.layerCount).toBe(2); + expect(body.message).toBe('Template updated.'); + }); + + it('returns 401 without auth', async () => { + const app = createApp(); + const res = await app.request('/templates', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'x', layers: [{ type: 'fill', color: '#000' }] }), + }); + expect(res.status).toBe(401); + }); + + it('returns 400 for invalid layer type', async () => { + const record = createApiKey('bad@example.com'); + const app = createApp(); + + const res = await app.request('/templates', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${record.key}` }, + body: JSON.stringify({ name: 'bad', layers: [{ type: 'invalid' }] }), + }); + expect(res.status).toBe(400); + }); +}); + +describe('GET /templates', () => { + it('lists created templates', async () => { + const record = createApiKey('list@example.com'); + const app = createApp(); + const headers = { 'Content-Type': 'application/json', Authorization: `Bearer ${record.key}` }; + + await app.request('/templates', { + method: 'POST', + headers, + body: JSON.stringify({ name: 'a', layers: [{ type: 'fill', color: '#000' }] }), + }); + await app.request('/templates', { + method: 'POST', + headers, + body: JSON.stringify({ name: 'b', layers: [{ type: 'fill', color: '#fff' }] }), + }); + + const res = await app.request('/templates', { headers }); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.templates).toHaveLength(2); + }); +}); diff --git a/tests/api/triggers.test.ts b/tests/api/triggers.test.ts new file mode 100644 index 0000000..5efe260 --- /dev/null +++ b/tests/api/triggers.test.ts @@ -0,0 +1,121 @@ +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { Hono } from 'hono'; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { triggersRoute } from '../../src/api/triggers'; +import { closeDb, createApiKey } from '../../src/db'; +import { registerFonts } from '../../src/engine/fonts'; +import { authMiddleware } from '../../src/middleware/auth'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +beforeAll(async () => { + await registerFonts(join(__dirname, '..', '..', 'fonts')); +}); + +beforeEach(() => { + closeDb(); + process.env.DATABASE_URL = 'file::memory:'; +}); + +afterAll(() => { + closeDb(); + delete process.env.DATABASE_URL; +}); + +function createApp() { + const app = new Hono(); + app.use('/triggers', authMiddleware()); + app.use('/triggers/*', authMiddleware()); + app.route('/', triggersRoute); + return app; +} + +describe('POST /triggers', () => { + it('creates a webhook trigger', async () => { + const record = createApiKey('trigger@example.com'); + const app = createApp(); + + const res = await app.request('/triggers', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${record.key}` }, + body: JSON.stringify({ + url: 'https://example.com/callback', + renderConfig: { format: 'og', title: 'Trigger Test' }, + }), + }); + + expect(res.status).toBe(201); + const body = await res.json(); + expect(body.id).toBeTruthy(); + expect(body.secret).toMatch(/^whsec_/); + expect(body.url).toBe('https://example.com/callback'); + }); + + it('returns 401 without auth', async () => { + const app = createApp(); + const res = await app.request('/triggers', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ url: 'https://example.com', renderConfig: { format: 'og', title: 'X' } }), + }); + expect(res.status).toBe(401); + }); + + it('returns 400 for invalid URL', async () => { + const record = createApiKey('bad@example.com'); + const app = createApp(); + + const res = await app.request('/triggers', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${record.key}` }, + body: JSON.stringify({ + url: 'not-a-url', + renderConfig: { format: 'og', title: 'X' }, + }), + }); + expect(res.status).toBe(400); + }); +}); + +describe('GET /triggers', () => { + it('lists webhook triggers', async () => { + const record = createApiKey('list@example.com'); + const app = createApp(); + const headers = { 'Content-Type': 'application/json', Authorization: `Bearer ${record.key}` }; + + await app.request('/triggers', { + method: 'POST', + headers, + body: JSON.stringify({ url: 'https://a.com/cb', renderConfig: { format: 'og', title: 'A' } }), + }); + + const res = await app.request('/triggers', { headers }); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.triggers).toHaveLength(1); + }); +}); + +describe('DELETE /triggers/:id', () => { + it('deletes a trigger', async () => { + const record = createApiKey('del@example.com'); + const app = createApp(); + const headers = { 'Content-Type': 'application/json', Authorization: `Bearer ${record.key}` }; + + const createRes = await app.request('/triggers', { + method: 'POST', + headers, + body: JSON.stringify({ url: 'https://x.com/cb', renderConfig: { format: 'og', title: 'X' } }), + }); + const { id } = await createRes.json(); + + const res = await app.request(`/triggers/${id}`, { method: 'DELETE', headers }); + expect(res.status).toBe(200); + + // Verify it's gone from list + const listRes = await app.request('/triggers', { headers }); + const body = await listRes.json(); + expect(body.triggers).toHaveLength(0); + }); +}); diff --git a/tests/api/usage.test.ts b/tests/api/usage.test.ts new file mode 100644 index 0000000..39be1d5 --- /dev/null +++ b/tests/api/usage.test.ts @@ -0,0 +1,56 @@ +import { Hono } from 'hono'; +import { afterAll, beforeEach, describe, expect, it } from 'vitest'; +import { usageRoute } from '../../src/api/usage'; +import { closeDb, createApiKey, incrementUsage, logUsage } from '../../src/db'; +import { authMiddleware } from '../../src/middleware/auth'; + +beforeEach(() => { + closeDb(); + process.env.DATABASE_URL = 'file::memory:'; +}); + +afterAll(() => { + closeDb(); + delete process.env.DATABASE_URL; +}); + +function createApp() { + const app = new Hono(); + app.use('/usage', authMiddleware()); + app.route('/', usageRoute); + return app; +} + +describe('GET /usage', () => { + it('returns 401 without auth', async () => { + const app = createApp(); + const res = await app.request('/usage'); + expect(res.status).toBe(401); + }); + + it('returns usage stats with valid auth', async () => { + const record = createApiKey('usage@example.com'); + incrementUsage(record.id); + logUsage(record.id, '/render', 10.0, 'og'); + + const app = createApp(); + const res = await app.request('/usage', { + headers: { Authorization: `Bearer ${record.key}` }, + }); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.plan).toBe('free'); + expect(body.quota.limit).toBe(500); + expect(body.quota.used).toBe(1); + expect(body.quota.remaining).toBe(499); + expect(body.usage.total).toBe(1); + }); + + it('returns 401 for invalid key', async () => { + const app = createApp(); + const res = await app.request('/usage', { + headers: { Authorization: 'Bearer oge_sk_invalid' }, + }); + expect(res.status).toBe(401); + }); +}); diff --git a/tests/api/validate.test.ts b/tests/api/validate.test.ts new file mode 100644 index 0000000..c61fa5e --- /dev/null +++ b/tests/api/validate.test.ts @@ -0,0 +1,94 @@ +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { Hono } from 'hono'; +import { beforeAll, describe, expect, it } from 'vitest'; +import { validateRoute } from '../../src/api/validate'; +import { registerFonts } from '../../src/engine/fonts'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const app = new Hono(); +app.route('/', validateRoute); + +beforeAll(async () => { + await registerFonts(join(__dirname, '..', '..', 'fonts')); +}); + +function post(body: unknown) { + return app.request('/validate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); +} + +describe('POST /validate', () => { + it('returns fits: true for short text', async () => { + const res = await post({ format: 'og', title: 'Hello' }); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.fits).toBe(true); + expect(body.title.lines).toBe(1); + expect(body.title.overflow).toBe(false); + expect(body.computeTimeMs).toBeGreaterThanOrEqual(0); + }); + + it('returns fits: false for overflowing title', async () => { + const res = await post({ + format: 'og', + title: + 'This is an extremely long title that will certainly overflow because it has too many words to possibly fit within three lines of the OG format at the default font size of forty-eight pixels', + }); + const body = await res.json(); + expect(body.fits).toBe(false); + expect(body.title.overflow).toBe(true); + expect(body.title.lines).toBeGreaterThan(3); + }); + + it('returns 400 for missing format', async () => { + const res = await post({ title: 'Hello' }); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toBe('invalid_request'); + }); + + it('returns 400 for missing title', async () => { + const res = await post({ format: 'og' }); + expect(res.status).toBe(400); + }); + + it('accepts custom maxTitleLines', async () => { + const res = await post({ + format: 'og', + title: 'Short title', + maxTitleLines: 1, + }); + const body = await res.json(); + expect(body.title.maxLines).toBe(1); + }); + + it('includes description validation when provided', async () => { + const res = await post({ + format: 'og', + title: 'Title', + description: 'Some description text', + }); + const body = await res.json(); + expect(body.description).toBeDefined(); + expect(body.description.lines).toBeGreaterThan(0); + }); + + it('returns autoFit sizes when autoFit is true', async () => { + const res = await post({ + format: 'og', + title: + 'A very long title that would normally overflow but autoFit should find a smaller size that works perfectly fine', + autoFit: true, + }); + const body = await res.json(); + expect(body.fits).toBe(true); + expect(body.autoFit).toBeDefined(); + expect(body.autoFit.titleSize).toBeGreaterThanOrEqual(28); + expect(body.autoFit.titleSize).toBeLessThanOrEqual(48); + expect(body.title.overflow).toBe(false); + }); +}); diff --git a/tests/api/webhooks.test.ts b/tests/api/webhooks.test.ts new file mode 100644 index 0000000..18c55f8 --- /dev/null +++ b/tests/api/webhooks.test.ts @@ -0,0 +1,161 @@ +import { Hono } from 'hono'; +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { closeDb, createApiKey, findApiKeyByEmail, updateStripeInfo } from '../../src/db'; + +// Mock stripe +vi.mock('stripe', () => { + return { + // biome-ignore lint/complexity/useArrowFunction: function keyword required for `new Stripe()` constructor mock + default: vi.fn().mockImplementation(function () { + return { + webhooks: { + constructEventAsync: vi.fn().mockImplementation(async (body: string) => JSON.parse(body)), + }, + subscriptions: { + retrieve: vi.fn().mockResolvedValue({ + items: { data: [{ price: { id: 'price_pro_monthly' } }] }, + }), + }, + }; + }), + }; +}); + +// Mock email +vi.mock('../../src/email/send', () => ({ + sendWelcomeEmail: vi.fn().mockResolvedValue(undefined), + sendUpgradeEmail: vi.fn().mockResolvedValue(undefined), + sendDowngradeEmail: vi.fn().mockResolvedValue(undefined), +})); + +beforeEach(() => { + closeDb(); + process.env.DATABASE_URL = 'file::memory:'; + process.env.STRIPE_SECRET_KEY = 'sk_test_123'; + process.env.STRIPE_WEBHOOK_SECRET = 'whsec_test_123'; + process.env.STRIPE_PRICE_PRO = 'price_pro_monthly'; + process.env.STRIPE_PRICE_STARTER = 'price_starter_monthly'; + process.env.STRIPE_PRICE_SCALE = 'price_scale_monthly'; +}); + +afterAll(() => { + closeDb(); + delete process.env.DATABASE_URL; + delete process.env.STRIPE_SECRET_KEY; + delete process.env.STRIPE_WEBHOOK_SECRET; + delete process.env.STRIPE_PRICE_PRO; + delete process.env.STRIPE_PRICE_STARTER; + delete process.env.STRIPE_PRICE_SCALE; +}); + +async function importWebhooksRoute() { + const mod = await import('../../src/api/webhooks'); + const app = new Hono(); + app.route('/', mod.webhooksRoute); + return app; +} + +function postWebhook(app: Hono, event: object) { + return app.request('/webhooks/stripe', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'stripe-signature': 'test_sig', + }, + body: JSON.stringify(event), + }); +} + +describe('POST /webhooks/stripe', () => { + it('returns 400 without stripe-signature header', async () => { + const app = await importWebhooksRoute(); + const res = await app.request('/webhooks/stripe', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ type: 'test' }), + }); + expect(res.status).toBe(400); + }); + + it('handles checkout.session.completed for new user', async () => { + const app = await importWebhooksRoute(); + const res = await postWebhook(app, { + type: 'checkout.session.completed', + data: { + object: { + customer_email: 'newpaid@example.com', + customer: 'cus_123', + subscription: 'sub_123', + }, + }, + }); + expect(res.status).toBe(200); + const record = findApiKeyByEmail('newpaid@example.com'); + expect(record).not.toBeNull(); + expect(record!.plan).toBe('pro'); + expect(record!.stripe_customer_id).toBe('cus_123'); + expect(record!.stripe_subscription_id).toBe('sub_123'); + }); + + it('upgrades existing free user on checkout', async () => { + createApiKey('existing@example.com', 'free'); + const app = await importWebhooksRoute(); + const res = await postWebhook(app, { + type: 'checkout.session.completed', + data: { + object: { + customer_email: 'existing@example.com', + customer: 'cus_456', + subscription: 'sub_456', + }, + }, + }); + expect(res.status).toBe(200); + const record = findApiKeyByEmail('existing@example.com'); + expect(record!.plan).toBe('pro'); + expect(record!.stripe_customer_id).toBe('cus_456'); + }); + + it('handles customer.subscription.deleted — downgrades to free', async () => { + const record = createApiKey('cancel@example.com', 'pro'); + updateStripeInfo(record.id, 'cus_789', 'sub_789'); + const app = await importWebhooksRoute(); + const res = await postWebhook(app, { + type: 'customer.subscription.deleted', + data: { object: { id: 'sub_789' } }, + }); + expect(res.status).toBe(200); + const updated = findApiKeyByEmail('cancel@example.com'); + expect(updated!.plan).toBe('free'); + }); + + it('handles invoice.paid — resets usage', async () => { + const record = createApiKey('invoice@example.com', 'pro'); + updateStripeInfo(record.id, 'cus_inv', 'sub_inv'); + const { getDb } = await import('../../src/db'); + getDb().prepare('UPDATE api_keys SET calls_used = 100 WHERE id = ?').run(record.id); + + const app = await importWebhooksRoute(); + const res = await postWebhook(app, { + type: 'invoice.paid', + data: { + object: { + parent: { + subscription_details: { subscription: 'sub_inv' }, + }, + }, + }, + }); + expect(res.status).toBe(200); + const updated = findApiKeyByEmail('invoice@example.com'); + expect(updated!.calls_used).toBe(0); + }); + + it('returns 500 when Stripe is not configured', async () => { + delete process.env.STRIPE_SECRET_KEY; + vi.resetModules(); + const app = await importWebhooksRoute(); + const res = await postWebhook(app, { type: 'test' }); + expect(res.status).toBe(500); + }); +}); diff --git a/tests/db/db.test.ts b/tests/db/db.test.ts new file mode 100644 index 0000000..1a29867 --- /dev/null +++ b/tests/db/db.test.ts @@ -0,0 +1,110 @@ +import { afterAll, beforeEach, describe, expect, it } from 'vitest'; +import { + closeDb, + createApiKey, + findApiKeyByEmail, + findApiKeyByKey, + generateApiKey, + getDb, + getUsageStats, + incrementUsage, + logUsage, + PLAN_LIMITS, + resetUsage, + updatePlan, +} from '../../src/db'; + +// Use in-memory database for tests +beforeEach(() => { + closeDb(); + // Point to in-memory DB via env + process.env.DATABASE_URL = 'file::memory:'; +}); + +afterAll(() => { + closeDb(); + delete process.env.DATABASE_URL; +}); + +describe('database', () => { + it('creates tables on first access', () => { + const db = getDb(); + const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all() as { name: string }[]; + const names = tables.map((t) => t.name); + expect(names).toContain('api_keys'); + expect(names).toContain('usage_log'); + }); +}); + +describe('generateApiKey', () => { + it('generates key with oge_sk_ prefix', () => { + const key = generateApiKey(); + expect(key.startsWith('oge_sk_')).toBe(true); + expect(key.length).toBeGreaterThan(7); + }); +}); + +describe('API key CRUD', () => { + it('creates and retrieves a free tier key', () => { + const record = createApiKey('test@example.com'); + expect(record.plan).toBe('free'); + expect(record.calls_limit).toBe(500); + expect(record.calls_used).toBe(0); + expect(record.key.startsWith('oge_sk_')).toBe(true); + + const found = findApiKeyByKey(record.key); + expect(found).not.toBeNull(); + expect(found!.email).toBe('test@example.com'); + }); + + it('finds by email', () => { + createApiKey('lookup@example.com'); + const found = findApiKeyByEmail('lookup@example.com'); + expect(found).not.toBeNull(); + }); + + it('returns null for unknown key', () => { + expect(findApiKeyByKey('oge_sk_nonexistent')).toBeNull(); + }); + + it('increments usage', () => { + const record = createApiKey('inc@example.com'); + incrementUsage(record.id); + incrementUsage(record.id); + const updated = findApiKeyByKey(record.key); + expect(updated!.calls_used).toBe(2); + }); + + it('updates plan and limits', () => { + const record = createApiKey('upgrade@example.com'); + updatePlan(record.id, 'pro'); + const updated = findApiKeyByKey(record.key); + expect(updated!.plan).toBe('pro'); + expect(updated!.calls_limit).toBe(PLAN_LIMITS.pro); + }); + + it('resets usage', () => { + const record = createApiKey('reset@example.com'); + incrementUsage(record.id); + incrementUsage(record.id); + resetUsage(record.id); + const updated = findApiKeyByKey(record.key); + expect(updated!.calls_used).toBe(0); + }); +}); + +describe('usage logging', () => { + it('logs and retrieves usage stats', () => { + const record = createApiKey('log@example.com'); + logUsage(record.id, '/render', 12.5, 'og'); + logUsage(record.id, '/render', 8.3, 'twitter'); + logUsage(record.id, '/render/batch', 45.0, 'og'); + + const stats = getUsageStats(record.id); + expect(stats.total).toBe(3); + expect(stats.byEndpoint['/render']).toBe(2); + expect(stats.byEndpoint['/render/batch']).toBe(1); + expect(stats.byFormat.og).toBe(2); + expect(stats.byFormat.twitter).toBe(1); + }); +}); diff --git a/tests/email/send.test.ts b/tests/email/send.test.ts new file mode 100644 index 0000000..9f35744 --- /dev/null +++ b/tests/email/send.test.ts @@ -0,0 +1,60 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// Mock resend before importing send module +vi.mock('resend', () => { + const mockSend = vi.fn().mockResolvedValue({ id: 'mock-email-id' }); + return { + // biome-ignore lint/complexity/useArrowFunction: function keyword required for new Resend() constructor mock + Resend: vi.fn().mockImplementation(function () { + return { emails: { send: mockSend } }; + }), + }; +}); + +describe('email/send', () => { + beforeEach(() => { + process.env.RESEND_API_KEY = 're_test_123'; + }); + + afterEach(() => { + delete process.env.RESEND_API_KEY; + vi.resetModules(); + }); + + it('sendWelcomeEmail sends with correct fields', async () => { + const { sendWelcomeEmail } = await import('../../src/email/send'); + await sendWelcomeEmail('user@example.com', 'oge_sk_abc123', 'free'); + const { Resend } = await import('resend'); + const instance = new Resend('test'); + expect(instance.emails.send).toHaveBeenCalledWith( + expect.objectContaining({ + to: 'user@example.com', + from: expect.stringContaining('OG Engine'), + subject: expect.stringContaining('API Key'), + }), + ); + }); + + it('sendUpgradeEmail sends with plan info', async () => { + const { sendUpgradeEmail } = await import('../../src/email/send'); + await sendUpgradeEmail('user@example.com', 'pro'); + const { Resend } = await import('resend'); + const instance = new Resend('test'); + expect(instance.emails.send).toHaveBeenCalled(); + }); + + it('sendDowngradeEmail sends downgrade notice', async () => { + const { sendDowngradeEmail } = await import('../../src/email/send'); + await sendDowngradeEmail('user@example.com'); + const { Resend } = await import('resend'); + const instance = new Resend('test'); + expect(instance.emails.send).toHaveBeenCalled(); + }); + + it('skips silently when RESEND_API_KEY is not set', async () => { + delete process.env.RESEND_API_KEY; + const { sendWelcomeEmail } = await import('../../src/email/send'); + // Should not throw + await expect(sendWelcomeEmail('user@example.com', 'oge_sk_abc', 'free')).resolves.toBeUndefined(); + }); +}); diff --git a/tests/engine/autofit.test.ts b/tests/engine/autofit.test.ts new file mode 100644 index 0000000..85fb3c9 --- /dev/null +++ b/tests/engine/autofit.test.ts @@ -0,0 +1,83 @@ +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { beforeAll, describe, expect, it } from 'vitest'; +import { autoFitCard, autoFitText } from '../../src/engine/autofit'; +import { registerFonts } from '../../src/engine/fonts'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +beforeAll(async () => { + await registerFonts(join(__dirname, '..', '..', 'fonts')); +}); + +describe('autoFitText', () => { + it('returns maxSize when short text fits', () => { + const result = autoFitText({ + text: 'Hi', + format: 'og', + fontName: 'Outfit', + fontWeight: '800', + maxLines: 3, + minSize: 28, + maxSize: 72, + }); + expect(result.fontSize).toBe(72); + expect(result.overflow).toBe(false); + }); + + it('reduces font size for long text', () => { + const result = autoFitText({ + text: 'This is an extremely long title that will certainly need a smaller font size to fit within the allowed number of lines for the OG image format', + format: 'og', + fontName: 'Outfit', + fontWeight: '800', + maxLines: 3, + minSize: 28, + maxSize: 72, + }); + expect(result.fontSize).toBeLessThan(72); + expect(result.fontSize).toBeGreaterThanOrEqual(28); + expect(result.overflow).toBe(false); + }); + + it('returns minSize with overflow when text is too long even at minimum', () => { + const veryLong = 'word '.repeat(200); + const result = autoFitText({ + text: veryLong, + format: 'og', + fontName: 'Outfit', + fontWeight: '800', + maxLines: 2, + minSize: 60, + maxSize: 72, + }); + expect(result.fontSize).toBe(60); + expect(result.overflow).toBe(true); + }); +}); + +describe('autoFitCard', () => { + it('returns fitted sizes for title and description', () => { + const result = autoFitCard({ + title: 'A reasonable title for an OG image', + description: 'A short description.', + format: 'og', + fontName: 'Outfit', + }); + expect(result.titleSize).toBeGreaterThanOrEqual(28); + expect(result.titleSize).toBeLessThanOrEqual(72); + expect(result.descSize).toBeGreaterThanOrEqual(14); + expect(result.descSize).toBeLessThanOrEqual(32); + }); + + it('handles empty description', () => { + const result = autoFitCard({ + title: 'Title only', + description: '', + format: 'og', + fontName: 'Outfit', + }); + expect(result.descSize).toBe(32); // max when no description + expect(result.descLines).toBe(0); + }); +}); diff --git a/tests/engine/cache.test.ts b/tests/engine/cache.test.ts new file mode 100644 index 0000000..102937b --- /dev/null +++ b/tests/engine/cache.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest'; +import { LRUCache } from '../../src/engine/cache'; + +describe('LRUCache', () => { + it('stores and retrieves values', () => { + const cache = new LRUCache<string, number>(10); + cache.set('a', 1); + expect(cache.get('a')).toBe(1); + }); + + it('returns undefined for missing keys', () => { + const cache = new LRUCache<string, number>(10); + expect(cache.get('missing')).toBeUndefined(); + }); + + it('evicts oldest entry when full', () => { + const cache = new LRUCache<string, number>(3); + cache.set('a', 1); + cache.set('b', 2); + cache.set('c', 3); + cache.set('d', 4); // evicts 'a' + expect(cache.get('a')).toBeUndefined(); + expect(cache.get('b')).toBe(2); + expect(cache.get('d')).toBe(4); + }); + + it('promotes accessed entries (LRU behavior)', () => { + const cache = new LRUCache<string, number>(3); + cache.set('a', 1); + cache.set('b', 2); + cache.set('c', 3); + cache.get('a'); // promote 'a' + cache.set('d', 4); // should evict 'b' (oldest unused) + expect(cache.get('a')).toBe(1); + expect(cache.get('b')).toBeUndefined(); + }); + + it('tracks size correctly', () => { + const cache = new LRUCache<string, number>(5); + cache.set('a', 1); + cache.set('b', 2); + expect(cache.size).toBe(2); + cache.set('a', 10); // update + expect(cache.size).toBe(2); + }); + + it('clears all entries', () => { + const cache = new LRUCache<string, number>(10); + cache.set('a', 1); + cache.set('b', 2); + cache.clear(); + expect(cache.size).toBe(0); + expect(cache.get('a')).toBeUndefined(); + }); +}); diff --git a/tests/engine/custom-template.test.ts b/tests/engine/custom-template.test.ts new file mode 100644 index 0000000..02c8201 --- /dev/null +++ b/tests/engine/custom-template.test.ts @@ -0,0 +1,185 @@ +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createCanvas } from '@napi-rs/canvas'; +import { beforeAll, describe, expect, it } from 'vitest'; +import { customTemplateSchema, renderCustomTemplate } from '../../src/engine/custom-template'; +import { registerFonts } from '../../src/engine/fonts'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +beforeAll(async () => { + await registerFonts(join(__dirname, '..', '..', 'fonts')); +}); + +function parseDef(raw: unknown) { + return customTemplateSchema.parse(raw); +} + +describe('customTemplateSchema', () => { + it('validates a simple template', () => { + const result = customTemplateSchema.safeParse({ + name: 'my-template', + layers: [ + { type: 'fill', color: '#000000' }, + { type: 'text', content: '{{title}}', fontSize: 48, x: 64, y: 100, width: 1072 }, + ], + }); + expect(result.success).toBe(true); + }); + + it('rejects empty layers', () => { + const result = customTemplateSchema.safeParse({ + name: 'empty', + layers: [], + }); + expect(result.success).toBe(false); + }); + + it('rejects unknown layer type', () => { + const result = customTemplateSchema.safeParse({ + name: 'bad', + layers: [{ type: 'unknown' }], + }); + expect(result.success).toBe(false); + }); +}); + +describe('renderCustomTemplate', () => { + it('renders a simple template with fill + text', () => { + const canvas = createCanvas(1200, 630); + const ctx = canvas.getContext('2d'); + + const def = parseDef({ + name: 'test', + layers: [ + { type: 'fill', color: '#1a1a2e' }, + { + type: 'text', + content: '{{title}}', + fontSize: 48, + fontWeight: 800, + x: 64, + y: 100, + width: 1072, + maxLines: 3, + color: '#ffffff', + }, + ], + }); + + const result = renderCustomTemplate( + def, + ctx, + 1200, + 630, + { title: 'Hello Custom', description: '', author: '', tag: '' }, + { accent: '#38ef7d', fontFamily: 'Outfit' }, + null, + {}, + ); + + expect(result.titleTotalLines).toBeGreaterThan(0); + expect(result.titleVisibleLines).toBeGreaterThan(0); + }); + + it('interpolates variables', () => { + const canvas = createCanvas(1200, 630); + const ctx = canvas.getContext('2d'); + + const def = parseDef({ + name: 'vars', + layers: [ + { type: 'fill', color: '{{accent}}' }, + { type: 'text', content: 'By {{author}}', fontSize: 20, x: 64, y: 500, width: 1072, color: '#ffffff' }, + ], + }); + + const result = renderCustomTemplate( + def, + ctx, + 1200, + 630, + { title: 'Title', description: 'Desc', author: 'Claude', tag: 'AI' }, + { accent: '#ff0000', fontFamily: 'Outfit' }, + null, + {}, + ); + + expect(result).toBeDefined(); + }); + + it('renders gradient and rect layers', () => { + const canvas = createCanvas(1200, 630); + const ctx = canvas.getContext('2d'); + + const def = parseDef({ + name: 'multi', + layers: [ + { type: 'gradient', gradient: 'void' }, + { type: 'rect', color: '#38ef7d', x: 64, y: 500, width: 100, height: 4, radius: 2 }, + { + type: 'text', + content: '{{title}}', + fontSize: 48, + fontWeight: 800, + x: 64, + y: 100, + width: 1072, + color: '#ffffff', + }, + ], + }); + + const result = renderCustomTemplate( + def, + ctx, + 1200, + 630, + { title: 'Multi Layer', description: '', author: '', tag: '' }, + { accent: '#38ef7d', fontFamily: 'Outfit' }, + null, + {}, + ); + + expect(result.titleVisibleLines).toBeGreaterThan(0); + }); +}); + +describe('renderCustomTemplate with named images', () => { + it('renders image layer with source referencing a named image (graceful skip when missing)', () => { + // Test that the template renders without error when named images is empty + // and source references a missing image (should skip gracefully). + const canvas = createCanvas(1200, 630); + const ctx = canvas.getContext('2d'); + + const def = parseDef({ + name: 'test-named-img', + layers: [ + { type: 'fill', color: '#000000' }, + { type: 'image', source: 'logo', x: 50, y: 50, width: 200, height: 200 }, + { + type: 'text', + content: '{{title}}', + fontSize: 48, + x: 100, + y: 300, + width: 1000, + color: '#ffffff', + }, + ], + }); + + const result = renderCustomTemplate( + def, + ctx, + 1200, + 630, + { title: 'Test', description: '', author: '', tag: '' }, + { accent: '#38ef7d', fontFamily: 'Outfit' }, + null, + {}, // empty named images — logo should be skipped gracefully + ); + + expect(result.overflow).toBe(false); + }); +}); diff --git a/tests/engine/formats.test.ts b/tests/engine/formats.test.ts new file mode 100644 index 0000000..f4be318 --- /dev/null +++ b/tests/engine/formats.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest'; +import { FORMAT_KEYS, FORMATS } from '../../src/engine/formats'; + +describe('FORMATS', () => { + it('defines exactly 5 formats', () => { + expect(FORMAT_KEYS).toHaveLength(5); + }); + + it('includes og, twitter, square, linkedin, story', () => { + expect(FORMAT_KEYS).toContain('og'); + expect(FORMAT_KEYS).toContain('twitter'); + expect(FORMAT_KEYS).toContain('square'); + expect(FORMAT_KEYS).toContain('linkedin'); + expect(FORMAT_KEYS).toContain('story'); + }); + + it('og format is 1200x630 with 3 title / 4 desc max lines', () => { + const og = FORMATS.og; + expect(og.w).toBe(1200); + expect(og.h).toBe(630); + expect(og.maxTitleLines).toBe(3); + expect(og.maxDescLines).toBe(4); + }); + + it('story format is 1080x1920 with 5 title / 6 desc max lines', () => { + const story = FORMATS.story; + expect(story.w).toBe(1080); + expect(story.h).toBe(1920); + expect(story.maxTitleLines).toBe(5); + expect(story.maxDescLines).toBe(6); + }); +}); diff --git a/tests/engine/image-cache.test.ts b/tests/engine/image-cache.test.ts new file mode 100644 index 0000000..48f432b --- /dev/null +++ b/tests/engine/image-cache.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest'; +import { + clearImageCache, + getCachedImage, + getImageCacheSize, + hashRequest, + setCachedImage, +} from '../../src/engine/image-cache'; + +describe('image cache', () => { + it('caches and retrieves images', () => { + clearImageCache(); + const hash = hashRequest({ format: 'og', title: 'Test' }); + const image = { + buffer: Buffer.from('fake-png'), + contentType: 'image/png', + headers: { 'Content-Type': 'image/png' }, + }; + + setCachedImage(hash, image); + const cached = getCachedImage(hash); + expect(cached).toBeDefined(); + expect(cached!.buffer.toString()).toBe('fake-png'); + }); + + it('returns undefined for cache miss', () => { + clearImageCache(); + expect(getCachedImage('nonexistent')).toBeUndefined(); + }); + + it('produces consistent hashes', () => { + const a = hashRequest({ format: 'og', title: 'Hello' }); + const b = hashRequest({ format: 'og', title: 'Hello' }); + expect(a).toBe(b); + }); + + it('produces different hashes for different requests', () => { + const a = hashRequest({ format: 'og', title: 'Hello' }); + const b = hashRequest({ format: 'og', title: 'World' }); + expect(a).not.toBe(b); + }); + + it('tracks cache size', () => { + clearImageCache(); + expect(getImageCacheSize()).toBe(0); + setCachedImage('a', { buffer: Buffer.from('1'), contentType: 'image/png', headers: {} }); + setCachedImage('b', { buffer: Buffer.from('2'), contentType: 'image/png', headers: {} }); + expect(getImageCacheSize()).toBe(2); + }); +}); diff --git a/tests/engine/image-loader.test.ts b/tests/engine/image-loader.test.ts new file mode 100644 index 0000000..4cc63b4 --- /dev/null +++ b/tests/engine/image-loader.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest'; +import { loadRemoteImage, loadRemoteImages } from '../../src/engine/image-loader'; + +describe('loadRemoteImage', () => { + it('loads a valid image from a URL', async () => { + const img = await loadRemoteImage('https://www.google.com/favicon.ico'); + expect(img).toBeDefined(); + expect(img).not.toBeNull(); + expect(img!.width).toBeGreaterThan(0); + expect(img!.height).toBeGreaterThan(0); + }); + + it('returns null for invalid URLs', async () => { + const img = await loadRemoteImage('https://this-domain-does-not-exist-12345.com/img.png'); + expect(img).toBeNull(); + }); + + it('returns null for non-image content types', async () => { + const img = await loadRemoteImage('https://example.com'); + expect(img).toBeNull(); + }); +}); + +describe('loadRemoteImages', () => { + it('loads multiple images in parallel', async () => { + const result = await loadRemoteImages({ + favicon: 'https://www.google.com/favicon.ico', + bad: 'https://this-domain-does-not-exist-12345.com/nope.png', + }); + expect(result.favicon).not.toBeNull(); + expect(result.bad).toBeNull(); + }); + + it('returns empty map for empty input', async () => { + const result = await loadRemoteImages({}); + expect(result).toEqual({}); + }); +}); diff --git a/tests/engine/meta-extract.test.ts b/tests/engine/meta-extract.test.ts new file mode 100644 index 0000000..64d0027 --- /dev/null +++ b/tests/engine/meta-extract.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest'; +import { extractMeta } from '../../src/engine/meta-extract'; + +const sampleHtml = ` +<!DOCTYPE html> +<html> +<head> + <title>My Blog Post + + + + + + + + +`; + +describe('extractMeta', () => { + it('extracts OG tags into variables', () => { + const result = extractMeta(sampleHtml); + expect(result.variables.title).toBe('OG Title Override'); + expect(result.variables.description).toBe('A great article about testing.'); + expect(result.variables.author).toBe('Jane Doe'); + expect(result.variables.tag).toBe('Testing'); + expect(result.variables.siteName).toBe('My Blog'); + }); + + it('extracts og:image into images map', () => { + const result = extractMeta(sampleHtml); + expect(result.images.background).toBe('https://example.com/og.jpg'); + }); + + it('falls back to when og:title is missing', () => { + const html = '<html><head><title>Fallback Title'; + const result = extractMeta(html); + expect(result.variables.title).toBe('Fallback Title'); + }); + + it('falls back to meta description when og:description is missing', () => { + const html = ''; + const result = extractMeta(html); + expect(result.variables.description).toBe('Meta desc'); + }); + + it('handles missing meta tags gracefully', () => { + const html = ''; + const result = extractMeta(html); + expect(result.variables.title).toBe(''); + expect(result.variables.description).toBe(''); + expect(result.images).toEqual({}); + }); + + it('extracts twitter:image when og:image is missing', () => { + const html = ''; + const result = extractMeta(html); + expect(result.images.background).toBe('https://example.com/tw.jpg'); + }); +}); diff --git a/tests/engine/pdf.test.ts b/tests/engine/pdf.test.ts new file mode 100644 index 0000000..b07dc0e --- /dev/null +++ b/tests/engine/pdf.test.ts @@ -0,0 +1,67 @@ +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { beforeAll, describe, expect, it } from 'vitest'; +import { registerFonts } from '../../src/engine/fonts'; +import { renderCard } from '../../src/engine/renderer'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +beforeAll(async () => { + await registerFonts(join(__dirname, '..', '..', 'fonts')); +}); + +describe('PDF output', () => { + it('returns a valid PDF buffer', async () => { + const result = await renderCard({ + title: 'PDF Test', + description: 'Testing PDF output.', + author: 'Test', + tag: 'PDF', + format: 'og', + template: 'default', + accent: '#38ef7d', + layout: 'left', + titleSize: 48, + descSize: 22, + fontName: 'Outfit', + gradient: 'void', + bgImageBuffer: null, + overlayOpacity: 0.65, + autoFit: false, + outputFormat: 'pdf', + outputQuality: 90, + }); + + expect(result.contentType).toBe('application/pdf'); + expect(result.buffer.length).toBeGreaterThan(0); + // PDF magic bytes: %PDF + expect(result.buffer.slice(0, 5).toString()).toBe('%PDF-'); + }); + + it('PDF has valid structure (xref and trailer)', async () => { + const result = await renderCard({ + title: 'PDF Structure', + description: '', + author: '', + tag: '', + format: 'og', + template: 'default', + accent: '#38ef7d', + layout: 'left', + titleSize: 48, + descSize: 22, + fontName: 'Outfit', + gradient: 'void', + bgImageBuffer: null, + overlayOpacity: 0.65, + autoFit: false, + outputFormat: 'pdf', + outputQuality: 90, + }); + + const pdfStr = result.buffer.toString('binary'); + expect(pdfStr).toContain('xref'); + expect(pdfStr).toContain('trailer'); + expect(pdfStr).toContain('%%EOF'); + }); +}); diff --git a/tests/engine/renderer.test.ts b/tests/engine/renderer.test.ts new file mode 100644 index 0000000..84957db --- /dev/null +++ b/tests/engine/renderer.test.ts @@ -0,0 +1,192 @@ +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { beforeAll, describe, expect, it } from 'vitest'; +import { registerFonts } from '../../src/engine/fonts'; +import { type RenderOptions, renderCard } from '../../src/engine/renderer'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +beforeAll(async () => { + await registerFonts(join(__dirname, '..', '..', 'fonts')); +}); + +function defaultOptions(overrides: Partial = {}): RenderOptions { + return { + title: 'Hello, OG Engine', + description: 'Generated in ~22ms, no browser needed.', + author: 'Test Author', + tag: 'Test', + format: 'og', + template: 'default', + accent: '#38ef7d', + layout: 'left', + titleSize: 48, + descSize: 22, + fontName: 'Outfit', + gradient: 'void', + bgImageBuffer: null, + overlayOpacity: 0.65, + autoFit: false, + outputFormat: 'png', + outputQuality: 90, + variables: {}, + namedImages: {}, + ...overrides, + }; +} + +describe('renderCard', () => { + it('returns a PNG buffer for default OG format', async () => { + const result = await renderCard(defaultOptions()); + expect(result.buffer).toBeInstanceOf(Buffer); + expect(result.buffer.length).toBeGreaterThan(0); + // PNG magic bytes: 0x89 P N G + expect(result.buffer[0]).toBe(0x89); + expect(result.buffer[1]).toBe(0x50); + expect(result.buffer[2]).toBe(0x4e); + expect(result.buffer[3]).toBe(0x47); + }); + + it('returns render metadata', async () => { + const result = await renderCard(defaultOptions()); + expect(result.titleTotalLines).toBeGreaterThan(0); + expect(result.titleVisibleLines).toBeGreaterThan(0); + expect(typeof result.overflow).toBe('boolean'); + }); + + it('produces correct dimensions (1200x630 for OG)', async () => { + const result = await renderCard(defaultOptions()); + expect(result.width).toBe(1200); + expect(result.height).toBe(630); + }); + + it('renders all 5 formats without error', async () => { + for (const format of ['og', 'twitter', 'square', 'linkedin', 'story'] as const) { + const result = await renderCard(defaultOptions({ format })); + expect(result.buffer.length).toBeGreaterThan(0); + } + }); + + it('renders all 3 layouts without error', async () => { + for (const layout of ['left', 'center', 'bottom'] as const) { + const result = await renderCard(defaultOptions({ layout })); + expect(result.buffer.length).toBeGreaterThan(0); + } + }); + + it('detects overflow for extremely long title (even after auto-shrink)', async () => { + // The title renderer auto-shrinks the font to fit when possible. We need a + // title long enough that even the minimum shrunk size cannot fit within the + // format's maxTitleLines, so overflow is reported. + const result = await renderCard( + defaultOptions({ + title: + 'This is an extraordinarily long title that will certainly overflow the maximum number of lines allowed for the OG format even after auto-shrink because there is simply too much text here to possibly fit into the three lines of title text that the OG format permits regardless of how small we make the typography', + }), + ); + expect(result.overflow).toBe(true); + expect(result.titleTotalLines).toBeGreaterThan(result.titleVisibleLines); + }); + + it('handles missing optional fields', async () => { + const result = await renderCard( + defaultOptions({ + description: '', + author: '', + tag: '', + }), + ); + expect(result.buffer.length).toBeGreaterThan(0); + }); +}); + +describe('renderCard templates', () => { + it('renders social-card template', async () => { + const result = await renderCard(defaultOptions({ template: 'social-card' })); + expect(result.buffer.length).toBeGreaterThan(0); + expect(result.contentType).toBe('image/png'); + }); + + it('renders blog-hero template', async () => { + const result = await renderCard(defaultOptions({ template: 'blog-hero' })); + expect(result.buffer.length).toBeGreaterThan(0); + }); + + it('renders email-banner template', async () => { + const result = await renderCard(defaultOptions({ template: 'email-banner' })); + expect(result.buffer.length).toBeGreaterThan(0); + }); + + it('falls back to default for unknown template', async () => { + const result = await renderCard(defaultOptions({ template: 'nonexistent' })); + expect(result.buffer.length).toBeGreaterThan(0); + }); +}); + +describe('renderCard WebP output', () => { + it('returns WebP buffer when outputFormat is webp', async () => { + const result = await renderCard(defaultOptions({ outputFormat: 'webp' })); + expect(result.contentType).toBe('image/webp'); + expect(result.buffer.length).toBeGreaterThan(0); + // WebP magic: RIFF....WEBP + expect(result.buffer.slice(0, 4).toString()).toBe('RIFF'); + expect(result.buffer.slice(8, 12).toString()).toBe('WEBP'); + }); +}); + +describe('renderCard with timing', () => { + it('returns phases when timing is true', async () => { + const result = await renderCard({ + ...defaultOptions(), + timing: true, + }); + expect(result.phases).toBeDefined(); + expect(result.phases!.textMeasureMs).toBeGreaterThanOrEqual(0); + expect(result.phases!.canvasDrawMs).toBeGreaterThanOrEqual(0); + expect(result.phases!.pngEncodeMs).toBeGreaterThanOrEqual(0); + expect(result.phases!.totalMs).toBeGreaterThanOrEqual(0); + }); + + it('does not return phases when timing is false or omitted', async () => { + const result = await renderCard(defaultOptions()); + expect(result.phases).toBeUndefined(); + }); +}); + +describe('renderCard with variables', () => { + it('renders with custom variables without error', async () => { + const result = await renderCard({ + ...defaultOptions(), + variables: { price: '€129', badge: '-20%' }, + }); + expect(result.buffer.length).toBeGreaterThan(0); + }); + + it('passes variables through to custom templates', async () => { + const result = await renderCard({ + ...defaultOptions(), + variables: { headline: 'Custom Headline' }, + customTemplateDefinition: { + name: 'test-vars', + layers: [ + { type: 'fill', color: '#000000' }, + { + type: 'text', + content: '{{headline}}', + fontSize: 48, + fontWeight: 'bold', + align: 'left', + lineHeight: 1.2, + ellipsis: false, + x: 100, + y: 100, + width: 1000, + color: '#ffffff', + }, + ], + }, + template: 'custom:test-vars', + }); + expect(result.buffer.length).toBeGreaterThan(0); + }); +}); diff --git a/tests/engine/templates.test.ts b/tests/engine/templates.test.ts new file mode 100644 index 0000000..1f918a7 --- /dev/null +++ b/tests/engine/templates.test.ts @@ -0,0 +1,180 @@ +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { beforeAll, describe, expect, it } from 'vitest'; +import { registerFonts } from '../../src/engine/fonts'; +import { type RenderOptions, renderCard } from '../../src/engine/renderer'; +import { getTemplate, TEMPLATE_NAMES, TEMPLATES } from '../../src/engine/templates'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +beforeAll(async () => { + await registerFonts(join(__dirname, '..', '..', 'fonts')); +}); + +function opts(template: string): RenderOptions { + return { + title: 'Template Test Title', + description: 'A description for testing templates.', + author: 'Test Author', + tag: 'Testing', + format: 'og', + template, + accent: '#38ef7d', + layout: 'left', + titleSize: 48, + descSize: 22, + fontName: 'Outfit', + gradient: 'void', + bgImageBuffer: null, + overlayOpacity: 0.65, + autoFit: false, + outputFormat: 'png', + outputQuality: 90, + }; +} + +describe('template registry', () => { + it('exports 12 templates', () => { + expect(TEMPLATE_NAMES).toEqual([ + 'default', + 'social-card', + 'blog-hero', + 'email-banner', + 'event', + 'github-repo', + 'product-card', + 'testimonial', + 'news-article', + 'pricing', + 'profile-card', + 'announcement', + ]); + }); + + it('getTemplate returns default for unknown name', () => { + const fn = getTemplate('unknown'); + expect(fn).toBe(TEMPLATES.default); + }); +}); + +describe('each template renders correctly', () => { + for (const name of TEMPLATE_NAMES) { + it(`renders "${name}" template to valid PNG`, async () => { + const result = await renderCard(opts(name)); + expect(result.buffer.length).toBeGreaterThan(0); + expect(result.buffer[0]).toBe(0x89); // PNG + expect(result.width).toBe(1200); + expect(result.height).toBe(630); + expect(result.titleVisibleLines).toBeGreaterThan(0); + }); + } + + it('social-card reports 0 description lines', async () => { + const result = await renderCard(opts('social-card')); + expect(result.descTotalLines).toBe(0); + expect(result.descVisibleLines).toBe(0); + }); + + it('email-banner limits title to 2 lines', async () => { + const result = await renderCard({ + ...opts('email-banner'), + title: + 'This is a very long title for an email banner that should be limited to two lines maximum for a clean horizontal layout', + }); + expect(result.titleVisibleLines).toBeLessThanOrEqual(2); + }); + + it('renders event template', async () => { + const result = await renderCard({ + ...opts('event'), + variables: { date: 'June 15, 2026', location: 'Amsterdam', speaker: 'Dan Abramov' }, + }); + expect(result.buffer.length).toBeGreaterThan(0); + expect(result.buffer[0]).toBe(0x89); // PNG magic byte + expect(result.width).toBe(1200); + expect(result.height).toBe(630); + expect(result.titleVisibleLines).toBeGreaterThan(0); + expect(result.descTotalLines).toBe(0); + expect(result.overflow).toBe(false); + }); + + it('renders github-repo template', async () => { + const result = await renderCard({ + ...opts('github-repo'), + title: 'vercel/next.js', + variables: { owner: 'vercel', stars: '12.4k', language: 'TypeScript' }, + }); + expect(result.buffer.length).toBeGreaterThan(0); + expect(result.buffer[0]).toBe(0x89); // PNG magic byte + expect(result.width).toBe(1200); + expect(result.height).toBe(630); + expect(result.titleVisibleLines).toBeGreaterThan(0); + }); + + it('renders product-card template', async () => { + const result = await renderCard({ + ...opts('product-card'), + title: 'Air Max 270', + variables: { price: '€129', badge: '-20%', brand: 'Nike' }, + }); + expect(result.buffer.length).toBeGreaterThan(0); + expect(result.buffer[0]).toBe(0x89); // PNG magic byte + expect(result.width).toBe(1200); + expect(result.height).toBe(630); + expect(result.titleVisibleLines).toBeGreaterThan(0); + expect(result.descTotalLines).toBe(0); + expect(result.descVisibleLines).toBe(0); + }); + + it('renders testimonial template', async () => { + const result = await renderCard({ + ...opts('testimonial'), + variables: { quote: 'This changed our workflow.', name: 'Jane Doe', company: 'Acme Corp', role: 'CTO' }, + }); + expect(result.buffer.length).toBeGreaterThan(0); + expect(result.buffer[0]).toBe(0x89); // PNG magic byte + expect(result.width).toBe(1200); + expect(result.height).toBe(630); + expect(result.titleVisibleLines).toBeGreaterThan(0); + expect(result.descTotalLines).toBe(0); + expect(result.descVisibleLines).toBe(0); + }); + + it('renders news-article template', async () => { + const result = await renderCard({ + ...opts('news-article'), + variables: { source: 'TechCrunch', date: 'April 10, 2026', category: 'AI' }, + }); + expect(result.buffer.length).toBeGreaterThan(0); + }); + + it('renders pricing template', async () => { + const result = await renderCard({ + ...opts('pricing'), + variables: { + plan: 'Pro', + price: '€39', + period: '/mo', + features: 'Unlimited renders,Priority support,Custom templates', + cta: 'Start Free Trial', + }, + }); + expect(result.buffer.length).toBeGreaterThan(0); + }); + + it('renders profile-card template', async () => { + const result = await renderCard({ + ...opts('profile-card'), + variables: { name: 'Jane Doe', role: 'CTO', company: 'Acme Corp', bio: 'Building the future of tech.' }, + }); + expect(result.buffer.length).toBeGreaterThan(0); + }); + + it('renders announcement template', async () => { + const result = await renderCard({ + ...opts('announcement'), + variables: { subtitle: 'The fastest image API just got faster.', cta: 'Try It Free' }, + }); + expect(result.buffer.length).toBeGreaterThan(0); + }); +}); diff --git a/tests/engine/text-measure.test.ts b/tests/engine/text-measure.test.ts new file mode 100644 index 0000000..6df9180 --- /dev/null +++ b/tests/engine/text-measure.test.ts @@ -0,0 +1,91 @@ +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { registerFonts } from '../../src/engine/fonts'; +import { clearMeasureCache, getMeasureCacheStats, measureLines, measureTextWidth } from '../../src/engine/text-measure'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +beforeAll(async () => { + await registerFonts(join(__dirname, '..', '..', 'fonts')); +}); + +beforeEach(() => { + clearMeasureCache(); +}); + +describe('measureLines', () => { + it('returns empty array for empty text', () => { + expect(measureLines('', '400 48px Outfit', 800)).toEqual([]); + }); + + it('returns single line for short text', () => { + const lines = measureLines('Hello', '400 48px Outfit', 800); + expect(lines).toHaveLength(1); + expect(lines[0].text).toBe('Hello'); + expect(lines[0].width).toBeGreaterThan(0); + }); + + it('wraps long text into multiple lines', () => { + const longText = 'This is a much longer title that should definitely wrap onto multiple lines when rendered'; + const lines = measureLines(longText, '800 48px Outfit', 600); + expect(lines.length).toBeGreaterThan(1); + const reconstructed = lines.map((l) => l.text).join(' '); + expect(reconstructed).toBe(longText); + }); + + it('handles explicit newlines', () => { + const lines = measureLines('Line one\nLine two', '400 48px Outfit', 800); + expect(lines.length).toBeGreaterThanOrEqual(2); + expect(lines[0].text).toBe('Line one'); + expect(lines[1].text).toBe('Line two'); + }); +}); + +describe('measureTextWidth', () => { + it('returns a positive number for non-empty text', () => { + const w = measureTextWidth('Hello', '400 48px Outfit'); + expect(w).toBeGreaterThan(0); + }); + + it('returns 0 for empty text', () => { + const w = measureTextWidth('', '400 48px Outfit'); + expect(w).toBe(0); + }); +}); + +describe('LRU cache hit rate', () => { + it('reports zero hits on first call', () => { + measureLines('Hello world', '400 48px Outfit', 800); + const stats = getMeasureCacheStats(); + expect(stats.hits).toBe(0); + expect(stats.misses).toBe(1); + expect(stats.hitRate).toBe(0); + }); + + it('reports 100% hit rate on repeated identical call', () => { + measureLines('Hello world', '400 48px Outfit', 800); + measureLines('Hello world', '400 48px Outfit', 800); + const stats = getMeasureCacheStats(); + expect(stats.hits).toBe(1); + expect(stats.misses).toBe(1); + expect(stats.hitRate).toBe(50); + }); + + it('cache is faster on repeated calls', () => { + const text = 'Benchmarking LRU cache performance for repeated text measurement calls'; + const font = '700 48px Outfit'; + const maxWidth = 1000; + const iterations = 500; + + const t0 = performance.now(); + for (let i = 0; i < iterations; i++) measureLines(text, font, maxWidth); + const elapsed = performance.now() - t0; + + const stats = getMeasureCacheStats(); + // After first call all subsequent are cache hits — hit rate should be > 99% + expect(stats.hitRate).toBeGreaterThan(99); + // 500 cached lookups should complete well under 100ms + expect(elapsed).toBeLessThan(100); + }); +}); diff --git a/tests/middleware/auth.test.ts b/tests/middleware/auth.test.ts new file mode 100644 index 0000000..9bf6b04 --- /dev/null +++ b/tests/middleware/auth.test.ts @@ -0,0 +1,150 @@ +import { Hono } from 'hono'; +import { afterAll, beforeEach, describe, expect, it } from 'vitest'; +import { type ApiKeyRecord, closeDb, createApiKey, updatePlan } from '../../src/db'; +import { authMiddleware, canAccessFeature, optionalAuthMiddleware, planGate } from '../../src/middleware/auth'; + +beforeEach(() => { + closeDb(); + process.env.DATABASE_URL = 'file::memory:'; +}); + +afterAll(() => { + closeDb(); + delete process.env.DATABASE_URL; +}); + +describe('authMiddleware', () => { + function createApp() { + const app = new Hono(); + app.use('/protected', authMiddleware()); + app.get('/protected', (c) => { + const key = (c as any).get('apiKey') as ApiKeyRecord; + return c.json({ email: key.email }); + }); + return app; + } + + it('rejects request without auth header', async () => { + const app = createApp(); + const res = await app.request('/protected'); + expect(res.status).toBe(401); + const body = await res.json(); + expect(body.error).toBe('unauthorized'); + }); + + it('rejects request with invalid key', async () => { + const app = createApp(); + const res = await app.request('/protected', { + headers: { Authorization: 'Bearer oge_sk_invalid' }, + }); + expect(res.status).toBe(401); + }); + + it('accepts request with valid key', async () => { + const record = createApiKey('auth@example.com'); + const app = createApp(); + const res = await app.request('/protected', { + headers: { Authorization: `Bearer ${record.key}` }, + }); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.email).toBe('auth@example.com'); + }); + + it('rejects when quota exceeded', async () => { + const record = createApiKey('quota@example.com'); + // Exhaust quota by setting calls_used = calls_limit directly + const { getDb } = await import('../../src/db'); + getDb().prepare('UPDATE api_keys SET calls_used = calls_limit WHERE id = ?').run(record.id); + + const app = createApp(); + const res = await app.request('/protected', { + headers: { Authorization: `Bearer ${record.key}` }, + }); + expect(res.status).toBe(429); + const body = await res.json(); + expect(body.error).toBe('quota_exceeded'); + }); +}); + +describe('optionalAuthMiddleware', () => { + function createApp() { + const app = new Hono(); + app.use('/optional', optionalAuthMiddleware()); + app.get('/optional', (c) => { + const key = (c as any).get('apiKey') as ApiKeyRecord | undefined; + return c.json({ authenticated: !!key }); + }); + return app; + } + + it('allows unauthenticated requests', async () => { + const app = createApp(); + const res = await app.request('/optional'); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.authenticated).toBe(false); + }); + + it('attaches key when provided', async () => { + const record = createApiKey('opt@example.com'); + const app = createApp(); + const res = await app.request('/optional', { + headers: { Authorization: `Bearer ${record.key}` }, + }); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.authenticated).toBe(true); + }); +}); + +describe('planGate', () => { + it('returns 402 when plan insufficient', async () => { + const record = createApiKey('gate@example.com'); // free plan + const app = new Hono(); + app.use('/gated', authMiddleware(), planGate('batch')); + app.get('/gated', (c) => c.text('ok')); + + const res = await app.request('/gated', { + headers: { Authorization: `Bearer ${record.key}` }, + }); + expect(res.status).toBe(402); + const body = await res.json(); + expect(body.error).toBe('plan_required'); + }); + + it('allows when plan is sufficient', async () => { + const record = createApiKey('pro@example.com'); + updatePlan(record.id, 'pro'); + const app = new Hono(); + app.use('/gated', authMiddleware(), planGate('batch')); + app.get('/gated', (c) => c.text('ok')); + + const res = await app.request('/gated', { + headers: { Authorization: `Bearer ${record.key}` }, + }); + expect(res.status).toBe(200); + }); +}); + +describe('canAccessFeature', () => { + it('free plan cannot access webp', () => { + expect(canAccessFeature('free', 'webp')).toBe(false); + }); + + it('starter plan can access webp', () => { + expect(canAccessFeature('starter', 'webp')).toBe(true); + }); + + it('free plan cannot access batch', () => { + expect(canAccessFeature('free', 'batch')).toBe(false); + }); + + it('pro plan can access batch', () => { + expect(canAccessFeature('pro', 'batch')).toBe(true); + }); + + it('all plans can access ungated features', () => { + expect(canAccessFeature('free', 'nonexistent')).toBe(true); + }); +}); diff --git a/tests/middleware/rate-limit.test.ts b/tests/middleware/rate-limit.test.ts new file mode 100644 index 0000000..8e4ab90 --- /dev/null +++ b/tests/middleware/rate-limit.test.ts @@ -0,0 +1,40 @@ +import { Hono } from 'hono'; +import { describe, expect, it } from 'vitest'; +import { rateLimit } from '../../src/middleware/rate-limit'; + +describe('rateLimit middleware', () => { + it('allows requests within the limit', async () => { + const app = new Hono(); + app.use('*', rateLimit({ windowMs: 60_000, max: 5 })); + app.get('/test', (c) => c.text('ok')); + + const res = await app.request('/test'); + expect(res.status).toBe(200); + expect(res.headers.get('X-RateLimit-Limit')).toBe('5'); + expect(res.headers.get('X-RateLimit-Remaining')).toBe('4'); + }); + + it('returns 429 when limit is exceeded', async () => { + const app = new Hono(); + app.use('*', rateLimit({ windowMs: 60_000, max: 2 })); + app.get('/test', (c) => c.text('ok')); + + await app.request('/test'); + await app.request('/test'); + const res = await app.request('/test'); + + expect(res.status).toBe(429); + const body = await res.json(); + expect(body.error).toBe('rate_limit_exceeded'); + }); + + it('sets rate limit headers', async () => { + const app = new Hono(); + app.use('*', rateLimit({ windowMs: 60_000, max: 10 })); + app.get('/test', (c) => c.text('ok')); + + const res = await app.request('/test'); + expect(res.headers.get('X-RateLimit-Limit')).toBe('10'); + expect(res.headers.get('X-RateLimit-Reset')).toBeTruthy(); + }); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..6ba5ff3 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "outDir": "dist", + "rootDir": ".", + "declaration": true, + "types": ["bun-types"] + }, + "include": ["src/**/*.ts", "tests/**/*.ts", "scripts/**/*.ts"] +} diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..e3a223c --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['tests/**/*.test.ts'], + globals: false, + testTimeout: 10000, + }, +});