commit e2bed4b5a270f72c643eb3a34285df5354ffe3b5 Author: wehub-resource-sync Date: Mon Jul 13 12:27:29 2026 +0800 chore: import upstream snapshot with attribution diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..77a7a77 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,51 @@ +name: CI + +on: + pull_request: + push: + branches: + - main + +permissions: + contents: read + +jobs: + public-checks: + name: Public checks + runs-on: ubuntu-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 + + - name: Install Playwright browser + run: pnpm -F @html-anything/e2e exec playwright install --with-deps chromium + + - name: E2E smoke + run: pnpm -F @html-anything/e2e test diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e9b5542 --- /dev/null +++ b/.gitignore @@ -0,0 +1,53 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/e2e/node_modules +/next/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage +/test-results/ +/playwright-report/ +/e2e/ui/reports/ +/e2e/ui/test-results/ + +# next.js +/.next/ +/next/.next/ +/out/ +/next/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts +/next/next-env.d.ts + +# claude code +.claude-sessions/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..7d10219 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,24 @@ + +# This is NOT the Next.js you know + +This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `next/node_modules/next/dist/docs/` before writing any code. Heed deprecation notices. + + +## Workspace Shape + +- Root is the public harness boundary only: workspace metadata, `.github/workflows/ci.yml`, `scripts/guard.ts`, and repository docs. +- `next/` owns the complete Next application: app routes, components, app libraries, public assets, Next config, app TypeScript config, and app unit tests. +- `e2e/` owns browser-level tests as the only source of truth: Playwright config, e2e TypeScript config, helper scripts, and flat `ui/*.test.ts` cases. +- Do not add Playwright tests under `next/`. Do not add app source back at root `src/` or root `app/`. +- Root `package.json` must not proxy app or e2e scripts. Use pnpm workspace filters from the repository root. + +## Commands + +- Install: `pnpm install --frozen-lockfile` +- Guard shape: `pnpm exec tsx scripts/guard.ts` +- App dev: `pnpm -F @html-anything/next dev` +- App typecheck: `pnpm -F @html-anything/next typecheck` +- App unit tests: `pnpm -F @html-anything/next test` +- App build: `pnpm -F @html-anything/next build` +- E2E typecheck: `pnpm -F @html-anything/e2e typecheck` +- E2E tests: `pnpm -F @html-anything/e2e test` diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..d6dada2 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,241 @@ +# Contributing to HTML Anything + +Thanks for thinking about contributing. HTML Anything is small on purpose — most of the value lives in **files** (skill folders, prompt fragments, agent adapters) rather than framework code. The highest-leverage contributions are usually one folder, one Markdown file, or a ten-line adapter. + +This guide tells you exactly where to look for each type of contribution and what bar a PR has to clear before we merge. + +

English · 简体中文

+ +--- + +## Three things you can ship in one afternoon + +| If you want to… | You're really adding | Where it lives | Ship size | +|---|---|---|---| +| Make HTML Anything render a new kind of artifact (an invoice, a job posting, an iOS Settings screen…) | a **Skill** | [`src/lib/templates/skills//`](src/lib/templates/skills/) | one folder, ~3 files | +| Hook up a new coding-agent CLI | an **Agent adapter** | [`src/lib/agents/argv.ts`](src/lib/agents/argv.ts) + [`src/lib/agents/detect.ts`](src/lib/agents/detect.ts) | ~10 lines in one array | +| Add a new export target (WeChat Channels, Douyin captions, Notion, …) | an **Export adapter** | [`src/components/drafts-menu.tsx`](src/components/drafts-menu.tsx) + helper under `src/lib/export/` | one component + one helper | +| Add a feature, fix a bug, refactor the streaming parser | code | `src/app/`, `src/lib/`, `src/components/` | normal PR | +| Improve docs, port a section into another language, fix typos | docs | `README.md`, `README.zh-CN.md`, this file | one PR | + +If you're not sure which bucket your idea is in, [open an issue first](https://github.com/nexu-io/html-anything/issues/new) and we'll point you at the right surface. + +--- + +## Local setup + +```bash +git clone https://github.com/nexu-io/html-anything.git +cd html-anything +pnpm install +pnpm dev # next dev — http://localhost:3000 +pnpm build # next build, when verifying a release-shaped bundle +``` + +Node `~20` and `pnpm` are required. macOS, Linux, and WSL2 are the primary paths. Native Windows should work but isn't a primary target — file an issue if it doesn't. + +Before you push, make sure you have **at least one coding-agent CLI logged in** (`claude login`, `cursor login`, `gemini auth`, etc.) so you can actually run an end-to-end generation. PRs that touch the streaming or agent layer are expected to include a screenshot or log snippet showing it worked. + +> **A note on this project.** It is a normal Next.js 16 App Router app — no daemon, no Electron shell, no extra processes. Everything happens in `next dev`: server routes spawn the local CLI, stream stdout back as SSE, and the browser appends into an iframe `srcdoc`. If you find yourself wanting to introduce a separate long-running process, please open a discussion first. + +--- + +## Adding a new Skill + +A skill is a folder under [`src/lib/templates/skills/`](src/lib/templates/skills/) with a `SKILL.md` at the root, following Claude Code's [`SKILL.md` convention][skill] plus a small extended frontmatter that the picker reads. **No registration step.** Drop the folder in, restart `pnpm dev`, the picker shows it. + +### Skill folder layout + +```text +src/lib/templates/skills/your-skill/ +├── SKILL.md # required — prompt body + frontmatter +├── example.html # required — what the agent should produce, hand-authored +├── assets/ # optional — fonts, images, reusable CSS, layout fragments +└── references/ # optional — design-system snippets, references the agent should Read +``` + +### `SKILL.md` shape + +```markdown +--- +name: your-skill +description: One sentence shown in the picker preview. +mode: prototype # prototype | deck | frame | social | office | doc | mockup | vfx +scenario: marketing # design | marketing | engineering | product | finance | hr | sale | personal +surface: desktop # desktop | mobile | A4 | 1080x1920 | 1600x900 | 1920x1080 | … +preview: + type: iframe # iframe | image | deck + thumbnail: docs/screenshots/skills/your-skill.png # optional, used in the README showcase grid +design_system: + requires: optional # required | optional | none +featured: false # set true if this should appear in "Showcase examples" +example_prompt: | + Three-sentence example prompt that demonstrates the kind of input this skill is for. +--- + +# Your Skill + + + +## Hard constraints +- 8 px baseline grid · all spacing / line-height / font-size as multiples of 8. +- CJK-first font stack: `"Noto Sans SC", "PingFang SC", "Source Han Sans"`. Latin: `"Inter", "Manrope"`. +- Color contrast ≥ 4.5. Real `:focus` state on every interactive element. +- Must use the user's real data. No `lorem ipsum`. No invented metrics. No purple gradients. + +## Layout + + + +## What "good" looks like +- +- + +## What "bad" looks like +- +- +``` + +### Bar for merging a new skill + +1. **Real `example.html` ships in the folder.** Hand-author it once — the agent has a target to copy. PRs without one get bounced. +2. **The example renders in the browser** (`pnpm dev` → pick the skill → ⌘+Enter → screenshot). Attach the screenshot to the PR. +3. **Hard constraints exist and are specific.** Vague directives ("use modern typography") are not constraints. Real ones look like "Inter 96 / 64 / 40 / 24 / 16 px, 8 px grid, max two weights per slide". +4. **No `lorem ipsum`** anywhere in the example. If the example uses placeholder data, it must be plausibly-real placeholder data. +5. **Slug uses ASCII lowercase with dashes** — `deck-swiss-international`, `social-x-post-card`. Mirror the 75 existing folders. +6. **If you vendored work from another repo**, the original `LICENSE` and authorship attribution have to ship inside your skill folder. Example: `src/lib/templates/skills/deck-guizang-editorial/LICENSE` preserves the original op7418 license verbatim. + +### Picker grouping + +The picker organizes skills along two axes. Pick values that already exist where possible — only introduce a new value if your skill genuinely doesn't fit: + +- **`mode`** — `prototype` · `deck` · `frame` · `social` · `office` · `doc` · `mockup` · `vfx`. +- **`scenario`** — `design` · `marketing` · `engineering` · `product` · `finance` · `hr` · `sale` · `personal`. + +--- + +## Adding a new coding-agent CLI + +Hooking up a new agent (e.g. some new shop's `foo-coder` CLI) is one entry in [`src/lib/agents/argv.ts`](src/lib/agents/argv.ts): + +```ts +{ + id: 'foo', + name: 'Foo Coder', + bin: 'foo', + detect: { args: ['--version'] }, + build: (prompt: string) => ({ + args: ['exec', '-p', prompt], + stdin: null, // or 'prompt' if the CLI reads from stdin + }), + stream: 'plain', // 'plain' | 'json-event' | 'claude-stream-json' +} +``` + +That's it. `/api/agents` will detect it on `PATH`, the top-bar picker shows it, the chat path works through the same SSE pipeline. If the CLI emits **typed events** (like Claude Code's `--output-format stream-json`), add a parser in [`src/lib/agents/invoke.ts`](src/lib/agents/invoke.ts) and set `stream: 'claude-stream-json'`. + +### Bar for merging an agent adapter + +1. **A real session works end-to-end.** Run `pnpm dev`, pick your agent, generate any skill's `example_prompt`, and paste the SSE log into the PR description showing it streamed an artifact through. +2. **`PATH` detection works on macOS, Linux, and WSL.** The scanner already includes `~/.local/bin` · `~/.bun/bin` · `/opt/homebrew/bin` · `~/.npm-global/bin`; if your CLI lives somewhere else, add the dir to the scan list. +3. **The README's "Supported coding agents" table gets one row** in both `README.md` and `README.zh-CN.md`. +4. **Stream parser is reusable.** If the CLI emits the same JSON-line shape as another adapter, share the parser — don't fork. + +--- + +## Adding a new export target + +Export targets live in two places: a helper under `src/lib/export/` that produces the bytes (string for `.html`, Blob for `.png`, `ClipboardItem` for paste), and a menu entry in [`src/components/drafts-menu.tsx`](src/components/drafts-menu.tsx) that wires it into the UI. + +### Bar for merging an export target + +1. **Round-trip test in the target platform.** Paste / upload the output into the platform itself (WeChat editor, X composer, Zhihu editor) and screenshot the result in the PR. +2. **No platform credentials in code.** If the target needs an API key, the user supplies it in settings; we don't ship one. +3. **Output is self-contained.** `.html` exports inline their CSS via `juice`; PNG exports use `modern-screenshot` at 2×. + +--- + +## Code style + +We're not pedantic about formatting (Prettier on save is fine), but two rules are non-negotiable because they show up in the prompt stack and the user-facing API: + +1. **Single quotes in TS/TSX.** Strings are single-quoted unless escaping makes them ugly. The codebase is already consistent — please match. +2. **Comments in English.** Even if the PR is translating something into 中文, code comments stay in English so we can keep one set of greppable references. + +Beyond that: + +- **Don't narrate.** No `// import the module`, no `// loop through items`. If the code reads obviously, the comment is noise. Save comments for non-obvious intent or constraints the code can't express. +- **TypeScript for `src/`.** No new top-level `.js` files unless there's a compelling reason. +- **No new top-level dependencies** without a paragraph in the PR description on what we get vs. what bytes we ship. The dep list in [`package.json`](package.json) is small on purpose. +- **Run `pnpm build`** before pushing structural changes. Type errors block merge. + +--- + +## Commits & pull requests + +- **One concern per PR.** Adding a skill + refactoring the SSE parser + bumping a dep is three PRs. +- **Title is imperative + scope.** `add deck-product-launch skill`, `fix SSE backpressure when CLI hangs`, `docs: clarify skill frontmatter`. +- **Body explains the why.** "What does this do" is usually obvious from the diff; "why does this need to exist" rarely is. +- **Reference an issue** if there is one. If there isn't and the PR is non-trivial, open one first so we can agree the change is wanted before you spend the time. +- **No squash-during-review.** Push fixups; we'll squash on merge. +- **No force-push to a shared branch** unless the reviewer asked. + +We don't enforce a CLA. Apache-2.0 covers us; your contribution is licensed under the same. + +--- + +## Reporting bugs + +Open an issue with: + +- What you ran (the exact `pnpm dev` invocation, or which UI button you clicked). +- Which agent CLI was selected (Claude Code? Cursor Agent? …). +- The skill that triggered it. +- The relevant **server log tail** — most "the artifact never rendered" reports get diagnosed in 30 seconds when we can see `spawn ENOENT` or the CLI's actual error. +- A screenshot if it's UI. + +For prompt-stack bugs ("the agent emitted a purple gradient hero, the constraint in `SKILL.md` was supposed to forbid that"), include the **full assistant message** so we can see whether the violation was the model or the prompt. + +--- + +## Asking questions + +- Architecture question, design question, "is this a bug or a misuse" → [GitHub Discussions](https://github.com/nexu-io/html-anything/discussions) (preferred — searchable for the next person). +- "How do I write a skill that does X" → open a discussion. We'll answer it and turn the answer into an entry in this guide if the pattern is missing. + +--- + +## What we don't accept + +To keep the project focused, please don't open PRs that: + +- **Vendor a model runtime.** HTML Anything's whole bet is "your existing CLI is enough". We don't ship `pi-ai`, OpenAI keys, model loaders, or hosted inference proxies. +- **Rewrite the frontend away from the current stack without prior discussion.** Next.js 16 App Router + React 19 + Tailwind v4 + TypeScript is the line. No Astro, Solid, Svelte, or other framework rewrites unless maintainers explicitly want that migration. +- **Replace the SSE streaming model with WebSocket / long-polling.** SSE + iframe `srcdoc` append is the simplest thing that ships a live render; we keep it. +- **Add telemetry / analytics / phone-home.** HTML Anything is local-first. The only outbound calls are to the agent CLI on your laptop, plus whatever the generated HTML itself references (Tailwind CDN, Google Fonts). +- **Bundle a binary** without a license file and authorship attribution next to it. +- **Add a skill whose `example.html` is empty / placeholder / clearly model-generated without review.** A skill is judged by what its example renders; an empty example is rejected immediately. + +If you're not sure whether your idea fits, open a discussion before writing the code. + +--- + +## Localization maintenance + +The repo ships two languages at parity: English (`README.md`, `CONTRIBUTING.md`) and 简体中文 (`README.zh-CN.md`, `CONTRIBUTING.zh-CN.md`). When adding or renaming a skill, agent, or export target: + +- Update **both** READMEs' tables. +- Update **both** CONTRIBUTING docs if your change introduces a new contribution surface or a new bar a PR has to clear. +- **Skill prompt bodies stay in their source language.** Don't translate `SKILL.md` — it's part of the prompt stack the agent reads, and keeping one source language avoids multiplying prompt QA across locales. +- Daemon error messages, file names, and agent-generated artifact text are known limitations unless a PR explicitly scopes them. + +--- + +## License + +By contributing, you agree your contribution is licensed under the [Apache-2.0 License](LICENSE) of this repository. + +Vendored work retains its original license and authorship attribution — see each `src/lib/templates/skills//` folder's own `LICENSE` / `README.md` for what it inherits from upstream. The most prominent example is [`src/lib/templates/skills/deck-guizang-editorial/`](src/lib/templates/skills/deck-guizang-editorial/), which retains the original license and authorship attribution to [op7418](https://github.com/op7418). + +[skill]: https://docs.anthropic.com/en/docs/claude-code/skills diff --git a/CONTRIBUTING.zh-CN.md b/CONTRIBUTING.zh-CN.md new file mode 100644 index 0000000..7f7f394 --- /dev/null +++ b/CONTRIBUTING.zh-CN.md @@ -0,0 +1,240 @@ +# 为 HTML Anything 贡献代码 + +谢谢你愿意贡献。HTML Anything 是一个故意保持小的项目 —— 大部分价值都在**文件**里(skill 文件夹、提示词片段、agent adapter),而不在框架代码里。回报率最高的贡献,通常是一个文件夹、一个 Markdown 文件,或者十行 adapter。 + +这份指南告诉你:每种贡献该放在哪个目录,PR 在合并前要过哪些线。 + +

English · 简体中文

+ +--- + +## 一个下午就能交付的三件事 + +| 你想做什么 | 你其实是在加 | 文件位置 | 体积 | +|---|---|---|---| +| 让 HTML Anything 渲染一种新作品(发票、招聘启事、iOS 设置页…) | 一个 **Skill** | [`src/lib/templates/skills//`](src/lib/templates/skills/) | 一个文件夹,约 3 个文件 | +| 接入一个新的 coding-agent CLI | 一个 **Agent adapter** | [`src/lib/agents/argv.ts`](src/lib/agents/argv.ts) + [`src/lib/agents/detect.ts`](src/lib/agents/detect.ts) | 一个数组里加 10 行 | +| 加一个新的发布目标(视频号、抖音字幕、Notion …) | 一个 **Export adapter** | [`src/components/drafts-menu.tsx`](src/components/drafts-menu.tsx) + `src/lib/export/` 下的 helper | 一个组件 + 一个 helper | +| 加功能、修 bug、重构流式 parser | 代码 | `src/app/`, `src/lib/`, `src/components/` | 常规 PR | +| 改文档、把某一段翻译到另一种语言、修错别字 | 文档 | `README.md`, `README.zh-CN.md`, 本文 | 一个 PR | + +不确定自己的想法属于哪一类?[先开个 issue](https://github.com/nexu-io/html-anything/issues/new),我们帮你定位到对应的目录。 + +--- + +## 本地开发 + +```bash +git clone https://github.com/nexu-io/html-anything.git +cd html-anything +pnpm install +pnpm dev # next dev — http://localhost:3000 +pnpm build # next build,发布前验包 +``` + +Node `~20` 与 `pnpm` 是硬要求。macOS、Linux、WSL2 是主路径;纯 Windows 应该能跑,但不是主要目标 —— 跑不通请提 issue。 + +push 之前请确保你**至少有一个 coding-agent CLI 登录好了**(`claude login`、`cursor login`、`gemini auth` …),这样才能跑端到端生成。涉及流式或 agent 层的 PR,期望附上截图或日志片段,证明它真的跑通了。 + +> **关于项目形态的说明。** 这是一个普通的 Next.js 16 App Router 项目 —— 没有 daemon、没有 Electron 壳、没有别的常驻进程。所有事都在 `next dev` 里完成:server route 直接 spawn 本地 CLI,把 stdout 作为 SSE 流回浏览器,浏览器 append 进 iframe `srcdoc`。如果你打算引入一个独立的长期进程,**请先开 discussion**。 + +--- + +## 加一个新的 Skill + +skill 是 [`src/lib/templates/skills/`](src/lib/templates/skills/) 下的一个文件夹,根目录有 `SKILL.md`,遵循 Claude Code 的 [`SKILL.md` 约定][skill] + 我们的扩展 frontmatter(picker 会读这部分)。**不需要在任何地方注册。** 把文件夹放进去,重启 `pnpm dev`,picker 里就出现了。 + +### Skill 目录结构 + +```text +src/lib/templates/skills/your-skill/ +├── SKILL.md # 必需 —— 提示词正文 + frontmatter +├── example.html # 必需 —— 你希望 agent 产出什么样的成品,手写一份 +├── assets/ # 可选 —— 字体、图片、可复用 CSS、布局片段 +└── references/ # 可选 —— 设计系统片段、agent 应该 Read 的参考资料 +``` + +### `SKILL.md` 写法 + +```markdown +--- +name: your-skill +description: picker 预览里显示的一句话简介。 +mode: prototype # prototype | deck | frame | social | office | doc | mockup | vfx +scenario: marketing # design | marketing | engineering | product | finance | hr | sale | personal +surface: desktop # desktop | mobile | A4 | 1080x1920 | 1600x900 | 1920x1080 | … +preview: + type: iframe # iframe | image | deck + thumbnail: docs/screenshots/skills/your-skill.png # 可选,README 精选示例网格会用 +design_system: + requires: optional # required | optional | none +featured: false # 想登上 "精选示例" 就置 true +example_prompt: | + 三句话的示例 prompt,演示这个 skill 接受什么样的输入。 +--- + +# Your Skill + +<一段话写清身份 / 纪律 —— 声音、意图、agent 必须遵守什么> + +## 硬约束 +- 8 px 基线网格 · 所有间距 / line-height / 字号都是 8 的倍数。 +- 中文字体栈:`"Noto Sans SC", "PingFang SC", "Source Han Sans"`;英文:`"Inter", "Manrope"`。 +- 颜色对比 ≥ 4.5。每个可交互元素都有真实 `:focus` 态。 +- 必须使用用户提供的真实数据。禁止 `lorem ipsum`、禁止编造指标、禁止紫色渐变。 + +## 版式 + +<结构 / 区块 / 层级,用具体 token —— 尺寸、比例、命名插槽 —— 不要用空泛的审美词> + +## "好" 长什么样 +- <一句正面例子> +- <再一句> + +## "坏" 长什么样 +- <一句反面例子,对应反 AI-slop 黑名单> +- <再一句> +``` + +### Skill PR 合并标准 + +1. **文件夹里附真实 `example.html`。** 手写一份 —— agent 才有 target 可抄。没附的 PR 直接打回。 +2. **`example.html` 能在浏览器里渲染**(`pnpm dev` → 选这个 skill → ⌘+Enter → 截图)。截图附 PR。 +3. **硬约束写得具体。** "用现代字体" 不是约束。真正的约束长这样:"Inter 96 / 64 / 40 / 24 / 16 px 字号梯度,8px 网格,每页最多两个字重"。 +4. **example 里不许有 `lorem ipsum`**。要用占位数据,也得是看起来像真的占位数据。 +5. **slug 用小写 + 连字符** —— `deck-swiss-international`、`social-x-post-card`。和已有的 75 个文件夹保持一致。 +6. **vendor 进来的作品,必须保留原始 `LICENSE` 和署名**。比如 [`src/lib/templates/skills/deck-guizang-editorial/`](src/lib/templates/skills/deck-guizang-editorial/) 完整保留了 op7418 的 LICENSE 和署名。 + +### picker 分组规则 + +picker 用两个维度组织 skill。**优先用已有的取值**,只有当你的 skill 真的不适合任何已有值时才引入新值: + +- **`mode`** —— `prototype` · `deck` · `frame` · `social` · `office` · `doc` · `mockup` · `vfx` +- **`scenario`** —— `design` · `marketing` · `engineering` · `product` · `finance` · `hr` · `sale` · `personal` + +--- + +## 加一个新的 coding-agent CLI + +接入新 agent(比如某家的 `foo-coder` CLI),就在 [`src/lib/agents/argv.ts`](src/lib/agents/argv.ts) 加一行: + +```ts +{ + id: 'foo', + name: 'Foo Coder', + bin: 'foo', + detect: { args: ['--version'] }, + build: (prompt: string) => ({ + args: ['exec', '-p', prompt], + stdin: null, // 如果 CLI 走 stdin,写 'prompt' + }), + stream: 'plain', // 'plain' | 'json-event' | 'claude-stream-json' +} +``` + +完事。`/api/agents` 会在 `PATH` 上扫到它,顶栏 picker 会出现,chat 流程走同一条 SSE 管道。如果这个 CLI 输出**有类型的事件**(比如 Claude Code 的 `--output-format stream-json`),在 [`src/lib/agents/invoke.ts`](src/lib/agents/invoke.ts) 加 parser,把 `stream` 设成 `'claude-stream-json'`。 + +### Agent adapter PR 合并标准 + +1. **端到端跑通过一次真实 session。** `pnpm dev` 起服,选你接入的 agent,用任一 skill 的 `example_prompt` 生成一次,把 SSE 日志贴在 PR 描述里。 +2. **`PATH` 检测在 macOS / Linux / WSL 都能找到。** 扫描器已经包含 `~/.local/bin` · `~/.bun/bin` · `/opt/homebrew/bin` · `~/.npm-global/bin`;如果你的 CLI 装在别处,把目录加进扫描列表。 +3. **README 的 "Supported coding agents" 表格加一行**,`README.md` 和 `README.zh-CN.md` 都改。 +4. **stream parser 复用。** 如果新 CLI 输出和已有 adapter 形状一致,请共用 parser,不要 fork。 + +--- + +## 加一个新的发布目标 + +发布目标分两块:`src/lib/export/` 下的 helper 负责产出字节(`.html` 的字符串、`.png` 的 Blob、粘贴的 `ClipboardItem`),[`src/components/drafts-menu.tsx`](src/components/drafts-menu.tsx) 里加一个菜单项把它接进 UI。 + +### Export PR 合并标准 + +1. **目标平台往返测试。** 把产物粘 / 上传到目标平台(公众号编辑器、X 输入框、知乎编辑器),把结果截图贴 PR。 +2. **代码里不许有平台凭证。** 需要 API Key 的话,让用户在 settings 里填,仓库不许内置。 +3. **产物自包含。** `.html` 用 `juice` 内联 CSS;PNG 用 `modern-screenshot` 走 2× DPR。 + +--- + +## 代码风格 + +我们对格式不严苛(Prettier on save 就够),但有两条不商量,因为它们直接出现在 prompt 栈和对外 API 里: + +1. **TS/TSX 用单引号。** 除非转义太丑,否则字符串一律单引号。仓库已经全员一致 —— 请保持。 +2. **代码注释一律英文。** 即使你这个 PR 是把某段翻成中文,**代码注释**也保持英文,方便整库 grep。 + +此外: + +- **不要复读。** `// import the module`、`// loop through items` 之类的注释是噪音。只在代码无法表达的"为什么"上写注释。 +- **`src/` 下用 TypeScript。** 没特别理由不要新增顶层 `.js` 文件。 +- **新增顶层依赖**要在 PR 描述里写一段"我们得到了什么 vs 我们打包多了多少字节"。[`package.json`](package.json) 的依赖列表是有意保持小的。 +- **push 前跑一次 `pnpm build`**。类型错会卡 merge。 + +--- + +## Commit & PR 规范 + +- **一个 PR 一件事。** 加一个 skill + 重构 SSE parser + 升一个依赖 = 三个 PR。 +- **标题用祈使句 + scope。** `add deck-product-launch skill`、`fix SSE backpressure when CLI hangs`、`docs: clarify skill frontmatter`。 +- **正文写"为什么"。** "做了什么"通常 diff 一看就懂;"为什么要做"很少看得出来。 +- **挂关联 issue。** 没有 issue 但 PR 又不小?请先开一个,约好这件事值得做之后再写代码。 +- **review 期间不要 squash。** 用 fixup commit;merge 时我们会 squash。 +- **不要 force-push 共享分支**,除非 reviewer 让你做。 + +我们不强制 CLA。Apache-2.0 已经覆盖;你的贡献以同一份协议授权。 + +--- + +## 报 bug + +开 issue 时请附: + +- 你跑的命令(确切的 `pnpm dev` 调用,或者你点的 UI 按钮)。 +- 选的是哪个 agent CLI(Claude Code? Cursor Agent? …)。 +- 触发的是哪个 skill。 +- 相关的 **server 日志尾部** —— "artifact 没有渲染出来"这类问题,看到 `spawn ENOENT` 或者 CLI 的真实错误,通常 30 秒就能诊断。 +- 如果是 UI 问题,附截图。 + +prompt 栈相关的 bug("agent 输出了紫色渐变 hero,`SKILL.md` 里明明禁止了"),请把**完整的 assistant message** 贴进来,方便判断是模型违规还是 prompt 写漏了。 + +--- + +## 提问 + +- 架构问题、设计问题、"这算 bug 还是误用" → [GitHub Discussions](https://github.com/nexu-io/html-anything/discussions)(首选 —— 下一个遇到同样问题的人能搜到)。 +- "怎么写一个做 X 的 skill" → 开 discussion。我们回答之后,如果这是一个新的 pattern,会把答案沉淀进这份文档。 + +--- + +## 我们不接受的 PR + +为了让项目保持聚焦,请不要提以下 PR: + +- **内置模型 runtime。** 这个项目押注的就是"你已经有的 CLI 就够了"。不内置 `pi-ai`、不内置 OpenAI key、不内置 model loader、不内置托管推理 proxy。 +- **未经讨论就把前端栈换掉。** Next.js 16 App Router + React 19 + Tailwind v4 + TypeScript 是底线。不要换 Astro / Solid / Svelte 等等,除非维护者明确想做这次迁移。 +- **把 SSE 流式换成 WebSocket / 长轮询。** SSE + iframe `srcdoc` append 是最简单能做到实时渲染的方案,我们保留它。 +- **加 telemetry / analytics / phone-home。** 项目是 local-first。唯一的对外调用是你笔记本上的 agent CLI,以及生成出来的 HTML 自己引用的资源(Tailwind CDN、Google Fonts)。 +- **打包二进制**但不附 LICENSE 与署名。 +- **加一个 skill,但 `example.html` 是空的 / 占位的 / 一眼模型批量糊出来没审过。** skill 的价值靠 example 撑;空 example 直接打回。 + +不确定自己的想法合不合适?写代码之前先开 discussion。 + +--- + +## 多语言维护 + +仓库以 parity 方式维护两种语言:英文(`README.md`、`CONTRIBUTING.md`)与简体中文(`README.zh-CN.md`、`CONTRIBUTING.zh-CN.md`)。新增 / 改名 skill、agent、export 目标时: + +- **两份 README 的表格都要更新。** +- 如果你的改动引入了新的贡献入口或新的合并标准,**两份 CONTRIBUTING 都要同步**。 +- **Skill 提示词正文保持源语言。** 不要翻译 `SKILL.md` —— 它是给 agent 读的 prompt 栈的一部分,保持单一源语言能避免提示词 QA 跨语言膨胀。 +- server 错误消息、文件名、agent 生成的产物文本都是已知短板,除非 PR 明确把它们纳入改动范围。 + +--- + +## License + +参与贡献即代表你同意:你的贡献以本仓库的 [Apache-2.0 License](LICENSE) 授权。 + +vendor 进来的第三方作品保留**原始** LICENSE 与署名 —— 每个 `src/lib/templates/skills//` 文件夹里的 `LICENSE` / `README.md` 以它为准。最明显的例子是 [`src/lib/templates/skills/deck-guizang-editorial/`](src/lib/templates/skills/deck-guizang-editorial/),完整保留了 [op7418](https://github.com/op7418) 的原始 LICENSE 与署名。 + +[skill]: https://docs.anthropic.com/en/docs/claude-code/skills diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/PROGRESS.md b/PROGRESS.md new file mode 100644 index 0000000..e17f265 --- /dev/null +++ b/PROGRESS.md @@ -0,0 +1,273 @@ +# HTML Anything — 构建进度追踪 + +> 持久化任务列表,防止 context 丢失。每完成一项就更新此文件。 +> +> **当前状态(v5 / 2026-05)**: 75 个 skill 模板(13 类 / 9 大交付场景)· 17 种 coding agent 自动检测(其中 8 种 stdin 协议立即可用,9 种 ACP/pi-rpc 协议适配中)· dev server 默认端口 `:3000`(README / CONTRIBUTING 口径),开发可 `PORT=3021 pnpm dev` 自定义。 +> +> *以下早期 v1 状态记录保留作为历史,反映项目从 9 个内置模板演进到 75 个 skill 注册表的轨迹。* +> 最近更新: **全部 11 项任务完成 ✅** — 端到端跑通, dev server 在 :3456 + +## 项目目标 + +把任意数据/文档(文本、Markdown、CSV、Excel、JSON、SQL、图片)转成"世界级好看"的 HTML: +PPT、简历、海报、知识卡片、社媒图、网页原型、文章、Hyperframes 视频脚本…… +然后一键复制成公众号/推特/知乎可发布格式,或截图导出。 + +灵感来源: Claude Code 创始人不再用 markdown 全用 HTML 表达 → 我们做 "everything → HTML"。 + +## 全局任务清单 + +| # | 任务 | 状态 | 备注 | +|---|------|------|------| +| 1 | 研究参考项目核心特性 | ✅ | open-design agent 检测 / markdown-nice 复制 / markdown-to-image 截图 都已研究 | +| 2 | 初始化 Next.js 项目骨架 | ✅ | Next.js 16 + React 19 + Tailwind v4 + Turbopack | +| 3 | 实现本地 Code Agent 检测 API | ✅ | detect.ts + argv.ts + invoke.ts + /api/agents + /api/convert SSE | +| 4 | 构建编辑器/预览主界面布局 | ✅ | toolbar + agent picker + template picker + editor pane + preview pane + export menu | +| 5 | 实现多种数据格式输入处理 | ✅ | auto detect (md/csv/tsv/json/yaml/sql/html) + xlsx 解析 + 图片 dataUrl + 文件上传 | +| 6 | 设计多套世界级 HTML 模板/设计系统 | ✅ | 9 个模板: 文章/PPT/简历/海报/小红书/推特/Web 原型/数据报告/Hyperframes | +| 7 | 实现 AI 转换流水线 | ✅ | spawn agent → SSE → 客户端 hook → iframe 实时刷新 (Claude 测试通过 80s/31KB) | +| 8 | 实现一键复制到公众号/推特/知乎 | ✅ | juice 内联 + ClipboardItem + 知乎数学公式处理 + Safari fallback | +| 9 | 实现 HTML 截图/导出/分享 | ✅ | modern-screenshot 2x PNG / 复制到剪贴板 / 下载 .html / .png | +| 10 | 编写有传播力的 README | ✅ | 中英双语, 含 architecture/templates/agents 表/致谢 | +| 11 | 端到端验证整个流程跑通 | ✅ | curl POST /api/convert → claude → SSE → 完整 HTML 输出, 0 错误 | + +## 技术决策 + +- **框架**: Next.js 16 App Router + Server Components 边界控制 +- **样式**: Tailwind v4 (无配置文件, CSS-first) +- **编辑器**: 简洁 textarea + 标签页(文本/MD/CSV/Excel/JSON/上传),不引入 Monaco +- **预览**: iframe srcdoc 沙箱,每次转换后注入完整 HTML +- **状态**: zustand (客户端) + RSC fetch +- **AI 调用**: 优先本地 CLI agent(spawn + stdin pipe + SSE relay),可选 API key 兜底 +- **复制**: juice 内联样式 → ClipboardItem({'text/html', 'text/plain'}) +- **截图**: modern-screenshot domToBlob (image/png) → 剪贴板或下载 +- **模板**: 每个模板 = name + category + aspectRatio + designTokens + promptTemplate + - 提示词中包含 Tailwind CDN 引导 + 字体 + 配色 + 排版规则 → AI 输出自包含 HTML + +## 文件结构(计划) + +``` +src/ +├── app/ +│ ├── api/ +│ │ ├── agents/route.ts ✅ GET 检测 agent +│ │ └── convert/route.ts ⏳ POST 流式转换 +│ ├── layout.tsx ⏳ 改 metadata + 字体 +│ ├── page.tsx ⏳ 主编辑器 +│ └── globals.css ⏳ 主题 +├── components/ +│ ├── editor-pane.tsx +│ ├── preview-pane.tsx +│ ├── toolbar.tsx +│ ├── agent-picker.tsx +│ ├── template-picker.tsx +│ ├── export-menu.tsx +│ └── upload-dropzone.tsx +├── lib/ +│ ├── agents/ +│ │ ├── detect.ts ✅ +│ │ ├── argv.ts ✅ +│ │ └── invoke.ts ⏳ spawn helper +│ ├── parsers/ +│ │ ├── csv.ts ⏳ +│ │ ├── xlsx.ts ⏳ +│ │ └── auto.ts ⏳ 自动判断输入类型 +│ ├── templates/ +│ │ ├── index.ts ⏳ +│ │ ├── article.ts +│ │ ├── ppt.ts +│ │ ├── resume.ts +│ │ ├── poster.ts +│ │ ├── xiaohongshu.ts +│ │ ├── twitter-card.ts +│ │ ├── prototype.ts +│ │ └── hyperframes.ts +│ ├── export/ +│ │ ├── wechat.ts ⏳ +│ │ ├── twitter.ts +│ │ ├── zhihu.ts +│ │ └── image.ts +│ └── store.ts ⏳ zustand +├── PROGRESS.md ✅ 本文件 +└── README.md ⏳ +``` + +## 待办(细化) — 全部 ✅ + +- [x] task-1 研究 (open-design / markdown-nice / markdown-to-image) +- [x] task-2 init Next.js 16 + Tailwind v4 + Turbopack +- [x] task-3.1 detect.ts (PATH 扫描 + 16 个 agent 定义) +- [x] task-3.2 argv.ts (8 个 agent 的 headless 调用参数 + 解析器) +- [x] task-3.3 /api/agents/route.ts +- [x] task-3.4 invoke.ts (spawn + stream + 防御性 close 处理) +- [x] task-4.1 store.ts (zustand + persist) +- [x] task-4.2 主界面布局 (toolbar + 50/50 split) +- [x] task-4.3 toolbar (logo + agent + template + convert + export + ⌘+Enter) +- [x] task-4.4 agent-picker (auto detect + refresh + 已安装/未安装分组) +- [x] task-4.4 template-picker (9 个模板 emoji + 尺寸提示) +- [x] task-4.5 editor-pane (输入/上传/示例 三 tab + 自动格式识别) +- [x] task-4.5 preview-pane (预览/代码/日志 三 tab + status badge) +- [x] task-5.1 parsers/auto.ts (md/csv/tsv/json/yaml/sql/html 自动识别) +- [x] task-5.2 parsers/file.ts (xlsx 解析 + 图片 dataUrl + 文本) +- [x] task-5.2 upload-dropzone (拖拽 + 点击) +- [x] task-6.1 templates/index.ts 注册表 +- [x] task-6.2 9 个模板(高质量提示词 + 设计系统约束) +- [x] task-7.1 /api/convert/route.ts (SSE relay) +- [x] task-7.2 use-convert.ts (客户端 SSE 解析 + 流式 append) +- [x] task-7.3 extract-html.ts (从 chatty agent 输出抽取 HTML) +- [x] task-8.1 export/wechat.ts (juice 内联) +- [x] task-8.2 export/zhihu.ts (math → data-eeimg) +- [x] task-8.3 export/image.ts (modern-screenshot 2x PNG) +- [x] task-8.4 export/clipboard.ts (ClipboardItem + Safari fallback) +- [x] task-8.5 export-menu UI (7 个动作: 公众号/知乎/推特/HTML/纯文本/.html/.png) +- [x] task-9 download.ts (.html / .png) +- [x] task-10 README 中英文 + agent 表 + 模板表 + architecture 图 +- [x] task-11 dev server :3456 + 真实 claude 调用通过 + +## 风险记录 + +- Tailwind v4 + 在 iframe 预览中需要 CDN 注入(每次转换的 HTML 中包含 Tailwind play CDN)✅ 已在所有模板提示词中要求 +- 本地 agent 不存在时需引导用户 → 显示安装指引(claude/cursor/codex)✅ agent-picker 显示"未检测到", 未安装条目 disabled 显示 +- HTML 通过 iframe srcdoc 注入: 注意 XSS, 严格沙箱 ✅ `sandbox="allow-scripts allow-same-origin"`, 没有给到 top-frame +- Iframe srcdoc 在流式更新时会刷新 → 当前是接受的代价 (claude 大约 2-3 个 chunk, 不是字符级流), 后续可加 debounce + +## 验证记录 (2026-05-10 23:30) + +- `pnpm exec tsc --noEmit` ✅ 0 errors +- `GET / 200` (295ms, SSR + Tailwind v4 OK) +- `GET /api/agents 200` → 检测到 7 个本地 agent (claude/codex/cursor-agent/gemini/copilot/opencode/aider) +- `POST /api/convert` (claude, article-magazine 模板) → 80s 内返回完整 SSE 流, 4 个事件 (start + 2 deltas + done), 31KB HTML +- 生成的 HTML: 自包含 Tailwind CDN + Google Fonts (Inter/Manrope/Noto Sans/Serif SC/JetBrains Mono) + 自定义 theme + 真实文章排版 + +## 启动方式 + +```bash +cd /Users/pftom/.superset/projects/html-everthing +pnpm dev # 默认 :3000 +# 或 PORT=3456 pnpm dev +``` + +## v4 升级 (2026-05-11) — 模型选择 + 首次入口流程 + +用户反馈: 首次进来如果没配置过 agent, 应该自动弹框选择 + 配置, 包括能选对应 CLI 的模型. + +页面入口的 auto-popup 之前已经在 `page.tsx:24` 实现 (`!welcomeAck || !selectedAgent → setOpen(true)`); 本次补齐**模型选择** + **协议感知**. + +### 关键改动文件 +- `src/lib/agents/detect.ts` + - 新加 `ModelOption` 类型 + `DEFAULT_MODEL` 常量 ("default" → 不传 `--model`, 让 CLI 自己挑) + - 17 个 agent 全部带 `fallbackModels`, 参考 open-design 的 `apps/daemon/src/agents.ts` 的 evidence-based 列表 + - `DetectedAgent` 暴露 `protocol` + `models` + `unsupported`, 客户端一次拉到所有 picker 数据 +- `src/lib/store.ts` — 新 `agentModels: Record` 持久化每个 agent 的最近选择;`AgentInfo` 类型同步加 `protocol/models/unsupported` 字段;persist v2 → v3, 加 `setAgentModel(agent, model)` setter +- `src/components/welcome-modal.tsx` + - 新 `ModelPicker` 子组件 — 选中 agent 后渲染圆角 chip 列表, 点击切换并写入 `agentModels[id]` + - 新 `PROTOCOL_HINT` — 在 agent card 上加 `stdin · stream` / `positional argv` / `ACP JSON-RPC · 暂未接入` / `pi-rpc · 暂未接入` 标签 + - 选中 ACP/pi-rpc 时 footer 显红色提示 + "进入编辑器" 按钮 disabled (不阻止用户继续浏览, 但拒绝带未支持 agent 进入流程) +- `src/lib/use-convert.ts` — `ConvertReq` 加 `model`, payload 中按需 `{model: ...}`, log 行包含模型名 +- `src/components/toolbar.tsx` + - 从 store 读 `agentModels[selectedAgent]`, 透传到 `run()` + - agent button 增加 mono 风格 model badge (default 时不显示) + - `canConvert` 现在也排除 `agentInfo?.unsupported` 的情形, 配套 friendly tooltip + +### 验证 +- `pnpm exec tsc --noEmit` ✅ 0 errors in agents/store/use-convert/toolbar/welcome-modal +- `GET /api/agents` → 17 个 agent, 每个含 1-9 个 model, protocol 字段正确 (stdin × 9 / argv × 1 / acp × 6 / pi-rpc × 1), unsupported 标记齐 +- `POST /api/convert {agent:"claude", model:"haiku", ...}` → spawn argv 末尾确实多出 `--model haiku` ✅ +- 首次访问 (清 localStorage) → modal 自动弹出, 选 agent → 出现 model chip 行 → 选 sonnet → 进入编辑器后 toolbar 显示 `Claude Code [sonnet] ›` + +## v3 升级 (2026-05-11) — 全量 code agent 接入 + +参考 open-design 的 `apps/daemon/src/agents.ts`,把代理矩阵从 8 → 17: + +| # | agent | bin | 协议 | 备注 | +|---|-------|-----|------|------| +| ✅ | claude | `claude` (fb: openclaude/openclaw) | stdin · stream-json | 加 openclaw fork 入口 | +| ✅ | codex | `codex` | stdin · json | 既有 | +| ✅ | cursor-agent | `cursor-agent` | stdin · stream-json | 既有 | +| ✅ | gemini | `gemini` | stdin · stream-json | 既有 | +| ✅ | copilot | `copilot` | stdin · json | 既有 | +| ✅ | opencode | `opencode-cli`/`opencode` | stdin · json | 既有 | +| ✅ | qwen | `qwen` | stdin · plain | 既有 | +| ✅ | aider | `aider` | stdin · plain | 既有 | +| ✅ | qoder | `qodercli` | stdin · stream-json | 新, 复用 claude 风格 envelope 解析 | +| ✅ | deepseek | `deepseek` | **argv** · plain | 新, prompt 走位置参数 (`exec --auto `); invoke.ts 加 `protocol:"argv"` 分支 | +| ⚠️ | hermes | `hermes` | ACP JSON-RPC | 检测+安装提示,调用时返回 `UnsupportedAgentProtocolError` | +| ⚠️ | kimi | `kimi` | ACP JSON-RPC | 同上 | +| ⚠️ | devin | `devin` | ACP JSON-RPC | 同上 | +| ⚠️ | kiro | `kiro-cli` | ACP JSON-RPC | 同上 | +| ⚠️ | kilo | `kilo` | ACP JSON-RPC | 同上 | +| ⚠️ | vibe | `vibe-acp` | ACP JSON-RPC | 同上 | +| ⚠️ | pi | `pi` | pi-rpc (Inflection 自有 RPC) | 同上 | + +### 关键改动文件 +- `src/lib/agents/detect.ts` — 加 `AgentProtocol` 类型 (stdin/argv/acp/pi-rpc); 注册 9 个新 agent; claude 加 `openclaw` fallback +- `src/lib/agents/argv.ts` — 加 `UnsupportedAgentProtocolError`; `buildArgv` 新增 qoder/deepseek 分支 + 6 个 ACP agent + pi 全部抛 friendly error; `parseLine` 处理 qoder envelope + 把 deepseek 视作 plain text (同 aider) +- `src/lib/agents/invoke.ts` — `protocol:"argv"` 时把 `opts.prompt` 追加到 argv 末尾,并跳过 stdin 写入;捕获 `UnsupportedAgentProtocolError` 转 SSE error event +- `src/components/welcome-modal.tsx` — `VENDOR_HINT` 加 9 个 vendor (DeepSeek/Cognition/Mature/Moonshot/Inflection/AWS/Kilo/Mistral/Qoder), 每个含 gradient 配色 + 安装命令 + +### 验证 +- `pnpm exec tsc --noEmit` ✅ 0 errors in agents/* and welcome-modal (其它 store/toolbar 错误为 v2 task-list 重构遗留, 与本次无关) +- `GET /api/agents` → 17 个 agent, 本机 10 个已安装 (claude/codex/cursor-agent/gemini/copilot/opencode/deepseek/aider/hermes/kiro) +- `POST /api/convert {agent:"hermes",...}` → SSE 立即返回 `{"type":"error","message":"hermes uses the ACP JSON-RPC protocol, which is not yet wired up..."}` ✅ +- `POST /api/convert {agent:"pi",...}` → 未安装时报 `not installed or not on PATH` ✅ + +## v2 升级 (2026-05-11) — 用户反馈后 + +| # | 任务 | 状态 | 说明 | +|---|------|------|------| +| 12 | 增强 verbose 日志 | ✅ | DELTA/META/START/STDERR/RAW/DONE/ERROR 7 类彩色 badge + 时间戳 + +elapsed; meta 含 model/session/cwd/usage/cost/duration | +| 13 | 进入页 agent 选择弹框 | ✅ | 居中 modal, 已安装/未安装分组卡片, 含 vendor 渐变色块 + 安装命令提示 + pulse-dot | +| 14 | 整体样式 1:1 open-design | ✅ | paper/bone/ink/coral 调色 + Inter Tight + Playfair italic 强调 + 圆角 pill + paper-grain 背景 | +| 15 | 代码 + 预览实时流式 | ✅ | claude 加 --include-partial-messages → 150 deltas (vs 旧 2 chunks); 代码 tab 自动 scroll + ▍ 光标; iframe 320ms 防抖 | + +### 关键改动文件 +- `src/lib/agents/argv.ts` — 加 `--include-partial-messages` 给 claude; 新 `parseLine()` 抽出 delta + meta (model/session/usage/cost/duration/rate_limit) +- `src/lib/agents/invoke.ts` — emit `meta` / `raw` / `start.promptBytes` 事件 +- `src/lib/use-convert.ts` — 客户端解析 meta, 写入 `stats` (model/tokens/cost/ttfb) +- `src/lib/store.ts` — 新 `RunStats` + 结构化 `LogEntry` (kind/elapsed/data) +- `src/components/welcome-modal.tsx` — 新建, 1:1 open-design 风格 +- `src/components/toolbar.tsx` — agent button 改 pill 形式 + 实时 stats +- `src/components/preview-pane.tsx` — 代码 tab 流式 + 光标; debouncedHtml 320ms; LogPanel 7 色 badge +- `src/components/template-picker.tsx` — 改成卡片下拉 +- `src/components/export-menu.tsx` — 三段式分组 +- `src/app/globals.css` — 完整 open-design 调色板 + .pill / .btn-primary / .btn-ghost / .btn-ink / .od-card / .pulse-dot / .serif-em +- `src/app/layout.tsx` — Inter / Inter Tight / Playfair Display / JetBrains Mono 4 套字体 +- `src/app/page.tsx` — 挂载 WelcomeModal, hydration-safe + +## 所有参考的项目地址+原始 query + +我要做一个任意数据文档可以转 HTML 的一个产品,一个编辑器产品。它核心可以完成这么几个事情: + 首先一进来可以去识别用户的本地的 code agent. + 然后可以选择某个 code agent, 这是第一个。第二个的话就是说它左侧是一个编辑器或者是 upload + 的一个入口,然后右侧是一个 HTML 的预览。然后呢左侧的话可以去直接贴一些文本,无论是 markdown + 的纯文本还是一些 CSV、excel 等数据,对或者是一些 CQ 等 database,然后可以直接贴进去,或者是有一个上传入口 + 可以上传进去。然后上点完之后呢,又点完一个转换之后呢,就可以调 AI,然后去转成右侧的 HTML。这第二个然后第 3 + 个的话就是右边那个 HTML,它可以去选择不同的 design system 或者是好看的模板。然后可以结合一套完整的提示词 + 加流程,把这个左侧的这些数据或者是文档转成右侧,按照这个模板好看的 HTML,然后这个 HTML 可以是 PPT + 或者是原型/或者通过 html 可以表达的内容,比如简历/hyperframes 视频等的形态对。然后呢,这些转换之后的 HTML + 可以一键复制成公众号、推特或者是知乎等格式的发布的数据的形态,然后可以复制到剪贴板。 + + 参考如下这几个项目完成工作: + - 编辑器/html 预览/复制到推特/公众号知乎等格式参考:https://github.com/mdnice/markdown-nice + - 各种 design + system/examples/模板等/形态(ppt/网页/简历等,或者你可以拓展很多有价值的场景,比如海报/各种社媒配图等), + 这个参考 https://github.com/nexu-io/open-design https://github.com/mdnice/markdown-resume + https://github.com/jimliu/baoyu-skills https://github.com/gcui-art/markdown-to-image + - 识别本地 code agent 参考 https://github.com/nexu-io/open-design + - 转成 hyperframes 视频/remotion 视频:参见https://github.com/remotion-dev/remotion + https://github.com/heygen-com/hyperframes https://github.com/nexu-io/open-design + + 需要仔细检查最后有没有跑完并完成整个工作,整体的目标就是 everthing 都可以表达为 html,然后被表达为 html + 可以表达的任何内容,核心是一定要好看/世界级设计水准/让用户易于传播 + + 使用 nextjs 实现项目,用户要做就是识别 agent,填入内容或者上传内容,然后转化(调用 + agent),然后下载/分享等过程一定要易用,快捷,有价值 + + 搞定之后,可以 写个 readme, 参考 https://github.com/nexu-io/open-design , + 有很多技术/例子/图片等,让用户觉得非常有价值能宣传和传播 + 这个项目的缘由是 claude code 创始人发了一篇文章,说他们现在不用 markdown,全部用 html 表达了,我们希望呈现 + everhting 到 html ,以及能做的事情,传播和吃热度 https://x.com/AlchainHust/status/2053138568818684101 + https://github.com/alchaincyf/huashu-md-html https://x.com/trq212/status/2052809885763747935 + + 可以先写个 plan+todo 列表,然后围绕 todo 列表一遍遍检查,直到所有 todo + 都勾掉确保能够完成跑通直接可以使用,我明天早上过来检查你的任务 diff --git a/README.md b/README.md new file mode 100644 index 0000000..0a7029a --- /dev/null +++ b/README.md @@ -0,0 +1,520 @@ +# HTML Anything + +

From the team behind Open Design40k★ · 200+ contributors, production-grade and iterating faster. html-anything is the focused agent-era HTML editor; if it clicks for you, Open Design is where the same team ships at scale.

+ +

Live page: open-design.ai/html-anything/ — overview, surface modes, and showcase before you clone.

+ +> **Markdown is the draft. HTML is what humans read. Your local agent writes it.** The agentic HTML editor — in the agentic era, you don't hand-edit docs anymore, so the output format should be what the reader actually wants: HTML. Local-first, zero API key, reuses the CLI session you already have logged in — **9 coding-agent CLIs** auto-detected on your `PATH` (Claude Code · Cursor Agent · Codex · Gemini CLI · GitHub Copilot CLI · OpenCode · Qwen Coder · Aider · IBM Bob), driven by **75 composable skill templates** across **9 deliverable surfaces** (magazine articles · keynote decks · résumés · posters · Xiaohongshu cards · tweet cards · web prototypes · data reports · Hyperframes videos). One-click export to WeChat / X / Zhihu, or download `.html` / `.png`. + +

+ HTML Anything — the agentic HTML editor, on your laptop +

+ +

+ License + Agents + Skills + Export + Quickstart + No API key +

+ + +

+ Discord (html-anything) + Follow @nexudotio on X + open-design release + open-design commits / month + Design systems + Built on open-design +

+ +

English · 简体中文

+ +--- + +## Showcase + +The eight skills that surface at the top of the picker's **Featured / 推荐** group — sorted by their `recommended:` rank in `SKILL.md` frontmatter (lower = higher). Each ships a real `example.html` you can open straight from the repo, no auth, no setup. + + + + + + + + + + + + + + + + + + +
+deck-guizang-editorial
+deck-guizang-editorial · deck · recommended: 1
Magazine × e-ink editorial deck, inspired by op7418/guizang-ppt-skill — 10 locked layouts × 5 palettes (Ink / Indigo Porcelain / Forest Ink / Kraft / Dune). Reads like a printed art-zine, not a slide deck.
+
+deck-swiss-international
+deck-swiss-international · deck · recommended: 2
Swiss International deck — 16-column grid + one saturated accent (Klein Blue / Lemon / Mint / Safety Orange) across 22 locked layouts. Cold, rational, institutional. The deck that reads "a designer made this" the moment it opens.
+
+doc-kami-parchment
+doc-kami-parchment · doc · recommended: 3
Warm-parchment editorial document, inspired by tw93/kami. #f5f4ed ground + ink-blue accent + single serif voice — a noticeably calmer reading surface than plain-white markdown for long essays, reports, and one-pagers.
+
+magazine-poster
+magazine-poster · poster · recommended: 4
Newsprint Sunday-paper poster — oversized serif headline, two-column body, six numbered sections, dot-pattern cream ground. Reads like a printed broadsheet, not a webpage.
+
+video-hyperframes
+video-hyperframes · frame / video · recommended: 5
Hyperframes / Remotion-compatible storyboard — 6–10 sequential 1920×1080 frames with hidden duration + transition markers and an auto-play script. Hand straight to heygen-com/hyperframes or Remotion to render .mp4.
+
+frame-glitch-title
+frame-glitch-title · frame · recommended: 6
Glitch title frame — cyan/magenta chromatic offset, CRT scanlines, corrupted-data subtitle, ASCII noise in the corners. Cyberpunk hero card or video transition.
+
+vfx-text-cursor
+vfx-text-cursor · vfx · recommended: 7
VFX text-cursor opener — a cursor "types" across the canvas, each character revealed with a hot-pink × cyan chromatic trail and directional light leaks. Drop in a quote, get a film-grade opening frame.
+
+frame-logo-outro
+frame-logo-outro · frame · recommended: 8
Logo outro frame — logo assembles piece-by-piece with glow bloom, tagline rises, CTA appears. The closing card for a product reveal or brand film.
+
+ +The full skill catalog (organized by mode) is in [Skills](#skills) below. + +## Why this exists + +Anthropic's [Claude Code team announced](https://x.com/trq212/status/2052809885763747935) they stopped writing internal docs in Markdown — they ship HTML now. The argument is simple: + +| Markdown | HTML | +|---|---| +| Good for the writer | Good for the reader | +| Layout limited to the renderer | Layout is yours | +| Looks ugly screenshotted into a tweet | Already looks like a designed image | +| Has to be re-flowed for WeChat / Zhihu / newsletter | One-click format conversion | + +**HTML is the final form for humans. Markdown is just an intermediate state during writing.** + +But "writing HTML" used to mean writing CSS, picking type scales, snapping to a grid, doing responsive — most users won't, designers won't bother, writers don't have the patience. So what we built: after you press ⌘+Enter, your **local AI agent** turns any input (Markdown / CSV / Excel / JSON / SQL / raw notes) into a **ship-ready single-file HTML** in seconds, then one-click sends it to WeChat / X / Zhihu / anywhere. "Ship-ready" is the bar — when generation finishes, the artifact is what your audience actually sees. No "I'll touch it up later" pass. + +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). + +## At a glance + +| | What you get | +|---|---| +| **Coding-agent CLIs (8)** | Claude Code · Cursor Agent · OpenAI Codex · Gemini CLI · GitHub Copilot CLI · OpenCode · Qwen Coder · Aider — scanned on startup across `PATH` (including `~/.local/bin`, `~/.bun/bin`, `/opt/homebrew/bin`, `~/.npm-global/bin` — directories a GUI-launched Node process normally misses), swap from the top-bar picker. | +| **Zero API key** | We reuse the session you already have logged in via `claude login` · `cursor login` · `gemini auth`. Your existing subscription does the work; marginal cost is **$0**. | +| **75 skill templates** | `prototype` (web / SaaS landing / dashboard / data report) · `deck` (20 keynote skills incl. Swiss International, Guizang Editorial, XHS Pastel, Hermes Cyber, Replit, Magazine Web…) · `frame` (10 Hyperframes video frames — liquid hero, NYT data chart, sticky-note flowchart, glitch title, cinema light-leak, macOS notification, logo outro…) · `social` (X / Xiaohongshu / Spotify / Reddit cards) · `office` (PM spec · eng runbook · finance report · HR onboarding · invoice · OKRs · weekly update · meeting notes · kanban) · `doc` (Kami warm-parchment editorial) · `mockup` (3D device frame) · `vfx` (text-cursor effect). | +| **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 · `` → `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** | `", + ); + const body = parseFragment(html); + expect(body.querySelector("script")).toBeNull(); + expect(body.querySelector("iframe")).toBeNull(); + expect(body.querySelector("style")).toBeNull(); + expect(body.querySelector("p")?.textContent).toBe("keep"); + }); + + it("unwraps
/
but keeps their children", () => { + const html = toBilibiliHtml( + "

a

b

", + ); + const body = parseFragment(html); + expect(body.querySelector("div")).toBeNull(); + expect(body.querySelector("section")).toBeNull(); + const ps = Array.from(body.querySelectorAll("p")).map((p) => p.textContent); + expect(ps).toEqual(["a", "b"]); + }); + + it("preserves headings, lists, code, blockquotes, hr, br", () => { + const html = toBilibiliHtml( + "

Title

  • x
k
q


", + ); + const body = parseFragment(html); + expect(body.querySelector("h2")?.textContent).toBe("Title"); + expect(body.querySelector("ul li")?.textContent).toBe("x"); + expect(body.querySelector("pre code")?.textContent).toBe("k"); + expect(body.querySelector("blockquote")?.textContent).toBe("q"); + expect(body.querySelector("hr")).not.toBeNull(); + expect(body.querySelector("br")).not.toBeNull(); + }); +}); + +describe("toBilibiliHtml — attribute filtering", () => { + it("drops all attributes except those in the allow-list", () => { + const html = toBilibiliHtml( + '

hi

', + ); + const p = parseFragment(html).querySelector("p"); + expect(p?.getAttribute("class")).toBeNull(); + expect(p?.getAttribute("data-x")).toBeNull(); + expect(p?.getAttribute("onclick")).toBeNull(); + expect(p?.getAttribute("id")).toBeNull(); + expect(p?.textContent).toBe("hi"); + }); + + it("keeps href and title on , drops javascript: hrefs", () => { + const html = toBilibiliHtml( + '

ok ' + + 'bad

', + ); + const anchors = Array.from(parseFragment(html).querySelectorAll("a")); + expect(anchors[0].getAttribute("href")).toBe("https://example.com"); + expect(anchors[0].getAttribute("title")).toBe("t"); + expect(anchors[1].hasAttribute("href")).toBe(false); + }); + + it("keeps only language-* classes on ", () => { + const html = toBilibiliHtml( + '
x
', + ); + const code = parseFragment(html).querySelector("code"); + // Whitespace allowed, but the only token retained is language-ts. + const tokens = (code?.getAttribute("class") ?? "") + .split(/\s+/) + .filter(Boolean); + expect(tokens).toEqual(["language-ts"]); + }); + + it("removes the class attribute entirely when no language-* token survives", () => { + const html = toBilibiliHtml( + '
x
', + ); + const code = parseFragment(html).querySelector("code"); + expect(code?.hasAttribute("class")).toBe(false); + }); +}); + +describe("toBilibiliHtml — image rewriting", () => { + it("leaves bilibili CDN images intact", () => { + const html = toBilibiliHtml( + '

a

', + ); + const img = parseFragment(html).querySelector("img"); + expect(img?.getAttribute("src")).toBe("https://i0.hdslb.com/x.png"); + expect(img?.getAttribute("alt")).toBe("a"); + expect(img?.hasAttribute("data-bili-placeholder")).toBe(false); + }); + + it("replaces non-CDN images with a placeholder SVG and stashes the original src", () => { + const html = toBilibiliHtml( + '

ext

', + ); + const img = parseFragment(html).querySelector("img"); + expect(img?.getAttribute("data-original-src")).toBe( + "https://imgur.com/foo.png", + ); + expect(img?.getAttribute("data-bili-placeholder")).toBe("true"); + expect(img?.getAttribute("src")).toMatch(/^data:image\/svg\+xml/); + expect(img?.getAttribute("alt")).toBe("ext"); + }); + + it("rejects lookalike hostnames (no subdomain dot before the literal)", () => { + // `evilhdslb.com` and `notbilibili.com` would slip past a `[^/]*` regex + // and skip the placeholder swap — both must be treated as non-CDN. + for (const src of [ + "https://evilhdslb.com/foo.png", + "https://notbilibili.com/foo.png", + ]) { + const html = toBilibiliHtml(`

x

`); + const img = parseFragment(html).querySelector("img"); + expect(img?.getAttribute("data-bili-placeholder")).toBe("true"); + expect(img?.getAttribute("data-original-src")).toBe(src); + } + }); + + it("accepts subdomains of the bilibili CDN", () => { + for (const src of [ + "https://i0.hdslb.com/bfs/x.png", + "https://album.bilibili.com/x.png", + "https://hdslb.com/x.png", // bare apex + "https://bilibili.com/foo.png", // bare apex + ]) { + const html = toBilibiliHtml(`

x

`); + const img = parseFragment(html).querySelector("img"); + expect(img?.hasAttribute("data-bili-placeholder")).toBe(false); + expect(img?.getAttribute("src")).toBe(src); + } + }); +}); diff --git a/next/src/lib/export/__tests__/markdown-roundtrip.test.ts b/next/src/lib/export/__tests__/markdown-roundtrip.test.ts new file mode 100644 index 0000000..d2377f3 --- /dev/null +++ b/next/src/lib/export/__tests__/markdown-roundtrip.test.ts @@ -0,0 +1,356 @@ +/** + * Tests for the HTML → Markdown emitter. Each case is verified two ways: + * 1. The emitted Markdown matches expectations (substring / regex / exact). + * 2. The emitted Markdown is fed through `marked` (a real CommonMark/GFM + * parser, also a runtime dependency) and the resulting HTML is asserted + * to round-trip the structural intent. This is the closest "simulate + * real" check we can do without spinning up Hugo / 11ty / Obsidian. + */ +import { describe, it, expect, beforeAll } from "vitest"; +import { marked } from "marked"; + +import { htmlToMarkdown } from "../markdown-roundtrip"; + +beforeAll(() => { + marked.setOptions({ async: false, gfm: true, breaks: false }); +}); + +function mdToDoc(md: string): Document { + const html = marked.parse(md) as string; + return new DOMParser().parseFromString(`${html}`, "text/html"); +} + +function roundtripTags(html: string): string[] { + const md = htmlToMarkdown(html); + const doc = mdToDoc(md); + return Array.from(doc.body.children).map((c) => c.tagName.toLowerCase()); +} + +describe("htmlToMarkdown — headings", () => { + it("emits ATX headings for h1–h6", () => { + for (let lvl = 1; lvl <= 6; lvl++) { + const md = htmlToMarkdown(`Title ${lvl}`); + expect(md).toContain(`${"#".repeat(lvl)} Title ${lvl}`); + const doc = mdToDoc(md); + const h = doc.body.querySelector(`h${lvl}`); + expect(h?.textContent).toBe(`Title ${lvl}`); + } + }); +}); + +describe("htmlToMarkdown — paragraphs and inline marks", () => { + it("renders **strong**, *em*, ~~del~~", () => { + const md = htmlToMarkdown( + "

bold it gone

", + ); + expect(md).toMatch(/\*\*bold\*\*/); + expect(md).toMatch(/\*it\*/); + expect(md).toMatch(/~~gone~~/); + const doc = mdToDoc(md); + expect(doc.body.querySelector("strong")?.textContent).toBe("bold"); + expect(doc.body.querySelector("em")?.textContent).toBe("it"); + expect(doc.body.querySelector("del,s")?.textContent).toBe("gone"); + }); + + it("escapes literal *, _, ~ in plain text so they don't become marks", () => { + const md = htmlToMarkdown("

5 * 3 = 15 and _foo_ and ~~bar~~ literal

"); + // None of these should round-trip as //. + const doc = mdToDoc(md); + expect(doc.body.querySelector("em")).toBeNull(); + expect(doc.body.querySelector("strong")).toBeNull(); + expect(doc.body.querySelector("del,s")).toBeNull(); + expect(doc.body.textContent).toContain("5 * 3 = 15"); + expect(doc.body.textContent).toContain("_foo_"); + expect(doc.body.textContent).toContain("~~bar~~"); + }); +}); + +describe("htmlToMarkdown — inline code with backticks", () => { + it("handles content containing a single backtick with a longer fence", () => { + const md = htmlToMarkdown("

see a `b` c done

"); + const doc = mdToDoc(md); + const code = doc.body.querySelector("code"); + expect(code).not.toBeNull(); + // The textContent must include the literal backticks, not be split by them. + expect(code!.textContent).toContain("`b`"); + }); + + it("handles content starting and ending with a backtick", () => { + const md = htmlToMarkdown("

`tick`

"); + const doc = mdToDoc(md); + const code = doc.body.querySelector("code"); + expect(code!.textContent?.trim()).toBe("`tick`"); + }); +}); + +describe("htmlToMarkdown — fenced code blocks", () => { + it("uses the language hint from class=language-*", () => { + const md = htmlToMarkdown( + '
const x: number = 1;
', + ); + expect(md).toMatch(/```ts\nconst x: number = 1;\n```/); + const doc = mdToDoc(md); + expect(doc.body.querySelector("pre code")?.textContent?.trim()).toBe( + "const x: number = 1;", + ); + }); + + it("uses a longer fence when the body contains triple backticks", () => { + const md = htmlToMarkdown( + "
before\n```\nfake fence inside\n```\nafter
", + ); + // Body still has the original triple backticks; the outer fence is longer. + expect(md).toContain("```\nfake fence inside\n```"); + // Outer fence is at least 4 backticks long. + expect(md).toMatch(/````+\n[\s\S]*```\nfake fence inside\n```/); + const doc = mdToDoc(md); + const code = doc.body.querySelector("pre code"); + expect(code?.textContent).toContain("fake fence inside"); + expect(code?.textContent).toContain("before"); + expect(code?.textContent).toContain("after"); + }); +}); + +describe("htmlToMarkdown — links and images", () => { + it("emits a plain [text](url) for simple links", () => { + const md = htmlToMarkdown( + '

site

', + ); + expect(md).toMatch(/\[site\]\(https:\/\/example\.com\)/); + const a = mdToDoc(md).body.querySelector("a"); + expect(a?.getAttribute("href")).toBe("https://example.com"); + expect(a?.textContent).toBe("site"); + }); + + it("wraps URLs containing spaces or parens with <…>", () => { + const md = htmlToMarkdown( + '

click

', + ); + expect(md).toContain("()"); + // marked URL-encodes spaces during HTML rendering — verify the decoded + // form so we're testing the source-of-truth (the markdown the user pastes + // into Hugo/Obsidian preserves the unencoded URL). + const a = mdToDoc(md).body.querySelector("a"); + expect(decodeURI(a?.getAttribute("href") ?? "")).toBe( + "https://example.com/foo (bar)/baz", + ); + }); + + it("escapes quotes and backslashes inside the title", () => { + const md = htmlToMarkdown( + '

t

', + ); + // Title quotes are backslash-escaped, so the link form remains valid. + expect(md).toMatch(/"say \\"hi\\" \\\\back"/); + const a = mdToDoc(md).body.querySelector("a"); + expect(a?.getAttribute("title")).toBe('say "hi" \\back'); + }); + + it("escapes [ and ] inside link text", () => { + const md = htmlToMarkdown( + '

[bracketed] text

', + ); + expect(md).toMatch(/\\\[bracketed\\\] text/); + const a = mdToDoc(md).body.querySelector("a"); + expect(a?.textContent).toBe("[bracketed] text"); + }); + + it("renders images with alt, src and optional title", () => { + const md = htmlToMarkdown( + '

hello

', + ); + expect(md).toMatch(/!\[hello\]\(https:\/\/x\.com\/a\.png "t"\)/); + const img = mdToDoc(md).body.querySelector("img"); + expect(img?.getAttribute("src")).toBe("https://x.com/a.png"); + expect(img?.getAttribute("alt")).toBe("hello"); + expect(img?.getAttribute("title")).toBe("t"); + }); + + it("preserves nested inside (badge-style link)", () => { + const md = htmlToMarkdown( + '

badge

', + ); + const doc = mdToDoc(md); + const a = doc.body.querySelector("a"); + expect(a?.getAttribute("href")).toBe( + "https://repo.example/owner/proj", + ); + // The image must still be a child of the , not flattened to text. + const img = a?.querySelector("img"); + expect(img).not.toBeNull(); + expect(img?.getAttribute("src")).toBe("https://shields.io/badge.svg"); + expect(img?.getAttribute("alt")).toBe("badge"); + }); + + it("preserves nested inside ", () => { + const md = htmlToMarkdown( + '

foo

', + ); + const doc = mdToDoc(md); + const a = doc.body.querySelector("a"); + const code = a?.querySelector("code"); + expect(code).not.toBeNull(); + expect(code?.textContent).toBe("foo"); + // No stray backslashes inside the code span. + expect(code?.textContent).not.toContain("\\"); + }); +}); + +describe("htmlToMarkdown — line-start block-marker escaping", () => { + it("escapes a paragraph that starts with '1. ' so it stays a paragraph", () => { + const tags = roundtripTags("

1. First item, not a list

"); + expect(tags).toEqual(["p"]); + }); + + it("escapes a paragraph starting with '# '", () => { + const tags = roundtripTags("

# Not a heading

"); + expect(tags).toEqual(["p"]); + }); + + it("escapes a paragraph starting with '> '", () => { + const tags = roundtripTags("

> Not a quote

"); + expect(tags).toEqual(["p"]); + }); + + it("escapes a paragraph starting with '- '", () => { + const tags = roundtripTags("

- Not a bullet

"); + expect(tags).toEqual(["p"]); + }); + + it("does NOT escape mid-text markers", () => { + // "#hashtag" mid-text is not a heading and should not be escaped. + const md = htmlToMarkdown("

see #hashtag here

"); + expect(md).not.toMatch(/\\#hashtag/); + }); +}); + +describe("htmlToMarkdown — lists", () => { + it("renders a simple unordered list", () => { + const md = htmlToMarkdown("
  • a
  • b
"); + const doc = mdToDoc(md); + const items = Array.from(doc.body.querySelectorAll("ul > li")).map( + (li) => li.textContent?.trim(), + ); + expect(items).toEqual(["a", "b"]); + }); + + it("renders a nested list with correct indentation", () => { + const md = htmlToMarkdown( + "
  • outer
    • inner-a
    • inner-b
  • other
", + ); + const doc = mdToDoc(md); + const outer = doc.body.querySelector("ul"); + const innerLis = outer + ?.querySelector("li > ul") + ?.querySelectorAll(":scope > li"); + expect(innerLis?.length).toBe(2); + expect(innerLis?.[0].textContent?.trim()).toBe("inner-a"); + }); + + it("renders a list item with a paragraph and a code block inside", () => { + const md = htmlToMarkdown( + "
  1. step one

    x++;
  2. step two
", + ); + const doc = mdToDoc(md); + const lis = doc.body.querySelectorAll("ol > li"); + expect(lis.length).toBe(2); + // First
  • contains both a paragraph and a fenced code block. + expect(lis[0].querySelector("p")?.textContent?.trim()).toBe("step one"); + expect(lis[0].querySelector("pre code")?.textContent?.trim()).toBe("x++;"); + expect(lis[1].textContent?.trim()).toBe("step two"); + }); +}); + +describe("htmlToMarkdown — blockquotes, hr, tables, br", () => { + it("renders blockquotes with > prefix", () => { + const md = htmlToMarkdown( + "

    line one

    line two

    ", + ); + const doc = mdToDoc(md); + const bq = doc.body.querySelector("blockquote"); + expect(bq).not.toBeNull(); + expect(bq?.textContent).toMatch(/line one/); + expect(bq?.textContent).toMatch(/line two/); + }); + + it("renders a horizontal rule", () => { + const md = htmlToMarkdown("

    before


    after

    "); + const doc = mdToDoc(md); + expect(doc.body.querySelector("hr")).not.toBeNull(); + }); + + it("renders a GFM table", () => { + const md = htmlToMarkdown( + "
    AB
    12
    34
    ", + ); + const doc = mdToDoc(md); + const headers = Array.from(doc.body.querySelectorAll("thead th")).map( + (th) => th.textContent?.trim(), + ); + expect(headers).toEqual(["A", "B"]); + const rows = Array.from(doc.body.querySelectorAll("tbody tr")).map((tr) => + Array.from(tr.querySelectorAll("td")).map((td) => td.textContent?.trim()), + ); + expect(rows).toEqual([ + ["1", "2"], + ["3", "4"], + ]); + }); + + it("escapes pipes inside table cells", () => { + const md = htmlToMarkdown( + "
    a|b
    c|d
    ", + ); + expect(md).toMatch(/a\\\|b/); + expect(md).toMatch(/c\\\|d/); + }); + + it("keeps a cell containing
    on a single row (no smashed table)", () => { + const md = htmlToMarkdown( + "
    H
    line1
    line2
    next
    ", + ); + const doc = mdToDoc(md); + const trs = doc.body.querySelectorAll("table tr"); + // Header + 2 body rows = 3 total; if the
    had injected a literal \n, + // marked would see only the header and the table would collapse. + expect(trs.length).toBe(3); + const firstBodyCell = doc.body.querySelector("tbody tr:first-child td"); + expect(firstBodyCell?.textContent).toContain("line1"); + expect(firstBodyCell?.textContent).toContain("line2"); + expect(firstBodyCell?.querySelector("br")).not.toBeNull(); + }); +}); + +describe("htmlToMarkdown — full document smoke", () => { + it("round-trips a deck-style document end-to-end", () => { + const html = ` + +

    Demo Deck

    +

    An intro paragraph with bold and a tricky link.

    +

    Steps

    +
      +
    1. Install +
      npm i
      +
    2. +
    3. Run with a foo \`bar\` baz snippet
    4. +
    +

    Quote with 1. literal numeric prefix.

    + `; + const md = htmlToMarkdown(html); + const doc = mdToDoc(md); + expect(doc.body.querySelector("h1")?.textContent).toBe("Demo Deck"); + expect(doc.body.querySelector("h2")?.textContent).toBe("Steps"); + expect(doc.body.querySelector("ol > li pre code")?.textContent).toContain( + "npm i", + ); + expect(doc.body.querySelector("blockquote p")?.textContent).toMatch( + /1\. literal numeric prefix/, + ); + // Tricky-link href survives the angle-bracket wrap (decoded form, + // since marked percent-encodes spaces in the rendered HTML). + const a = doc.body.querySelector("a"); + expect(decodeURI(a?.getAttribute("href") ?? "")).toBe( + "https://example.com/page (v2)", + ); + }); +}); diff --git a/next/src/lib/export/__tests__/mastodon.test.ts b/next/src/lib/export/__tests__/mastodon.test.ts new file mode 100644 index 0000000..2cf71f5 --- /dev/null +++ b/next/src/lib/export/__tests__/mastodon.test.ts @@ -0,0 +1,81 @@ +/** + * Tests for `truncateForPost` — shared by both Mastodon (500 cap) and + * Bluesky (300 cap). Verifies that the function never splits a surrogate + * pair and stays under the platform limit for compound emoji (ZWJ + * sequences count as multiple code points, which is conservative). + */ +import { describe, it, expect } from "vitest"; +import { truncateForPost } from "../mastodon"; + +describe("truncateForPost — basic length handling", () => { + it("returns the trimmed input when under the limit", () => { + expect(truncateForPost(" hello world ", 50)).toBe("hello world"); + }); + + it("collapses internal whitespace runs", () => { + expect(truncateForPost("a b\t\tc\n\nd", 50)).toBe("a b c d"); + }); + + it("truncates a long ASCII string with an ellipsis", () => { + const long = "abcdefghij".repeat(10); // 100 chars + const out = truncateForPost(long, 30); + expect(out.length).toBeLessThanOrEqual(30); + expect(out.endsWith("…")).toBe(true); + }); +}); + +describe("truncateForPost — Unicode safety", () => { + it("does not split a surrogate pair", () => { + // 5 single-code-point emoji = 5 code points = 10 UTF-16 units. + const s = "🎉🎉🎉🎉🎉"; + const out = truncateForPost(s, 3); + // Output is at most 3 code points; ellipsis takes 1, so we keep 2 emoji. + const points = Array.from(out); + expect(points.length).toBeLessThanOrEqual(3); + // No lone surrogate should appear in the output. + for (const ch of out) { + const code = ch.codePointAt(0)!; + const isSurrogate = code >= 0xd800 && code <= 0xdfff; + // `for…of` on a string iterates by code point, so any individual char + // in this loop should already be a full code point, never a surrogate. + expect(isSurrogate).toBe(false); + } + expect(out.endsWith("…")).toBe(true); + }); + + it("returns input unchanged when code-point count is within limit even if UTF-16 length exceeds it", () => { + // 10 emoji = 10 code points but 20 UTF-16 units. + const s = "🎉🎉🎉🎉🎉🎉🎉🎉🎉🎉"; + expect(s.length).toBe(20); // UTF-16 units + expect(Array.from(s).length).toBe(10); // code points + // Limit 15: collapsed.length=20 > 15, but code points 10 <= 15, so the + // fast path's second check returns the string unchanged. + expect(truncateForPost(s, 15)).toBe(s); + }); + + it("counts ZWJ-joined emoji as multiple code points (documented conservative behavior)", () => { + // 👨‍👩‍👧 = 5 code points (3 emoji + 2 zero-width joiners) but 1 grapheme. + // The doc comment on truncateForPost calls this out — verify the behavior. + const family = "👨‍👩‍👧"; + expect(Array.from(family).length).toBe(5); + // With limit 3, the family alone (5 code points) exceeds it. + const out = truncateForPost(family, 3); + expect(Array.from(out).length).toBeLessThanOrEqual(3); + }); +}); + +describe("truncateForPost — boundary cases", () => { + it("handles empty input", () => { + expect(truncateForPost("", 10)).toBe(""); + }); + + it("handles limit equal to length", () => { + expect(truncateForPost("hello", 5)).toBe("hello"); + }); + + it("handles limit exactly one less than length", () => { + const out = truncateForPost("hello", 4); + expect(out.endsWith("…")).toBe(true); + expect(Array.from(out).length).toBeLessThanOrEqual(4); + }); +}); diff --git a/next/src/lib/export/__tests__/notion.test.ts b/next/src/lib/export/__tests__/notion.test.ts new file mode 100644 index 0000000..2bad515 --- /dev/null +++ b/next/src/lib/export/__tests__/notion.test.ts @@ -0,0 +1,91 @@ +/** + * Tests for the Notion exporter. Notion's paste handler honors inline styles + * (added by juice) but ignores classes and `
    `/`
    ` wrappers, + * so we verify the post-processing pipeline against real DOM parses. + */ +import { describe, it, expect } from "vitest"; +import { toNotionHtml } from "../notion"; + +function parseFragment(html: string): HTMLBodyElement { + return new DOMParser().parseFromString(`${html}`, "text/html") + .body as HTMLBodyElement; +} + +describe("toNotionHtml — wrapper unwrapping", () => { + it("unwraps
    /
    /
    /