@topmindspace/topmind-writing-skills 0.7.1

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 (115) hide show
  1. package/CHANGELOG.md +931 -0
  2. package/LICENSE +21 -0
  3. package/README.en.md +124 -0
  4. package/README.md +124 -0
  5. package/bin/topmind-writing-skills.js +388 -0
  6. package/package.json +56 -0
  7. package/topmind-briefs/CHANGELOG.md +22 -0
  8. package/topmind-briefs/README.en.md +41 -0
  9. package/topmind-briefs/README.md +37 -0
  10. package/topmind-briefs/SKILL.md +101 -0
  11. package/topmind-briefs/dist/topmind-briefs.manifest.json +21 -0
  12. package/topmind-briefs/dist/topmind-briefs.zip +0 -0
  13. package/topmind-briefs/package-lock.json +13 -0
  14. package/topmind-briefs/package.json +1 -0
  15. package/topmind-briefs/references/examples.md +131 -0
  16. package/topmind-briefs/references/platform-specs.md +63 -0
  17. package/topmind-briefs/references/style-guide.md +60 -0
  18. package/topmind-briefs/scripts/audit_css.py +39 -0
  19. package/topmind-briefs/scripts/audit_docs.py +80 -0
  20. package/topmind-briefs/scripts/audit_skill.py +60 -0
  21. package/topmind-briefs/scripts/audit_styles.py +54 -0
  22. package/topmind-briefs/scripts/negative_tests.py +88 -0
  23. package/topmind-briefs/scripts/package_skill.py +188 -0
  24. package/topmind-cover/README.en.md +54 -0
  25. package/topmind-cover/README.md +54 -0
  26. package/topmind-cover/SKILL.md +134 -0
  27. package/topmind-cover/assets/examples/README.md +34 -0
  28. package/topmind-cover/assets/examples/bg-blur-alt.png +0 -0
  29. package/topmind-cover/assets/examples/bg-blur-wechat.png +0 -0
  30. package/topmind-cover/assets/examples/bg-blur.png +0 -0
  31. package/topmind-cover/assets/examples/big-type-wechat.png +0 -0
  32. package/topmind-cover/assets/examples/big-type.png +0 -0
  33. package/topmind-cover/assets/examples/brand-launch-wechat.png +0 -0
  34. package/topmind-cover/assets/examples/brand-launch.png +0 -0
  35. package/topmind-cover/assets/examples/gan-huo-wechat.png +0 -0
  36. package/topmind-cover/assets/examples/gan-huo.png +0 -0
  37. package/topmind-cover/assets/examples/ip-fun-wechat.png +0 -0
  38. package/topmind-cover/assets/examples/ip-fun.png +0 -0
  39. package/topmind-cover/assets/examples/magazine-wechat.png +0 -0
  40. package/topmind-cover/assets/examples/magazine.png +0 -0
  41. package/topmind-cover/assets/examples/minimal-wechat.png +0 -0
  42. package/topmind-cover/assets/examples/minimal.png +0 -0
  43. package/topmind-cover/assets/examples/news-flash-wechat.png +0 -0
  44. package/topmind-cover/assets/examples/news-flash.png +0 -0
  45. package/topmind-cover/assets/examples/overview.png +0 -0
  46. package/topmind-cover/assets/examples/paper-collage-alt.png +0 -0
  47. package/topmind-cover/assets/examples/paper-collage-wechat.png +0 -0
  48. package/topmind-cover/assets/examples/paper-collage.png +0 -0
  49. package/topmind-cover/assets/examples/tutorial-steps-wechat.png +0 -0
  50. package/topmind-cover/assets/examples/tutorial-steps.png +0 -0
  51. package/topmind-cover/assets/examples/white-clean-alt-wechat.png +0 -0
  52. package/topmind-cover/assets/examples/white-clean-alt.png +0 -0
  53. package/topmind-cover/assets/examples/white-clean-wechat.png +0 -0
  54. package/topmind-cover/assets/examples/white-clean.png +0 -0
  55. package/topmind-cover/package-lock.json +16 -0
  56. package/topmind-cover/package.json +16 -0
  57. package/topmind-cover/references/cover-prompts.md +436 -0
  58. package/topmind-cover/references/cover-styles.md +496 -0
  59. package/topmind-cover/references/impact-language.md +94 -0
  60. package/topmind-cover/references/ref-white-clean-1.png +0 -0
  61. package/topmind-cover/references/ref-white-clean-2.png +0 -0
  62. package/topmind-cover/scripts/audit_css.py +39 -0
  63. package/topmind-cover/scripts/audit_docs.py +80 -0
  64. package/topmind-cover/scripts/audit_skill.py +60 -0
  65. package/topmind-cover/scripts/audit_styles.py +54 -0
  66. package/topmind-cover/scripts/crop-cover.py +74 -0
  67. package/topmind-cover/scripts/negative_tests.py +71 -0
  68. package/topmind-cover/scripts/package_skill.py +188 -0
  69. package/topmind-wechat-post/README.en.md +111 -0
  70. package/topmind-wechat-post/README.md +111 -0
  71. package/topmind-wechat-post/SKILL.md +251 -0
  72. package/topmind-wechat-post/assets/themes/amber-review.json +35 -0
  73. package/topmind-wechat-post/assets/themes/graphite.json +35 -0
  74. package/topmind-wechat-post/assets/themes/minimal-ink.json +35 -0
  75. package/topmind-wechat-post/assets/themes/newsprint.json +35 -0
  76. package/topmind-wechat-post/assets/themes/tech-blue.json +35 -0
  77. package/topmind-wechat-post/package-lock.json +16 -0
  78. package/topmind-wechat-post/package.json +16 -0
  79. package/topmind-wechat-post/references/design.md +50 -0
  80. package/topmind-wechat-post/references/element-spec.md +163 -0
  81. package/topmind-wechat-post/references/known-pits.md +85 -0
  82. package/topmind-wechat-post/references/theme-map.md +139 -0
  83. package/topmind-wechat-post/references/typography-rules.md +62 -0
  84. package/topmind-wechat-post/references/wechat-constraints.md +111 -0
  85. package/topmind-wechat-post/references/workflow.md +102 -0
  86. package/topmind-wechat-post/references/writing-quality.md +105 -0
  87. package/topmind-wechat-post/scripts/audit_css.py +39 -0
  88. package/topmind-wechat-post/scripts/audit_docs.py +80 -0
  89. package/topmind-wechat-post/scripts/audit_skill.py +60 -0
  90. package/topmind-wechat-post/scripts/audit_styles.py +54 -0
  91. package/topmind-wechat-post/scripts/lint-wechat.py +446 -0
  92. package/topmind-wechat-post/scripts/md2wechat.py +1792 -0
  93. package/topmind-wechat-post/scripts/negative_tests.py +171 -0
  94. package/topmind-wechat-post/scripts/new-article.py +183 -0
  95. package/topmind-wechat-post/scripts/package_skill.py +188 -0
  96. package/topmind-wechat-post/scripts/push-to-topstream.py +226 -0
  97. package/topmind-wechat-post/scripts/scan_ai_flavor.py +464 -0
  98. package/topmind-wechat-post/scripts/sync-mapping.py +220 -0
  99. package/topmind-wechat-post/scripts/sync-status.py +187 -0
  100. package/topmind-x-article/README.en.md +109 -0
  101. package/topmind-x-article/README.md +104 -0
  102. package/topmind-x-article/SKILL.md +156 -0
  103. package/topmind-x-article/package-lock.json +16 -0
  104. package/topmind-x-article/package.json +16 -0
  105. package/topmind-x-article/references/publish-checklist.md +31 -0
  106. package/topmind-x-article/references/x-format.md +27 -0
  107. package/topmind-x-article/references/x-html-format.md +85 -0
  108. package/topmind-x-article/scripts/audit_css.py +39 -0
  109. package/topmind-x-article/scripts/audit_docs.py +80 -0
  110. package/topmind-x-article/scripts/audit_skill.py +60 -0
  111. package/topmind-x-article/scripts/audit_styles.py +54 -0
  112. package/topmind-x-article/scripts/md2x-html.py +522 -0
  113. package/topmind-x-article/scripts/md2x.py +323 -0
  114. package/topmind-x-article/scripts/negative_tests.py +91 -0
  115. package/topmind-x-article/scripts/package_skill.py +188 -0
@@ -0,0 +1,323 @@
1
+ #!/usr/bin/env python3
2
+ """md → X 长文可粘贴纯文本。
3
+
4
+ 把 Markdown 原稿转成可直接粘贴进 X Article 编辑器的纯文本:
5
+ 标题/正文保留,排版标记剥离,图片转为 [图N] 占位。
6
+
7
+ 用法:
8
+ python3 md2x.py <输入.md> --out <输出/X发布稿.txt>
9
+
10
+ 规则见 references/x-format.md。
11
+ """
12
+ import argparse
13
+ import re
14
+ import sys
15
+ from pathlib import Path
16
+
17
+ # --- 行内规则 ---------------------------------------------------------------
18
+
19
+ # markdown 转义:反斜杠 + ASCII 标点 → 标点本身(先占位保护,避免被 emphasis 误配对)
20
+ _UNESCAPE_RE = re.compile(r"\\([!\"#$%&'()*+,\-./:;<=>?@\[\\\]^_`{|}~])")
21
+ _PUA0, _PUA1 = "\ue000", "\ue001"
22
+
23
+ HR_RE = re.compile(r"^(\*(\s*\*){2,}|-(\s*-){2,}|_(\s*_){2,})\s*$")
24
+ HEADING_RE = re.compile(r"^(#{1,6})\s+(.*)$")
25
+ SETEXT_RE = re.compile(r"^=+\s*$")
26
+ TABLE_SEP_RE = re.compile(r"^:?-+:?$")
27
+ # 引用式定义:[label]: url "可选 title"(行首至多 3 空格)
28
+ REFDEF_RE = re.compile(r"^\s{0,3}\[([^\]]+)\]:\s*(\S+)(?:\s+[\"'(].*[\"')])?\s*$")
29
+
30
+
31
+ def _protect_escapes(s):
32
+ """把反斜杠转义换成私有用区占位符,返回 (新串, 还原表)。"""
33
+ store = []
34
+
35
+ def repl(m):
36
+ store.append(m.group(1))
37
+ return f"{_PUA0}{len(store) - 1}{_PUA1}"
38
+
39
+ return _UNESCAPE_RE.sub(repl, s), store
40
+
41
+
42
+ def _restore_escapes(s, store):
43
+ def repl(m):
44
+ return store[int(m.group(1))]
45
+
46
+ return re.sub(f"{_PUA0}(\\d+){_PUA1}", repl, s)
47
+
48
+
49
+ def _parse_paren(s, j):
50
+ """s[j] == '(' 时,找与之匹配的 ')'(括号可嵌套)。
51
+
52
+ 返回 (括号内文本, 右括号之后的位置);括号不平衡返回 None。
53
+ """
54
+ depth = 0
55
+ k, n = j, len(s)
56
+ while k < n:
57
+ c = s[k]
58
+ if c == "(":
59
+ depth += 1
60
+ elif c == ")":
61
+ depth -= 1
62
+ if depth == 0:
63
+ return s[j + 1:k], k + 1
64
+ k += 1
65
+ return None
66
+
67
+
68
+ def _split_url_title(inner):
69
+ """把 `(url "title")` 括号内文本拆出 url(title 丢弃)。"""
70
+ inner = inner.strip()
71
+ m = re.match(r"^(\S+?)(?:\s+(?:\"[^\"]*\"|'[^']*'|\([^)]*\)))?$", inner)
72
+ return m.group(1) if m else inner
73
+
74
+
75
+ def _replace_images(s, images, defs):
76
+ """![alt](url) / ![alt][ref] → [图N](alt 记入 images)。"""
77
+ res = []
78
+ i, n = 0, len(s)
79
+ while i < n:
80
+ if s.startswith("![", i):
81
+ k = s.find("]", i + 2)
82
+ if k == -1 or "]" in s[i + 2:k]:
83
+ res.append(s[i])
84
+ i += 1
85
+ continue
86
+ alt = s[i + 2:k]
87
+ j = k + 1
88
+ if j < n and s[j] == "(":
89
+ parsed = _parse_paren(s, j)
90
+ if not parsed:
91
+ res.append(s[i])
92
+ i += 1
93
+ continue
94
+ j = parsed[1] # url 允许为空;图片占位不依赖 url
95
+ elif j < n and s[j] == "[":
96
+ k2 = s.find("]", j + 1)
97
+ if k2 == -1:
98
+ res.append(s[i])
99
+ i += 1
100
+ continue
101
+ label = s[j + 1:k2] or alt # [alt][] 省略式:label 取 alt
102
+ if label.strip().lower() not in defs:
103
+ res.append(s[i])
104
+ i += 1
105
+ continue
106
+ j = k2 + 1
107
+ else:
108
+ res.append(s[i])
109
+ i += 1
110
+ continue
111
+ images.append(alt.strip())
112
+ res.append(f"[图{len(images)}]")
113
+ i = j
114
+ else:
115
+ res.append(s[i])
116
+ i += 1
117
+ return "".join(res)
118
+
119
+
120
+ def _replace_links(s, defs):
121
+ """[文字](url) / [文字][ref] / [文字][] / [文字] → 文字(url)。"""
122
+ # 1) 行内式:url 括号可嵌套
123
+ res = []
124
+ i, n = 0, len(s)
125
+ while i < n:
126
+ if s[i] == "[" and not (i > 0 and s[i - 1] == "!"):
127
+ k = s.find("](", i + 1)
128
+ if k != -1 and "]" not in s[i + 1:k]:
129
+ parsed = _parse_paren(s, k + 1)
130
+ if parsed:
131
+ url = _split_url_title(parsed[0])
132
+ if url:
133
+ res.append(f"{s[i + 1:k]}({url})")
134
+ i = parsed[1]
135
+ continue
136
+ res.append(s[i])
137
+ i += 1
138
+ s = "".join(res)
139
+
140
+ # 2) 引用式 [文字][label] / [文字][]((?<!\!) 避免误伤残留的坏图片写法)
141
+ def _ref(m):
142
+ label = (m.group(2) or m.group(1)).strip().lower()
143
+ url = defs.get(label)
144
+ return f"{m.group(1)}({url})" if url else m.group(0)
145
+
146
+ s = _INLINE_REF2_RE.sub(_ref, s)
147
+
148
+ # 3) 快捷引用 [文字](仅当 label 有定义时才转,避免误伤普通方括号)
149
+ def _shortcut(m):
150
+ url = defs.get(m.group(1).strip().lower())
151
+ return f"{m.group(1)}({url})" if url else m.group(0)
152
+
153
+ return _INLINE_REF1_RE.sub(_shortcut, s)
154
+
155
+
156
+ # _inline() 的行内模式:预编译在模块级,避免每行重复走 re 缓存查找
157
+ _INLINE_BI_RE = re.compile(r"\*\*\*(.+?)\*\*\*") # 粗斜体
158
+ _INLINE_B_RE = re.compile(r"\*\*(.+?)\*\*") # 加粗
159
+ _INLINE_BU_RE = re.compile(r"__(.+?)__")
160
+ _INLINE_I_RE = re.compile(r"\*(.+?)\*") # 斜体
161
+ _INLINE_CODE_RE = re.compile(r"`(.+?)`") # 行内代码
162
+ _INLINE_DEL_RE = re.compile(r"~~(.+?)~~") # 删除线
163
+ _INLINE_TASK0_RE = re.compile(r"^(\s*)- \[ \]\s+")
164
+ _INLINE_TASK1_RE = re.compile(r"^(\s*)- \[x\]\s+", re.I)
165
+ _INLINE_REF2_RE = re.compile(r"(?<!\!)\[([^\]]+)\]\[([^\]]*)\]")
166
+ _INLINE_REF1_RE = re.compile(r"(?<!\!)\[([^\]]+)\]")
167
+
168
+
169
+ def _inline(s, defs):
170
+ """行内标记剥离(用于正文行、标题文本、表格单元格)。"""
171
+ s = _INLINE_BI_RE.sub(r"\1", s) # 粗斜体
172
+ s = _INLINE_B_RE.sub(r"\1", s) # 加粗
173
+ s = _INLINE_BU_RE.sub(r"\1", s)
174
+ s = _INLINE_I_RE.sub(r"\1", s) # 斜体
175
+ s = _INLINE_CODE_RE.sub(r"\1", s) # 行内代码
176
+ s = _INLINE_DEL_RE.sub(r"\1", s) # 删除线
177
+ s = _replace_links(s, defs) # 链接(行内式/引用式)
178
+ s = _INLINE_TASK0_RE.sub(r"\1- ", s) # 任务列表
179
+ s = _INLINE_TASK1_RE.sub(r"\1- ", s)
180
+ return s
181
+
182
+
183
+ def _strip_frontmatter(md):
184
+ """去掉文首 frontmatter 块。块内非空行必须都含 ':',否则视为正文(如文首分割线)。"""
185
+ m = re.match(r"^---[ \t]*\r?\n(.*?)\r?\n---[ \t]*(\r?\n|$)", md, flags=re.S)
186
+ if m and all(not ln.strip() or ":" in ln for ln in m.group(1).splitlines()):
187
+ return md[m.end():]
188
+ return md
189
+
190
+
191
+ def convert(md: str):
192
+ images = []
193
+ defs = {}
194
+ out_lines = []
195
+ in_code = False
196
+ fence_len = 0
197
+
198
+ md, esc_store = _protect_escapes(md)
199
+ md = _strip_frontmatter(md)
200
+
201
+ # 先收集引用式定义([label]: url),定义行不进正文
202
+ body = []
203
+ for raw in md.splitlines():
204
+ m = REFDEF_RE.match(raw)
205
+ if m:
206
+ defs[m.group(1).strip().lower()] = m.group(2)
207
+ else:
208
+ body.append(raw)
209
+
210
+ for raw in body:
211
+ line = raw.rstrip()
212
+
213
+ # 代码块:先处理围栏,块内行原样缩进(分割线规则不进代码块)。
214
+ # 围栏反引号数 >= 3:```` 开栏只能由 >= 4 反引号闭合(CommonMark),
215
+ # 否则 markdown 示例里的内层 ``` 会把外层栏提前"闭合",造成静默错排。
216
+ stripped = line.strip()
217
+ if stripped.startswith("```"):
218
+ k = 0
219
+ while k < len(stripped) and stripped[k] == "`":
220
+ k += 1
221
+ if not in_code:
222
+ in_code = True
223
+ fence_len = k
224
+ elif k >= fence_len and not stripped[k:].strip():
225
+ in_code = False
226
+ fence_len = 0
227
+ else:
228
+ # 栏内误写的短栏 / 带信息串的栏是代码内容,不是闭合栏
229
+ out_lines.append(" " + line)
230
+ continue
231
+ if in_code:
232
+ out_lines.append(" " + line)
233
+ continue
234
+
235
+ # 图片占位(行内式/引用式)
236
+ line = _replace_images(line, images, defs)
237
+
238
+ # 引用:剥掉所有层级的 >(放标题识别之前,引用内标题也能转)
239
+ line = re.sub(r"^(>\s?)+", "", line)
240
+
241
+ # 标题 → 纯文本行(标题内行文标记同样剥离)
242
+ m = HEADING_RE.match(line)
243
+ if m:
244
+ out_lines.append("")
245
+ out_lines.append(_inline(m.group(2).strip(), defs))
246
+ out_lines.append("")
247
+ continue
248
+
249
+ # Setext 一级标题:上一行是正文则转为标题
250
+ if SETEXT_RE.match(line):
251
+ if out_lines and out_lines[-1].strip():
252
+ text = out_lines.pop()
253
+ out_lines.append("")
254
+ out_lines.append(text)
255
+ out_lines.append("")
256
+ else:
257
+ out_lines.append("")
258
+ continue
259
+
260
+ # 分割线(* * * / - - - / _ _ _ 允许空格)→ 空行
261
+ if HR_RE.match(line):
262
+ out_lines.append("")
263
+ continue
264
+
265
+ # 表格行 → "项:值" 列表(单元格内行文标记同样剥离)
266
+ if "|" in line and line.strip().startswith("|"):
267
+ raw_cells = [c.strip() for c in line.strip().strip("|").split("|")]
268
+ if all(TABLE_SEP_RE.match(c) for c in raw_cells):
269
+ continue
270
+ cells = [_inline(c, defs) for c in raw_cells]
271
+ label = cells[0]
272
+ rest = ",".join(c for c in cells[1:] if c)
273
+ out_lines.append(f"{label}:{rest}" if rest else label)
274
+ continue
275
+
276
+ out_lines.append(_inline(line, defs))
277
+
278
+ # 压缩 3+ 空行为 2 个;去首尾空行(只 strip 换行,保留首行缩进——文档可能以代码块开头)
279
+ text = "\n".join(out_lines)
280
+ text = re.sub(r"\n{3,}", "\n\n", text).lstrip("\n").rstrip() + "\n"
281
+
282
+ # 文末附图注清单
283
+ if images:
284
+ text += "\n—— 配图清单 ——\n"
285
+ for i, alt in enumerate(images, 1):
286
+ text += f"[图{i}] {alt or '(未写图注)'}\n"
287
+
288
+ return _restore_escapes(text, esc_store), images
289
+
290
+
291
+ def main() -> None:
292
+ ap = argparse.ArgumentParser()
293
+ ap.add_argument("input", help="输入 Markdown")
294
+ ap.add_argument("--out", required=True, help="输出 X发布稿.txt")
295
+ args = ap.parse_args()
296
+
297
+ inp = Path(args.input)
298
+ if not inp.is_file():
299
+ if inp.is_dir():
300
+ sys.exit(f"错误:输入是目录不是文件 {inp}")
301
+ sys.exit(f"错误:找不到输入文件 {inp}")
302
+ try:
303
+ md = inp.read_text(encoding="utf-8-sig")
304
+ except UnicodeDecodeError:
305
+ sys.exit(f"错误:输入文件不是有效的 UTF-8 编码 {inp}")
306
+ except OSError as e:
307
+ sys.exit(f"错误:无法读取输入文件 {inp}({e})")
308
+ text, images = convert(md)
309
+
310
+ out = Path(args.out)
311
+ try:
312
+ out.parent.mkdir(parents=True, exist_ok=True)
313
+ out.write_text(text, encoding="utf-8")
314
+ except OSError as e:
315
+ sys.exit(f"错误:无法写入输出文件 {out}({e})")
316
+
317
+ chars = len(text.replace("\n", ""))
318
+ print(f"OK {out}")
319
+ print(f"正文约 {chars} 字,配图 {len(images)} 张")
320
+
321
+
322
+ if __name__ == "__main__":
323
+ main()
@@ -0,0 +1,91 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """topmind-x-article 异常输入测试:md2x.py 在各种坏输入下不崩溃(任一失败 → 退出码 1)。"""
4
+ from __future__ import annotations
5
+
6
+ import subprocess
7
+ import sys
8
+ import tempfile
9
+ from pathlib import Path
10
+
11
+ try:
12
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace")
13
+ except Exception:
14
+ pass
15
+
16
+ ROOT = Path(__file__).resolve().parent.parent
17
+ MD2X = ROOT / "scripts" / "md2x.py"
18
+ PY = sys.executable
19
+ fails: list[str] = []
20
+
21
+
22
+ def run(args: list[str]) -> subprocess.CompletedProcess:
23
+ return subprocess.run([PY, str(MD2X), *args], capture_output=True, text=True, timeout=30)
24
+
25
+
26
+ def check(name: str, cond: bool, detail: str = "") -> None:
27
+ print((" ✓ " if cond else " ✗ ") + name + (f" — {detail}" if detail and not cond else ""))
28
+ if not cond:
29
+ fails.append(name)
30
+
31
+
32
+ def main() -> None:
33
+ print("[negative_tests] topmind-x-article")
34
+ with tempfile.TemporaryDirectory() as td:
35
+ td = Path(td)
36
+
37
+ # 1. 空文件
38
+ (td / "empty.md").write_text("", encoding="utf-8")
39
+ r = run([str(td / "empty.md"), "--out", str(td / "o1.txt")])
40
+ check("空文件不崩溃", r.returncode == 0 and "Traceback" not in r.stderr)
41
+
42
+ # 2. 只有 frontmatter
43
+ (td / "fm.md").write_text("---\ntitle: t\n---\n", encoding="utf-8")
44
+ r = run([str(td / "fm.md"), "--out", str(td / "o2.txt")])
45
+ check("纯 frontmatter 不崩溃", r.returncode == 0 and "Traceback" not in r.stderr)
46
+
47
+ # 3. 未闭合代码围栏
48
+ (td / "fence.md").write_text("# t\n\n```python\nprint(1)\n", encoding="utf-8")
49
+ r = run([str(td / "fence.md"), "--out", str(td / "o3.txt")])
50
+ check("未闭合围栏不崩溃", r.returncode == 0 and "Traceback" not in r.stderr)
51
+
52
+ # 4. 无分隔行的表格
53
+ (td / "tbl.md").write_text("# t\n\n| a | b |\n| 1 | 2 |\n", encoding="utf-8")
54
+ r = run([str(td / "tbl.md"), "--out", str(td / "o4.txt")])
55
+ check("异常表格不崩溃", r.returncode == 0 and "Traceback" not in r.stderr)
56
+
57
+ # 5. 不存在的输入 → 非零退出且无 Traceback
58
+ r = run([str(td / "nope.md"), "--out", str(td / "o5.txt")])
59
+ check("缺失输入干净报错", r.returncode != 0 and "Traceback" not in r.stderr)
60
+
61
+ # 6. 正常输入产出配图清单
62
+ (td / "ok.md").write_text("# t\n\n![a](x.png)\n", encoding="utf-8")
63
+ r = run([str(td / "ok.md"), "--out", str(td / "o6.txt")])
64
+ out = (td / "o6.txt").read_text(encoding="utf-8") if (td / "o6.txt").exists() else ""
65
+ check("正常输入含配图清单", r.returncode == 0 and "[图1]" in out)
66
+
67
+ # 7. 嵌套围栏:```` 开栏不被内层 ``` 提前闭合(内层栏是代码内容)
68
+ (td / "nest.md").write_text(
69
+ "# t\n\n````markdown\n外层\n```python\nprint(1)\n```\n结束\n````\n",
70
+ encoding="utf-8")
71
+ r = run([str(td / "nest.md"), "--out", str(td / "o7.txt")])
72
+ out = (td / "o7.txt").read_text(encoding="utf-8") if (td / "o7.txt").exists() else ""
73
+ check("嵌套围栏不提前闭合",
74
+ r.returncode == 0 and " ```python" in out and "print(1)" in out)
75
+
76
+ # 8. 括号 URL 链接不断裂(与 md2wechat 对齐的守卫)
77
+ (td / "paren.md").write_text(
78
+ "# t\n\n[维基](https://zh.wikipedia.org/wiki/猫_(动物))\n", encoding="utf-8")
79
+ r = run([str(td / "paren.md"), "--out", str(td / "o8.txt")])
80
+ out = (td / "o8.txt").read_text(encoding="utf-8") if (td / "o8.txt").exists() else ""
81
+ check("括号 URL 不截断",
82
+ r.returncode == 0 and "猫_(动物)" in out)
83
+
84
+ if fails:
85
+ print(f"\n[negative_tests] {len(fails)} 项失败")
86
+ sys.exit(1)
87
+ print("\n[negative_tests] 全部通过")
88
+
89
+
90
+ if __name__ == "__main__":
91
+ main()
@@ -0,0 +1,188 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """技能分发包打包器(发布前完整性校验 + 发布清单)。
4
+
5
+ 用法:
6
+ python scripts/package_skill.py # 校验 → 打包 → 写发布清单
7
+ python scripts/package_skill.py --check # 只校验不打包(CI / 提交前自检)
8
+
9
+ 产物:
10
+ dist/<skill>.zip 分发包(解压目录名 = 技能名)
11
+ dist/<skill>.manifest.json 发布清单(版本 / 条目 / 大小 / SHA-256)
12
+
13
+ 校验(任一不过 → 退出码 1):
14
+ · SKILL.md frontmatter 含 name / description;name 与目录名一致;
15
+ description ≤ 1024 字符(平台截断阈值)
16
+ · README.md / package.json 存在;package.json name 与技能名一致
17
+ · scripts/*.py 全部可编译;references/*.md 非空
18
+ · SKILL.md 引用的 references/<file> 全部存在
19
+ · 无 scripts/_* / __pycache__ / .env 等不应进包的文件
20
+ """
21
+ from __future__ import annotations
22
+
23
+ import hashlib
24
+ import json
25
+ import re
26
+ import sys
27
+ import zipfile
28
+ from datetime import datetime, timezone
29
+ from pathlib import Path
30
+
31
+ try:
32
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace")
33
+ except Exception:
34
+ pass
35
+
36
+ ROOT = Path(__file__).resolve().parent.parent
37
+ NAME = ROOT.name
38
+ OUT = ROOT / "dist" / f"{NAME}.zip"
39
+ MANIFEST = ROOT / "dist" / f"{NAME}.manifest.json"
40
+ DESC_LIMIT = 1024
41
+
42
+ INCLUDE = ["SKILL.md", "README.md", "package.json", "assets/", "references/", "scripts/", "evals/", "agents/"]
43
+ EXCLUDE_RES = [
44
+ re.compile(r"(^|/)scripts/_"),
45
+ re.compile(r"(^|/)\.env($|\.)"),
46
+ re.compile(r"(^|/)__pycache__(/|$)"),
47
+ re.compile(r"(^|/)node_modules(/|$)"),
48
+ re.compile(r"(^|/)dist(/|$)"),
49
+ ]
50
+
51
+ errors: list[str] = []
52
+
53
+
54
+ def fail(msg: str) -> None:
55
+ errors.append(msg)
56
+ print(f" ✗ {msg}")
57
+
58
+
59
+ def ok(msg: str) -> None:
60
+ print(f" ✓ {msg}")
61
+
62
+
63
+ def read_frontmatter() -> dict:
64
+ text = (ROOT / "SKILL.md").read_text(encoding="utf-8")
65
+ m = re.match(r"^---\r?\n([\s\S]*?)\r?\n---", text)
66
+ if not m:
67
+ fail("SKILL.md 缺 frontmatter")
68
+ return {}
69
+ fm = m.group(1)
70
+ name = re.search(r"^name:\s*(\S+)\s*$", fm, re.M)
71
+ desc = ""
72
+ dm = re.search(r'^description:\s*"([^"]*)"\s*$', fm, re.M)
73
+ if dm:
74
+ desc = dm.group(1).strip()
75
+ else:
76
+ dm = re.search(r"^description:\s*>\-\s*\n((?:[ \t]+.*\n?)+)", fm, re.M)
77
+ if dm:
78
+ desc = " ".join(line.strip() for line in dm.group(1).splitlines() if line.strip())
79
+ return {"name": name.group(1) if name else "", "description": desc}
80
+
81
+
82
+ def check() -> bool:
83
+ print(f"[check] {NAME}")
84
+ fm = read_frontmatter()
85
+ if fm.get("name") != NAME:
86
+ fail(f"frontmatter name={fm.get('name')!r} 与目录名 {NAME!r} 不一致")
87
+ else:
88
+ ok("name 与目录名一致")
89
+ desc = fm.get("description", "")
90
+ if not desc:
91
+ fail("description 为空")
92
+ elif len(desc) > DESC_LIMIT:
93
+ fail(f"description {len(desc)} 字符,超过 {DESC_LIMIT}")
94
+ else:
95
+ ok(f"description {len(desc)} 字符")
96
+
97
+ for f in ("README.md", "package.json"):
98
+ if not (ROOT / f).is_file():
99
+ fail(f"缺 {f}")
100
+ else:
101
+ ok(f"有 {f}")
102
+ try:
103
+ pkg = json.loads((ROOT / "package.json").read_text(encoding="utf-8"))
104
+ if pkg.get("name") != NAME:
105
+ fail(f"package.json name={pkg.get('name')!r} 与技能名不一致")
106
+ if not re.match(r"^\d+\.\d+\.\d+", str(pkg.get("version", ""))):
107
+ fail("package.json version 非 semver")
108
+ except (OSError, json.JSONDecodeError) as e:
109
+ fail(f"package.json 解析失败: {e}")
110
+
111
+ for p in ROOT.rglob("*"):
112
+ if not p.is_file() or ".git" in p.parts:
113
+ continue
114
+ rel = p.relative_to(ROOT).as_posix()
115
+ for rx in EXCLUDE_RES:
116
+ if rx.search(rel):
117
+ fail(f"不应进包的文件: {rel}")
118
+ break
119
+ ok("无禁用文件")
120
+
121
+ for py in sorted((ROOT / "scripts").glob("*.py")):
122
+ try:
123
+ compile(py.read_text(encoding="utf-8"), str(py), "exec")
124
+ except SyntaxError as e:
125
+ fail(f"{py.name} 编译失败: {e}")
126
+ ok("scripts/*.py 编译通过")
127
+
128
+ refdir = ROOT / "references"
129
+ if refdir.is_dir():
130
+ for md in sorted(refdir.glob("*.md")):
131
+ if md.stat().st_size == 0:
132
+ fail(f"references/{md.name} 为空")
133
+ skill_text = (ROOT / "SKILL.md").read_text(encoding="utf-8")
134
+ for ref in sorted(set(re.findall(r"references/([\w\-.]+\.md)", skill_text))):
135
+ if not (refdir / ref).is_file():
136
+ fail(f"SKILL.md 引用的 references/{ref} 不存在")
137
+ ok("references 完整性通过")
138
+
139
+ if errors:
140
+ print(f"\n[check] {NAME}: {len(errors)} 项失败")
141
+ return False
142
+ print(f"\n[check] {NAME}: 全部通过")
143
+ return True
144
+
145
+
146
+ def package() -> None:
147
+ if not check():
148
+ sys.exit(1)
149
+ (ROOT / "dist").mkdir(exist_ok=True)
150
+ entries: list[str] = []
151
+ with zipfile.ZipFile(OUT, "w", zipfile.ZIP_DEFLATED) as z:
152
+ for p in sorted(ROOT.rglob("*")):
153
+ if not p.is_file() or ".git" in p.parts:
154
+ continue
155
+ rel = p.relative_to(ROOT)
156
+ rel_posix = rel.as_posix()
157
+ if any(rx.search(rel_posix) for rx in EXCLUDE_RES):
158
+ continue
159
+ if not any(
160
+ rel_posix == inc.rstrip("/") or rel_posix.startswith(inc)
161
+ for inc in INCLUDE
162
+ if inc.endswith("/")
163
+ ) and rel_posix not in INCLUDE:
164
+ continue
165
+ z.write(p, arcname=f"{NAME}/{rel_posix}")
166
+ entries.append(rel_posix)
167
+ sha = hashlib.sha256(OUT.read_bytes()).hexdigest()
168
+ pkg = json.loads((ROOT / "package.json").read_text(encoding="utf-8"))
169
+ manifest = {
170
+ "name": NAME,
171
+ "version": pkg.get("version"),
172
+ "built_at": datetime.now(timezone.utc).isoformat(),
173
+ "files": len(entries),
174
+ "sha256": sha,
175
+ "entries": entries,
176
+ }
177
+ MANIFEST.write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
178
+ print(f"\n[package] {OUT} ({len(entries)} 文件, sha256={sha[:12]}…)")
179
+
180
+
181
+ def main() -> None:
182
+ if "--check" in sys.argv[1:]:
183
+ sys.exit(0 if check() else 1)
184
+ package()
185
+
186
+
187
+ if __name__ == "__main__":
188
+ main()