diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e24971a1f4..9ea98ba30a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -78,3 +78,34 @@ jobs: - name: Run tests run: pnpm run test + + # The verify gate's browser is downloaded once per Playwright version and + # restored from cache on every later run. + - name: Resolve Playwright version + id: playwright + run: echo "version=$(node -p "require('playwright/package.json').version")" >> "$GITHUB_OUTPUT" + + - name: Cache Playwright browsers + uses: actions/cache@v4 + with: + path: ~/.cache/ms-playwright + key: playwright-${{ runner.os }}-${{ steps.playwright.outputs.version }} + + - name: Install Playwright Chromium + run: pnpm exec playwright install --with-deps chromium + + # The config-projection acceptance needs a browser, so it runs here + # instead of in the fast suite. Blocking: it guards a real regression + # (an export's article content leaking into the comparison). + - name: Run browser-gated CLI tests + env: + MD_WEB_CLI_BROWSER_TESTS: '1' + run: pnpm --filter @dane.dai/md-web-cli exec vitest run tests/verify-config.test.ts + + # TEMPORARY: non-blocking while the gate is red on purpose. The difference + # classes are visible in the log on every change. The transition to + # blocking is an explicit decision, recorded per + # docs/adr/0003-single-verify-gate.md ("The gate deliberately landed red"). + - name: Verify rendering (non-blocking) + continue-on-error: true + run: pnpm wcli:verify diff --git a/.github/workflows/publish-md-web-cli.yml b/.github/workflows/publish-md-web-cli.yml new file mode 100644 index 0000000000..d23eb28615 --- /dev/null +++ b/.github/workflows/publish-md-web-cli.yml @@ -0,0 +1,103 @@ +name: Publish md-web-cli + +# Ticket #9: push to main runs the verify gate (which builds the md-web-cli +# bundle itself, captures a browser Oracle, and compares byte for byte) and, +# only when everything is green, publishes to npm via OIDC trusted publishing. +# Red runs publish nothing; failure notification is GitHub's built-in Actions +# email (account-level: Email + only failed workflows) — no notification +# secrets or steps here. +# +# While the gate is deliberately red (see docs/adr/0003-single-verify-gate.md), +# this workflow stays blocking on purpose: a red gate publishes nothing. +# +# DANE: before the first successful publish, configure the npmjs trusted +# publisher for @dane.dai/md-web-cli (npmjs.com → package/Automation settings): +# - package name: @dane.dai/md-web-cli +# - repository: DaneSpiritGOD/md +# - workflow filename: publish-md-web-cli.yml +# - environment: (leave empty — this workflow uses no GitHub environment) +# The very first publish is manual (trusted publisher cannot be configured +# before the package exists on npm); afterwards this workflow owns publishing. + +on: + push: + branches: [main] + +concurrency: + group: ci-publish + cancel-in-progress: false + +permissions: + contents: read + id-token: write # OIDC trusted publishing — no NODE_AUTH_TOKEN anywhere below + +jobs: + verify-and-publish: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v7 + with: + persist-credentials: false + + - name: Set up pnpm + uses: pnpm/setup@v3 + with: + version: 12.14.0 + runtime: node@22 + cache: true + install: false + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Resolve Playwright version + id: playwright + run: echo "version=$(node -p "require('playwright/package.json').version")" >> "$GITHUB_OUTPUT" + + - name: Cache Playwright browsers + uses: actions/cache@v4 + with: + path: ~/.cache/ms-playwright + key: playwright-${{ runner.os }}-${{ steps.playwright.outputs.version }} + + - name: Install Playwright Chromium + run: pnpm exec playwright install --with-deps chromium + + # Builds both workspaces itself (never a previously built bundle), captures + # a fresh Oracle, and compares byte for byte. Non-zero exits here publish + # nothing: every later step is skipped. + - name: Verify rendering (wcli:verify) + run: pnpm wcli:verify + + - name: Run package unit tests + run: pnpm --filter @dane.dai/md-web-cli test + + - name: Upgrade npm for OIDC trusted publishing + # Node 22 bundles npm 10.x; npm trusted publishing (id-token OIDC) + # requires npm >= 11.5.1. + run: npm install -g npm@latest + + - name: Resolve publish version + id: resolve-version + working-directory: packages/md-web-cli + env: + # Same registry the gate/CI uses for reads; explicit so the repo + # .npmrc mirror cannot redirect version lookups. + NPM_CONFIG_REGISTRY: https://registry.npmjs.org + run: node scripts/resolve-publish-version.mjs + + - name: Apply publish version + working-directory: packages/md-web-cli + env: + PUBLISH_VERSION: ${{ steps.resolve-version.outputs.version }} + run: npm version "$PUBLISH_VERSION" --no-git-tag-version + + - name: Publish to npm (OIDC trusted publishing) + working-directory: packages/md-web-cli + env: + NPM_CONFIG_REGISTRY: https://registry.npmjs.org + # Deliberately NO NODE_AUTH_TOKEN: authentication is the job's + # id-token (npm provenance / trusted publishing). + NPM_CONFIG_PROVENANCE: 'true' + run: npm publish diff --git a/.github/workflows/sync-upstream.yml b/.github/workflows/sync-upstream.yml new file mode 100644 index 0000000000..98e7b05ba1 --- /dev/null +++ b/.github/workflows/sync-upstream.yml @@ -0,0 +1,208 @@ +name: Sync upstream doocs/md + +# Ticket #10: weekly cron + manual dispatch upstream sync. +# +# Flow: fetch upstream (doocs/md) → if upstream/main has commits not yet on +# main, merge them into the `sync/upstream` branch in this workspace → run the +# verify gate (same `pnpm wcli:verify` as publish-md-web-cli.yml, so the two +# never disagree about what passes) → push the branch and open — or +# force-update an already-open — PR into main whose body records the +# verification result rendered from this run's actual outcome, plus this run +# link. The human reviews the diff and merges manually; that merge is a real +# push to main and triggers the publish chain (publish-md-web-cli.yml) +# normally. +# +# The PR is created with GITHUB_TOKEN, so it carries no checks — events from +# the default token do not trigger workflows. The verification that already +# passed here, recorded in the PR body, is what the human merges on. +# +# Failure modes (all surface as job failure → GitHub's built-in Actions +# notification; no PR is opened, nothing is pushed): +# 1. Merge conflict — "Merge upstream main" step fails. +# 2. Build failure — the build inside `pnpm wcli:verify` fails. +# 3. Verify failure — `pnpm wcli:verify` exits non-zero (the gate is red). +# +# While the gate is deliberately red (docs/adr/0003-single-verify-gate.md), +# this workflow stays blocking on purpose: upstream merges are held until the +# known differences are worked through. +# +# No new upstream commits → job exits successfully, no PR, no branch change. + +on: + schedule: + - cron: '0 3 * * 1' # every Monday 03:00 UTC + workflow_dispatch: + +concurrency: + group: sync-upstream + cancel-in-progress: false + +permissions: + contents: write # push / force-update the sync branch + pull-requests: write # open and update the sync PR + +jobs: + sync: + runs-on: ubuntu-latest + steps: + - name: Checkout main (full history) + uses: actions/checkout@v7 + with: + ref: main + fetch-depth: 0 # merge + ancestry check + force-update need history + persist-credentials: true # GITHUB_TOKEN push access for the sync branch + + - name: Fetch upstream + run: | + git remote add upstream https://github.com/doocs/md.git + git fetch upstream main --no-tags + git fetch origin main --no-tags + + - name: Check for new upstream commits + id: upstream + run: | + UPSTREAM_HEAD="$(git rev-parse upstream/main)" + echo "head=${UPSTREAM_HEAD}" >> "$GITHUB_OUTPUT" + if git merge-base --is-ancestor "${UPSTREAM_HEAD}" origin/main; then + echo "new=false" >> "$GITHUB_OUTPUT" + echo "No new upstream commits (upstream/main ${UPSTREAM_HEAD} already contained in main)." + else + echo "new=true" >> "$GITHUB_OUTPUT" + echo "New upstream commits detected; range main..${UPSTREAM_HEAD}:" + git log --oneline origin/main.."${UPSTREAM_HEAD}" || true + fi + + - name: Stop early — nothing to sync + if: steps.upstream.outputs.new != 'true' + run: echo "Upstream is already fully merged into main; nothing to do." + + - name: Create sync branch from main + if: steps.upstream.outputs.new == 'true' + run: | + git checkout -B sync/upstream origin/main + + - name: Merge upstream main + # Failure here = failure mode 1 (merge conflict): git exits non-zero, + # the job fails, GitHub notifies, no PR is opened, main is untouched. + if: steps.upstream.outputs.new == 'true' + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git merge --no-edit "upstream/main" \ + -m "chore: merge upstream doocs/md at $(git rev-parse upstream/main)" + + - name: Set up pnpm + # Mirror publish-md-web-cli.yml so both workflows run identical toolchain. + if: steps.upstream.outputs.new == 'true' + uses: pnpm/setup@v3 + with: + version: 12.14.0 + runtime: node@22 + cache: true + install: false + + - name: Install dependencies + # Failure here is surfaced as a build failure (failure mode 2). + # Upstream may have changed dependencies, breaking the frozen lockfile; + # fall back to a plain install and commit the refreshed lockfile with + # the merge so the PR is self-consistent. + if: steps.upstream.outputs.new == 'true' + run: | + pnpm config set store-dir ~/.pnpm-store + if ! pnpm install --frozen-lockfile; then + echo "::warning::pnpm install --frozen-lockfile failed — retrying without frozen lockfile (upstream dependency change)" + pnpm install --no-frozen-lockfile + fi + + - name: Resolve Playwright version + if: steps.upstream.outputs.new == 'true' + id: playwright + run: echo "version=$(node -p "require('playwright/package.json').version")" >> "$GITHUB_OUTPUT" + + - name: Cache Playwright browsers + if: steps.upstream.outputs.new == 'true' + uses: actions/cache@v4 + with: + path: ~/.cache/ms-playwright + key: playwright-${{ runner.os }}-${{ steps.playwright.outputs.version }} + + - name: Install Playwright Chromium + if: steps.upstream.outputs.new == 'true' + run: pnpm exec playwright install --with-deps chromium + + - name: Verify rendering (wcli:verify) + id: verify + # Failure here = failure mode 2 or 3: the build inside the gate or the + # comparison failed. Job fails, GitHub notifies, no PR is opened, main + # is untouched. + if: steps.upstream.outputs.new == 'true' + run: | + set +e + pnpm wcli:verify 2>&1 | tee verify.log + STATUS=${PIPESTATUS[0]} + set -e + if [ "$STATUS" -ne 0 ]; then + # Recorded so the PR body can never claim a success that did not + # happen; this step exits before any PR step runs. + echo "result=FAILED" >> "$GITHUB_OUTPUT" + echo "::error::pnpm wcli:verify failed — nothing was pushed and no PR was opened" + exit "$STATUS" + fi + echo "result=PASSED" >> "$GITHUB_OUTPUT" + + - name: Run package unit tests + if: steps.upstream.outputs.new == 'true' + run: pnpm --filter @dane.dai/md-web-cli test + + - name: Push sync branch + # force-with-lease: a second dispatch while the PR is open re-creates + # the branch from the latest main and force-updates it — the open PR + # follows the branch, no duplicate is created. + if: steps.upstream.outputs.new == 'true' && steps.verify.outputs.result == 'PASSED' + run: git push --force-with-lease origin sync/upstream + + - name: Open or update sync pull request + if: steps.upstream.outputs.new == 'true' && steps.verify.outputs.result == 'PASSED' + env: + GH_TOKEN: ${{ github.token }} + RUN_LINK: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + UPSTREAM_HEAD: ${{ steps.upstream.outputs.head }} + VERIFY_RESULT: ${{ steps.verify.outputs.result }} + run: | + COMMIT_COUNT="$(git rev-list --count origin/main..upstream/main)" + RANGE="origin/main...${UPSTREAM_HEAD}" + # Rendered from the run's real outcome. A failed verification exits + # before this step, so this line can never claim a success that did + # not happen. + if [ "${VERIFY_RESULT}" = "PASSED" ]; then + VERIFY_LINE="✅ PASSED — \`pnpm wcli:verify\` (build + fresh Oracle + byte-exact comparison + structural assertions) and package unit tests are green in this workflow's sync workspace" + else + VERIFY_LINE="❌ ${VERIFY_RESULT:-NOT RUN} — \`pnpm wcli:verify\` did not pass; this pull request must not be merged" + fi + BODY=$(cat < The PR is created with the workflow's own token, so it carries no + > checks. The verification recorded above is this run's actual + > result in the sync workspace — that is what you merge on. Merging this PR is a real + > push to \`main\` and triggers the publish chain + > (\`publish-md-web-cli.yml\`) normally. + EOF + ) + if gh pr view sync/upstream --json state >/dev/null 2>&1; then + gh pr edit sync/upstream --title "chore: sync upstream doocs/md" --body "$BODY" + echo "Force-updated existing sync PR." + else + gh pr create \ + --base main \ + --head sync/upstream \ + --title "chore: sync upstream doocs/md" \ + --body "$BODY" + fi diff --git a/.gitignore b/.gitignore index 9a7d0a83d2..84a7ec1822 100644 --- a/.gitignore +++ b/.gitignore @@ -66,3 +66,16 @@ dev-dist # uTools build artifacts apps/utools/dist apps/utools/release + +# Corepack cache, relocated inside the workspace when the home cache is not writable +.corepack + +# Playwright browser cache relocated inside the workspace when the home cache is not writable +.playwright-browsers + +# implement-spec worktrees +.wt-ticket-* +.pnpm-store/ + +# local scratch docs (handoff notes, keep out of git) +temp/ diff --git a/AGENTS.md b/AGENTS.md index d5fd7c1654..81d262b94d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,17 +13,18 @@ ## Monorepo 结构 -| 工作区 | 路径 | 说明 | -| ---------------- | --------------------- | ----------------------------------------------------------------------------------------------------- | -| `@md/web` | `apps/web` | 主应用,Vue 3 + 浏览器扩展(WXT: Chrome/Firefox) | -| `doocs-md` | `apps/vscode` | VS Code 扩展(webpack 构建,marketplace ID: `doocs.doocs-md`) | -| `@md/utools` | `apps/utools` | uTools 插件打包 | -| `@md/core` | `packages/core` | 核心 Markdown 渲染引擎(marked + 自定义扩展) | -| `@md/shared` | `packages/shared` | 共享工具函数、配置、类型、编辑器配置 | -| `@md/config` | `packages/config` | TypeScript 配置基础文件 | -| `@doocs/md-cli` | `packages/md-cli` | CLI 工具(Express 服务托管构建产物) | -| `@md/mcp-server` | `packages/mcp-server` | MCP 服务,为 AI Agent 暴露接口 | -| `@md/api` | `apps/api` | 后端 API:账户、云同步、计费、上传代理、分享、主题/组件市场、表情包(Cloudflare Workers + Hono + D1) | +| 工作区 | 路径 | 说明 | +| ---------------------- | --------------------- | ----------------------------------------------------------------------------------------------------- | +| `@md/web` | `apps/web` | 主应用,Vue 3 + 浏览器扩展(WXT: Chrome/Firefox) | +| `doocs-md` | `apps/vscode` | VS Code 扩展(webpack 构建,marketplace ID: `doocs.doocs-md`) | +| `@md/utools` | `apps/utools` | uTools 插件打包 | +| `@md/core` | `packages/core` | 核心 Markdown 渲染引擎(marked + 自定义扩展) | +| `@md/shared` | `packages/shared` | 共享工具函数、配置、类型、编辑器配置 | +| `@md/config` | `packages/config` | TypeScript 配置基础文件 | +| `@doocs/md-cli` | `packages/md-cli` | CLI 工具(Express 服务托管构建产物) | +| `@md/mcp-server` | `packages/mcp-server` | MCP 服务,为 AI Agent 暴露接口 | +| `@md/api` | `apps/api` | 后端 API:账户、云同步、计费、上传代理、分享、主题/组件市场、表情包(Cloudflare Workers + Hono + D1) | +| `@dane.dai/md-web-cli` | `packages/md-web-cli` | Fork-local 无浏览器渲染 CLI(esbuild 单文件零依赖 bundle,发布 npm) | 独立示例(不在 workspace 内):`docs/examples/wechat-openapi-worker/` — 微信公众号 OpenAPI 代理 Worker。 @@ -151,6 +152,7 @@ Web 主应用与部分浏览器扩展 UI 支持 **zh-CN**、**zh-TW**、**en-US* - **提交信息 / PR 标题:** 遵循 Conventional Commits(`feat`、`fix`、`docs`、`style`、`refactor`、`perf`、`test`、`build`、`chore`),**一律使用英文**(见 `CONTRIBUTING.md`) - **分支命名:** `feat/description`、`fix/description`、`docs/description`;其他类型用 `/` - **PR 说明:** 按 [`.github/pull_request_template.md`](./.github/pull_request_template.md) 填写;无关联 Issue 时不要写 Related Issue 占位符 +- **Fork-local 工作流:** 本 fork 自有改动(`md-web-cli` 包、其发布工作流与 fixture)直接推 `main`,不开 PR、不走上游 review;上游更新以 merge commit 进入 `main`。详见 [ADR-0001](./docs/adr/0001-fork-local-workflow.md) ## Skills @@ -167,3 +169,17 @@ Reusable workflows live in [`.agents/skills/`](./.agents/skills/) (canonical). C | `wechat-svg` | WeChat SVG whitelist, bubbling-group interaction, paste compatibility (`/wechat-svg`) | Invoke manually: `/skill-name` in Cursor or Claude Code; OpenCode uses the `skill` tool. + +## Agent skills + +### Issue tracker + +Issues live in this repo's GitHub Issues (`DaneSpiritGOD/md`), via the `gh` CLI. See `docs/agents/issue-tracker.md`. + +### Triage labels + +Default five canonical roles, label strings unchanged. See `docs/agents/triage-labels.md`. + +### Domain docs + +Single-context: root `GLOSSARY.md` + `docs/adr/`. See `docs/agents/domain.md`. diff --git a/GLOSSARY.md b/GLOSSARY.md new file mode 100644 index 0000000000..89b1cba33c --- /dev/null +++ b/GLOSSARY.md @@ -0,0 +1,41 @@ +# doocs/md + +A WeChat Markdown editor that renders Markdown into article HTML for the WeChat official account editor. This glossary covers the vocabulary of the fork-local headless rendering work. + +## Language + +**md-web-cli**: +A headless Markdown-to-WeChat-ready-HTML converter shipped as a Node CLI from this fork. +_Avoid_: renderer CLI, headless backend, md-to-richtext + +**WeChat-ready HTML**: +HTML that pastes into the WeChat article editor without further processing: inline styles, no class attributes, no `\n${processedHtml}` + + return { + html, + readingTime: { + words: readingTime.words, + minutes: readingTime.minutes, + }, + } +} diff --git a/packages/md-web-cli/src/types.ts b/packages/md-web-cli/src/types.ts new file mode 100644 index 0000000000..7bfaa015d4 --- /dev/null +++ b/packages/md-web-cli/src/types.ts @@ -0,0 +1,33 @@ +/** Shared types for the md-web-cli render pipeline. */ + +import type { HeadingStyles } from '@md/shared/configs/style' + +/** Figure legend display mode, mirroring @md/shared legendOptions values. */ +export type LegendValue = 'title-alt' | 'alt-title' | 'title' | 'alt' | 'filename' | 'none' + +/** Options accepted by the render pipeline; all fall back to site defaults. */ +export interface RenderConfigInput { + theme?: 'default' | 'grace' | 'simple' + primaryColor?: string + fontFamily?: string + fontSize?: string + lineHeight?: string + blockSpacing?: string + linkColor?: string + blockquoteBackground?: string + legend?: LegendValue + codeBlockTheme?: string + isMacCodeBlock?: boolean + isShowLineNumber?: boolean + citeStatus?: boolean + countStatus?: boolean + themeMode?: 'light' | 'dark' + isUseIndent?: boolean + isUseJustify?: boolean + /** Per-level heading style presets (MCP dialect key: headingStyles). */ + headingStyles?: HeadingStyles + /** Active CSS editor tab's custom CSS (Designer dialect), merged after theme CSS. */ + customCss?: string + /** Body text color, default rgb(10,10,10); applied the way the copy flow does. */ + bodyColor?: string +} diff --git a/packages/md-web-cli/src/wechat.ts b/packages/md-web-cli/src/wechat.ts new file mode 100644 index 0000000000..7a94b72bac --- /dev/null +++ b/packages/md-web-cli/src/wechat.ts @@ -0,0 +1,370 @@ +/** + * WeChat-ready output pipeline for md-web-cli. + * + * CSS inlining + WeChat post-processing vendored from the official copy flow in + * apps/web/src/services/export/clipboard.ts and clipboard-dom.ts (juice options, + * nested-list hoisting, img width/height -> inline style, zero-font-size + * paragraph bracketing, hsl()/var() literal resolution). Vendored — not imported + * — so this package never depends on apps/web. + * + * Also detects pending async placeholders (mermaid / KaTeX / infographic / + * unregistered code languages) so the CLI can fail loudly instead of silently + * stripping them. + */ +import type { RenderConfigInput } from './types' +import process from 'node:process' +import { JSDOM } from 'jsdom' +import juice from 'juice' +import { siteDefaultRenderConfig } from './defaults' +import { renderMarkdownToStyledHtml } from './render' + +const JUICE_OPTIONS = { + inlinePseudoElements: true, + preserveImportant: true, + resolveCSSVariables: false, +} as const + +// --------------------------------------------------------------------------- +// Vendored from apps/web/src/services/export/clipboard-dom.ts +// --------------------------------------------------------------------------- + +const GENERIC_FONT_FAMILIES = /^(?:serif|sans-serif|monospace|cursive|fantasy|system-ui|ui-sans-serif|ui-serif|ui-monospace|ui-rounded|inherit|initial|unset|revert|revert-layer)$/i + +/** Mermaid may emit `style="undefined;"` on edges; juice's CSS parser then throws. */ +function isUndefinedCssValue(value: string): boolean { + const normalized = value.replace(/\s*!important$/i, ``).trim() + return !normalized || /^undefined$/i.test(normalized) || /\bundefined\b/i.test(normalized) +} + +function mapFontFamilyName(family: string): string { + const unquoted = family.replace(/["']/g, ``).trim() + if (!unquoted) + return family + if (/^var\(/i.test(family)) + return family + if (GENERIC_FONT_FAMILIES.test(unquoted)) + return unquoted + // Quote multi-word / CJK names so juice/PostCSS can parse them. + if (/\s/.test(unquoted) || unquoted.split(``).some(ch => ch.charCodeAt(0) > 127)) + return `'${unquoted}'` + return unquoted +} + +function quoteFontFamilyList(value: string): string { + return value + .split(/,(?=(?:[^'"]|'[^']*'|"[^"]*")*$)/) + .map(part => mapFontFamilyName(part.trim())) + .filter(Boolean) + .join(`, `) +} + +/** C4 / sequence / gantt default to unquoted `Open Sans`, which juice/PostCSS rejects. */ +function quoteUnquotedFontFamilies(css: string): string { + return css + .replace(/font-family\s*:\s*([^;}{]+)/gi, (_, value: string) => `font-family: ${quoteFontFamilyList(value)}`) + .replace(/(? part.trim()) + .filter((part) => { + if (!part || /^undefined$/i.test(part)) + return false + const colon = part.indexOf(`:`) + if (colon === -1) + return false + return !isUndefinedCssValue(part.slice(colon + 1)) + }) + .join(`; `) + return quoteUnquotedFontFamilies(cleaned) +} + +function sanitizeEmbeddedStylesheet(css: string): string { + return quoteUnquotedFontFamilies( + css.replace(/([a-z_-]+)\s*:\s*undefined\b\s*;?/gi, ``), + ) +} + +/** + * Quote / drop juice-invalid CSS in the HTML string juice actually parses. + * (apps/web: sanitizeHtmlCssForJuice) + */ +function sanitizeHtmlCssForJuice(html: string): string { + const withStyleAttrs = html.replace(/\sstyle\s*=\s*(["'])([\s\S]*?)\1/gi, (_full, quote: string, value: string) => { + const sanitized = sanitizeCssDeclarations(decodeStyleAttr(value)) + return ` style=${quote}${encodeStyleAttr(sanitized, quote)}${quote}` + }) + + const withSheets = withStyleAttrs.replace(/(]*>)([\s\S]*?)(<\/style>)/gi, (_full, open: string, css: string, close: string) => { + return `${open}${sanitizeEmbeddedStylesheet(css)}${close}` + }) + + return withFontAttrs(withSheets) +} + +function withFontAttrs(html: string): string { + return html + .replace(/\sfont-family\s*=\s*(["'])([\s\S]*?)\1/gi, (_full, quote: string, value: string) => { + return ` font-family=${quote}${encodeStyleAttr(quoteFontFamilyList(decodeStyleAttr(value)), quote)}${quote}` + }) + .replace(/(? juice(sanitized, JUICE_OPTIONS), + () => juice(sanitized, { ...JUICE_OPTIONS, inlinePseudoElements: false }), + () => juice(stripFontFamilyForJuiceFallback(sanitized), { ...JUICE_OPTIONS, inlinePseudoElements: false }), + ] + + for (const attempt of attempts) { + try { + return attempt() + } + catch { + // Fall through to the next, progressively laxer juice attempt. + } + } + + // Every juice attempt failed: returning the un-inlined form would silently + // ship a non-WeChat-ready artifact (`) + }) + + it('differs from the WeChat output of the same source', () => { + const raw = renderMarkdownToStyledHtml(markdown, {}) + const wechat = renderMarkdownToWeChatHtml(markdown, {}) + expect(raw.html).not.toBe(wechat.html) + expect(wechat.html.startsWith(`') + const body = result.html.slice(result.html.indexOf('')) + expect(body).toContain('container') + expect(body.length).toBeGreaterThan(1000) + }) + + it('renders the full fixture body: headings, table, code, lists, blockquote, images, hr', () => { + for (const tag of ['h1', 'h2', 'h3', 'h4', 'h5', 'h6']) { + expect(result.html).toMatch(new RegExp(`<${tag}[\\s>]`)) + } + expect(result.html).toContain('preview-table') + expect(result.html).toContain('mac-sign') + expect(result.html).toContain('hr hr-dash') + expect(result.html).toContain(' { + // 16px / 1.75 typography via CSS variables + expect(result.html).toContain('--md-font-size: 16px') + expect(result.html).toContain('--md-line-height: 1.75') + // theme primary color + expect(result.html).toContain('--md-primary-color: #0F4C81') + // link color + expect(result.html).toContain('#576b95') + // github-dark highlighting colors shipped inline (zero network) + expect(result.html).toMatch(/\.hljs\b/) + const styleBlock = result.html.slice(0, result.html.indexOf('')) + expect(styleBlock).toContain('#0d1117') // github-dark background + }) + + it('h2 heading carries theme-color background with white text (default theme)', () => { + // processCSS resolves var() to concrete values (required for WeChat), + // so the h2 rule carries the resolved primary color, centered, white text. + const styleBlock = result.html.slice(0, result.html.indexOf('')) + const h2Rule = styleBlock.match(/h2\s*\{[^}]*\}/)?.[0] ?? '' + expect(h2Rule).toContain('background: #0F4C81') + expect(h2Rule).toContain('color: #fff') + expect(h2Rule).toContain('text-align: center') + }) + + it('mac code block title bar is enabled by default (three lights)', () => { + // postProcessHtml toggles .mac-sign display via inline style block + expect(result.html).toMatch(/\.hljs\.code__pre\s*>\s*\.mac-sign\s*\{[^}]*display:\s*flex/) + // The renderer emits the three-light mac sign markup + expect(result.html).toMatch(/mac-sign/) + }) + + it('computes reading time', () => { + expect(result.readingTime.words).toBeGreaterThan(0) + expect(result.readingTime.minutes).toBeGreaterThan(0) + }) + + it('throws a clear error for unknown code themes', () => { + expect(() => renderMarkdownToStyledHtml(fixtureMarkdown, { codeBlockTheme: 'dracula' })) + .toThrow(/Unknown code block theme/) + }) + + it('contains no network URLs in the style block (offline by construction)', () => { + // Strip comments first: the vendored highlight.js themes carry a metadata + // comment crediting the upstream repo, which is not a runtime request. + const styleBlock = result.html.slice(0, result.html.indexOf('')) + const withoutComments = styleBlock.replace(/\/\*[\s\S]*?\*\//g, '') + expect(withoutComments).not.toMatch(/https?:\/\//) + }) + + it('contains no CDN or dynamic-import network calls in the body pipeline output', () => { + expect(result.html).not.toMatch(/cdn-doocs\.oss/) + expect(result.html).not.toMatch(/import\s*\(\s*['"]https?/) + }) +}) diff --git a/packages/md-web-cli/tests/verify-command.test.ts b/packages/md-web-cli/tests/verify-command.test.ts new file mode 100644 index 0000000000..9192007236 --- /dev/null +++ b/packages/md-web-cli/tests/verify-command.test.ts @@ -0,0 +1,100 @@ +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { describe, expect, it } from 'vitest' +import { runVerify } from './helpers/run-verify' + +/** + * The verify gate's process seam, browser-free half. + * + * Argument validation happens before any build or browser work, so these cases + * exercise the command's contract (exit codes, messages, documented options) + * without the eight-second Oracle capture. The failure-path coverage that needs + * a render belongs to the reverse-case self-test. + */ + +describe(`verify command: options`, () => { + it(`documents fixtures, config, and artifact handling in --help`, () => { + const { status, stdout } = runVerify([`--help`]) + + expect(status).toBe(0) + expect(stdout).toContain(`--fixture`) + expect(stdout).toContain(`--config`) + expect(stdout).toContain(`--out-dir`) + expect(stdout).toContain(`--keep`) + expect(stdout).toContain(`byte for byte`) + // The absence of a tolerance is part of the contract, not an implementation + // detail: it is stated where the maintainer reads the options. + expect(stdout).toMatch(/no tolerance, allow-list, or known-gaps/) + }) + + it(`rejects an unknown option instead of ignoring it`, () => { + const { status, stderr } = runVerify([`--frobnicate`]) + + expect(status).toBe(1) + expect(stderr).toContain(`unknown option "--frobnicate"`) + }) + + it(`rejects an option given without a value`, () => { + const { status, stderr } = runVerify([`--fixture`]) + + expect(status).toBe(1) + expect(stderr).toContain(`--fixture requires a value`) + }) +}) + +describe(`verify command: missing inputs`, () => { + it(`fails loudly for an unknown fixture`, () => { + const { status, stderr } = runVerify([`--fixture`, `no-such-fixture`]) + + expect(status).toBe(1) + expect(stderr).toContain(`fixture not found:`) + expect(stderr).toContain(`known names: full, minimal`) + }) + + it(`resolves the infrastructure-probe fixture by name`, () => { + // Reaching the config check proves the fixture name resolved; an unknown + // name would have failed earlier with "fixture not found". + const { status, stderr } = runVerify([`--fixture`, `minimal`, `--config`, `no-such-config.json`]) + + expect(status).toBe(1) + expect(stderr).toContain(`config not found:`) + }) + + it(`rejects a config that is not valid JSON`, () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), `verify-config-`)) + try { + const configPath = path.join(dir, `broken.json`) + fs.writeFileSync(configPath, `{ not json`) + const { status, stderr } = runVerify([`--fixture`, `minimal`, `--config`, configPath]) + + expect(status).toBe(1) + expect(stderr).toContain(`config is not valid JSON:`) + } + finally { + fs.rmSync(dir, { recursive: true, force: true }) + } + }) + + it(`rejects a theme the two sides cannot both honour`, () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), `verify-config-`)) + try { + const configPath = path.join(dir, `marketplace.json`) + fs.writeFileSync(configPath, JSON.stringify({ theme: `md-theme-obsidian`, primaryColor: `#FA5151` })) + const { status, stderr } = runVerify([`--fixture`, `minimal`, `--config`, configPath]) + + expect(status).toBe(1) + expect(stderr).toContain(`theme "md-theme-obsidian" is not available to both sides`) + } + finally { + fs.rmSync(dir, { recursive: true, force: true }) + } + }) + + it(`fails loudly for a config file that does not exist`, () => { + const { status, stderr } = runVerify([`--fixture`, `full`, `--config`, `no-such-config.json`]) + + expect(status).toBe(1) + expect(stderr).toContain(`config not found:`) + }) +}) diff --git a/packages/md-web-cli/tests/verify-config.test.ts b/packages/md-web-cli/tests/verify-config.test.ts new file mode 100644 index 0000000000..c483b29a1d --- /dev/null +++ b/packages/md-web-cli/tests/verify-config.test.ts @@ -0,0 +1,77 @@ +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { describe, expect, it } from 'vitest' +import { runVerify } from './helpers/run-verify' + +/** + * Config projection at the verify process seam. + * + * This case needs the browser (it runs the whole gate), so it is opt-in: + * `MD_WEB_CLI_BROWSER_TESTS=1 pnpm --filter @dane.dai/md-web-cli test`. + * It stays at the process boundary on purpose — a unit test of the projection + * function would be a third seam, and the behaviour that matters is "the gate + * never compares an article carried in the export". + */ + +const browserTests = Boolean(process.env.MD_WEB_CLI_BROWSER_TESTS) + +const ARTICLE_MARKER = `ARTICLE MARKER: this article must never be rendered` + +describe.skipIf(!browserTests)(`verify command: Designer config`, () => { + it(`compares the fixture, never the article carried in the export`, () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), `verify-config-seam-`)) + try { + const configPath = path.join(dir, `export.json`) + const outDir = path.join(dir, `artifacts`) + fs.writeFileSync(configPath, JSON.stringify({ + // Article content: must be dropped before either side sees the config. + posts: [{ id: `post-1`, content: `# ${ARTICLE_MARKER}` }], + currentPostId: `post-1`, + currentPostIndex: 0, + titleList: [ARTICLE_MARKER], + readingTime: { words: 9999, minutes: 42 }, + // Pure UI: no effect on the rendered article. + isDark: true, + isOpenRightSlider: false, + isOpenPostSlider: true, + showAIToolbox: false, + isShowCssEditor: true, + isShowTableEditDialog: true, + isShowUploadImgDialog: true, + aiDialogVisible: true, + aiImageDialogVisible: true, + // Render-affecting: reach both sides. + theme: `default`, + primaryColor: `#0F4C81`, + fontFamily: `-apple-system-font,BlinkMacSystemFont, Helvetica Neue, PingFang SC, Hiragino Sans GB , Microsoft YaHei UI , Microsoft YaHei ,Arial,sans-serif`, + fontSize: `16px`, + codeBlockTheme: `https://cdn-doocs.oss-cn-shenzhen.aliyuncs.com/npm/highlightjs/11.11.1/styles/github-dark.min.css`, + legend: `alt`, + isMacCodeBlock: true, + isShowLineNumber: false, + isCiteStatus: false, + isCountStatus: false, + isUseIndent: false, + isUseJustify: false, + cssContentConfig: { active: `default`, tabs: [{ id: `default`, title: `default`, name: `default`, content: `` }] }, + }), `utf-8`) + + const { stdout, stderr } = runVerify([`--fixture`, `minimal`, `--config`, configPath, `--out-dir`, outDir], 240_000) + const combined = `${stdout}${stderr}` + + expect(combined).toContain(`article-content`) + expect(combined).toContain(`the same projected file goes to both sides`) + + const cliHtml = fs.readFileSync(path.join(outDir, `minimal`, `cli.html`), `utf-8`) + const oracleHtml = fs.readFileSync(path.join(outDir, `minimal`, `oracle.html`), `utf-8`) + for (const html of [cliHtml, oracleHtml]) { + expect(html).toContain(`Minimal fixture`) + expect(html).not.toContain(ARTICLE_MARKER) + } + } + finally { + fs.rmSync(dir, { recursive: true, force: true }) + } + }, 240_000) +}) diff --git a/packages/md-web-cli/tests/verify-structural.test.ts b/packages/md-web-cli/tests/verify-structural.test.ts new file mode 100644 index 0000000000..76ad81be72 --- /dev/null +++ b/packages/md-web-cli/tests/verify-structural.test.ts @@ -0,0 +1,89 @@ +import { execFileSync } from 'node:child_process' +import { resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' +import { findInlineStyleProblems, findPendingPlaceholders, findStructuralProblems } from '../../../scripts/verify/structural.mjs' + +/** + * Coverage adapted from the verifier the byte-exact gate replaced. + * + * The script those tests imported is gone from the gate path, but the + * invariants it protected are not: pending placeholders must never ship, the + * output must be WeChat-ready, and the reverse-case self-test must prove the + * gate can fail. + */ + +const here = fileURLToPath(import.meta.url) +const repoRoot = resolve(here, '../../../..') +const verifyScript = resolve(repoRoot, 'scripts/verify.mjs') + +describe(`pending placeholder detection`, () => { + it(`finds diagram, katex and language markers, naming each kind`, () => { + const html = '
xfn' + const problems = findPendingPlaceholders(html) + + expect(problems.map(problem => problem.kind)).toEqual([ + `pending-diagram`, + `pending-katex`, + `pending-language`, + ]) + expect(problems[2].detail).toContain(`rust`) + }) + + it(`ignores katex-pending selectors inside a style block`, () => { + expect(findPendingPlaceholders('

x

')).toHaveLength(0) + }) +}) + +describe(`weChat-ready inline style checks`, () => { + it(`accepts a WeChat-ready artifact`, () => { + expect(findInlineStyleProblems('

x

')).toHaveLength(0) + }) + + it(`rejects class attributes, style blocks and unstyled block elements`, () => { + const problems = findInlineStyleProblems('

b

') + + expect(problems.map(problem => problem.kind)).toEqual([ + `style-tag`, + `class-attribute`, + `unstyled-block`, + ]) + }) + + it(`exempts the documented structurally-formatted elements`, () => { + const html = '
x
' + + expect(findInlineStyleProblems(html)).toHaveLength(0) + }) + + it(`reports every problem class for one artifact`, () => { + const problems = findStructuralProblems('

no style

') + + expect(problems.map(problem => problem.kind).sort()).toEqual([`class-attribute`, `pending-diagram`, `unstyled-block`]) + }) +}) + +describe(`reverse-case self-test`, () => { + it(`exits 0 with every mutant rejected, without launching a browser`, () => { + const output = execFileSync(process.execPath, [verifyScript, `--self-test`], { + cwd: repoRoot, + encoding: `utf-8`, + stdio: [`ignore`, `pipe`, `pipe`], + timeout: 120_000, + }) + + for (const name of [ + `injected diagram placeholder`, + `injected formula placeholder`, + `injected unregistered-language marker`, + `stripped inline styles`, + `injected class attribute`, + `injected style block`, + `truncated artifact`, + ]) { + expect(output).toContain(`PASS: ${name} -> gate rejects it`) + } + expect(output).toContain(`PASS: identical input compares equal`) + expect(output).toContain(`7 reverse case(s)`) + }, 120_000) +}) diff --git a/packages/md-web-cli/tests/wechat.test.ts b/packages/md-web-cli/tests/wechat.test.ts new file mode 100644 index 0000000000..e602eb8ad9 --- /dev/null +++ b/packages/md-web-cli/tests/wechat.test.ts @@ -0,0 +1,118 @@ +/** + * Ticket 04: WeChat-ready output + pending-placeholder failure semantics. + * + * Library-level tests cover the transformation itself; cli.test.ts covers the + * subprocess exit/stderr contract for pending placeholders. + */ +import { describe, expect, it } from 'vitest' +import { renderMarkdownToWeChatHtml } from '../src/wechat' + +function blockLevelElementsMissingStyle(html: string): string[] { + const missing: string[] = [] + const regex = /<(section|p|div|h[1-6]|ul|ol|li|table|thead|tbody|tr|th|td|blockquote|pre|hr|img)\b([^>]*)>/gi + for (const match of html.matchAll(regex)) { + if (!/\sstyle=/i.test(match[2]) && match[2].trim() !== ``) + missing.push(`<${match[1]}${match[2]}>`) + } + return missing +} + +describe('renderMarkdownToWeChatHtml: WeChat-ready output', () => { + it('produces zero ${html}`, + }, + { + name: `truncated artifact`, + expectKind: null, + mutate: html => html.slice(0, Math.floor(html.length / 2)), + }, +] + +/** + * @param {string} knownGoodHtml A CLI render that already passes the structural + * assertions. + * @returns {{baselineProblems: Array, positiveControl: boolean, cases: Array<{name: string, rejected: boolean, expectedKind: string|null, matchedKinds: string[], expectedKindFound: boolean, firstReason: string}>}} + */ +export function runSelfTest(knownGoodHtml) { + const baselineProblems = findStructuralProblems(knownGoodHtml) + // Positive control: a comparator that always reports a difference would make + // every reverse case "pass" for the wrong reason. + const positiveControl = compareOutputs(knownGoodHtml, knownGoodHtml).equal + + const cases = CASES.map((testCase) => { + const mutant = testCase.mutate(knownGoodHtml) + const problems = findStructuralProblems(mutant) + const matchedKinds = problems.map(problem => problem.kind) + const comparison = compareOutputs(knownGoodHtml, mutant) + const expectedKindFound = testCase.expectKind === null || matchedKinds.includes(testCase.expectKind) + return { + name: testCase.name, + expectedKind: testCase.expectKind, + matchedKinds, + rejected: !comparison.equal && expectedKindFound, + expectedKindFound, + firstReason: problems[0]?.detail ?? `byte comparison rejected the mutant`, + } + }) + + return { baselineProblems, positiveControl, cases } +} diff --git a/scripts/verify/structural.mjs b/scripts/verify/structural.mjs new file mode 100644 index 0000000000..93aa2bd172 --- /dev/null +++ b/scripts/verify/structural.mjs @@ -0,0 +1,93 @@ +/** + * Non-browser assertions the gate owns. + * + * These are the invariants the retired verifier enforced, kept alive so they do + * not die with the script that used to host them: the CLI's output must carry no + * pending async placeholders, and it must be WeChat-ready (no style block, no + * class attributes, inline styles on block-level elements). + * + * They run against the CLI output only. The browser's clipboard HTML is + * pre-paste and legitimately keeps class attributes; WeChat strips them at paste + * time. Asserting WeChat readiness on that side would be asserting the wrong + * artefact's contract. + */ + +/** Elements WeChat formats structurally; they legitimately carry no style attr. */ +const STYLE_EXEMPT = new Set([`tr`, `tbody`, `thead`, `svg`]) + +const BLOCK_TAGS = new Set([ + `section`, + `p`, + `h1`, + `h2`, + `h3`, + `h4`, + `h5`, + `h6`, + `blockquote`, + `table`, + `pre`, + `ul`, + `ol`, + `li`, + `td`, + `th`, + `figure`, + `figcaption`, + `hr`, + `div`, + `details`, + `summary`, +]) + +/** + * Scan for pending-async markers. + * + * Leading style blocks are stripped first so a legitimate CSS selector such as + * `.katex-pending` never reads as pending content. + */ +export function findPendingPlaceholders(html) { + const body = html.replace(//gi, ``) + const found = [] + if (/]*data-md-diagram-state="loading"/.test(body)) + found.push({ kind: `pending-diagram`, detail: `async diagram placeholder (data-md-diagram-state="loading")` }) + if (/<[^>]*\bkatex-pending\b/.test(body)) + found.push({ kind: `pending-katex`, detail: `KaTeX formula placeholder (.katex-pending)` }) + for (const match of body.matchAll(/data-language-pending="([^"]*)"/g)) + found.push({ kind: `pending-language`, detail: `unregistered code language placeholder (${match[1]})` }) + return found +} + +/** + * Scan for output that is not WeChat-ready. + * + * The exemptions are a documented product rule, not a convenience: WeChat + * formats table sections, rows, and SVG structurally, so demanding an inline + * style on them would report a difference that does not exist. The full + * fixture contains tables, so the baseline render in the self-test fails if + * these stop being honoured. + */ +export function findInlineStyleProblems(html) { + const problems = [] + if (/]/i.test(html)) + problems.push({ kind: `style-tag`, detail: `document contains a