Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
26 changes: 26 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -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*
22 changes: 22 additions & 0 deletions docs/deploy/yaduo-server/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -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
42 changes: 42 additions & 0 deletions docs/deploy/yaduo-server/run.sh
Original file line number Diff line number Diff line change
@@ -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"
48 changes: 48 additions & 0 deletions next/Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]
2 changes: 1 addition & 1 deletion next/src/components/export-menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
173 changes: 173 additions & 0 deletions next/src/lib/export/__tests__/wechat.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
import { describe, it, expect, afterEach } from "vitest";
import { toWechatHtmlFromDocument } from "../wechat";

function parseFragment(html: string): HTMLBodyElement {
return new DOMParser().parseFromString(`<body>${html}</body>`, "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 = `
<style>
.card {
background: rgb(12, 34, 56);
border-radius: 18px;
padding: 24px;
}
.title {
color: rgb(210, 55, 44);
font-size: 32px;
font-weight: 800;
line-height: 1.25;
}
</style>
`;
document.body.innerHTML = `
<article class="card">
<h1 class="title">Styled headline</h1>
</article>
`;

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 = `
<style>
.layout {
position: absolute;
display: grid;
grid-template-columns: 320px 1fr;
width: 1280px;
height: 720px;
gap: 48px;
background: rgb(250, 248, 240);
padding: 40px;
}
.panel {
display: flex;
min-height: 360px;
color: rgb(25, 28, 32);
border: 2px solid rgb(80, 90, 100);
}
</style>
`;
document.body.innerHTML = `
<section class="layout">
<p class="panel">First paragraph</p>
<p class="panel">Second paragraph</p>
</section>
`;

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 = `
<ul>
<li class="check">Item one</li>
</ul>
<p class="tier">Pro plan</p>
`;

const original = window.getComputedStyle.bind(window);
const stub = ((el: Element, pseudo?: string | null) => {
const base = original(el);
if (!pseudo) return base;
const overrides: Record<string, string> = {};
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<string | symbol, unknown>)[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 = `
<style>
.loose {
margin-top: -24px;
margin-bottom: 180px;
padding: 96px;
color: rgb(40, 40, 40);
}
</style>
`;
document.body.innerHTML = `<p class="loose">Too much spacing</p>`;

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");
});
});
Loading
Loading