Initial import of the guizang-ppt-skill project: add SKILL.md, CONTRIBUTING.md, LICENSE (MIT), populated README.md and README.en.md, references (layouts, themes, prompts, checklist, etc.), templates and screenshot background assets, motion.min.js, and helper scripts (validate-swiss-deck.mjs, sync-htmlslide.mjs). Sets up templates, theme presets, image/screenshot guidance, and tooling to generate, validate, and sync single-file HTML slide decks.
365 lines
12 KiB
JavaScript
365 lines
12 KiB
JavaScript
#!/usr/bin/env node
|
|
import { existsSync, mkdirSync, readFileSync, writeFileSync, watch } from "node:fs";
|
|
import { homedir } from "node:os";
|
|
import { basename, dirname, extname, isAbsolute, join, normalize, relative, resolve, sep } from "node:path";
|
|
|
|
const DEFAULT_API_BASE = "http://127.0.0.1:3001/api";
|
|
const DEFAULT_APP_BASE = "http://127.0.0.1:5173";
|
|
const SIDECAR_NAME = ".htmlslide-sync.json";
|
|
const CONFIG_DIR = join(homedir(), ".htmlslide-agent");
|
|
const CONFIG_PATH = join(CONFIG_DIR, "config.json");
|
|
|
|
const parsed = parseArgs(process.argv.slice(2));
|
|
const command = parsed.command || "push";
|
|
|
|
if (command === "login") {
|
|
await login(parsed).catch(fail);
|
|
} else if (command === "list") {
|
|
await listProjects(parsed).catch(fail);
|
|
} else if (command === "pull") {
|
|
await pullProject(parsed).catch(fail);
|
|
} else if (command === "push") {
|
|
await pushProject(parsed).catch(fail);
|
|
} else {
|
|
usage(`Unknown command: ${command}`);
|
|
}
|
|
|
|
async function login(args) {
|
|
const config = readConfig();
|
|
const apiBase = stripTrailingSlash(args.apiBase || process.env.HTMLSLIDE_API_BASE || config.apiBase || DEFAULT_API_BASE);
|
|
const appBase = stripTrailingSlash(args.appBase || process.env.HTMLSLIDE_APP_BASE || config.appBase || DEFAULT_APP_BASE);
|
|
const deviceName = args.deviceName || `${process.env.COMPUTERNAME || process.env.HOSTNAME || "Local"} Agent`;
|
|
|
|
const start = await apiJson(`${apiBase}/agent/oauth/start`, {
|
|
method: "POST",
|
|
body: { deviceName }
|
|
});
|
|
|
|
const url = `${appBase}${start.verificationUriComplete || `/agent/authorize?code=${encodeURIComponent(start.userCode)}`}`;
|
|
console.log("Open this URL in HTMLSlide while logged in:");
|
|
console.log(url);
|
|
console.log(`Authorization code: ${start.userCode}`);
|
|
console.log("Waiting for approval...");
|
|
|
|
const deadline = Date.now() + start.expiresIn * 1000;
|
|
while (Date.now() < deadline) {
|
|
await sleep((start.interval || 2) * 1000);
|
|
const tokenRes = await apiJson(`${apiBase}/agent/oauth/token`, {
|
|
method: "POST",
|
|
body: { deviceCode: start.deviceCode },
|
|
soft: true
|
|
});
|
|
if (tokenRes.ok === false) continue;
|
|
const nextConfig = {
|
|
...config,
|
|
apiBase,
|
|
appBase,
|
|
accessToken: tokenRes.accessToken,
|
|
userId: tokenRes.userId,
|
|
tokenType: tokenRes.tokenType || "Bearer",
|
|
updatedAt: new Date().toISOString()
|
|
};
|
|
writeConfig(nextConfig);
|
|
console.log(`HTMLSlide agent login complete. Bound user: ${tokenRes.userId}`);
|
|
return;
|
|
}
|
|
|
|
throw new Error("Authorization timed out. Run login again.");
|
|
}
|
|
|
|
async function listProjects(args) {
|
|
const client = authedClient(args);
|
|
const projects = await client.get("/projects");
|
|
projects.forEach((project) => {
|
|
console.log(`${project.id}\t${project.kind || "slides"}\t${project.title}\t${project.updatedAt || ""}`);
|
|
});
|
|
}
|
|
|
|
async function pullProject(args) {
|
|
const outPath = args.out ? resolve(args.out) : resolve("ppt", "index.html");
|
|
const client = authedClient(args);
|
|
const project = await resolveProject(client, args);
|
|
if (project.kind !== "html" || !project.html) {
|
|
throw new Error(`Project "${project.title}" is not an HTML PPT project.`);
|
|
}
|
|
|
|
const outDir = dirname(outPath);
|
|
if (!existsSync(outDir)) mkdirSync(outDir, { recursive: true });
|
|
writeFileSync(outPath, project.html, "utf8");
|
|
writeSidecar(join(outDir, SIDECAR_NAME), {
|
|
apiBase: client.apiBase,
|
|
projectId: project.id,
|
|
title: project.title,
|
|
originalFileName: project.originalFileName || "index.html",
|
|
pulledAt: new Date().toISOString()
|
|
});
|
|
|
|
console.log(`[htmlslide-sync] pulled "${project.title}" -> ${outPath}`);
|
|
}
|
|
|
|
async function pushProject(args) {
|
|
const htmlFile = args.file ? resolve(args.file) : null;
|
|
if (!htmlFile) usage("Missing HTML file. Example: node sync-htmlslide.mjs push path/to/ppt/index.html");
|
|
if (!existsSync(htmlFile)) throw new Error(`HTML file not found: ${htmlFile}`);
|
|
|
|
const deckDir = dirname(htmlFile);
|
|
const sidecarPath = join(deckDir, SIDECAR_NAME);
|
|
let sidecar = readJson(sidecarPath, {});
|
|
const client = authedClient({ ...sidecar, ...args });
|
|
const watchMode = Boolean(args.watch);
|
|
|
|
let syncing = false;
|
|
let pending = false;
|
|
|
|
async function syncOnce() {
|
|
if (syncing) {
|
|
pending = true;
|
|
return;
|
|
}
|
|
syncing = true;
|
|
try {
|
|
const { html, assets, title } = buildHtmlPayload(htmlFile, args.title || sidecar.title);
|
|
const result = await client.post("/agent/projects/html", {
|
|
projectId: args.projectId || sidecar.projectId,
|
|
title,
|
|
html,
|
|
assets,
|
|
originalFileName: basename(htmlFile),
|
|
source: "guizang-ppt-skill 同步",
|
|
author: "本地 Agent"
|
|
});
|
|
|
|
sidecar = {
|
|
...sidecar,
|
|
apiBase: client.apiBase,
|
|
projectId: result.project.id,
|
|
title: result.project.title,
|
|
originalFileName: basename(htmlFile),
|
|
updatedAt: result.project.updatedAt
|
|
};
|
|
writeSidecar(sidecarPath, sidecar);
|
|
console.log(`[htmlslide-sync] pushed "${result.project.title}" -> ${result.project.id}`);
|
|
} finally {
|
|
syncing = false;
|
|
if (pending) {
|
|
pending = false;
|
|
await syncOnce();
|
|
}
|
|
}
|
|
}
|
|
|
|
await syncOnce();
|
|
|
|
if (watchMode) {
|
|
console.log(`[htmlslide-sync] watching ${deckDir}`);
|
|
let timer = null;
|
|
watch(deckDir, { recursive: true }, (_event, changed) => {
|
|
if (!changed || String(changed).endsWith(SIDECAR_NAME)) return;
|
|
if (timer) clearTimeout(timer);
|
|
timer = setTimeout(() => {
|
|
syncOnce().catch((err) => console.error(`[htmlslide-sync] ${err.message}`));
|
|
}, 450);
|
|
});
|
|
}
|
|
}
|
|
|
|
async function resolveProject(client, args) {
|
|
if (args.projectId) return client.get(`/projects/${encodeURIComponent(args.projectId)}`);
|
|
if (!args.title) throw new Error("Pass --project-id <id> or --title <title>.");
|
|
const projects = await client.get("/projects");
|
|
const exact = projects.filter((project) => project.title === args.title);
|
|
if (exact.length === 1) return exact[0];
|
|
if (exact.length > 1) throw new Error(`Multiple projects named "${args.title}". Use --project-id.`);
|
|
const fuzzy = projects.filter((project) => project.title.includes(args.title));
|
|
if (fuzzy.length === 1) return fuzzy[0];
|
|
if (fuzzy.length > 1) {
|
|
throw new Error(`Multiple projects match "${args.title}": ${fuzzy.map((project) => project.title).join(", ")}. Use --project-id.`);
|
|
}
|
|
throw new Error(`Project not found: ${args.title}`);
|
|
}
|
|
|
|
function authedClient(args) {
|
|
const config = readConfig();
|
|
const apiBase = stripTrailingSlash(args.apiBase || process.env.HTMLSLIDE_API_BASE || config.apiBase || DEFAULT_API_BASE);
|
|
const accessToken = args.token || process.env.HTMLSLIDE_AGENT_TOKEN || config.accessToken;
|
|
if (!accessToken) {
|
|
throw new Error("Not logged in. Run: node scripts/sync-htmlslide.mjs login");
|
|
}
|
|
return {
|
|
apiBase,
|
|
get: (path) => apiJson(`${apiBase}${path}`, { token: accessToken }),
|
|
post: (path, body) => apiJson(`${apiBase}${path}`, { method: "POST", token: accessToken, body })
|
|
};
|
|
}
|
|
|
|
function buildHtmlPayload(htmlPath, titleOverride) {
|
|
const rawHtml = readFileSync(htmlPath, "utf8");
|
|
const assets = {};
|
|
const html = inlineLocalAssets(rawHtml, htmlPath, assets);
|
|
return {
|
|
html,
|
|
assets,
|
|
title: titleOverride || titleFromHtml(rawHtml) || basename(htmlPath).replace(/\.html?$/i, "")
|
|
};
|
|
}
|
|
|
|
function inlineLocalAssets(html, htmlPath, assets) {
|
|
const root = dirname(htmlPath);
|
|
let next = html;
|
|
|
|
next = next.replace(/\b(src|href)=["']([^"']+)["']/gi, (match, attr, ref) => {
|
|
const dataUrl = localRefToDataUrl(ref, root, assets);
|
|
return dataUrl ? `${attr}="${dataUrl}"` : match;
|
|
});
|
|
|
|
next = next.replace(/url\((["']?)([^"')]+)\1\)/gi, (match, _quote, ref) => {
|
|
const dataUrl = localRefToDataUrl(ref, root, assets);
|
|
return dataUrl ? `url("${dataUrl}")` : match;
|
|
});
|
|
|
|
return next;
|
|
}
|
|
|
|
function localRefToDataUrl(ref, root, assets) {
|
|
if (!ref || ref.startsWith("#") || /^[a-z][a-z0-9+.-]*:/i.test(ref) || ref.startsWith("//")) return "";
|
|
const cleanRef = decodeURIComponent(ref.split("#")[0].split("?")[0]);
|
|
const filePath = resolve(root, cleanRef);
|
|
if (!isInside(root, filePath) || !existsSync(filePath)) return "";
|
|
const ext = extname(filePath).toLowerCase();
|
|
if (!isEmbeddable(ext)) return "";
|
|
|
|
const rel = normalize(relative(root, filePath)).split(sep).join("/");
|
|
const mime = mimeFromExt(ext);
|
|
const base64 = readFileSync(filePath).toString("base64");
|
|
const dataUrl = `data:${mime};base64,${base64}`;
|
|
assets[rel] = dataUrl;
|
|
return dataUrl;
|
|
}
|
|
|
|
async function apiJson(url, options = {}) {
|
|
const res = await fetch(url, {
|
|
method: options.method || "GET",
|
|
headers: {
|
|
"content-type": "application/json",
|
|
...(options.token ? { authorization: `Bearer ${options.token}` } : {})
|
|
},
|
|
body: options.body ? JSON.stringify(options.body) : undefined
|
|
});
|
|
const text = await res.text();
|
|
const body = text ? JSON.parse(text) : {};
|
|
if (!res.ok) {
|
|
if (options.soft) return { ok: false, status: res.status, ...body };
|
|
throw new Error(body.error || `HTMLSlide API ${res.status}`);
|
|
}
|
|
return body;
|
|
}
|
|
|
|
function readConfig() {
|
|
return readJson(CONFIG_PATH, {});
|
|
}
|
|
|
|
function writeConfig(config) {
|
|
if (!existsSync(CONFIG_DIR)) mkdirSync(CONFIG_DIR, { recursive: true });
|
|
writeFileSync(CONFIG_PATH, `${JSON.stringify(config, null, 2)}\n`, "utf8");
|
|
}
|
|
|
|
function readJson(path, fallback) {
|
|
if (!existsSync(path)) return fallback;
|
|
try {
|
|
return JSON.parse(readFileSync(path, "utf8"));
|
|
} catch {
|
|
return fallback;
|
|
}
|
|
}
|
|
|
|
function writeSidecar(path, data) {
|
|
writeFileSync(path, `${JSON.stringify(data, null, 2)}\n`, "utf8");
|
|
}
|
|
|
|
function isInside(root, filePath) {
|
|
const rel = relative(root, filePath);
|
|
return rel && !rel.startsWith("..") && !isAbsolute(rel);
|
|
}
|
|
|
|
function isEmbeddable(ext) {
|
|
return [".png", ".jpg", ".jpeg", ".webp", ".gif", ".svg", ".avif", ".ico", ".woff", ".woff2", ".ttf", ".otf"].includes(ext);
|
|
}
|
|
|
|
function mimeFromExt(ext) {
|
|
return {
|
|
".png": "image/png",
|
|
".jpg": "image/jpeg",
|
|
".jpeg": "image/jpeg",
|
|
".webp": "image/webp",
|
|
".gif": "image/gif",
|
|
".svg": "image/svg+xml",
|
|
".avif": "image/avif",
|
|
".ico": "image/x-icon",
|
|
".woff": "font/woff",
|
|
".woff2": "font/woff2",
|
|
".ttf": "font/ttf",
|
|
".otf": "font/otf"
|
|
}[ext] || "application/octet-stream";
|
|
}
|
|
|
|
function titleFromHtml(html) {
|
|
const match = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
|
|
return match ? decodeHtml(match[1].replace(/\s+/g, " ").trim()) : "";
|
|
}
|
|
|
|
function decodeHtml(value) {
|
|
return value
|
|
.replace(/&/g, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, "'");
|
|
}
|
|
|
|
function stripTrailingSlash(value) {
|
|
return String(value || "").replace(/\/+$/, "");
|
|
}
|
|
|
|
function sleep(ms) {
|
|
return new Promise((resolveSleep) => setTimeout(resolveSleep, ms));
|
|
}
|
|
|
|
function parseArgs(argv) {
|
|
const result = {};
|
|
const commands = new Set(["login", "list", "pull", "push"]);
|
|
if (commands.has(argv[0])) result.command = argv.shift();
|
|
|
|
for (let i = 0; i < argv.length; i += 1) {
|
|
const arg = argv[i];
|
|
if (arg === "--watch") result.watch = true;
|
|
else if (arg === "--api-base") result.apiBase = argv[++i];
|
|
else if (arg === "--app-base") result.appBase = argv[++i];
|
|
else if (arg === "--device-name") result.deviceName = argv[++i];
|
|
else if (arg === "--token") result.token = argv[++i];
|
|
else if (arg === "--title") result.title = argv[++i];
|
|
else if (arg === "--project-id") result.projectId = argv[++i];
|
|
else if (arg === "--out") result.out = argv[++i];
|
|
else if (!result.file && result.command !== "list" && result.command !== "login") result.file = arg;
|
|
else usage(`Unknown argument: ${arg}`);
|
|
}
|
|
|
|
if (!result.command && result.file) result.command = "push";
|
|
return result;
|
|
}
|
|
|
|
function usage(message) {
|
|
if (message) console.error(message);
|
|
console.error(`Usage:
|
|
node scripts/sync-htmlslide.mjs login [--api-base http://127.0.0.1:3001/api] [--app-base http://127.0.0.1:5173]
|
|
node scripts/sync-htmlslide.mjs list
|
|
node scripts/sync-htmlslide.mjs pull --title "Project title" --out path/to/ppt/index.html
|
|
node scripts/sync-htmlslide.mjs pull --project-id <id> --out path/to/ppt/index.html
|
|
node scripts/sync-htmlslide.mjs push path/to/ppt/index.html [--title "Project title"] [--project-id <id>] [--watch]`);
|
|
process.exit(2);
|
|
}
|
|
|
|
function fail(err) {
|
|
console.error(`[htmlslide-sync] ${err.message}`);
|
|
process.exit(1);
|
|
}
|