diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..15bb6d1 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,26 @@ +.git +.DS_Store +.env +.env.* + +node_modules +next/node_modules +e2e/node_modules + +.next +next/.next +out +next/out +build + +coverage +test-results +playwright-report +e2e/ui/reports +e2e/ui/test-results + +tmp +*.tsbuildinfo +npm-debug.log* +yarn-debug.log* +pnpm-debug.log* diff --git a/docs/deploy/yaduo-server/docker-compose.yml b/docs/deploy/yaduo-server/docker-compose.yml new file mode 100644 index 0000000..179402d --- /dev/null +++ b/docs/deploy/yaduo-server/docker-compose.yml @@ -0,0 +1,22 @@ +services: + html-anything: + build: + context: /opt/apps/html-anything + dockerfile: next/Dockerfile + image: html-anything:local + container_name: html-anything + restart: unless-stopped + ports: + - "3100:3000" + extra_hosts: + - "host.docker.internal:host-gateway" + environment: + NODE_ENV: production + NEXT_TELEMETRY_DISABLED: "1" + CODEX_HOME: /root/.codex + CODEX_BIN: /usr/local/bin/codex + HTML_ANYTHING_ALLOW_ANY_HOST: "1" + MY_PROXY_API_KEY: ${MY_PROXY_API_KEY} + volumes: + - /opt/apps/html-anything-data/codex:/root/.codex + - /opt/apps/html-anything-data/state:/root/.html-anything diff --git a/docs/deploy/yaduo-server/run.sh b/docs/deploy/yaduo-server/run.sh new file mode 100755 index 0000000..5464c7e --- /dev/null +++ b/docs/deploy/yaduo-server/run.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env sh +set -eu + +APP_DIR="${APP_DIR:-/opt/apps/html-anything}" +DATA_DIR="${DATA_DIR:-/opt/apps/html-anything-data}" +IMAGE="${IMAGE:-html-anything:local}" +CONTAINER="${CONTAINER:-html-anything}" +PORT="${PORT:-3100}" + +mkdir -p "$DATA_DIR/codex" "$DATA_DIR/state" + +if [ ! -f "$DATA_DIR/codex/config.toml" ]; then + cat > "$DATA_DIR/codex/config.toml" <<'EOF' +model_provider = "codexzh" +model = "gpt-5.5" + +[model_providers.codexzh] +name = "codexzh" +base_url = "http://host.docker.internal:2345/v1" +wire_api = "responses" +requires_openai_auth = true +web_search = "live" +EOF +fi + +docker build -f "$APP_DIR/next/Dockerfile" -t "$IMAGE" "$APP_DIR" + +docker rm -f "$CONTAINER" >/dev/null 2>&1 || true + +docker run -d \ + --name "$CONTAINER" \ + --restart unless-stopped \ + --add-host host.docker.internal:host-gateway \ + -p "$PORT:3000" \ + -e NODE_ENV=production \ + -e NEXT_TELEMETRY_DISABLED=1 \ + -e CODEX_HOME=/root/.codex \ + -e CODEX_BIN=/usr/local/bin/codex \ + -e HTML_ANYTHING_ALLOW_ANY_HOST=1 \ + -v "$DATA_DIR/codex:/root/.codex" \ + -v "$DATA_DIR/state:/root/.html-anything" \ + "$IMAGE" diff --git a/next/Dockerfile b/next/Dockerfile new file mode 100644 index 0000000..aeeebbd --- /dev/null +++ b/next/Dockerfile @@ -0,0 +1,48 @@ +# syntax=docker/dockerfile:1 + +FROM node:24-bookworm-slim AS base + +ENV PNPM_HOME=/pnpm +ENV PATH="${PNPM_HOME}:${PATH}" +ENV NEXT_TELEMETRY_DISABLED=1 + +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates curl git openssh-client \ + && rm -rf /var/lib/apt/lists/* + +RUN corepack enable \ + && corepack prepare pnpm@10.33.2 --activate \ + && npm install -g @openai/codex + +FROM base AS deps + +WORKDIR /app + +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +COPY next/package.json next/package.json +COPY e2e/package.json e2e/package.json + +RUN pnpm install --frozen-lockfile + +FROM deps AS builder + +WORKDIR /app + +COPY . . + +RUN pnpm -F @html-anything/next build + +FROM base AS runner + +WORKDIR /app + +ENV NODE_ENV=production +ENV CODEX_HOME=/root/.codex +ENV CODEX_BIN=/usr/local/bin/codex +ENV HTML_ANYTHING_ALLOW_ANY_HOST=1 + +COPY --from=builder /app ./ + +EXPOSE 3000 + +CMD ["pnpm", "-F", "@html-anything/next", "start", "-p", "3000", "-H", "0.0.0.0"] diff --git a/next/src/components/export-menu.tsx b/next/src/components/export-menu.tsx index 3131d7e..5514c68 100644 --- a/next/src/components/export-menu.tsx +++ b/next/src/components/export-menu.tsx @@ -76,7 +76,7 @@ export function ExportMenu({ iframeRef }: ExportMenuProps) { { title: t("export.section.platform"), actions: [ - { id: "wechat", label: t("export.action.wechat"), emoji: "💬", fn: wrap(t("export.toast.wechat"), async () => { await copyToWechat(cleanHtml()); }) }, + { id: "wechat", label: t("export.action.wechat"), emoji: "💬", fn: wrap(t("export.toast.wechat"), async () => { await copyToWechat(cleanHtml(), iframeRef.current?.contentDocument); }) }, { id: "zhihu", label: t("export.action.zhihu"), emoji: "🦓", fn: wrap(t("export.toast.zhihu"), async () => { await copyToZhihu(cleanHtml()); }) }, { id: "twitter-img", label: t("export.action.twitterImg"), emoji: "🐦", fn: wrap(t("export.toast.image"), async () => { if (!iframeRef.current) throw new Error(t("export.error.previewNotReady")); await copyIframeToClipboard(iframeRef.current); diff --git a/next/src/lib/export/__tests__/wechat.test.ts b/next/src/lib/export/__tests__/wechat.test.ts new file mode 100644 index 0000000..5ed15d7 --- /dev/null +++ b/next/src/lib/export/__tests__/wechat.test.ts @@ -0,0 +1,173 @@ +import { describe, it, expect, afterEach } from "vitest"; +import { toWechatHtmlFromDocument } from "../wechat"; + +function parseFragment(html: string): HTMLBodyElement { + return new DOMParser().parseFromString(`${html}`, "text/html") + .body as HTMLBodyElement; +} + +describe("toWechatHtmlFromDocument", () => { + afterEach(() => { + document.head.innerHTML = ""; + document.body.innerHTML = ""; + }); + + it("inlines computed styles from the rendered preview DOM", () => { + document.head.innerHTML = ` + + `; + document.body.innerHTML = ` +
+

Styled headline

+
+ `; + + const body = parseFragment(toWechatHtmlFromDocument(document)); + const section = body.querySelector("section"); + const card = body.querySelector("article"); + const title = body.querySelector("h1"); + + expect(section?.getAttribute("data-tool")).toBe("html-anything"); + expect(card?.getAttribute("data-tool")).toBe("html-anything"); + expect(card?.getAttribute("style")).toContain("background-color: rgb(12, 34, 56)"); + expect(card?.getAttribute("style")).toContain("border-radius: 18px"); + expect(card?.getAttribute("style")).toContain("padding: 24px"); + expect(title?.getAttribute("style")).toContain("color: rgb(210, 55, 44)"); + expect(title?.getAttribute("style")).toContain("font-size: 32px"); + expect(title?.getAttribute("style")).toContain("font-weight: 800"); + }); + + it("drops fragile page-layout styles so WeChat paste stays in article flow", () => { + document.head.innerHTML = ` + + `; + document.body.innerHTML = ` +
+

First paragraph

+

Second paragraph

+
+ `; + + const html = toWechatHtmlFromDocument(document); + const body = parseFragment(html); + const layoutStyle = body.querySelector(".layout")?.getAttribute("style") ?? ""; + const panelStyle = body.querySelector(".panel")?.getAttribute("style") ?? ""; + + expect(layoutStyle).toContain("background-color: rgb(250, 248, 240)"); + expect(layoutStyle).toContain("padding: 40px"); + expect(panelStyle).toContain("color: rgb(25, 28, 32)"); + expect(panelStyle).toContain("border-top: 2px solid rgb(80, 90, 100)"); + expect(`${layoutStyle}; ${panelStyle}`).not.toMatch( + /(?:^|;\s*)(position|display|grid-template-columns|width|height|min-height|gap|flex-direction|flex-wrap|flex):/, + ); + }); + + it("materializes ::before and ::after content into real DOM nodes", () => { + document.body.innerHTML = ` + +

Pro plan

+ `; + + const original = window.getComputedStyle.bind(window); + const stub = ((el: Element, pseudo?: string | null) => { + const base = original(el); + if (!pseudo) return base; + const overrides: Record = {}; + if (pseudo === "::before" && (el as HTMLElement).matches?.(".check")) { + overrides.content = '"✓"'; + overrides.color = "rgb(255, 0, 0)"; + } else if (pseudo === "::before" && (el as HTMLElement).matches?.(".tier")) { + overrides.content = '"Recommended"'; + overrides.color = "rgb(255, 255, 255)"; + } else if (pseudo === "::after" && (el as HTMLElement).matches?.(".tier")) { + overrides.content = '"★"'; + } + return new Proxy(base, { + get(target, prop) { + if (prop === "getPropertyValue") { + return (name: string) => overrides[name] ?? target.getPropertyValue(name); + } + const value = (target as unknown as Record)[prop]; + return typeof value === "function" ? (value as () => unknown).bind(target) : value; + }, + }); + }) as typeof window.getComputedStyle; + window.getComputedStyle = stub; + + try { + const body = parseFragment(toWechatHtmlFromDocument(document)); + const check = body.querySelector("li.check"); + const tier = body.querySelector("p.tier"); + + const checkBefore = check?.firstElementChild; + expect(checkBefore?.getAttribute("data-pseudo")).toBe("::before"); + expect(checkBefore?.textContent).toBe("✓"); + expect(checkBefore?.getAttribute("style") ?? "").toContain("color: rgb(255, 0, 0)"); + + const tierBefore = tier?.firstElementChild; + expect(tierBefore?.getAttribute("data-pseudo")).toBe("::before"); + expect(tierBefore?.textContent).toBe("Recommended"); + + const tierAfter = tier?.lastElementChild; + expect(tierAfter?.getAttribute("data-pseudo")).toBe("::after"); + expect(tierAfter?.textContent).toBe("★"); + } finally { + window.getComputedStyle = original as typeof window.getComputedStyle; + } + }); + + it("clamps oversized spacing and drops negative margins", () => { + document.head.innerHTML = ` + + `; + document.body.innerHTML = `

Too much spacing

`; + + const body = parseFragment(toWechatHtmlFromDocument(document)); + const style = body.querySelector("p")?.getAttribute("style") ?? ""; + + expect(style).toContain("margin-bottom: 48px"); + expect(style).toContain("padding: 48px"); + expect(style).toContain("color: rgb(40, 40, 40)"); + expect(style).not.toContain("-24px"); + expect(style).not.toContain("180px"); + expect(style).not.toContain("96px"); + }); +}); diff --git a/next/src/lib/export/wechat.ts b/next/src/lib/export/wechat.ts index 8764508..3b3aaa7 100644 --- a/next/src/lib/export/wechat.ts +++ b/next/src/lib/export/wechat.ts @@ -3,6 +3,41 @@ import juice from "juice"; import { copyHtml } from "./clipboard"; +/** + * Clamp `margin` / `padding` to 48px. Poster- and deck-scale templates + * routinely use 80-120px gutters that read as luxurious on a 1080 canvas + * but blow up in WeChat's ~375-540px article flow. 48 ≈ 8px baseline × 6 + * lines — the comfortable max for mobile reading. + */ +const MAX_FLOW_SPACING_PX = 48; + +const STYLE_PROPS = [ + "color", + "background-color", + "background-image", + "background-position", + "background-size", + "background-repeat", + "font-family", + "font-size", + "font-style", + "font-weight", + "line-height", + "letter-spacing", + "text-align", + "text-decoration", + "text-transform", + "white-space", + "word-break", + "overflow-wrap", + "list-style-type", + "list-style-position", + "border-collapse", + "box-shadow", + "text-shadow", + "opacity", +] as const; + /** * Take a full HTML document, extract content, inline all CSS via juice, * and tag top-level children with data-tool="html-anything" so WeChat trusts the styles. @@ -13,21 +48,15 @@ export function toWechatHtml(fullHtml: string): string { const doc = new DOMParser().parseFromString(fullHtml, "text/html"); - // Collect all