diff --git a/.claude/skills/release/SKILL.md b/.claude/skills/release/SKILL.md index 311deee4b8..b1505356df 100644 --- a/.claude/skills/release/SKILL.md +++ b/.claude/skills/release/SKILL.md @@ -2,12 +2,10 @@ name: release description: > Ships a TablePro release end to end: bumps Configs/Version.xcconfig, finalizes CHANGELOG.md, - commits, tags, pushes, releases plugins, and then, when the release is big enough to be worth - announcing, writes the blog post, the newsletter and the X posts that point at it. Use whenever the - user says "release", "bump version", "ship version", "tag a release", "cut a release", gives a - version number ("/release 0.67.0", "/release plugin-oracle 1.0.0"), or asks for the announcement - that goes with one: "newsletter for 0.67", "blog post for the release", "announce 0.67", "write the - X thread". + commits, tags, pushes and releases plugins. Use whenever the user says "release", "bump version", + "ship version", "tag a release", "cut a release", or gives a version number ("/release 0.67.0", + "/release plugin-oracle 1.0.0"). Not for the blog post, newsletter or X posts: those are the + announce skill in TableProApp/web. --- # Release @@ -21,26 +19,15 @@ description: > - Once a release run is past **Publish the release**, only **Re-run failed jobs** (`RELEASING.md`, When a release run fails). -Run the stages in order, starting one only when the one before it is real: 1 decide the shape, 2 -release (always), 3 plugins (when they changed), 4 to 6 blog post, newsletter and X posts (big -only). A normal release stops after stage 3; the changelog and docs changelog are its announcement. -A big release's newsletter and X posts come from the finished post, so the three cannot disagree. +Run the stages in order, starting one only when the one before it is real: 1 release (always), 2 +plugins (when they changed). Announcing it is the `announce` skill in TableProApp/web +(`../tablepro-web`). House rules relied on, not repeated: `.claude/rules/changelog.md`, `.claude/rules/plugin-system.md`, the commit format in the top comment of `.github/pull_request_template.md`, and `RELEASING.md` for the pipeline. -## Stage 1: Decide the shape - -The release is **big** when `### 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 it is **normal**: fixes, small changes, plugin work. -The entry count per section is a sanity check, not the rule. Say which shape you picked and why -before any work, and ask when it is borderline: an unwanted newsletter costs an hour, a skipped one -costs a launch. - -## Stage 2: Release +## Stage 1: Release ### Pre-flight @@ -52,7 +39,7 @@ Check each. If one fails, stop and say what is wrong. release. `[Unreleased]` has entries; if not, say the release has no notes. 3. `swiftlint lint --strict` is clean: the tag's checks do not cover SwiftLint. Fix findings through their own pull request. -4. If `Plugins/TableProPluginKit/` changed since the last tag, do Stage 3, PluginKit, now, before +4. If `Plugins/TableProPluginKit/` changed since the last tag, do Stage 2, PluginKit, now, before the tag. 5. `scripts/export-cloudkit-schema.sh --check` passes; it needs the cktool management token on this Mac. A field in the snapshot but not in Production makes CloudKit reject every record carrying @@ -233,7 +220,7 @@ Only when a build loses data, fails to launch or breaks connecting, and the fix minutes away. Commands and effects: `RELEASING.md`, Withdrawing a release; the user runs its push. Users already on the build are reached by the corrective release with `criticalUpdate` set. -## Stage 3: Plugins +## Stage 2: Plugins After the app tag is pushed, list the registry plugins with commits since their last tag: @@ -272,28 +259,3 @@ before publishing anything, and act on the diff as `.claude/rules/plugin-system. `scripts/release-all-plugins.sh` runs for a breaking bump only: each run uses a registry retention slot for every plugin. One driver fix reaches users who have not updated through `scripts/release-plugin-for-shipped-app.sh plugin--v v`. - -## Stages 4 to 6: Announce (big only) - -1. **Blog post**, by `references/blog-post.md`: one file in `resources/blog/` of the marketing site - repo (`../tablepro-web` by default; confirm the path). It goes live first, because the - newsletter and the X posts link to it. Once live, link it from the version's `` block in - the docs `changelog.mdx` with `[Read the announcement](https://tablepro.app/blog/)` on its - own line before the first `###`, pushed as `docs(changelog): link the announcement`. -2. **Newsletter**, by `references/newsletter.md`: 150 to 250 words that name the headline items and - link the post. -3. **X posts**, by `references/newsletter.md`: four to six posts plus one standalone, written from - the finished post and newsletter. - -Lint each draft, then work `references/fact-checks.md` top to bottom. Its closing blocker list -gates all three; report blockers apart from the drafts. - -```bash -python3 .claude/skills/release/scripts/lint-draft.py --blog /resources/blog/.md -python3 .claude/skills/release/scripts/lint-draft.py -``` - -Newsletter and X drafts go in the session scratchpad, named by version (`newsletter-v0.77.0.md`, -`x-posts-v0.77.0.md`). Sending is out of scope. If the user asks to send the newsletter, warn them -first: in the license backend `all` means verified subscribers only, a small fraction of the list, -and `everyone` is the real list. diff --git a/.claude/skills/release/references/blog-post.md b/.claude/skills/release/references/blog-post.md deleted file mode 100644 index 9618ef9d8a..0000000000 --- a/.claude/skills/release/references/blog-post.md +++ /dev/null @@ -1,150 +0,0 @@ -# Blog post - -The blog lives in the marketing site repo, `../tablepro-web` by default; confirm the path with the -user. Every command here runs from that repo's root. - -A post is one markdown file, `resources/blog/.md`. Nothing else registers it: `BlogService` -globs the directory and the route takes any `[a-z0-9-]+`, so there is no index or route to edit. For -a release, the post carries the detail, the figures and the limits, and the newsletter and X posts -are written from it. - -The three usual mistakes: inventing a layout instead of matching the shipped posts, leaving out -figures, and forgetting `tests/Feature/Landing/BlogTest.php`, which hardcodes the post count. - -## 1. Read first - -```bash -ls resources/blog/ -grep -n "^## \|^
" resources/blog/*.md -``` - -Read the newest `tablepro-0-*.md` release post and `mcp-database-claude.md` in full: together they -show the house structure and the figure convention. Aim for about 1,000 words of prose -(`lint-draft.py --blog` prints the count without figures). If it runs long, cut sections to bullets, -not adjectives. - -## 2. Frontmatter - -`app/Services/Blog/Post.php` is the contract. Every field is required; `ogPunchline` is optional in -code but every post sets it, because it is the subtitle on the OG card. - -```yaml ---- -slug: tablepro-0-77 # equals the filename -title: "TablePro 0.77: Charts for Every Result" # quote a title with a colon -description: One or two sentences. The meta description and the card blurb. -date: 2026-10-15 # YYYY-MM-DD, newest first -author: TablePro Team -tags: [release, charts] # lowercase kebab -ogPunchline: Two or three short sentences, under about 90 characters. ---- -``` - -`ogPunchline` is the line someone reads on a shared link, not the description restated. - -## 3. Shape - -``` -intro two paragraphs, no heading -
the hero, right after the intro -## section 5 to 8 of these, figures between them -## limits what this does not do, or where a competitor still wins -## closing how to do it in TablePro, or how to get it -``` - -- **A release post opens with the number** and gets out of the way: "TablePro 0.77 is out: 210 - changes, 150 of them fixes." A topical post opens on the reader's problem and reaches TablePro in - the second paragraph, never with "TablePro 0.77 adds". -- **Headings are plain statements or names**: "Where the 320 MB goes", "When D1 is not the right - call". No questions, slogans or gerund stacks. -- **The limits section is required.** Name real gaps ("Charts draw the loaded rows, not the whole - table"), not disguised strengths. -- **The closing is concrete**: numbered steps, a config snippet, or the update path. Never a summary. - -## 4. Weight - -A post where every section weighs the same reads as machine-written, whatever its sentence length. -Decide per feature before writing: - -- **Prose plus a figure** for something a reader will change their behavior over. Four is usually the - ceiling. -- **One bullet** for everything else, collected in one "Also new" section. -- **A bold one-liner** for a breaking change, wherever it lands, so it cannot be skimmed past. - -Aim for at least a 3x spread in words between the biggest and smallest section; under 2x means the -post has not been edited. `lint-draft.py --blog` measures it. Explain rationale once per post, not in -every section: state the behavior and stop. - -## 5. Figures - -Raw HTML, not markdown image syntax, which gives no caption: - -```html -
- TablePro Connect a Client sheet with tabs for Claude Code, Claude Desktop, and Cursor, showing the JSON snippet to paste into the chosen client's config -
Connect a Client: pick Claude Code, Claude Desktop, or Cursor and TablePro shows the exact config to paste.
-
-``` - -- Files live in `public/images/blog/` (tracked in git) and are referenced as `/images/blog/...`. -- **Alt text describes the picture** in one long, specific sentence: what is on screen, not which - feature it is. -- **The caption says what the picture proves**, in one sentence that stands on its own. -- One figure per 150 to 350 words. Retina and unpadded, no fixed size; never upscale a small capture. -- A docs screenshot can be reused, renamed to the blog scheme `-.png`. - -**When the screenshot does not exist yet**, ship a rendered placeholder that names what to capture -and the file path to overwrite, and list it as pending in your report. Never point an `` at a -missing file or reuse a shot of a pane that has changed. The real screenshot later replaces the file -at the same path, so the post needs no edit. - -```bash -npx puppeteer browsers install chrome-headless-shell -``` - -```php -Browsershot::html($html)->windowSize(2400, 1500)->setScreenshotType('png') - ->save(public_path('images/blog/' . $name . '.png')); -``` - -## 6. Voice - -- Second person, present tense. Contractions are fine here. -- Numbers instead of adjectives: "400 MB at idle", "three round trips", never "much faster". -- Backticks on shortcuts, SQL keywords, identifiers, paths and config keys. Bold for menu paths: - `**Settings > MCP**`. -- Tables only for tabular things, two per post at most. -- State a limit flatly and without apology. Never call a competitor bad; say what it costs. -- No em dashes, no banned words (`scripts/banned-words.txt` in the TablePro repo), US spelling to - match the docs and the app. The lint checks all three. - -Then run `references/fact-checks.md`: tier claims, menu labels, thresholds and plugin availability -all need checking before the post is indexed and quoted. - -## 7. Wire it up and verify - -1. In `tests/Feature/Landing/BlogTest.php`, add one to the `->has('posts', N)` count and add the slug - to the `blogSlugs` dataset. -2. Render the OG card, tracked at `public/og/blog/.png`, and look at it: a long title wraps to - three lines and can push the punchline off the card. - - ```bash - php artisan og:generate --type=blog --slug= - ``` - -3. The sitemap needs nothing; it is regenerated on deploy. -4. Lint, test, and confirm the post parsed, because bad frontmatter fails quietly: - - ```bash - python3 /.claude/skills/release/scripts/lint-draft.py --blog resources/blog/.md - php artisan test --compact - vendor/bin/pint --dirty --format agent # after any PHP edit - php artisan tinker --execute=" - \$p = app(App\Services\Blog\BlogService::class)->find(''); - echo \$p->wordCount.' words, '.substr_count(\$p->bodyHtml, '
').\" figures\n\"; - " - ``` - -`main` deploys itself on green tests, so pushing publishes the post. Push only when every blocker at -the end of `references/fact-checks.md` that applies to the post holds: a post that says "update now" -before the build exists sends readers to the previous version. diff --git a/.claude/skills/release/references/fact-checks.md b/.claude/skills/release/references/fact-checks.md deleted file mode 100644 index d137919b8a..0000000000 --- a/.claude/skills/release/references/fact-checks.md +++ /dev/null @@ -1,97 +0,0 @@ -# Fact checks - -`lint-draft.py` checks how a sentence reads; this file checks whether it is true. Work it top to -bottom on the blog post first, then on the newsletter and the X posts written from it. - -## 1. A fix is tied to a version only when git agrees - -```bash -git log --oneline -S "" -- -git 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 tag -l "plugin--*" | tail -3 -git 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 `.github/plugin-registry.json`. If a check fails, tag the -plugin with the release or cut the section. - -## 4. Images are real and current - -An `images/*.png` in the docs repository (`../tablepro-docs`) at exactly 1560x960 is a "Screenshot -coming soon" card. A real file can still be stale, so compare its date with the commits that changed -the UI it shows: - -```bash -sips -g pixelWidth -g pixelHeight ../tablepro-docs/images/.png -git -C ../tablepro-docs log -1 --format='%ad %s' --date=short -- images/.png -``` - -Never embed a placeholder or a stale shot. Mark the gap in the draft and list 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/Core/Menu/ | grep -i "" -``` - -## 6. Tiers, defaults and thresholds - -```bash -grep -n -A3 "case " TablePro/Models/Settings/ProFeature.swift -``` - -- `requiredTier` is the lowest tier that includes a feature, and Team includes Starter, so write - "needs a license, and both tiers include it". -- 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 - -```bash -ls ../tablepro-docs/features/.mdx ../tablepro-docs/databases/.mdx -grep -n "^## " ../tablepro-docs/databases/.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 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. - -## Blockers - -Report these apart from any draft. Nothing goes out until every one holds. - -- The release is live: the run finished, the GitHub release has its assets, and the appcast carries - the version. The commands and the `SUFeedURL` facts are in the release skill's Confirm it shipped - section (`.claude/skills/release/SKILL.md`). -- Every plugin an announcement names is tagged, built and in the registry (check 3). -- Screenshots are captured, light and dark, on a build that contains the change. -- The docs `changelog.mdx` is deployed with the version. -- The fix count is recounted after `[Unreleased]` closed. -- The blog post is live. Fetch the URL rather than trusting that the file was written. diff --git a/.claude/skills/release/references/newsletter.md b/.claude/skills/release/references/newsletter.md deleted file mode 100644 index 4de44fd347..0000000000 --- a/.claude/skills/release/references/newsletter.md +++ /dev/null @@ -1,105 +0,0 @@ -# Newsletter and X posts - -Both are short and both point at the blog post, which is the announcement. 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, matching the post's sections in the same 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. The sender already says TablePro and Gmail on iOS cuts near 40 - characters, so the first item has to finish 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, because Apple Intelligence can replace it with a generated summary. - -## The X posts - -A thread of **four to six posts** plus one standalone, one headline feature per post in the blog -post's order. - -- **280 characters at most**, and a post needing 279 is too dense. Two short paragraphs. -- **No markdown**: X renders none of it. -- **Links only in the last post**, which carries the round-up and the link. An earlier link costs - reach. -- **One image per post at most**, named in the post header so whoever posts it knows what to - attach, 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. -- No thread hooks ("a thread", "here's why this matters"), no engagement bait, no emoji, no "and - much more". - -```markdown -## 1/5 (attach: ) - -TablePro 0.77 is out. - - - -## 5/5 - -Also in 0.77: - -Full post: -Download: https://tablepro.app/download -``` - -## 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, and no banned words - (`scripts/banned-words.txt`). -5. Figures instead of vague quantifiers: "more than 130 other fixes", not "many" or "significantly". -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, and vary the form ("instead - of", "used to", "no longer"). -8. Second person, present tense, active voice. No "will now", no "has been improved", no paragraph - opening with "It also". -9. Backticks on every shortcut, SQL keyword, type name and literal (`Cmd+F`, `EXPLAIN ANALYZE`), at - most about eight in a sentence. Bold for menu paths and the first mention of a new proper noun, - not for run-in headlines. -10. US spelling (color, behavior), the same as the docs, whose writing check rejects British - spelling, and as the app's own strings. - -Run the lint before reading for facts: - -```bash -python3 .claude/skills/release/scripts/lint-draft.py -``` - -It checks rules 2, 4, 5, 6 and 10, the common phrases behind rule 3, the "It also" opener, and the -subject and preview budgets, and it resolves every docs link and image. Whether a sentence is true is -`fact-checks.md`. diff --git a/.claude/skills/release/scripts/lint-draft.py b/.claude/skills/release/scripts/lint-draft.py deleted file mode 100755 index 4e52a1e13d..0000000000 --- a/.claude/skills/release/scripts/lint-draft.py +++ /dev/null @@ -1,283 +0,0 @@ -#!/usr/bin/env python3 -"""Mechanical checks for a TablePro release announcement draft. - -Covers what can be decided from the text alone: house style, sentence length, subject and preview -budgets, section weight in a blog post, and links or images that do not resolve. Whether a -sentence is true is references/fact-checks.md. - -The banned words are read from scripts/banned-words.txt at the root of this repository, the one -list the docs and the agent checks share. - -Usage: - python3 lint-draft.py [--blog] [--repo ] - ---blog lints a post in the marketing site repo (resources/blog/.md): it drops the subject -and preview checks and adds frontmatter, section spread and image checks. - -Exits 1 when a hard rule is broken, 2 when the banned-word list is missing, 0 otherwise. -""" - -import argparse -import os -import re -import subprocess -import sys - -HARD = "hard" -SOFT = "soft" - -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"(?", "|", "---", "<", "**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, banned, blog, add): - for i, line in enumerate(lines_of(clean), 1): - if "\u2014" in line: - add(HARD, "em dash", "use a comma, a period, a colon, or rewrite", i) - 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(SOFT if blog else HARD, "semicolon", "split the sentence", i) - if "!" in 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 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 = os.path.splitext(os.path.basename(draft_path))[0] - 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") - punchline = fields.get("ogPunchline", "") - if len(punchline) > OG_PUNCHLINE_MAX: - add(SOFT, "ogPunchline", f"{len(punchline)} chars, keep it under about {OG_PUNCHLINE_MAX}") - - body = re.sub(r"
.*?
", "", text[front.end():], flags=re.S) - 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") - - web_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(draft_path)))) - for i, line in enumerate(lines_of(text), 1): - for src in re.findall(r'src="/(images/blog/[^"]+)"', line): - if not os.path.isfile(os.path.join(web_root, "public", src)): - add(HARD, "missing image", src, i) - - -def check_assets(text, repo, add): - if not repo: - return - for i, line in enumerate(lines_of(text), 1): - for path in re.findall(r"https://docs\.tablepro\.app/images/([\w.-]+)", line): - local = os.path.join(repo, "docs", "images", path) - if not os.path.exists(local): - add(HARD, "missing image", path, i) - elif png_size(local) == (1560, 960): - add(HARD, "placeholder image", f"{path} is a 1560x960 placeholder card", i) - for kind, page in re.findall(r"https://docs\.tablepro\.app/(features|databases)/([\w-]+)", line): - if not os.path.exists(os.path.join(repo, "docs", kind, page + ".mdx")): - add(HARD, "missing docs page", f"{kind}/{page}", 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(): - root = repo_root() - parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) - parser.add_argument("draft") - parser.add_argument("--blog", action="store_true", help="lint a marketing site blog post") - parser.add_argument("--repo", default=root, help="the TablePro checkout for docs links") - args = parser.parse_args() - - banned = load_banned(root) - with open(args.draft, encoding="utf-8") as handle: - text = handle.read() - - findings = [] - - def add(level, rule, detail, line=None): - findings.append((level, rule, detail, line)) - - clean = strip_code(strip_frontmatter(text)) - check_prose(clean, banned, args.blog, add) - if args.blog: - check_blog(text, args.draft, add) - else: - check_envelope(text, add) - check_assets(text, args.repo, add) - - hard = [f for f in findings if f[0] == HARD] - soft = [f for f in findings if f[0] == SOFT] - words = len(re.sub(r"
.*?
", "", clean, flags=re.S).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/scripts/banned-words.txt b/scripts/banned-words.txt index a98652d2cd..5f630ed887 100644 --- a/scripts/banned-words.txt +++ b/scripts/banned-words.txt @@ -1,7 +1,6 @@ # One term per line, matched case-insensitively at the start of a word. A term that # starts with punctuation, like the em dash, matches anywhere. Read by -# scripts/check-banned-words.sh, the docs repository's scripts/check-writing-style.sh and -# .claude/skills/release/scripts/lint-draft.py. +# scripts/check-banned-words.sh and the docs repository's scripts/check-writing-style.sh. — comprehensive delve diff --git a/scripts/check-doc-symbols.sh b/scripts/check-doc-symbols.sh index 137e8bcd7b..4b4abc19f1 100755 --- a/scripts/check-doc-symbols.sh +++ b/scripts/check-doc-symbols.sh @@ -63,11 +63,6 @@ FRAMEWORK_PREFIX_PATTERN='^(NS|CK|CF|CG|CA|CT|UI|XC|OS)[A-Z]' TAB="$(printf '\t')" SCRIPT_PATH_REGEX='(\.claude/hooks|([A-Za-z0-9_.-]+/)*scripts)(/[A-Za-z0-9_-]+)*/[A-Za-z0-9_.-]+\.sh' -# The release skill writes its blog post in the marketing site repo (../tablepro-web), a Laravel app. -# A path under one of that app's top-level folders, which this tree does not have, names that repo, -# and blog-post.md runs every command from there, so the names it uses are the site's own. -SITE_REPO_ROOTS="app public resources routes tests" -SITE_REPO_DOCS=".claude/skills/release/references/blog-post.md" # The docs site is its own repository, cloned beside this one. A path under it names that repo, so # it is checked against the clone when one is there and skipped when it is not. DOCS_REPO="../tablepro-docs" @@ -195,15 +190,6 @@ resolve_path() { return 1 } -is_site_repo_path() { - local root="${1%%/*}" - [ ! -d "$root" ] && printf ' %s ' $SITE_REPO_ROOTS | grep -qF " $root " -} - -is_site_repo_doc() { - printf ' %s ' $SITE_REPO_DOCS | grep -qF " $1 " -} - # ------------------------------------------------------------------ checks check_paths() { @@ -250,8 +236,6 @@ check_paths() { [[ "$token" =~ ^$SCRIPT_PATH_REGEX$ ]] && continue if resolved="$(resolve_path "$token" "$dir")"; then pass "$token${resolved#*"$TAB"}" "$doc:$line" - elif is_site_repo_path "$token"; then - skip "$token (marketing site repo)" "$doc:$line" else report "$doc:$line" "path does not exist: $token" fi @@ -277,8 +261,6 @@ check_symbols() { pass "$token (top-level path)" "$doc:$line" elif printf '%s\n' "$token" | grep -qE "$PLACEHOLDER_PATTERN"; then skip "$token (placeholder)" "$doc:$line" - elif is_site_repo_doc "$doc"; then - skip "$token (marketing site repo)" "$doc:$line" elif [ "$sdk_indexed" -eq 0 ] && printf '%s\n' "$token" | grep -qE "$FRAMEWORK_PREFIX_PATTERN"; then skip "$token (SDK name, no SDK to check against)" "$doc:$line" else