9e45780ba0
First commit of the Next.js community site that powers deepseek-tui.com, deployed via Cloudflare Workers / OpenNext. This commit lands the scaffold and applies the visual + correctness pass requested by community feedback: - Palette: drop the cream/Anthropic-feel paper (#F4F1E8) for a DeepSeek-aligned cool white + soft gray (#FFFFFF / #F4F6FB), with indigo accents kept. Soften default hairlines so a pure-white background reads clean instead of harsh. - Mobile: add a hamburger menu (mobile-menu.tsx) so phones can reach Install / Docs / Activity / Roadmap / Contribute — previously the link list was hidden on phones with no replacement. Tighter hero, flexible button row, viewport-safe code blocks, columnar grids collapse cleanly under 768px, and the printed-almanac center rule is desktop-only now (it sliced through narrow viewports). - "How it works" diagram: replace the hand-rolled ASCII art (which misaligned under CJK monospace because Han characters take 2 columns vs Latin's 1, per dhh's note in WeChat) with a real mermaid diagram rendered client-side via dynamic import. Uses the mermaid.live standard syntax 庄表伟 recommended. - Issue #1104: the docs listed a `deepseek-cn` provider that the v0.8.16 binary doesn't accept (`ProviderArg` in crates/cli only has 9 variants; the 10th lives only in the legacy tui/config.rs). derive-facts.mjs now omits `deepseek-cn` until that variant is wired through the shared ProviderKind, and the install page's China-network recipe uses `base_url` / `DEEPSEEK_BASE_URL` (which actually works on v0.8.16) instead of the unsupported provider. Auto-deploys via .github/workflows/deploy-web.yml on push to main. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
135 lines
4.7 KiB
TypeScript
135 lines
4.7 KiB
TypeScript
/**
|
|
* roadmap-feed.ts — fetch the live roadmap from GitHub.
|
|
*
|
|
* "Shipped" ← last 8 published Releases on Hmbown/deepseek-tui
|
|
* "Underway" ← open issues with label `roadmap:underway`
|
|
* "Considered" ← open issues with label `roadmap:considered`
|
|
* "Ruled out" ← issues (open or closed) with label `roadmap:ruled-out`
|
|
*
|
|
* Cached in CURATED_KV under `roadmap:feed` with a 30-minute TTL so the
|
|
* roadmap page renders fast and the GH rate limit never matters.
|
|
*
|
|
* Categories that come back empty fall through to the page's static items —
|
|
* the maintainer can adopt label-driven roadmap incrementally.
|
|
*/
|
|
const REPO = "Hmbown/deepseek-tui";
|
|
const KV_KEY = "roadmap:feed";
|
|
const KV_TTL = 60 * 30;
|
|
|
|
export interface RoadmapItem {
|
|
title: string;
|
|
note: string;
|
|
href?: string;
|
|
number?: number;
|
|
}
|
|
|
|
export interface RoadmapFeed {
|
|
generatedAt: string;
|
|
shipped: RoadmapItem[];
|
|
underway: RoadmapItem[];
|
|
considered: RoadmapItem[];
|
|
ruledOut: RoadmapItem[];
|
|
}
|
|
|
|
interface KVNamespace {
|
|
get(k: string): Promise<string | null>;
|
|
put(k: string, v: string, o?: { expirationTtl?: number }): Promise<void>;
|
|
}
|
|
|
|
async function gh<T>(url: string, ghToken?: string): Promise<T | null> {
|
|
const headers: Record<string, string> = {
|
|
Accept: "application/vnd.github+json",
|
|
"User-Agent": "deepseek-tui-web-roadmap",
|
|
"X-GitHub-Api-Version": "2022-11-28",
|
|
};
|
|
if (ghToken) headers["Authorization"] = `Bearer ${ghToken}`;
|
|
try {
|
|
const r = await fetch(url, { headers });
|
|
if (!r.ok) return null;
|
|
return (await r.json()) as T;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
interface GhRelease { tag_name: string; name: string | null; body: string | null; html_url: string; prerelease: boolean; draft: boolean }
|
|
interface GhIssue { number: number; title: string; html_url: string; body: string | null; state: string; pull_request?: unknown }
|
|
|
|
function summarizeReleaseBody(body: string | null): string {
|
|
if (!body) return "";
|
|
// First non-empty line, stripped of markdown headers / bullets / links
|
|
const lines = body.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
|
|
const candidate = lines.find((l) => !l.startsWith("#") && !l.startsWith("---") && l.length > 8);
|
|
if (!candidate) return "";
|
|
// Strip bullets, trailing emoji, links, and cap length
|
|
const stripped = candidate.replace(/^[*\-•]\s+/, "").replace(/\[([^\]]+)\]\([^)]+\)/g, "$1").trim();
|
|
return stripped.length > 140 ? stripped.slice(0, 137) + "…" : stripped;
|
|
}
|
|
|
|
function summarizeIssueBody(body: string | null): string {
|
|
if (!body) return "";
|
|
// Issue bodies are often very long; take the first non-empty paragraph (up to ~140 chars)
|
|
const para = body.split(/\r?\n\r?\n/).map((p) => p.trim()).find((p) => p.length > 0) ?? "";
|
|
const stripped = para
|
|
.replace(/^[#>*\-\s]+/, "")
|
|
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
|
|
.replace(/\s+/g, " ")
|
|
.trim();
|
|
return stripped.length > 140 ? stripped.slice(0, 137) + "…" : stripped;
|
|
}
|
|
|
|
async function fetchByLabel(label: string, ghToken?: string, state: "open" | "closed" | "all" = "open"): Promise<RoadmapItem[]> {
|
|
const url = `https://api.github.com/repos/${REPO}/issues?state=${state}&labels=${encodeURIComponent(label)}&per_page=10&sort=updated`;
|
|
const issues = await gh<GhIssue[]>(url, ghToken);
|
|
if (!issues) return [];
|
|
return issues
|
|
.filter((i) => !i.pull_request) // skip PRs
|
|
.map((i) => ({
|
|
title: i.title,
|
|
note: summarizeIssueBody(i.body) || `Issue #${i.number}`,
|
|
href: i.html_url,
|
|
number: i.number,
|
|
}));
|
|
}
|
|
|
|
export async function fetchRoadmap(ghToken?: string): Promise<RoadmapFeed> {
|
|
const [releases, underway, considered, ruledOut] = await Promise.all([
|
|
gh<GhRelease[]>(`https://api.github.com/repos/${REPO}/releases?per_page=8`, ghToken),
|
|
fetchByLabel("roadmap:underway", ghToken, "open"),
|
|
fetchByLabel("roadmap:considered", ghToken, "open"),
|
|
fetchByLabel("roadmap:ruled-out", ghToken, "all"),
|
|
]);
|
|
|
|
const shipped: RoadmapItem[] = (releases ?? [])
|
|
.filter((r) => !r.draft)
|
|
.map((r) => ({
|
|
title: r.name?.trim() || r.tag_name,
|
|
note: summarizeReleaseBody(r.body) || r.tag_name,
|
|
href: r.html_url,
|
|
}));
|
|
|
|
return {
|
|
generatedAt: new Date().toISOString(),
|
|
shipped,
|
|
underway,
|
|
considered,
|
|
ruledOut,
|
|
};
|
|
}
|
|
|
|
export async function getCachedRoadmap(kv: KVNamespace | undefined, ghToken: string | undefined): Promise<RoadmapFeed | null> {
|
|
try {
|
|
if (kv) {
|
|
const cached = await kv.get(KV_KEY);
|
|
if (cached) return JSON.parse(cached) as RoadmapFeed;
|
|
}
|
|
const fresh = await fetchRoadmap(ghToken);
|
|
if (kv) {
|
|
await kv.put(KV_KEY, JSON.stringify(fresh), { expirationTtl: KV_TTL });
|
|
}
|
|
return fresh;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|