mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-04 07:19:52 +00:00
feat(cli): add remote template fetching via giget (#162)
* feat(cli): add remote template fetching via giget * fix: update remote.ts to use templates/ instead of examples/ * fix(cli): validate template ID against manifest before downloading Fails fast with available template list instead of downloading an empty directory for nonexistent templates. * refactor(cli): simplify to single --template flag with dynamic validation - Remove --example flag (--template handles bundled + remote) - Remove static ALL_TEMPLATE_IDS list (validates against GitHub manifest) - No CLI release needed to add new templates — just add to templates/ and templates.json - scaffoldProject auto-detects bundled vs remote * chore: update lockfiles for giget dependency * fix(cli): remove undefined isAudioOnly reference
This commit is contained in:
@@ -1,32 +1,48 @@
|
||||
export type TemplateId =
|
||||
| "blank"
|
||||
| "warm-grain"
|
||||
| "play-mode"
|
||||
| "swiss-grid"
|
||||
| "vignelli"
|
||||
| "decision-tree"
|
||||
| "kinetic-type"
|
||||
| "product-promo"
|
||||
| "nyt-graph";
|
||||
import { listRemoteTemplates, type RemoteTemplateInfo } from "./remote.js";
|
||||
|
||||
export type TemplateSource = "bundled" | "remote";
|
||||
|
||||
export interface TemplateOption {
|
||||
id: TemplateId;
|
||||
id: string;
|
||||
label: string;
|
||||
hint: string;
|
||||
source: TemplateSource;
|
||||
}
|
||||
|
||||
export const TEMPLATES: TemplateOption[] = [
|
||||
{ id: "blank", label: "Blank", hint: "Empty composition — just the scaffolding" },
|
||||
{ id: "warm-grain", label: "Warm Grain", hint: "Cream aesthetic with grain texture" },
|
||||
{ id: "play-mode", label: "Play Mode", hint: "Playful elastic animations" },
|
||||
{ id: "swiss-grid", label: "Swiss Grid", hint: "Structured grid layout" },
|
||||
{ id: "vignelli", label: "Vignelli", hint: "Bold typography with red accents" },
|
||||
{ id: "decision-tree", label: "Decision Tree", hint: "Animated flowchart with branching paths" },
|
||||
{ id: "kinetic-type", label: "Kinetic Type", hint: "Bold kinetic typography promo" },
|
||||
/** Templates bundled in the CLI package (available offline). */
|
||||
export const BUNDLED_TEMPLATES: TemplateOption[] = [
|
||||
{
|
||||
id: "product-promo",
|
||||
label: "Product Promo",
|
||||
hint: "Multi-scene product showcase with SVG assets",
|
||||
id: "blank",
|
||||
label: "Blank",
|
||||
hint: "Empty composition — just the scaffolding",
|
||||
source: "bundled",
|
||||
},
|
||||
{ id: "nyt-graph", label: "NYT Graph", hint: "Animated data chart in print editorial style" },
|
||||
];
|
||||
|
||||
/**
|
||||
* Resolve the full template list by merging bundled and remote templates.
|
||||
* Fetches templates.json from GitHub (cached 24h). No CLI release needed to add templates.
|
||||
* If offline, returns only bundled templates.
|
||||
*/
|
||||
export async function resolveTemplateList(): Promise<TemplateOption[]> {
|
||||
const bundled = [...BUNDLED_TEMPLATES];
|
||||
const bundledIds = new Set(bundled.map((t) => t.id));
|
||||
|
||||
let remote: RemoteTemplateInfo[] = [];
|
||||
try {
|
||||
remote = await listRemoteTemplates();
|
||||
} catch {
|
||||
// Offline — return bundled only
|
||||
}
|
||||
|
||||
const remoteOptions: TemplateOption[] = remote
|
||||
.filter((r) => !r.bundled && !bundledIds.has(r.id))
|
||||
.map((r) => ({
|
||||
id: r.id,
|
||||
label: r.label,
|
||||
hint: r.hint,
|
||||
source: "remote" as const,
|
||||
}));
|
||||
|
||||
return [...bundled, ...remoteOptions];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Remote Template Fetching
|
||||
*
|
||||
* Downloads templates from the hyperframes GitHub repository using giget.
|
||||
* Templates live in the `templates/` directory of the repo.
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { homedir } from "node:os";
|
||||
|
||||
const REPO = "heygen-com/hyperframes";
|
||||
const TEMPLATES_DIR = "templates";
|
||||
const MANIFEST_FILENAME = "templates.json";
|
||||
|
||||
/** Cache directory for remote template metadata. */
|
||||
const CACHE_DIR = join(homedir(), ".hyperframes", "cache");
|
||||
const MANIFEST_CACHE_PATH = join(CACHE_DIR, "remote-templates.json");
|
||||
const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
||||
|
||||
export interface RemoteTemplateInfo {
|
||||
id: string;
|
||||
label: string;
|
||||
hint: string;
|
||||
bundled: boolean;
|
||||
}
|
||||
|
||||
interface ManifestCache {
|
||||
fetchedAt: number;
|
||||
templates: RemoteTemplateInfo[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the remote template manifest from GitHub.
|
||||
* Caches the result for 24 hours to avoid rate limits.
|
||||
*/
|
||||
export async function listRemoteTemplates(): Promise<RemoteTemplateInfo[]> {
|
||||
// Check cache first
|
||||
if (existsSync(MANIFEST_CACHE_PATH)) {
|
||||
try {
|
||||
const cached: ManifestCache = JSON.parse(readFileSync(MANIFEST_CACHE_PATH, "utf-8"));
|
||||
if (Date.now() - cached.fetchedAt < CACHE_TTL_MS) {
|
||||
return cached.templates;
|
||||
}
|
||||
} catch {
|
||||
// Cache corrupt — refetch
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch from GitHub raw content
|
||||
const url = `https://raw.githubusercontent.com/${REPO}/main/${TEMPLATES_DIR}/${MANIFEST_FILENAME}`;
|
||||
try {
|
||||
const res = await fetch(url, { signal: AbortSignal.timeout(5_000) });
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP ${res.status}`);
|
||||
}
|
||||
const data = (await res.json()) as { templates: RemoteTemplateInfo[] };
|
||||
const templates = data.templates;
|
||||
|
||||
// Write cache
|
||||
mkdirSync(CACHE_DIR, { recursive: true });
|
||||
const cache: ManifestCache = { fetchedAt: Date.now(), templates };
|
||||
writeFileSync(MANIFEST_CACHE_PATH, JSON.stringify(cache), "utf-8");
|
||||
|
||||
return templates;
|
||||
} catch {
|
||||
// Offline or rate-limited — return empty (caller should fall back to bundled)
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Download a template from GitHub into destDir using giget.
|
||||
* Fetches from `examples/<templateId>` in the hyperframes repo.
|
||||
*/
|
||||
export async function fetchRemoteTemplate(
|
||||
templateId: string,
|
||||
destDir: string,
|
||||
options?: { ref?: string },
|
||||
): Promise<void> {
|
||||
// Validate against manifest before downloading
|
||||
const known = await listRemoteTemplates();
|
||||
if (known.length > 0 && !known.some((t) => t.id === templateId)) {
|
||||
const available = known.map((t) => t.id).join(", ");
|
||||
throw new Error(`Template "${templateId}" not found. Available: ${available}`);
|
||||
}
|
||||
|
||||
const { downloadTemplate } = await import("giget");
|
||||
const ref = options?.ref ?? "main";
|
||||
const source = `github:${REPO}/${TEMPLATES_DIR}/${templateId}#${ref}`;
|
||||
|
||||
await downloadTemplate(source, {
|
||||
dir: destDir,
|
||||
force: true,
|
||||
});
|
||||
|
||||
// Safety check — giget can succeed with empty dir if path doesn't exist
|
||||
if (!existsSync(join(destDir, "index.html"))) {
|
||||
throw new Error(
|
||||
`Template "${templateId}" downloaded but missing index.html. The template may be malformed.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user