Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
b84ea9a
fix(security): sandbox preview iframes to opaque origin, decouple PNG…
Caosmart1979 Jul 28, 2026
4978757
fix(security): gate /api/convert spawn, harden binOverride, close Win…
Caosmart1979 Jul 28, 2026
e01f7e1
fix(security): drop redundant marketplace host-guard, tighten deploy …
Caosmart1979 Jul 28, 2026
9e58a8d
docs(readme): correct factual claims that contradicted the code
Caosmart1979 Jul 28, 2026
8c89873
fix(ci,skills): replace system tar with pure-JS extractor, add Window…
Caosmart1979 Jul 28, 2026
a9a24ce
test(core): add invoke + /api/convert tests; fix run status on SSE error
Caosmart1979 Jul 28, 2026
b293318
fix(security): scope child env to the running agent's own secrets
Caosmart1979 Jul 28, 2026
80e44a9
fix(wechat): add computed-style inlining for Tailwind-CDN templates
Caosmart1979 Jul 28, 2026
fce51ac
fix(obs): add structured invoke-agent logging (spawn/close/error)
Caosmart1979 Jul 28, 2026
4ffdadc
feat(design-os): DESIGN.md loader, adapter, anti-slop rule bank, prom…
Caosmart1979 Jul 28, 2026
d7f4b07
feat(critique): add /api/critique 5-dim quality gate (open-design Ste…
Caosmart1979 Jul 28, 2026
149a073
feat(compare): add /api/compare multi-template parallel-candidate route
Caosmart1979 Jul 28, 2026
5fbb736
feat(discovery): add /api/discovery design advisor (Phase 5)
Caosmart1979 Jul 28, 2026
078db79
feat(iterate): add critique→retry closed loop
Caosmart1979 Jul 28, 2026
d521354
docs(showcase): archive Design OS end-to-end real-task test
Caosmart1979 Jul 28, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,3 +49,37 @@ jobs:

- name: E2E smoke
run: pnpm -F @html-anything/e2e test

windows-tests:
name: Windows unit tests
runs-on: windows-latest

steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "24"

- name: Enable Corepack
run: corepack enable

- name: Install dependencies
run: pnpm install --frozen-lockfile

- name: Guard project shape
run: pnpm exec tsx scripts/guard.ts

- name: Typecheck app
run: pnpm -F @html-anything/next typecheck

- name: Typecheck e2e
run: pnpm -F @html-anything/e2e typecheck

- name: App unit tests
run: pnpm -F @html-anything/next test

- name: Build
run: pnpm -F @html-anything/next build
28 changes: 16 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# HTML Anything

<p align="center"><sub>From the team behind <a href="https://github.com/nexu-io/open-design"><b>Open Design</b></a> — <b>40k★ · 200+ contributors</b>, production-grade and iterating faster. html-anything is the focused agent-era HTML editor; if it clicks for you, <a href="https://github.com/nexu-io/open-design">Open Design</a> is where the same team ships at scale.</sub></p>
<p align="center"><sub>From the team behind <a href="https://github.com/nexu-io/open-design"><b>Open Design</b></a> — production-grade and iterating faster. html-anything is the focused agent-era HTML editor; if it clicks for you, <a href="https://github.com/nexu-io/open-design">Open Design</a> is where the same team ships at scale.</sub></p>

<p align="center"><b>Live page:</b> <a href="https://open-design.ai/html-anything/"><b>open-design.ai/html-anything/</b></a> — overview, surface modes, and showcase before you clone.</p>

Expand Down Expand Up @@ -102,7 +102,7 @@ We stand on four open-source shoulders:
- [**`nexu-io/open-design`**](https://github.com/nexu-io/open-design) — the agent-detection layer, the design-system model, and the `SKILL.md` protocol. `next/src/lib/agents/` and `next/src/lib/templates/skills/*` mirror this architecture directly.
- [**`mdnice/markdown-nice`**](https://github.com/mdnice/markdown-nice) — proof that `juice`-inlined CSS pastes cleanly into WeChat and Zhihu without per-platform manual fix-up.
- [**`gcui-art/markdown-to-image`**](https://github.com/gcui-art/markdown-to-image) — the iframe → high-DPI PNG export path.
- [**`alchaincyf/huashu-md-html`**](https://github.com/alchaincyf/huashu-md-html) — the anti-AI-slop discipline that maps into the hard constraints inside every `SKILL.md` (CJK-first font stack, 8 px baseline grid, contrast ≥ 4.5, must-use-real-data rule).
- [**`alchaincyf/huashu-md-html`**](https://github.com/alchaincyf/huashu-md-html) — the anti-AI-slop discipline that informs the hard constraints centralized in `next/src/lib/templates/shared.ts` and applied to every skill at prompt-assembly time (CJK-first font stack, 8 px baseline grid, contrast ≥ 4.5, must-use-real-data). These live in `shared.ts`, not per-`SKILL.md` frontmatter.

## At a glance

Expand All @@ -114,7 +114,7 @@ We stand on four open-source shoulders:
| **9 surface modes** | 📖 magazine article · 🎬 keynote deck · 📄 résumé · 🖼️ poster · 📱 Xiaohongshu card · 🐦 tweet card · 🛠️ web prototype · 📊 data report · 🎞️ Hyperframes video. Each has multiple skills you can pick from. |
| **One-click export** | `juice` inlines CSS → WeChat paste with zero re-formatting · `modern-screenshot` renders the iframe to a 2× PNG → `ClipboardItem` → drop straight into the tweet composer · `<mjx-container>` → `data-eeimg` placeholder → Zhihu equations render automatically · standalone `.html` download · high-DPI `.png` download. |
| **Streaming render** | `POST /api/convert` over SSE. The agent's stdout JSON-line stream is parsed for text deltas → server-sent events → client appends → iframe `srcdoc` updates live. Waiting for an AI generation looks like watching it type in real time. |
| **Sandboxed preview** | `<iframe sandbox="allow-scripts allow-same-origin">`. User-emitted HTML runs in an isolated origin — Tailwind CDN / Google Fonts / inline scripts work, but cookies and localStorage are quarantined from the host. |
| **Sandboxed preview** | `<iframe sandbox="allow-scripts">`. User-emitted HTML runs in an opaque origin — Tailwind CDN / Google Fonts / inline scripts work, but the iframe cannot reach the host page (no access to parent cookies / localStorage, no credentialed calls to `/api/*`). |
| **Format auto-detect** | The editor accepts Markdown / CSV / TSV / JSON / SQL / plain text. `papaparse` + `xlsx` parse tabular data in the browser — nothing is uploaded. |
| **Deployable to** | Local (`pnpm -F @html-anything/next dev`) · Vercel for the web layer (the agent always stays on your laptop). |
| **License** | Apache-2.0 |
Expand Down Expand Up @@ -357,7 +357,7 @@ Mechanically inspired by [`mdnice/markdown-nice`](https://github.com/mdnice/mark

### 6 · Sandboxed iframe = secure + isolated.

User-emitted HTML always renders inside `<iframe sandbox="allow-scripts allow-same-origin">`. Third-party scripts (Tailwind CDN, Google Fonts, custom animations) still execute, but cookies and localStorage stay in the iframe's own origin — the host page is never poisoned. Opening devtools only shows the iframe's DOM, so the debugging experience matches a standalone HTML file.
User-emitted HTML always renders inside `<iframe sandbox="allow-scripts">` (no `allow-same-origin`, so the iframe gets an opaque origin). Third-party scripts (Tailwind CDN, Google Fonts, custom animations) still execute, but the iframe cannot reach the host page — it has no access to parent cookies / localStorage and cannot make credentialed calls to `/api/*`. PNG export snapshots the HTML via a throwaway offscreen iframe (see `next/src/lib/export/image.ts`).

## Architecture

Expand Down Expand Up @@ -424,21 +424,25 @@ User-emitted HTML always renders inside `<iframe sandbox="allow-scripts allow-sa

## Status

Early but real. The closed loop — **detect agent → pick skill → SSE stream → sandboxed iframe preview → one-click export** — runs end-to-end against all 8 CLIs listed above. The skill library and the `SKILL.md` hard-constraints are where most of the leverage lives, and both are stable. The picker UX, design-system metadata, and the multi-template compare flow ship daily. If something looks broken on your machine, open an issue with the agent CLI you were using and the input — those are the bug reports that move things forward fastest.
Early but real. The closed loop — **detect agent → pick skill → SSE stream → sandboxed iframe preview → one-click export** — runs end-to-end against all 8 CLIs listed above. The skill library and the `SKILL.md` hard-constraints are where most of the leverage lives, and both are stable. The picker UX and design-system metadata ship daily. (Multi-template compare is planned, not yet shipped — see the table.) If something looks broken on your machine, open an issue with the agent CLI you were using and the input — those are the bug reports that move things forward fastest.

| Surface | State |
|---|---|
| Agent detection (8 CLIs) | ✅ stable |
| Agent detection — 11 stdin/argv CLIs (claude / codex / cursor-agent / gemini / copilot / opencode / qwen / qoder / deepseek / aider / openclaw) | ✅ stable |
| ACP / pi-rpc agents (hermes / kimi / devin / kiro / kilo / vibe / pi) | ⏳ detect-only, not wired |
| Skill registry + picker (75 skills) | ✅ stable |
| SSE streaming render | ✅ stable |
| Sandboxed iframe preview | ✅ stable |
| One-click WeChat / X / Zhihu / `.html` / `.png` export | ✅ stable |
| Sandboxed iframe preview (opaque-origin) | ✅ stable |
| One-click X / Zhihu / `.html` / `.png` export | ✅ stable |
| WeChat MP paste (computed-style inlining for Tailwind-CDN templates) | 🛠 partial |
| CSV / Excel / JSON / SQL format auto-detect | ✅ stable |
| Multi-template compare (generate 4, pick 1) | 🛠 in progress |
| Hyperframes → `.mp4` one-click handoff to Remotion | 🛠 in progress |
| Skill marketplace (`install <github-repo>`) | ✅ shipped |
| Per-task version history | ✅ shipped |
| Hyperframes → Remotion project (`.zip`) | ✅ shipped |
| Hyperframes → in-browser `.mp4` render | ⏳ planned |
| Multi-template compare (generate 4, pick 1) | ⏳ planned (not started) |
| Browser extension (select on any page → convert) | ⏳ planned |
| History / version diff / IndexedDB archive | ⏳ planned |
| Skill marketplace (`install <github-repo>`) | ⏳ planned |
| History archive / version diff | ⏳ planned |

## Security

Expand Down
226 changes: 226 additions & 0 deletions next/src/app/api/compare/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
/**
* POST /api/compare — multi-template compare (open-design's "5 direction"
* parallel-candidate generator).
*
* Accepts up to `n` design directions (or defaults to the 5-direction library)
* and spawns each as a parallel agent. The SSE stream returns per-direction
* delta events tagged with a `direction` key so the client can slot each
* candidate into its own panel.
*
* The stream fires a `ready` event per direction when that candidate's HTML
* has been fully received, plus a top-level `done` when all candidates are
* settled.
*/

import { NextRequest } from "next/server";
import { invokeAgent, type InvokeEvent } from "@/lib/agents/invoke";
import {
tryAcquireSpawnSlots,
releaseSpawnSlots,
MAX_BODY_BYTES,
MAX_PROMPT_BYTES,
SPAWN_TIMEOUT_MS,
} from "@/lib/agents/spawn-guards";
import {
DESIGN_DIRECTIONS,
directionPrompt,
type DesignDirection,
} from "@/lib/design-systems/directions";
import { loadSkill } from "@/lib/templates/loader";
import { SHARED_DESIGN_DIRECTIVES } from "@/lib/templates/shared";

export const runtime = "nodejs";
export const dynamic = "force-dynamic";

type CompareReq = {
agent: string;
templateId: string;
content: string;
format?: string;
model?: string;
binOverride?: string;
/** Number of candidates to generate. Defaults to 3. */
n?: number;
/** Specific direction ids to use. Defaults to the built-in 5. */
directions?: string[];
};

const MAX_CANDIDATES = 5;

function buildCandidatePrompt(dir: DesignDirection, skillBody: string, content: string, format: string): string {
return `${directionPrompt(dir)}

---
${SHARED_DESIGN_DIRECTIVES}
${skillBody.trim()}

【输入格式】: ${format}
【用户内容】:
${content}
`;
}

export async function POST(req: NextRequest) {
const contentLength = Number(req.headers.get("content-length") ?? 0);
if (contentLength && contentLength > MAX_BODY_BYTES) {
return new Response("payload too large", { status: 413 });
}

let body: CompareReq;
try {
body = (await req.json()) as CompareReq;
} catch {
return new Response("invalid JSON body", { status: 400 });
}
const {
agent,
templateId,
content,
format = "text",
model,
binOverride,
n = 3,
directions,
} = body;

if (!agent || !templateId || !content) {
return new Response("missing required fields: agent, templateId, content", { status: 400 });
}
const skill = loadSkill(templateId);
if (!skill) return new Response(`unknown template: ${templateId}`, { status: 400 });

// Pick the direction set.
let dirs: DesignDirection[];
if (directions?.length) {
dirs = directions
.map((id) => DESIGN_DIRECTIONS.find((d) => d.id === id))
.filter(Boolean) as DesignDirection[];
} else {
dirs = DESIGN_DIRECTIONS.slice(0, Math.min(n, MAX_CANDIDATES, DESIGN_DIRECTIONS.length));
}
if (!dirs.length) {
return new Response("no valid directions to compare", { status: 400 });
}

// Build prompts.
const prompts = dirs.map((d) =>
buildCandidatePrompt(d, skill.body, content, format),
);

// Check prompt caps.
const maxBytes = Math.max(...prompts.map((p) => Buffer.byteLength(p, "utf8")));
if (maxBytes > MAX_PROMPT_BYTES) {
return new Response("prompt too large", { status: 413 });
}

// Acquire concurrent slots.
const granted = tryAcquireSpawnSlots(prompts.length);
if (granted === 0) {
return new Response("server busy: too many concurrent conversions", { status: 503 });
}

let released = false;
const cleanup = () => {
if (!released) {
releaseSpawnSlots(granted);
released = true;
}
};

const abortCtl = new AbortController();
req.signal?.addEventListener("abort", () => abortCtl.abort(), { once: true });
const watchdog = setTimeout(() => abortCtl.abort(), SPAWN_TIMEOUT_MS);

const sse = new ReadableStream({
async start(controller) {
const enc = new TextEncoder();
let outClosed = false;
const send = (event: string, data: unknown) => {
if (outClosed) return;
try {
controller.enqueue(
enc.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`),
);
} catch {
outClosed = true;
}
};

// Process only up to `granted` — remaining candidates are silently
// dropped when we're at capacity.
const toSpawn = prompts.slice(0, granted);

const results = await Promise.allSettled(
toSpawn.map((prompt, i) =>
consumeAgentStream(
invokeAgent({ agent, prompt, model, binOverride, signal: abortCtl.signal }),
(type, payload) => {
const p = payload as Record<string, unknown>;
send(type, { ...p, direction: dirs[i].id, candidateIdx: i });
},
dirs[i].id,
),
),
);

const ok = results.filter((r) => r.status === "fulfilled").length;
send("done", { candidatesGenerated: ok, candidatesRequested: toSpawn.length });

outClosed = true;
cleanup();
clearTimeout(watchdog);
try {
controller.close();
} catch {}
},
cancel() {
abortCtl.abort();
cleanup();
clearTimeout(watchdog);
},
});

return new Response(sse, {
headers: {
"Content-Type": "text/event-stream; charset=utf-8",
"Cache-Control": "no-cache, no-transform",
Connection: "keep-alive",
"X-Accel-Buffering": "no",
},
});
}

async function consumeAgentStream(
stream: ReadableStream<InvokeEvent>,
onEvent: (type: string, payload: unknown) => void,
directionId: string,
): Promise<void> {
const reader = stream.getReader();
const chunks: string[] = [];
try {
while (true) {
const { value, done } = await reader.read();
if (done) break;
if (!value) continue;
if (value.type === "delta") {
const text = (value as { text?: string }).text;
if (text) {
chunks.push(text);
onEvent("delta", value);
}
} else if (value.type === "error") {
onEvent("error", value);
} else {
onEvent(value.type, value);
}
}
} finally {
const html = chunks.join("");
const trimmed = html.trim();
onEvent("ready", {
direction: directionId,
html: trimmed,
bytes: Buffer.byteLength(trimmed, "utf8"),
});
}
}
Loading