feat(newsletter): protect code, verdicts and author notes in rewrites

protected-lines now keeps fenced code blocks (re-indent allowed) and
**Đánh giá:** verdict lines, and leaves summary sub-headings and short
italic key-point labels rewritable. The rewrite skill skips posts
without the AI-Assisted tag.
This commit is contained in:
tiennm99 committed 2026-09-27 21:47:31 +07:00
1 parent c0eecc1263
commit 210d2cdbea
4 files changed
+90 -5

No files matched your search

@@ -26,12 +26,16 @@ Classified by `node scripts/newsletter protected-lines <post>`:
| `frontmatter` | the whole `---` block |
| `heading` | `## [Source Title](url)`, `### Bonus`, `## Bonus: Vài ảnh hay ho…` |
| `html-block` | `<i> … </i>` 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):
+3 -2
View File
@@ -157,8 +157,9 @@ Counts what a post already holds, so a handler can report a running tally.
### `protected-lines <path/to/index.md> [--against <snapshot.json>]`
Lists the lines a rewrite (`mt-rewrite-newsletter`) must keep byte-for-byte:
frontmatter, headings, `<i>` 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), `<i>`
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.
+50 -2
View File
@@ -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 <path/to/index.md> [--against <snapshot.json>]
// 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;
}
@@ -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");
});