@roughen/cli 0.3.0
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 +76 -0
- package/bin/roughen.mjs +204 -0
- package/lib/config.mjs +86 -0
- package/lib/jsx.mjs +494 -0
- package/lib/lint-file.mjs +86 -0
- package/lib/review.mjs +60 -0
- package/lib/site.mjs +409 -0
- package/lib/verify.mjs +299 -0
- package/package.json +40 -0
package/lib/jsx.mjs
ADDED
|
@@ -0,0 +1,494 @@
|
|
|
1
|
+
import { parse } from '@babel/parser';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Reader-facing copy inside .tsx/.jsx/.ts/.js files: JSX text, copy-bearing
|
|
5
|
+
* JSX attributes, and string values of copy-named object properties. Parsing
|
|
6
|
+
* uses @babel/parser (a CLI dependency; core stays dependency-free), so
|
|
7
|
+
* Roughen never guesses at JavaScript grammar with regular expressions.
|
|
8
|
+
* Project TypeScript can't serve here: TypeScript 7 no longer exposes an
|
|
9
|
+
* in-process parser, and the fleet runs both 5.x and 7.x.
|
|
10
|
+
*
|
|
11
|
+
* Every fragment carries a role:
|
|
12
|
+
* body paragraphs and descriptions: every rule applies, and an editor may rewrite it
|
|
13
|
+
* short headings, labels, buttons, alt text: too short for density rules, still editable
|
|
14
|
+
* protected leave exactly as written: H1s, titles and headlines, FAQ questions (exact-match
|
|
15
|
+
* search queries), keywords, schema names, and verbatim words (quotes,
|
|
16
|
+
* testimonials, reviews, citations). Verbatim fragments aren't linted at all.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
// Attributes and keys whose string values are prose a visitor reads.
|
|
20
|
+
const copyAttributes = new Set(['alt', 'title', 'aria-label', 'aria-description', 'placeholder', 'label', 'description', 'summary', 'caption', 'heading', 'subheading', 'subtitle', 'tagline', 'eyebrow', 'cta', 'ctaText', 'ctaLabel', 'buttonText', 'text', 'body', 'intro', 'answer', 'question']);
|
|
21
|
+
const bodyKeys = new Set(['description', 'desc', 'body', 'text', 'content', 'intro', 'summary', 'excerpt', 'answer', 'blurb', 'lede', 'lead', 'copy', 'message', 'paragraph', 'paragraphs', 'details', 'detail', 'bio', 'metaDescription', 'longDescription', 'shortDescription', 'subtext', 'subcopy', 'note', 'explanation', 'definition', 'takeaway', 'overview', 'story']);
|
|
22
|
+
const shortKeys = new Set(['heading', 'subheadline', 'subheading', 'subtitle', 'tagline', 'eyebrow', 'label', 'cta', 'ctaText', 'ctaLabel', 'buttonText', 'alt', 'aria-label', 'placeholder', 'caption', 'stat', 'headers', 'rows', 'cells']);
|
|
23
|
+
const listKeys = new Set(['paragraphs', 'bullets', 'features', 'points', 'benefits', 'highlights', 'steps', 'deliverables', 'includes', 'included', 'items', 'pros', 'cons', 'notes', 'tips', 'examples', 'takeaways', 'rows', 'cells', 'headers']);
|
|
24
|
+
// Search-facing strings: exact titles and questions people search for. Left exactly as written.
|
|
25
|
+
const protectedKey = /^(?:(?:meta|seo|page|og|twitter|document)(?:Title|Headline)|(?:title|headline)(?:[A-Z0-9]\w*)?|question|q|keywords|h1)$/;
|
|
26
|
+
// Verbatim words attributed to someone: any word of the key names one (clientQuote, TESTIMONIALS, sourceName).
|
|
27
|
+
const verbatimWords = new Set(['quote', 'quotes', 'testimonial', 'testimonials', 'review', 'reviews', 'reviewer', 'author', 'attribution', 'citation', 'citations', 'cite', 'publisher']);
|
|
28
|
+
const verbatimKeys = new Set(['sourceName', 'sourceTitle']);
|
|
29
|
+
// Strings under these keys are code or data, never copy, however they read.
|
|
30
|
+
const codeKey = /^(?:className|class|style|styles|css|sx|href|src|srcSet|url|uri|path|pathname|slug|id|key|ref|icon|image|img|logo|video|poster|variant|color|size|type|kind|tone|theme|as|rel|target|format|status|mode|d|fill|stroke|viewBox|sizes|loading|role|email|phone|tel|pattern|regex|query|sql|prompt|systemPrompt|system|instructions|model|template|mimeType|contentType|locale|lang|date|datetime|time|timezone|hash|token|apiKey|secret|env|method|route|redirect|destination|source|permanent|font|fontFamily|animation|transition|easing|gradient|background|border|shadow|cursor|display|position|transform|filter|mask|clipPath|align|justify|direction|name|slot|selector|event|action|endpoint|host|domain|canonical|robots|charset|viewport|manifest|@type|@context|@id)$|(?:ClassName|Class|Classes|Style|Styles|Url|Href|Src|Path|Slug|Id|Key|Icon|Image|Color|Variant|Prompt|Pattern|Regex|Query|Selector|Endpoint)$|^(?:data|on)[-A-Z]/;
|
|
31
|
+
const schemaNameKeys = new Set(['name', 'alternateName', 'legalName']);
|
|
32
|
+
const shortSuffixes = new Set(['title', 'heading', 'headline', 'label', 'cta', 'caption', 'eyebrow', 'tagline', 'subtitle']);
|
|
33
|
+
const bodySuffixes = new Set(['description', 'text', 'copy', 'body', 'summary', 'intro', 'answer', 'paragraph', 'blurb', 'detail', 'details', 'note', 'message']);
|
|
34
|
+
const headingTags = /^h[1-6]$/;
|
|
35
|
+
// Children of these render as code, not copy: a <style>{`...`}</style> block is CSS.
|
|
36
|
+
const codeTags = /^(?:style|script|code|pre)$/;
|
|
37
|
+
// HTML entities JSX decodes in text and attribute strings. Plain JavaScript
|
|
38
|
+
// strings don't decode them, so only JSX fragments are passed through this.
|
|
39
|
+
const entities = {
|
|
40
|
+
amp: '&', apos: "'", quot: '"', lt: '<', gt: '>', nbsp: ' ', mdash: '—', ndash: '–', hellip: '…',
|
|
41
|
+
rsquo: '’', lsquo: '‘', rdquo: '”', ldquo: '“', rarr: '→', larr: '←', uarr: '↑', darr: '↓',
|
|
42
|
+
middot: '·', bull: '•', copy: '©', reg: '®', trade: '™', times: '×', deg: '°', laquo: '«', raquo: '»',
|
|
43
|
+
eacute: 'é', shy: '', zwj: '', thinsp: ' ', ensp: ' ', emsp: ' ',
|
|
44
|
+
};
|
|
45
|
+
const entityPattern = /&(?:#x([0-9a-f]+)|#(\d+)|([a-z][a-z0-9]*));/gi;
|
|
46
|
+
function decodeEntity(match, hex, decimal, named) {
|
|
47
|
+
if (hex || decimal) { const code = parseInt(hex ?? decimal, hex ? 16 : 10); return code > 0 && code <= 0x10ffff ? String.fromCodePoint(code) : null; }
|
|
48
|
+
return entities[named.toLowerCase()] ?? null;
|
|
49
|
+
}
|
|
50
|
+
// Inline elements continue the surrounding sentence; everything else starts a new one.
|
|
51
|
+
const inlineTags = /^(?:a|abbr|b|bdi|bdo|cite|code|data|dfn|em|i|kbd|mark|q|s|samp|small|span|strong|sub|sup|time|u|var|br|wbr|Link)$/;
|
|
52
|
+
|
|
53
|
+
/** Words as Roughen counts them in copy reports. */
|
|
54
|
+
export function countWords(text) {
|
|
55
|
+
return (text.match(/[\p{L}\p{N}]+(?:['’][\p{L}\p{N}]+)*/gu) ?? []).length;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Whether a string under a key Roughen doesn't know reads as prose: five or
|
|
60
|
+
* more words, mostly words, and a sentence (ending punctuation, or eight or
|
|
61
|
+
* more words). Class lists, paths, URLs and code don't qualify.
|
|
62
|
+
*/
|
|
63
|
+
export function proseShaped(text) {
|
|
64
|
+
const value = text.trim();
|
|
65
|
+
if (countWords(value) < 5 || /[{}<>=\\]|\$\{/.test(value)) return false;
|
|
66
|
+
if (/^(?:https?:|www\.|\/|\.{1,2}\/|#|@|mailto:)/i.test(value)) return false;
|
|
67
|
+
const tokens = value.split(/\s+/);
|
|
68
|
+
if (tokens.every((token) => /^[a-z0-9:\-/[\].#%!_()&>]+$/.test(token)) && tokens.some((token) => /[-:[]/.test(token))) return false;
|
|
69
|
+
// Mostly plain words: not URLs, paths, 'quoted-keywords' or numbers.
|
|
70
|
+
const plainWord = (token) => /^[("“‘]?\p{L}[\p{L}\p{M}'’-]*[)"”’.,;:!?]*$/u.test(token) && !/^'.*'[.,;:]?$/.test(token);
|
|
71
|
+
if (tokens.filter(plainWord).length / tokens.length < 0.6) return false;
|
|
72
|
+
return /[.!?…:)"'”’]$/.test(value) || countWords(value) >= 8;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Data modules named for attributed words (citations.js, testimonials.ts,
|
|
77
|
+
* industry-service-citations.js) hold them whole: every string is protected.
|
|
78
|
+
* Components (.jsx/.tsx) aren't, since a Reviews page has its own headings.
|
|
79
|
+
*/
|
|
80
|
+
export function protectedByName(file) {
|
|
81
|
+
if (!file || /\.[jt]sx$/i.test(file)) return false;
|
|
82
|
+
const base = file.split(/[\\/]/).pop().replace(/\.[^.]+$/, '');
|
|
83
|
+
return keyWords(base).some((word) => /^(?:citations?|testimonials?|reviews?|quotes?)$/.test(word));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Text written for a model, not a reader.
|
|
87
|
+
const promptWords = new Set(['prompt', 'prompts', 'system', 'instruction', 'instructions', 'template', 'templates', 'regex', 'pattern', 'patterns']);
|
|
88
|
+
|
|
89
|
+
/** Short names and labels, most of them more than one word: "San Marco", "Naval Station Mayport". Not class lists, units or code. */
|
|
90
|
+
function labelList(texts) {
|
|
91
|
+
if (texts.length < 3) return false;
|
|
92
|
+
const label = (text) => {
|
|
93
|
+
const value = text.trim();
|
|
94
|
+
return /^[\p{L}\p{N}"“'‘(]/u.test(value) && countWords(value) <= 12 && !/[{}<>=\\;]|\$\{|^https?:|^\/|:\s*\d/.test(value)
|
|
95
|
+
&& !(/^[a-z0-9:\-/[\].#%!_()&>]+(?:\s+[a-z0-9:\-/[\].#%!_()&>]+)*$/.test(value) && /[-:[]/.test(value));
|
|
96
|
+
};
|
|
97
|
+
return texts.every(label) && texts.filter((text) => /\s/.test(text.trim())).length * 2 >= texts.length;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function keyWords(key) {
|
|
101
|
+
return key.replace(/([a-z0-9])([A-Z])/g, '$1 $2').toLowerCase().split(/[\s_-]+/).filter(Boolean);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Single source of truth for what a property key or JSX attribute holds:
|
|
106
|
+
* 'ignore' (code or data), 'verbatim', 'protected', 'short', 'body', 'list'
|
|
107
|
+
* (items are body), or null when unknown (a prose-shaped value counts as body).
|
|
108
|
+
* Used by extraction, so linting, site reports and verify agree. A project's
|
|
109
|
+
* `copy` config adds keys to a role, and wins over the defaults.
|
|
110
|
+
*/
|
|
111
|
+
export function classifyKey(key, keys = {}) {
|
|
112
|
+
if (!key) return null;
|
|
113
|
+
if (keys.ignore?.includes(key)) return 'ignore';
|
|
114
|
+
if (keys.protected?.includes(key)) return 'protected';
|
|
115
|
+
if (keys.short?.includes(key)) return 'short';
|
|
116
|
+
if (keys.body?.includes(key)) return 'body';
|
|
117
|
+
if (codeKey.test(key)) return 'ignore';
|
|
118
|
+
const words = keyWords(key);
|
|
119
|
+
if (verbatimKeys.has(key) || verbatimWords.has(words[0]) || verbatimWords.has(words.at(-1))) return 'verbatim';
|
|
120
|
+
if (protectedKey.test(key)) return 'protected';
|
|
121
|
+
if (shortKeys.has(key)) return 'short';
|
|
122
|
+
if (listKeys.has(key)) return 'list';
|
|
123
|
+
if (bodyKeys.has(key)) return 'body';
|
|
124
|
+
// sectionTitle, cardHeading, introText, seoDescription: the last word names the role.
|
|
125
|
+
if (shortSuffixes.has(words.at(-1))) return 'short';
|
|
126
|
+
if (bodySuffixes.has(words.at(-1))) return 'body';
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function name(node) {
|
|
131
|
+
if (!node) return null;
|
|
132
|
+
if (node.type === 'Identifier' || node.type === 'JSXIdentifier') return node.name;
|
|
133
|
+
if (node.type === 'StringLiteral') return node.value;
|
|
134
|
+
if (node.type === 'JSXNamespacedName') return `${node.namespace.name}:${node.name.name}`;
|
|
135
|
+
if (node.type === 'JSXMemberExpression') return `${name(node.object)}.${node.property.name}`;
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const stringNode = (node) => node?.type === 'StringLiteral' || node?.type === 'TemplateLiteral';
|
|
140
|
+
const literalText = (node) => node.type === 'StringLiteral' ? node.value : node.quasis.map((quasi) => quasi.value.cooked ?? quasi.value.raw).join(' ');
|
|
141
|
+
|
|
142
|
+
/** Parses JS/TS/JSX the way extraction does. Throws a SyntaxError when the file doesn't parse. */
|
|
143
|
+
export function parseSource(source, fileName) {
|
|
144
|
+
const jsx = !/\.(?:c|m)?ts$/i.test(fileName);
|
|
145
|
+
return parse(source, {
|
|
146
|
+
sourceType: 'unambiguous',
|
|
147
|
+
plugins: [...(/\.(?:c|m)?tsx?$/i.test(fileName) ? ['typescript'] : []), ...(jsx ? ['jsx'] : []), 'decorators-legacy'],
|
|
148
|
+
errorRecovery: false,
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Fragments of copy with exact source ranges. `group` joins JSX text split by
|
|
154
|
+
* inline elements (`We build <strong>fast</strong> sites`) back into one
|
|
155
|
+
* sentence; `role` says what an editor may do with it (see above); `short`
|
|
156
|
+
* (role isn't body) marks what density rules skip; `key` names the key,
|
|
157
|
+
* attribute or tag it came from; `inferred` marks prose found under a key
|
|
158
|
+
* Roughen doesn't know. Options: `keys` ({ body, short, protected, ignore }
|
|
159
|
+
* arrays from the project's `copy` config) and `protectedFile` (every
|
|
160
|
+
* fragment is protected). Throws a SyntaxError when the file doesn't parse.
|
|
161
|
+
*/
|
|
162
|
+
export function extractCopy(source, fileName, { keys = {}, protectedFile = false, ast = parseSource(source, fileName) } = {}) {
|
|
163
|
+
const fragments = [];
|
|
164
|
+
let groupId = 0;
|
|
165
|
+
const add = (start, end, group, role, key, extra = {}) => {
|
|
166
|
+
if (end <= start || !source.slice(start, end).trim()) return;
|
|
167
|
+
const final = protectedFile && role !== 'verbatim' ? 'protected' : role;
|
|
168
|
+
fragments.push({ start, end, group, role: final === 'verbatim' ? 'protected' : final, short: final !== 'body', key: key ?? null, ...(final === 'verbatim' ? { verbatim: true } : {}), ...extra });
|
|
169
|
+
};
|
|
170
|
+
// A string or template literal. Template quasis share one group: the expressions between them are values.
|
|
171
|
+
const literal = (node, role, key, { jsxEntities = false, group = ++groupId, inferred = false } = {}) => {
|
|
172
|
+
const extra = { ...(jsxEntities ? { entities: true } : {}), ...(inferred ? { inferred: true } : {}) };
|
|
173
|
+
if (node?.type === 'StringLiteral') add(node.start + 1, node.end - 1, group, role, key, extra);
|
|
174
|
+
else if (node?.type === 'TemplateLiteral') for (const quasi of node.quasis) add(quasi.start, quasi.end, group, role, key, extra);
|
|
175
|
+
};
|
|
176
|
+
// Every string in a subtree takes one role: a testimonial object, a keywords array, a schema name.
|
|
177
|
+
const forced = (node, role, key) => {
|
|
178
|
+
if (!node || typeof node.type !== 'string') return;
|
|
179
|
+
if (stringNode(node)) { literal(node, role, key); return; }
|
|
180
|
+
if (node.type === 'JSXText') { add(node.start, node.end, ++groupId, role, key, { entities: true }); return; }
|
|
181
|
+
if (node.type === 'ObjectProperty') {
|
|
182
|
+
const inner = node.computed ? null : name(node.key);
|
|
183
|
+
if (classifyKey(inner, keys) === 'ignore') return;
|
|
184
|
+
forced(node.value, role, inner ?? key);
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
if (node.type === 'JSXAttribute') { if (node.value) forced(node.value, role, name(node.name)); return; }
|
|
188
|
+
for (const field of Object.keys(node)) {
|
|
189
|
+
if (field === 'loc' || field === 'start' || field === 'end' || field === 'extra' || field === 'key' || field.endsWith('Comments') || field === 'callee') continue;
|
|
190
|
+
const value = node[field];
|
|
191
|
+
if (Array.isArray(value)) for (const item of value) forced(item, role, key);
|
|
192
|
+
else if (value && typeof value === 'object') forced(value, role, key);
|
|
193
|
+
}
|
|
194
|
+
};
|
|
195
|
+
// "a " + b + "c": the string pieces of a concatenation render as one string.
|
|
196
|
+
const concatenated = (node) => node?.type === 'BinaryExpression' && node.operator === '+' && [node.left, node.right].some((side) => stringNode(side) || concatenated(side));
|
|
197
|
+
const pieces = (node, role, key, group) => {
|
|
198
|
+
if (stringNode(node)) literal(node, role, key, { group });
|
|
199
|
+
else if (node?.type === 'BinaryExpression' && node.operator === '+') { pieces(node.left, role, key, group); pieces(node.right, role, key, group); }
|
|
200
|
+
};
|
|
201
|
+
// A value under a known key: strings take the key's role; arrays (of arrays) are items.
|
|
202
|
+
const keyed = (value, role, key) => {
|
|
203
|
+
if (stringNode(value)) { literal(value, role, key); return true; }
|
|
204
|
+
if (concatenated(value)) { pieces(value, role, key, ++groupId); return true; }
|
|
205
|
+
if (value?.type === 'ArrayExpression') {
|
|
206
|
+
for (const item of value.elements) if (!keyed(item, role, key) && item) visit(item, null);
|
|
207
|
+
return true;
|
|
208
|
+
}
|
|
209
|
+
return false;
|
|
210
|
+
};
|
|
211
|
+
// An unknown key is copy in this file if any of its values reads as prose:
|
|
212
|
+
// then its short values ("of leads lost to slow intake response") are copy too.
|
|
213
|
+
const proseKeys = new Set();
|
|
214
|
+
const scan = (node) => {
|
|
215
|
+
if (!node || typeof node.type !== 'string') return;
|
|
216
|
+
if (node.type === 'ObjectProperty' && !node.computed && stringNode(node.value) && proseShaped(literalText(node.value))) {
|
|
217
|
+
const key = name(node.key);
|
|
218
|
+
if (classifyKey(key, keys) === null) proseKeys.add(key);
|
|
219
|
+
}
|
|
220
|
+
for (const field of Object.keys(node)) {
|
|
221
|
+
if (field === 'loc' || field === 'start' || field === 'end' || field === 'extra' || field.endsWith('Comments')) continue;
|
|
222
|
+
const value = node[field];
|
|
223
|
+
if (Array.isArray(value)) for (const item of value) scan(item);
|
|
224
|
+
else if (value && typeof value === 'object') scan(value);
|
|
225
|
+
}
|
|
226
|
+
};
|
|
227
|
+
scan(ast.program);
|
|
228
|
+
// A value under an unknown key: prose-shaped strings are body copy, and a
|
|
229
|
+
// list of labels ("San Marco", "Mayo Clinic, Florida campus") is short copy.
|
|
230
|
+
const inferred = (value, key) => {
|
|
231
|
+
if (stringNode(value) && (proseKeys.has(key) || proseShaped(literalText(value)))) { literal(value, 'body', key, { inferred: true }); return true; }
|
|
232
|
+
if (value?.type === 'ArrayExpression' && value.elements.length && value.elements.every((item) => stringNode(item))) {
|
|
233
|
+
const texts = value.elements.map(literalText);
|
|
234
|
+
const role = texts.some(proseShaped) ? 'body' : labelList(texts) ? 'short' : null;
|
|
235
|
+
if (!role) return false;
|
|
236
|
+
for (const item of value.elements) literal(item, role, key, { inferred: true });
|
|
237
|
+
return true;
|
|
238
|
+
}
|
|
239
|
+
return false;
|
|
240
|
+
};
|
|
241
|
+
// ['Review your site', 'Send a quote'].map((item) => <li>{item}</li>): an array literal rendered in place.
|
|
242
|
+
const rendersJsx = (node) => {
|
|
243
|
+
let found = false;
|
|
244
|
+
const walk = (child) => {
|
|
245
|
+
if (found || !child || typeof child.type !== 'string') return;
|
|
246
|
+
if (child.type === 'JSXElement' || child.type === 'JSXFragment') { found = true; return; }
|
|
247
|
+
for (const field of Object.keys(child)) {
|
|
248
|
+
if (field === 'loc' || field === 'start' || field === 'end' || field === 'extra' || field.endsWith('Comments')) continue;
|
|
249
|
+
const value = child[field];
|
|
250
|
+
if (Array.isArray(value)) value.forEach(walk); else if (value && typeof value === 'object') walk(value);
|
|
251
|
+
}
|
|
252
|
+
};
|
|
253
|
+
walk(node);
|
|
254
|
+
return found;
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
const visit = (node, jsxGroup) => {
|
|
258
|
+
if (!node || typeof node.type !== 'string') return;
|
|
259
|
+
switch (node.type) {
|
|
260
|
+
case 'JSXElement': {
|
|
261
|
+
const tag = name(node.openingElement.name) ?? '';
|
|
262
|
+
for (const attribute of node.openingElement.attributes) visit(attribute, null);
|
|
263
|
+
if (codeTags.test(tag)) return;
|
|
264
|
+
const role = tag === 'h1' || tag === 'title' ? 'protected' : headingTags.test(tag) ? 'short' : 'body';
|
|
265
|
+
const group = jsxGroup && inlineTags.test(tag) ? jsxGroup : { id: ++groupId, role, tag };
|
|
266
|
+
for (const child of node.children) visit(child, group);
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
case 'JSXFragment':
|
|
270
|
+
for (const child of node.children) visit(child, jsxGroup ?? { id: ++groupId, role: 'body', tag: null });
|
|
271
|
+
return;
|
|
272
|
+
case 'JSXText':
|
|
273
|
+
if (jsxGroup) add(node.start, node.end, jsxGroup.id, jsxGroup.role, jsxGroup.tag, { entities: true });
|
|
274
|
+
return;
|
|
275
|
+
case 'JSXExpressionContainer': {
|
|
276
|
+
const expression = node.expression;
|
|
277
|
+
// {"literal"} inside JSX children renders as text.
|
|
278
|
+
if (jsxGroup && stringNode(expression)) { literal(expression, jsxGroup.role, jsxGroup.tag, { group: jsxGroup.id }); return; }
|
|
279
|
+
// {open ? "Close" : "Open"} and {note && "Beta"}: each string branch renders on its own.
|
|
280
|
+
if (jsxGroup && (expression.type === 'ConditionalExpression' || expression.type === 'LogicalExpression')) {
|
|
281
|
+
const branches = expression.type === 'ConditionalExpression' ? [expression.test, expression.consequent, expression.alternate] : [expression.left, expression.right];
|
|
282
|
+
for (const branch of branches) {
|
|
283
|
+
if (stringNode(branch) && branch !== expression.test && !(expression.type === 'LogicalExpression' && expression.operator === '&&' && branch === expression.left)) literal(branch, jsxGroup.role, jsxGroup.tag);
|
|
284
|
+
else visit(branch, null);
|
|
285
|
+
}
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
visit(expression, null);
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
case 'JSXAttribute': {
|
|
292
|
+
const attribute = name(node.name);
|
|
293
|
+
if (!node.value) return;
|
|
294
|
+
const value = node.value.type === 'JSXExpressionContainer' ? node.value.expression : node.value;
|
|
295
|
+
const kind = classifyKey(attribute, keys);
|
|
296
|
+
// body="We'll" decodes; body={"We'll"} is a JavaScript string and doesn't.
|
|
297
|
+
const jsxEntities = value === node.value;
|
|
298
|
+
if (kind === 'ignore') { if (!stringNode(value)) visit(value, null); return; }
|
|
299
|
+
if (kind === 'verbatim') { forced(value, 'verbatim', attribute); return; }
|
|
300
|
+
if (kind === 'protected') { forced(value, 'protected', attribute); return; }
|
|
301
|
+
if (stringNode(value)) {
|
|
302
|
+
if (copyAttributes.has(attribute) || kind === 'short' || kind === 'body' || kind === 'list') literal(value, kind === 'short' ? 'short' : 'body', attribute, { jsxEntities });
|
|
303
|
+
else if (kind === null && proseShaped(literalText(value))) literal(value, 'body', attribute, { jsxEntities, inferred: true });
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
// items={['Fast', 'Secure']}: a list prop's strings are copy too.
|
|
307
|
+
if ((kind || copyAttributes.has(attribute)) && keyed(value, kind === 'short' ? 'short' : 'body', attribute)) return;
|
|
308
|
+
if (!kind && inferred(value, attribute)) return;
|
|
309
|
+
visit(value, null);
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
case 'ObjectExpression': {
|
|
313
|
+
const siblings = new Set(node.properties.map((property) => property.type === 'ObjectProperty' && !property.computed ? name(property.key) : null).filter(Boolean));
|
|
314
|
+
const schema = siblings.has('@type');
|
|
315
|
+
const faq = siblings.has('q') || siblings.has('question');
|
|
316
|
+
for (const property of node.properties) {
|
|
317
|
+
if (property.type !== 'ObjectProperty') { visit(property, null); continue; }
|
|
318
|
+
const key = property.computed ? null : name(property.key);
|
|
319
|
+
const value = property.value;
|
|
320
|
+
if (schema && schemaNameKeys.has(key)) { forced(value, 'protected', key); continue; }
|
|
321
|
+
// { q, a } is a question and its answer.
|
|
322
|
+
const kind = faq && key === 'a' ? 'body' : classifyKey(key, keys);
|
|
323
|
+
if (kind === 'ignore') { if (!stringNode(value)) visit(value, null); continue; }
|
|
324
|
+
if (kind === 'verbatim' || kind === 'protected') { forced(value, kind, key); continue; }
|
|
325
|
+
if (kind && keyed(value, kind === 'list' ? 'body' : kind, key)) continue;
|
|
326
|
+
if (!kind && key && inferred(value, key)) continue;
|
|
327
|
+
visit(value, null);
|
|
328
|
+
}
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
case 'CallExpression': {
|
|
332
|
+
const callee = node.callee;
|
|
333
|
+
if (callee?.type === 'MemberExpression' && name(callee.property) === 'map' && callee.object.type === 'ArrayExpression' && callee.object.elements.length
|
|
334
|
+
&& callee.object.elements.every((item) => stringNode(item)) && callee.object.elements.some((item) => /\s/.test(literalText(item).trim())) && rendersJsx(node.arguments[0])) {
|
|
335
|
+
keyed(callee.object, 'body', 'map');
|
|
336
|
+
for (const argument of node.arguments) visit(argument, null);
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
for (const field of ['callee', 'arguments', 'typeArguments', 'typeParameters']) {
|
|
340
|
+
const value = node[field];
|
|
341
|
+
if (Array.isArray(value)) for (const item of value) visit(item, null); else visit(value, null);
|
|
342
|
+
}
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
case 'VariableDeclarator':
|
|
346
|
+
case 'AssignmentPattern': {
|
|
347
|
+
// const description = "..." and ({ description = "..." }) hold copy like a description key does.
|
|
348
|
+
const [target, value] = node.type === 'VariableDeclarator' ? [node.id, node.init] : [node.left, node.right];
|
|
349
|
+
const key = target?.type === 'Identifier' ? target.name : null;
|
|
350
|
+
const kind = key && value ? classifyKey(key, keys) : null;
|
|
351
|
+
// const TESTIMONIALS = [...] holds verbatim words, whatever its items' keys.
|
|
352
|
+
if (kind === 'verbatim' && verbatimWords.has(keyWords(key).at(-1))) { forced(value, 'verbatim', key); return; }
|
|
353
|
+
if (kind === 'protected' && stringNode(value)) { literal(value, 'protected', key); return; }
|
|
354
|
+
if ((kind === 'body' || kind === 'short' || kind === 'list') && keyed(value, kind === 'short' ? 'short' : 'body', key)) return;
|
|
355
|
+
// const facts = ['…', '…']: an array of prose under any name that isn't a prompt or code.
|
|
356
|
+
if (kind === null && key && value?.type === 'ArrayExpression' && !keyWords(key).some((word) => promptWords.has(word)) && inferred(value, key)) return;
|
|
357
|
+
if (node.type === 'VariableDeclarator') visit(target, null);
|
|
358
|
+
visit(value, null);
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
default:
|
|
362
|
+
for (const key of Object.keys(node)) {
|
|
363
|
+
if (key === 'loc' || key === 'start' || key === 'end' || key === 'extra' || key === 'leadingComments' || key === 'trailingComments' || key === 'innerComments') continue;
|
|
364
|
+
const value = node[key];
|
|
365
|
+
if (Array.isArray(value)) for (const item of value) visit(item, jsxGroup);
|
|
366
|
+
else if (value && typeof value === 'object') visit(value, jsxGroup);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
};
|
|
370
|
+
visit(ast.program, null);
|
|
371
|
+
return fragments.sort((a, b) => a.start - b.start);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* One virtual plain-text document per kind of copy, with a map back to the
|
|
376
|
+
* source. Body copy is joined into paragraphs so density rules see the page
|
|
377
|
+
* as a reader does; short and protected copy (headings, labels, titles) is
|
|
378
|
+
* linted separately, without density rules. Verbatim words aren't linted.
|
|
379
|
+
*/
|
|
380
|
+
export function virtualDocuments(source, fragments) {
|
|
381
|
+
const build = (items) => {
|
|
382
|
+
let text = '';
|
|
383
|
+
const segments = [];
|
|
384
|
+
let previousGroup = null;
|
|
385
|
+
let previousEnd = 0;
|
|
386
|
+
for (const fragment of items) {
|
|
387
|
+
// {' '} between inline elements renders as a space the fragments don't carry.
|
|
388
|
+
if (previousGroup !== null) text += fragment.group !== previousGroup ? '\n\n' : /\{\s*(['"`])\s+\1\s*\}/.test(source.slice(previousEnd, fragment.start)) ? ' ' : '';
|
|
389
|
+
previousEnd = fragment.end;
|
|
390
|
+
// Plain characters map back one to one. A decoded entity is its own
|
|
391
|
+
// segment: one virtual character standing for the whole `'`.
|
|
392
|
+
const raw = source.slice(fragment.start, fragment.end);
|
|
393
|
+
let cursor = 0;
|
|
394
|
+
const plain = (to) => {
|
|
395
|
+
if (to <= cursor) return;
|
|
396
|
+
segments.push({ virtualStart: text.length, sourceStart: fragment.start + cursor, length: to - cursor, sourceLength: to - cursor });
|
|
397
|
+
text += raw.slice(cursor, to);
|
|
398
|
+
};
|
|
399
|
+
if (fragment.entities) {
|
|
400
|
+
for (const match of raw.matchAll(entityPattern)) {
|
|
401
|
+
const decoded = decodeEntity(...match);
|
|
402
|
+
if (decoded === null) continue;
|
|
403
|
+
plain(match.index);
|
|
404
|
+
segments.push({ virtualStart: text.length, sourceStart: fragment.start + match.index, length: decoded.length, sourceLength: match[0].length });
|
|
405
|
+
text += decoded;
|
|
406
|
+
cursor = match.index + match[0].length;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
plain(raw.length);
|
|
410
|
+
previousGroup = fragment.group;
|
|
411
|
+
}
|
|
412
|
+
return { text, segments };
|
|
413
|
+
};
|
|
414
|
+
return {
|
|
415
|
+
body: build(fragments.filter((fragment) => fragment.role === 'body')),
|
|
416
|
+
short: build(fragments.filter((fragment) => fragment.role !== 'body' && !fragment.verbatim)),
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/** The text a group of fragments renders, entities decoded: what a reader sees. */
|
|
421
|
+
export function groupText(source, fragments) {
|
|
422
|
+
return virtualDocuments(source, fragments.map((fragment) => ({ ...fragment, role: 'body', verbatim: false }))).body.text;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// A point inside a segment. Plain segments map by offset; a decoded entity maps
|
|
426
|
+
// to its start, or to its end once the point is past its first character.
|
|
427
|
+
function sourcePoint(segment, offset) {
|
|
428
|
+
const sourceLength = segment.sourceLength ?? segment.length;
|
|
429
|
+
if (sourceLength === segment.length) return segment.sourceStart + offset;
|
|
430
|
+
return segment.sourceStart + (offset === 0 ? 0 : sourceLength);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/** Maps a virtual range back to the source, or null when it starts in a separator Roughen inserted. */
|
|
434
|
+
export function toSourceRange(segments, [start, end]) {
|
|
435
|
+
const segment = segments.findLast((item) => item.virtualStart <= start);
|
|
436
|
+
if (!segment) return null;
|
|
437
|
+
const offset = start - segment.virtualStart;
|
|
438
|
+
if (offset >= segment.length && !(offset === segment.length && start === end)) return null;
|
|
439
|
+
if (start === end) { const point = sourcePoint(segment, offset); return [point, point]; }
|
|
440
|
+
// A range can cross segments: inline elements split a sentence, and entities split a fragment.
|
|
441
|
+
const last = segments.findLast((item) => item.virtualStart < end);
|
|
442
|
+
if (!last || end - last.virtualStart > last.length) return null;
|
|
443
|
+
return [sourcePoint(segment, offset), sourcePoint(last, end - last.virtualStart)];
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/** The fragment containing a source offset, for tagging findings with a role. Fragments are sorted and disjoint. */
|
|
447
|
+
export function fragmentAt(fragments, offset) {
|
|
448
|
+
let low = 0; let high = fragments.length - 1;
|
|
449
|
+
while (low <= high) {
|
|
450
|
+
const middle = (low + high) >>> 1;
|
|
451
|
+
const fragment = fragments[middle];
|
|
452
|
+
if (offset < fragment.start) high = middle - 1;
|
|
453
|
+
else if (offset >= fragment.end) low = middle + 1;
|
|
454
|
+
else return fragment;
|
|
455
|
+
}
|
|
456
|
+
return null;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* Prose-shaped strings the copy reader didn't take, by where they sit
|
|
461
|
+
* ("key:prompt", "call:join", "attribute:data-tip"). Site reports list them
|
|
462
|
+
* so a project can add a key to its copy config if one of them is copy.
|
|
463
|
+
* Import paths and directives are skipped.
|
|
464
|
+
*/
|
|
465
|
+
export function missedCopy(source, ast, strings) {
|
|
466
|
+
const taken = strings.map((item) => item.range).sort((a, b) => a[0] - b[0]);
|
|
467
|
+
const covered = (node) => taken.some(([start, end]) => start < node.end && end > node.start);
|
|
468
|
+
const out = [];
|
|
469
|
+
const walk = (node, context) => {
|
|
470
|
+
if (!node || typeof node.type !== 'string') return;
|
|
471
|
+
if (node.type === 'ImportDeclaration' || node.type === 'ExportAllDeclaration' || node.type === 'Directive' || (node.type === 'ExportNamedDeclaration' && node.source)) return;
|
|
472
|
+
if (node.type === 'StringLiteral' || node.type === 'JSXText' || (node.type === 'TemplateLiteral' && node.expressions.length === 0)) {
|
|
473
|
+
const text = node.type === 'TemplateLiteral' ? node.quasis[0].value.raw : node.value;
|
|
474
|
+
if (proseShaped(text) && !covered(node)) out.push({ context: context ?? 'other', words: countWords(text) });
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
let next = context;
|
|
478
|
+
// Keys and attributes that hold code (className, fontFamily) aren't candidates, however they read.
|
|
479
|
+
if ((node.type === 'ObjectProperty' && !node.computed && classifyKey(name(node.key)) === 'ignore') || (node.type === 'JSXAttribute' && classifyKey(name(node.name)) === 'ignore')) return;
|
|
480
|
+
if (node.type === 'ObjectProperty' && !node.computed) next = `key:${name(node.key)}`;
|
|
481
|
+
else if (node.type === 'JSXAttribute') next = `attribute:${name(node.name)}`;
|
|
482
|
+
else if (node.type === 'CallExpression') next = `call:${node.callee?.type === 'MemberExpression' ? name(node.callee.property) : name(node.callee)}`;
|
|
483
|
+
else if (node.type === 'VariableDeclarator') next = `variable:${node.id?.name ?? '?'}`;
|
|
484
|
+
else if (node.type === 'ReturnStatement') next = 'return';
|
|
485
|
+
for (const key of Object.keys(node)) {
|
|
486
|
+
if (key === 'loc' || key === 'start' || key === 'end' || key === 'extra' || key.endsWith('Comments')) continue;
|
|
487
|
+
const value = node[key];
|
|
488
|
+
if (Array.isArray(value)) for (const item of value) walk(item, next);
|
|
489
|
+
else if (value && typeof value === 'object') walk(value, next);
|
|
490
|
+
}
|
|
491
|
+
};
|
|
492
|
+
walk(ast.program, null);
|
|
493
|
+
return out;
|
|
494
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { lint } from '@roughen/core';
|
|
3
|
+
import { extractCopy, virtualDocuments, toSourceRange, fragmentAt, countWords } from './jsx.mjs';
|
|
4
|
+
import { copyOptions } from './config.mjs';
|
|
5
|
+
|
|
6
|
+
/** Single source of truth for which files Roughen reads, shared by the CLI and the Claude plugin. */
|
|
7
|
+
export const proseFormats = { '.md': 'md', '.mdx': 'mdx', '.html': 'html', '.htm': 'html', '.txt': 'text' };
|
|
8
|
+
export const sourceExtensions = new Set(['.tsx', '.jsx', '.ts', '.js', '.mjs', '.cjs', '.mts', '.cts']);
|
|
9
|
+
|
|
10
|
+
/** 'md' | 'mdx' | 'html' | 'text' for prose, 'copy' for JS/TS source whose strings are linted. */
|
|
11
|
+
export function formatFor(file) {
|
|
12
|
+
const extension = path.extname(file).toLowerCase();
|
|
13
|
+
if (proseFormats[extension]) return proseFormats[extension];
|
|
14
|
+
if (sourceExtensions.has(extension) && !/\.d\.[cm]?ts$/i.test(file)) return 'copy';
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Density rules measure paragraphs; headings, buttons and labels are too short to judge.
|
|
19
|
+
const densityRules = ['structure/list-saturation', 'structure/contrast-frame', 'hedge/generalizer', 'structure/reveal-frame', 'rhythm/low-variance'];
|
|
20
|
+
|
|
21
|
+
function locator(source) {
|
|
22
|
+
const starts = [0];
|
|
23
|
+
for (let i = 0; i < source.length; i++) if (source[i] === '\n') starts.push(i + 1);
|
|
24
|
+
return (offset) => {
|
|
25
|
+
let low = 0; let high = starts.length;
|
|
26
|
+
while (low + 1 < high) { const middle = (low + high) >>> 1; if (starts[middle] <= offset) low = middle; else high = middle; }
|
|
27
|
+
return { line: low + 1, column: offset - starts[low] + 1 };
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Copy inside JS/TS: lint the page's body copy as one virtual document and its
|
|
33
|
+
* short copy separately, then map every finding back to the source file.
|
|
34
|
+
* Fixes are never applied to source files; findings are for review.
|
|
35
|
+
*/
|
|
36
|
+
function lintCopy(source, file, config, ast) {
|
|
37
|
+
const fragments = extractCopy(source, file, { ...copyOptions(file, config), ...(ast ? { ast } : {}) });
|
|
38
|
+
const { body, short } = virtualDocuments(source, fragments);
|
|
39
|
+
const base = { rules: config.rules, format: 'text', register: config.register };
|
|
40
|
+
// Banned terms and characters apply to every string a reader sees, headings included.
|
|
41
|
+
const voice = { banned: config.voice?.banned, bannedCharacters: config.voice?.bannedCharacters, bannedPatterns: config.voice?.bannedPatterns };
|
|
42
|
+
const bodyResult = lint(body.text, { ...base, voice });
|
|
43
|
+
const shortRules = { ...config.rules };
|
|
44
|
+
for (const id of densityRules) shortRules[id] = 'off';
|
|
45
|
+
const shortResult = lint(short.text, { ...base, rules: shortRules, voice });
|
|
46
|
+
const locate = locator(source);
|
|
47
|
+
const findings = [];
|
|
48
|
+
for (const [result, map] of [[bodyResult, body.segments], [shortResult, short.segments]]) {
|
|
49
|
+
for (const finding of result.findings) {
|
|
50
|
+
if (finding.ruleId === 'artifact/double-space') continue; // JSX collapses whitespace when it renders
|
|
51
|
+
const range = toSourceRange(map, finding.range);
|
|
52
|
+
if (!range) continue;
|
|
53
|
+
// A character spelled as an entity ( , ’) was typed on purpose, not pasted in.
|
|
54
|
+
if (finding.ruleId.startsWith('artifact/') && /^&(?:#x[0-9a-f]+|#\d+|[a-z][a-z0-9]*);$/i.test(source.slice(...range))) continue;
|
|
55
|
+
const fixRange = finding.fix && toSourceRange(map, finding.fix.range);
|
|
56
|
+
const first = locate(range[0]); const last = locate(range[1]);
|
|
57
|
+
const { fix, ...rest } = finding;
|
|
58
|
+
// The role of the string it's in: an editor may rewrite body and short copy, never protected copy.
|
|
59
|
+
const fragment = fragmentAt(fragments, range[0]);
|
|
60
|
+
findings.push({ ...rest, range, loc: { ...first, endLine: last.line, endColumn: last.column }, ...(fix && fixRange ? { fix: { range: fixRange, text: fix.text } } : {}), role: fragment?.role ?? null, key: fragment?.key ?? null });
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
// Required terms count anywhere on the page, headings included.
|
|
64
|
+
const required = lint(`${body.text}\n\n${short.text}`, { format: 'text', rules: config.rules, voice: { required: config.voice?.required } })
|
|
65
|
+
.findings.filter((finding) => finding.ruleId === 'voice/required');
|
|
66
|
+
findings.push(...required.map((finding) => ({ ...finding, loc: { line: 1, column: 1, endLine: 1, endColumn: 1 } })));
|
|
67
|
+
findings.sort((a, b) => a.range[0] - b.range[0]);
|
|
68
|
+
return { ...bodyResult, findings, copy: copySummary(source, fragments, locate, body) };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Every copy string with its role, key and line, and word totals per role: what an editor may rewrite. */
|
|
72
|
+
function copySummary(source, fragments, locate, body) {
|
|
73
|
+
const words = { body: 0, short: 0, protected: 0 };
|
|
74
|
+
const strings = fragments.map((fragment) => {
|
|
75
|
+
const count = countWords(source.slice(fragment.start, fragment.end));
|
|
76
|
+
words[fragment.role] += count;
|
|
77
|
+
return { range: [fragment.start, fragment.end], line: locate(fragment.start).line, role: fragment.role, key: fragment.key, words: count, ...(fragment.verbatim ? { verbatim: true } : {}), ...(fragment.inferred ? { inferred: true } : {}) };
|
|
78
|
+
});
|
|
79
|
+
return { fragments: fragments.length, bodyCharacters: body.text.length, words, strings };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Lints one file's text. `format` comes from formatFor; 'copy' means JS/TS source (`ast` reuses a parse). */
|
|
83
|
+
export function lintSource(source, { file, format, config = {}, fix = false, careful = false, ast = null }) {
|
|
84
|
+
if (format === 'copy') return lintCopy(source, file ?? 'input.tsx', config, ast);
|
|
85
|
+
return lint(source, { rules: config.rules, voice: config.voice, register: config.register, format, fix, careful });
|
|
86
|
+
}
|
package/lib/review.mjs
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { applyFixes, revisionBrief, scopeFindings } from '@roughen/core';
|
|
2
|
+
import { lintSource } from './lint-file.mjs';
|
|
3
|
+
|
|
4
|
+
/** Lines listed before "and N more" in a protected-strings note. */
|
|
5
|
+
const maxProtectedLines = 12;
|
|
6
|
+
|
|
7
|
+
const lineList = (lines) => `${lines.slice(0, maxProtectedLines).join(', ')}${lines.length > maxProtectedLines ? `, and ${lines.length - maxProtectedLines} more` : ''}`;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Protected strings in component copy: search-facing titles, H1s, FAQ
|
|
11
|
+
* questions, keywords, schema names, and verbatim words. The brief tells the
|
|
12
|
+
* editor to leave them alone, and findings inside them (a banned character in
|
|
13
|
+
* an H1, say) go to the owner instead of the rewrite list.
|
|
14
|
+
*/
|
|
15
|
+
function protectedNote(copy, findings, author) {
|
|
16
|
+
if (!copy) return null;
|
|
17
|
+
const strings = copy.strings.filter((item) => item.role === 'protected');
|
|
18
|
+
if (!strings.length) return null;
|
|
19
|
+
const lines = [...new Set(strings.map((item) => item.line))];
|
|
20
|
+
const flagged = findings.filter((finding) => finding.role === 'protected');
|
|
21
|
+
const flaggedLines = [...new Set(flagged.map((finding) => finding.loc.line))];
|
|
22
|
+
const constraint = author
|
|
23
|
+
? `Fix only what's flagged in the ${strings.length} protected string(s) (line ${lineList(lines)}). H1s, titles and headlines, FAQ questions, keywords and schema names are matched exactly by search, and quotations and citations are someone's words, so don't otherwise reword them.`
|
|
24
|
+
: `Leave the ${strings.length} protected string(s) exactly as written: H1s, titles and headlines, FAQ questions, keywords, schema names, quotations and citations (line ${lineList(lines)}).`;
|
|
25
|
+
const note = flagged.length ? `${flagged.length} finding(s) sit in protected strings (line ${lineList(flaggedLines)}). Don't edit them; report them to the page's owner.` : '';
|
|
26
|
+
return { count: strings.length, lines, findings: flagged.length, constraint, note };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Single source of truth for reviewing a piece of copy on every surface: the
|
|
31
|
+
* CLI's --brief, the Claude plugin hook, the MCP server and site plans. Lints,
|
|
32
|
+
* optionally narrows to the regions just written, and returns the revision
|
|
33
|
+
* brief, the mechanical fixes Roughen can apply itself, and the safely fixed
|
|
34
|
+
* text. For component copy the brief covers only what an editor may rewrite.
|
|
35
|
+
*/
|
|
36
|
+
export function review(source, { file, format, config = {}, regions = null, maxExcerpts = 8, linted = null, author = false } = {}) {
|
|
37
|
+
linted ??= lintSource(source, { file, format, config });
|
|
38
|
+
const result = regions ? { ...linted, findings: scopeFindings(linted.findings, regions) } : linted;
|
|
39
|
+
const guarded = protectedNote(linted.copy, result.findings, author);
|
|
40
|
+
// Protection stops an editor rewriting strings someone else settled. Whoever
|
|
41
|
+
// just wrote the copy (the plugin hook's Claude) fixes its own findings, protected or not.
|
|
42
|
+
const editable = guarded && !author ? result.findings.filter((finding) => finding.role !== 'protected') : result.findings;
|
|
43
|
+
const brief = revisionBrief(source, { findings: editable }, { maxExcerpts, constraints: guarded ? [guarded.constraint] : [] });
|
|
44
|
+
if (guarded) {
|
|
45
|
+
brief.protected = { strings: guarded.count, lines: guarded.lines, findings: guarded.findings };
|
|
46
|
+
if (guarded.note && !author) brief.text = brief.text ? `${brief.text}\n\n${guarded.note}` : guarded.note;
|
|
47
|
+
}
|
|
48
|
+
// Invisible characters, assistant framing, spacing: no judgment needed.
|
|
49
|
+
const mechanical = editable.filter((finding) => finding.fix && finding.fixSafety === 'safe' && finding.severity !== 'info');
|
|
50
|
+
// Source files are never rewritten; their copy is reviewed in place.
|
|
51
|
+
const fixed = format === 'copy' ? null : applyFixes(source, result.findings);
|
|
52
|
+
return { result, brief, mechanical, fixed };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** One line naming the mechanical issues, for surfaces that hand them to a writer. */
|
|
56
|
+
export function mechanicalSummary(mechanical, limit = 6) {
|
|
57
|
+
if (!mechanical.length) return '';
|
|
58
|
+
const items = [...new Set(mechanical.map((finding) => `${finding.message} (line ${finding.loc.line})`))];
|
|
59
|
+
return `Remove ${mechanical.length} mechanical issue(s): ${items.slice(0, limit).join('; ')}${items.length > limit ? '; …' : ''}.`;
|
|
60
|
+
}
|