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.
- package/LICENSE +21 -0
- package/README.md +110 -0
- package/bin/page-foundry.js +169 -0
- package/package.json +45 -0
- package/skills/page-foundry/README.md +13 -0
- package/skills/page-foundry/SKILL.md +233 -0
- package/skills/page-foundry/TESTS.md +22 -0
- package/skills/page-foundry/assets/brief-template.md +50 -0
- package/skills/page-foundry/references/archetypes.md +197 -0
- package/skills/page-foundry/references/conversion-rules.md +70 -0
- package/skills/page-foundry/references/design-direction.md +48 -0
- package/skills/page-foundry/references/handoff.md +82 -0
- package/skills/page-foundry/references/ship-gates.md +224 -0
- package/skills/page-foundry/references/voice.md +204 -0
- package/skills/page-foundry/scripts/voice_scan.py +255 -0
|
@@ -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))
|