mirror of
https://github.com/JimLiu/baoyu-skills.git
synced 2026-07-12 22:09:48 +08:00
Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 484b00109f | |||
| ac217d5402 | |||
| 70d9f63727 | |||
| 3398509d9e | |||
| a11613c11b | |||
| cb17cb9cca | |||
| 88d6e09472 | |||
| 004236682d | |||
| 7e07c1bb84 | |||
| c151f33775 | |||
| 32003da694 | |||
| 6f38724163 | |||
| 374a6b28fd | |||
| 95970480c8 | |||
| fc324319d8 | |||
| 056fa06c3b |
@@ -6,7 +6,7 @@
|
||||
},
|
||||
"metadata": {
|
||||
"description": "Skills shared by Baoyu for improving daily work efficiency",
|
||||
"version": "1.64.0"
|
||||
"version": "1.66.0"
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
name: Test
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
node-tests:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Run tests
|
||||
run: npm test
|
||||
@@ -2,6 +2,39 @@
|
||||
|
||||
English | [中文](./CHANGELOG.zh.md)
|
||||
|
||||
## 1.66.0 - 2026-03-13
|
||||
|
||||
### Features
|
||||
- `baoyu-image-gen`: add Jimeng (即梦) and Seedream (豆包) image generation providers (by @lindaifeng)
|
||||
|
||||
### Fixes
|
||||
- `baoyu-image-gen`: tighten Jimeng provider behavior
|
||||
|
||||
### Refactor
|
||||
- `baoyu-image-gen`: export functions for testability and add module entry guard
|
||||
|
||||
### Documentation
|
||||
- `baoyu-image-gen`: add Jimeng and Seedream provider documentation to SKILL.md and READMEs
|
||||
|
||||
### Tests
|
||||
- Add test infrastructure with CI workflow and image-gen unit tests
|
||||
|
||||
## 1.65.1 - 2026-03-13
|
||||
|
||||
### Refactor
|
||||
- `baoyu-translate`: replace remark/unified with markdown-it for chunk parsing, add main.ts CLI entry point
|
||||
|
||||
## 1.65.0 - 2026-03-13
|
||||
|
||||
### Features
|
||||
- `baoyu-post-to-wechat`: add placeholder image upload support with deduplication for markdown-embedded images
|
||||
|
||||
### Fixes
|
||||
- `baoyu-post-to-wechat`: fix frontmatter parsing to allow leading whitespace and optional trailing newline
|
||||
|
||||
### Refactor
|
||||
- `baoyu-post-to-wechat`: replace `renderMarkdownToHtml` with `renderMarkdownWithPlaceholders` for structured output
|
||||
|
||||
## 1.64.0 - 2026-03-13
|
||||
|
||||
### Features
|
||||
|
||||
@@ -2,6 +2,39 @@
|
||||
|
||||
[English](./CHANGELOG.md) | 中文
|
||||
|
||||
## 1.66.0 - 2026-03-13
|
||||
|
||||
### 新功能
|
||||
- `baoyu-image-gen`:新增即梦(Jimeng)和豆包(Seedream)图像生成服务商 (by @lindaifeng)
|
||||
|
||||
### 修复
|
||||
- `baoyu-image-gen`:收紧即梦服务商行为
|
||||
|
||||
### 重构
|
||||
- `baoyu-image-gen`:导出函数以支持测试,新增模块入口守卫
|
||||
|
||||
### 文档
|
||||
- `baoyu-image-gen`:在 SKILL.md 和 README 中添加即梦和豆包服务商文档
|
||||
|
||||
### 测试
|
||||
- 新增测试基础设施,包含 CI 工作流和 image-gen 单元测试
|
||||
|
||||
## 1.65.1 - 2026-03-13
|
||||
|
||||
### 重构
|
||||
- `baoyu-translate`:将 chunk 解析从 remark/unified 替换为 markdown-it,新增 main.ts CLI 入口
|
||||
|
||||
## 1.65.0 - 2026-03-13
|
||||
|
||||
### 新功能
|
||||
- `baoyu-post-to-wechat`:新增占位符图片上传支持,自动去重 Markdown 内嵌图片
|
||||
|
||||
### 修复
|
||||
- `baoyu-post-to-wechat`:修复 frontmatter 解析,允许前导空白和可选的尾随换行
|
||||
|
||||
### 重构
|
||||
- `baoyu-post-to-wechat`:将 `renderMarkdownToHtml` 重构为 `renderMarkdownWithPlaceholders`,输出结构化结果
|
||||
|
||||
## 1.64.0 - 2026-03-13
|
||||
|
||||
### 新功能
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# CLAUDE.md
|
||||
|
||||
Claude Code marketplace plugin providing AI-powered content generation skills. Version: **1.64.0**.
|
||||
Claude Code marketplace plugin providing AI-powered content generation skills. Version: **1.66.0**.
|
||||
|
||||
## Architecture
|
||||
|
||||
|
||||
@@ -665,7 +665,7 @@ AI-powered generation backends.
|
||||
|
||||
#### baoyu-image-gen
|
||||
|
||||
AI SDK-based image generation using OpenAI, Google, OpenRouter, DashScope (Aliyun Tongyi Wanxiang), and Replicate APIs. Supports text-to-image, reference images, aspect ratios, and quality presets.
|
||||
AI SDK-based image generation using OpenAI, Google, OpenRouter, DashScope (Aliyun Tongyi Wanxiang), Jimeng (即梦), Seedream (豆包), and Replicate APIs. Supports text-to-image, reference images, aspect ratios, and quality presets.
|
||||
|
||||
```bash
|
||||
# Basic generation (auto-detect provider)
|
||||
@@ -689,6 +689,12 @@ AI SDK-based image generation using OpenAI, Google, OpenRouter, DashScope (Aliyu
|
||||
# Replicate
|
||||
/baoyu-image-gen --prompt "A cat" --image cat.png --provider replicate
|
||||
|
||||
# Jimeng (即梦)
|
||||
/baoyu-image-gen --prompt "一只可爱的猫" --image cat.png --provider jimeng
|
||||
|
||||
# Seedream (豆包)
|
||||
/baoyu-image-gen --prompt "一只可爱的猫" --image cat.png --provider seedream
|
||||
|
||||
# With reference images (Google, OpenAI, OpenRouter, or Replicate)
|
||||
/baoyu-image-gen --prompt "Make it blue" --image out.png --ref source.png
|
||||
```
|
||||
@@ -699,7 +705,7 @@ AI SDK-based image generation using OpenAI, Google, OpenRouter, DashScope (Aliyu
|
||||
| `--prompt`, `-p` | Prompt text |
|
||||
| `--promptfiles` | Read prompt from files (concatenated) |
|
||||
| `--image` | Output image path (required) |
|
||||
| `--provider` | `google`, `openai`, `openrouter`, `dashscope` or `replicate` (default: auto-detect; prefers google) |
|
||||
| `--provider` | `google`, `openai`, `openrouter`, `dashscope`, `jimeng`, `seedream` or `replicate` (default: auto-detect; prefers google) |
|
||||
| `--model`, `-m` | Model ID |
|
||||
| `--ar` | Aspect ratio (e.g., `16:9`, `1:1`, `4:3`) |
|
||||
| `--size` | Size (e.g., `1024x1024`) |
|
||||
@@ -714,16 +720,24 @@ AI SDK-based image generation using OpenAI, Google, OpenRouter, DashScope (Aliyu
|
||||
| `GOOGLE_API_KEY` | Google API key | - |
|
||||
| `DASHSCOPE_API_KEY` | DashScope API key (Aliyun) | - |
|
||||
| `REPLICATE_API_TOKEN` | Replicate API token | - |
|
||||
| `JIMENG_ACCESS_KEY_ID` | Jimeng Volcengine access key | - |
|
||||
| `JIMENG_SECRET_ACCESS_KEY` | Jimeng Volcengine secret key | - |
|
||||
| `ARK_API_KEY` | Seedream Volcengine ARK API key | - |
|
||||
| `OPENAI_IMAGE_MODEL` | OpenAI model | `gpt-image-1.5` |
|
||||
| `OPENROUTER_IMAGE_MODEL` | OpenRouter model | `google/gemini-3.1-flash-image-preview` |
|
||||
| `GOOGLE_IMAGE_MODEL` | Google model | `gemini-3-pro-image-preview` |
|
||||
| `DASHSCOPE_IMAGE_MODEL` | DashScope model | `z-image-turbo` |
|
||||
| `REPLICATE_IMAGE_MODEL` | Replicate model | `google/nano-banana-pro` |
|
||||
| `JIMENG_IMAGE_MODEL` | Jimeng model | `jimeng_t2i_v40` |
|
||||
| `SEEDREAM_IMAGE_MODEL` | Seedream model | `doubao-seedream-5-0-260128` |
|
||||
| `OPENAI_BASE_URL` | Custom OpenAI endpoint | - |
|
||||
| `OPENROUTER_BASE_URL` | Custom OpenRouter endpoint | `https://openrouter.ai/api/v1` |
|
||||
| `GOOGLE_BASE_URL` | Custom Google endpoint | - |
|
||||
| `DASHSCOPE_BASE_URL` | Custom DashScope endpoint | - |
|
||||
| `REPLICATE_BASE_URL` | Custom Replicate endpoint | - |
|
||||
| `JIMENG_BASE_URL` | Custom Jimeng endpoint | `https://visual.volcengineapi.com` |
|
||||
| `JIMENG_REGION` | Jimeng region | `cn-north-1` |
|
||||
| `SEEDREAM_BASE_URL` | Custom Seedream endpoint | `https://ark.cn-beijing.volces.com/api/v3` |
|
||||
|
||||
**Provider Auto-Selection**:
|
||||
1. If `--provider` specified → use it
|
||||
@@ -989,6 +1003,18 @@ DASHSCOPE_IMAGE_MODEL=z-image-turbo
|
||||
REPLICATE_API_TOKEN=r8_xxx
|
||||
REPLICATE_IMAGE_MODEL=google/nano-banana-pro
|
||||
# REPLICATE_BASE_URL=https://api.replicate.com
|
||||
|
||||
# Jimeng (即梦)
|
||||
JIMENG_ACCESS_KEY_ID=xxx
|
||||
JIMENG_SECRET_ACCESS_KEY=xxx
|
||||
JIMENG_IMAGE_MODEL=jimeng_t2i_v40
|
||||
# JIMENG_BASE_URL=https://visual.volcengineapi.com
|
||||
# JIMENG_REGION=cn-north-1
|
||||
|
||||
# Seedream (豆包)
|
||||
ARK_API_KEY=xxx
|
||||
SEEDREAM_IMAGE_MODEL=doubao-seedream-5-0-260128
|
||||
# SEEDREAM_BASE_URL=https://ark.cn-beijing.volces.com/api/v3
|
||||
EOF
|
||||
```
|
||||
|
||||
|
||||
+28
-2
@@ -665,7 +665,7 @@ AI 驱动的生成后端。
|
||||
|
||||
#### baoyu-image-gen
|
||||
|
||||
基于 AI SDK 的图像生成,支持 OpenAI、Google、OpenRouter、DashScope(阿里通义万相)和 Replicate API。支持文生图、参考图、宽高比和质量预设。
|
||||
基于 AI SDK 的图像生成,支持 OpenAI、Google、OpenRouter、DashScope(阿里通义万相)、即梦(Jimeng)、豆包(Seedream)和 Replicate API。支持文生图、参考图、宽高比和质量预设。
|
||||
|
||||
```bash
|
||||
# 基础生成(自动检测服务商)
|
||||
@@ -689,6 +689,12 @@ AI 驱动的生成后端。
|
||||
# Replicate
|
||||
/baoyu-image-gen --prompt "一只猫" --image cat.png --provider replicate
|
||||
|
||||
# 即梦(Jimeng)
|
||||
/baoyu-image-gen --prompt "一只可爱的猫" --image cat.png --provider jimeng
|
||||
|
||||
# 豆包(Seedream)
|
||||
/baoyu-image-gen --prompt "一只可爱的猫" --image cat.png --provider seedream
|
||||
|
||||
# 带参考图(Google、OpenAI、OpenRouter 或 Replicate)
|
||||
/baoyu-image-gen --prompt "把它变成蓝色" --image out.png --ref source.png
|
||||
```
|
||||
@@ -699,7 +705,7 @@ AI 驱动的生成后端。
|
||||
| `--prompt`, `-p` | 提示词文本 |
|
||||
| `--promptfiles` | 从文件读取提示词(多文件拼接) |
|
||||
| `--image` | 输出图片路径(必需) |
|
||||
| `--provider` | `google`、`openai`、`openrouter`、`dashscope` 或 `replicate`(默认:自动检测,优先 google) |
|
||||
| `--provider` | `google`、`openai`、`openrouter`、`dashscope`、`jimeng`、`seedream` 或 `replicate`(默认:自动检测,优先 google) |
|
||||
| `--model`, `-m` | 模型 ID |
|
||||
| `--ar` | 宽高比(如 `16:9`、`1:1`、`4:3`) |
|
||||
| `--size` | 尺寸(如 `1024x1024`) |
|
||||
@@ -714,16 +720,24 @@ AI 驱动的生成后端。
|
||||
| `GOOGLE_API_KEY` | Google API 密钥 | - |
|
||||
| `DASHSCOPE_API_KEY` | DashScope API 密钥(阿里云) | - |
|
||||
| `REPLICATE_API_TOKEN` | Replicate API Token | - |
|
||||
| `JIMENG_ACCESS_KEY_ID` | 即梦火山引擎 Access Key | - |
|
||||
| `JIMENG_SECRET_ACCESS_KEY` | 即梦火山引擎 Secret Key | - |
|
||||
| `ARK_API_KEY` | 豆包火山引擎 ARK API 密钥 | - |
|
||||
| `OPENAI_IMAGE_MODEL` | OpenAI 模型 | `gpt-image-1.5` |
|
||||
| `OPENROUTER_IMAGE_MODEL` | OpenRouter 模型 | `google/gemini-3.1-flash-image-preview` |
|
||||
| `GOOGLE_IMAGE_MODEL` | Google 模型 | `gemini-3-pro-image-preview` |
|
||||
| `DASHSCOPE_IMAGE_MODEL` | DashScope 模型 | `z-image-turbo` |
|
||||
| `REPLICATE_IMAGE_MODEL` | Replicate 模型 | `google/nano-banana-pro` |
|
||||
| `JIMENG_IMAGE_MODEL` | 即梦模型 | `jimeng_t2i_v40` |
|
||||
| `SEEDREAM_IMAGE_MODEL` | 豆包模型 | `doubao-seedream-5-0-260128` |
|
||||
| `OPENAI_BASE_URL` | 自定义 OpenAI 端点 | - |
|
||||
| `OPENROUTER_BASE_URL` | 自定义 OpenRouter 端点 | `https://openrouter.ai/api/v1` |
|
||||
| `GOOGLE_BASE_URL` | 自定义 Google 端点 | - |
|
||||
| `DASHSCOPE_BASE_URL` | 自定义 DashScope 端点 | - |
|
||||
| `REPLICATE_BASE_URL` | 自定义 Replicate 端点 | - |
|
||||
| `JIMENG_BASE_URL` | 自定义即梦端点 | `https://visual.volcengineapi.com` |
|
||||
| `JIMENG_REGION` | 即梦区域 | `cn-north-1` |
|
||||
| `SEEDREAM_BASE_URL` | 自定义豆包端点 | `https://ark.cn-beijing.volces.com/api/v3` |
|
||||
|
||||
**服务商自动选择**:
|
||||
1. 如果指定了 `--provider` → 使用指定的
|
||||
@@ -989,6 +1003,18 @@ DASHSCOPE_IMAGE_MODEL=z-image-turbo
|
||||
REPLICATE_API_TOKEN=r8_xxx
|
||||
REPLICATE_IMAGE_MODEL=google/nano-banana-pro
|
||||
# REPLICATE_BASE_URL=https://api.replicate.com
|
||||
|
||||
# 即梦(Jimeng)
|
||||
JIMENG_ACCESS_KEY_ID=xxx
|
||||
JIMENG_SECRET_ACCESS_KEY=xxx
|
||||
JIMENG_IMAGE_MODEL=jimeng_t2i_v40
|
||||
# JIMENG_BASE_URL=https://visual.volcengineapi.com
|
||||
# JIMENG_REGION=cn-north-1
|
||||
|
||||
# 豆包(Seedream)
|
||||
ARK_API_KEY=xxx
|
||||
SEEDREAM_IMAGE_MODEL=doubao-seedream-5-0-260128
|
||||
# SEEDREAM_BASE_URL=https://ark.cn-beijing.volces.com/api/v3
|
||||
EOF
|
||||
```
|
||||
|
||||
|
||||
+3
-1
@@ -27,7 +27,9 @@ Release hooks are configured via `.releaserc.yml`. This repo does not stage a se
|
||||
|
||||
`packages/` is the **only** source of truth. Never edit `skills/*/scripts/vendor/` directly.
|
||||
|
||||
Current package: `baoyu-chrome-cdp` (Chrome CDP utilities), consumed by 6 skills (`baoyu-danger-gemini-web`, `baoyu-danger-x-to-markdown`, `baoyu-post-to-wechat`, `baoyu-post-to-weibo`, `baoyu-post-to-x`, `baoyu-url-to-markdown`).
|
||||
Current packages:
|
||||
- `baoyu-chrome-cdp` (Chrome CDP utilities), consumed by 6 skills (`baoyu-danger-gemini-web`, `baoyu-danger-x-to-markdown`, `baoyu-post-to-wechat`, `baoyu-post-to-weibo`, `baoyu-post-to-x`, `baoyu-url-to-markdown`)
|
||||
- `baoyu-md` (shared Markdown rendering and placeholder pipeline), consumed by 3 skills (`baoyu-markdown-to-html`, `baoyu-post-to-wechat`, `baoyu-post-to-weibo`)
|
||||
|
||||
**How it works**: Sync script copies packages into each consuming skill's `vendor/` directory and rewrites dependency specs to `file:./vendor/<name>`. Vendor copies are committed to git, making skills self-contained.
|
||||
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
# Testing Strategy
|
||||
|
||||
This repository has many scripts, but they do not share a single runtime or dependency graph. The lowest-risk testing strategy is to start from stable Node-based library code, then expand outward to CLI and skill-specific smoke tests.
|
||||
|
||||
## Current Baseline
|
||||
|
||||
- Root test runner: `node:test`
|
||||
- Entry point: `npm test`
|
||||
- Coverage command: `npm run test:coverage`
|
||||
- CI trigger: GitHub Actions on `push`, `pull_request`, and manual dispatch
|
||||
|
||||
This avoids introducing Jest/Vitest across a repo that already mixes plain Node scripts, Bun-based skill packages, vendored code, and browser automation.
|
||||
|
||||
## Rollout Plan
|
||||
|
||||
### Phase 1: Stable library coverage
|
||||
|
||||
Focus on pure functions under `scripts/lib/` first.
|
||||
|
||||
- `scripts/lib/release-files.mjs`
|
||||
- `scripts/lib/shared-skill-packages.mjs`
|
||||
|
||||
Goals:
|
||||
|
||||
- Validate file filtering and release packaging rules
|
||||
- Catch regressions in package vendoring and dependency rewriting
|
||||
- Keep tests deterministic and free of network, Bun, or browser requirements
|
||||
|
||||
### Phase 2: Root CLI integration tests
|
||||
|
||||
Add temp-directory integration tests for root CLIs that already support dry-run or local-only flows.
|
||||
|
||||
- `scripts/sync-shared-skill-packages.mjs`
|
||||
- `scripts/publish-skill.mjs --dry-run`
|
||||
- `scripts/sync-clawhub.mjs` argument handling and local skill discovery
|
||||
|
||||
Goals:
|
||||
|
||||
- Assert exit codes and stdout for common flows
|
||||
- Cover CLI argument parsing without hitting external services
|
||||
|
||||
### Phase 3: Skill script smoke tests
|
||||
|
||||
Add opt-in smoke tests for selected `skills/*/scripts/` packages, starting with those that:
|
||||
|
||||
- accept local input files
|
||||
- have deterministic output
|
||||
- do not require authenticated browser sessions
|
||||
|
||||
Examples:
|
||||
|
||||
- markdown transforms
|
||||
- file conversion helpers
|
||||
- local content analyzers
|
||||
|
||||
Keep browser automation, login flows, and live API publishing scripts outside the default CI path unless they are explicitly mocked.
|
||||
|
||||
### Phase 4: Coverage gates
|
||||
|
||||
After the stable Node path has enough breadth, add coverage thresholds in CI for the tested root modules.
|
||||
|
||||
Recommended order:
|
||||
|
||||
1. Start with reporting only
|
||||
2. Add line/function thresholds for `scripts/lib/**`
|
||||
3. Expand include patterns once skill-level smoke tests are reliable
|
||||
|
||||
## Conventions For New Tests
|
||||
|
||||
- Prefer temp directories over committed fixtures unless the fixture is reused heavily
|
||||
- Test exported functions before testing CLI wrappers
|
||||
- Avoid network, browser, and credential dependencies in default CI
|
||||
- Keep tests isolated so they can run with plain `node --test`
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "baoyu-skills",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test": "node --test",
|
||||
"test:coverage": "node --experimental-test-coverage --test"
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,11 @@
|
||||
{
|
||||
"name": "baoyu-md",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"fflate": "^0.8.2",
|
||||
"front-matter": "^4.0.2",
|
||||
@@ -12,7 +12,7 @@ export function printUsage(): void {
|
||||
console.error(
|
||||
[
|
||||
"Usage:",
|
||||
" npx tsx src/md/render.ts <markdown_file> [options]",
|
||||
" npx tsx render.ts <markdown_file> [options]",
|
||||
"",
|
||||
"Options:",
|
||||
` --theme <name> Theme (${THEME_NAMES.join(", ")})`,
|
||||
@@ -0,0 +1,105 @@
|
||||
import { Lexer } from "marked";
|
||||
|
||||
export type FrontmatterFields = Record<string, string>;
|
||||
|
||||
export function parseFrontmatter(content: string): {
|
||||
frontmatter: FrontmatterFields;
|
||||
body: string;
|
||||
} {
|
||||
const match = content.match(/^\s*---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
|
||||
if (!match) {
|
||||
return { frontmatter: {}, body: content };
|
||||
}
|
||||
|
||||
const frontmatter: FrontmatterFields = {};
|
||||
const lines = match[1]!.split("\n");
|
||||
for (const line of lines) {
|
||||
const colonIdx = line.indexOf(":");
|
||||
if (colonIdx <= 0) continue;
|
||||
|
||||
const key = line.slice(0, colonIdx).trim();
|
||||
const value = line.slice(colonIdx + 1).trim();
|
||||
frontmatter[key] = stripWrappingQuotes(value);
|
||||
}
|
||||
|
||||
return { frontmatter, body: match[2]! };
|
||||
}
|
||||
|
||||
export function serializeFrontmatter(frontmatter: FrontmatterFields): string {
|
||||
const entries = Object.entries(frontmatter);
|
||||
if (entries.length === 0) return "";
|
||||
return `---\n${entries.map(([key, value]) => `${key}: ${value}`).join("\n")}\n---\n`;
|
||||
}
|
||||
|
||||
export function stripWrappingQuotes(value: string): string {
|
||||
if (!value) return value;
|
||||
|
||||
const doubleQuoted = value.startsWith('"') && value.endsWith('"');
|
||||
const singleQuoted = value.startsWith("'") && value.endsWith("'");
|
||||
const cjkDoubleQuoted = value.startsWith("\u201c") && value.endsWith("\u201d");
|
||||
const cjkSingleQuoted = value.startsWith("\u2018") && value.endsWith("\u2019");
|
||||
|
||||
if (doubleQuoted || singleQuoted || cjkDoubleQuoted || cjkSingleQuoted) {
|
||||
return value.slice(1, -1).trim();
|
||||
}
|
||||
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
export function toFrontmatterString(value: unknown): string | undefined {
|
||||
if (typeof value === "string") {
|
||||
return stripWrappingQuotes(value);
|
||||
}
|
||||
if (typeof value === "number" || typeof value === "boolean") {
|
||||
return String(value);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function pickFirstString(
|
||||
frontmatter: Record<string, unknown>,
|
||||
keys: string[],
|
||||
): string | undefined {
|
||||
for (const key of keys) {
|
||||
const value = toFrontmatterString(frontmatter[key]);
|
||||
if (value) return value;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function extractTitleFromMarkdown(markdown: string): string {
|
||||
const tokens = Lexer.lex(markdown, { gfm: true, breaks: true });
|
||||
for (const token of tokens) {
|
||||
if (token.type !== "heading" || (token.depth !== 1 && token.depth !== 2)) continue;
|
||||
return stripWrappingQuotes(token.text);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
export function extractSummaryFromBody(body: string, maxLen: number): string {
|
||||
const lines = body.split("\n");
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
if (trimmed.startsWith("#")) continue;
|
||||
if (trimmed.startsWith("![")) continue;
|
||||
if (trimmed.startsWith(">")) continue;
|
||||
if (trimmed.startsWith("-") || trimmed.startsWith("*")) continue;
|
||||
if (/^\d+\./.test(trimmed)) continue;
|
||||
if (trimmed.startsWith("```")) continue;
|
||||
|
||||
const cleanText = trimmed
|
||||
.replace(/\*\*(.+?)\*\*/g, "$1")
|
||||
.replace(/\*(.+?)\*/g, "$1")
|
||||
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
|
||||
.replace(/`([^`]+)`/g, "$1");
|
||||
|
||||
if (cleanText.length > 20) {
|
||||
if (cleanText.length <= maxLen) return cleanText;
|
||||
return `${cleanText.slice(0, maxLen - 3)}...`;
|
||||
}
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
import type { ReadTimeResults } from "reading-time";
|
||||
|
||||
import {
|
||||
COLOR_PRESETS,
|
||||
DEFAULT_STYLE,
|
||||
FONT_FAMILY_MAP,
|
||||
THEME_STYLE_DEFAULTS,
|
||||
} from "./constants.js";
|
||||
import {
|
||||
extractSummaryFromBody,
|
||||
extractTitleFromMarkdown,
|
||||
pickFirstString,
|
||||
stripWrappingQuotes,
|
||||
} from "./content.js";
|
||||
import { loadExtendConfig } from "./extend-config.js";
|
||||
import {
|
||||
buildCss,
|
||||
buildHtmlDocument,
|
||||
inlineCss,
|
||||
loadCodeThemeCss,
|
||||
modifyHtmlStructure,
|
||||
normalizeInlineCss,
|
||||
removeFirstHeading,
|
||||
} from "./html-builder.js";
|
||||
import { initRenderer, postProcessHtml, renderMarkdown } from "./renderer.js";
|
||||
import { loadThemeCss, normalizeThemeCss } from "./themes.js";
|
||||
import type { HtmlDocumentMeta, IOpts, StyleConfig, ThemeName } from "./types.js";
|
||||
|
||||
export interface RenderMarkdownDocumentOptions {
|
||||
codeTheme?: string;
|
||||
countStatus?: boolean;
|
||||
citeStatus?: boolean;
|
||||
defaultTitle?: string;
|
||||
fontFamily?: string;
|
||||
fontSize?: string;
|
||||
isMacCodeBlock?: boolean;
|
||||
isShowLineNumber?: boolean;
|
||||
keepTitle?: boolean;
|
||||
legend?: string;
|
||||
primaryColor?: string;
|
||||
theme?: ThemeName;
|
||||
themeMode?: IOpts["themeMode"];
|
||||
}
|
||||
|
||||
export interface RenderMarkdownDocumentResult {
|
||||
contentHtml: string;
|
||||
html: string;
|
||||
meta: HtmlDocumentMeta;
|
||||
readingTime: ReadTimeResults;
|
||||
style: StyleConfig;
|
||||
yamlData: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function resolveColorToken(value?: string): string | undefined {
|
||||
if (!value) return undefined;
|
||||
return COLOR_PRESETS[value] ?? value;
|
||||
}
|
||||
|
||||
export function resolveFontFamilyToken(value?: string): string | undefined {
|
||||
if (!value) return undefined;
|
||||
return FONT_FAMILY_MAP[value] ?? value;
|
||||
}
|
||||
|
||||
export function formatTimestamp(date = new Date()): string {
|
||||
const pad = (value: number) => String(value).padStart(2, "0");
|
||||
return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(
|
||||
date.getDate(),
|
||||
)}${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`;
|
||||
}
|
||||
|
||||
export function buildMarkdownDocumentMeta(
|
||||
markdown: string,
|
||||
yamlData: Record<string, unknown>,
|
||||
defaultTitle = "document",
|
||||
): HtmlDocumentMeta {
|
||||
const title = pickFirstString(yamlData, ["title"])
|
||||
|| extractTitleFromMarkdown(markdown)
|
||||
|| defaultTitle;
|
||||
const author = pickFirstString(yamlData, ["author"]);
|
||||
const description = pickFirstString(yamlData, ["description", "summary"])
|
||||
|| extractSummaryFromBody(markdown, 120);
|
||||
|
||||
return {
|
||||
title: stripWrappingQuotes(title),
|
||||
author: author ? stripWrappingQuotes(author) : undefined,
|
||||
description: description ? stripWrappingQuotes(description) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveMarkdownStyle(options: RenderMarkdownDocumentOptions = {}): StyleConfig {
|
||||
const theme = options.theme ?? "default";
|
||||
const themeDefaults = THEME_STYLE_DEFAULTS[theme] ?? {};
|
||||
|
||||
return {
|
||||
...DEFAULT_STYLE,
|
||||
...themeDefaults,
|
||||
...(options.primaryColor !== undefined ? { primaryColor: options.primaryColor } : {}),
|
||||
...(options.fontFamily !== undefined ? { fontFamily: options.fontFamily } : {}),
|
||||
...(options.fontSize !== undefined ? { fontSize: options.fontSize } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveRenderOptions(
|
||||
options: RenderMarkdownDocumentOptions = {},
|
||||
): RenderMarkdownDocumentOptions {
|
||||
const extendConfig = loadExtendConfig();
|
||||
|
||||
return {
|
||||
codeTheme: options.codeTheme ?? extendConfig.default_code_theme ?? "github",
|
||||
countStatus: options.countStatus ?? extendConfig.count ?? false,
|
||||
citeStatus: options.citeStatus ?? extendConfig.cite ?? false,
|
||||
defaultTitle: options.defaultTitle,
|
||||
fontFamily: options.fontFamily ?? resolveFontFamilyToken(extendConfig.default_font_family ?? undefined),
|
||||
fontSize: options.fontSize ?? extendConfig.default_font_size ?? undefined,
|
||||
isMacCodeBlock: options.isMacCodeBlock ?? extendConfig.mac_code_block ?? true,
|
||||
isShowLineNumber: options.isShowLineNumber ?? extendConfig.show_line_number ?? false,
|
||||
keepTitle: options.keepTitle ?? extendConfig.keep_title ?? false,
|
||||
legend: options.legend ?? extendConfig.legend ?? "alt",
|
||||
primaryColor: options.primaryColor ?? resolveColorToken(extendConfig.default_color ?? undefined),
|
||||
theme: options.theme ?? extendConfig.default_theme ?? "default",
|
||||
themeMode: options.themeMode,
|
||||
};
|
||||
}
|
||||
|
||||
export async function renderMarkdownDocument(
|
||||
markdown: string,
|
||||
options: RenderMarkdownDocumentOptions = {},
|
||||
): Promise<RenderMarkdownDocumentResult> {
|
||||
const resolvedOptions = resolveRenderOptions(options);
|
||||
const theme = resolvedOptions.theme ?? "default";
|
||||
const codeTheme = resolvedOptions.codeTheme ?? "github";
|
||||
const style = resolveMarkdownStyle(resolvedOptions);
|
||||
|
||||
const { baseCss, themeCss } = loadThemeCss(theme);
|
||||
const css = normalizeThemeCss(buildCss(baseCss, themeCss, style));
|
||||
const codeThemeCss = loadCodeThemeCss(codeTheme);
|
||||
|
||||
const renderer = initRenderer({
|
||||
citeStatus: resolvedOptions.citeStatus ?? false,
|
||||
countStatus: resolvedOptions.countStatus ?? false,
|
||||
isMacCodeBlock: resolvedOptions.isMacCodeBlock ?? true,
|
||||
isShowLineNumber: resolvedOptions.isShowLineNumber ?? false,
|
||||
legend: resolvedOptions.legend ?? "alt",
|
||||
themeMode: resolvedOptions.themeMode,
|
||||
});
|
||||
|
||||
const { yamlData, markdownContent, readingTime } = renderer.parseFrontMatterAndContent(markdown);
|
||||
const { html: baseHtml, readingTime: readingTimeResult } = renderMarkdown(markdown, renderer);
|
||||
|
||||
let contentHtml = postProcessHtml(baseHtml, readingTimeResult, renderer);
|
||||
if (!(resolvedOptions.keepTitle ?? false)) {
|
||||
contentHtml = removeFirstHeading(contentHtml);
|
||||
}
|
||||
|
||||
const meta = buildMarkdownDocumentMeta(
|
||||
markdownContent,
|
||||
yamlData as Record<string, unknown>,
|
||||
resolvedOptions.defaultTitle,
|
||||
);
|
||||
const html = buildHtmlDocument(meta, css, contentHtml, codeThemeCss);
|
||||
const inlinedHtml = normalizeInlineCss(await inlineCss(html), style);
|
||||
|
||||
return {
|
||||
contentHtml,
|
||||
html: modifyHtmlStructure(inlinedHtml),
|
||||
meta,
|
||||
readingTime,
|
||||
style,
|
||||
yamlData: yamlData as Record<string, unknown>,
|
||||
};
|
||||
}
|
||||
|
||||
export async function renderMarkdownFileToHtml(
|
||||
inputPath: string,
|
||||
options: RenderMarkdownDocumentOptions = {},
|
||||
): Promise<RenderMarkdownDocumentResult & {
|
||||
backupPath?: string;
|
||||
outputPath: string;
|
||||
}> {
|
||||
const markdown = fs.readFileSync(inputPath, "utf-8");
|
||||
const outputPath = path.resolve(
|
||||
path.dirname(inputPath),
|
||||
`${path.basename(inputPath, path.extname(inputPath))}.html`,
|
||||
);
|
||||
const result = await renderMarkdownDocument(markdown, {
|
||||
...options,
|
||||
defaultTitle: options.defaultTitle ?? path.basename(outputPath, ".html"),
|
||||
});
|
||||
|
||||
let backupPath: string | undefined;
|
||||
if (fs.existsSync(outputPath)) {
|
||||
backupPath = `${outputPath}.bak-${formatTimestamp()}`;
|
||||
fs.renameSync(outputPath, backupPath);
|
||||
}
|
||||
|
||||
fs.writeFileSync(outputPath, result.html, "utf-8");
|
||||
|
||||
return {
|
||||
...result,
|
||||
backupPath,
|
||||
outputPath,
|
||||
};
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user