Files
manavarya09__design-extract/website/lib/cache.js
T
Manav Arya Singh cd58dcaece feat(v12): chat REPL + permalinks + gallery + spec + distribution prep
The "best in the game" release: takes designlang from one-shot extractor
to iterative + shareable + standardized + distributed.

ITERATIVE — designlang chat
- src/chat.js: REPL over a live extraction. Heuristic-only router
  parses natural-ish English ("make it brutalist", "primary #ff4800",
  "sharpen radii", "dark mode") into structured operations on the
  design object. Re-derives DTCG / Tailwind / CSS vars / DESIGN.md
  on `save`. Loads from URL or existing *-design-tokens.json.
- bin: new `chat <target>` subcommand. ~360 lines, zero new deps.

SHAREABLE — permalinks
- website/lib/cache.js: getCachedByHash + listRecent helpers over
  the existing Blob cache (24h TTL), no new infra.
- website/lib/build-files.js: shared file-builder used by both
  /api/extract (streaming) and /x/[hash] (permalink page).
- website/app/x/[hash]/page.js + PermalinkViewer.js: every cached
  extraction is now a stable URL. Stat strip + Copy permalink CTA +
  the same 12-tab ResultViewer.
- /api/extract emits a `permalink` event up front; HeroExtractor
  rewrites the URL bar via history.replaceState so refresh-and-share
  works mid-stream.
- website/app/gallery/page.js: public directory of recent extractions,
  palette-strip cards, relative timestamps, intent + material + library
  signals. ISR-cached every 10 min.

STANDARDIZED — DESIGN.md spec
- website/app/spec/page.js: formal spec doc. Eight canonical sections,
  YAML front-matter schema, reference implementation pointer, CC BY 4.0
  license, credit to design-extractor.com for pioneering the format.
- website/public/badge.svg: embeddable Verified badge (paper + ink + accent).
- Sitemap + robots updated with /spec, /gallery.

DISTRIBUTED — submission playbook
- marketplace/SUBMISSION-PLAYBOOK.md: ordered list of 6 marketplaces
  with click-time estimates and review-queue waits.
- marketplace/{figma,cursor,vscode,claude-code-skill,raycast,chrome}-listing.md:
  pre-written listing copy, manifests, screenshot briefs, tweet drafts
  for each. Click "submit" tomorrow morning; nothing left to write.

Home nav: Gallery + Spec links added; v11 → v12 label.
Tests: 317/317 pass.
2026-04-26 21:55:00 +04:00

135 lines
4.6 KiB
JavaScript

// Vercel Blob-backed cache for extraction payloads.
//
// TTL: 24h, enforced on read by checking `generatedAt` in the stored payload.
// Key: normalized URL → SHA-256 hex.
// Gracefully no-ops when BLOB_READ_WRITE_TOKEN is absent (local dev).
import { createHash } from 'node:crypto';
const TTL_MS = 24 * 60 * 60 * 1000;
let warnedMissingToken = false;
export function cacheKey(url) {
const normalized = String(url).trim().toLowerCase();
return createHash('sha256').update(normalized).digest('hex');
}
function hasBlob() {
if (!process.env.BLOB_READ_WRITE_TOKEN) {
if (!warnedMissingToken) {
console.log('[cache] BLOB_READ_WRITE_TOKEN not set — skipping cache');
warnedMissingToken = true;
}
return false;
}
return true;
}
function blobPath(key) {
return `extract-cache/${key}.json`;
}
export async function getCached(key) {
if (!hasBlob()) return null;
try {
const { list } = await import('@vercel/blob');
const path = blobPath(key);
const result = await list({ prefix: path, limit: 1 });
const blob = result.blobs?.find((b) => b.pathname === path);
if (!blob) return null;
const res = await fetch(blob.url, { cache: 'no-store' });
if (!res.ok) return null;
const payload = await res.json();
const generatedAt = typeof payload?.generatedAt === 'number' ? payload.generatedAt : 0;
if (Date.now() - generatedAt > TTL_MS) return null;
if (!payload?.design) return null;
return { design: payload.design };
} catch (err) {
console.error('[cache] read failed', err?.message);
return null;
}
}
export async function putCached(key, { design }) {
if (!hasBlob()) return;
try {
const { put } = await import('@vercel/blob');
const payload = {
generatedAt: Date.now(),
expiresAt: Date.now() + TTL_MS,
design,
};
await put(blobPath(key), JSON.stringify(payload), {
access: 'public',
contentType: 'application/json',
addRandomSuffix: false,
allowOverwrite: true,
});
} catch (err) {
console.error('[cache] write failed', err?.message);
}
}
// ─── Public permalink helpers — used by /x/[hash] and /gallery ───
//
// Permalinks are forever (well, 24h while the Blob entry exists). The page
// re-derives files from the cached `design` object on each request — same
// pipeline the streaming /api/extract uses on a cache hit.
export async function getCachedByHash(hash) {
if (!/^[a-f0-9]{64}$/.test(hash)) return null;
const cached = await getCached(hash);
return cached;
}
export async function listRecent(limit = 24) {
if (!hasBlob()) return [];
try {
const { list } = await import('@vercel/blob');
// Pull more than we need so we can drop expired ones.
const result = await list({ prefix: 'extract-cache/', limit: limit * 2 });
const entries = await Promise.all(
(result.blobs || [])
.sort((a, b) => new Date(b.uploadedAt).getTime() - new Date(a.uploadedAt).getTime())
.slice(0, limit * 2)
.map(async (blob) => {
try {
const m = blob.pathname.match(/^extract-cache\/([a-f0-9]{64})\.json$/);
if (!m) return null;
const res = await fetch(blob.url, { cache: 'no-store' });
if (!res.ok) return null;
const payload = await res.json();
const generatedAt = typeof payload?.generatedAt === 'number' ? payload.generatedAt : 0;
if (Date.now() - generatedAt > TTL_MS) return null;
const design = payload?.design;
if (!design) return null;
return {
hash: m[1],
url: design.meta?.url || '',
title: design.meta?.title || '',
generatedAt,
colors: design.colors?.all?.length ?? 0,
primary: design.colors?.primary?.hex || null,
secondary: design.colors?.secondary?.hex || null,
accent: design.colors?.accent?.hex || null,
foreground: design.colors?.text?.[0] || null,
background: design.colors?.backgrounds?.[0] || null,
fontFamily: design.typography?.families?.[0]?.name || null,
intent: design.pageIntent?.type || null,
material: design.materialLanguage?.label || null,
library: design.componentLibrary?.library || null,
score: design.score?.overall ?? null,
grade: design.score?.grade ?? null,
};
} catch { return null; }
})
);
return entries.filter(Boolean).slice(0, limit);
} catch (err) {
console.error('[cache] listRecent failed', err?.message);
return [];
}
}