mirror of
https://github.com/Manavarya09/design-extract.git
synced 2026-09-19 02:41:14 +08:00
cd58dcaece
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.
135 lines
4.6 KiB
JavaScript
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 [];
|
|
}
|
|
}
|