Skill: tangyijun/nuomi-xhs-card (version 1.0.0)

--- name: nuomi-xhs-card description: 当用户提及渲染 Markdown/文本 为小红书风格卡片 ,当用户提及"生成小红书卡片"、"md 转图片"、"制作卡片"、"小红书图文" . --- # XHS Card Generator 本地 Markdown/MDX 转小红书卡片图生成器。支持 24 种模板、light/dark 模式、智能分页。 不适用：通用图片编辑、截图、PDF 处理、视频制作。 ## 快速开始 ```bash # 首次安装（安装依赖 + Playwright chromium） bash scripts/setup.sh # 环境检查 node scripts/xhs-card.cjs doctor # 列出所有模板 node scripts/xhs-card.cjs templates list ``` ## 渲染卡片 ```bash node scripts/xhs-card.cjs render ./article.md \ --theme xiaohongshu --mode light --split auto \ --size 440x586 --scale 4 --pager --output ./output ``` ## CLI 参数 | 参数 | 默认值 | 说明 | |------|--------|------| | `--theme` | xiaohongshu | 模板 ID，见下方列表 | | `--mode` | light | light/dark（不支持时自动回退 light） | | `--split` | auto | auto=智能分页，hr=按水平线分页，none=不分页 | | `--size` | 440x586 | 卡片尺寸（宽x高） | | `--scale` | 4 | 输出图片倍率 | | `--pager` / `--no-pager` | pager | 显示/隐藏页码 | | `--mdx-mode` | false | 启用 MDX 解析 | | `--output` | ./output | 输出目录 | | `--max-pages` | 80 | 分页上限 | ## 24 个内置模板 `apple-notes` `xiaohongshu` `instagram` `dreamy` `nature` `minimalist` `notebook` `coil-notebook` `business` `typewriter` `watercolor` `fairytale` `japanese-magazine` `traditional-chinese` `art-deco` `pop-art` `cyberpunk` `darktech` `glassmorphism` `warm` `meadow-dawn` `minimal` `bytedance` `alibaba` 完整模板介绍见 [references/templates.md](references/templates.md) ## 执行步骤 1. 查看用户提供的文案,如果不是 md 格式,请咨询用户是否修改符合要求的 md 格式或者重新提供. 2. 根据用户的模板内容,对文案的模板进行建议,并且咨询用户确定模板. 3. 在执行前,需要咨询用户输出目录,或者根据用户上下文或默认行为进行建议,并且要求用户确定. 4. 执行过程中出现任何阻塞性问题,及时反馈. 5. 完成执行后,向用户明确说明. ## 模板选择建议 根据用户文案类型做出笔记模板建议, 并且咨询用户确定需要的模板 | 内容类型 | 推荐模板 | |----------|----------| | 知识笔记 | apple-notes, notebook, coil-notebook | | 生活方式 | xiaohongshu, warm, meadow-dawn | | 时尚穿搭 | instagram, japanese-magazine | | 科技数码 | minimalist, cyberpunk, darktech | | 文艺情感 | typewriter, watercolor, dreamy | | 商业职场 | business, bytedance, alibaba | ## 调试选项 ```bash # 导出中间产物用于调试 node scripts/xhs-card.cjs render ./article.md \ --dump-preview-html ./debug/preview.html \ --dump-structured-html ./debug/structured.html \ --dump-pagination-json ./debug/pagination.json ``` ## 执行后输出 每次渲染完成后，向用户明确说明： 1. **输出目录**和关键图片路径 2. `totalPages`（总页数）、是否回退主题模式、是否触发告警 3. 若失败：失败阶段（解析/结构化/分页/渲染）和下一步建议 ## 架构 渲染管线：`Markdown → HTML → 结构化 → 分页 → PNG` 核心模块位于 `scripts/src/core/`： - `markdown.ts` - Markdown/MDX 转 HTML - `structure.ts` - HTML 后处理 - `paginate.ts` - 智能分页 - `render.ts` - Playwright PNG 导出 - `themes.ts` - 主题加载与验证 完整 CLI 参考见 [references/cli-reference.md](references/cli-reference.md)

path: references/templates.md
content: # 24 个内置模板

## 模板列表

| ID | 名称 | 风格描述 | 推荐场景 |
|----|------|----------|----------|
| `apple-notes` | Apple Notes | 简洁白色背景，类似苹果备忘录 | 知识笔记、读书笔记 |
| `xiaohongshu` | 小红书 | 经典小红书风格，温暖配色 | 生活方式、美妆、穿搭 |
| `instagram` | Instagram | 现代、简约、高对比度 | 时尚、旅行、摄影 |
| `dreamy` | 梦幻 | 柔和渐变，梦幻紫粉色调 | 情感、治愈、少女心 |
| `nature` | 自然 | 绿色系，自然清新 | 户外、健康、环保 |
| `minimalist` | 极简主义 | 黑白灰，极简设计 | 科技、商业、专业内容 |
| `minimal` | 简约 | 简洁现代，留白多 | 通用场景 |
| `notebook` | 笔记本 | 横线纸张效果 | 学习笔记、日记 |
| `coil-notebook` | 线圈笔记本 | 带装订线的笔记本效果 | 手账、学习记录 |
| `business` | 商务 | 专业商务风格 | 职场、商业分析 |
| `typewriter` | 打字机 | 复古打字机效果 | 文艺、复古、诗歌 |
| `watercolor` | 水彩 | 水彩晕染效果 | 艺术、创意、情感 |
| `fairytale` | 童话 | 可爱童话风格 | 亲子、童话故事 |
| `japanese-magazine` | 日系杂志 | 日系杂志排版 | 时尚、生活美学 |
| `traditional-chinese` | 国风 | 中国传统元素 | 传统文化、诗词 |
| `art-deco` | 装饰艺术 | Art Deco 几何风格 | 复古、奢华、设计 |
| `pop-art` | 波普艺术 | 波普艺术色彩 | 创意、潮流、年轻 |
| `cyberpunk` | 赛博朋克 | 霓虹灯、科技感 | 科技、游戏、未来 |
| `darktech` | 暗黑科技 | 深色科技风 | 技术、编程、极客 |
| `glassmorphism` | 玻璃拟态 | 毛玻璃效果 | 现代 UI、设计趋势 |
| `warm` | 温暖 | 暖色调温馨风格 | 家居、美食、亲情 |
| `meadow-dawn` | 草原黎明 | 柔和自然色调 | 自然、疗愈、清晨 |
| `bytedance` | 字节跳动 | 字节跳动品牌风格 | 互联网、科技 |
| `alibaba` | 阿里巴巴 | 阿里巴巴品牌风格 | 电商、商业 |

## 主题模式支持

大部分模板支持 `light` 和 `dark` 两种模式：

```bash
# 亮色模式
--mode light

# 暗色模式
--mode dark
```

**注意**：部分模板仅支持 `light` 模式，使用 `dark` 模式时会自动回退到 `light` 并输出告警。

## 模板预览

### 预览命令

使用预置文案快速预览模板效果：

```bash
node scripts/xhs-card.cjs render ./assets/preview-template.md \
  --theme <模板ID> \
  --mode light \
  --split none \
  --output ./output/preview-<模板ID>
```

### 批量预览所有模板

```bash
for theme in apple-notes xiaohongshu instagram dreamy nature minimalist; do
  node scripts/xhs-card.cjs render ./assets/preview-template.md \
    --theme $theme \
    --mode light \
    --split none \
    --output ./output/preview-$theme
done
```

## 模板选择建议

### 按内容类型选择

| 内容类型 | 推荐模板 |
|----------|----------|
| 知识笔记 | apple-notes, notebook, coil-notebook |
| 生活方式 | xiaohongshu, warm, meadow-dawn |
| 时尚穿搭 | instagram, japanese-magazine |
| 科技数码 | minimalist, cyberpunk, darktech |
| 文艺情感 | typewriter, watercolor, dreamy |
| 商业职场 | business, bytedance, alibaba |
| 传统文化 | traditional-chinese |
| 创意设计 | pop-art, art-deco, glassmorphism |

### 按目标受众选择

| 受众 | 推荐模板 |
|------|----------|
| 年轻女性 | xiaohongshu, dreamy, fairytale |
| 职场人士 | business, minimalist, apple-notes |
| 设计师 | pop-art, glassmorphism, art-deco |
| 程序员 | darktech, cyberpunk, minimalist |
| 学生群体 | notebook, coil-notebook, apple-notes |

## 自定义模板

如需自定义模板，可在 `assets/templates/` 目录下创建新的 HTML 文件：

```html
<!DOCTYPE html>
<html>
<head>
  <style>
    :root {
      --bg-color: #ffffff;
      --text-color: #333333;
      --accent-color: #ff4f81;
    }
    .card {
      width: 440px;
      min-height: 586px;
      background: var(--bg-color);
      padding: 24px;
    }
    /* 更多样式... */
  </style>
</head>
<body>
  <div class="card">
    <div class="card-content">
      <!-- 内容区域 -->
    </div>
  </div>
</body>
</html>
```


path: references/cli-reference.md
content: # CLI 参数完整说明

## 核心命令

```bash
node scripts/xhs-card.cjs <command> [options]
```

### 可用命令

| 命令 | 说明 |
|------|------|
| `doctor` | 环境检查，验证依赖和 Playwright 安装 |
| `templates list` | 列出所有可用模板 |
| `render` | 渲染 Markdown/MDX 为卡片图片 |

## render 命令参数

### 必需参数

| 参数 | 说明 | 示例 |
|------|------|------|
| `<input>` | 输入 Markdown/MDX 文件路径 | `./article.md` |

### 可选参数

| 参数 | 说明 | 默认值 |
|------|------|--------|
| `--theme <id>` | 模板 ID，见 [templates.md](templates.md) | `xiaohongshu` |
| `--mode <light\|dark>` | 主题模式，模板无 dark 时自动回退 light 并告警 | `light` |
| `--split <auto\|hr\|none>` | 分页模式：auto=智能分页，hr=按水平线分页，none=不分页 | `auto` |
| `--size <WxH>` | 卡片尺寸（像素） | `440x586` |
| `--scale <number>` | 输出图片倍率 | `4` |
| `--pager` | 显示页码（默认） | 启用 |
| `--no-pager` | 隐藏页码 | - |
| `--output <dir>` | 输出目录 | `./output` |
| `--report <path>` | 渲染报告 JSON 输出路径 | - |
| `--max-pages <n>` | 自动分页上限保护 | `80` |

### MDX 相关参数

| 参数 | 说明 |
|------|------|
| `--mdx-mode` | 启用 MDX 编译（支持 JSX 组件） |

### 调试参数

| 参数 | 说明 |
|------|------|
| `--dump-preview-html <path>` | 导出预览阶段 HTML |
| `--dump-structured-html <path>` | 导出结构化后 HTML |
| `--dump-pagination-json <path>` | 导出分页诊断 JSON |

## 使用示例

### 基础渲染

```bash
node scripts/xhs-card.cjs render ./article.md \
  --theme xiaohongshu \
  --mode light \
  --split auto \
  --output ./output
```

### 完整参数

```bash
node scripts/xhs-card.cjs render ./article.md \
  --theme apple-notes \
  --mode light \
  --split auto \
  --size 440x586 \
  --scale 4 \
  --pager \
  --output ./output \
  --report ./output/report.json
```

### 调试模式

```bash
node scripts/xhs-card.cjs render ./article.md \
  --theme xiaohongshu \
  --dump-preview-html ./debug/preview.html \
  --dump-structured-html ./debug/structured.html \
  --dump-pagination-json ./debug/pagination.json
```

### MDX 支持

```bash
node scripts/xhs-card.cjs render ./article.mdx \
  --mdx-mode \
  --theme xiaohongshu
```

## 输出结果

### 文件结构

```
output/
├── card_1.png      # 第一页卡片
├── card_2.png      # 第二页卡片（如有）
├── ...
└── report.json     # 渲染报告（如果指定 --report）
```

### 渲染报告格式

```json
{
  "success": true,
  "totalPages": 3,
  "theme": "xiaohongshu",
  "mode": "light",
  "modeFallback": false,
  "warnings": [],
  "outputDir": "./output",
  "images": ["card_1.png", "card_2.png", "card_3.png"]
}
```

## 分页模式说明

### auto（智能分页）

- 基于 DOM 高度检测自动分页
- 每页最大内容高度约 420px
- 文本节点使用二分查找精确分割
- 适合长文章自动拆分

### hr（水平线分页）

- 按 Markdown 中的 `---` 水平线分页
- 用户完全控制分页位置
- 适合手动规划的内容

### none（不分页）

- 整篇内容渲染为单页
- 适合短内容或预览


path: scripts/setup.sh
content: #!/usr/bin/env bash
set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"

if command -v npm >/dev/null 2>&1; then
  if [[ -f "${SCRIPT_DIR}/package-lock.json" ]]; then
    npm --prefix "${SCRIPT_DIR}" ci
  else
    npm --prefix "${SCRIPT_DIR}" install
  fi
else
  echo "npm is required but not found."
  exit 1
fi

npx --prefix "${SCRIPT_DIR}" playwright install chromium


path: scripts/package.json
content: {
  "name": "nuomi-xhs-template",
  "version": "1.0.0",
  "private": true,
  "type": "module",
  "bin": {
    "xhs-card": "./xhs-card.cjs"
  },
  "scripts": {
    "render": "node ./xhs-card.cjs render",
    "templates": "node ./xhs-card.cjs templates list",
    "doctor": "node ./xhs-card.cjs doctor",
    "test": "node --import ./node_modules/tsx/dist/loader.mjs --test tests/*.test.ts",
    "test:visual": "node --import ./node_modules/tsx/dist/loader.mjs --test tests/visual.test.ts"
  },
  "dependencies": {
    "@mdx-js/mdx": "^3.1.1",
    "cheerio": "^1.1.2",
    "commander": "^14.0.1",
    "playwright": "^1.55.0",
    "react": "^19.1.1",
    "react-dom": "^19.1.1",
    "rehype-raw": "^7.0.0",
    "rehype-stringify": "^10.0.1",
    "remark-gfm": "^4.0.1",
    "remark-parse": "^11.0.0",
    "remark-rehype": "^11.1.2",
    "unified": "^11.0.5"
  },
  "devDependencies": {
    "@types/node": "^24.4.0",
    "@types/react": "^19.1.12",
    "@types/react-dom": "^19.1.9",
    "tsx": "^4.20.5",
    "typescript": "^5.9.2"
  }
}


path: scripts/tsconfig.json
content: {
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "jsx": "react-jsx",
    "resolveJsonModule": true,
    "types": ["node"],
    "outDir": "dist"
  },
  "include": ["src/**/*.ts", "src/**/*.tsx", "tests/**/*.ts"]
}


path: scripts/xhs-card.cjs
content: #!/usr/bin/env node
const { spawnSync } = require('node:child_process');
const path = require('node:path');

const cliPath = path.resolve(__dirname, 'src', 'cli.ts');
const tsxLoaderPath = path.resolve(__dirname, 'node_modules', 'tsx', 'dist', 'loader.mjs');

const result = spawnSync(process.execPath, ['--import', tsxLoaderPath, cliPath, ...process.argv.slice(2)], {
  stdio: 'inherit'
});

process.exit(result.status === null ? 1 : result.status);


path: scripts/src/core/structure.ts
content: import { load } from 'cheerio';

export interface StructureOptions {
  editorMode?: boolean;
  processRemoteImages?: boolean;
}

export interface StructuredHtmlResult {
  html: string;
  preloads: string[];
  warnings: string[];
}

function ensureMarkdownBody(html: string): string {
  if (/class=(["'])[^"']*\bmarkdown-body\b[^"']*\1/i.test(html)) {
    return html;
  }
  return `<div class="markdown-body">${html}</div>`;
}

async function fetchRemoteImageAsDataUrl(url: string): Promise<string> {
  const response = await fetch(url, {
    redirect: 'follow',
    cache: 'force-cache'
  });

  if (!response.ok) {
    throw new Error(`Failed to fetch image: ${response.status}`);
  }

  const arrayBuffer = await response.arrayBuffer();
  const contentType = response.headers.get('content-type') || 'image/png';
  const base64 = Buffer.from(arrayBuffer).toString('base64');
  return `data:${contentType};base64,${base64}`;
}

export async function restructureMarkdownHtml(inputHtml: string, options: StructureOptions = {}): Promise<StructuredHtmlResult> {
  const warnings: string[] = [];
  const wrappedHtml = ensureMarkdownBody(inputHtml);
  const $ = load(wrappedHtml);
  const root = $('.markdown-body').first();

  const preloads: string[] = [];
  root.find('link').each((_, node) => {
    preloads.push($.html(node));
    $(node).remove();
  });

  if (options.editorMode) {
    root.find('h1,h2,h3').each((_, node) => {
      const $heading = $(node);
      const content = $heading.html() ?? '';
      $heading.html(`<span class="prefix"></span><span class="content">${content}</span><span class="suffix"></span>`);
    });
  } else {
    root.find('h1,h2,h3').each((_, node) => {
      const $heading = $(node);
      $heading.attr('data-text', $heading.text().trim());
    });
  }

  root.find('ol').each((_, listNode) => {
    const $list = $(listNode);
    const startValue = Number.parseInt($list.attr('start') ?? '1', 10);
    const start = Number.isFinite(startValue) ? startValue : 1;

    $list.children('li').each((index, liNode) => {
      $(liNode).attr('data-index', String(start + index));
    });
  });

  const imageNodes = root.find('img').toArray();
  for (const imageNode of imageNodes) {
    const $image = $(imageNode);
    const src = $image.attr('src') ?? '';
    if (!src) {
      continue;
    }

    if (src.includes('data:image/svg+xml')) {
      warnings.push('Encountered inline SVG image; kept original data URI for rendering.');
      continue;
    }

    if (options.processRemoteImages && /^https?:\/\//i.test(src)) {
      try {
        const dataUrl = await fetchRemoteImageAsDataUrl(src);
        $image.attr('src', dataUrl);
      } catch (error) {
        const message = error instanceof Error ? error.message : String(error);
        warnings.push(`Remote image fallback to original URL: ${src} (${message})`);
      }
    }
  }

  const innerHtml = root.html() ?? '<p><br></p>';
  return {
    html: `<div class="markdown-body">${innerHtml.replace(/\n/g, '')}</div>`,
    preloads,
    warnings
  };
}


path: scripts/src/core/template.ts
content: import { renderBrowserPage } from '../browser/page.js';
import type { ThemeSpec } from './types.js';

const BASE_STYLE = `
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body { overflow: hidden; }
img { max-width: 100%; height: auto; }
.text {
  white-space: normal;
  overflow-wrap: anywhere;
  word-break: break-word;
}
.text > .markdown-body {
  color: inherit;
  font: inherit;
  line-height: inherit;
}
.markdown-body {
  color: inherit;
  font-family: inherit;
  font-size: inherit;
  line-height: inherit;
  letter-spacing: inherit;
  font-weight: inherit;
  word-break: break-word;
  white-space: normal;
  overflow-wrap: anywhere;
}
.markdown-body > :first-child { margin-top: 0 !important; }
.markdown-body > :last-child { margin-bottom: 0 !important; }
.markdown-body p,
.markdown-body ul,
.markdown-body ol,
.markdown-body blockquote,
.markdown-body pre,
.markdown-body table,
.markdown-body hr {
  margin: 0.95em 0 0;
}
.markdown-body li + li { margin-top: 0.35em; }
.markdown-body li > ul,
.markdown-body li > ol { margin: 0.45em 0 0; }
.markdown-body h1,
.markdown-body h2,
.markdown-body h3,
.markdown-body h4,
.markdown-body h5,
.markdown-body h6 {
  margin: 1.2em 0 0.55em;
  line-height: 1.35;
  font-weight: 700;
}
.markdown-body h1 { font-size: 2em; }
.markdown-body h2 { font-size: 1.55em; }
.markdown-body h3 { font-size: 1.3em; }
.markdown-body h4 { font-size: 1.1em; }
.markdown-body h5 { font-size: 1em; }
.markdown-body h6 { font-size: 0.92em; opacity: 0.8; }
.markdown-body blockquote {
  padding: 0.2em 0 0.2em 1em;
  border-left: 0.25em solid rgba(0, 0, 0, 0.18);
  background: rgba(0, 0, 0, 0.03);
  border-radius: 0.5em;
  opacity: 0.92;
}
.markdown-body hr {
  border: none;
  border-top: 1px solid rgba(0, 0, 0, 0.16);
}
.markdown-body pre {
  white-space: pre-wrap;
  word-break: normal;
  overflow-wrap: normal;
  overflow: auto;
  padding: 0.85em 1em;
  border-radius: 0.7em;
  background: rgba(0, 0, 0, 0.06);
}
.markdown-body code,
.markdown-body pre code {
  font-family: "SFMono-Regular", Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
}
.markdown-body code:not(pre code) {
  padding: 0.12em 0.35em;
  border-radius: 0.35em;
  background: rgba(0, 0, 0, 0.06);
  font-size: 0.92em;
}
.markdown-body strong,
.text .markdown-body strong {
  font-weight: 700;
}
.markdown-body a {
  color: inherit;
  text-decoration: underline;
  text-decoration-thickness: 0.08em;
  text-underline-offset: 0.14em;
}
.markdown-body table {
  width: 100%;
  border-collapse: collapse;
  table-layout: fixed;
  font-size: 0.95em;
}
.markdown-body th,
.markdown-body td {
  border: 1px solid rgba(0, 0, 0, 0.14);
  padding: 0.35em 0.5em;
  vertical-align: top;
}
.markdown-body th { text-align: left; }
.markdown-body ul,
.markdown-body ol { padding-left: 1.5em; }
.markdown-body img {
  display: block;
  margin: 1em auto 0;
  max-width: 100%;
}
`;

function htmlEscape(value: string): string {
  return value
    .replaceAll('&', '&amp;')
    .replaceAll('<', '&lt;')
    .replaceAll('>', '&gt;')
    .replaceAll('"', '&quot;')
    .replaceAll("'", '&#39;');
}

function maybeHtml(value: string): string {
  return /<\/?[a-z][\s\S]*>/i.test(value) ? value : htmlEscape(value).replaceAll('\n', '<br>');
}

interface BuildDocumentArgs {
  theme: ThemeSpec;
  contentHtml: string;
  width: number;
  height: number;
  pageNum: string;
  showPager: boolean;
  title?: string;
  mainTitle?: string;
}

export function buildCardDocument(args: BuildDocumentArgs): string {
  const {
    theme,
    contentHtml,
    width,
    height,
    pageNum,
    showPager,
    title = '',
    mainTitle = ''
  } = args;

  const cssText = theme.css
    .replaceAll('{{CARD_WIDTH}}', String(width))
    .replaceAll('{{CARD_HEIGHT}}', String(height));

  const normalizedContent = /class=(["'])[^"']*\bmarkdown-body\b[^"']*\1/i.test(contentHtml)
    ? contentHtml
    : `<div class="markdown-body">${contentHtml}</div>`;

  const bodyHtml = theme.bodyTemplate
    .replaceAll('{{CARD_WIDTH}}', String(width))
    .replaceAll('{{CARD_HEIGHT}}', String(height))
    .replaceAll('{{MAIN_TITLE}}', mainTitle ? `<div class="main-title">${maybeHtml(mainTitle)}</div>` : '')
    .replaceAll('{{TITLE}}', title ? `<div class="title">${maybeHtml(title)}</div>` : '')
    .replaceAll('{{TEXT}}', maybeHtml(normalizedContent))
    .replaceAll('{{PAGE_NUM}}', showPager ? pageNum : '');

  return renderBrowserPage(`${BASE_STYLE}\n${cssText}`, bodyHtml);
}


path: scripts/src/core/markdown.ts
content: import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { evaluate } from '@mdx-js/mdx';
import { Fragment, jsx, jsxs } from 'react/jsx-runtime';
import { unified } from 'unified';
import remarkParse from 'remark-parse';
import remarkGfm from 'remark-gfm';
import remarkRehype from 'remark-rehype';
import rehypeRaw from 'rehype-raw';
import rehypeStringify from 'rehype-stringify';

export interface MarkdownRenderOptions {
  mdxMode?: boolean;
}

export interface MarkdownRenderResult {
  html: string;
  warnings: string[];
}

async function mdxToHtml(markdown: string): Promise<string> {
  const mdxComponentNames = [...markdown.matchAll(/<([A-Z][A-Za-z0-9_]*)\b/g)].map((match) => match[1]);
  const fallbackComponents: Record<string, React.ComponentType<Record<string, unknown>>> = {};
  for (const componentName of mdxComponentNames) {
    if (fallbackComponents[componentName]) {
      continue;
    }
    fallbackComponents[componentName] = function UnknownMdxComponent(props: Record<string, unknown> = {}) {
      const { children } = props;
      return React.createElement(
        'div',
        {
          'data-mdx-component': componentName
        },
        children as React.ReactNode
      );
    };
  }

  const module = (await evaluate(markdown, {
    Fragment,
    jsx,
    jsxs,
    development: false,
    remarkPlugins: [remarkGfm]
  })) as { default: React.ComponentType<Record<string, unknown>> };

  const Component = module.default;
  const html = renderToStaticMarkup(
    React.createElement(Component, {
      components: fallbackComponents
    })
  ).trim();
  return html.length > 0 ? html : '<p><br></p>';
}

export async function markdownToHtml(markdown: string, options: MarkdownRenderOptions = {}): Promise<MarkdownRenderResult> {
  const warnings: string[] = [];

  if (options.mdxMode) {
    return {
      html: await mdxToHtml(markdown),
      warnings
    };
  }

  const file = await unified()
    .use(remarkParse)
    .use(remarkGfm)
    .use(remarkRehype, { allowDangerousHtml: true })
    .use(rehypeRaw)
    .use(rehypeStringify, { allowDangerousHtml: true })
    .process(markdown);

  const html = String(file.value).trim();
  return {
    html: html.length > 0 ? html : '<p><br></p>',
    warnings
  };
}


path: scripts/src/core/types.ts
content: export type ThemeMode = 'light' | 'dark';
export type SplitMode = 'auto' | 'hr' | 'none';

export interface FontConfig {
  family?: string;
  weight?: string;
  level?: number;
}

export interface ThemeSpec {
  id: string;
  name: string;
  className: string;
  modes: ThemeMode[];
  css: string;
  bodyTemplate: string;
  rawTemplate: string;
}

export interface ThemeResolution {
  theme: ThemeSpec;
  appliedMode: ThemeMode;
  warnings: string[];
}

export interface PreviewPayload {
  markdown: string;
  theme: string;
  width: number;
  height: number;
  mdxMode: boolean;
  splitMode: SplitMode;
  weChatMode: boolean;
  themeMode: ThemeMode;
  showPager: boolean;
  overHiddenMode: boolean;
  font?: FontConfig;
}

export interface PreviewMetaData {
  title?: string;
  description?: string;
}

export interface PreviewDiagnostics {
  markdownLength: number;
  htmlLength: number;
  structuredHtmlLength: number;
}

export interface PreviewResult {
  success: boolean;
  html: string;
  rawHtml: string;
  structuredHtml: string;
  preloads: string[];
  metaData: PreviewMetaData;
  warnings: string[];
  diagnostics: PreviewDiagnostics;
}

export interface SplitBoundary {
  pageIndex: number;
  startBlock: number;
  endBlock: number;
  mode: 'block' | 'range' | 'hr' | 'none';
}

export interface OverflowCheck {
  pageIndex: number;
  blockIndex: number;
  candidateBlockCount: number;
  overflow: boolean;
}

export interface PageDiagnostics {
  reason: string;
  blockCount: number;
  generatedPages: number;
  maxPages: number;
  sampledBlockLengths: number[];
  sampledCandidates?: Array<{ blockIndex: number; hash: string; preview: string }>;
}

export interface PaginationResult {
  pages: string[];
  splitBoundaries: SplitBoundary[];
  overflowChecks: OverflowCheck[];
  fallbackCount: number;
  diagnostics?: PageDiagnostics;
}

export interface RenderResult {
  imagePath: string;
  success: boolean;
  index: number;
}


path: scripts/src/core/render.ts
content: import { mkdirSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { chromium } from 'playwright';
import { buildCardDocument } from './template.js';
import type { RenderResult, ThemeSpec } from './types.js';

interface ExportPagesArgs {
  pages: string[];
  theme: ThemeSpec;
  width: number;
  height: number;
  scale: number;
  showPager: boolean;
  outputDir: string;
  title?: string;
  mainTitle?: string;
}

export async function exportPagesToPng(args: ExportPagesArgs): Promise<RenderResult[]> {
  mkdirSync(args.outputDir, { recursive: true });

  const browser = await chromium.launch();
  const page = await browser.newPage({
    viewport: { width: args.width, height: args.height },
    deviceScaleFactor: args.scale
  });

  const total = args.pages.length;
  const results: RenderResult[] = [];

  try {
    for (let index = 0; index < total; index += 1) {
      const pageNumber = index + 1;
      const documentHtml = buildCardDocument({
        theme: args.theme,
        contentHtml: args.pages[index],
        width: args.width,
        height: args.height,
        pageNum: `${pageNumber} / ${total}`,
        showPager: args.showPager,
        title: index === 0 ? args.title : undefined,
        mainTitle: index === 0 ? args.mainTitle : undefined
      });

      const outputPath = resolve(args.outputDir, `card_${pageNumber}.png`);
      await page.setContent(documentHtml, { waitUntil: 'load' });
      await page.waitForTimeout(30);
      await page.screenshot({
        path: outputPath,
        type: 'png',
        clip: { x: 0, y: 0, width: args.width, height: args.height },
        animations: 'disabled'
      });

      results.push({
        index: pageNumber,
        imagePath: outputPath,
        success: true
      });
    }
  } finally {
    await browser.close();
  }

  return results;
}

export async function doctorPlaywright(): Promise<{ ok: boolean; message: string }> {
  try {
    const browser = await chromium.launch();
    const page = await browser.newPage({ viewport: { width: 200, height: 100 } });
    await page.setContent('<html><body><div>ok</div></body></html>', { waitUntil: 'load' });
    await browser.close();
    return { ok: true, message: 'Playwright Chromium is ready.' };
  } catch (error) {
    return {
      ok: false,
      message: `Playwright Chromium unavailable: ${error instanceof Error ? error.message : String(error)}`
    };
  }
}

export function ensureOutputDir(pathValue: string): string {
  const outputDir = resolve(pathValue);
  mkdirSync(outputDir, { recursive: true });
  return outputDir;
}

export function ensureParentDir(pathValue: string): void {
  mkdirSync(dirname(pathValue), { recursive: true });
}


path: scripts/src/browser/page.tsx
content: import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';

interface BrowserPageProps {
  styleText: string;
  bodyHtml: string;
}

function BrowserPage({ styleText, bodyHtml }: BrowserPageProps): React.ReactElement {
  return (
    <html lang="zh-CN">
      <head>
        <meta charSet="utf-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1" />
        <style dangerouslySetInnerHTML={{ __html: styleText }} />
      </head>
      <body>
        <div id="app" dangerouslySetInnerHTML={{ __html: bodyHtml }} />
      </body>
    </html>
  );
}

export function renderBrowserPage(styleText: string, bodyHtml: string): string {
  return `<!doctype html>${renderToStaticMarkup(<BrowserPage styleText={styleText} bodyHtml={bodyHtml} />)}`;
}


path: assets/preview-template.md
content: # 模板预览示例

这是用于模板快速预览的固定文案。

- 用来观察标题、段落、列表、引用样式
- 默认保证一页可读，不用于正式发布

> 先看模板，再决定正式渲染参数。


Security scan verdicts — ClawScan: suspicious | static analysis: suspicious | VirusTotal: clean | SkillSpector: suspicious (CRITICAL)