@topmindspace/topmind-writing-skills 0.9.1 → 0.9.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.en.md +4 -2
  3. package/README.md +4 -2
  4. package/lib/host-pack.cjs +11 -1
  5. package/package.json +1 -1
  6. package/runtime/article-renderer.d.mts +21 -0
  7. package/runtime/article-renderer.mjs +225 -4
  8. package/runtime/content-sha256.mjs +19 -0
  9. package/runtime/length-rules.d.mts +24 -0
  10. package/runtime/length-rules.mjs +262 -0
  11. package/runtime/length-ruleset.d.mts +13 -0
  12. package/runtime/length-ruleset.mjs +121 -0
  13. package/topmind-briefs/knowledge/README.md +1 -1
  14. package/topmind-briefs/knowledge/cards/cover-typography.md +6 -0
  15. package/topmind-briefs/knowledge/index.json +2 -2
  16. package/topmind-briefs/references/platform-cover-specs.md +17 -2
  17. package/topmind-cover/README.en.md +5 -2
  18. package/topmind-cover/README.md +4 -1
  19. package/topmind-cover/SKILL.md +7 -6
  20. package/topmind-cover/knowledge/README.md +1 -1
  21. package/topmind-cover/knowledge/cards/cover-typography.md +6 -0
  22. package/topmind-cover/knowledge/index.json +2 -2
  23. package/topmind-cover/package.json +2 -2
  24. package/topmind-cover/references/platform-cover-specs.md +17 -2
  25. package/topmind-cover/references/typography-system.md +10 -0
  26. package/topmind-cover/scripts/crop-cover.py +18 -2
  27. package/topmind-cover/scripts/negative_tests.py +3 -0
  28. package/topmind-cover/scripts/test_crop_cover.py +29 -1
  29. package/topmind-cover/scripts/test_title_font_resolution.py +36 -0
  30. package/topmind-cover/scripts/title-engine.py +34 -8
  31. package/topmind-format/knowledge/README.md +1 -1
  32. package/topmind-format/knowledge/cards/cover-typography.md +6 -0
  33. package/topmind-format/knowledge/index.json +2 -2
  34. package/topmind-poster/knowledge/README.md +1 -1
  35. package/topmind-poster/knowledge/cards/cover-typography.md +6 -0
  36. package/topmind-poster/knowledge/index.json +2 -2
  37. package/topmind-poster/references/platform-cover-specs.md +17 -2
  38. package/topmind-revise/knowledge/README.md +1 -1
  39. package/topmind-revise/knowledge/cards/cover-typography.md +6 -0
  40. package/topmind-revise/knowledge/index.json +2 -2
  41. package/topmind-viral-posts/knowledge/README.md +1 -1
  42. package/topmind-viral-posts/knowledge/cards/cover-typography.md +6 -0
  43. package/topmind-viral-posts/knowledge/index.json +2 -2
  44. package/topmind-wechat-post/knowledge/README.md +1 -1
  45. package/topmind-wechat-post/knowledge/cards/cover-typography.md +6 -0
  46. package/topmind-wechat-post/knowledge/index.json +2 -2
  47. package/topmind-wechat-post/references/platform-cover-specs.md +17 -2
  48. package/topmind-x-article/knowledge/README.md +1 -1
  49. package/topmind-x-article/knowledge/cards/cover-typography.md +6 -0
  50. package/topmind-x-article/knowledge/index.json +2 -2
  51. package/topmind-x-article/references/platform-cover-specs.md +17 -2
package/CHANGELOG.md CHANGED
@@ -1,3 +1,15 @@
1
+ ## [0.9.3] - 2026-11-09
2
+
3
+ - `topmind-cover` 0.5.1:小红书与抖音横版先构图中央 4:3,按需保留 16:9 两侧场景;新增 `landscape-pair` 双尺寸导出。区分成片画幅、封面选择框与列表露出,记录公开证据和未实测入口。
4
+ - 系列封面按主体安排角色名、动作词和字效,增加缩略图与实际字重核对。标题引擎在 macOS 可回退到系统中文字体,列出实际字体字重和集合索引。
5
+ - 更新双语使用说明、共享知识卡和能力说明;自动验证中心裁切、字体回退及封面工具在独立 ZIP、宿主包和 npm 包中的交付。
6
+
7
+ ## [0.9.2] - 2026-10-31
8
+
9
+ - 宿主包运行时显式提供文章渲染、从源稿渲染、本地文本提取、长度评估和长度规则集评估。文章结果绑定所选稿与产物的 SHA-256,可选绑定创作快照指纹。
10
+ - 长度规则集只接受调用方传入的 JSON,先校验结构和日期再报告。本包不附带、也不启用真实平台规则。Unicode 码点上限不能代替 X 加权长度或编辑器里的最终核对。
11
+ - 八个技能的版本不变。
12
+
1
13
  ## [0.9.1] - 2026-10-28
2
14
 
3
15
  - 安装器新增 `install all`、`update` 与 `status`:整套首次安装只需一条命令,后续更新只处理已装技能,状态直接显示安装版本与包快照。
package/README.en.md CHANGED
@@ -30,6 +30,8 @@ Open a Markdown document and choose **Writing & delivery** from the article tool
30
30
 
31
31
  Built-in HTML/text typography requires no Python. Preview and copy use the same output; changed sources or images require regeneration. Diagnostics identify missing images, diagram source, extended containers and table paste limitations. AI images, PNG rendering and publishing require their respective tools. Copying does not publish; wording scores do not decide editorial quality.
32
32
 
33
+ The host pack also renders from source bytes, extracts local text from clipboard HTML, and evaluates a length rule set. The length entry accepts only caller-supplied JSON. This package does not ship or enable production platform rules. Fields, dates and reports are in [the length rule-set contract](docs/length-ruleset-v1.md). A Unicode code-point ceiling is not X weighted length and is not the target editor's final check. JavaScript and Python renderer differences are in [the capability matrix](docs/renderer-capability-matrix.md).
34
+
33
35
  About & updates tracks the application, core skills, independent writing pack and integrations separately. Writing resources can update independently; compiled renderer code follows the host release. Managed packs are isolated, local modifications block replacement and failed installs preserve the previous version. Supported Desktop installs can download an update and explicitly restart; manual downloads remain available when signing or the installation channel prevents automatic updates.
34
36
 
35
37
  ## More choices, concrete references
@@ -52,7 +54,7 @@ See the [practice and specimens](./shared/references/creative-practice.md) and [
52
54
  | Long-form · WeChat | [`topmind-wechat-post`](./topmind-wechat-post/) | WeChat articles: three quality gates, inline-image layout, publish checklist | "写公众号", "公众号排版" |
53
55
  | Formatting | [`topmind-format`](./topmind-format/) | Re-layout without changing a word; stitch alternative openings into full posts | "转段子", "排一下版" |
54
56
  | Revision | [`topmind-revise`](./topmind-revise/) | Structure → facts → logic → de-AI → fidelity audit; quick review for replies | "改稿", "去 AI 味", "看看这条回复" |
55
- | Covers | [`topmind-cover`](./topmind-cover/) | Xiaohongshu, Douyin image posts, WeChat and X covers; sourced guidance and crop presets | "做个封面" |
57
+ | Covers | [`topmind-cover`](./topmind-cover/) | Xiaohongshu, Douyin, WeChat and X covers, including video thumbnails and paired crops | "做个封面" |
56
58
  | Long images | [`topmind-poster`](./topmind-poster/) | HTML → hi-res long image / poster / infographic with exact text and numbers | "出张长图" |
57
59
 
58
60
  ## Creative directions and on-demand knowledge
@@ -74,7 +76,7 @@ Cover resources include 22 adaptable style entries, 16 composition relationships
74
76
 
75
77
  The [platform reference](./shared/references/platform-cover-specs.md) records public sources, verification dates and untested details. Start Xiaohongshu image notes at 3:4; check ordinary Douyin image posts separately from commerce content; distinguish WeChat article landscape / square covers from image messages. Dimensions are package export presets; current editors and user requirements take priority.
76
78
 
77
- `topmind-cover` provides `article`, `x-post`, `xiaohongshu`, `douyin` and `wechat` presets. The WeChat preset exports landscape and square versions, with adjustable crop alignment. Recompose portrait and landscape versions when needed. Dense content pages use `topmind-poster`; platform adaptation does not authorize publishing.
79
+ The `landscape-pair` preset in `topmind-cover` exports a centered 4:3 cover and the full 16:9 master. Compose the subject and title in the 4:3 window and extend the scene at both sides. Start portrait covers at 3:4 and export 9:16 separately when the publishing surface calls for it. Check the video frame, cover picker, and feed preview separately. Article, image-post, X, and WeChat presets continue to serve their own surfaces.
78
80
 
79
81
  ## Duanzi: 14 patterns
80
82
 
package/README.md CHANGED
@@ -30,6 +30,8 @@ npx @topmindspace/topmind-writing-skills@latest update # 以后只更新已
30
30
 
31
31
  内置 HTML/文本排版无需 Python,预览与复制同源;源稿或图片改变后需要重新生成。扩展容器、图表源码、缺图和平台表格粘贴限制会显示诊断。封面生图、PNG 和外部发布依赖实际工具,复制不等于已发布。审校不使用“AI 味分数”作为定稿门槛。
32
32
 
33
+ 宿主包运行时另外提供从源稿生成文章结果、从剪贴板 HTML 提取本地文本,以及长度规则集评估。长度入口只接受调用方传入的 JSON,本包不附带、也不启用真实平台规则。规则字段、日期和报告见 [长度规则集运行契约](docs/length-ruleset-v1.md)。Unicode 码点上限不能代替 X 加权长度或目标编辑器里的最终核对。JavaScript 与 Python 渲染器的差异见 [能力矩阵](docs/renderer-capability-matrix.md)。
34
+
33
35
  “关于与更新”分别展示 Desktop、核心技能、独立写作包和集成组件。写作资源可独立更新;编译进宿主的排版代码随宿主升级。受管包按身份隔离,覆盖本地定制会被阻止,失败保留旧版。Desktop 支持标准更新器下载后显式重启;签名、平台或渠道不支持时保留手动下载。
34
36
 
35
37
  ## 加选项,让经验帮助创作
@@ -52,7 +54,7 @@ npx @topmindspace/topmind-writing-skills@latest update # 以后只更新已
52
54
  | 长文 · 公众号 | [`topmind-wechat-post`](./topmind-wechat-post/) | 公众号长文:质量三关、图片内嵌排版、发布清单 | 「写公众号」「公众号排版」 |
53
55
  | 格式化 | [`topmind-format`](./topmind-format/) | 现成文字只改排版不改字:转段子、格式化成短文、几个开头拼成品 | 「转段子」「排一下版」 |
54
56
  | 改稿 | [`topmind-revise`](./topmind-revise/) | 结构 → 事实 → 逻辑 → 去 AI 味 → 保真审计;也能快审一条回复 | 「改稿」「去 AI 味」「看看这条回复」 |
55
- | 封面 | [`topmind-cover`](./topmind-cover/) | 小红书、抖音图文、公众号与 X 封面:平台适配、核字与多比例裁切 | 「做个封面」「头图」 |
57
+ | 封面 | [`topmind-cover`](./topmind-cover/) | 小红书、抖音、公众号与 X 封面,含视频缩略图、叠字和多比例裁切 | 「做个封面」「头图」 |
56
58
  | 长图 | [`topmind-poster`](./topmind-poster/) | HTML → 高清长图 / 海报 / 信息图,文字数字逐字正确 | 「出张长图」「做个榜单图」 |
57
59
 
58
60
  **短文** = X 纯文本帖,1000 字以内都行;**长文** = X Article 或公众号长文;**段子** = 以观察、反差或幽默推进的短帖,排版按节奏选择。
@@ -76,7 +78,7 @@ npm run knowledge:check
76
78
 
77
79
  [平台参考](./shared/references/platform-cover-specs.md) 记录公开来源、核验日期和未实测项:小红书图文可从 3:4 起步,抖音普通图文与商城内容分别核对,公众号普通文章宽图 / 方图与图片消息分开处理。尺寸是本包导出预设,当前发布入口与用户要求优先。
78
80
 
79
- `topmind-cover` 支持 `article`、`x-post`、`xiaohongshu`、`douyin` 和 `wechat` 预设;`wechat` 另给宽图与方图,裁切方向可调整。横竖版需要时分别构图,不用固定安全区或缺少证据的点击率约束设计。密集内容页用 `topmind-poster`,跨平台不自动扩展发布权限。
81
+ `topmind-cover` 的 `landscape-pair` 从同一张 16:9 母图导出中央 4:3 主版和完整 16:9 备版。先在 4:3 框内放主体和标题,两侧用场景延展。竖版以 3:4 构图为起点,按发布入口另取 9:16。成片画幅、封面选择和列表露出分别核对。文章、X 短帖、普通图文和微信宽图及方图继续使用各自预设。
80
82
 
81
83
  ## 段子:14 种写法
82
84
 
package/lib/host-pack.cjs CHANGED
@@ -28,7 +28,17 @@ function buildHostPack(root) {
28
28
  if (!/^---\r?\n/u.test(raw) || !/^name:\s*/mu.test(raw)) throw new Error(`Invalid skill: ${id}`);
29
29
  return { id, name: id, description: meta.description || id, version: meta.version, packId: 'topmind-writing-skills' };
30
30
  });
31
- const manifest = { schema_version: 1, name: 'topmind-writing-skills', version: pkg.version, license: pkg.license, repository: pkg.repository, entry_files: ids.map(id => `${id}/SKILL.md`), files, runtime: { api: 1, entry: 'runtime/article-renderer.mjs', update: 'host-release' } };
31
+ const capabilities = [
32
+ { id: 'article.render', entry: 'runtime/article-renderer.mjs', export: 'prepareArticleResult' },
33
+ { id: 'article.renderFromSource', entry: 'runtime/article-renderer.mjs', export: 'prepareArticleResultFromSource' },
34
+ { id: 'article.localTextDocument', entry: 'runtime/article-renderer.mjs', export: 'articleLocalTextDocument' },
35
+ { id: 'length.evaluate', entry: 'runtime/length-rules.mjs', export: 'evaluateLengthRules' },
36
+ { id: 'length.ruleset.evaluate', entry: 'runtime/length-ruleset.mjs', export: 'evaluateLengthRuleSet' },
37
+ ];
38
+ for (const capability of capabilities) {
39
+ if (!Object.hasOwn(files, capability.entry)) throw new Error(`Missing runtime capability entry: ${capability.entry}`);
40
+ }
41
+ const manifest = { schema_version: 1, name: 'topmind-writing-skills', version: pkg.version, license: pkg.license, repository: pkg.repository, entry_files: ids.map(id => `${id}/SKILL.md`), files, runtime: { api: 1, entry: 'runtime/article-renderer.mjs', capabilities, update: 'host-release' } };
32
42
  const digest = hash(JSON.stringify(manifest));
33
43
  return { manifest: { ...manifest, digest }, snapshot: { id: manifest.name, version: pkg.version, source: 'https://github.com/topmindspace/topmind-writing-skills', license: pkg.license, digest, skills, resources } };
34
44
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@topmindspace/topmind-writing-skills",
3
- "version": "0.9.1",
3
+ "version": "0.9.3",
4
4
  "description": "TopMindSpace 写作技能合集 — 通过 npm / npx 安装技能(公众号长文、X 长文、干货短文、X 引流短帖、改稿与质检、封面图、长图海报)。只出稿与发布包,不代发。",
5
5
  "license": "MIT",
6
6
  "author": "TopMindspace",
@@ -1,9 +1,30 @@
1
1
  export type WritingPlatform = 'wechat' | 'x' | 'text';
2
2
  export type WritingTheme = 'minimal-ink' | 'tech-blue' | 'newsprint' | 'graphite';
3
3
  export const WRITING_THEMES: Record<WritingTheme, { ink: string; accent: string; paper: string; muted: string }>;
4
+ export const ARTICLE_RENDERER_PROVIDER: Readonly<{ id: string; version: string }>;
4
5
  export const WRITING_ACTIONS: ReadonlyArray<{ id: string; zh: string; en: string; prompt: string; promptEn: string }>;
5
6
  export function stripFrontmatter(value: string): string;
6
7
  export interface ArticleDelivery { platform: WritingPlatform; theme: WritingTheme; html: string; body: string; text: string; checklist: string; images: Array<{ source: string; alt: string; status: string }>; diagnostics: Array<{ code: string; source?: string }> }
7
8
  export function renderArticle(input: { markdown: string; platform?: WritingPlatform; theme?: WritingTheme; title?: string; images?: Record<string, string> }): ArticleDelivery;
8
9
  export function resolveArticleImage(sourcePath: string, reference: string): string;
9
10
  export function prepareArticle(input: { sourcePath: string; markdown: string; platform?: WritingPlatform; theme?: WritingTheme; title?: string }, readImage: (relative: string) => Promise<string>): Promise<ArticleDelivery>;
11
+ export interface ArticleResult {
12
+ schemaVersion: 1;
13
+ capabilityId: 'article.render';
14
+ provider: Readonly<{ id: string; version: string }>;
15
+ status: 'ok' | 'warning' | 'error';
16
+ input: { fingerprint: string | null; target?: { platform: WritingPlatform; theme: WritingTheme };
17
+ source?: { relativePath: string; sha256: string };
18
+ imageBytes?: Array<{ relativePath: string; sha256: string }>;
19
+ creationSnapshotFingerprint?: string; localTextTitle?: string | null;
20
+ renderParameters?: { theme: WritingTheme; title: string } };
21
+ artifacts: Array<{ role: 'preview' | 'clipboard-html' | 'source-markdown' | 'image-checklist' | 'local-clipboard-text'; format: string; content: string; sha256: string }>;
22
+ images: ArticleDelivery['images'];
23
+ diagnostics: Array<{ code: string; severity: 'warning' | 'error'; message: string; location?: { source: string } }>;
24
+ }
25
+ export function renderArticleResult(input?: { markdown?: string; platform?: WritingPlatform; theme?: WritingTheme; title?: string; sourcePath?: string; images?: Record<string, string> }): Promise<ArticleResult>;
26
+ export function prepareArticleResult(input: { sourcePath: string; markdown: string; platform?: WritingPlatform; theme?: WritingTheme; title?: string }, readImage: (relative: string) => Promise<string>): Promise<ArticleResult>;
27
+ export function prepareArticleResultFromSource(input: { sourcePath: string; sourceBytes: Uint8Array;
28
+ platform?: WritingPlatform; theme?: WritingTheme; title?: string; creationSnapshotFingerprint?: string },
29
+ readImage: (relative: string) => Promise<string>): Promise<ArticleResult>;
30
+ export function articleLocalTextDocument(result: ArticleResult): { text_kind: 'platform_text'; title?: string; body: string } | null;
@@ -1,10 +1,13 @@
1
1
  /** Portable host renderer. Canonical source: topmind-writing-skills. No IO or executable HTML. */
2
+ import { sha256, sha256Bytes } from './content-sha256.mjs';
2
3
  export const WRITING_THEMES = Object.freeze({
3
4
  'minimal-ink': { ink: '#252525', accent: '#955247', paper: '#ffffff', muted: '#666666' },
4
5
  'tech-blue': { ink: '#172033', accent: '#0369a1', paper: '#ffffff', muted: '#536174' },
5
6
  newsprint: { ink: '#24211c', accent: '#80502a', paper: '#fffdf8', muted: '#655f55' },
6
7
  graphite: { ink: '#222222', accent: '#444444', paper: '#ffffff', muted: '#666666' },
7
8
  });
9
+ /** Version of the renderer's output semantics, independent from the npm package version. */
10
+ export const ARTICLE_RENDERER_PROVIDER = Object.freeze({ id: 'topmind-writing-skills/article-renderer', version: '1.1.0' });
8
11
  export const WRITING_ACTIONS = Object.freeze([
9
12
  { id: 'topmind-revise', zh: '改稿', en: 'Revise', prompt: '按 topmind-revise 审校当前内容,保留事实、数字、归因和作者原意。' },
10
13
  { id: 'topmind-format', zh: '只排版', en: 'Format', prompt: '按 topmind-format 只调整当前内容的排版,不改写文字。' },
@@ -20,8 +23,17 @@ export function stripFrontmatter(value) {
20
23
  return String(value || '').replace(/^\uFEFF/u, '').replace(/\r\n?/gu, '\n').replace(/^---\n[\s\S]*?\n---(?:\n|$)/u, '');
21
24
  }
22
25
  export function resolveArticleImage(sourcePath, reference) {
23
- if (!sourcePath || /[\\:]|^\//u.test(sourcePath) || /[\\:]|^\//u.test(reference)) throw new Error('Invalid workspace image path');
24
- const parts = sourcePath.split('/').slice(0, -1);
26
+ if (typeof sourcePath !== 'string' || typeof reference !== 'string'
27
+ || !sourcePath || !reference || /[\\:\u0000-\u001f]|^\//u.test(sourcePath)
28
+ || /[\\:\u0000-\u001f]|^\//u.test(reference)) throw new Error('Invalid workspace image path');
29
+ const parts = [];
30
+ for (const segment of sourcePath.split('/')) {
31
+ if (!segment || segment === '.') continue;
32
+ if (segment === '..') { if (!parts.length) throw new Error('Source escapes workspace'); parts.pop(); }
33
+ else parts.push(segment);
34
+ }
35
+ if (!parts.length) throw new Error('Invalid workspace image path');
36
+ parts.pop();
25
37
  for (const segment of reference.split('/')) {
26
38
  if (!segment || segment === '.') continue;
27
39
  if (segment === '..') { if (!parts.length) throw new Error('Image escapes workspace'); parts.pop(); }
@@ -29,14 +41,19 @@ export function resolveArticleImage(sourcePath, reference) {
29
41
  }
30
42
  return parts.join('/');
31
43
  }
32
- export async function prepareArticle(input, readImage) {
44
+ async function collectArticleImages(input, readImage) {
33
45
  const images = Object.create(null);
34
46
  for (const match of String(input.markdown).matchAll(/!\[[^\]]*\]\(([^\s)]+)(?:\s+"[^"]*")?\)/gu)) {
35
47
  const source = match[1];
36
48
  if (/^(https?:|data:)/iu.test(source) || Object.hasOwn(images, source)) continue;
37
- try { images[source] = await readImage(resolveArticleImage(input.sourcePath, source)); }
49
+ const relativePath = resolveArticleImage(input.sourcePath, source);
50
+ try { images[source] = await readImage(relativePath); }
38
51
  catch { images[source] = ''; }
39
52
  }
53
+ return images;
54
+ }
55
+ export async function prepareArticle(input, readImage) {
56
+ const images = await collectArticleImages(input, readImage);
40
57
  return renderArticle({ ...input, images });
41
58
  }
42
59
  function safeUrl(value, image = false) {
@@ -128,3 +145,207 @@ export function renderArticle({ markdown = '', platform = 'wechat', theme = 'min
128
145
  const checklist = ['# 图片交付清单', '', '按正文出现顺序核对;内嵌不代表目标平台已托管或发布。', '', ...imageList.map((item, i) => `${i + 1}. ${item.status} · ${item.source.replace(/[\r\n]/gu, ' ')}`)].join('\n');
129
146
  return { platform, theme, html, body, text, images: imageList, diagnostics, checklist };
130
147
  }
148
+
149
+ const ARTICLE_DIAGNOSTIC_MESSAGES = Object.freeze({
150
+ 'image-unavailable': 'Image could not be loaded or embedded.',
151
+ 'unsafe-link': 'Unsafe link was removed from the rendered output.',
152
+ 'unclosed-code-fence': 'Code fence was not closed.',
153
+ 'diagram-source-only': 'Diagram source needs a separate visual render.',
154
+ 'table-paste-check': 'Check table layout after pasting into the target editor.',
155
+ 'container-as-text': 'Unsupported container was rendered as text.',
156
+ 'remote-image-unverified': 'Remote image was referenced but its availability was not verified.',
157
+ });
158
+ const MAX_BOUND_SOURCE_BYTES = 16 * 1024 * 1024;
159
+ const MAX_BOUND_IMAGE_BASE64_CHARS = 48 * 1024 * 1024;
160
+ function validBoundSourcePath(value) {
161
+ return typeof value === 'string' && value.length > 0 && value.length <= 2048
162
+ && /\.(?:md|markdown)$/iu.test(value) && !/[\\:\u0000-\u001f]/u.test(value)
163
+ && value.split('/').every(segment => segment && segment !== '.' && segment !== '..');
164
+ }
165
+ async function embeddedImageBytesSha256(value) {
166
+ if (typeof value !== 'string') return null;
167
+ const match = value.match(/^data:image\/(?:png|jpe?g|gif|webp);base64,([a-z0-9+/]+={0,2})$/iu);
168
+ if (!match || match[1].length > MAX_BOUND_IMAGE_BASE64_CHARS || typeof atob !== 'function') return null;
169
+ try {
170
+ const binary = atob(match[1]);
171
+ return await sha256Bytes(Uint8Array.from(binary, char => char.charCodeAt(0)));
172
+ } catch {
173
+ return null;
174
+ }
175
+ }
176
+ function articleInputSnapshot(input, delivery) {
177
+ const images = Object.entries(input.images ?? {})
178
+ .map(([source, value]) => [source, String(value)])
179
+ .sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0);
180
+ return {
181
+ markdown: String(input.markdown ?? ''),
182
+ platform: delivery.platform,
183
+ theme: delivery.theme,
184
+ title: String(input.title ?? ''),
185
+ sourcePath: input.sourcePath == null ? null : String(input.sourcePath),
186
+ images,
187
+ provider: ARTICLE_RENDERER_PROVIDER,
188
+ };
189
+ }
190
+ function articleFailure(error) {
191
+ const message = error instanceof Error ? error.message : '';
192
+ const known = {
193
+ 'Unsupported delivery format': ['unsupported-platform', 'Unsupported delivery format.'],
194
+ 'Unknown writing theme': ['unknown-theme', 'Unknown writing theme.'],
195
+ 'Invalid workspace image path': ['invalid-image-path', 'Image path must remain inside the workspace.'],
196
+ 'Source escapes workspace': ['invalid-image-path', 'Image path must remain inside the workspace.'],
197
+ 'Image escapes workspace': ['invalid-image-path', 'Image path must remain inside the workspace.'],
198
+ 'Invalid workspace source path': ['invalid-source-path', 'Source path must be a canonical workspace-relative Markdown path.'],
199
+ 'Invalid source bytes': ['invalid-source-bytes', 'Source must be supplied as UTF-8 bytes.'],
200
+ 'Invalid source encoding': ['invalid-source-encoding', 'Source bytes must be valid UTF-8.'],
201
+ 'Source too large': ['source-too-large', 'Source exceeds the 16 MiB render limit.'],
202
+ 'Invalid creation snapshot fingerprint': ['invalid-snapshot-fingerprint', 'Creation snapshot fingerprint must be SHA-256.'],
203
+ 'Invalid article title': ['invalid-title', 'Article title must be a string.'],
204
+ 'Unsupported bound renderer option': ['unsupported-render-option', 'Byte-bound rendering accepts only platform, theme and title options.'],
205
+ }[message];
206
+ return {
207
+ schemaVersion: 1,
208
+ capabilityId: 'article.render',
209
+ provider: ARTICLE_RENDERER_PROVIDER,
210
+ status: 'error',
211
+ input: { fingerprint: null },
212
+ artifacts: [],
213
+ images: [],
214
+ diagnostics: [{ code: known?.[0] ?? 'render-failed', severity: 'error', message: known?.[1] ?? 'Article could not be rendered.' }],
215
+ };
216
+ }
217
+
218
+ // Extract a local text fallback only from markup produced by renderArticle.
219
+ // It is an estimate for preflight checks, never a target editor readback.
220
+ function localClipboardText(html) {
221
+ return html
222
+ .replace(/<img\b[^>]*\/?\s*>/giu, '')
223
+ .replace(/<br\s*\/?\s*>/giu, '\n')
224
+ .replace(/<\/(?:p|h[1-6]|blockquote|li|tr|section|table)>/giu, '\n')
225
+ .replace(/<\/(?:th|td)>/giu, '\t')
226
+ .replace(/<hr\b[^>]*\/?\s*>/giu, '\n')
227
+ .replace(/<[^>]+>/gu, '')
228
+ .replace(/&lt;/gu, '<').replace(/&gt;/gu, '>')
229
+ .replace(/&quot;/gu, '"').replace(/&#39;/gu, "'")
230
+ .replace(/&amp;/gu, '&')
231
+ .replace(/[\t ]+\n/gu, '\n')
232
+ .replace(/\n{3,}/gu, '\n\n')
233
+ .trim();
234
+ }
235
+
236
+ /** Prepared local text for a length preflight; the target editor must be checked separately. */
237
+ export function articleLocalTextDocument(result) {
238
+ if (result?.status === 'error' || !result?.input?.source) return null;
239
+ const artifact = result.artifacts?.find(item => item.role === 'local-clipboard-text');
240
+ if (artifact?.format !== 'text/plain' || typeof artifact.content !== 'string' ||
241
+ !/^[a-f0-9]{64}$/u.test(artifact.sha256 ?? '')) return null;
242
+ const title = result.input.localTextTitle;
243
+ if (title !== null && typeof title !== 'string') return null;
244
+ return { text_kind: 'platform_text', ...(title === null ? {} : { title }), body: artifact.content };
245
+ }
246
+ /** Deterministic, machine-readable delivery. The legacy renderArticle shape is unchanged. */
247
+ export async function renderArticleResult(input = {}) {
248
+ try {
249
+ const delivery = renderArticle(input);
250
+ const fingerprint = await sha256(JSON.stringify(articleInputSnapshot(input, delivery)));
251
+ const artifacts = await Promise.all([
252
+ { role: 'preview', format: 'text/html', content: delivery.html },
253
+ { role: 'clipboard-html', format: 'text/html', content: delivery.body },
254
+ { role: 'source-markdown', format: 'text/markdown', content: delivery.text },
255
+ { role: 'image-checklist', format: 'text/markdown', content: delivery.checklist },
256
+ ].map(async artifact => ({ ...artifact, sha256: await sha256(artifact.content) })));
257
+ const diagnostics = [
258
+ ...delivery.diagnostics.map(diagnostic => ({
259
+ code: diagnostic.code,
260
+ severity: 'warning',
261
+ message: ARTICLE_DIAGNOSTIC_MESSAGES[diagnostic.code] ?? 'Review the rendered output.',
262
+ ...(diagnostic.source == null ? {} : { location: { source: diagnostic.source } }),
263
+ })),
264
+ ...delivery.images.filter(image => image.status === 'remote').map(image => ({
265
+ code: 'remote-image-unverified',
266
+ severity: 'warning',
267
+ message: ARTICLE_DIAGNOSTIC_MESSAGES['remote-image-unverified'],
268
+ location: { source: image.source },
269
+ })),
270
+ ];
271
+ return {
272
+ schemaVersion: 1,
273
+ capabilityId: 'article.render',
274
+ provider: ARTICLE_RENDERER_PROVIDER,
275
+ status: diagnostics.length ? 'warning' : 'ok',
276
+ input: { fingerprint, target: { platform: delivery.platform, theme: delivery.theme } },
277
+ artifacts,
278
+ images: delivery.images,
279
+ diagnostics,
280
+ };
281
+ } catch (error) {
282
+ return articleFailure(error);
283
+ }
284
+ }
285
+ /** Load local images through the host, then fingerprint their actual returned data. */
286
+ export async function prepareArticleResult(input, readImage) {
287
+ try {
288
+ const images = await collectArticleImages(input, readImage);
289
+ return await renderArticleResult({ ...input, images });
290
+ } catch (error) {
291
+ return articleFailure(error);
292
+ }
293
+ }
294
+
295
+ /**
296
+ * Render exact UTF-8 source bytes supplied by a trusted host. The host compares
297
+ * input.source against the selected Kernel snapshot before using the artifacts.
298
+ */
299
+ export async function prepareArticleResultFromSource({
300
+ sourcePath, sourceBytes, creationSnapshotFingerprint, ...options
301
+ } = {}, readImage) {
302
+ if (!validBoundSourcePath(sourcePath)) return articleFailure(new Error('Invalid workspace source path'));
303
+ if (!(sourceBytes instanceof Uint8Array)) return articleFailure(new Error('Invalid source bytes'));
304
+ if (sourceBytes.byteLength > MAX_BOUND_SOURCE_BYTES) return articleFailure(new Error('Source too large'));
305
+ if (creationSnapshotFingerprint !== undefined &&
306
+ (typeof creationSnapshotFingerprint !== 'string' ||
307
+ !/^[a-f0-9]{64}$/u.test(creationSnapshotFingerprint))) {
308
+ return articleFailure(new Error('Invalid creation snapshot fingerprint'));
309
+ }
310
+ if (options.title !== undefined && typeof options.title !== 'string') {
311
+ return articleFailure(new Error('Invalid article title'));
312
+ }
313
+ if (Object.keys(options).some(key => !['platform', 'theme', 'title'].includes(key))) {
314
+ return articleFailure(new Error('Unsupported bound renderer option'));
315
+ }
316
+ // Own the bytes before the first await so caller mutation cannot split the
317
+ // hash from the Markdown actually rendered.
318
+ const stableBytes = Uint8Array.from(sourceBytes);
319
+ let markdown;
320
+ try {
321
+ markdown = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(stableBytes);
322
+ } catch {
323
+ return articleFailure(new Error('Invalid source encoding'));
324
+ }
325
+ const source = { relativePath: sourcePath, sha256: await sha256Bytes(stableBytes) };
326
+ const readImages = new Map();
327
+ const trackedReadImage = async relativePath => {
328
+ const dataUrl = await readImage(relativePath);
329
+ const sha256 = await embeddedImageBytesSha256(dataUrl);
330
+ if (sha256) readImages.set(relativePath, sha256);
331
+ return dataUrl;
332
+ };
333
+ const result = await prepareArticleResult({ ...options, sourcePath, markdown }, trackedReadImage);
334
+ if (result.status === 'error') return result;
335
+ const clipboard = result.artifacts.find(item => item.role === 'clipboard-html');
336
+ const localTextArtifact = { role: 'local-clipboard-text', format: 'text/plain',
337
+ content: localClipboardText(clipboard.content) };
338
+ localTextArtifact.sha256 = await sha256(localTextArtifact.content);
339
+ const localTextTitle = options.title ?? null;
340
+ const renderParameters = { theme: result.input.target.theme, title: options.title ?? '' };
341
+ const imageBytes = [...readImages].sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0)
342
+ .map(([relativePath, sha256]) => ({ relativePath, sha256 }));
343
+ return { ...result, artifacts: [...result.artifacts, localTextArtifact], input: { ...result.input, source,
344
+ imageBytes,
345
+ localTextTitle,
346
+ renderParameters,
347
+ ...(creationSnapshotFingerprint === undefined ? {} : { creationSnapshotFingerprint }),
348
+ fingerprint: await sha256(JSON.stringify({ rendererFingerprint: result.input.fingerprint, source, imageBytes,
349
+ localTextTitle, renderParameters, localTextSha256: localTextArtifact.sha256,
350
+ ...(creationSnapshotFingerprint === undefined ? {} : { creationSnapshotFingerprint }) })) } };
351
+ }
@@ -0,0 +1,19 @@
1
+ /** SHA-256 for browser and Node hosts; never hashes by reading a path. */
2
+ export async function sha256Bytes(bytes) {
3
+ if (!(bytes instanceof Uint8Array)) throw new TypeError('SHA-256 input must be bytes');
4
+ if (globalThis.crypto?.subtle) {
5
+ const digest = await globalThis.crypto.subtle.digest('SHA-256', bytes);
6
+ return Array.from(new Uint8Array(digest), byte => byte.toString(16).padStart(2, '0')).join('');
7
+ }
8
+ // Node 18 may run without the global Web Crypto API. Keep browser imports portable.
9
+ if (typeof process !== 'undefined' && process.versions?.node) {
10
+ const nodeCrypto = 'node:crypto';
11
+ const { createHash } = await import(nodeCrypto);
12
+ return createHash('sha256').update(bytes).digest('hex');
13
+ }
14
+ throw new Error('SHA-256 is unavailable');
15
+ }
16
+
17
+ export async function sha256(value) {
18
+ return sha256Bytes(new TextEncoder().encode(value));
19
+ }
@@ -0,0 +1,24 @@
1
+ export type LengthTarget = { platform: string; format: string; entrypoint: string; account_capability: string };
2
+ export type LengthRule = {
3
+ id: string;
4
+ category: 'platform_limit' | 'creator_preference';
5
+ status: string;
6
+ applies_to: LengthTarget;
7
+ check: { field: 'title' | 'body'; operation: 'max_characters'; counter: 'unicode_code_points'; max: number };
8
+ outcome: { exceeded: 'block' | 'warn' };
9
+ evidence: { tier: string; url?: string; checked_at?: string; review_after?: string; confirmed_at?: string };
10
+ };
11
+ export interface LengthRuleResult {
12
+ schemaVersion: 1;
13
+ capabilityId: 'platform.validate.length';
14
+ provider: Readonly<{ id: string; version: string }>;
15
+ status: 'ok' | 'warning' | 'error';
16
+ verdict: 'pass' | 'review' | 'block' | 'invalid';
17
+ input: { fingerprint: string | null; rulesDigest?: string; documentDigest?: string; target?: LengthTarget; asOf?: string };
18
+ artifacts: [];
19
+ checks: Array<{ ruleId: string; category: string; field: 'title' | 'body'; observed: number; max: number; unit: 'unicode_code_points'; exceeded: boolean }>;
20
+ effectiveLimits: Array<{ field: 'title' | 'body'; unit: 'unicode_code_points'; hardMax: number | null; preferenceMax: number | null; effectiveMax: number }>;
21
+ diagnostics: Array<{ code: string; severity: 'warning' | 'error'; message: string; ruleId?: string; dimension?: string; location?: { field: string }; observed?: number; max?: number; unit?: string; hardMax?: number; preferenceMax?: number }>;
22
+ }
23
+ export const LENGTH_RULE_PROVIDER: Readonly<{ id: string; version: string }>;
24
+ export function evaluateLengthRules(input: { target: LengthTarget; document: { text_kind: 'platform_text'; title?: string; body?: string }; rules: LengthRule[]; asOf?: string }): Promise<LengthRuleResult>;