
Doc Image Sync
- 1 installs
- 2 repo stars
- Updated March 18, 2026
- vigorx777/doc-image-sync
Generate and insert prototype screenshots into product requirement docs using Playwright, driven by data-shot anchors in the HTML prototype.
About
Automates capturing prototype screenshots via Playwright, locating subjects through HTML data-shot anchors, and writing images back into the matching Markdown doc sections. A developer or product designer uses it to keep requirement-doc illustrations in sync with an HTML prototype.
- Uses data-shot semantic anchors to target screenshot subjects
- Bundled scripts init config, capture, and write images back to doc sections
Doc Image Sync by the numbers
- 1 all-time installs (skills.sh)
- Ranked #1,366 of 1,879 Documentation skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/vigorx777/doc-image-sync --skill doc-image-syncAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1 |
|---|---|
| repo stars | ★ 2 |
| Last updated | March 18, 2026 |
| Repository | vigorx777/doc-image-sync ↗ |
What it does
Generate and insert prototype screenshots into product requirement docs using Playwright, driven by data-shot anchors in the HTML prototype.
Files
Doc Image Sync
Overview
这个 skill 用于把“原型截图”收敛成一个稳定工作流:定位需求文档、优先使用 HTML 中的 data-shot 语义锚点确定截图主体、在必要时补最少的交互动作、执行 Playwright 截图,并把图片插入到目标章节。
它自带 scripts/ 下的执行脚本,可直接嵌入任何以 HTML 原型 + Markdown 文档为核心的产品设计项目中使用,不要求项目本身预置额外工具目录。
何时使用
当用户出现下面这些意图时使用:
- “给这个需求文档补一轮配图”
- “把原型截图插到需求说明文档里”
- “只更新某一节的截图”
- “帮我给新需求初始化截图配置”
- “这个章节的图不对,重新抓弹窗打开后的状态”
- “这个原型已经加了 data-shot,直接按锚点出图”
如果用户只是要做普通网页截图、浏览器测试、表单自动化,改用更通用的浏览器 skill,不要用这个 skill。
先做什么
1. 先定位目标需求目录、HTML 原型和 Markdown 文档。 2. 如果用户给的是某个需求目录,优先自动推断:
📒 需求说明文档.md- 同目录下与文件夹同名的
.html原型
3. 先检查原型中是否已有 data-shot="<功能锚点>",静态区域优先基于 data-shot 定位。 4. 如果已有配置,优先复用并只更新需要的截图项。 5. 如果没有配置,使用 scripts/init-shot-config.mjs 先生成配置骨架,再补充必要的 selector 或 actions。
标准工作流
1. 构建上下文
- 识别目标需求文档、原型 HTML、截图配置文件。
- 若用户只说“补配图”,优先在当前需求目录或项目内约定的
configs/目录中寻找已有配置。 - 扫描原型中是否存在
data-shot;如果有,优先把它当作截图主体定位锚点。 - 若配置不存在,按 references/config-patterns.md 的规则初始化。
2. 初始化或调整配置
- 新需求:运行
node "<skill-dir>/scripts/init-shot-config.mjs" ...生成配置骨架。 - 旧需求:优先补充或修正以下字段:
sectionHeading/sectionHeadingIncludes/sectionHeadingRegexselectoractionspaddingmode- 如用户只更新单张图,优先加
--id限定范围。 - 选图逻辑优先级:
- 先判断文档在讲哪个功能点。
- 再找原型中最能表达该功能点的区域或状态。
- 静态功能块优先用
data-shot。 - hover、展开、下钻等动态场景保留最小必要动作,不要强行追求零动作。
3. 执行截图与回写
- 默认使用统一入口:
node "<skill-dir>/scripts/sync-doc-images.mjs" --config "<config.json>"- 只更新某一项:
node "<skill-dir>/scripts/sync-doc-images.mjs" --config "<config.json>" --id "<shot-id>"- 只预演文档回写:
node "<skill-dir>/scripts/sync-doc-images.mjs" --config "<config.json>" --dry-run --skip-capture
4. 验证结果
- 确认图片已生成到
附件/<需求名>/screenshots/latest/或配置指定路径。 - 确认 Markdown 对应章节下只保留一张正确图片,避免重复插图。
- 确认图片真正表达了该段文档描述的功能点,而不只是“技术上成功截到一个区域”。
- 如果截图内容不对:
- 静态区域先检查
data-shot是否打在正确主体上; - 动态区域再调整
actions; - 不要手改文档图片路径。
失败排查顺序
1. pagePath 是否指向正确原型文件。 2. 如果原型已打 data-shot,优先检查锚点是否命中到正确主体。 3. selector 是否能在当前状态下命中。 4. actions 是否缺少点击、悬浮、展开、等待。 5. 章节匹配是否需要从 sectionHeading 改为 sectionHeadingIncludes 或 sectionHeadingRegex。 6. 图片路径是否符合项目附件规范。
注意事项
- 不要把这个 skill 当成通用截图器使用,它的目标是“需求文档配图闭环”。
- 不要直接修改业务说明文档正文来规避章节匹配问题,优先修配置。
- 默认先最小化变更范围,优先更新单个
id,避免整批回写影响其他章节。 data-shot主要解决“截哪块”,不自动解决“怎么进入那个状态”;hover、展开、下钻场景通常仍需要最小动作编排。- 不要把
data-shot打到按钮、文案碎片或纯布局容器上,优先打在值得单独成图的功能主体上。 - 如果项目里还没有截图配置目录,直接在需求目录附近新建
configs/即可,没必要强制套某个固定项目结构。
参考
- 配置字段与示例:见 references/config-patterns.md
- skill 内置工具入口:
scripts/
interface:
display_name: "Doc Image Sync"
short_description: "为原型需求文档生成截图并自动回写对应章节"
default_prompt: "使用 $doc-image-sync 为当前需求文档补配图:优先识别原型中的 data-shot 锚点,必要时补最小交互动作,执行截图并回写到对应章节。"
doc-image-sync
一个面向产品设计场景的需求文档配图 skill。
它的目标不是做通用截图工具,而是把“原型 HTML -> 截图 -> 回写到 Markdown 需求文档”这条链路收敛成一个稳定、可复用的工作流。
这个 skill 已经自带执行脚本,不依赖某个特定项目内的临时工具目录。
!doc-image-sync 概览
背景
在很多产品设计项目中,需求文档里的配图通常依赖人工截图、手工裁剪和手工插入。这个过程有几个常见问题:
- 截图动作重复,效率低;
- 原型更新后,文档截图容易过期;
- 同一个功能点反复截图时,难以保持视角和状态一致;
- 动态场景如弹窗、展开态、hover 卡片,人工维护成本更高。
这个 skill 的设计初衷,就是让配图从“零散手工操作”变成“可复用的半自动工作流”。
!为什么需要 doc-image-sync
推荐搭配仓库
推荐与这个 GitHub 仓库搭配使用:
vigorX777/product-design-system
推荐原因:
- 这个仓库本身就是围绕产品设计资产、原型、需求文档和知识沉淀组织的;
doc-image-sync刚好补上“原型截图自动生成并回写文档”这一环;- 两者组合后,可以更完整地形成一套产品设计系统:
- 原型设计与迭代;
- 需求文档维护;
- 配图同步;
- 设计知识沉淀与复盘。
如果把这个 skill 单独看,它解决的是“需求文档配图”。 如果和 product-design-system 仓库一起看,它解决的是“产品设计系统中的文档配图自动化能力”。
!doc-image-sync 在产品设计系统中的位置
作用
这个 skill 主要用于:
- 为 Markdown 需求说明文档补配图;
- 根据原型 HTML 批量生成截图;
- 将截图自动插入到文档对应章节;
- 复用已有截图配置,只更新受影响的配图;
- 在原型中存在
data-shot时,优先基于语义锚点定位截图主体。
它特别适合这种工作方式:
1. 先改 HTML 原型; 2. 再改需求文档; 3. 最后同步配图。
如何使用
推荐的使用方式很简单:将当前项目链接丢给AI,让AI自己学习和使用即可。
推荐让 AI 直接看到完整项目目录,而不是只给单个文件。这样它更容易自动完成:
1. 识别原型文件和需求文档; 2. 判断文档在讲哪些功能点; 3. 选择应该截图的区域或状态; 4. 生成截图并回写到文档; 5. 在需要时提示补 data-shot 或最少动作配置。
一句话总结:
这个 skill 最适合的使用方式,不是手动逐条喂命令,而是把整个产品设计项目交给 AI,让它基于项目上下文自动识别并使用。
!如何使用 doc-image-sync
核心思路
这个 skill 当前采用三层思路:
- 语义判断负责“拍什么”;
data-shot负责“截哪里”;- 少量动作编排负责“怎么进入那个状态”。
这意味着:
- 静态功能块优先通过
data-shot或稳定容器定位; - hover、展开、下钻、弹窗等动态场景,仍允许保留最少的
actions; - 配置文件不是重型编排系统,而是连接“文档落点”和“截图目标”的轻量桥接层。
效果
这个 skill 已经在真实原型上验证过两类场景:
1. 常规页面型需求
例如:
- 页面入口
- 列表卡片
- 编辑弹窗
这类场景通常可以稳定完成:
- 定位截图区域;
- 生成图片;
- 回写到指定文档章节。
2. 较复杂的拓扑交互场景
例如:
- 拓扑总览图;
- 折叠态 / 展开态差异;
- 节点 hover 卡片;
- 展开后上半部分与下半部分的不同节点类型。
这类场景的验证结果表明:
- 单纯依靠语义判断可以确定“应该拍什么”;
data-shot能明显降低截图主体定位成本;- 但 hover、展开、下钻等动态态,通常仍需要少量动作来复现。
适用场景
推荐用于:
- 产品需求文档配图;
- 交互原型说明文档配图;
- 需要反复更新截图的单文件 HTML 原型项目;
- 已有固定目录规范的设计系统或产品设计仓库。
不推荐用于:
- 通用网页截图;
- 网站测试自动化;
- 大量无结构网页抓图;
- 完全不依赖文档落点的图像采集任务。
前置依赖
1. 原型来源
默认输入为本地 HTML 原型文件。
推荐原型为:
- 单文件 HTML;
- 结构清晰;
- 有稳定的
id/ class; - 高频截图区域可选加
data-shot="<anchor>"。
2. 文档来源
默认输入为 Markdown 需求文档。
当前回写逻辑依赖文档中存在可定位的章节标题,例如:
sectionHeadingsectionHeadingIncludessectionHeadingRegex
3. 运行依赖
- Node.js
- Playwright
当前截图脚本基于 Playwright 执行。
4. skill 内置脚本
skill 自带以下脚本:
scripts/sync-doc-images.mjs:统一执行截图和文档回写;scripts/capture-shots.mjs:单独执行截图;scripts/update-doc-images.mjs:单独回写文档;scripts/init-shot-config.mjs:根据 Markdown 章节初始化截图配置。
这意味着它可以作为一个独立 skill 分发,而不是依赖某个项目里额外维护的“配图自动化”目录。
推荐的 HTML 约定
如果原型中有高频截图区域,建议给截图主体加上:
<div data-shot="topology-main-view"></div>建议打标的对象:
- 页面主功能区;
- 独立列表 / 图表 / 卡片模块;
- 弹窗主体;
- 侧滑框主体;
- 折叠态 / 展开态下的稳定功能主体。
不建议打标的对象:
- 单个按钮;
- 单行字段;
- 文案碎片;
- 纯布局容器。
配套能力
- 内置截图脚本;
- 内置文档回写脚本;
- 内置配置初始化脚本;
- 支持
data-shot优先定位和最小动作编排。
总结
如果你的问题是“如何把需求文档配图做得可复用、可更新、可持续维护”,这个 skill 就是为这个目标设计的。
它最适合的定位不是通用截图器,而是:
“一个围绕产品原型和需求文档的配图工作流 skill。”
与 product-design-system 搭配时,它可以作为产品设计系统中的“配图同步层”,把原型、文档和截图更新串成一条更完整的工作流。
Config Patterns
当前推荐思路
- 语义判断决定“拍什么”。
data-shot决定“截哪里”。- hover、展开、下钻等动态态保留最少的
actions来复现状态。
不要把配置理解成完整编排系统;当前更适合作为“文档落点 + 截图主体 + 最少动作”的轻量桥接层。
这些配置文件可以放在项目里任意清晰的位置,常见做法是放在需求目录下的 configs/ 或项目根目录的 configs/ 中。它们不是某个固定目录结构的强依赖。
最小截图项
{
"id": "shot-xxxxxx-01",
"docPath": "v4.5.x/某产品/某需求/📒 需求说明文档.md",
"sectionHeading": "### 一、页面定位与入口",
"pagePath": "v4.5.x/某产品/某需求/某需求.html",
"type": "element",
"selector": "body",
"imagePath": "附件/某需求/screenshots/latest/shot-01.png",
"mode": "replace"
}data-shot 优先示例
如果原型里已经有:
<div data-shot="topology-main-view"></div>则优先写成:
{
"id": "req-topology-view",
"docPath": "v4.5.x/某产品/某需求/📒 需求说明文档.md",
"sectionHeading": "### 五、拓扑视图展示",
"pagePath": "v4.5.x/某产品/某需求/某需求.html",
"type": "element",
"selector": "[data-shot=\"topology-main-view\"]",
"imagePath": "附件/某需求/screenshots/latest/topology-view.png",
"mode": "replace"
}常用字段
id:截图项唯一标识,建议稳定不变。docPath:需求说明文档路径。pagePath:原型 HTML 路径。type:fullpage、element、scenario。imagePath:图片输出路径。selector:局部截图或场景截图的目标区域。actions:截图前执行的交互步骤。padding:截图边距扩展。mode:replace、append、skipIfExists。
何时优先用 data-shot
- 页面主功能区
- 独立列表 / 图表 / 卡片模块
- 弹窗主体
- 侧滑框主体
- 折叠态 / 展开态这类稳定状态下的主体容器
不建议优先用 data-shot 的情况:
- 单个按钮
- 单行字段
- 只是布局包裹层的容器
- 纯 hover 触发器本身
一句话规则:只给值得单独成图的功能主体打 data-shot。
章节定位建议
优先顺序:
1. sectionHeading 2. sectionHeadingIncludes 3. sectionHeadingRegex
建议:
- 标题稳定时,优先使用
sectionHeading。 - 标题可能加标记或轻微改文案时,用
sectionHeadingIncludes。 - 标题格式波动较大时,再考虑
sectionHeadingRegex。
actions 示例
[
{ "type": "click", "selector": "#retro-trigger-btn" },
{ "type": "waitForSelector", "selector": "#retro-area.retro-area--visible" },
{ "type": "waitForTimeout", "ms": 200 }
]支持的动作:
clickdblclickhoverwaitForSelectorwaitForTimeoutevaluatescrollTo
动态场景建议
- 静态区域:尽量只用
data-shot或稳定selector。 - hover 卡片:通常仍需要
hover + waitForSelector。 - 折叠 / 展开:通常仍需要
dblclick或点击展开动作。 - 下钻 / 侧滑:通常仍需要点击节点或按钮,再等待目标容器可见。
不要为了追求“零动作”而把配置设计得过重。当前推荐做法是:能用 data-shot 稳住截图主体,就只为状态切换保留最小动作。
#!/usr/bin/env node
import fs from 'fs';
import path from 'path';
import { chromium } from 'playwright';
const cwd = process.cwd();
function parseArgs(argv) {
const args = {};
for (let i = 2; i < argv.length; i += 1) {
const current = argv[i];
if (current === '--config') args.config = argv[++i];
else if (current === '--id') args.id = argv[++i];
else if (current === '--help' || current === '-h') args.help = true;
}
return args;
}
function ensureDir(filePath) {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
}
function resolvePath(p) {
return path.isAbsolute(p) ? p : path.resolve(cwd, p);
}
async function runAction(page, action) {
switch (action.type) {
case 'click':
await page.locator(action.selector).first().click();
return;
case 'dblclick':
await page.locator(action.selector).first().dblclick();
return;
case 'hover':
await page.locator(action.selector).first().hover();
return;
case 'waitForSelector':
await page.locator(action.selector).waitFor({ state: action.state || 'visible', timeout: action.timeout || 10000 });
return;
case 'waitForTimeout':
await page.waitForTimeout(action.ms || 300);
return;
case 'evaluate':
await page.evaluate(new Function(`return (${action.script})`)());
return;
case 'scrollTo':
await page.locator(action.selector).scrollIntoViewIfNeeded();
return;
default:
throw new Error(`Unsupported action type: ${action.type}`);
}
}
async function main() {
const args = parseArgs(process.argv);
if (args.help || !args.config) {
console.log('Usage: node capture-shots.mjs --config <config.json> [--id <shotId>]');
process.exit(args.help ? 0 : 1);
}
const configPath = resolvePath(args.config);
const raw = JSON.parse(fs.readFileSync(configPath, 'utf8'));
const shots = (raw.shots || []).filter((item) => !args.id || item.id === args.id);
if (!shots.length) {
throw new Error('No shots found to execute.');
}
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 1600, height: 1200 }, deviceScaleFactor: 1 });
for (const shot of shots) {
const pagePath = resolvePath(shot.pagePath);
const imagePath = resolvePath(shot.imagePath);
const url = `file://${pagePath}`;
console.log(`Capturing ${shot.id} -> ${shot.imagePath}`);
await page.goto(url);
await page.waitForLoadState('domcontentloaded');
await page.waitForTimeout(300);
for (const action of shot.actions || []) {
await runAction(page, action);
}
ensureDir(imagePath);
if (shot.type === 'fullpage') {
await page.screenshot({ path: imagePath, fullPage: true, type: 'png' });
} else {
const locator = page.locator(shot.selector).first();
await locator.waitFor({ state: 'visible', timeout: 10000 });
const box = await locator.boundingBox();
if (!box || box.width < 4 || box.height < 4) {
throw new Error(`Invalid bounding box for ${shot.id}: ${shot.selector}`);
}
const padding = shot.padding || {};
const clip = {
x: Math.max(0, box.x - (padding.left || 0)),
y: Math.max(0, box.y - (padding.top || 0)),
width: box.width + (padding.left || 0) + (padding.right || 0),
height: box.height + (padding.top || 0) + (padding.bottom || 0)
};
await page.screenshot({ path: imagePath, clip, type: 'png' });
}
}
await browser.close();
}
main().catch((error) => {
console.error(error);
process.exit(1);
});
#!/usr/bin/env node
import crypto from 'crypto';
import fs from 'fs';
import path from 'path';
const cwd = process.cwd();
function parseArgs(argv) {
const args = {
headingLevel: 3,
type: 'element'
};
for (let i = 2; i < argv.length; i += 1) {
const current = argv[i];
if (current === '--doc') args.doc = argv[++i];
else if (current === '--page') args.page = argv[++i];
else if (current === '--config') args.config = argv[++i];
else if (current === '--image-dir') args.imageDir = argv[++i];
else if (current === '--heading-level') args.headingLevel = Number(argv[++i] || 3);
else if (current === '--type') args.type = argv[++i];
else if (current === '--selector') args.selector = argv[++i];
else if (current === '--help' || current === '-h') args.help = true;
}
return args;
}
function resolvePath(p) {
return path.isAbsolute(p) ? p : path.resolve(cwd, p);
}
function toPosix(p) {
return p.split(path.sep).join('/');
}
function toRelative(p) {
return toPosix(path.relative(cwd, p));
}
function ensureDir(dirPath) {
fs.mkdirSync(dirPath, { recursive: true });
}
function parseHeadings(content, headingLevel) {
const headingToken = '#'.repeat(headingLevel);
return content
.split('\n')
.map((line) => line.trim())
.filter((line) => line.startsWith(`${headingToken} `));
}
function createId(docPath, heading, index) {
const hash = crypto.createHash('md5').update(`${docPath}:${heading}`).digest('hex').slice(0, 6);
return `shot-${hash}-${String(index + 1).padStart(2, '0')}`;
}
function buildDefaultImageDir(docPath) {
const demandName = path.basename(path.dirname(docPath));
return path.resolve(cwd, '附件', demandName, 'screenshots', 'latest');
}
function buildDefaultFileName(index) {
return `shot-${String(index + 1).padStart(2, '0')}.png`;
}
function main() {
const args = parseArgs(process.argv);
if (args.help || !args.doc || !args.page || !args.config) {
console.log('Usage: node init-shot-config.mjs --doc <doc.md> --page <page.html> --config <config.json> [--image-dir <dir>] [--heading-level <n>] [--type <fullpage|element|scenario>] [--selector <css>]');
process.exit(args.help ? 0 : 1);
}
const docPath = resolvePath(args.doc);
const pagePath = resolvePath(args.page);
const configPath = resolvePath(args.config);
const imageDir = resolvePath(args.imageDir || buildDefaultImageDir(docPath));
const docContent = fs.readFileSync(docPath, 'utf8');
const headings = parseHeadings(docContent, args.headingLevel);
if (!headings.length) {
throw new Error(`No level-${args.headingLevel} headings found in ${docPath}`);
}
let config = { shots: [] };
if (fs.existsSync(configPath)) {
config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
if (!Array.isArray(config.shots)) config.shots = [];
}
ensureDir(path.dirname(configPath));
const existingKeys = new Set(
config.shots.map((shot) => `${toRelative(resolvePath(shot.docPath))}::${shot.sectionHeading || ''}`)
);
const newShots = [];
for (const [index, heading] of headings.entries()) {
const key = `${toRelative(docPath)}::${heading}`;
if (existingKeys.has(key)) continue;
const shot = {
id: createId(docPath, heading, index),
docPath: toRelative(docPath),
sectionHeading: heading,
pagePath: toRelative(pagePath),
type: args.type,
imagePath: toPosix(path.join(toRelative(imageDir), buildDefaultFileName(index))),
mode: 'replace'
};
if (args.type !== 'fullpage') {
shot.selector = args.selector || 'body';
}
newShots.push(shot);
}
config.shots.push(...newShots);
fs.writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, 'utf8');
console.log(`Added ${newShots.length} shots to ${toRelative(configPath)}`);
}
main();
#!/usr/bin/env node
import path from 'path';
import { fileURLToPath } from 'url';
import { spawnSync } from 'child_process';
const cwd = process.cwd();
const __filename = fileURLToPath(import.meta.url);
const scriptsDir = path.dirname(__filename);
function parseArgs(argv) {
const args = {
passthrough: []
};
for (let i = 2; i < argv.length; i += 1) {
const current = argv[i];
if (current === '--config') args.config = argv[++i];
else if (current === '--id') args.id = argv[++i];
else if (current === '--dry-run') args.dryRun = true;
else if (current === '--skip-capture') args.skipCapture = true;
else if (current === '--skip-update') args.skipUpdate = true;
else if (current === '--help' || current === '-h') args.help = true;
else args.passthrough.push(current);
}
return args;
}
function runNodeScript(scriptPath, args) {
const result = spawnSync(process.execPath, [scriptPath, ...args], {
cwd,
stdio: 'inherit'
});
if (result.status !== 0) {
process.exit(result.status || 1);
}
}
function main() {
const args = parseArgs(process.argv);
if (args.help || !args.config) {
console.log('Usage: node sync-doc-images.mjs --config <config.json> [--id <shotId>] [--dry-run] [--skip-capture] [--skip-update]');
process.exit(args.help ? 0 : 1);
}
const baseArgs = ['--config', args.config];
if (args.id) baseArgs.push('--id', args.id);
if (!args.skipCapture) {
runNodeScript(path.join(scriptsDir, 'capture-shots.mjs'), [...baseArgs, ...args.passthrough]);
}
if (!args.skipUpdate) {
const updateArgs = [...baseArgs];
if (args.dryRun) updateArgs.push('--dry-run');
runNodeScript(path.join(scriptsDir, 'update-doc-images.mjs'), updateArgs);
}
}
main();
#!/usr/bin/env node
import fs from 'fs';
import path from 'path';
const cwd = process.cwd();
function parseArgs(argv) {
const args = {};
for (let i = 2; i < argv.length; i += 1) {
const current = argv[i];
if (current === '--config') args.config = argv[++i];
else if (current === '--id') args.id = argv[++i];
else if (current === '--dry-run') args.dryRun = true;
else if (current === '--help' || current === '-h') args.help = true;
}
return args;
}
function resolvePath(p) {
return path.isAbsolute(p) ? p : path.resolve(cwd, p);
}
function toPosix(p) {
return p.split(path.sep).join('/');
}
function buildImageBlock(docPath, imagePath) {
const relative = toPosix(path.relative(path.dirname(docPath), imagePath));
return `\n\n`;
}
function normalizeLine(line) {
return line.replace(/\s+/g, ' ').trim();
}
function isImageLine(line) {
return /^!\[[^\]]*\]\((<)?[^)]+(>)?\)$/.test(line.trim());
}
function findSectionIndex(lines, shot) {
const normalizedHeading = shot.sectionHeading ? normalizeLine(shot.sectionHeading) : null;
const normalizedIncludes = shot.sectionHeadingIncludes ? normalizeLine(shot.sectionHeadingIncludes) : null;
const headingRegex = shot.sectionHeadingRegex ? new RegExp(shot.sectionHeadingRegex) : null;
const idx = lines.findIndex((line) => {
const normalizedLine = normalizeLine(line);
if (normalizedHeading && normalizedLine === normalizedHeading) return true;
if (normalizedIncludes && normalizedLine.includes(normalizedIncludes)) return true;
if (headingRegex && headingRegex.test(line)) return true;
return false;
});
if (idx === -1) {
const matcher = shot.sectionHeading || shot.sectionHeadingIncludes || shot.sectionHeadingRegex;
throw new Error(`Section not found: ${matcher}`);
}
return idx;
}
function findSectionEnd(lines, startIndex) {
for (let i = startIndex + 1; i < lines.length; i += 1) {
if (/^#{1,6}\s+/.test(lines[i].trim())) {
return i;
}
}
return lines.length;
}
function upsertImageAfterSection(content, shot, imageBlock) {
const lines = content.split('\n');
const idx = findSectionIndex(lines, shot);
const sectionEnd = findSectionEnd(lines, idx);
let insertAt = idx + 1;
while (insertAt < lines.length && lines[insertAt].trim() === '') insertAt += 1;
const mode = shot.mode || 'replace';
const imageLineIndex = lines.findIndex((line, lineIndex) => lineIndex > idx && lineIndex < sectionEnd && isImageLine(line));
if (mode === 'replace' && imageLineIndex !== -1) {
lines[imageLineIndex] = imageBlock.trim();
return lines.join('\n');
}
if (mode === 'skipIfExists' && imageLineIndex !== -1) {
return lines.join('\n');
}
lines.splice(idx + 1, 0, '', imageBlock.trim(), '');
return lines.join('\n');
}
function main() {
const args = parseArgs(process.argv);
if (args.help || !args.config) {
console.log('Usage: node update-doc-images.mjs --config <config.json> [--id <shotId>] [--dry-run]');
process.exit(args.help ? 0 : 1);
}
const configPath = resolvePath(args.config);
const raw = JSON.parse(fs.readFileSync(configPath, 'utf8'));
const shots = (raw.shots || []).filter((item) => !args.id || item.id === args.id);
const grouped = new Map();
for (const shot of shots) {
const docPath = resolvePath(shot.docPath);
if (!grouped.has(docPath)) grouped.set(docPath, []);
grouped.get(docPath).push(shot);
}
for (const [docPath, docShots] of grouped.entries()) {
let content = fs.readFileSync(docPath, 'utf8');
for (const shot of docShots) {
const imagePath = resolvePath(shot.imagePath);
if (!fs.existsSync(imagePath)) {
throw new Error(`Image not found for shot ${shot.id}: ${imagePath}`);
}
const imageBlock = buildImageBlock(docPath, imagePath);
content = upsertImageAfterSection(content, shot, imageBlock);
}
if (args.dryRun) {
console.log(`[dry-run] Updated doc images: ${path.relative(cwd, docPath)}`);
continue;
}
fs.writeFileSync(docPath, content, 'utf8');
console.log(`Updated doc images: ${path.relative(cwd, docPath)}`);
}
}
main();