commit eb0a17f75e94e12090a2d98d8caf93cbc43bda3e Author: Codex Date: Mon Jun 8 21:40:57 2026 -0700 Initial Slide Factory MVP scaffold diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..f4b4382 --- /dev/null +++ b/.env.example @@ -0,0 +1,9 @@ +SLIDE_FACTORY_PORT=3025 +SLIDE_FACTORY_OUTPUT_DIR=outputs + +# Future Model Service Gateway integration. +# Use a scoped runtime projection or env var name only; do not commit token values. +MODEL_SERVICE_GATEWAY_URL= +MODEL_SERVICE_APP_ID=slide-factory +MODEL_SERVICE_GATEWAY_TOKEN= + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..83cfd49 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +node_modules/ +dist/ +outputs/ +.env +.env.* +!.env.example +.DS_Store +*.log + diff --git a/README.md b/README.md new file mode 100644 index 0000000..6aa9276 --- /dev/null +++ b/README.md @@ -0,0 +1,58 @@ +# Slide Factory + +Slide Factory turns messy business content into brand-consistent slide decks. + +The first build is intentionally local-first: a CLI, an API, and a small web UI +share the same deck spec, style packs, and PPTX renderer. The same core contract +can later run behind `dev.slides.scottfelten.com` and `slides.scottfelten.com`. + +## MVP Contract + +```text +source content + -> content extraction + -> deck planning + -> validated DeckSpec + -> brand/style pack + -> PPTX renderer + -> saved artifacts +``` + +The LLM layer should produce structured thinking, not freehand slide design. +Renderers consume a validated `DeckSpec` and a `StylePack`. + +## Quick Start + +```bash +npm install +npm run sample +``` + +Outputs are written to: + +```text +outputs/sample-incorta.pptx +outputs/sample-incorta.deck.json +``` + +Run the local API: + +```bash +npm run dev:api +``` + +Run the web shell: + +```bash +npm run dev:web +``` + +## AgentPlane Notes + +- Implementation truth should live in Forgejo. +- Runtime secrets must come from scoped projections or Model Service Gateway. +- Do not commit raw source data, tokens, refresh tokens, or customer documents. +- Google Slides generation should reuse the existing AgentPlane guidance: + copy templates, inventory native containers, fill existing object IDs, and + render thumbnails/contact sheets before handoff. + diff --git a/apps/api/package.json b/apps/api/package.json new file mode 100644 index 0000000..010ca47 --- /dev/null +++ b/apps/api/package.json @@ -0,0 +1,25 @@ +{ + "name": "@slide-factory/api", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "build": "tsc -p tsconfig.json", + "dev": "tsx src/server.ts" + }, + "dependencies": { + "@slide-factory/core": "0.1.0", + "@slide-factory/render-pptx": "0.1.0", + "cors": "^2.8.5", + "express": "^4.19.2", + "nanoid": "^5.0.7", + "tsx": "^4.16.2", + "zod": "^3.23.8" + }, + "devDependencies": { + "@types/cors": "^2.8.17", + "@types/express": "^4.17.21", + "typescript": "^5.5.4" + } +} + diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts new file mode 100644 index 0000000..dd45975 --- /dev/null +++ b/apps/api/src/server.ts @@ -0,0 +1,96 @@ +import { access, mkdir, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import cors from "cors"; +import express from "express"; +import { nanoid } from "nanoid"; +import { z } from "zod"; +import { loadStylePack, planDeckFromSource } from "@slide-factory/core"; +import { renderPptx } from "@slide-factory/render-pptx"; + +const CreateDeckRequestSchema = z.object({ + style: z.string().default("incorta"), + instructions: z.string().optional(), + audience: z.string().default("executives"), + input: z.object({ + type: z.enum(["text", "markdown"]).default("markdown"), + title: z.string().optional(), + content: z.string().min(1) + }) +}); + +const app = express(); +const port = Number(process.env.SLIDE_FACTORY_PORT || 3025); +const outputDir = path.resolve(process.env.SLIDE_FACTORY_OUTPUT_DIR || "outputs"); + +app.use(cors()); +app.use(express.json({ limit: "5mb" })); +app.use("/outputs", express.static(outputDir)); + +app.get("/api/health", (_req, res) => { + res.json({ ok: true, service: "slide-factory", version: "0.1.0" }); +}); + +app.post("/api/decks", async (req, res, next) => { + try { + const body = CreateDeckRequestSchema.parse(req.body); + const id = nanoid(10); + const stylePath = await resolveStylePath(body.style); + const style = await loadStylePack(stylePath); + const deck = planDeckFromSource({ + source: body.input, + style, + instructions: body.instructions, + audience: body.audience + }); + + const jobDir = path.join(outputDir, id); + const specPath = path.join(jobDir, "deck.json"); + const pptxPath = path.join(jobDir, "deck.pptx"); + await mkdir(jobDir, { recursive: true }); + await writeFile(specPath, `${JSON.stringify(deck, null, 2)}\n`); + await renderPptx({ deck, style, outputPath: pptxPath }); + + res.status(201).json({ + id, + title: deck.title, + slides: deck.slides.length, + specUrl: `/outputs/${id}/deck.json`, + pptxUrl: `/outputs/${id}/deck.pptx` + }); + } catch (error) { + next(error); + } +}); + +app.use((error: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => { + console.error(error); + res.status(400).json({ + error: error instanceof Error ? error.message : "Unknown error" + }); +}); + +app.listen(port, () => { + console.log(`Slide Factory API listening on http://127.0.0.1:${port}`); +}); + +async function resolveStylePath(styleId: string): Promise { + const direct = path.resolve("styles", styleId); + if (await exists(path.join(direct, "theme.json"))) return direct; + + const moduleDir = path.dirname(fileURLToPath(import.meta.url)); + const repoRoot = path.resolve(moduleDir, "../../.."); + const fromRepoRoot = path.join(repoRoot, "styles", styleId); + if (await exists(path.join(fromRepoRoot, "theme.json"))) return fromRepoRoot; + + return direct; +} + +async function exists(targetPath: string): Promise { + try { + await access(targetPath); + return true; + } catch { + return false; + } +} diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json new file mode 100644 index 0000000..75d17be --- /dev/null +++ b/apps/api/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src/**/*.ts"] +} + diff --git a/apps/cli/package.json b/apps/cli/package.json new file mode 100644 index 0000000..df5f036 --- /dev/null +++ b/apps/cli/package.json @@ -0,0 +1,21 @@ +{ + "name": "@slide-factory/cli", + "version": "0.1.0", + "private": true, + "type": "module", + "bin": { + "slide-factory": "dist/index.js" + }, + "scripts": { + "build": "tsc -p tsconfig.json" + }, + "dependencies": { + "@slide-factory/core": "0.1.0", + "@slide-factory/render-pptx": "0.1.0", + "commander": "^12.1.0" + }, + "devDependencies": { + "typescript": "^5.5.4" + } +} + diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts new file mode 100644 index 0000000..af65ae8 --- /dev/null +++ b/apps/cli/src/index.ts @@ -0,0 +1,70 @@ +#!/usr/bin/env node +import { access, mkdir, readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { Command } from "commander"; +import { loadStylePack, planDeckFromSource } from "@slide-factory/core"; +import { renderPptx } from "@slide-factory/render-pptx"; + +const program = new Command(); + +program + .name("slide-factory") + .description("Create brand-consistent slide decks from source content.") + .version("0.1.0"); + +program + .command("create") + .requiredOption("-i, --input ", "Input markdown or text file") + .option("-s, --style ", "Style pack path", "styles/incorta") + .option("-o, --output ", "Output PPTX path", "outputs/deck.pptx") + .option("--spec ", "Output DeckSpec JSON path", "outputs/deck.deck.json") + .option("--instructions ", "Deck instructions") + .option("--audience ", "Target audience", "executives") + .action(async (options) => { + const inputPath = path.resolve(options.input); + const stylePath = await resolvePathFromRepo(options.style); + const outputPath = path.resolve(options.output); + const specPath = path.resolve(options.spec); + const content = await readFile(inputPath, "utf8"); + const style = await loadStylePack(stylePath); + const deck = planDeckFromSource({ + source: { + type: inputPath.endsWith(".md") ? "markdown" : "text", + title: undefined, + content, + sourceName: path.basename(inputPath) + }, + style, + instructions: options.instructions, + audience: options.audience + }); + + await mkdir(path.dirname(specPath), { recursive: true }); + await writeFile(specPath, `${JSON.stringify(deck, null, 2)}\n`); + await renderPptx({ deck, style, outputPath }); + console.log(JSON.stringify({ deck: specPath, pptx: outputPath }, null, 2)); + }); + +await program.parseAsync(process.argv); + +async function resolvePathFromRepo(inputPath: string): Promise { + const direct = path.resolve(inputPath); + if (await exists(direct)) return direct; + + const moduleDir = path.dirname(fileURLToPath(import.meta.url)); + const repoRoot = path.resolve(moduleDir, "../../.."); + const fromRoot = path.resolve(repoRoot, inputPath); + if (await exists(fromRoot)) return fromRoot; + + return direct; +} + +async function exists(targetPath: string): Promise { + try { + await access(targetPath); + return true; + } catch { + return false; + } +} diff --git a/apps/cli/tsconfig.json b/apps/cli/tsconfig.json new file mode 100644 index 0000000..75d17be --- /dev/null +++ b/apps/cli/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src/**/*.ts"] +} + diff --git a/apps/web/index.html b/apps/web/index.html new file mode 100644 index 0000000..506e6ec --- /dev/null +++ b/apps/web/index.html @@ -0,0 +1,13 @@ + + + + + + Slide Factory + + +
+ + + + diff --git a/apps/web/package.json b/apps/web/package.json new file mode 100644 index 0000000..c559d1e --- /dev/null +++ b/apps/web/package.json @@ -0,0 +1,23 @@ +{ + "name": "@slide-factory/web", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "build": "tsc -p tsconfig.json && vite build", + "dev": "vite --host 127.0.0.1 --port 5185" + }, + "dependencies": { + "@vitejs/plugin-react": "^4.3.1", + "vite": "^5.3.5", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "lucide-react": "^0.468.0" + }, + "devDependencies": { + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", + "typescript": "^5.5.4" + } +} + diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx new file mode 100644 index 0000000..022c16b --- /dev/null +++ b/apps/web/src/main.tsx @@ -0,0 +1,100 @@ +import React, { useState } from "react"; +import { createRoot } from "react-dom/client"; +import { Download, FileText, WandSparkles } from "lucide-react"; +import "./styles.css"; + +type DeckResult = { + id: string; + title: string; + slides: number; + specUrl: string; + pptxUrl: string; +}; + +function App() { + const [content, setContent] = useState(`# Customer Discovery Notes\n\nPaste notes, transcript excerpts, or brief content here.\n\n- Confirm the top decisions.\n- Build an executive narrative.\n- Render a PPTX for review.`); + const [instructions, setInstructions] = useState("Create a concise executive deck."); + const [result, setResult] = useState(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + async function createDeck() { + setBusy(true); + setError(null); + setResult(null); + try { + const response = await fetch("/api/decks", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + style: "incorta", + instructions, + input: { type: "markdown", content } + }) + }); + const json = await response.json(); + if (!response.ok) throw new Error(json.error || "Deck generation failed"); + setResult(json); + } catch (err) { + setError(err instanceof Error ? err.message : "Unknown error"); + } finally { + setBusy(false); + } + } + + return ( +
+
+
+
+

Slide Factory

+

Deck builder

+
+ +
+ +
+ + +