/gi) || []).length;
+ if (depth > 0 || opens > 0) {
+ raw.push(line);
+ depth += opens - closes;
+ if (depth <= 0) {
+ depth = 0;
+ flush();
+ }
+ continue;
+ }
+ output.push(line);
+ }
+ if (raw.length) flush();
+ return output.join('\n');
+}
+
+function preprocessComponents(body, imports, assets, warnings) {
+ let bodyImageIndex = 0;
+ return transformOutsideFencedCode(body, (source) =>
+ transformOutsideInlineCode(source, (plainSource) => {
+ const components = plainSource
+ .replace(
+ /<([A-Z][A-Za-z0-9]*)\b(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|[^<>"'])*?\/>/g,
+ (component, name) => {
+ if (name === 'ArticleImage') {
+ const variable = component.match(/\bsrc\s*=\s*\{([A-Za-z_$][\w$]*)\}/)?.[1] || '';
+ const fileName = imports.get(variable) || '';
+ const caption = quotedAttribute(component, 'caption');
+ const alt = quotedAttribute(component, 'alt');
+ const sourceLabel = quotedAttribute(component, 'source');
+ const sourceUrl = quotedAttribute(component, 'sourceUrl');
+ bodyImageIndex += 1;
+ assets.push({
+ kind: 'body',
+ index: 0,
+ componentId: bodyImageIndex,
+ fileName,
+ caption,
+ alt,
+ source: sourceLabel,
+ sourceUrl,
+ });
+ return `\n@@PUBLICATION_ASSET_${bodyImageIndex}@@\n`;
+ }
+
+ if (name === 'ArticleDataTable') {
+ const caption = quotedAttribute(component, 'caption');
+ const sourceLabel = quotedAttribute(component, 'source');
+ warnings.add(
+ '正文含站点数据表;发布包保留了位置与说明,请在平台编辑器中补充表格或截图。'
+ );
+ const encodedCaption = encodeURIComponent(caption).replaceAll('_', '%5F');
+ const encodedSource = encodeURIComponent(sourceLabel).replaceAll('_', '%5F');
+ return `\n@@PUBLICATION_TABLE_${encodedCaption}_${encodedSource}@@\n`;
+ }
+
+ warnings.add(`正文含 ${name} 组件;发布包已用人工复核标记替代。`);
+ return `\n@@PUBLICATION_COMPONENT_${name}@@\n`;
+ }
+ )
+ .replace(
+ /<([A-Z][A-Za-z0-9]*)\b(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|[^<>"'])*?>/g,
+ (_, name) => {
+ warnings.add(`正文含 ${name} 组件;发布包已保留组件内文字并添加人工复核标记。`);
+ return `\n@@PUBLICATION_COMPONENT_${name}@@\n`;
+ }
+ )
+ .replace(/<\/([A-Z][A-Za-z0-9]*)\s*>/g, (_, name) => {
+ warnings.add(`正文含 ${name} 组件;发布包已保留组件内文字并添加人工复核标记。`);
+ return '\n';
+ });
+ return downgradeRawDivBlocks(components, warnings);
+ })
+ );
+}
+
+function normalizeMarkdownLinks(body, siteOrigin) {
+ return transformOutsideFencedCode(body, (source) =>
+ source.replace(/\[([^\]]+)]\((\/[^\s)]+)\)/g, (_, label, url) => {
+ return `[${label}](${absoluteUrl(url, siteOrigin)})`;
+ })
+ );
+}
+
+function codeFence(line) {
+ const match = line.match(/^[ \t]{0,3}(`{3,}|~{3,})(.*)$/);
+ if (!match) return null;
+ return {
+ character: match[1][0],
+ length: match[1].length,
+ language: match[2].trim(),
+ };
+}
+
+function closesCodeFence(line, fence) {
+ const match = line.match(/^[ \t]{0,3}(`+|~+)[ \t]*$/);
+ return Boolean(match && match[1][0] === fence.character && match[1].length >= fence.length);
+}
+
+function unescapeMarkdownValue(value) {
+ return value.replace(/\\([\\`*{}[\]()#+.!_>~'" -])/g, '$1');
+}
+
+function isEscapedMarkdownPosition(source, position) {
+ let backslashes = 0;
+ for (let index = position - 1; index >= 0 && source[index] === '\\'; index -= 1) {
+ backslashes += 1;
+ }
+ return backslashes % 2 === 1;
+}
+
+function markdownImageCandidateStarts(line) {
+ const ranges = inlineCodeRanges(line);
+ const starts = [];
+ let cursor = 0;
+ while (cursor < line.length) {
+ const start = line.indexOf('![', cursor);
+ if (start === -1) break;
+ const insideCode = ranges.some(([rangeStart, rangeEnd]) => {
+ return start >= rangeStart && start < rangeEnd;
+ });
+ if (!insideCode && !isEscapedMarkdownPosition(line, start)) starts.push(start);
+ cursor = start + 2;
+ }
+ return starts;
+}
+
+function parseMarkdownImageAt(source, start) {
+ if (!source.startsWith('![', start) || isEscapedMarkdownPosition(source, start)) return null;
+
+ let index = start + 2;
+ let alt = '';
+ for (; index < source.length; index += 1) {
+ if (source[index] === '\\' && index + 1 < source.length) {
+ alt += source[index] + source[index + 1];
+ index += 1;
+ continue;
+ }
+ if (source[index] === ']') break;
+ alt += source[index];
+ }
+ if (source[index] !== ']' || source[index + 1] !== '(') return null;
+ index += 2;
+ while (/\s/.test(source[index] || '')) index += 1;
+
+ let destination = '';
+ if (source[index] === '<') {
+ index += 1;
+ while (index < source.length && source[index] !== '>') {
+ if (source[index] === '\\' && index + 1 < source.length) {
+ destination += source[index] + source[index + 1];
+ index += 2;
+ continue;
+ }
+ destination += source[index];
+ index += 1;
+ }
+ if (source[index] !== '>') return null;
+ index += 1;
+ } else {
+ let nested = 0;
+ while (index < source.length) {
+ const character = source[index];
+ if (character === '\\' && index + 1 < source.length) {
+ destination += character + source[index + 1];
+ index += 2;
+ continue;
+ }
+ if (character === '(') {
+ nested += 1;
+ destination += character;
+ index += 1;
+ continue;
+ }
+ if (character === ')') {
+ if (nested === 0) break;
+ nested -= 1;
+ destination += character;
+ index += 1;
+ continue;
+ }
+ if (/\s/.test(character) && nested === 0) break;
+ destination += character;
+ index += 1;
+ }
+ if (nested !== 0) return null;
+ }
+ if (!destination) return null;
+
+ const whitespaceStart = index;
+ while (/\s/.test(source[index] || '')) index += 1;
+ let title = '';
+ if (source[index] !== ')') {
+ if (index === whitespaceStart) return null;
+ const opener = source[index];
+ const closer = opener === '(' ? ')' : opener;
+ if (opener !== '"' && opener !== "'" && opener !== '(') return null;
+ index += 1;
+ while (index < source.length && source[index] !== closer) {
+ if (source[index] === '\\' && index + 1 < source.length) {
+ title += source[index] + source[index + 1];
+ index += 2;
+ continue;
+ }
+ title += source[index];
+ index += 1;
+ }
+ if (source[index] !== closer) return null;
+ index += 1;
+ while (/\s/.test(source[index] || '')) index += 1;
+ }
+ if (source[index] !== ')') return null;
+
+ return {
+ start,
+ end: index + 1,
+ alt: unescapeMarkdownValue(alt),
+ destination: unescapeMarkdownValue(destination),
+ title: unescapeMarkdownValue(title),
+ };
+}
+
+function markdownImagesInLine(line, candidateStarts = markdownImageCandidateStarts(line)) {
+ const images = [];
+ let consumedThrough = 0;
+ for (const start of candidateStarts) {
+ if (start < consumedThrough) continue;
+ const image = parseMarkdownImageAt(line, start);
+ if (image) {
+ images.push(image);
+ consumedThrough = image.end;
+ }
+ }
+ return images;
+}
+
+function markdownAssetFileName(destination) {
+ const path = destination.replace(/[?#][\s\S]*$/, '');
+ const fileName = path.split('/').pop() || '';
+ try {
+ return decodeURIComponent(fileName);
+ } catch {
+ return fileName;
+ }
+}
+
+function isBlockStart(line, nextLine = '') {
+ const trimmed = line.trim();
+ return (
+ !trimmed ||
+ /^#{1,4}\s+/.test(trimmed) ||
+ /^---+$/.test(trimmed) ||
+ /^>\s?/.test(trimmed) ||
+ /^[-*+]\s+/.test(trimmed) ||
+ /^\d+\.\s+/.test(trimmed) ||
+ Boolean(codeFence(trimmed)) ||
+ /^\$\$$/.test(trimmed) ||
+ /^@@PUBLICATION_/.test(trimmed) ||
+ markdownImageCandidateStarts(trimmed).length > 0 ||
+ (/^\|.*\|$/.test(trimmed) && /^\|?[\s:|-]+\|?$/.test(nextLine.trim()))
+ );
+}
+
+function parseTableRow(line) {
+ return line
+ .trim()
+ .replace(/^\||\|$/g, '')
+ .split('|')
+ .map((cell) => cell.trim());
+}
+
+function parseBlocks(body, assets, warnings) {
+ const lines = body.split(/\r?\n/);
+ const blocks = [];
+ let index = 0;
+ let bodyImageIndex = 0;
+
+ while (index < lines.length) {
+ const line = lines[index];
+ const trimmed = line.trim();
+ if (!trimmed) {
+ index += 1;
+ continue;
+ }
+
+ const assetMatch = trimmed.match(/^@@PUBLICATION_ASSET_(\d+)@@$/);
+ if (assetMatch) {
+ const asset = assets.find((item) => item.componentId === +assetMatch[1]);
+ bodyImageIndex += 1;
+ if (asset) asset.index = bodyImageIndex;
+ blocks.push({ type: 'asset', asset });
+ index += 1;
+ continue;
+ }
+
+ const tableComponent = trimmed.match(/^@@PUBLICATION_TABLE_(.*?)_(.*?)@@$/);
+ if (tableComponent) {
+ blocks.push({
+ type: 'component-table',
+ caption: decodeURIComponent(tableComponent[1]),
+ source: decodeURIComponent(tableComponent[2]),
+ });
+ index += 1;
+ continue;
+ }
+
+ const component = trimmed.match(/^@@PUBLICATION_COMPONENT_(.+)@@$/);
+ if (component) {
+ blocks.push({ type: 'component', name: component[1] });
+ index += 1;
+ continue;
+ }
+
+ const heading = trimmed.match(/^(#{1,4})\s+(.+)$/);
+ if (heading) {
+ blocks.push({ type: 'heading', level: heading[1].length, text: heading[2] });
+ index += 1;
+ continue;
+ }
+
+ if (/^---+$/.test(trimmed)) {
+ blocks.push({ type: 'rule' });
+ index += 1;
+ continue;
+ }
+
+ const fence = codeFence(trimmed);
+ if (fence) {
+ const code = [];
+ index += 1;
+ while (index < lines.length && !closesCodeFence(lines[index], fence)) {
+ code.push(lines[index]);
+ index += 1;
+ }
+ if (index < lines.length) index += 1;
+ blocks.push({ type: 'code', language: fence.language, text: code.join('\n') });
+ continue;
+ }
+
+ if (/^\$\$$/.test(trimmed)) {
+ const math = [];
+ index += 1;
+ while (index < lines.length && !/^\$\$$/.test(lines[index].trim())) {
+ math.push(lines[index]);
+ index += 1;
+ }
+ if (index < lines.length) index += 1;
+ blocks.push({ type: 'math', text: math.join('\n') });
+ continue;
+ }
+
+ if (/^\|.*\|$/.test(trimmed) && /^\|?[\s:|-]+\|?$/.test((lines[index + 1] || '').trim())) {
+ const headers = parseTableRow(line);
+ const rows = [];
+ index += 2;
+ while (index < lines.length && /^\|.*\|$/.test(lines[index].trim())) {
+ rows.push(parseTableRow(lines[index]));
+ index += 1;
+ }
+ blocks.push({ type: 'table', headers, rows });
+ continue;
+ }
+
+ const markdownImageCandidates = markdownImageCandidateStarts(trimmed);
+ const markdownImages = markdownImagesInLine(trimmed, markdownImageCandidates);
+ if (markdownImages.length) {
+ let cursor = 0;
+ for (const image of markdownImages) {
+ const before = trimmed.slice(cursor, image.start).trim();
+ if (before) blocks.push({ type: 'paragraph', text: before });
+ bodyImageIndex += 1;
+ const asset = {
+ kind: 'body',
+ index: bodyImageIndex,
+ fileName: markdownAssetFileName(image.destination),
+ caption: image.title || image.alt,
+ alt: image.alt,
+ source: '',
+ sourceUrl: '',
+ };
+ assets.push(asset);
+ blocks.push({ type: 'asset', asset });
+ cursor = image.end;
+ }
+ const after = trimmed.slice(cursor).trim();
+ if (after) blocks.push({ type: 'paragraph', text: after });
+ if (
+ markdownImages.length > 1 ||
+ markdownImages[0].start !== 0 ||
+ markdownImages[markdownImages.length - 1].end !== trimmed.length
+ ) {
+ warnings.add('正文含行内 Markdown 图片;发布包已将图片拆为独立位置,请发布前检查上下文。');
+ }
+ if (markdownImageCandidates.length > markdownImages.length) {
+ warnings.add('正文含未能完整解析的 Markdown 图片语法;请对照原稿人工检查图片清单。');
+ }
+ index += 1;
+ continue;
+ }
+
+ if (markdownImageCandidates.length) {
+ warnings.add('正文含未能完整解析的 Markdown 图片语法;请对照原稿人工检查图片清单。');
+ }
+
+ if (/^>\s?/.test(trimmed)) {
+ const quote = [];
+ while (index < lines.length && /^>\s?/.test(lines[index].trim())) {
+ quote.push(lines[index].trim().replace(/^>\s?/, ''));
+ index += 1;
+ }
+ blocks.push({ type: 'quote', text: quote.join(' ') });
+ continue;
+ }
+
+ const unordered = trimmed.match(/^[-*+]\s+(.+)$/);
+ const ordered = trimmed.match(/^\d+\.\s+(.+)$/);
+ if (unordered || ordered) {
+ const listType = unordered ? 'ul' : 'ol';
+ const items = [];
+ while (index < lines.length) {
+ const candidate = lines[index].trim();
+ const match =
+ listType === 'ul' ? candidate.match(/^[-*+]\s+(.+)$/) : candidate.match(/^\d+\.\s+(.+)$/);
+ if (!match) break;
+ items.push(match[1]);
+ index += 1;
+ }
+ blocks.push({ type: 'list', listType, items });
+ continue;
+ }
+
+ const paragraph = [trimmed];
+ index += 1;
+ while (index < lines.length && !isBlockStart(lines[index], lines[index + 1] || '')) {
+ paragraph.push(lines[index].trim());
+ index += 1;
+ }
+ blocks.push({ type: 'paragraph', text: paragraph.join(' ') });
+ }
+
+ return blocks;
+}
+
+function inlineHtml(value) {
+ return escapeHtml(value)
+ .replace(
+ /`([^`]+)`/g,
+ '
$1'
+ )
+ .replace(
+ /\[([^\]]+)]\((https?:\/\/[^\s)]+)\)/g,
+ '
$1'
+ )
+ .replace(/\*\*([^*]+)\*\*/g, '
$1')
+ .replace(/(?$1');
+}
+
+function inlinePlain(value) {
+ return value
+ .replace(/!\[([^\]]*)]\([^)]+\)/g, '$1')
+ .replace(/\[([^\]]+)]\((https?:\/\/[^\s)]+)\)/g, '$1($2)')
+ .replace(/\*\*([^*]+)\*\*/g, '$1')
+ .replace(/(? {
+ const size = level <= 2 ? (chinese ? '22px' : '26px') : chinese ? '18px' : '21px';
+ const accent =
+ platformId === 'wechat' && level <= 2
+ ? 'border-left:4px solid #526e77;padding-left:12px;'
+ : '';
+ return `margin:2em 0 .8em;${accent}color:#302f2d;font-size:${size};font-weight:700;line-height:1.4`;
+ },
+ paragraph: 'margin:1.1em 0;color:#3f3d3a;line-height:1.9;text-align:left',
+ quote:
+ 'margin:1.6em 0;padding:12px 18px;border-left:3px solid #8ba2a9;background:#f5f3ef;color:#66615c;line-height:1.8',
+ list: 'margin:1.15em 0;padding-left:1.5em;color:#3f3d3a;line-height:1.8',
+ asset:
+ 'margin:1.6em 0;padding:14px 16px;border:1px dashed #aebcc0;background:#f4f7f7;color:#526e77;text-align:center;font-size:14px;line-height:1.65',
+ };
+}
+
+function richBody(blocks, platformId) {
+ const styles = richStyles(platformId);
+ const html = blocks
+ .map((block) => {
+ if (block.type === 'heading') {
+ const level = Math.min(Math.max(block.level + 1, 2), 4);
+ return `
${inlineHtml(block.text)}`;
+ }
+ if (block.type === 'paragraph') {
+ return `
${inlineHtml(block.text)}
`;
+ }
+ if (block.type === 'quote') {
+ return `
${inlineHtml(block.text)}
`;
+ }
+ if (block.type === 'list') {
+ return `<${block.listType} style="${styles.list}">${block.items
+ .map((item) => `
${inlineHtml(item)}`)
+ .join('')}${block.listType}>`;
+ }
+ if (block.type === 'rule') {
+ return '
';
+ }
+ if (block.type === 'code' || block.type === 'math') {
+ return `
${escapeHtml(block.text)}`;
+ }
+ if (block.type === 'table') {
+ return `
${block.headers
+ .map(
+ (cell) =>
+ `| ${inlineHtml(cell)} | `
+ )
+ .join('')}
${block.rows
+ .map(
+ (row) =>
+ `${row
+ .map(
+ (cell) =>
+ `| ${inlineHtml(cell)} | `
+ )
+ .join('')}
`
+ )
+ .join('')}
`;
+ }
+ if (block.type === 'asset') {
+ const asset = block.asset;
+ const sourceUrl = /^https?:\/\//.test(asset?.sourceUrl || '') ? asset.sourceUrl : '';
+ const sourceLabel = asset?.source || sourceUrl;
+ const source = sourceLabel
+ ? `
来源:${
+ sourceUrl
+ ? `${escapeHtml(sourceLabel)}`
+ : escapeHtml(sourceLabel)
+ }`
+ : '';
+ return `
【正文图片 ${asset?.index || ''}:${escapeHtml(imageLabel(asset))}】${source}
`;
+ }
+ if (block.type === 'component-table') {
+ const label = block.caption || '站点数据表';
+ return `
【数据表:${escapeHtml(label)}】${block.source ? `
来源:${escapeHtml(block.source)}` : ''}
`;
+ }
+ if (block.type === 'component') {
+ return `
【${escapeHtml(block.name)} 组件:请人工补充】
`;
+ }
+ return '';
+ })
+ .join('\n');
+ return `
`;
+}
+
+function plainBody(blocks) {
+ return blocks
+ .map((block) => {
+ if (block.type === 'heading')
+ return `${'#'.repeat(Math.min(block.level, 3))} ${inlinePlain(block.text)}`;
+ if (block.type === 'paragraph') return inlinePlain(block.text);
+ if (block.type === 'quote') return `> ${inlinePlain(block.text)}`;
+ if (block.type === 'list') {
+ return block.items
+ .map(
+ (item, index) =>
+ `${block.listType === 'ol' ? `${index + 1}.` : '•'} ${inlinePlain(item)}`
+ )
+ .join('\n');
+ }
+ if (block.type === 'rule') return '———';
+ if (block.type === 'code' || block.type === 'math') return block.text;
+ if (block.type === 'table') {
+ return [block.headers, ...block.rows]
+ .map((row) => row.map(inlinePlain).join('\t'))
+ .join('\n');
+ }
+ if (block.type === 'asset') {
+ const source = block.asset?.source || block.asset?.sourceUrl;
+ return `【正文图片 ${block.asset?.index || ''}:${imageLabel(block.asset)}】${source ? `\n来源:${source}${block.asset?.sourceUrl && block.asset.sourceUrl !== source ? `(${block.asset.sourceUrl})` : ''}` : ''}`;
+ }
+ if (block.type === 'component-table') return `【数据表:${block.caption || '请人工补充'}】`;
+ if (block.type === 'component') return `【${block.name} 组件:请人工补充】`;
+ return '';
+ })
+ .filter(Boolean)
+ .join('\n\n')
+ .trim();
+}
+
+function codePointLength(value) {
+ return Array.from(value).length;
+}
+
+function truncate(value, maximum) {
+ const points = Array.from(value.trim());
+ if (points.length <= maximum) return points.join('');
+ return `${points
+ .slice(0, Math.max(0, maximum - 1))
+ .join('')
+ .trimEnd()}…`;
+}
+
+function firstProse(blocks) {
+ return blocks.find((block) => block.type === 'paragraph')?.text || '';
+}
+
+function socialCopy(platformId, metadata, blocks, language) {
+ const url = metadata.canonicalUrl;
+ const title = metadata.title || 'Untitled';
+ const description = metadata.description || inlinePlain(firstProse(blocks));
+ if (platformId === 'x-post') {
+ const suffix = url ? `\n\n${url}` : '';
+ const allowance = Math.max(40, 260 - codePointLength(title) - codePointLength(suffix) - 2);
+ return `${title}\n\n${truncate(description, allowance)}${suffix}`.trim();
+ }
+ const lead = inlinePlain(firstProse(blocks));
+ const parts = [title, description];
+ if (lead && lead !== description) parts.push(truncate(lead, 700));
+ if (url)
+ parts.push(`${language === 'cn' ? '阅读全文' : 'Read the full bilingual essay'}:${url}`);
+ return truncate(parts.filter(Boolean).join('\n\n'), 2800);
+}
+
+export function buildPublicationPackage({
+ source = '',
+ platformId = 'wechat',
+ articleId = '',
+ language = 'cn',
+ published = false,
+ siteOrigin = SITE_ORIGIN,
+} = {}) {
+ const platform =
+ PUBLICATION_PLATFORMS.find((candidate) => candidate.id === platformId) ||
+ PUBLICATION_PLATFORMS[0];
+ const { body, metadata } = extractArticle(source, articleId, language, siteOrigin);
+ const imports = importedAssets(body);
+ const assets = [];
+ if (metadata.cover) {
+ assets.push({
+ kind: 'cover',
+ index: 0,
+ fileName: metadata.cover.split('/').pop() || '',
+ caption: metadata.coverCaption,
+ alt: metadata.coverAlt,
+ source: metadata.coverSource,
+ sourceUrl: metadata.coverSourceUrl,
+ });
+ }
+ const warnings = new Set();
+ const preparedBody = normalizeMarkdownLinks(
+ preprocessComponents(stripMdxPreamble(body), imports, assets, warnings),
+ siteOrigin
+ );
+ const blocks = parseBlocks(preparedBody, assets, warnings);
+ assets.sort((left, right) => {
+ if (left.kind === 'cover') return -1;
+ if (right.kind === 'cover') return 1;
+ return left.index - right.index;
+ });
+ if (
+ blocks[0]?.type === 'heading' &&
+ inlinePlain(blocks[0].text).trim() === metadata.title.trim()
+ ) {
+ blocks.shift();
+ }
+ if (blocks.some((block) => block.type === 'math')) {
+ warnings.add('正文含公式;发布包保留了 LaTeX 原文,请在目标平台中转成公式图片或重新排版。');
+ }
+ const bodyText = plainBody(blocks);
+
+ if (assets.length) {
+ warnings.add('图片不会随 HTML 自动上传;请按图片清单逐张复制,并替换正文中的图片标记。');
+ }
+ if (assets.some((asset) => /\.(?:gif|webp)$/i.test(asset.fileName || ''))) {
+ warnings.add('GIF 或动态 WebP 在复制到剪贴板时会转换为 PNG,动画效果将丢失。');
+ }
+ if (!published && metadata.canonicalUrl) {
+ warnings.add('这篇文章尚未进入正式站点目录;原文链接可能暂时无法访问。');
+ }
+
+ const plainText =
+ platform.format === 'plain' ? socialCopy(platform.id, metadata, blocks, language) : bodyText;
+ const richHtml = platform.format === 'rich' ? richBody(blocks, platform.id) : '';
+ if (platform.id === 'wechat' && richHtml.length > 20_000) {
+ warnings.add(
+ '带排版 HTML 超过微信公众号草稿接口的 20,000 字符基线;API 接入时需压缩样式或拆分。'
+ );
+ }
+ return {
+ platform,
+ metadata,
+ assets,
+ warnings: [...warnings],
+ richHtml,
+ plainText,
+ bodyText,
+ characterCount: codePointLength(plainText),
+ };
+}
diff --git a/tools/writer-studio/public/styles.css b/tools/writer-studio/public/styles.css
index 84f0905..4a4da5b 100644
--- a/tools/writer-studio/public/styles.css
+++ b/tools/writer-studio/public/styles.css
@@ -33,6 +33,7 @@ body {
button,
input,
+select,
textarea {
font: inherit;
}
@@ -92,7 +93,76 @@ button {
font-weight: 500;
}
-.new-draft-button {
+.column-picker {
+ display: grid;
+ gap: 7px;
+ margin-bottom: 12px;
+ color: var(--muted);
+ font-size: 10px;
+ font-weight: 750;
+ letter-spacing: 0.08em;
+}
+
+.column-picker select,
+.idea-capture input,
+.idea-capture textarea,
+.idea-capture select,
+.ideas-toolbar select,
+dialog select {
+ width: 100%;
+ padding: 10px 11px;
+ border: 1px solid var(--line);
+ border-radius: 8px;
+ outline: none;
+ background: rgba(248, 245, 240, 0.72);
+ color: var(--ink);
+ font-size: 12px;
+}
+
+.column-picker select:focus,
+.idea-capture input:focus,
+.idea-capture textarea:focus,
+.idea-capture select:focus,
+.ideas-toolbar select:focus,
+dialog select:focus {
+ border-color: var(--accent);
+ box-shadow: 0 0 0 3px rgba(114, 141, 149, 0.12);
+}
+
+.studio-mode-tabs {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 4px;
+ margin-bottom: 12px;
+ padding: 4px;
+ border-radius: 10px;
+ background: rgba(72, 70, 66, 0.07);
+}
+
+.studio-mode-tab {
+ padding: 8px 6px;
+ border: 0;
+ border-radius: 7px;
+ background: transparent;
+ color: var(--muted);
+ cursor: pointer;
+ font-size: 11px;
+ font-weight: 700;
+}
+
+.studio-mode-tab.active {
+ background: var(--paper);
+ color: var(--ink);
+ box-shadow: 0 2px 10px rgba(72, 70, 66, 0.08);
+}
+
+.studio-mode-tab span {
+ margin-left: 3px;
+ color: var(--accent-dark);
+}
+
+.new-draft-button,
+.new-idea-button {
display: flex;
align-items: center;
justify-content: center;
@@ -109,11 +179,28 @@ button {
transition: 160ms ease;
}
-.new-draft-button:hover {
+.new-draft-button:hover,
+.new-idea-button:hover {
border-color: var(--accent);
background: rgba(248, 245, 240, 0.78);
}
+.new-idea-button {
+ margin-bottom: 8px;
+ border-color: transparent;
+ background: var(--accent-dark);
+ color: white;
+}
+
+.new-idea-button:hover {
+ background: #405c65;
+}
+
+.new-draft-button:disabled {
+ cursor: not-allowed;
+ opacity: 0.42;
+}
+
.sidebar-section-heading {
display: flex;
align-items: center;
@@ -262,6 +349,11 @@ button {
gap: 9px;
}
+.topbar-actions {
+ flex-wrap: wrap;
+ justify-content: flex-end;
+}
+
.save-status {
margin-right: 5px;
color: var(--muted);
@@ -655,6 +747,250 @@ button {
line-height: 1.7;
}
+.ideas-workspace {
+ grid-row: 1 / -1;
+ min-height: 100vh;
+ overflow: auto;
+ padding: clamp(32px, 5vw, 68px);
+ background:
+ linear-gradient(rgba(248, 245, 240, 0.92), rgba(248, 245, 240, 0.96)),
+ radial-gradient(circle at 85% 5%, var(--accent-soft), transparent 34%);
+}
+
+.ideas-heading,
+.idea-form-heading,
+.ideas-toolbar,
+.idea-form-actions {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 22px;
+}
+
+.ideas-heading {
+ padding-bottom: 30px;
+ border-bottom: 1px solid var(--line);
+}
+
+.ideas-heading h2 {
+ margin: 7px 0 10px;
+ font-family: Georgia, 'Microsoft YaHei', serif;
+ font-size: clamp(30px, 4vw, 48px);
+ font-weight: 500;
+ letter-spacing: -0.025em;
+}
+
+.ideas-heading p:not(.eyebrow) {
+ max-width: 720px;
+ margin: 0;
+ color: var(--muted);
+ font-size: 13px;
+ line-height: 1.75;
+}
+
+.privacy-badge {
+ display: flex;
+ align-items: center;
+ gap: 9px;
+ padding: 10px 12px;
+ border: 1px solid rgba(111, 146, 121, 0.22);
+ border-radius: 999px;
+ background: rgba(111, 146, 121, 0.08);
+ color: #5e7d68;
+ font-size: 10px;
+ font-weight: 750;
+ white-space: nowrap;
+}
+
+.idea-capture {
+ display: grid;
+ gap: 16px;
+ margin: 30px 0 42px;
+ padding: clamp(22px, 3vw, 34px);
+ border: 1px solid rgba(82, 110, 119, 0.2);
+ border-radius: 16px;
+ background: rgba(255, 255, 255, 0.58);
+ box-shadow: 0 14px 42px rgba(73, 66, 59, 0.06);
+}
+
+.idea-capture label,
+.ideas-toolbar label {
+ display: grid;
+ gap: 7px;
+ color: var(--muted);
+ font-size: 10px;
+ font-weight: 750;
+ letter-spacing: 0.04em;
+}
+
+.idea-capture textarea {
+ min-height: 132px;
+ resize: vertical;
+ background: white;
+ color: var(--ink);
+ font-family: Georgia, 'Microsoft YaHei', serif;
+ font-size: 17px;
+ line-height: 1.75;
+}
+
+.idea-form-heading h3,
+.ideas-toolbar h3 {
+ margin: 3px 0 0;
+ font-family: Georgia, 'Microsoft YaHei', serif;
+ font-size: 22px;
+ font-weight: 500;
+}
+
+.idea-meta-grid {
+ display: grid;
+ grid-template-columns: minmax(220px, 1.4fr) minmax(180px, 1fr) minmax(145px, 0.55fr);
+ gap: 12px;
+}
+
+.idea-form-actions {
+ align-items: center;
+ padding-top: 2px;
+}
+
+.idea-form-actions span {
+ color: var(--muted);
+ font-size: 10px;
+}
+
+.ideas-toolbar {
+ align-items: end;
+ padding-bottom: 14px;
+ border-bottom: 1px solid var(--line);
+}
+
+.ideas-toolbar label {
+ min-width: 150px;
+}
+
+.ideas-list {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(310px, 1fr));
+ gap: 14px;
+ padding: 18px 0 80px;
+}
+
+.idea-card {
+ display: grid;
+ align-content: start;
+ gap: 14px;
+ min-height: 240px;
+ padding: 20px;
+ border: 1px solid var(--line);
+ border-radius: 13px;
+ background: rgba(255, 255, 255, 0.62);
+}
+
+.idea-card.archived {
+ opacity: 0.58;
+}
+
+.idea-card-header,
+.idea-card-footer {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 12px;
+}
+
+.idea-card h4 {
+ margin: 0;
+ font-family: Georgia, 'Microsoft YaHei', serif;
+ font-size: 18px;
+ font-weight: 600;
+ line-height: 1.4;
+}
+
+.idea-card time,
+.idea-target-date {
+ color: var(--muted);
+ font:
+ 9px ui-monospace,
+ SFMono-Regular,
+ Consolas,
+ monospace;
+ white-space: nowrap;
+}
+
+.idea-card-body {
+ display: -webkit-box;
+ overflow: hidden;
+ margin: 0;
+ color: #615d58;
+ font-family: Georgia, 'Microsoft YaHei', serif;
+ font-size: 13px;
+ line-height: 1.75;
+ white-space: pre-wrap;
+ -webkit-box-orient: vertical;
+ -webkit-line-clamp: 7;
+}
+
+.idea-tags {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+}
+
+.idea-tag {
+ padding: 4px 7px;
+ border-radius: 999px;
+ background: var(--accent-soft);
+ color: var(--accent-dark);
+ font-size: 9px;
+ font-weight: 700;
+}
+
+.idea-source {
+ overflow: hidden;
+ color: var(--accent-dark);
+ font-size: 10px;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.idea-card-actions {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.idea-card-actions select,
+.idea-card-actions button {
+ padding: 7px 8px;
+ border: 1px solid var(--line);
+ border-radius: 7px;
+ background: transparent;
+ color: var(--ink);
+ cursor: pointer;
+ font-size: 10px;
+}
+
+.ideas-empty {
+ grid-column: 1 / -1;
+ padding: 70px 30px;
+ border: 1px dashed rgba(82, 110, 119, 0.28);
+ border-radius: 14px;
+ color: var(--muted);
+ text-align: center;
+ font-size: 12px;
+ line-height: 1.75;
+}
+
+.ideas-warning {
+ grid-column: 1 / -1;
+ padding: 13px 15px;
+ border: 1px solid rgba(163, 112, 72, 0.24);
+ border-radius: 10px;
+ background: rgba(192, 143, 99, 0.1);
+ color: #805f45;
+ font-size: 11px;
+ line-height: 1.65;
+}
+
.hidden {
display: none;
}
@@ -733,7 +1069,8 @@ dialog label {
}
dialog input,
-dialog textarea {
+dialog textarea,
+dialog select {
width: 100%;
padding: 10px 11px;
resize: vertical;
@@ -746,7 +1083,8 @@ dialog textarea {
}
dialog input:focus,
-dialog textarea:focus {
+dialog textarea:focus,
+dialog select:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(114, 141, 149, 0.12);
}
@@ -818,6 +1156,274 @@ dialog textarea:focus {
white-space: nowrap;
}
+.publication-package-dialog {
+ width: min(1040px, calc(100vw - 44px));
+ max-height: calc(100vh - 44px);
+ overflow: auto;
+ padding: 28px;
+}
+
+.publication-package-heading {
+ margin-bottom: 20px;
+}
+
+.publication-package-heading > div > p:last-child {
+ max-width: 680px;
+ margin: 8px 0 0;
+ color: var(--muted);
+ font-size: 11px;
+ line-height: 1.65;
+}
+
+.publication-package-controls {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 12px;
+}
+
+.publication-package-controls label {
+ display: grid;
+ gap: 7px;
+ color: var(--muted);
+ font-size: 10px;
+ font-weight: 750;
+}
+
+.publication-package-note {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ margin: 14px 0;
+ padding: 11px 13px;
+ border: 1px solid rgba(82, 110, 119, 0.17);
+ border-radius: 9px;
+ background: var(--accent-soft);
+}
+
+.publication-package-note p {
+ margin: 0;
+ color: #5a6f75;
+ font-size: 11px;
+ line-height: 1.55;
+}
+
+.package-format-badge {
+ flex: 0 0 auto;
+ padding: 4px 7px;
+ border-radius: 999px;
+ background: var(--accent-dark);
+ color: white;
+ font-size: 9px;
+ font-weight: 750;
+ letter-spacing: 0.05em;
+}
+
+.package-format-badge[data-format='plain'] {
+ background: #796d62;
+}
+
+.publication-metadata {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
+ gap: 12px;
+ margin-bottom: 14px;
+}
+
+.publication-metadata > div {
+ display: grid;
+ min-width: 0;
+ gap: 5px;
+ padding: 12px 14px;
+ border: 1px solid var(--line);
+ border-radius: 9px;
+ background: white;
+}
+
+.publication-metadata span,
+.publication-section-heading {
+ color: var(--muted);
+ font-size: 9px;
+ font-weight: 750;
+ letter-spacing: 0.07em;
+ text-transform: uppercase;
+}
+
+.publication-metadata strong,
+.publication-metadata a {
+ overflow: hidden;
+ color: var(--ink);
+ font-family: Georgia, 'Microsoft YaHei', serif;
+ font-size: 13px;
+ font-weight: 550;
+ line-height: 1.45;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.publication-metadata a {
+ color: var(--accent-dark);
+ font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
+ font-size: 10px;
+}
+
+.publication-preview-shell,
+.publication-assets {
+ overflow: hidden;
+ border: 1px solid var(--line);
+ border-radius: 11px;
+ background: white;
+}
+
+.publication-section-heading {
+ display: flex;
+ min-height: 40px;
+ align-items: center;
+ justify-content: space-between;
+ gap: 16px;
+ padding: 0 14px;
+ border-bottom: 1px solid var(--line);
+}
+
+.publication-section-heading > div {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.publication-section-heading small,
+.publication-section-heading > span:last-child {
+ color: var(--muted);
+ font-size: 9px;
+ font-weight: 500;
+ letter-spacing: 0;
+ text-transform: none;
+}
+
+#publication-rich-preview,
+#publication-plain-preview {
+ width: 100%;
+ height: min(44vh, 460px);
+ min-height: 330px;
+ border: 0;
+ background: white;
+}
+
+#publication-plain-preview {
+ padding: 24px;
+ resize: vertical;
+ outline: none;
+ color: var(--ink);
+ font:
+ 13px/1.8 ui-monospace,
+ SFMono-Regular,
+ Consolas,
+ 'Microsoft YaHei',
+ monospace;
+}
+
+.publication-assets {
+ margin-top: 14px;
+}
+
+.publication-asset-list {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 1px;
+ background: var(--line);
+}
+
+.publication-asset-item {
+ display: grid;
+ min-width: 0;
+ grid-template-columns: 64px minmax(0, 1fr) auto;
+ align-items: center;
+ gap: 11px;
+ padding: 10px;
+ background: white;
+}
+
+.publication-asset-item img,
+.publication-asset-missing {
+ display: grid;
+ width: 64px;
+ height: 48px;
+ place-items: center;
+ border-radius: 6px;
+ background: var(--canvas);
+ object-fit: cover;
+}
+
+.publication-asset-missing {
+ color: var(--muted);
+ font-size: 9px;
+}
+
+.publication-asset-item > div:not(.publication-asset-missing) {
+ display: grid;
+ min-width: 0;
+ gap: 3px;
+}
+
+.publication-asset-item span,
+.publication-asset-item small {
+ overflow: hidden;
+ color: var(--muted);
+ font-size: 8px;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.publication-asset-item strong {
+ overflow: hidden;
+ font-size: 10px;
+ font-weight: 650;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.publication-asset-item .button {
+ padding: 7px 9px;
+ font-size: 9px;
+}
+
+.publication-warnings {
+ margin-top: 14px;
+ padding: 13px 15px;
+ border: 1px solid rgba(163, 112, 72, 0.22);
+ border-radius: 9px;
+ background: rgba(192, 143, 99, 0.09);
+ color: #76583f;
+ font-size: 10px;
+ line-height: 1.65;
+}
+
+.publication-warnings strong {
+ display: block;
+ margin-bottom: 4px;
+}
+
+.publication-warnings ul {
+ margin: 0;
+ padding-left: 18px;
+}
+
+.publication-package-actions {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 16px;
+ margin-top: 18px;
+}
+
+.publication-package-actions > div {
+ display: flex;
+ gap: 8px;
+}
+
+.publication-platform-link {
+ text-decoration: none;
+}
+
@media (max-width: 1180px) {
.app-shell {
grid-template-columns: 248px minmax(0, 1fr);
@@ -829,4 +1435,16 @@ dialog textarea:focus {
padding-right: 20px;
padding-left: 20px;
}
+
+ .ideas-workspace {
+ padding: 34px;
+ }
+
+ .idea-meta-grid {
+ grid-template-columns: 1fr 1fr;
+ }
+
+ .publication-asset-list {
+ grid-template-columns: 1fr;
+ }
}
diff --git a/tools/writer-studio/server.mjs b/tools/writer-studio/server.mjs
index cef8d11..3c36223 100644
--- a/tools/writer-studio/server.mjs
+++ b/tools/writer-studio/server.mjs
@@ -1,17 +1,36 @@
import { createReadStream } from 'node:fs';
import { spawn } from 'node:child_process';
-import { access, copyFile, mkdir, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises';
+import { createHash, randomBytes } from 'node:crypto';
+import {
+ access,
+ copyFile,
+ mkdir,
+ readFile,
+ readdir,
+ rename,
+ rm,
+ stat,
+ writeFile,
+} from 'node:fs/promises';
import http from 'node:http';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
+import { DEFAULT_COLUMN_ID, listColumns, requireColumn } from './columns.mjs';
+import {
+ createIdea,
+ IDEA_STATUSES,
+ listIdeas,
+ listIdeasWithDiagnostics,
+ updateIdea,
+} from './idea-store.mjs';
import {
ensureTaskWorkspace,
publishTaskAssets,
readTaskWorkspace,
saveTaskAsset,
+ TASK_DOCUMENTS,
taskAssetPath,
updateTaskStage,
- writeTaskDocument,
} from './task-workspace.mjs';
const moduleDir = path.dirname(fileURLToPath(import.meta.url));
@@ -21,6 +40,7 @@ const draftIdPattern = /^\d{4}\/[a-z0-9]+(?:-[a-z0-9]+)*$/;
const slugPattern = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
const languagePattern = /^(cn|en)$/;
const maxBodyBytes = 12 * 1024 * 1024;
+const fileMutationQueues = new Map();
const contentTypes = {
'.css': 'text/css; charset=utf-8',
@@ -28,14 +48,82 @@ const contentTypes = {
'.js': 'text/javascript; charset=utf-8',
};
+const securityHeaders = {
+ 'Content-Security-Policy':
+ "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; frame-src 'self'; frame-ancestors 'none'; connect-src 'self'; base-uri 'none'; form-action 'self'",
+ 'Cross-Origin-Resource-Policy': 'same-origin',
+ 'Referrer-Policy': 'no-referrer',
+ 'X-Content-Type-Options': 'nosniff',
+};
+
function sendJson(response, status, payload) {
response.writeHead(status, {
+ ...securityHeaders,
'Content-Type': 'application/json; charset=utf-8',
'Cache-Control': 'no-store',
});
response.end(JSON.stringify(payload));
}
+function contentHash(content) {
+ return createHash('sha256').update(content).digest('hex');
+}
+
+async function atomicWriteText(file, content, expectedHash, afterTemporaryWrite) {
+ const temporary = `${file}.${randomBytes(8).toString('hex')}.tmp`;
+ try {
+ await writeFile(temporary, content, 'utf8');
+ await afterTemporaryWrite?.({ target: file, temporary });
+ const current = await readFile(file, 'utf8');
+ const currentHash = contentHash(current);
+ if (currentHash !== expectedHash) return { conflict: true, currentHash };
+ await rename(temporary, file);
+ return { saved: true, hash: contentHash(content) };
+ } finally {
+ await rm(temporary, { force: true }).catch(() => {});
+ }
+}
+
+async function serializeFileMutation(file, mutation) {
+ const key = path.resolve(file);
+ const previous = fileMutationQueues.get(key) ?? Promise.resolve();
+ const result = previous.catch(() => {}).then(mutation);
+ const settled = result.then(
+ () => undefined,
+ () => undefined
+ );
+ fileMutationQueues.set(key, settled);
+
+ try {
+ return await result;
+ } finally {
+ if (fileMutationQueues.get(key) === settled) fileMutationQueues.delete(key);
+ }
+}
+
+function hasWriteConflict(current, input) {
+ return input.baseHash !== contentHash(current) && input.content !== current;
+}
+
+function isMutatingRequest(method) {
+ return ['POST', 'PUT', 'PATCH', 'DELETE'].includes(method || '');
+}
+
+function hasValidLocalOrigin(request) {
+ const origin = request.headers.origin;
+ if (!origin) return true;
+ try {
+ const parsed = new URL(origin);
+ return (
+ parsed.protocol === 'http:' &&
+ /^(127\.0\.0\.1|localhost)$/i.test(parsed.hostname) &&
+ parsed.host === request.headers.host
+ );
+ } catch {
+ return false;
+ }
+}
+
function parseFrontmatter(content) {
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
const frontmatter = match?.[1] ?? '';
@@ -194,13 +282,16 @@ export async function seedExampleDraft(root = defaultRoot) {
return latest.id;
}
-async function listDrafts(root) {
+async function listDrafts(root, options = {}) {
const draftsRoot = path.join(root, '.drafts', 'blog');
- await mkdir(draftsRoot, { recursive: true });
+ if (options.initialize) await mkdir(draftsRoot, { recursive: true });
const results = [];
- const years = (await readdir(draftsRoot, { withFileTypes: true })).filter((entry) =>
- entry.isDirectory()
- );
+ const years = (
+ await readdir(draftsRoot, { withFileTypes: true }).catch((error) => {
+ if (error?.code === 'ENOENT') return [];
+ throw error;
+ })
+ ).filter((entry) => entry.isDirectory());
for (const year of years) {
const yearRoot = path.join(draftsRoot, year.name);
@@ -220,7 +311,7 @@ async function listDrafts(root) {
primary ||= parseFrontmatter(content);
modifiedAt = Math.max(modifiedAt, (await stat(file)).mtimeMs);
}
- await ensureTaskWorkspace(root, id, primary || {});
+ if (options.initialize) await ensureTaskWorkspace(root, id, primary || {});
const workspace = await readTaskWorkspace(root, id);
modifiedAt = Math.max(
modifiedAt,
@@ -238,6 +329,7 @@ async function listDrafts(root) {
modifiedAt,
published: await exists(contentDirectory(root, id)),
stage: workspace.task.stage,
+ columnId: workspace.task.columnId || DEFAULT_COLUMN_ID,
assetCount: workspace.assets.length,
});
}
@@ -398,10 +490,11 @@ function parseDraftRoute(pathname) {
async function serveStatic(response, publicDir, pathname) {
const requested = pathname === '/' ? 'index.html' : pathname.slice(1);
- if (!/^(index\.html|app\.js|styles\.css)$/.test(requested)) return false;
+ if (!/^(index\.html|app\.js|publication-package\.js|styles\.css)$/.test(requested)) return false;
const file = path.join(publicDir, requested);
if (!(await exists(file))) return false;
response.writeHead(200, {
+ ...securityHeaders,
'Content-Type': contentTypes[path.extname(file)] || 'application/octet-stream',
'Cache-Control': 'no-store',
});
@@ -412,7 +505,10 @@ async function serveStatic(response, publicDir, pathname) {
export async function createWriterServer(options = {}) {
const root = options.root || defaultRoot;
const publicDir = options.publicDir || defaultPublicDir;
+ const sessionToken = options.sessionToken || randomBytes(32).toString('base64url');
+ const afterTemporaryWrite = options.testHooks?.afterTemporaryWrite;
await seedExampleDraft(root);
+ await listDrafts(root, { initialize: true });
return http.createServer(async (request, response) => {
try {
@@ -425,13 +521,63 @@ export async function createWriterServer(options = {}) {
return;
}
+ if (isMutatingRequest(request.method)) {
+ if (!hasValidLocalOrigin(request)) {
+ sendJson(response, 403, { error: 'Writer Studio rejected a cross-origin request.' });
+ return;
+ }
+ if (request.headers['x-writer-studio-token'] !== sessionToken) {
+ sendJson(response, 403, { error: 'Writer Studio session token is missing or invalid.' });
+ return;
+ }
+ }
+
if (request.method === 'GET' && url.pathname === '/api/state') {
- sendJson(response, 200, { drafts: await listDrafts(root) });
+ const ideas = await listIdeas(root);
+ const ideaCounts = Object.fromEntries(listColumns().map((column) => [column.id, 0]));
+ for (const idea of ideas) ideaCounts[idea.columnId] = (ideaCounts[idea.columnId] || 0) + 1;
+ sendJson(response, 200, {
+ sessionToken,
+ columns: listColumns(),
+ drafts: await listDrafts(root),
+ ideaCounts,
+ });
+ return;
+ }
+
+ if (request.method === 'GET' && url.pathname === '/api/ideas') {
+ const columnId = url.searchParams.get('column') || '';
+ const ideas = await listIdeasWithDiagnostics(root, columnId);
+ sendJson(response, 200, {
+ ideas: ideas.ideas,
+ statuses: IDEA_STATUSES,
+ invalidRecords: ideas.invalidRecords,
+ });
+ return;
+ }
+
+ if (request.method === 'POST' && url.pathname === '/api/ideas') {
+ sendJson(response, 201, { idea: await createIdea(root, await readBody(request)) });
+ return;
+ }
+
+ const ideaRoute = url.pathname.match(/^\/api\/ideas\/([a-z0-9-]+)$/i);
+ if (ideaRoute && request.method === 'PATCH') {
+ sendJson(response, 200, {
+ idea: await updateIdea(root, ideaRoute[1].toLowerCase(), await readBody(request)),
+ });
return;
}
if (request.method === 'POST' && url.pathname === '/api/drafts') {
const input = await readBody(request);
+ const column = requireColumn(String(input.columnId || DEFAULT_COLUMN_ID));
+ if (!column.capabilities.drafts) {
+ sendJson(response, 400, {
+ error: 'This column does not have a fixed draft and publication format yet.',
+ });
+ return;
+ }
const year = String(input.year || '');
const slug = String(input.slug || '');
if (!/^\d{4}$/.test(year) || !slugPattern.test(slug)) {
@@ -463,7 +609,7 @@ export async function createWriterServer(options = {}) {
'utf8'
);
}
- await ensureTaskWorkspace(root, id, { title });
+ await ensureTaskWorkspace(root, id, { title, columnId: column.id });
sendJson(response, 201, { id });
return;
}
@@ -481,6 +627,7 @@ export async function createWriterServer(options = {}) {
id: route.id,
language,
content,
+ hash: contentHash(content),
metadata: parseFrontmatter(content),
});
return;
@@ -493,13 +640,31 @@ export async function createWriterServer(options = {}) {
sendJson(response, 400, { error: 'Content must be a string.' });
return;
}
+ if (typeof input.baseHash !== 'string') {
+ sendJson(response, 428, { error: 'A base content hash is required.' });
+ return;
+ }
const directory = draftDirectory(root, route.id);
if (!(await exists(directory))) {
sendJson(response, 404, { error: 'Draft does not exist.' });
return;
}
- await writeFile(path.join(directory, `${language}.mdx`), input.content, 'utf8');
- sendJson(response, 200, { saved: true });
+ const file = path.join(directory, `${language}.mdx`);
+ const result = await serializeFileMutation(file, async () => {
+ const current = await readFile(file, 'utf8');
+ if (hasWriteConflict(current, input)) {
+ return { conflict: true, currentHash: contentHash(current) };
+ }
+ return atomicWriteText(file, input.content, contentHash(current), afterTemporaryWrite);
+ });
+ if (result.conflict) {
+ sendJson(response, 409, {
+ error: 'Draft changed on disk. The browser copy was not written.',
+ currentHash: result.currentHash,
+ });
+ return;
+ }
+ sendJson(response, 200, result);
return;
}
@@ -514,14 +679,45 @@ export async function createWriterServer(options = {}) {
}
if (route && request.method === 'GET' && route.action === 'workspace') {
- sendJson(response, 200, await readTaskWorkspace(root, route.id));
+ const workspace = await readTaskWorkspace(root, route.id);
+ for (const document of Object.values(workspace.documents)) {
+ document.hash = contentHash(document.content);
+ }
+ sendJson(response, 200, workspace);
return;
}
if (route && request.method === 'PUT' && route.action === 'documents') {
const input = await readBody(request);
- await writeTaskDocument(root, route.id, route.detail, input.content);
- sendJson(response, 200, { saved: true });
+ if (typeof input.content !== 'string') {
+ sendJson(response, 400, { error: 'Content must be a string.' });
+ return;
+ }
+ if (typeof input.baseHash !== 'string') {
+ sendJson(response, 428, { error: 'A base content hash is required.' });
+ return;
+ }
+ const document = TASK_DOCUMENTS[route.detail];
+ if (!document) {
+ sendJson(response, 400, { error: 'Invalid task document.' });
+ return;
+ }
+ const file = path.join(draftDirectory(root, route.id), document.file);
+ const result = await serializeFileMutation(file, async () => {
+ const current = await readFile(file, 'utf8');
+ if (hasWriteConflict(current, input)) {
+ return { conflict: true, currentHash: contentHash(current) };
+ }
+ return atomicWriteText(file, input.content, contentHash(current), afterTemporaryWrite);
+ });
+ if (result.conflict) {
+ sendJson(response, 409, {
+ error: 'Task document changed on disk. The browser copy was not written.',
+ currentHash: result.currentHash,
+ });
+ return;
+ }
+ sendJson(response, 200, result);
return;
}
@@ -538,15 +734,17 @@ export async function createWriterServer(options = {}) {
}
if (route && request.method === 'GET' && route.action === 'assets' && route.detail) {
- const asset = taskAssetPath(root, route.id, decodeURIComponent(route.detail));
+ const asset = await taskAssetPath(root, route.id, decodeURIComponent(route.detail));
if (!(await exists(asset.file))) {
sendJson(response, 404, { error: 'Image does not exist.' });
return;
}
response.writeHead(200, {
+ ...securityHeaders,
+ 'Content-Security-Policy':
+ "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; sandbox",
'Content-Type': asset.contentType,
'Cache-Control': 'no-store',
- 'X-Content-Type-Options': 'nosniff',
});
createReadStream(asset.file).pipe(response);
return;
@@ -569,7 +767,7 @@ async function start() {
const port = Number(process.env.WRITER_PORT || 4321);
server.listen(port, '127.0.0.1', () => {
console.log(`Writer Studio: http://127.0.0.1:${port}`);
- console.log('Drafts stay local under .drafts/blog. Press Ctrl+C to stop.');
+ console.log('Drafts and ideas stay local under .drafts/. Press Ctrl+C to stop.');
});
}
diff --git a/tools/writer-studio/server.test.mjs b/tools/writer-studio/server.test.mjs
index a42c818..77c9a99 100644
--- a/tools/writer-studio/server.test.mjs
+++ b/tools/writer-studio/server.test.mjs
@@ -1,12 +1,14 @@
import assert from 'node:assert/strict';
-import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
+import { mkdtemp, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises';
import http from 'node:http';
import os from 'node:os';
import path from 'node:path';
import { afterEach, test } from 'node:test';
+import { buildPublicationPackage } from './public/publication-package.js';
import { createWriterServer, seedExampleDraft } from './server.mjs';
const temporaryRoots = [];
+const sessionTokens = new Map();
async function fixture() {
const root = await mkdtemp(path.join(os.tmpdir(), 'writer-studio-'));
@@ -44,7 +46,30 @@ async function requestWithHost(base, pathname, host) {
});
}
+async function writerFetch(base, pathname, options = {}) {
+ const method = options.method || 'GET';
+ let token = sessionTokens.get(base);
+ if (method !== 'GET' && !token) {
+ const state = await fetch(`${base}/api/state`);
+ token = (await state.json()).sessionToken;
+ sessionTokens.set(base, token);
+ }
+ const response = await fetch(`${base}${pathname}`, {
+ ...options,
+ headers: {
+ ...(options.headers || {}),
+ ...(method === 'GET' ? {} : { 'X-Writer-Studio-Token': token }),
+ },
+ });
+ if (method === 'GET' && pathname === '/api/state' && response.ok) {
+ const payload = await response.clone().json();
+ sessionTokens.set(base, payload.sessionToken);
+ }
+ return response;
+}
+
afterEach(async () => {
+ sessionTokens.clear();
await Promise.all(
temporaryRoots.splice(0).map((root) => rm(root, { recursive: true, force: true }))
);
@@ -66,30 +91,172 @@ test('lists, reads, edits, and validates a local draft', async (context) => {
context.after(() => server.close());
const base = await listen(server);
- const stateResponse = await fetch(`${base}/api/state`);
+ const stateResponse = await writerFetch(base, '/api/state');
const state = await stateResponse.json();
assert.equal(state.drafts[0].id, '2026/latest-article');
+ assert.equal(state.columns.length, 3);
+ assert.equal(state.drafts[0].columnId, 'intellipharma');
const encodedId = encodeURIComponent('2026/latest-article');
- const draftResponse = await fetch(`${base}/api/drafts/${encodedId}?lang=cn`);
+ const draftResponse = await writerFetch(base, `/api/drafts/${encodedId}?lang=cn`);
const draft = await draftResponse.json();
assert.match(draft.content, /正文保持不变/);
const updated = draft.content.replace('正文保持不变。', 'Codex 协作后的正文。');
- const saveResponse = await fetch(`${base}/api/drafts/${encodedId}?lang=cn`, {
+ const saveResponse = await writerFetch(base, `/api/drafts/${encodedId}?lang=cn`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ content: updated }),
+ body: JSON.stringify({ content: updated, baseHash: draft.hash }),
});
assert.equal(saveResponse.status, 200);
+ const saved = await saveResponse.json();
+
+ const draftFile = path.join(root, '.drafts', 'blog', '2026', 'latest-article', 'cn.mdx');
+ const externalVersion = updated.replace('Codex 协作后的正文。', '外部编辑器写入的正文。');
+ await writeFile(draftFile, externalVersion, 'utf8');
+ const conflictedResponse = await writerFetch(base, `/api/drafts/${encodedId}?lang=cn`, {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ content: updated.replace('Codex 协作后的正文。', '浏览器继续写入的正文。'),
+ baseHash: saved.hash,
+ }),
+ });
+ assert.equal(conflictedResponse.status, 409);
+ const conflict = await conflictedResponse.json();
+ assert.equal(await readFile(draftFile, 'utf8'), externalVersion);
+
+ const resolvedResponse = await writerFetch(base, `/api/drafts/${encodedId}?lang=cn`, {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ content: updated, baseHash: conflict.currentHash }),
+ });
+ assert.equal(resolvedResponse.status, 200);
- const validationResponse = await fetch(`${base}/api/drafts/${encodedId}/validate`, {
+ const validationResponse = await writerFetch(base, `/api/drafts/${encodedId}/validate`, {
method: 'POST',
});
const validation = await validationResponse.json();
assert.equal(validation.ok, true);
});
+test('serializes concurrent article and task-document saves against the same base hash', async (context) => {
+ const { root } = await fixture();
+ const server = await createWriterServer({ root });
+ context.after(() => server.close());
+ const base = await listen(server);
+ const id = encodeURIComponent('2026/latest-article');
+ const draft = await (await writerFetch(base, `/api/drafts/${id}?lang=cn`)).json();
+ const workspace = await (await writerFetch(base, `/api/drafts/${id}/workspace`)).json();
+ const directory = path.join(root, '.drafts', 'blog', '2026', 'latest-article');
+ const cases = [
+ {
+ label: 'article',
+ pathname: `/api/drafts/${id}?lang=cn`,
+ baseHash: draft.hash,
+ file: path.join(directory, 'cn.mdx'),
+ contents: [
+ draft.content.replace('正文保持不变。', '并发保存版本 A。'),
+ draft.content.replace('正文保持不变。', '并发保存版本 B。'),
+ ],
+ },
+ {
+ label: 'task document',
+ pathname: `/api/drafts/${id}/documents/outline`,
+ baseHash: workspace.documents.outline.hash,
+ file: path.join(directory, 'outline.md'),
+ contents: ['# 文章大纲\n\n并发保存版本 A。\n', '# 文章大纲\n\n并发保存版本 B。\n'],
+ },
+ ];
+
+ for (const saveCase of cases) {
+ const attempts = await Promise.all(
+ saveCase.contents.map(async (content) => {
+ const response = await writerFetch(base, saveCase.pathname, {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ content, baseHash: saveCase.baseHash }),
+ });
+ return { content, status: response.status, payload: await response.json() };
+ })
+ );
+
+ assert.deepEqual(
+ attempts.map((attempt) => attempt.status).sort((left, right) => left - right),
+ [200, 409],
+ saveCase.label
+ );
+ const winner = attempts.find((attempt) => attempt.status === 200);
+ const conflict = attempts.find((attempt) => attempt.status === 409);
+ assert.equal(await readFile(saveCase.file, 'utf8'), winner.content, saveCase.label);
+ assert.equal(conflict.payload.currentHash, winner.payload.hash, saveCase.label);
+ }
+});
+
+test('preserves external writes that land after the temporary save and before replacement', async (context) => {
+ const { root } = await fixture();
+ const directory = path.join(root, '.drafts', 'blog', '2026', 'latest-article');
+ const externalWrites = new Map();
+ const server = await createWriterServer({
+ root,
+ testHooks: {
+ async afterTemporaryWrite({ target }) {
+ if (!externalWrites.has(target)) return;
+ const content = externalWrites.get(target);
+ externalWrites.delete(target);
+ await writeFile(target, content, 'utf8');
+ },
+ },
+ });
+ context.after(() => server.close());
+ const base = await listen(server);
+ const id = encodeURIComponent('2026/latest-article');
+ const draft = await (await writerFetch(base, `/api/drafts/${id}?lang=cn`)).json();
+ const workspace = await (await writerFetch(base, `/api/drafts/${id}/workspace`)).json();
+ const cases = [
+ {
+ label: 'article',
+ pathname: `/api/drafts/${id}?lang=cn`,
+ baseHash: draft.hash,
+ file: path.join(directory, 'cn.mdx'),
+ browserContent: draft.content.replace('正文保持不变。', '浏览器保存版本。'),
+ externalContent: draft.content.replace('正文保持不变。', '外部编辑器最后写入。'),
+ },
+ {
+ label: 'task document',
+ pathname: `/api/drafts/${id}/documents/outline`,
+ baseHash: workspace.documents.outline.hash,
+ file: path.join(directory, 'outline.md'),
+ browserContent: '# 文章大纲\n\n浏览器保存版本。\n',
+ externalContent: '# 文章大纲\n\n外部编辑器最后写入。\n',
+ },
+ ];
+
+ for (const saveCase of cases) {
+ externalWrites.set(saveCase.file, saveCase.externalContent);
+ const response = await writerFetch(base, saveCase.pathname, {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ content: saveCase.browserContent,
+ baseHash: saveCase.baseHash,
+ }),
+ });
+ const conflict = await response.json();
+
+ assert.equal(response.status, 409, saveCase.label);
+ assert.equal(typeof conflict.currentHash, 'string', saveCase.label);
+ assert.equal(await readFile(saveCase.file, 'utf8'), saveCase.externalContent, saveCase.label);
+ assert.equal(
+ (await readdir(directory)).some(
+ (entry) => entry.startsWith(`${path.basename(saveCase.file)}.`) && entry.endsWith('.tmp')
+ ),
+ false,
+ saveCase.label
+ );
+ }
+});
+
test('rejects non-local host headers and invalid draft paths', async (context) => {
const { root } = await fixture();
const server = await createWriterServer({ root });
@@ -98,17 +265,448 @@ test('rejects non-local host headers and invalid draft paths', async (context) =
assert.equal(await requestWithHost(base, '/api/state', 'writer.example.com'), 403);
- const invalid = await fetch(`${base}/api/drafts/${encodeURIComponent('../secrets')}?lang=cn`);
+ const invalid = await writerFetch(
+ base,
+ `/api/drafts/${encodeURIComponent('../secrets')}?lang=cn`
+ );
assert.equal(invalid.status, 404);
});
+test('serves the publication package module with the local security headers', async (context) => {
+ const { root } = await fixture();
+ const server = await createWriterServer({ root });
+ context.after(() => server.close());
+ const base = await listen(server);
+
+ const response = await writerFetch(base, '/publication-package.js');
+ assert.equal(response.status, 200);
+ assert.match(response.headers.get('content-type'), /text\/javascript/);
+ assert.match(await response.text(), /buildPublicationPackage/);
+});
+
+test('builds conservative rich and social packages without leaking MDX code', () => {
+ const source = `---
+title: '发布包示例'
+date: 2026-08-02
+description: '一段适合作为平台导语的摘要。'
+lang: 'zh'
+canonical:
+ url: '/zh/blog/2026/package-example/cn'
+image: ./images/cover.png
+imageAlt: '封面说明'
+---
+
+import ArticleImage from '../../../../components/blog/ArticleImage.astro';
+import chart from './images/chart.svg';
+
+export const rows = [
+ { label: '不应泄漏' },
+];
+
+# 发布包示例
+
+## 核心判断
+
+这是一段带有 **重点** 和 [站内链接](/zh/blog/2026/other/cn) 的正文。
+
+
+
+
+
+$$
+x = y + 1
+$$
+`;
+ const rich = buildPublicationPackage({
+ source,
+ platformId: 'wechat',
+ articleId: '2026/package-example',
+ language: 'cn',
+ published: true,
+ });
+ assert.equal(rich.metadata.title, '发布包示例');
+ assert.equal(rich.assets.length, 3);
+ assert.deepEqual(
+ rich.assets.map((asset) => [asset.fileName, asset.index]),
+ [
+ ['cover.png', 0],
+ ['inline.png', 1],
+ ['chart.svg', 2],
+ ]
+ );
+ assert.match(rich.richHtml, /https:\/\/ssooop\.github\.io\/zh\/blog\/2026\/other\/cn/);
+ assert.match(rich.richHtml, /图表说明/);
+ assert.match(rich.richHtml, /https:\/\/example\.com\/source/);
+ assert.doesNotMatch(rich.richHtml, /export const|不应泄漏|
]*>发布包示例/);
+ assert.ok(rich.warnings.some((warning) => warning.includes('公式')));
+
+ const social = buildPublicationPackage({
+ source,
+ platformId: 'x-post',
+ articleId: '2026/package-example',
+ language: 'cn',
+ published: true,
+ });
+ assert.equal(social.richHtml, '');
+ assert.ok(Array.from(social.plainText).length <= 260);
+ assert.match(social.plainText, /https:\/\/ssooop\.github\.io/);
+});
+
+test('preserves fenced examples and parses rich-image edge cases', () => {
+ const source = `---
+title: 'Parser edge cases'
+date: 2026-08-02
+description: 'Parser regression coverage.'
+lang: 'en'
+canonical:
+ url: '/en/blog/2026/parser-edge-cases/en'
+---
+
+import ArticleImage from '../../../../components/blog/ArticleImage.astro';
+import diagram from './images/diagram.webp';
+
+# Parser edge cases
+
+\`\`\`mdx
+import hidden from './images/inside.gif';
+export const example = [{ label: 'keep me' }];
+
+
Keep this HTML example
+\`\`\`
+
+
+
+Keep this important callout body.
+
+Inline syntax example: \`\`.
+
+Inline component example: \`\`.
+
+Before .gif "Motion over time") after.
+`;
+ const result = buildPublicationPackage({
+ source,
+ platformId: 'linkedin-article',
+ articleId: '2026/parser-edge-cases',
+ language: 'en',
+ published: true,
+ });
+
+ assert.match(result.richHtml, /import hidden/);
+ assert.match(result.richHtml, /export const example/);
+ assert.match(result.richHtml, /Keep this HTML example/);
+ assert.match(result.richHtml, /Keep this important callout body/);
+ assert.match(result.richHtml, /Not an asset/);
+ assert.match(result.richHtml, /Not a component asset/);
+ assert.deepEqual(
+ result.assets.map((asset) => [asset.fileName, asset.alt, asset.caption]),
+ [
+ ['diagram.webp', "James Watt's working engine", "Watt's diagram"],
+ ['chart(1).gif', 'Animated chart', 'Motion over time'],
+ ]
+ );
+ assert.ok(result.warnings.some((warning) => warning.includes('行内 Markdown 图片')));
+ assert.ok(result.warnings.some((warning) => warning.includes('动画效果将丢失')));
+ assert.ok(result.warnings.some((warning) => warning.includes('Callout')));
+
+ const xArticle = buildPublicationPackage({
+ source,
+ platformId: 'x-article',
+ articleId: '2026/parser-edge-cases',
+ language: 'en',
+ published: true,
+ });
+ assert.equal(xArticle.platform.format, 'rich');
+ assert.equal(xArticle.platform.editorUrl, 'https://x.com/compose/articles');
+ assert.match(xArticle.richHtml, /Watt's diagram/);
+});
+
+test('falls back to trusted site images, including SVG, for a seeded published draft', async (context) => {
+ const { root } = await fixture();
+ const images = path.join(root, 'src', 'content', 'blog', '2026', 'latest-article', 'images');
+ await mkdir(images, { recursive: true });
+ await writeFile(
+ path.join(images, 'Ontology_Simple.svg'),
+ '',
+ 'utf8'
+ );
+
+ const server = await createWriterServer({ root });
+ context.after(() => server.close());
+ const base = await listen(server);
+ const id = encodeURIComponent('2026/latest-article');
+ const workspace = await (await writerFetch(base, `/api/drafts/${id}/workspace`)).json();
+ assert.deepEqual(
+ workspace.assets.map((asset) => [asset.name, asset.origin]),
+ [['Ontology_Simple.svg', 'site']]
+ );
+
+ const response = await writerFetch(base, `/api/drafts/${id}/assets/Ontology_Simple.svg`);
+ assert.equal(response.status, 200);
+ assert.equal(response.headers.get('content-type'), 'image/svg+xml');
+ assert.match(response.headers.get('content-security-policy'), /sandbox/);
+});
+
+test('rejects normalized image-name collisions without changing the first upload', async (context) => {
+ const { root } = await fixture();
+ const server = await createWriterServer({ root });
+ context.after(() => server.close());
+ const base = await listen(server);
+ const id = encodeURIComponent('2026/latest-article');
+ const firstBytes = Buffer.from('first image');
+
+ const first = await writerFetch(base, `/api/drafts/${id}/assets`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ name: '中文 图片.PNG', base64: firstBytes.toString('base64') }),
+ });
+ assert.equal(first.status, 201);
+ assert.equal((await first.json()).asset.name, '中文-图片.png');
+
+ const collision = await writerFetch(base, `/api/drafts/${id}/assets`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ name: '中文 图片.png',
+ base64: Buffer.from('second image').toString('base64'),
+ }),
+ });
+ assert.equal(collision.status, 409);
+ assert.deepEqual(
+ await readFile(
+ path.join(root, '.drafts', 'blog', '2026', 'latest-article', 'images', '中文-图片.png')
+ ),
+ firstBytes
+ );
+});
+
+test('preserves malformed task metadata when the server initializes workspaces', async (context) => {
+ const { root, content } = await fixture();
+ const directory = path.join(root, '.drafts', 'blog', '2026', 'broken-task');
+ await mkdir(directory, { recursive: true });
+ await writeFile(path.join(directory, 'cn.mdx'), content, 'utf8');
+ const malformed = '{"stage":"draft",\n';
+ await writeFile(path.join(directory, 'task.json'), malformed, 'utf8');
+
+ const server = await createWriterServer({ root });
+ context.after(() => server.close());
+ const base = await listen(server);
+ const state = await (await writerFetch(base, '/api/state')).json();
+ const id = encodeURIComponent('2026/broken-task');
+ const workspace = await (await writerFetch(base, `/api/drafts/${id}/workspace`)).json();
+
+ assert.equal(await readFile(path.join(directory, 'task.json'), 'utf8'), malformed);
+ assert.equal(state.drafts.find((draft) => draft.id === '2026/broken-task').stage, 'ideation');
+ assert.equal(workspace.taskMetadata.status, 'malformed');
+ assert.equal(workspace.taskMetadata.diagnostic.code, 'MALFORMED_TASK_METADATA');
+
+ const stageUpdate = await writerFetch(base, `/api/drafts/${id}/task`, {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ stage: 'draft' }),
+ });
+ assert.equal(stageUpdate.status, 422);
+ assert.equal((await stageUpdate.json()).details.code, 'MALFORMED_TASK_METADATA');
+ assert.equal(await readFile(path.join(directory, 'task.json'), 'utf8'), malformed);
+});
+
+test('detects task-document conflicts and requires an explicit new base hash', async (context) => {
+ const { root } = await fixture();
+ const server = await createWriterServer({ root });
+ context.after(() => server.close());
+ const base = await listen(server);
+ const id = encodeURIComponent('2026/latest-article');
+ const workspace = await (await writerFetch(base, `/api/drafts/${id}/workspace`)).json();
+ const outlineFile = path.join(root, '.drafts', 'blog', '2026', 'latest-article', 'outline.md');
+ const externalVersion = '# 文章大纲\n\n外部编辑器版本。\n';
+ await writeFile(outlineFile, externalVersion, 'utf8');
+
+ const conflictResponse = await writerFetch(base, `/api/drafts/${id}/documents/outline`, {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ content: '# 文章大纲\n\n浏览器版本。\n',
+ baseHash: workspace.documents.outline.hash,
+ }),
+ });
+ assert.equal(conflictResponse.status, 409);
+ const conflict = await conflictResponse.json();
+ assert.equal(await readFile(outlineFile, 'utf8'), externalVersion);
+
+ const resolved = await writerFetch(base, `/api/drafts/${id}/documents/outline`, {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ content: '# 文章大纲\n\n浏览器版本。\n',
+ baseHash: conflict.currentHash,
+ }),
+ });
+ assert.equal(resolved.status, 200);
+});
+
+test('protects mutations with a session token and same-origin check', async (context) => {
+ const { root } = await fixture();
+ const server = await createWriterServer({ root });
+ context.after(() => server.close());
+ const base = await listen(server);
+ const state = await (await writerFetch(base, '/api/state')).json();
+
+ const missingToken = await fetch(`${base}/api/ideas`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ columnId: 'economics-after-ai', body: '未授权写入' }),
+ });
+ assert.equal(missingToken.status, 403);
+
+ const crossOrigin = await fetch(`${base}/api/ideas`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ Origin: 'https://example.com',
+ 'X-Writer-Studio-Token': state.sessionToken,
+ },
+ body: JSON.stringify({ columnId: 'economics-after-ai', body: '跨站写入' }),
+ });
+ assert.equal(crossOrigin.status, 403);
+
+ const taskFile = path.join(root, '.drafts', 'blog', '2026', 'latest-article', 'task.json');
+ await writeFile(taskFile, '{ deliberately invalid task metadata\n', 'utf8');
+ const stateRead = await writerFetch(base, '/api/state');
+ assert.equal(stateRead.status, 200);
+ assert.equal(await readFile(taskFile, 'utf8'), '{ deliberately invalid task metadata\n');
+});
+
+test('captures, filters, and develops private ideas across columns', async (context) => {
+ const { root } = await fixture();
+ const server = await createWriterServer({ root });
+ context.after(() => server.close());
+ const base = await listen(server);
+
+ const createdResponse = await writerFetch(base, '/api/ideas', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ columnId: 'economics-after-ai',
+ title: '增长循环',
+ body: 'AI 生产率怎样重新进入社会再生产?',
+ tags: '增长, 待验证',
+ sourceUrl: 'https://example.com/source',
+ targetDate: '2026-08-18',
+ }),
+ });
+ assert.equal(createdResponse.status, 201);
+ const created = (await createdResponse.json()).idea;
+ assert.equal(created.status, 'inbox');
+ assert.deepEqual(created.tags, ['增长', '待验证']);
+
+ const listed = await (await writerFetch(base, '/api/ideas?column=economics-after-ai')).json();
+ assert.equal(listed.ideas.length, 1);
+ assert.equal(listed.statuses.length, 5);
+
+ const updatedResponse = await writerFetch(base, `/api/ideas/${created.id}`, {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ status: 'developing', body: `${created.body}\n补充一条连接。` }),
+ });
+ const updated = (await updatedResponse.json()).idea;
+ assert.equal(updated.status, 'developing');
+ assert.match(updated.body, /补充一条连接/);
+ assert.match(
+ await readFile(
+ path.join(root, '.drafts', 'ideas', 'economics-after-ai', `${created.id}.json`),
+ 'utf8'
+ ),
+ /增长循环/
+ );
+
+ const [bodyUpdate, statusUpdate] = await Promise.all([
+ writerFetch(base, `/api/ideas/${created.id}`, {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ body: `${updated.body}\n并发补充正文。` }),
+ }),
+ writerFetch(base, `/api/ideas/${created.id}`, {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ status: 'planned' }),
+ }),
+ ]);
+ assert.equal(bodyUpdate.status, 200);
+ assert.equal(statusUpdate.status, 200);
+ const afterConcurrentUpdates = await (
+ await writerFetch(base, '/api/ideas?column=economics-after-ai')
+ ).json();
+ assert.equal(afterConcurrentUpdates.ideas[0].status, 'planned');
+ assert.match(afterConcurrentUpdates.ideas[0].body, /并发补充正文/);
+ assert.equal(afterConcurrentUpdates.ideas[0].revision, 4);
+});
+
+test('surfaces malformed idea records without disabling the inbox', async (context) => {
+ const { root } = await fixture();
+ const server = await createWriterServer({ root });
+ context.after(() => server.close());
+ const base = await listen(server);
+ const id = '20260802t000000-deadbeef';
+ const directory = path.join(root, '.drafts', 'ideas', 'economics-after-ai');
+ await mkdir(directory, { recursive: true });
+ await writeFile(path.join(directory, `${id}.json`), '{}\n', 'utf8');
+
+ const response = await writerFetch(base, '/api/ideas?column=economics-after-ai');
+ assert.equal(response.status, 200);
+ const payload = await response.json();
+ assert.deepEqual(payload.ideas, []);
+ assert.equal(payload.invalidRecords.length, 1);
+ assert.equal(payload.invalidRecords[0].id, id);
+ assert.equal((await writerFetch(base, '/api/state')).status, 200);
+
+ const updateResponse = await writerFetch(base, `/api/ideas/${id}`, {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ status: 'archived' }),
+ });
+ assert.equal(updateResponse.status, 422);
+});
+
+test('does not force book and research columns into the blog article adapter', async (context) => {
+ const { root } = await fixture();
+ const server = await createWriterServer({ root });
+ context.after(() => server.close());
+ const base = await listen(server);
+
+ const response = await writerFetch(base, '/api/drafts', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ columnId: 'economics-after-ai',
+ title: '不应成为博客',
+ year: '2026',
+ date: '2026-08-02',
+ slug: 'not-a-blog',
+ }),
+ });
+ assert.equal(response.status, 400);
+ await assert.rejects(
+ readFile(path.join(root, '.drafts', 'blog', '2026', 'not-a-blog', 'cn.mdx'))
+ );
+});
+
test('creates a bilingual draft and publishes it without overwriting content', async (context) => {
const { root } = await fixture();
const server = await createWriterServer({ root });
context.after(() => server.close());
const base = await listen(server);
- const createResponse = await fetch(`${base}/api/drafts`, {
+ const createResponse = await writerFetch(base, '/api/drafts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@@ -122,14 +720,16 @@ test('creates a bilingual draft and publishes it without overwriting content', a
assert.equal(createResponse.status, 201);
const id = encodeURIComponent('2026/bilingual-draft');
- const stateResponse = await fetch(`${base}/api/state`);
+ const stateResponse = await writerFetch(base, '/api/state');
const state = await stateResponse.json();
const created = state.drafts.find((draft) => draft.id === '2026/bilingual-draft');
assert.deepEqual(created.languages, ['cn', 'en']);
- const workspaceResponse = await fetch(`${base}/api/drafts/${id}/workspace`);
+ const workspaceResponse = await writerFetch(base, `/api/drafts/${id}/workspace`);
const workspace = await workspaceResponse.json();
assert.equal(workspace.task.stage, 'ideation');
+ assert.equal(workspace.task.schemaVersion, 2);
+ assert.equal(workspace.task.columnId, 'intellipharma');
assert.match(workspace.documents.references.content, /构思与研究记录/);
assert.match(workspace.documents.style.content, /本篇风格指南/);
assert.equal(workspace.skills.draft.command, '$draft-from-outline 2026/bilingual-draft');
@@ -137,23 +737,29 @@ test('creates a bilingual draft and publishes it without overwriting content', a
assert.equal('brief' in workspace.documents, false);
assert.equal('images' in workspace.documents, false);
- const referencesResponse = await fetch(`${base}/api/drafts/${id}/documents/references`, {
+ const referencesResponse = await writerFetch(base, `/api/drafts/${id}/documents/references`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ content: '# 构思与研究记录\n\n- URL: https://example.com\n' }),
+ body: JSON.stringify({
+ content: '# 构思与研究记录\n\n- URL: https://example.com\n',
+ baseHash: workspace.documents.references.hash,
+ }),
});
assert.equal(referencesResponse.status, 200);
- const styleResponse = await fetch(`${base}/api/drafts/${id}/documents/style`, {
+ const styleResponse = await writerFetch(base, `/api/drafts/${id}/documents/style`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ content: '# 本篇风格指南\n\n克制、连续行文。\n' }),
+ body: JSON.stringify({
+ content: '# 本篇风格指南\n\n克制、连续行文。\n',
+ baseHash: workspace.documents.style.hash,
+ }),
});
assert.equal(styleResponse.status, 200);
- const refreshedWorkspace = await (await fetch(`${base}/api/drafts/${id}/workspace`)).json();
+ const refreshedWorkspace = await (await writerFetch(base, `/api/drafts/${id}/workspace`)).json();
assert.match(refreshedWorkspace.documents.style.content, /克制、连续行文/);
- const stageResponse = await fetch(`${base}/api/drafts/${id}/task`, {
+ const stageResponse = await writerFetch(base, `/api/drafts/${id}/task`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ stage: 'outline' }),
@@ -161,7 +767,7 @@ test('creates a bilingual draft and publishes it without overwriting content', a
const stage = await stageResponse.json();
assert.equal(stage.task.stage, 'outline');
- const imageResponse = await fetch(`${base}/api/drafts/${id}/assets`, {
+ const imageResponse = await writerFetch(base, `/api/drafts/${id}/assets`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@@ -172,10 +778,12 @@ test('creates a bilingual draft and publishes it without overwriting content', a
});
assert.equal(imageResponse.status, 201);
- const servedImage = await fetch(`${base}/api/drafts/${id}/assets/inline-01.png`);
+ const servedImage = await writerFetch(base, `/api/drafts/${id}/assets/inline-01.png`);
assert.equal(servedImage.headers.get('content-type'), 'image/png');
- const publishResponse = await fetch(`${base}/api/drafts/${id}/publish`, { method: 'POST' });
+ const publishResponse = await writerFetch(base, `/api/drafts/${id}/publish`, {
+ method: 'POST',
+ });
assert.equal(publishResponse.status, 201);
assert.equal(
await readFile(
@@ -202,7 +810,7 @@ test('creates a bilingual draft and publishes it without overwriting content', a
true
);
- const secondPublish = await fetch(`${base}/api/drafts/${id}/publish`, { method: 'POST' });
+ const secondPublish = await writerFetch(base, `/api/drafts/${id}/publish`, { method: 'POST' });
assert.equal(secondPublish.status, 409);
});
@@ -226,10 +834,11 @@ test('migrates legacy stages and notes without deleting old task files', async (
const server = await createWriterServer({ root });
context.after(() => server.close());
const base = await listen(server);
- await fetch(`${base}/api/state`);
+ await writerFetch(base, '/api/state');
- const workspaceResponse = await fetch(
- `${base}/api/drafts/${encodeURIComponent('2026/legacy-workflow')}/workspace`
+ const workspaceResponse = await writerFetch(
+ base,
+ `/api/drafts/${encodeURIComponent('2026/legacy-workflow')}/workspace`
);
const workspace = await workspaceResponse.json();
assert.equal(workspace.task.stage, 'ideation');
@@ -250,7 +859,7 @@ test('rolls back the site copy when the repository content audit fails', async (
context.after(() => server.close());
const base = await listen(server);
- await fetch(`${base}/api/drafts`, {
+ await writerFetch(base, '/api/drafts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@@ -263,7 +872,9 @@ test('rolls back the site copy when the repository content audit fails', async (
});
const id = encodeURIComponent('2026/rollback-draft');
- const publishResponse = await fetch(`${base}/api/drafts/${id}/publish`, { method: 'POST' });
+ const publishResponse = await writerFetch(base, `/api/drafts/${id}/publish`, {
+ method: 'POST',
+ });
assert.equal(publishResponse.status, 422);
await assert.rejects(
readFile(path.join(root, 'src', 'content', 'blog', '2026', 'rollback-draft', 'cn.mdx'))
diff --git a/tools/writer-studio/task-workspace.mjs b/tools/writer-studio/task-workspace.mjs
index 4295f68..110f72e 100644
--- a/tools/writer-studio/task-workspace.mjs
+++ b/tools/writer-studio/task-workspace.mjs
@@ -1,5 +1,17 @@
-import { access, cp, mkdir, readFile, readdir, stat, writeFile } from 'node:fs/promises';
+import { randomUUID } from 'node:crypto';
+import {
+ access,
+ cp,
+ mkdir,
+ readFile,
+ readdir,
+ rename,
+ stat,
+ unlink,
+ writeFile,
+} from 'node:fs/promises';
import path from 'node:path';
+import { DEFAULT_COLUMN_ID, requireColumn } from './columns.mjs';
export const TASK_STAGES = [
{ id: 'ideation', label: '构思与研究' },
@@ -21,16 +33,19 @@ const legacyStageMap = {
images: 'draft',
};
-const imageExtensions = new Set(['.avif', '.gif', '.jpeg', '.jpg', '.png', '.webp']);
+const imageExtensions = new Set(['.avif', '.gif', '.jpeg', '.jpg', '.png', '.svg', '.webp']);
+const uploadImageExtensions = new Set(['.avif', '.gif', '.jpeg', '.jpg', '.png', '.webp']);
const imageContentTypes = {
'.avif': 'image/avif',
'.gif': 'image/gif',
'.jpeg': 'image/jpeg',
'.jpg': 'image/jpeg',
'.png': 'image/png',
+ '.svg': 'image/svg+xml',
'.webp': 'image/webp',
};
const stageIds = new Set(TASK_STAGES.map((stage) => stage.id));
+const taskFileQueues = new Map();
function draftDirectory(root, id) {
return path.join(root, '.drafts', 'blog', ...id.split('/'));
@@ -49,6 +64,84 @@ function now() {
return new Date().toISOString();
}
+async function atomicWriteTextFile(target, content) {
+ const directory = path.dirname(target);
+ const temporary = path.join(
+ directory,
+ `.${path.basename(target)}.${process.pid}.${randomUUID()}.tmp`
+ );
+ try {
+ await writeFile(temporary, content, { encoding: 'utf8', flag: 'wx' });
+ await rename(temporary, target);
+ } catch (error) {
+ await unlink(temporary).catch(() => {});
+ throw error;
+ }
+}
+
+async function withTaskFileQueue(taskFile, operation) {
+ const previous = taskFileQueues.get(taskFile) || Promise.resolve();
+ let release;
+ const current = new Promise((resolve) => {
+ release = resolve;
+ });
+ taskFileQueues.set(taskFile, current);
+ await previous;
+ try {
+ return await operation();
+ } finally {
+ release();
+ if (taskFileQueues.get(taskFile) === current) taskFileQueues.delete(taskFile);
+ }
+}
+
+function parseTaskFile(content) {
+ let task;
+ try {
+ task = JSON.parse(content);
+ } catch (cause) {
+ const error = new Error('task.json contains invalid JSON.', { cause });
+ error.code = 'MALFORMED_TASK_METADATA';
+ throw error;
+ }
+ if (!task || Array.isArray(task) || typeof task !== 'object') {
+ const error = new TypeError('task.json must contain a JSON object.');
+ error.code = 'MALFORMED_TASK_METADATA';
+ throw error;
+ }
+ return task;
+}
+
+function taskFileFailure(error, task = { stage: 'ideation' }) {
+ const missing = error?.code === 'ENOENT';
+ const malformed = error?.code === 'MALFORMED_TASK_METADATA';
+ const status = missing ? 'missing' : malformed ? 'malformed' : 'unreadable';
+ const code = missing
+ ? 'MISSING_TASK_METADATA'
+ : malformed
+ ? 'MALFORMED_TASK_METADATA'
+ : 'UNREADABLE_TASK_METADATA';
+ const message = missing
+ ? 'task.json is missing.'
+ : malformed
+ ? 'task.json is invalid; the original file was preserved.'
+ : 'task.json could not be read; the original file was preserved.';
+ return {
+ status,
+ task,
+ diagnostic: {
+ code,
+ message,
+ cause:
+ error?.cause instanceof Error
+ ? error.cause.message
+ : error instanceof Error
+ ? error.message
+ : String(error),
+ },
+ };
+}
+
function taskEntryTemplate(id, title) {
return (
`# Article task: ${title}\n\n` +
@@ -103,44 +196,62 @@ async function initialReferences(directory, title) {
export async function ensureTaskWorkspace(root, id, metadata = {}) {
const directory = draftDirectory(root, id);
const title = metadata.title || id.split('/')[1];
+ const columnId = requireColumn(metadata.columnId || DEFAULT_COLUMN_ID).id;
+ const fallbackTask = {
+ schemaVersion: 2,
+ id,
+ title,
+ columnId,
+ contentKind: 'bilingual_article',
+ stage: 'ideation',
+ };
await mkdir(path.join(directory, 'images'), { recursive: true });
const taskFile = path.join(directory, 'task.json');
- if (!(await exists(taskFile))) {
- await writeFile(
- taskFile,
- `${JSON.stringify(
- {
- id,
- title,
- stage: 'ideation',
+ const result = await withTaskFileQueue(taskFile, async () => {
+ let content;
+ try {
+ content = await readFile(taskFile, 'utf8');
+ } catch (error) {
+ if (error?.code === 'ENOENT') {
+ const task = {
+ ...fallbackTask,
createdAt: now(),
updatedAt: now(),
- },
- null,
- 2
- )}\n`,
- 'utf8'
- );
- } else {
- try {
- const existingTask = JSON.parse(await readFile(taskFile, 'utf8'));
- const stage = normalizeStage(existingTask.stage);
- if (stage !== existingTask.stage) {
- await writeFile(
- taskFile,
- `${JSON.stringify({ ...existingTask, id, title: existingTask.title || title, stage }, null, 2)}\n`,
- 'utf8'
- );
+ };
+ await atomicWriteTextFile(taskFile, `${JSON.stringify(task, null, 2)}\n`);
+ return { status: 'created', task };
}
- } catch {
- await writeFile(
- taskFile,
- `${JSON.stringify({ id, title, stage: 'ideation', createdAt: now(), updatedAt: now() }, null, 2)}\n`,
- 'utf8'
- );
+ return taskFileFailure(error, fallbackTask);
+ }
+
+ let existingTask;
+ try {
+ existingTask = parseTaskFile(content);
+ } catch (error) {
+ return taskFileFailure(error, fallbackTask);
}
- }
+
+ const stage = normalizeStage(existingTask.stage);
+ const task = {
+ ...existingTask,
+ schemaVersion: 2,
+ id,
+ title: existingTask.title || title,
+ columnId: existingTask.columnId || columnId,
+ contentKind: existingTask.contentKind || 'bilingual_article',
+ stage,
+ };
+ const migrated =
+ stage !== existingTask.stage ||
+ existingTask.schemaVersion !== 2 ||
+ !existingTask.columnId ||
+ !existingTask.contentKind;
+ if (migrated) {
+ await atomicWriteTextFile(taskFile, `${JSON.stringify(task, null, 2)}\n`);
+ }
+ return { status: migrated ? 'migrated' : 'ready', task };
+ });
const entryFile = path.join(directory, 'TASK.md');
if (!(await exists(entryFile))) {
@@ -159,36 +270,54 @@ export async function ensureTaskWorkspace(root, id, metadata = {}) {
);
}
}
+
+ return result;
}
-async function readTaskFile(directory) {
- const file = path.join(directory, 'task.json');
+async function readTaskFile(directory, includeState = false) {
+ const taskFile = path.join(directory, 'task.json');
+ let state;
try {
- const task = JSON.parse(await readFile(file, 'utf8'));
- return { ...task, stage: normalizeStage(task.stage) };
- } catch {
- return { stage: 'ideation' };
+ const task = parseTaskFile(await readFile(taskFile, 'utf8'));
+ state = { status: 'ready', task: { ...task, stage: normalizeStage(task.stage) } };
+ } catch (error) {
+ state = taskFileFailure(error);
}
+ return includeState ? state : state.task;
}
-async function listAssets(directory) {
- const imagesDirectory = path.join(directory, 'images');
- await mkdir(imagesDirectory, { recursive: true });
- const entries = await readdir(imagesDirectory, { withFileTypes: true });
- const assets = [];
- for (const entry of entries) {
- const extension = path.extname(entry.name).toLowerCase();
- if (!entry.isFile() || !imageExtensions.has(extension)) continue;
- const file = path.join(imagesDirectory, entry.name);
- const details = await stat(file);
- assets.push({ name: entry.name, size: details.size, modifiedAt: details.mtimeMs });
+async function listAssets(directory, fallbackDirectory = '') {
+ const assets = new Map();
+ for (const [sourceDirectory, origin] of [
+ [directory, 'draft'],
+ [fallbackDirectory, 'site'],
+ ]) {
+ if (!sourceDirectory) continue;
+ const imagesDirectory = path.join(sourceDirectory, 'images');
+ const entries = await readdir(imagesDirectory, { withFileTypes: true }).catch((error) => {
+ if (error?.code === 'ENOENT') return [];
+ throw error;
+ });
+ for (const entry of entries) {
+ const extension = path.extname(entry.name).toLowerCase();
+ if (!entry.isFile() || !imageExtensions.has(extension) || assets.has(entry.name)) continue;
+ const file = path.join(imagesDirectory, entry.name);
+ const details = await stat(file);
+ assets.set(entry.name, {
+ name: entry.name,
+ size: details.size,
+ modifiedAt: details.mtimeMs,
+ origin,
+ });
+ }
}
- return assets.sort((left, right) => left.name.localeCompare(right.name));
+ return [...assets.values()].sort((left, right) => left.name.localeCompare(right.name));
}
export async function readTaskWorkspace(root, id) {
const directory = draftDirectory(root, id);
- const task = await readTaskFile(directory);
+ const siteDirectory = path.join(root, 'src', 'content', 'blog', ...id.split('/'));
+ const taskState = await readTaskFile(directory, true);
const documents = {};
for (const [key, document] of Object.entries(TASK_DOCUMENTS)) {
const file = path.join(directory, document.file);
@@ -200,10 +329,14 @@ export async function readTaskWorkspace(root, id) {
};
}
return {
- task,
+ task: taskState.task,
+ taskMetadata: {
+ status: taskState.status,
+ ...(taskState.diagnostic ? { diagnostic: taskState.diagnostic } : {}),
+ },
stages: TASK_STAGES,
documents,
- assets: await listAssets(directory),
+ assets: await listAssets(directory, siteDirectory),
skills: {
research: {
name: 'start-article-research',
@@ -224,7 +357,9 @@ export async function writeTaskDocument(root, id, key, content) {
error.status = 400;
throw error;
}
- await writeFile(path.join(draftDirectory(root, id), document.file), content, 'utf8');
+ const directory = draftDirectory(root, id);
+ const target = path.join(directory, document.file);
+ await atomicWriteTextFile(target, content);
}
export async function updateTaskStage(root, id, stage) {
@@ -234,28 +369,50 @@ export async function updateTaskStage(root, id, stage) {
throw error;
}
const directory = draftDirectory(root, id);
- const current = await readTaskFile(directory);
- const task = { ...current, id, stage, updatedAt: now() };
- await writeFile(path.join(directory, 'task.json'), `${JSON.stringify(task, null, 2)}\n`, 'utf8');
- return task;
+ const taskFile = path.join(directory, 'task.json');
+ return withTaskFileQueue(taskFile, async () => {
+ const current = await readTaskFile(directory, true);
+ if (current.status !== 'ready') {
+ const error = new Error(
+ current.status === 'malformed'
+ ? 'Article stage cannot be updated because task.json is invalid. Repair task.json and try again.'
+ : 'Article stage cannot be updated because task.json is missing or unreadable.'
+ );
+ error.status = 422;
+ error.details = current.diagnostic;
+ throw error;
+ }
+ const task = { ...current.task, id, stage, updatedAt: now() };
+ await atomicWriteTextFile(taskFile, `${JSON.stringify(task, null, 2)}\n`);
+ return task;
+ });
}
-function safeAssetName(value) {
- const name = path
- .basename(String(value || ''))
- .toLowerCase()
- .replace(/[^a-z0-9._-]+/g, '-');
- const extension = path.extname(name);
- if (!name || name.startsWith('.') || !imageExtensions.has(extension)) {
+function safeAssetName(value, allowedExtensions = imageExtensions, normalize = false) {
+ const basename = path.basename(String(value || '')).normalize('NFC');
+ const candidate = normalize ? basename.toLocaleLowerCase('en-US') : basename;
+ const originalExtension = path.extname(candidate);
+ const extension = originalExtension.toLowerCase();
+ if (!candidate || !allowedExtensions.has(extension)) {
const error = new Error('Image must be AVIF, GIF, JPEG, PNG, or WebP.');
error.status = 400;
throw error;
}
- return name;
+ const stem = candidate
+ .slice(0, -originalExtension.length)
+ .replace(/[^\p{L}\p{M}\p{N}._-]+/gu, '-')
+ .replace(/-+/g, '-')
+ .replace(/^[-.]+|[-.]+$/g, '');
+ if (candidate.startsWith('.') || !/[\p{L}\p{N}]/u.test(stem)) {
+ const error = new Error('Image filename must contain at least one letter or number.');
+ error.status = 400;
+ throw error;
+ }
+ return `${stem}${normalize ? extension : originalExtension}`;
}
export async function saveTaskAsset(root, id, input) {
- const name = safeAssetName(input.name);
+ const name = safeAssetName(input.name, uploadImageExtensions, true);
const bytes = Buffer.from(String(input.base64 || ''), 'base64');
if (bytes.length === 0 || bytes.length > 8 * 1024 * 1024) {
const error = new Error('Image must be between 1 byte and 8 MB.');
@@ -264,14 +421,25 @@ export async function saveTaskAsset(root, id, input) {
}
const directory = path.join(draftDirectory(root, id), 'images');
await mkdir(directory, { recursive: true });
- await writeFile(path.join(directory, name), bytes);
+ try {
+ await writeFile(path.join(directory, name), bytes, { flag: 'wx' });
+ } catch (error) {
+ if (error?.code === 'EEXIST') {
+ const conflict = new Error(`Image already exists: ${name}`);
+ conflict.status = 409;
+ throw conflict;
+ }
+ throw error;
+ }
return { name, size: bytes.length };
}
-export function taskAssetPath(root, id, value) {
+export async function taskAssetPath(root, id, value) {
const name = safeAssetName(value);
+ const draftFile = path.join(draftDirectory(root, id), 'images', name);
+ const siteFile = path.join(root, 'src', 'content', 'blog', ...id.split('/'), 'images', name);
return {
- file: path.join(draftDirectory(root, id), 'images', name),
+ file: (await exists(draftFile)) ? draftFile : siteFile,
contentType: imageContentTypes[path.extname(name).toLowerCase()],
};
}