From f2557086b4bc37641075ee8ff2ca6bd1a9513da7 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 17 Oct 2026 23:13:33 +0700 Subject: [PATCH] feat(skills): add the announce skill for release posts, newsletters and X --- .claude/skills/announce/SKILL.md | 75 ++++ .../skills/announce/references/blog-post.md | 116 ++++++ .../skills/announce/references/fact-checks.md | 87 +++++ .../announce/references/newsletter-and-x.md | 101 ++++++ .claude/skills/announce/scripts/lint-draft.py | 331 ++++++++++++++++++ CLAUDE.md | 2 + tests/Feature/AnnounceLintDraftTest.php | 124 +++++++ 7 files changed, 836 insertions(+) create mode 100644 .claude/skills/announce/SKILL.md create mode 100644 .claude/skills/announce/references/blog-post.md create mode 100644 .claude/skills/announce/references/fact-checks.md create mode 100644 .claude/skills/announce/references/newsletter-and-x.md create mode 100755 .claude/skills/announce/scripts/lint-draft.py create mode 100644 tests/Feature/AnnounceLintDraftTest.php diff --git a/.claude/skills/announce/SKILL.md b/.claude/skills/announce/SKILL.md new file mode 100644 index 00000000..36ee5c4b --- /dev/null +++ b/.claude/skills/announce/SKILL.md @@ -0,0 +1,75 @@ +--- +name: announce +description: > + Writes the announcement for a TablePro Mac release: the release post in resources/blog/ with its + figures, then the newsletter and the X posts written from it, and the checks that keep all three + true. Use when the user says "announce 0.82", "blog post for the release", "release post for + 0.82", "newsletter for 0.82", "write the X thread", "X posts for 0.82", hands over an X draft to + polish, or asks whether a release is worth announcing. Not for cutting the release itself: that is + the release skill in TableProApp/TablePro. +--- + +# Announce + +The post is the announcement; the newsletter and the X posts are written from the finished post, so +the three cannot disagree. Order: post, newsletter, X. + +Commands run from this repository's root. `../TablePro` and `../tablepro-docs` are the app and docs +checkouts beside it: confirm the paths with the user and `git pull --ff-only` both first. + +## Is it worth announcing + +Worth a post when the shipped `### Added` has something a reader would change their behavior over (a +new pane or mode, a new way to connect, a new database, a rebuilt surface they will not recognize), +when something people rely on was removed or changed under them, or when a long-standing class of +bugs users have asked about is gone. Otherwise the changelog and the docs changelog are the +announcement. The entry count per section is a sanity check, not the rule. + +Say which way you went and why before any work, and ask when it is borderline: an unwanted +newsletter costs an hour, a skipped one costs a launch. + +## Blockers + +Nothing goes out until each holds. Report them apart from the drafts. + +- The release is live. In `php artisan release:check`, the live value of the GitHub + `releases/latest` and Sparkle appcast `version` rows is the new version. A DRIFT on those rows + against an old `resources/data/platforms.json` is the site's own follow-up, not a blocker. +- The docs changelog is deployed with the version: + `curl -s https://docs.tablepro.app/changelog | grep -c 'v'` prints more than 0. +- `references/fact-checks.md` holds for every claim, plugin and screenshot. +- The fix count is recounted from the shipped section of `../TablePro/CHANGELOG.md`. + +`main` deploys itself on green tests, so merging the post publishes it. A post that says "update +now" before the build exists sends readers to the previous version. + +## 1. The post + +`references/blog-post.md`: one file in `resources/blog/`, its figures and its test edits, through a +pull request. + +Once it is live, link it from the version's `` block in `../tablepro-docs/changelog.mdx`: +`[Read the announcement](https://tablepro.app/blog/)` on its own line before the first `###`, +committed as `docs(changelog): link the announcement`. + +## 2. The newsletter, 3. the X posts + +`references/newsletter-and-x.md`. Drafts go in the session scratchpad, named by version +(`newsletter-v0.82.0.md`, `x-posts-v0.82.0.md`), never in this repository, which is public. + +Sending is out of scope. If the user asks to send the newsletter, warn them first: in the license +backend the `all` audience means verified subscribers only, a small fraction of the list, and +`everyone` is the real list. + +## Checks on every draft + +```bash +python3 .claude/skills/announce/scripts/lint-draft.py --blog resources/blog/.md +python3 .claude/skills/announce/scripts/lint-draft.py /newsletter-v.md +python3 .claude/skills/announce/scripts/lint-draft.py --x /x-posts-v.md +../TablePro/scripts/check-banned-words.sh resources/blog/.md /*-v.md +``` + +`lint-draft.py` checks style, budgets, figures and docs links, and exits 1 on anything under +"Fix". `check-banned-words.sh` reads the one banned-word list, `../TablePro/scripts/banned-words.txt`. +Then work `references/fact-checks.md` top to bottom. diff --git a/.claude/skills/announce/references/blog-post.md b/.claude/skills/announce/references/blog-post.md new file mode 100644 index 00000000..445e508f --- /dev/null +++ b/.claude/skills/announce/references/blog-post.md @@ -0,0 +1,116 @@ +# Release post + +A release post is one file, `resources/blog/.md`, English only. `BlogService` globs the +directory, so there is no index or route to edit. It carries the detail, the figures and the limits; +the newsletter and X posts are written from it. + +Three things get a post sent back: no figures, copy that runs long or reads like marketing, and a +layout invented instead of matched to the shipped posts. + +## 1. Read first + +```bash +ls resources/blog/tablepro-0-*.md +grep -n "^## \|^ the hero, right after the intro +## one per featured item, figures between them +## Also new bullets +## Fixes worth knowing about +## What it will not do real gaps, not disguised strengths +## Getting it the update path, and plugins with updates in Settings > Plugins +``` + +Headings are plain statements or names. No questions, slogans or gerund stacks. + +## 4. Frontmatter + +```yaml +--- +slug: tablepro-0-81 # equals the filename +title: "TablePro 0.81: Tabs per Database, Firebase and Bigtable" # quote a title with a colon +description: One or two sentences, at most 155 characters (SeoLengthsTest). +date: 2026-10-16 # the publish date, never changed later +release: "TablePro 0.81" # marks a release post; a guide has none +author: TablePro Team +tags: [release, connections, oracle] +ogPunchline: Two or three short sentences under about 90 characters. The OG card subtitle. +--- +``` + +- Every tag after `release` is a key of `PostTopics::PAGES` or an engine id in + `resources/data/engines.json`; `BlogTest` fails on any other. +- `ogPunchline` is what someone reads on a shared link, not the description restated. It counts no + engines (`NoTypedCountsTest`), and neither field makes a privacy claim (`BannedClaimsTest`). + +## 5. Figures + +Required: never open the post's pull request without them. The screenshots usually exist on the +feature pull requests in TableProApp/TablePro: `gh pr view --json body`, then download the +`user-attachments` URLs with `Authorization: token $(gh auth token)`. + +- Look at each shot before using it. Crop out a home folder path, a version label from the build + that took it, and any backdrop behind a sheet. Sample data only. +- A figure is `` in the post plus an entry in + `resources/data/assets.json`. Copy the newest post's `blog--N` entries and change the key, + `usedOn`, `aspect` (the source's `W:H`), `replacement.base` (the new key), `src`, `legacySource`, + `description`, `alt` and `caption`. +- Files: the PNG at `public/images/blog/.png`, and `blog--N-light-{704,1408}.{avif,webp}` + beside it, each under 200 KB (`cwebp`, `avifenc`). The `figure` kind in `docs/visual-assets.md` + is the spec. +- Alt text describes the picture in one long, specific sentence: what is on screen. The caption says + what the picture proves, in one sentence that stands alone. +- Then `php artisan assets:generate`. A slot left as a placeholder renders nothing in production. + +## 6. Voice + +Dev to dev, plain and short: about 900 words at most, three or four figures. + +- Second person, present tense, contractions. No marketing, no hype, no first person. +- Numbers instead of adjectives: "14 MB instead of a 32 MB zip", never "much faster". +- Backticks on shortcuts, SQL keywords, identifiers, paths and config keys. Bold for menu paths + (`**Settings > Appearance**`) and UI labels. +- Bullets for limits and fixes. Tables only for tabular things. +- State a limit flatly. Never call a competitor bad; say what it costs. +- No em dashes, no banned words, US spelling. + +## 7. Wire it up + +1. `tests/Feature/Landing/BlogTest.php`: + - the slug and date at the top of `BLOG_PUBLISHED`; + - in the `/blog` test, the newest post's `dateFormatted`; + - in the `/vi/blog` test, `posts.0` (slug, url, title, Vietnamese date) and the `slice(N, $guides)` + offset, which counts the release posts newer than the newest guide. +2. Render the OG card and look at it: a long title wraps to three lines and can push the punchline + off. `WEB_DOMAIN=tablepro.app php artisan og:generate --type=blog --slug=`. +3. Run: + + ```bash + python3 .claude/skills/announce/scripts/lint-draft.py --blog resources/blog/.md + ../TablePro/scripts/check-banned-words.sh resources/blog/.md + php artisan test --compact --filter='BlogTest|SeoLengthsTest|OgCardsTest|AssetManifestTest' + php artisan test --compact + ``` + +Then `references/fact-checks.md`. The pull request body lists what the post covers, the figures and +the test edits, a few bullets. diff --git a/.claude/skills/announce/references/fact-checks.md b/.claude/skills/announce/references/fact-checks.md new file mode 100644 index 00000000..78c028c0 --- /dev/null +++ b/.claude/skills/announce/references/fact-checks.md @@ -0,0 +1,87 @@ +# Fact checks + +`lint-draft.py` checks how a sentence reads; this file checks whether it is true. Work it top to +bottom on the post first, then on the newsletter and the X posts written from it. `../TablePro` and +`../tablepro-docs` are the app and docs checkouts, pulled to their `main`. + +## 1. A fix is tied to a version only when git agrees + +```bash +git -C ../TablePro log --oneline -S "" -- +git -C ../TablePro show : | grep -n "" +``` + +If the broken code is also at the tag before that, it was never a regression. Say what was wrong +without naming a version. + +## 2. A behavior change matches what the old code did + +Before grouping several things under one verb ("joins, unions and CTEs are read-only now"), read the +diff and confirm each one changed. A summary easily drops the changelog's scope. + +## 3. A plugin feature needs a published binary + +It is not installable until its tag exists, the tag contains the commit, and the registry lists it: + +```bash +git -C ../TablePro tag -l "plugin--*" | tail -3 +git -C ../TablePro merge-base --is-ancestor plugin--v && echo included || echo NOT included +curl -s https://raw.githubusercontent.com/TableProApp/plugins/main/plugins.json | python3 -c \ + 'import json,sys; [print(p["id"], p["version"]) for p in json.load(sys.stdin)["plugins"] if "" in p["id"]]' +``` + +The registry `id` is the `bundleId` in `../TablePro/.github/plugin-registry.json`. If a check fails, +the plugin ships first or the section goes. + +## 4. Images are real and current + +A figure is taken on a build that contains the change; compare the shot with the pull request that +changed the UI it shows. An `images/*.png` in `../tablepro-docs` at exactly 1560x960 is a +"Screenshot coming soon" card, and `lint-draft.py` flags one a draft links. A real docs image can +still be stale: + +```bash +git -C ../tablepro-docs log -1 --format='%ad %s' --date=short -- images/.png +``` + +Never ship a placeholder or a stale shot. Mark the gap and report it as a blocker. + +## 5. Menu labels come from the app + +The docs paraphrase, so take the string from the menu builder. Watch for labels that change by +engine: "Close Tabs for Other Databases" reads "Other Schemas" on Oracle, Dameng and BigQuery. + +```bash +grep -rn "String(localized:" ../TablePro/TablePro/Core/Menu/ | grep -i "" +``` + +## 6. Tiers, defaults and thresholds + +```bash +grep -n -A3 "case " ../TablePro/TablePro/Models/Settings/ProFeature.swift +``` + +- `requiredTier` is the lowest tier that includes a feature, and Team includes Starter: a Starter + feature "needs a license, and both tiers include it", a Team feature needs Team. +- Describe a gate as it behaves: a locked overlay is not a disabled menu item. +- Quote the number that decides a behavior, or none, after reading every constant in the model + file, including the default range a comparison runs over. + +## 7. Every URL resolves + +`lint-draft.py` resolves each `docs.tablepro.app` page and image against `../tablepro-docs`. For an +anchor, read the headings: + +```bash +grep -n "^## " ../tablepro-docs/
/.mdx +``` + +The docs `changelog.mdx` carries the version only after the release-day docs deploy, so that link is +a blocker until then, not a broken link. + +## 8. Counts and references + +- Count the bullets under `### Fixed` in the shipped section of `../TablePro/CHANGELOG.md` and write + "N fixes", never "N bugs fixed": one bullet can cover several issues. +- Never inflate the count with issue or commit counts. +- Leave issue numbers out of every announcement; changelog references are sometimes wrong. diff --git a/.claude/skills/announce/references/newsletter-and-x.md b/.claude/skills/announce/references/newsletter-and-x.md new file mode 100644 index 00000000..5367d11c --- /dev/null +++ b/.claude/skills/announce/references/newsletter-and-x.md @@ -0,0 +1,101 @@ +# Newsletter and X posts + +Both are short and both point at the post. Write them from the finished post, not from the changelog, +so the three cannot disagree. + +## The newsletter + +**150 to 250 words**, one phone screen. It says a version exists, names the headline items one line +each, and links the post. + +```markdown +**Subject:** TablePro : + +**Preview text:** + +--- + +# TablePro + + + +- ****: +- ****: +- ****: + + + +[Read the full post]() + +Update from **TablePro > Check for Updates**, or [download it](https://tablepro.app/download). +``` + +- **The bullets are the whole body**: three to five, in the post's order. No headings, images or + tables. A bullet that needs a second sentence belongs in the post. +- **Put these in the mail itself**, because the readers who do not click are the ones they hurt: a + breaking change or removal, and a one-time reset or migration with what it does not affect. +- **Subject**: `TablePro : ` plus two or three plain items, lowercase after the colon, comma + separated, no adjectives. Gmail on iOS cuts near 40 characters, so the first item finishes inside + that. Every item in the subject is a bullet. +- **Preview text**: 40 to 90 characters that add what the subject left out. Nothing load-bearing + lives only here: Apple Intelligence can replace it with a generated summary. + +### Newsletter voice + +Each rule is a yes or no against the draft, and a no is a rewrite. + +1. The opener names what the release is about in different words from the subject. +2. No first person: the subject of a sentence is the product, the feature, or "you". +3. Nothing tells the reader what is worth their time or how they will feel ("fixes you will + notice"). +4. No em dashes, semicolons, exclamation marks or emoji. +5. Figures instead of vague quantifiers: "more than 130 other fixes", not "many". +6. No prose sentence over 35 words. An enumeration after a colon may run longer. +7. Name the old behavior beside the new one where it fits in one line. +8. Second person, present tense, active voice. No "will now", no "has been improved". +9. Backticks on shortcuts, SQL keywords and literals (`Cmd+F`, `EXPLAIN ANALYZE`). Bold for menu + paths and the first mention of a new proper noun, not for run-in headlines. +10. US spelling (color, behavior), as in the docs and the app. + +## The X posts + +A thread of **four to six posts** plus one standalone, one feature per post. + +**Voice: an indie developer telling other developers what shipped.** Casual and plain, the way a dev +types on X: "you can now", "handy when", contractions, short lines. First person is fine here, and so +is a question opener or a little humor. Humble, not promotional: no PR or AI phrasing, no thread +hooks ("a thread", "here's why this matters"), no engagement bait, no "and much more", no hashtags +or emoji unless one really fits. Not every post has to read as an announcement. + +- **Order by reach**: the features most users touch first (totals, tabs, import), niche ones (one + engine's sign-in, a socket option) in the round-up. +- **280 characters at most** per post (a link counts 23), and one needing 279 is too dense. +- **No markdown**: X renders none of it. +- **Links only in the last post** and the standalone. An earlier link costs reach. +- **One image per post at most**, named in the post header, and only images that exist and are + current. +- **The first post works alone**: the version and the biggest change in its first two lines. +- **The standalone post** is the version, three or four changes in plain nouns, then the link. It is + the one that gets reposted, so it needs no context. + +```markdown +## 1/5 (attach: ) + +TablePro 0.82 is out. + + + +## 5/5 + +Also in 0.82: + +Full post: +Download: https://tablepro.app/download + +## Standalone + +TablePro 0.82: . +``` + +When the user hands over their own draft to polish, keep its meaning, fix the wording, and reply +with the final text only, ready to post. diff --git a/.claude/skills/announce/scripts/lint-draft.py b/.claude/skills/announce/scripts/lint-draft.py new file mode 100755 index 00000000..ed716dcf --- /dev/null +++ b/.claude/skills/announce/scripts/lint-draft.py @@ -0,0 +1,331 @@ +#!/usr/bin/env python3 +import argparse +import json +import re +import sys +from pathlib import Path + +HARD = "hard" +SOFT = "soft" + +WEB_ROOT = Path(__file__).resolve().parents[4] +ASSETS = WEB_ROOT / "resources" / "data" / "assets.json" +DEFAULT_DOCS = WEB_ROOT.parent / "tablepro-docs" + +HYPE_PHRASES = [ + "you asked, we listened", "and much more", "worth your time", "you will notice", + "excited", "thrilled", "introducing the", +] + +VAGUE = [ + "many", "several", "a lot of", "significantly", "much faster", "greatly", + "various", "numerous", "a number of", +] + +BRITISH = re.compile(r"colour|behaviour|honour|recognis", re.I) +FIRST_PERSON = re.compile( + r"(?') +URL = re.compile(r"https?://\S*[^\s.,;:!?'\")\]]") +DOCS_URL = re.compile(r"https://docs\.tablepro\.app/([A-Za-z0-9_./-]*)") +THREAD_HEADER = re.compile(r"^##\s+(\d+)/(\d+)\b") +MARKDOWN = re.compile(r"\*\*|`|\[[^\]]+\]\([^)]+\)") + +BLOG_FIELDS = ["slug", "title", "description", "date", "release", "author", "tags", "ogPunchline"] + +SUBJECT_COMFORTABLE = 48 +SUBJECT_FIRST_ITEM = 41 +PREVIEW_MIN, PREVIEW_MAX = 40, 95 +MAX_SENTENCE_WORDS = 35 +DESCRIPTION_MAX = 155 +OG_PUNCHLINE_MAX = 90 +SPREAD_FLOOR, SPREAD_TARGET = 2.0, 3.0 +X_POST_MAX = 280 +X_LINK_LENGTH = 23 +X_LIGHT_RANGES = ((0x0000, 0x10FF), (0x2000, 0x200D), (0x2010, 0x201F), (0x2032, 0x2037)) +PLACEHOLDER_SIZE = (1560, 960) + + +def strip_links(text): + return URL.sub("", re.sub(r"\[([^\]]*)\]\([^)\s]*\)", r"\1", text)) + + +def x_length(text): + text = EMOJI_SEQUENCE.sub("xx", URL.sub("x" * X_LINK_LENGTH, text)) + return sum(1 if any(low <= ord(c) <= high for low, high in X_LIGHT_RANGES) else 2 for c in text) + + +def strip_code(text): + text = re.sub(r"```.*?```", lambda m: "\n" * m.group(0).count("\n"), text, flags=re.S) + return re.sub(r"`[^`\n]*`", "``", text) + + +def strip_frontmatter(text): + return FRONTMATTER.sub(lambda m: "\n" * m.group(0).count("\n"), text, count=1) + + +def lines_of(text): + return text.split("\n") + + +def find_word(text, term): + pattern = re.compile(r"(?", "|", "---", "<", "**Subject:", "**Preview")): + continue + collapsed = re.sub(r"\[([^\]]*)\]\([^)]*\)", r"\1", stripped) + collapsed = re.sub(r"^[-*]\s+", "", collapsed) + for sentence in re.split(r"(?<=[.!?])\s+", collapsed): + if sentence.strip(): + out.append((i, sentence.strip())) + return out + + +def check_prose(clean, kind, add): + for i, line in enumerate(lines_of(clean), 1): + if kind != "x": + for match in FIRST_PERSON.finditer(line): + if match.group(0) == "US": + continue + add(HARD, "first person", f'"{match.group(0)}", the subject is the product or "you"', i) + if ";" in line and "&#" not in line: + add(HARD if kind == "newsletter" else SOFT, "semicolon", "split the sentence", i) + if re.search(r"!(?!\[)", line) and not line.lstrip().startswith(" MAX_SENTENCE_WORDS and ":" not in sentence: + add(SOFT, "long sentence", f"{count} words, split it", i) + + +def check_envelope(text, add): + subject = re.search(r"^\*\*Subject:\*\*\s*(.+)$", text, re.M) + if subject: + value = subject.group(1).strip() + head = value.split(",")[0] + if len(value) > SUBJECT_COMFORTABLE: + add(SOFT, "subject length", f"{len(value)} chars, Apple Mail on iPhone shows about 48") + if len(head) > SUBJECT_FIRST_ITEM: + add(HARD, "subject first item", f"{len(head)} chars, Gmail on iOS cuts near 40") + items = [p for p in value.split(":", 1)[-1].split(",") if p.strip()] + if len(items) > 3: + add(SOFT, "subject items", f"{len(items)} items, use two or three") + else: + add(SOFT, "subject", "no **Subject:** line found") + + preview = re.search(r"^\*\*Preview text:\*\*\s*(.+)$", text, re.M) + if preview: + length = len(preview.group(1).strip()) + if not PREVIEW_MIN <= length <= PREVIEW_MAX: + add(SOFT, "preview length", f"{length} chars, aim for {PREVIEW_MIN} to {PREVIEW_MAX}") + elif subject: + add(SOFT, "preview text", "no **Preview text:** line found") + + +def unquote(value): + value = value.strip() + if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'": + return value[1:-1] + return value + + +def load_assets(): + try: + with open(ASSETS, encoding="utf-8") as handle: + return json.load(handle).get("assets", {}) + except (OSError, ValueError): + return None + + +def check_blog(text, draft_path, add): + front = FRONTMATTER.match(text) + if not front: + add(HARD, "frontmatter", "no --- block at the top") + return + fields = dict(re.findall(r"^(\w+):\s*(.*)$", front.group(1), re.M)) + for name in BLOG_FIELDS: + if not fields.get(name): + add(HARD, "frontmatter", f"missing {name}") + stem = Path(draft_path).stem + if fields.get("slug") and fields["slug"] != stem: + add(HARD, "frontmatter", f"slug {fields['slug']} does not match the filename {stem}") + title = fields.get("title", "") + if ":" in title and not title.startswith(('"', "'")): + add(HARD, "frontmatter", "a title with a colon must be quoted") + description = unquote(fields.get("description", "")) + if len(description) > DESCRIPTION_MAX: + add(HARD, "description", f"{len(description)} chars, SeoLengthsTest allows {DESCRIPTION_MAX}") + punchline = unquote(fields.get("ogPunchline", "")) + if len(punchline) > OG_PUNCHLINE_MAX: + add(SOFT, "ogPunchline", f"{len(punchline)} chars, keep it under about {OG_PUNCHLINE_MAX}") + + check_slots(text, add) + + body = ASSET_SLOT.sub("", text[front.end():]) + sections = [len(s.split()) for s in re.split(r"\n## ", body)[1:]] + if len(sections) >= 2 and min(sections) > 0: + spread = max(sections) / min(sections) + detail = f"{spread:.1f}x between sections of {min(sections)} and {max(sections)} words" + if spread < SPREAD_FLOOR: + add(HARD, "section spread", f"{detail}, every section weighs the same") + elif spread < SPREAD_TARGET: + add(SOFT, "section spread", f"{detail}, aim for {SPREAD_TARGET:.0f}x") + + +def check_slots(text, add): + slots = [(i, slot) for i, line in enumerate(lines_of(text), 1) for slot in ASSET_SLOT.findall(line)] + if not slots: + add(HARD, "figures", "no , a release post ships with its figures") + for i, line in enumerate(lines_of(text), 1): + if re.search(r" with an entry in resources/data/assets.json", i) + if not slots: + return + assets = load_assets() + if assets is None: + add(HARD, "asset slot", f"cannot read {ASSETS}") + return + for i, slot in slots: + entry = assets.get(slot) + if entry is None: + add(HARD, "asset slot", f"{slot} has no entry in resources/data/assets.json", i) + elif entry.get("status") != "supplied": + add(HARD, "asset slot", f"{slot} is a placeholder, which renders nothing in production", i) + + +def check_x(text, add): + posts = [] + for i, line in enumerate(lines_of(text), 1): + if line.startswith("## "): + posts.append((i, line, [])) + elif posts: + posts[-1][2].append(line) + if not posts: + add(HARD, "posts", "no `## 1/5` headers, one per post") + return + for i, header, lines in posts: + body = "\n".join(lines).strip() + length = x_length(body) + if length > X_POST_MAX: + add(HARD, "post length", f"{length} chars, X allows {X_POST_MAX}", i) + if MARKDOWN.search(body): + add(HARD, "markdown", "X renders none of it", i) + thread = THREAD_HEADER.match(header) + if thread and int(thread.group(1)) < int(thread.group(2)) and URL.search(body): + add(SOFT, "early link", "a link before the last post costs reach", i) + + +def check_docs(text, docs, add): + links = [(i, m) for i, line in enumerate(lines_of(text), 1) for m in DOCS_URL.findall(line)] + if not links: + return + if not docs.is_dir(): + add(SOFT, "docs links", f"{docs} not found, so no docs link was checked; pass --docs") + return + for i, path in links: + path = path.rstrip("./") + if path.startswith("images/"): + image = docs / path + if not image.is_file(): + add(HARD, "missing image", path, i) + elif png_size(image) == PLACEHOLDER_SIZE: + add(HARD, "placeholder image", f"{path} is a 1560x960 placeholder card", i) + elif not any(p.is_file() for p in (docs / f"{path or 'index'}.mdx", docs / path / "index.mdx")): + add(HARD, "missing docs page", path or "/", i) + + +def png_size(path): + try: + with open(path, "rb") as handle: + header = handle.read(24) + except OSError: + return None + if header[:8] != b"\x89PNG\r\n\x1a\n": + return None + return int.from_bytes(header[16:20], "big"), int.from_bytes(header[20:24], "big") + + +def main(): + parser = argparse.ArgumentParser( + description="Mechanical checks for a release announcement draft: house style, budgets, " + "figures and docs links. Banned words are ../TablePro/scripts/check-banned-words.sh; " + "whether a sentence is true is references/fact-checks.md.", + ) + parser.add_argument("draft") + kind = parser.add_mutually_exclusive_group() + kind.add_argument("--blog", action="store_true", help="a post in resources/blog/") + kind.add_argument("--x", action="store_true", help="X posts, one `## n/m` header per post") + parser.add_argument("--docs", type=Path, default=DEFAULT_DOCS, help="the docs checkout (default: %(default)s)") + args = parser.parse_args() + + try: + with open(args.draft, encoding="utf-8") as handle: + text = handle.read() + except OSError as error: + print(f"lint-draft.py: {error}", file=sys.stderr) + return 2 + + findings = [] + + def add(level, rule, detail, line=None): + findings.append((level, rule, detail, line)) + + kind = "blog" if args.blog else "x" if args.x else "newsletter" + clean = strip_links(strip_code(strip_frontmatter(text))) + check_prose(clean, kind, add) + if kind == "blog": + check_blog(text, args.draft, add) + elif kind == "x": + check_x(text, add) + else: + check_envelope(text, add) + check_docs(text, args.docs, add) + + hard = [f for f in findings if f[0] == HARD] + soft = [f for f in findings if f[0] == SOFT] + words = len(ASSET_SLOT.sub("", clean).split()) + print(f"{args.draft}: {words} words, {len(hard)} to fix, {len(soft)} to look at\n") + + for label, group in (("Fix", hard), ("Look at", soft)): + if not group: + continue + print(f"{label}:") + for _, rule, detail, line in sorted(group, key=lambda f: (f[1], f[3] or 0)): + where = f"line {line}" if line else "header" + print(f" {where:>10} {rule}: {detail}") + print() + + if not findings: + print("Nothing mechanical to report. The factual pass is references/fact-checks.md.") + return 1 if hard else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/CLAUDE.md b/CLAUDE.md index 94701969..8afc3fa4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -146,6 +146,8 @@ in the job with a live SSR service. It skips locally without one. - A blog post is a markdown file in `resources/blog/`; the filename is the slug. A Vietnamese version of a guide is `resources/blog/vi/{same slug}.md` and keeps the original `date`. Release posts stay English-only. +- A release post, its newsletter and its X posts follow the `announce` skill + (`.claude/skills/announce/SKILL.md`). - Database, comparison and feature pages need a slug in the matching constant class in `app/Support/Content/Slugs/` (never in `routes/`) and a content file in **every** locale. A slug with content in only one locale renders only diff --git a/tests/Feature/AnnounceLintDraftTest.php b/tests/Feature/AnnounceLintDraftTest.php new file mode 100644 index 00000000..9fe8a744 --- /dev/null +++ b/tests/Feature/AnnounceLintDraftTest.php @@ -0,0 +1,124 @@ + + +## Tabs per database + +Each database in the connections strip keeps its own tabs, and the tab bar shows the tabs of the one you're on. Switching database brings back that database's last tab, and a query tab stays on the database it was opened in. + +## Getting it + +Choose **TablePro > Check for Updates**. +MD; + +const LINT_DRAFT_NEWSLETTER = <<<'MD' +**Subject:** TablePro 9.9: tabs per database + +**Preview text:** Plus eight fixes, and query tabs that remember their database. + +--- + +# TablePro 9.9 + +Each database on a connection keeps its own tabs. +MD; + +const LINT_DRAFT_X = <<<'MD' +## 1/2 + +TablePro 9.9 is out. We finally made tabs follow the database you're on. + +## 2/2 + +Full post: https://tablepro.app/blog/a-post +MD; + +/** + * @return array{0: int|null, 1: string} + */ +function lintDraft(string $text, string ...$options): array +{ + $directory = sys_get_temp_dir() . '/lint-draft-' . bin2hex(random_bytes(4)); + mkdir($directory); + $draft = "{$directory}/a-post.md"; + file_put_contents($draft, $text); + + $process = new Process(['python3', base_path('.claude/skills/announce/scripts/lint-draft.py'), ...$options, $draft]); + $process->run(); + + unlink($draft); + rmdir($directory); + + return [$process->getExitCode(), $process->getOutput()]; +} + +it('passes a release post in the house shape, with a supplied figure', function (): void { + [$exit, $output] = lintDraft(LINT_DRAFT_POST, '--blog'); + + expect($exit)->toBe(0, $output); +}); + +it('fails a release post that would not ship', function (string $from, string $to, string $finding): void { + [$exit, $output] = lintDraft(str_replace($from, $to, LINT_DRAFT_POST), '--blog'); + + expect($exit)->toBe(1) + ->and($output)->toContain($finding); +})->with([ + 'no figure' => ['', '', 'no '], + 'a slot with no manifest entry' => ['blog-tablepro-0-81-1', 'blog-no-such-slot', 'has no entry in resources/data/assets.json'], + 'a raw image' => ['', "\n\n![Tabs](/images/blog/tabs.png)", 'raw image'], + 'a description over the SEO budget' => ['keeps its own tabs.', 'keeps its own tabs. ' . str_repeat('Long. ', 30), 'SeoLengthsTest allows 155'], + 'no release' => ["release: \"TablePro 9.9\"\n", '', 'missing release'], + 'a slug that is not the filename' => ['slug: a-post', 'slug: another-post', 'does not match the filename'], +]); + +it('holds the newsletter to no first person', function (string $sentence, bool $flagged): void { + [$exit, $output] = lintDraft(str_replace('Each database on a connection keeps its own tabs.', $sentence, LINT_DRAFT_NEWSLETTER)); + + expect($exit)->toBe($flagged ? 1 : 0, $output) + ->and(str_contains($output, 'first person'))->toBe($flagged); +})->with([ + 'we' => ['We fixed eight bugs.', true], + 'a contraction' => ["We'll ship more next week.", true], + 'I' => ['I fixed eight bugs.', true], + 'my' => ['My latest release keeps tabs per database.', true], + 'a US App Store link' => ['Get it from [the App Store](https://apps.apple.com/us/app/tablepro/id6761621829).', false], + 'US spelling' => ['Colors follow US spelling.', false], +]); + +it('passes X posts that speak as the developer and fit the limit', function (string $posts): void { + [$exit, $output] = lintDraft($posts, '--x'); + + expect($exit)->toBe(0, $output); +})->with([ + 'the thread' => [LINT_DRAFT_X], + 'a joined emoji, which X counts as two' => ["## 1/1\n\n" . str_repeat('a', 278) . "\u{1F469}\u{200D}\u{1F4BB}"], +]); + +it('fails an X post that X would cut or print as markdown', function (string $from, string $to, string $finding): void { + [$exit, $output] = lintDraft(str_replace($from, $to, LINT_DRAFT_X), '--x'); + + expect($exit)->toBe(1) + ->and($output)->toContain($finding); +})->with([ + 'over 280 characters' => ['is out.', 'is out. ' . str_repeat('More words here. ', 20), 'X allows 280'], + 'CJK text, which X counts double' => ['is out.', 'is out. ' . str_repeat('่กจ', 110), 'X allows 280'], + 'a link that ends a sentence' => ['Full post: https://tablepro.app/blog/a-post', str_repeat('a', 256) . ' https://tablepro.app/download.', 'X allows 280'], + 'bold text' => ['tabs follow', '**tabs** follow', 'markdown'], +]);