page-foundry 2.9.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.
@@ -0,0 +1,224 @@
1
+ # Ship Gates
2
+
3
+ Run every gate. Report results as a pass/fail table plus the MECLABS score. A failed gate means fix and re-run, not ship with a caveat.
4
+
5
+ Handoff mode: gates 1, 2, and 8 run before the package is delivered; gates 3, 4, 5, 6, and 7 become acceptance criteria in the package and run in full when the built asset comes back, along with a re-run of 1 and 2 on the rendered page. Details in `references/handoff.md`.
6
+
7
+ ## Gate 1: Conversion audit
8
+
9
+ - [ ] 5-second test passes on a cold read (headline + subhead + CTA label only).
10
+ - [ ] Primary headline within budget (target under ~44 chars, ceiling 70).
11
+ - [ ] Exactly one primary CTA action on the page; repeats are identical in wording and destination.
12
+ - [ ] Every CTA instance has a proof element within one viewport.
13
+ - [ ] campaign-landing only: no site navigation, hero language matches the traffic source.
14
+ - [ ] Forms: every field beyond name/email has a written justification in the spec.
15
+ - [ ] MECLABS heuristic scored (rubric in conversion-rules.md); flagged factors (M/V/I low, F/A high) addressed or explicitly accepted.
16
+ - [ ] Red-team read: one simulated skeptical reader per segment x entry state from the brief walks the page; every reader must reach the CTA. Any "not for me" exit by a qualified reader is a failure (conversion rule 10). Log bounce points and fix.
17
+
18
+ ## Gate 2: Voice
19
+
20
+ - [ ] `python3 scripts/voice_scan.py <files>` returns zero FAILs (banned phrases, em/en dashes in prose).
21
+ - [ ] Pattern pass done: every `AI language pattern` WARN resolved (negative parallelism "not X, it's Y", copula avoidance "serves as", tailing negation "no X, no Y", significance inflation, authority trope, three-verb-clause runs, parallel-list uniformity). **The humanizer skill was invoked on the final copy, its rewrites applied, and that is recorded on the `humanizer` line below.** The scanner passing is necessary but not sufficient: structural uniformity across separate elements (a parallel roster) is beyond regex, so a scanner PASS with no humanizer pass is an incomplete Gate 2, not a clean one.
22
+ - [ ] Read-aloud pass done: no sentence the owner would not say to a client across a table.
23
+ - [ ] No fabricated specificity: every number, name, and quote traces to the proof inventory.
24
+
25
+ ## Gate 3: Accessibility (WCAG 2.2 AA spot checks)
26
+
27
+ - [ ] One `h1`; heading levels do not skip.
28
+ - [ ] Landmarks present (`nav`, `main`, `footer`); links and buttons are real `a`/`button` elements; icon-only controls carry an `aria-label`.
29
+ - [ ] Text contrast 4.5:1 (3:1 for large text and UI components); CTA buttons included.
30
+ - [ ] Visible keyboard focus on all interactive elements (`:focus-visible`), logical tab order, and focus never obscured (WCAG 2.2 SC 2.4.11).
31
+ - [ ] Touch targets at least 24x24px (WCAG 2.2 SC 2.5.8); 44px preferred for primary actions.
32
+ - [ ] Every informative image has real alt text and explicit `width`/`height` (CLS); decorative images have empty alt.
33
+ - [ ] No content conveyed by color alone; `prefers-reduced-motion` respected; motion animates only `transform`/`opacity`, never `transition: all`.
34
+ - [ ] Form inputs have programmatic labels plus correct `type`/`inputmode`/`autocomplete`; async status changes use `aria-live`.
35
+ - [ ] If the page has auth, no step forces a memory or transcription test with no easier alternative (WCAG 2.2 SC 3.3.8).
36
+
37
+ ## Gate 4: Performance
38
+
39
+ - [ ] Total transfer under ~1MB; CSS+JS under ~200KB.
40
+ - [ ] Images sized to display dimensions, modern formats (WebP/AVIF), lazy-loaded below the fold.
41
+ - [ ] Fonts: system stack, or at most two self-hosted files, preloaded, `font-display: swap`.
42
+ - [ ] No render-blocking third-party scripts; analytics deferred.
43
+ - [ ] Target: LCP under ~2s on a mid-range phone. If a Lighthouse run is possible in the environment, run it and record the score.
44
+
45
+ ## Gate 5: Render review
46
+
47
+ - [ ] Render the page and look at it: screenshot at 390px and 1440px via Playwright, headless Chrome, or gstack `/design-review` when available. Critique the screenshots against the chosen design direction, the anti-slop list, and (when installed) the web-design-guidelines rules; fix anything a cold viewer would flag in five seconds (cramped hero, wall of gray, broken wrap, invisible CTA).
48
+ - [ ] If no render tool exists in the environment, mark this gate N/A in the report and tell the user to eyeball both widths before deploy. Never silently skip it; an unrendered page is unreviewed design.
49
+
50
+ ## Gate 6: AI discovery and meta
51
+
52
+ - [ ] JSON-LD schema present and valid for the archetype (templates below).
53
+ - [ ] `llms.txt` present at the site root (template below).
54
+ - [ ] `robots.txt` does not `Disallow` the AI crawlers that drive citations: `GPTBot`, `ChatGPT-User`, `PerplexityBot`, `ClaudeBot`, `anthropic-ai`, `Google-Extended`, `Bingbot`. Blocking one loses that engine's citation; only `CCBot` is safe to block. An AI-discovery gate that passes while `ClaudeBot` is disallowed is a false pass.
55
+ - [ ] Page copy is machine-extractable: the primary answer sits in a self-contained 40 to 60 word block near the top, key facts are in real text (not baked into images), and comparisons/FAQs use plain markup.
56
+ - [ ] For archetypes with public pricing (saas-homepage, course-sales, membership-community), a machine-readable `/pricing.md` (or `/pricing.txt`) is present for agentic buyers.
57
+ - [ ] `<title>` and meta description written to the same standard as page copy (voice rules apply), and short enough not to truncate.
58
+ - [ ] Open Graph + Twitter card meta complete, with a real OG image (1200x630).
59
+ - [ ] Canonical URL set; favicon present.
60
+
61
+ Note: Google does not consume `llms.txt`; it helps ChatGPT, Claude, and Perplexity. Keep it, and do not rely on it for Google.
62
+
63
+ ## Gate 7: Measurement
64
+
65
+ - [ ] Conversion event defined: the form destination (ESP, endpoint) records source/medium, or the user has consciously declined measurement.
66
+ - [ ] UTM convention stated for every planned traffic source, so campaign performance is attributable from day one.
67
+ - [ ] Analytics: privacy-respecting option (Plausible, GoatCounter, or server logs) wired or consciously declined. A gauge-interest page without measurement cannot gauge interest.
68
+
69
+ ## Gate 8: Integrity
70
+
71
+ - [ ] Zero fabricated testimonials, logos, counts, or statistics.
72
+ - [ ] Zero fabricated technical artifacts or staged scenarios: (a) every command, terminal output, code snippet, config, and file path is real and runnable exactly as shown, or removed; and (b) every terminal, screenshot, console, or UI depicts a real action the user takes and real output they see. Real data in a styled card (the page's own gate report) is fine; a terminal of a script the user never runs is not, even if the script is real. A demo of the product is held to this.
73
+ - [ ] All `[TK]` placeholders resolved: filled with real material, or the section cut.
74
+ - [ ] Any urgency element (deadline, cap) verified real with the user.
75
+ - [ ] Security/privacy claims substantiated by the brief.
76
+
77
+ ---
78
+
79
+ ## Templates
80
+
81
+ ### llms.txt
82
+
83
+ Place at `/llms.txt`. Markdown, written in the same plain voice as the page.
84
+
85
+ ```markdown
86
+ # {Product Name}
87
+
88
+ > One-sentence factual description: what it is, who it is for, what it does.
89
+
90
+ {2-4 sentences of plain factual detail: core capabilities, platform, pricing model, license if OSS.}
91
+
92
+ ## Links
93
+
94
+ - [Homepage]({url})
95
+ - [Documentation]({docs-url})
96
+ - [Pricing]({pricing-url})
97
+ - [Source]({repo-url}) <!-- OSS only -->
98
+
99
+ ## Facts
100
+
101
+ - Built by: {person/company}
102
+ - Platform: {web / iOS / Android / CLI / self-hosted}
103
+ - Pricing: {model, starting price, free tier, or license}
104
+ - Data handling: {one factual sentence}
105
+ ```
106
+
107
+ ### JSON-LD by archetype
108
+
109
+ Wrap in `<script type="application/ld+json">` in `<head>`. Fill only fields with real values; omit anything unknown rather than inventing it.
110
+
111
+ **saas-homepage / mobile-app** (`SoftwareApplication`):
112
+
113
+ ```json
114
+ {
115
+ "@context": "https://schema.org",
116
+ "@type": "SoftwareApplication",
117
+ "name": "{Product}",
118
+ "description": "{factual one-liner}",
119
+ "url": "{url}",
120
+ "applicationCategory": "{e.g. SecurityApplication, BusinessApplication}",
121
+ "operatingSystem": "{Web, iOS, Android}",
122
+ "offers": {
123
+ "@type": "Offer",
124
+ "price": "{0 or starting price}",
125
+ "priceCurrency": "USD"
126
+ },
127
+ "aggregateRating": {
128
+ "@type": "AggregateRating",
129
+ "ratingValue": "{real only}",
130
+ "ratingCount": "{real only}"
131
+ },
132
+ "publisher": { "@type": "Organization", "name": "{Company}", "url": "{company-url}" }
133
+ }
134
+ ```
135
+
136
+ Omit `aggregateRating` entirely unless the numbers are real and public.
137
+
138
+ **oss-project** (`SoftwareSourceCode`):
139
+
140
+ ```json
141
+ {
142
+ "@context": "https://schema.org",
143
+ "@type": "SoftwareSourceCode",
144
+ "name": "{Project}",
145
+ "description": "{factual one-liner}",
146
+ "codeRepository": "{repo-url}",
147
+ "programmingLanguage": "{language}",
148
+ "license": "https://spdx.org/licenses/{SPDX-ID}",
149
+ "author": { "@type": "Person", "name": "{maintainer}" }
150
+ }
151
+ ```
152
+
153
+ **campaign-landing with FAQ section** (add alongside the page's primary type):
154
+
155
+ ```json
156
+ {
157
+ "@context": "https://schema.org",
158
+ "@type": "FAQPage",
159
+ "mainEntity": [
160
+ {
161
+ "@type": "Question",
162
+ "name": "{question as it appears on the page}",
163
+ "acceptedAnswer": { "@type": "Answer", "text": "{answer as it appears on the page}" }
164
+ }
165
+ ]
166
+ }
167
+ ```
168
+
169
+ FAQ schema must mirror the visible page content exactly; do not add schema-only Q&A.
170
+
171
+ **course-sales / membership-community / dated webinar campaign-landing** (`Event`; use for a page selling a scheduled cohort, workshop, or live event):
172
+
173
+ ```json
174
+ {
175
+ "@context": "https://schema.org",
176
+ "@type": "Event",
177
+ "name": "{Event}",
178
+ "description": "{factual one-liner}",
179
+ "startDate": "{ISO 8601, e.g. 2026-09-01T17:00-07:00}",
180
+ "endDate": "{ISO 8601}",
181
+ "eventAttendanceMode": "https://schema.org/OnlineEventAttendanceMode",
182
+ "eventStatus": "https://schema.org/EventScheduled",
183
+ "location": { "@type": "VirtualLocation", "url": "{join or info url}" },
184
+ "organizer": { "@type": "Organization", "name": "{Company}", "url": "{url}" },
185
+ "offers": {
186
+ "@type": "Offer",
187
+ "price": "{price}",
188
+ "priceCurrency": "USD",
189
+ "url": "{registration url}",
190
+ "availability": "https://schema.org/InStock"
191
+ }
192
+ }
193
+ ```
194
+
195
+ Omit `endDate` and `offers` fields you cannot fill with real values; never invent a date, price, or seat count.
196
+
197
+ **homepage / personal-home** (optional, add alongside the primary type): a `WebSite` with `potentialAction` SearchAction when the site has search, and a standalone `Organization` (name, url, logo, sameAs social links) so the brand resolves as an entity.
198
+
199
+ ### Gate report format
200
+
201
+ ```
202
+ ## Ship gates: {product} / {archetype}
203
+
204
+ | Gate | Result | Notes |
205
+ |---|---|---|
206
+ | Conversion audit | PASS | MECLABS C = {n} (M{n} V{n} I{n} F{n} A{n}) |
207
+ | Voice scan | PASS | 0 hits across {n} files |
208
+ | Accessibility | PASS | |
209
+ | Performance | PASS | {transfer size}, {LCP if measured} |
210
+ | Render review | PASS / N/A | {widths checked, or why N/A} |
211
+ | AI discovery | PASS | schema valid, llms.txt present |
212
+ | Measurement | PASS | {conversion event, UTM, analytics or declined} |
213
+ | Integrity | PASS | {n} TK items resolved/cut |
214
+
215
+ Humanizer: {invoked on final copy — yes/no; one line on what it changed (e.g. "broke two three-verb runs, varied a 6-item roster"), or "not run" which makes Gate 2 incomplete}
216
+
217
+ Degraded phases: {any phase that ran on a reference-file fallback because its companion was missing or declined, named with the companion it lacked; "none, all companions present" otherwise. A degraded run is a partial execution, and the owner is told which skill would improve it.}
218
+
219
+ Open items for the owner: {real proof to collect, urgency to verify, etc.}
220
+ ```
221
+
222
+ ---
223
+
224
+ _Provenance: reconciled 2026-07-07 against marketingskills 2.3.0 (ai-seo, schema, cro) and web-design-guidelines (vercel-labs, live ruleset). JSON-LD templates verified current against schema.org; accessibility bumped to WCAG 2.2 AA. The MECLABS heuristic is external (rubric in conversion-rules.md), not from the cro companion. Re-reconcile when those companions or the schema.org / WCAG baselines change._
@@ -0,0 +1,204 @@
1
+ # Voice
2
+
3
+ This file governs every word that ships on a page, and it is the single source of truth for the mechanical scanner (`scripts/voice_scan.py` parses the blocks marked `scan:` below). Edit this file, or run the Voice wizard in SKILL.md, and both the writing guidance and the scanner change together.
4
+
5
+ **First run:** this file ships with a neutral default register. Run the Voice wizard ("set up my voice") to make it yours, or edit the sections below directly; the scanner follows this file, so guidance and enforcement change together. The AI-flagged vocabulary list near the bottom is owner-independent; keep it unless you have a specific reason not to. An owner's saved voice file can be dropped over this one at any time (overlay install).
6
+
7
+ <!-- voice-config
8
+ owner: default
9
+ em_dash: fail
10
+ en_dash: fail
11
+ max_exclamations: 1
12
+ -->
13
+
14
+ ## Register (owner-configurable; the wizard rewrites this section)
15
+
16
+ Neutral default until an owner configures their own:
17
+
18
+ - Plain language, short sentences, active voice. Write like a competent person explaining something to a peer.
19
+ - Specific beats clever. Concrete nouns and real numbers beat adjectives every time.
20
+ - Claims are checkable. If no reader could prove a sentence wrong, it is not saying anything; cut it.
21
+ - Describe the work; do not praise it. "Scans your whole estate in 20 minutes" beats "blazing-fast industry-leading scanning".
22
+ - One idea per sentence. If a line needs a second read, split it.
23
+ - Humor only if the owner's brand uses it; the default is none.
24
+
25
+ ## Punctuation (defaults; owners change these via the wizard, enforced via voice-config above)
26
+
27
+ - **No em dashes, no en dashes in prose.** Use commas, semicolons, periods, or restructure. Hyphens in compound words are fine. Numeric ranges use "to".
28
+ - No exclamation points in body copy beyond the configured maximum (currently 1 per page, and only if the brand uses them at all).
29
+ - Sentence case for headings and buttons unless the design direction states otherwise.
30
+
31
+ ## Banned list
32
+
33
+ Zero FAIL occurrences in shipped copy. The scanner enforces the lists below mechanically; the intent is broader than the literal list: anything that pattern-matches to AI-generated or generic-marketing voice is out, even if not listed. One term per line; lines starting with `#` are comments.
34
+
35
+ <!-- scan:banned-phrases -->
36
+ # AI-flagged vocabulary
37
+ delve
38
+ dive deep
39
+ deep dive
40
+ robust
41
+ streamline
42
+ streamlined
43
+ seamless
44
+ seamlessly
45
+ holistic
46
+ cutting-edge
47
+ cutting edge
48
+ state-of-the-art
49
+ state of the art
50
+ game-changer
51
+ game changer
52
+ game-changing
53
+ revolutionize
54
+ revolutionary
55
+ transformative
56
+ unlock
57
+ unleash
58
+ elevate
59
+ empower
60
+ empowering
61
+ supercharge
62
+ harness
63
+ foster
64
+ realm
65
+ tapestry
66
+ testament
67
+ embark
68
+ beacon
69
+ paradigm
70
+ synergy
71
+ effortless
72
+ effortlessly
73
+ frictionless
74
+ innovative
75
+ world-class
76
+ best-in-class
77
+ industry-leading
78
+ next-level
79
+ turbocharge
80
+ skyrocket
81
+ crucial
82
+ pivotal
83
+ furthermore
84
+ moreover
85
+ in today's
86
+ in an era of
87
+ in the ever-evolving
88
+ ever-evolving
89
+ look no further
90
+ take it to the next level
91
+ at the end of the day
92
+ digital landscape
93
+ threat landscape
94
+ optimize
95
+ optimise
96
+ utilize
97
+ utilise
98
+ facilitate
99
+ comprehensive
100
+ groundbreaking
101
+ bolster
102
+ underscore
103
+ unveil
104
+ nuanced
105
+ multifaceted
106
+ intricate
107
+ paramount
108
+ # AI openers, transitions, and closers
109
+ at its core
110
+ that being said
111
+ it's worth noting that
112
+ it is worth noting that
113
+ this begs the question
114
+ in conclusion
115
+ to sum up
116
+ in essence
117
+ with that in mind
118
+ imagine a world where
119
+ a myriad of
120
+ a plethora of
121
+ shed light on
122
+ pave the way for
123
+ # Empty marketing phrases
124
+ no fluff
125
+ no hype
126
+ no bs
127
+ real talk
128
+ the ultimate
129
+ you'll ever need
130
+ join the revolution
131
+ don't miss out
132
+ trusted by thousands
133
+ # Hedge filler
134
+ arguably
135
+ essentially
136
+ basically
137
+ ultimately
138
+ undoubtedly
139
+ simply put
140
+ needless to say
141
+ it goes without saying
142
+ as you may know
143
+ <!-- /scan:banned-phrases -->
144
+
145
+ Words banned only in figurative use are listed below; the scanner reports them as WARN for human judgment (it cannot do part-of-speech tagging, and some have legitimate literal uses, like "leverage" as a noun).
146
+
147
+ <!-- scan:judgment-words -->
148
+ navigate
149
+ leverage
150
+ landscape
151
+ journey
152
+ ecosystem
153
+ intuitive
154
+ vital
155
+ enhance
156
+ <!-- /scan:judgment-words -->
157
+
158
+ ## AI language patterns
159
+
160
+ A word list catches vocabulary; it cannot catch grammar. The tells that most give AI writing away are structural, so the gate checks patterns too. These fire as WARN; the copywriter reviews and rewrites each in Phase 3. The `scan:patterns` block below is the machine-readable subset; the Phase 3 pattern pass and the humanizer skill catch the rest that a regex cannot.
161
+
162
+ Patterns to kill on sight (from Wikipedia's "Signs of AI writing" and the seo-audit ai-writing-detection list):
163
+
164
+ - **Negative parallelism.** "It's not just X, it's Y." "Not only X but also Y." State the positive claim once.
165
+ - **Tailing negation fragments.** A sentence ending in a tacked-on "no guessing," "no wasted motion," "not a hunch." Write it as a real clause or cut it.
166
+ - **Copula avoidance.** "serves as," "stands as," "functions as," "acts as a." Use "is" or "are."
167
+ - **Forced rule of three.** Three parallel items or adjectives grouped to sound comprehensive. Use two, or four, or a plain sentence.
168
+ - **Authority tropes.** "the point is," "at its core," "what really matters," "the real question is." Drop the ceremony and state the point.
169
+ - **Significance inflation.** "marks a pivotal moment," "represents a shift," "a testament to." Describe what happened, not its importance.
170
+ - **Superficial -ing tack-ons.** A comma followed by "highlighting / underscoring / showcasing / reflecting..." that adds fake depth. Cut it.
171
+ - **False ranges.** "from X to Y" where X and Y are not on a scale. List the things instead.
172
+ - **Terse noun-pair fragments.** "Seven phases, eight gates." A punchy fragment pairing two counts. Make it a sentence.
173
+ - **Three parallel verb-clauses.** "It runs the skills, gates the result, and hands back a page." Three present-tense verb clauses in a row read as generated. Break the rhythm: two sentences, or vary the verbs and shapes. (Caught by the `three parallel verb-clauses` regex.)
174
+ - **Parallel-list uniformity.** A list where every item opens the same way ("Finds... / Pulls... / Structures... / Writes..."). Uniform openings across a set read as machine-made even when each item is fine alone. Vary the grammatical shape item to item. (The scanner flags a plain prose run of these; a list spread across separate DOM elements with labels is beyond regex, which is why the humanizer pass is a hard gate, not a suggestion.)
175
+
176
+ <!-- scan:patterns -->
177
+ # AI language patterns, WARN-level. Format: NAME|||REGEX (case-insensitive). Reviewed and fixed in Phase 3.
178
+ negative parallelism|||\bit'?s not (just |merely |simply |only )?[^.,;:]{1,45}[,;:]\s*it'?s\b
179
+ negative parallelism|||\bnot only\b[^.]{1,50}\bbut\b
180
+ tailing negation|||[.;:]\s*no [a-z]{3,},\s*no [a-z]{3,}
181
+ copula avoidance|||\b(serves|stands|functions|acts) as (a |an |the )
182
+ authority trope|||\b(the point is|what really matters|the real question is|the heart of the matter|the deeper point)\b
183
+ significance inflation|||\b(marks|marking|represents|signals) a (pivotal|key|defining|turning|significant) (moment|point|shift|role)\b
184
+ superficial -ing|||,\s+(highlighting|underscoring|emphasizing|showcasing|reflecting|symbolizing|ensuring|fostering|cultivating)\b
185
+ three parallel verb-clauses|||\b[a-z]+s\b[^,.;:!?]{2,60},\s+[a-z]+s\b[^,.;:!?]{2,60},\s+(?:and|then)\s+[a-z]+s\b
186
+ <!-- /scan:patterns -->
187
+
188
+ ## Replacement strategy
189
+
190
+ Do not synonym-swap a banned word; that produces the same sentence in a cheaper suit. Restructure around the concrete fact the sentence was gesturing at:
191
+
192
+ - Banned: "Leverage our robust platform to streamline your security workflow."
193
+ - Wrong fix: "Use our powerful platform to simplify your security workflow." (Same empty sentence.)
194
+ - Right fix: "Connect your scanner output once. Triage drops from four hours a week to twenty minutes." (A fact a reader can check.)
195
+
196
+ If no concrete fact exists to anchor the sentence, the sentence was decoration. Delete it.
197
+
198
+ ## Voice check, beyond the scan
199
+
200
+ After the mechanical scan passes, read the page top to bottom once and ask of each section: would the owner say this sentence out loud to a client across a table? If it would sound like a brochure, rewrite it as the spoken version.
201
+
202
+ ---
203
+
204
+ _Provenance: banned and judgment lists reconciled 2026-07-07 against marketingskills 2.3.0 (copywriting, copy-editing) and the seo-audit ai-writing-detection list, which copywriting itself defers to. Re-reconcile when those companions change; keep this list a superset of the AI-writing-detection vocabulary._
@@ -0,0 +1,255 @@
1
+ #!/usr/bin/env python3
2
+ """voice_scan.py: mechanical voice gate for page-foundry.
3
+
4
+ Rules live in references/voice.md, NOT in this script. The script parses:
5
+ <!-- voice-config ... --> key: value lines (em_dash, en_dash,
6
+ max_exclamations; values fail/warn/off
7
+ or an integer)
8
+ <!-- scan:banned-phrases --> ... <!-- /scan:banned-phrases --> one per line, FAIL
9
+ <!-- scan:judgment-words --> ... <!-- /scan:judgment-words --> one per line, WARN
10
+ <!-- scan:patterns --> ... <!-- /scan:patterns --> NAME|||REGEX per line, WARN
11
+ (AI language patterns: negative parallelism,
12
+ copula avoidance, tailing negation, etc.)
13
+
14
+ Edit voice.md (or run the Voice wizard) and the scanner follows automatically.
15
+ A minimal built-in fallback applies only if voice.md cannot be found.
16
+
17
+ Scans .html/.md/.txt copy. Strips tags, script/style, and code blocks first so
18
+ code samples and class names don't false-positive.
19
+
20
+ Usage:
21
+ python3 voice_scan.py file1.html pages/myproduct/ ...
22
+ python3 voice_scan.py --rules /path/to/voice.md <targets>
23
+
24
+ Exit code 0 = clean, 1 = FAIL violations found, 2 = usage error.
25
+ """
26
+
27
+ import re
28
+ import sys
29
+ from pathlib import Path
30
+
31
+ EM_DASH, EN_DASH = "\u2014", "\u2013"
32
+ EXTS = {".html", ".htm", ".md", ".txt"}
33
+
34
+ FALLBACK = {
35
+ "banned": ["delve", "robust", "seamless", "game-changer", "cutting-edge",
36
+ "unlock", "elevate", "streamline", "in today's"],
37
+ "judgment": ["leverage", "navigate", "journey"],
38
+ "config": {"em_dash": "fail", "en_dash": "fail", "max_exclamations": 1},
39
+ }
40
+
41
+
42
+ def find_rules_file(cli_path):
43
+ if cli_path:
44
+ return Path(cli_path)
45
+ here = Path(__file__).resolve().parent
46
+ for candidate in (here.parent / "references" / "voice.md",
47
+ here / "voice.md",
48
+ Path.cwd() / "references" / "voice.md"):
49
+ if candidate.is_file():
50
+ return candidate
51
+ return None
52
+
53
+
54
+ def parse_block(text, name):
55
+ m = re.search(r"<!--\s*scan:" + name + r"\s*-->(.*?)<!--\s*/scan:" + name + r"\s*-->",
56
+ text, flags=re.S)
57
+ if not m:
58
+ return []
59
+ items = []
60
+ for line in m.group(1).splitlines():
61
+ line = line.strip()
62
+ if line and not line.startswith("#"):
63
+ items.append(line.lower())
64
+ return items
65
+
66
+
67
+ def parse_patterns(text):
68
+ """Parse the scan:patterns block. Each line is NAME|||REGEX (case-insensitive).
69
+ Emits WARN, not FAIL: language patterns are heuristic and need a human read, but
70
+ the scanner surfaces them so 'not X, it's Y', copula-dodging, and the like cannot
71
+ slip past a word-only gate. Lines starting with # are comments."""
72
+ m = re.search(r"<!--\s*scan:patterns\s*-->(.*?)<!--\s*/scan:patterns\s*-->",
73
+ text, flags=re.S)
74
+ if not m:
75
+ return []
76
+ pats = []
77
+ for line in m.group(1).splitlines():
78
+ line = line.strip()
79
+ if not line or line.startswith("#"):
80
+ continue
81
+ name, sep, rx = line.partition("|||")
82
+ if not sep:
83
+ name, rx = "ai pattern", name
84
+ try:
85
+ pats.append((name.strip(), re.compile(rx.strip(), re.I)))
86
+ except re.error:
87
+ pass
88
+ return pats
89
+
90
+
91
+ def parse_config(text):
92
+ cfg = dict(FALLBACK["config"])
93
+ m = re.search(r"<!--\s*voice-config(.*?)-->", text, flags=re.S)
94
+ if m:
95
+ for line in m.group(1).splitlines():
96
+ if ":" in line:
97
+ k, v = line.split(":", 1)
98
+ k, v = k.strip().lower(), v.strip().lower()
99
+ if k == "max_exclamations":
100
+ try:
101
+ cfg[k] = int(v)
102
+ except ValueError:
103
+ pass
104
+ elif k in ("em_dash", "en_dash") and v in ("fail", "warn", "off"):
105
+ cfg[k] = v
106
+ return cfg
107
+
108
+
109
+ def load_rules(cli_path):
110
+ path = find_rules_file(cli_path)
111
+ if not path or not path.is_file():
112
+ print("note: voice.md not found; using minimal built-in fallback rules", file=sys.stderr)
113
+ return FALLBACK["banned"], FALLBACK["judgment"], [], dict(FALLBACK["config"]), None
114
+ text = path.read_text(encoding="utf-8", errors="replace")
115
+ banned = parse_block(text, "banned-phrases") or FALLBACK["banned"]
116
+ judgment = parse_block(text, "judgment-words")
117
+ patterns = parse_patterns(text)
118
+ return banned, judgment, patterns, parse_config(text), path
119
+
120
+
121
+ def strip_to_copy(text, suffix):
122
+ """Remove non-copy regions so only reader-facing prose is scanned."""
123
+ if suffix in (".html", ".htm"):
124
+ text = re.sub(r"<script\b.*?</script>", " ", text, flags=re.S | re.I)
125
+ text = re.sub(r"<style\b.*?</style>", " ", text, flags=re.S | re.I)
126
+ text = re.sub(r"<!--.*?-->", " ", text, flags=re.S)
127
+ text = re.sub(r"<[^>]+>", " ", text)
128
+ if suffix == ".md":
129
+ text = re.sub(r"```.*?```", " ", text, flags=re.S)
130
+ text = re.sub(r"`[^`]*`", " ", text)
131
+ text = re.sub(r"<!--.*?-->", " ", text, flags=re.S)
132
+ text = re.sub(r"!\[[^\]]*\]\([^)]*\)", " ", text) # markdown images / shields.io badges
133
+ text = re.sub(r"<[^>]+>", " ", text) # inline HTML used for README layout
134
+ return text
135
+
136
+
137
+ def word_hit(phrase, lowline):
138
+ return re.search(r"(?<![\w-])" + re.escape(phrase) + r"(?![\w-])", lowline)
139
+
140
+
141
+ VERB_FIRST = re.compile(r"^[A-Z][a-z]{2,}(?:es|s)\b") # Finds, Pulls, Structures, Runs, Gives
142
+
143
+
144
+ def scan_structural(copy):
145
+ """Flag parallel-list uniformity that per-line regex cannot see: 3+ consecutive
146
+ short fragments that open with the same word-class (a present-tense verb), the
147
+ 'Finds... / Pulls... / Structures...' cadence that reads as generated. Splits the
148
+ stripped copy on tag gaps (2+ spaces) and sentence ends so a prose list surfaces.
149
+ WARN-level, and honest about its limit: when a list is spread across separate DOM
150
+ elements with a label between each item, the opener is not the fragment's first
151
+ word and this misses it. That case is why the humanizer pass is a hard Gate 2
152
+ sub-check, not a suggestion."""
153
+ warns, frags = [], []
154
+ for i, line in enumerate(copy.splitlines(), 1):
155
+ for seg in re.split(r"\s{2,}|(?<=[.!?])\s+", line):
156
+ seg = seg.strip()
157
+ if seg:
158
+ frags.append((i, seg))
159
+ run = []
160
+
161
+ def flush(r):
162
+ if len(r) >= 3:
163
+ openers = ", ".join('"%s"' % s.split()[0] for _, s in r[:4])
164
+ warns.append((r[0][0],
165
+ "parallel-list uniformity (%d fragments open with a present-tense verb: %s)"
166
+ % (len(r), openers), r[0][1][:80]))
167
+
168
+ for ln, seg in frags:
169
+ if len(seg.split()) <= 9 and VERB_FIRST.match(seg):
170
+ run.append((ln, seg))
171
+ else:
172
+ flush(run)
173
+ run = []
174
+ flush(run)
175
+ return warns
176
+
177
+
178
+ def scan_file(path, banned, judgment, patterns, cfg):
179
+ fails, warns = [], []
180
+ raw = path.read_text(encoding="utf-8", errors="replace")
181
+ copy = strip_to_copy(raw, path.suffix.lower())
182
+ for i, line in enumerate(copy.splitlines(), 1):
183
+ low = line.lower()
184
+ ctx = line.strip()[:90]
185
+ for phrase in banned:
186
+ if word_hit(phrase, low):
187
+ fails.append((i, f"banned phrase: '{phrase}'", ctx))
188
+ for word in judgment:
189
+ if word_hit(word, low):
190
+ warns.append((i, f"judgment word (figurative use is banned): '{word}'", ctx))
191
+ for name, rx in patterns:
192
+ if rx.search(line):
193
+ warns.append((i, f"AI language pattern ({name})", ctx))
194
+ for dash, key, label in ((EM_DASH, "em_dash", "em dash in prose"),
195
+ (EN_DASH, "en_dash", "en dash in prose (use 'to' for ranges)")):
196
+ if dash in line and cfg.get(key) != "off":
197
+ (fails if cfg.get(key) == "fail" else warns).append((i, label, ctx))
198
+ warns += scan_structural(copy)
199
+ bangs = copy.count("!")
200
+ if bangs > cfg.get("max_exclamations", 1):
201
+ fails.append((0, f"{bangs} exclamation points (max {cfg.get('max_exclamations', 1)} per page)", ""))
202
+ return fails, warns
203
+
204
+
205
+ def collect(targets):
206
+ for t in targets:
207
+ p = Path(t)
208
+ if p.is_dir():
209
+ for f in sorted(p.rglob("*")):
210
+ if f.suffix.lower() in EXTS:
211
+ yield f
212
+ elif p.is_file():
213
+ yield p
214
+ else:
215
+ print(f" ? not found: {t}")
216
+
217
+
218
+ def main(argv):
219
+ args = argv[1:]
220
+ rules_path = None
221
+ if "--rules" in args:
222
+ i = args.index("--rules")
223
+ try:
224
+ rules_path = args[i + 1]
225
+ except IndexError:
226
+ print("--rules requires a path", file=sys.stderr)
227
+ return 2
228
+ del args[i:i + 2]
229
+ if not args:
230
+ print(__doc__)
231
+ return 2
232
+ banned, judgment, patterns, cfg, src = load_rules(rules_path)
233
+ print(f"rules: {src if src else 'built-in fallback'} "
234
+ f"({len(banned)} banned, {len(judgment)} judgment, {len(patterns)} patterns)")
235
+ total_fail = 0
236
+ for f in collect(args):
237
+ fails, warns = scan_file(f, banned, judgment, patterns, cfg)
238
+ if not fails and not warns:
239
+ print(f"PASS {f}")
240
+ continue
241
+ for line_no, why, ctx in fails:
242
+ loc = f"{f}:{line_no}" if line_no else f"{f}"
243
+ print(f"FAIL {loc} {why}" + (f" | {ctx}" if ctx else ""))
244
+ for line_no, why, ctx in warns:
245
+ print(f"WARN {f}:{line_no} {why}" + (f" | {ctx}" if ctx else ""))
246
+ total_fail += len(fails)
247
+ if total_fail:
248
+ print(f"\n{total_fail} violation(s). Restructure around concrete facts; do not synonym-swap.")
249
+ return 1
250
+ print("\nClean. Voice gate passed (review any WARN lines by hand).")
251
+ return 0
252
+
253
+
254
+ if __name__ == "__main__":
255
+ sys.exit(main(sys.argv))