diff --git a/.claude/skills/mt-rewrite-newsletter/SKILL.md b/.claude/skills/mt-rewrite-newsletter/SKILL.md index 758656d..acf11da 100644 --- a/.claude/skills/mt-rewrite-newsletter/SKILL.md +++ b/.claude/skills/mt-rewrite-newsletter/SKILL.md @@ -26,12 +26,16 @@ Classified by `node scripts/newsletter protected-lines `: | `frontmatter` | the whole `---` block | | `heading` | `## [Source Title](url)`, `### Bonus`, `## Bonus: Vài ảnh hay ho…` | | `html-block` | ` … ` greetings and intros | +| `author-note` | `**Đánh giá:** *…*` — the author's verdict on the tool/model used for the post | | `italic-note` | `*Mời bạn thưởng thức Newsletter #7.*`, author notes in italics | | `struck` | `~~…~~` lines — entries the author struck out stay struck and unchanged | | `asset` | `![label](url)`, `[video title](url)`, `**Images:**` | +| `code` | fenced code blocks in a summary — keep content verbatim, placed after the prose they illustrate; when the block sat inside a list, de-indent the whole block to column 0 (the check allows re-indenting) | `candidates` are paragraphs containing `mình` / `MiTi`. Judge each one: **author voice** (the blog author talking to readers — "tuần này mình đi chơi…") → keep verbatim; **summary voice** (paraphrasing the source author — "tác giả chia sẻ dự án của mình") → rewrite. When unsure, keep it and list it in the report. +`###`-level sub-headings inside a live entry (`### Kết luận:`, `### Điểm chính cần lưu ý:`, `### Ứng dụng thực tế`) and short italic labels ending in `:` (`*Những điểm chính cần ghi nhớ:*`) are AI summary structure, not handwritten: fold their content into the prose and drop them. Full-sentence italic notes inside an entry are the author's (e.g. model-review remarks) and stay. `protected-lines` leaves them unprotected; `Bonus` headings anywhere stay protected. + Anything else that is not an AI summary paragraph — a blank-line separator, a `---` rule, a bare comment, a list the author obviously typed — also stays. Rewrite only summary prose/lists under an entry heading or a video link. ## Workflow @@ -40,7 +44,7 @@ Anything else that is not an AI summary paragraph — a blank-line separator, a ```bash grep -rl --include=index.md -E '^categories:.*Newsletter' content/post | sort ``` - Drop posts whose `protected-lines` output has `newsletter_post: false`, and (unless `--force`) posts whose `note` already credits the chosen model. Show the count and the first/last post; for scope `all` or more than 10 posts, confirm with the user before editing. + Drop posts whose frontmatter `tags` lack `AI-Assisted` (the author wrote them by hand — nothing to rewrite, and a rewrite note would be false), posts whose `protected-lines` output has `newsletter_post: false`, and (unless `--force`) posts whose `note` already credits the chosen model. Show the count and the first/last post; for scope `all` or more than 10 posts, confirm with the user before editing. 2. **Per post, sequentially within the post** (posts are independent and may run in parallel subagents — at most 5 at once, one post per subagent, never two agents on the same file): diff --git a/docs/newsletter/engine-commands.md b/docs/newsletter/engine-commands.md index 74ec707..b6f8ee7 100644 --- a/docs/newsletter/engine-commands.md +++ b/docs/newsletter/engine-commands.md @@ -157,8 +157,9 @@ Counts what a post already holds, so a handler can report a running tally. ### `protected-lines [--against ]` Lists the lines a rewrite (`mt-rewrite-newsletter`) must keep byte-for-byte: -frontmatter, headings, `` blocks, whole-line italic notes, `~~struck~~` -lines, and asset links / Bonus subsection labels. Paragraphs mentioning `mình` +frontmatter, headings (except `###` sub-headings inside a summary), `` +blocks, whole-line italic notes, `~~struck~~` lines, fenced code blocks, and +asset links / Bonus subsection labels. Paragraphs mentioning `mình` or `MiTi` come back as `candidates` for a human-style judgement; the machine provenance note (`*Bài viết đã được … bởi …*`) comes back as `note` and is not protected. diff --git a/scripts/newsletter/protected-lines.js b/scripts/newsletter/protected-lines.js index de36db6..7512b59 100644 --- a/scripts/newsletter/protected-lines.js +++ b/scripts/newsletter/protected-lines.js @@ -1,6 +1,6 @@ // List the lines of a newsletter post that a rewrite must leave byte-for-byte // intact — frontmatter, headings, the author's handwritten notes, struck-out -// entries, and asset links — and, with --against, prove a rewritten post still +// entries, code samples, and asset links — and, with --against, prove a rewritten post still // holds every one of them in the original order. // Usage: node scripts/newsletter protected-lines [--against ] // Outputs: JSON { post, newsletter, newsletter_post, note?, lines, candidates } @@ -13,10 +13,21 @@ import { NEWSLETTER_NUM_RE } from "./find-newsletter-number.js"; const FENCE_RE = /^---\s*$/; const NEWSLETTER_CATEGORY_RE = /^categories:.*\bNewsletter\b/; const HEADING_RE = /^#{1,6}\s/; +const ENTRY_HEADING_RE = /^##\s+\[/; +const STRUCK_ENTRY_HEADING_RE = /^##\s+~~/; +const SUBHEADING_RE = /^#{3,6}\s/; +const BONUS_RE = /\bBonus\b/i; const HTML_OPEN_RE = /^<(i|em|div|p|blockquote)\b[^>]*>/i; const HTML_CLOSE_RE = /<\/(i|em|div|p|blockquote)>\s*$/i; const STRUCK_RE = /^~~.*~~$/; +const CODE_FENCE_RE = /^(```|~~~)/; const ITALIC_LINE_RE = /^(\*[^*].*\*|_[^_].*_)$/; +// "*Những điểm chính cần ghi nhớ:*" — a short italic label that introduced a +// machine key-points list. The author's own italic notes are full sentences. +const ITALIC_LABEL_RE = /^[*_].{1,60}:[*_]$/; +// "**Đánh giá:** *…*" — the author's bold-labelled verdict on the tool or +// model that produced the post, written in italics after the label. +const AUTHOR_NOTE_RE = /^\*\*Đánh giá:?\*\*:?\s*[*_]/; const ASSET_LINE_RE = /^!?\[[^\]]*\]\([^)]*\)$/; const SUBSECTION_RE = /^\*\*(Images|Videos|Documents):\*\*$/; // The blog author writes as "mình" / "MiTi"; AI summaries speak about the @@ -61,10 +72,28 @@ export function classifyPost(content) { } let inHtml = false; + let inCode = false; + let codeIndent = ""; + // zone tracks which part of the body a line sits in. Inside a live entry, + // "### Kết luận:"-style sub-headings are part of the machine summary, so + // they stay rewritable; everywhere else a heading is the author's. + /** @type {"pre" | "entry" | "struck" | "other"} */ + let zone = "pre"; for (; i < rows.length; i++) { const text = rows[i]; const trimmed = text.trim(); const line = i + 1; + // Code samples carry facts a paraphrase cannot, so they survive a rewrite + // verbatim, blank lines included. + // Code is stored relative to its fence's indent, so a block that sat + // inside a list may move out of it once the list becomes prose. + if (inCode || CODE_FENCE_RE.test(trimmed)) { + if (!inCode) codeIndent = text.slice(0, text.length - text.trimStart().length); + const rel = text.startsWith(codeIndent) ? text.slice(codeIndent.length) : text.trimStart(); + lines.push({ line, kind: "code", text: rel }); + if (CODE_FENCE_RE.test(trimmed)) inCode = !inCode; + continue; + } if (trimmed === "") continue; if (inHtml || HTML_OPEN_RE.test(trimmed)) { @@ -77,9 +106,17 @@ export function classifyPost(content) { continue; } if (HEADING_RE.test(trimmed)) { + if (ENTRY_HEADING_RE.test(trimmed)) zone = "entry"; + else if (STRUCK_ENTRY_HEADING_RE.test(trimmed)) zone = "struck"; + else if (BONUS_RE.test(trimmed) || !SUBHEADING_RE.test(trimmed)) zone = "other"; + else if (zone === "entry") continue; lines.push({ line, kind: "heading", text }); } else if (STRUCK_RE.test(trimmed)) { lines.push({ line, kind: "struck", text }); + } else if (AUTHOR_NOTE_RE.test(trimmed)) { + lines.push({ line, kind: "author-note", text }); + } else if (zone === "entry" && ITALIC_LABEL_RE.test(trimmed)) { + continue; } else if (ITALIC_LINE_RE.test(trimmed)) { lines.push({ line, kind: "italic-note", text }); } else if (ASSET_LINE_RE.test(trimmed) || SUBSECTION_RE.test(trimmed)) { @@ -91,6 +128,17 @@ export function classifyPost(content) { return { newsletterPost, note, lines, candidates }; } +/** + * sameCodeLine accepts a code line re-indented as a whole block: the row must + * end with the stored fence-relative text and differ only by leading space. + * @param {string} row + * @param {string} rel + * @returns {boolean} + */ +function sameCodeLine(row, rel) { + return row.endsWith(rel) && row.slice(0, row.length - rel.length).trim() === ""; +} + /** * findMissing reports every expected line that the rewritten post no longer * contains, matching in order so a duplicated line must survive as often as it @@ -107,7 +155,7 @@ export function findMissing(content, expected) { for (const want of expected) { let found = -1; for (let j = cursor; j < rows.length; j++) { - if (rows[j] === want.text) { + if (rows[j] === want.text || (want.kind === "code" && sameCodeLine(rows[j], want.text))) { found = j; break; } diff --git a/scripts/newsletter/protected-lines.test.js b/scripts/newsletter/protected-lines.test.js index 9f512b0..6e3b140 100644 --- a/scripts/newsletter/protected-lines.test.js +++ b/scripts/newsletter/protected-lines.test.js @@ -24,6 +24,22 @@ Chào các bạn, mình vừa đi chơi về. Tác giả chia sẻ kinh nghiệm của mình về hệ thống phân tán. +\`\`\`java +break outer; +\`\`\` + +*Những điểm chính cần ghi nhớ:* + +*Mình dùng thử công cụ này thấy khá ổn, nhưng hơi chậm.* + +**Đánh giá:** *Công cụ chạy ổn, nhưng còn dùng nhiều tiếng Anh.* + +**Đánh giá mở rộng quy mô** là một phần của bài viết. + +### Kết luận: + +- Một ý tóm tắt. + ## ~~[Bad Article](https://example.com/b)~~ ~~Tóm tắt cũ đã bị gạch.~~ @@ -52,6 +68,13 @@ test("classifyPost keeps structure and handwritten lines, not summaries", () => assert.ok(kinds.includes("asset:**Images:**")); assert.equal(lines.filter((l) => l.kind === "frontmatter").length, 6); assert.ok(!lines.some((l) => l.text.startsWith("Tác giả")), "summaries stay rewritable"); + assert.ok(!lines.some((l) => l.text === "### Kết luận:"), "sub-headings inside a summary stay rewritable"); + assert.ok(kinds.includes("heading:### Bonus"), "Bonus headings stay protected"); + assert.ok(kinds.includes("code:break outer;"), "code samples stay verbatim"); + assert.ok(!lines.some((l) => l.text === "*Những điểm chính cần ghi nhớ:*"), "key-points labels stay rewritable"); + assert.ok(kinds.includes("author-note:**Đánh giá:** *Công cụ chạy ổn, nhưng còn dùng nhiều tiếng Anh.*"), "the author's verdict lines stay protected"); + assert.ok(!lines.some((l) => l.text.startsWith("**Đánh giá mở rộng")), "a bold phrase in a summary is not a verdict"); + assert.ok(kinds.includes("italic-note:*Mình dùng thử công cụ này thấy khá ổn, nhưng hơi chậm.*"), "the author's notes inside an entry stay protected"); assert.ok(!lines.some((l) => l.text.includes("Opus 4.7")), "the provenance note is replaced, not kept"); assert.deepEqual( @@ -85,3 +108,12 @@ test("findMissing reports an edited handwritten line and a reordered heading", ( ); assert.ok(findMissing(moved, lines).length > 0, "order is part of the contract"); }); + +test("findMissing lets a code block leave its list indent", () => { + const post = "## [A](https://example.com/a)\n\n- Ý chính:\n\n ```java\n if (x) {\n run();\n }\n ```\n"; + const { lines } = classifyPost(post); + const dedented = "## [A](https://example.com/a)\n\nVăn xuôi.\n\n```java\nif (x) {\n run();\n}\n```\n"; + assert.deepEqual(findMissing(dedented, lines), []); + const edited = dedented.replace(" run();", " stop();"); + assert.equal(findMissing(edited, lines).length, 1, "code content still has to match"); +});