@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/site.mjs
ADDED
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
import { readFile, readdir, lstat } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { loadConfig, included } from './config.mjs';
|
|
4
|
+
import { formatFor, lintSource } from './lint-file.mjs';
|
|
5
|
+
import { parseSource, missedCopy } from './jsx.mjs';
|
|
6
|
+
import { review } from './review.mjs';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* `roughen site`: which copy a Next.js app-router site ships, where its
|
|
10
|
+
* habits are, and a plan for revising it. Follows static and dynamic imports
|
|
11
|
+
* from every route file, resolves tsconfig/jsconfig paths, and never runs the
|
|
12
|
+
* site, a bundler or the network. Reads files; writes nothing.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
// Files Next.js renders for a route segment. route.* handlers and metadata files (sitemap, icons) aren't pages.
|
|
16
|
+
const routeFile = /^(?:page|layout|template|not-found|error|global-error|loading|default)\.(?:[cm]?[jt]sx?|mdx?)$/;
|
|
17
|
+
const resolvable = ['.tsx', '.ts', '.jsx', '.js', '.mjs', '.cjs', '.mts', '.cts', '.mdx', '.md'];
|
|
18
|
+
// Directories that never hold shipped copy.
|
|
19
|
+
const skippedDirs = new Set(['node_modules', 'dist', 'build', 'out', 'coverage', 'public', 'scripts', 'supabase', 'docs', 'test', 'tests', '__tests__', '__mocks__', 'e2e', 'patches', 'fixtures']);
|
|
20
|
+
const skippedFiles = /\.(?:test|spec|stories|config|d)\.[cm]?[jt]sx?$|^(?:next-env\.d\.ts|middleware\.[jt]s|proxy\.[jt]s|instrumentation\.[jt]s)$/;
|
|
21
|
+
// Route handlers and metadata files under app/: they answer requests, they aren't pages a reader reads.
|
|
22
|
+
const handlerFile = /^(?:route|sitemap|robots|manifest|opengraph-image|twitter-image|icon|apple-icon)\.[cm]?[jt]sx?$/;
|
|
23
|
+
|
|
24
|
+
/** tsconfig/jsconfig allow comments and trailing commas. */
|
|
25
|
+
function parseJsonc(text) {
|
|
26
|
+
let out = '';
|
|
27
|
+
for (let i = 0; i < text.length; i++) {
|
|
28
|
+
const char = text[i];
|
|
29
|
+
if (char === '"') {
|
|
30
|
+
let j = i + 1;
|
|
31
|
+
for (; j < text.length && text[j] !== '"'; j++) if (text[j] === '\\') j++;
|
|
32
|
+
out += text.slice(i, j + 1); i = j; continue;
|
|
33
|
+
}
|
|
34
|
+
if (char === '/' && text[i + 1] === '/') { while (i < text.length && text[i] !== '\n') i++; out += '\n'; continue; }
|
|
35
|
+
if (char === '/' && text[i + 1] === '*') { const end = text.indexOf('*/', i + 2); i = end < 0 ? text.length : end + 1; continue; }
|
|
36
|
+
out += char;
|
|
37
|
+
}
|
|
38
|
+
return JSON.parse(out.replace(/,(\s*[}\]])/g, '$1'));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function exists(file) {
|
|
42
|
+
try { return (await lstat(file)).isFile(); } catch { return false; }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Path aliases from tsconfig.json or jsconfig.json (one level of `extends`). */
|
|
46
|
+
async function loadAliases(root) {
|
|
47
|
+
for (const name of ['tsconfig.json', 'jsconfig.json']) {
|
|
48
|
+
const file = path.join(root, name);
|
|
49
|
+
if (!await exists(file)) continue;
|
|
50
|
+
let config = parseJsonc(await readFile(file, 'utf8'));
|
|
51
|
+
let base = root;
|
|
52
|
+
if (typeof config.extends === 'string' && config.extends.startsWith('.')) {
|
|
53
|
+
const parentFile = path.resolve(root, config.extends.endsWith('.json') ? config.extends : `${config.extends}.json`);
|
|
54
|
+
if (await exists(parentFile)) {
|
|
55
|
+
const parent = parseJsonc(await readFile(parentFile, 'utf8'));
|
|
56
|
+
config = { ...parent, ...config, compilerOptions: { ...parent.compilerOptions, ...config.compilerOptions } };
|
|
57
|
+
if (!config.compilerOptions?.baseUrl && parent.compilerOptions?.baseUrl) base = path.dirname(parentFile);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
const options = config.compilerOptions ?? {};
|
|
61
|
+
const baseUrl = options.baseUrl ? path.resolve(base, options.baseUrl) : root;
|
|
62
|
+
const paths = Object.entries(options.paths ?? {}).map(([pattern, targets]) => ({ pattern, targets: targets.map((target) => path.resolve(baseUrl, target)) }));
|
|
63
|
+
return { baseUrl: options.baseUrl ? baseUrl : null, paths };
|
|
64
|
+
}
|
|
65
|
+
return { baseUrl: null, paths: [] };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Resolves an import specifier to a file, or null for packages and anything missing. */
|
|
69
|
+
async function resolveImport(from, specifier, aliases) {
|
|
70
|
+
const candidates = [];
|
|
71
|
+
if (specifier.startsWith('.')) candidates.push(path.resolve(path.dirname(from), specifier));
|
|
72
|
+
else {
|
|
73
|
+
for (const { pattern, targets } of aliases.paths) {
|
|
74
|
+
const star = pattern.indexOf('*');
|
|
75
|
+
const match = star < 0 ? specifier === pattern : specifier.startsWith(pattern.slice(0, star)) && specifier.endsWith(pattern.slice(star + 1));
|
|
76
|
+
if (!match) continue;
|
|
77
|
+
const rest = star < 0 ? '' : specifier.slice(star, specifier.length - (pattern.length - star - 1));
|
|
78
|
+
for (const target of targets) candidates.push(target.replace('*', rest));
|
|
79
|
+
}
|
|
80
|
+
if (aliases.baseUrl) candidates.push(path.join(aliases.baseUrl, specifier));
|
|
81
|
+
}
|
|
82
|
+
for (const base of candidates) {
|
|
83
|
+
// ESM-style TypeScript imports name the emitted .js file.
|
|
84
|
+
const stems = [base, ...(/\.[cm]?js$/.test(base) ? [base.replace(/\.([cm]?)js$/, '.$1ts'), base.replace(/\.js$/, '.tsx')] : [])];
|
|
85
|
+
for (const stem of stems) {
|
|
86
|
+
if (await exists(stem)) return stem;
|
|
87
|
+
for (const extension of resolvable) if (await exists(stem + extension)) return stem + extension;
|
|
88
|
+
for (const extension of resolvable) if (await exists(path.join(stem, `index${extension}`))) return path.join(stem, `index${extension}`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Import specifiers in a module: static imports and re-exports, import(),
|
|
96
|
+
* require(), and template-literal import(`./cities/${slug}`), which a bundler
|
|
97
|
+
* turns into every file that can match. Type-only imports ship nothing.
|
|
98
|
+
*/
|
|
99
|
+
function importsOf(ast) {
|
|
100
|
+
const found = [];
|
|
101
|
+
const walk = (node) => {
|
|
102
|
+
if (!node || typeof node.type !== 'string') return;
|
|
103
|
+
if ((node.type === 'ImportDeclaration' || node.type === 'ExportNamedDeclaration' || node.type === 'ExportAllDeclaration') && node.source && node.importKind !== 'type' && node.exportKind !== 'type') {
|
|
104
|
+
found.push({ specifier: node.source.value });
|
|
105
|
+
} else if ((node.type === 'ImportExpression' || (node.type === 'CallExpression' && (node.callee.type === 'Import' || (node.callee.type === 'Identifier' && node.callee.name === 'require'))))) {
|
|
106
|
+
const argument = node.type === 'ImportExpression' ? node.source : node.arguments[0];
|
|
107
|
+
if (argument?.type === 'StringLiteral') found.push({ specifier: argument.value });
|
|
108
|
+
else if (argument?.type === 'TemplateLiteral') {
|
|
109
|
+
const quasis = argument.quasis.map((quasi) => quasi.value.cooked ?? quasi.value.raw);
|
|
110
|
+
if (argument.expressions.length === 0) found.push({ specifier: quasis[0] });
|
|
111
|
+
else if (quasis[0].includes('/')) found.push({ template: quasis });
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
for (const key of Object.keys(node)) {
|
|
115
|
+
if (key === 'loc' || key === 'start' || key === 'end' || key === 'extra' || key.endsWith('Comments')) continue;
|
|
116
|
+
const value = node[key];
|
|
117
|
+
if (Array.isArray(value)) for (const item of value) walk(item);
|
|
118
|
+
else if (value && typeof value === 'object') walk(value);
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
walk(ast.program);
|
|
122
|
+
return found;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Every file under `dir` a template import could name: the static prefix picks the directory, the rest must match. */
|
|
126
|
+
async function resolveTemplate(from, quasis, aliases) {
|
|
127
|
+
const prefix = quasis[0];
|
|
128
|
+
const slash = prefix.lastIndexOf('/');
|
|
129
|
+
const directorySpecifier = prefix.slice(0, slash) || '.';
|
|
130
|
+
let directory = null;
|
|
131
|
+
if (directorySpecifier.startsWith('.')) directory = path.resolve(path.dirname(from), directorySpecifier);
|
|
132
|
+
else {
|
|
133
|
+
for (const { pattern, targets } of aliases.paths) {
|
|
134
|
+
const star = pattern.indexOf('*');
|
|
135
|
+
if (star >= 0 && directorySpecifier.startsWith(pattern.slice(0, star))) { directory = targets[0].replace('*', directorySpecifier.slice(star)); break; }
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
if (!directory) return [];
|
|
139
|
+
const escape = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
140
|
+
const matcher = new RegExp(`^${escape(prefix.slice(slash + 1))}${quasis.slice(1).map((quasi) => `.+${escape(quasi)}`).join('')}(?:${resolvable.map(escape).join('|')})?$`);
|
|
141
|
+
const files = [];
|
|
142
|
+
const walk = async (dir, relative) => {
|
|
143
|
+
let entries;
|
|
144
|
+
try { entries = await readdir(dir, { withFileTypes: true }); } catch { return; }
|
|
145
|
+
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
146
|
+
if (entry.name.startsWith('.') || entry.name === 'node_modules') continue;
|
|
147
|
+
const next = relative ? `${relative}/${entry.name}` : entry.name;
|
|
148
|
+
if (entry.isDirectory()) await walk(path.join(dir, entry.name), next);
|
|
149
|
+
else if (entry.isFile() && matcher.test(next) && resolvable.includes(path.extname(entry.name))) files.push(path.join(dir, entry.name));
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
await walk(directory, '');
|
|
153
|
+
return files;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** '/web-design/[area]' for app/web-design/[area]/page.jsx. Route groups and parallel slots don't appear in the URL. */
|
|
157
|
+
export function routePath(appDir, file) {
|
|
158
|
+
const segments = path.relative(appDir, path.dirname(file)).split(path.sep).filter((segment) => segment && !/^\(.*\)$/.test(segment) && !segment.startsWith('@'));
|
|
159
|
+
return `/${segments.join('/')}`;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** The site area a route belongs to: its first URL segment, 'home' for /, 'site' for root layouts. */
|
|
163
|
+
function areaOf(entry) {
|
|
164
|
+
const first = entry.route.split('/')[1];
|
|
165
|
+
if (first) return first.replace(/^\[+\.{0,3}|\]+$/g, '') || first;
|
|
166
|
+
return entry.kind === 'page' ? 'home' : 'site';
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function walkFiles(dir, root, out = []) {
|
|
170
|
+
let entries;
|
|
171
|
+
try { entries = await readdir(dir, { withFileTypes: true }); } catch { return out; }
|
|
172
|
+
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
173
|
+
if (entry.name.startsWith('.') || entry.isSymbolicLink()) continue;
|
|
174
|
+
const file = path.join(dir, entry.name);
|
|
175
|
+
if (entry.isDirectory()) { if (!skippedDirs.has(entry.name)) await walkFiles(file, root, out); }
|
|
176
|
+
else if (entry.isFile() && formatFor(file) && !skippedFiles.test(entry.name)) out.push(file);
|
|
177
|
+
}
|
|
178
|
+
return out;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Findings split the way an editor sees them. */
|
|
182
|
+
function tally(result, reviewed) {
|
|
183
|
+
const counts = { findings: 0, revise: 0, banned: 0, protected: 0 };
|
|
184
|
+
for (const finding of result.findings) {
|
|
185
|
+
if (finding.severity === 'info') continue;
|
|
186
|
+
if (finding.role === 'protected') { counts.protected++; continue; }
|
|
187
|
+
counts.findings++;
|
|
188
|
+
if (finding.ruleId.startsWith('voice/banned-')) counts.banned++;
|
|
189
|
+
}
|
|
190
|
+
counts.revise = reviewed.brief.items.reduce((sum, item) => sum + item.rewriteAtLeast, 0);
|
|
191
|
+
return counts;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const byRule = (findings) => {
|
|
195
|
+
const rules = {};
|
|
196
|
+
for (const finding of findings) if (finding.severity !== 'info') rules[finding.ruleId] = (rules[finding.ruleId] ?? 0) + 1;
|
|
197
|
+
return rules;
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Groups of files for editors working in parallel. Every file lands in
|
|
202
|
+
* exactly one group: its route area if one area ships it, 'shared' if
|
|
203
|
+
* several do. Areas split into groups of about `groupWords` editable words,
|
|
204
|
+
* in path order so a directory stays together. Busiest groups first.
|
|
205
|
+
*/
|
|
206
|
+
export function workPlan(files, { groupWords = 6000 } = {}) {
|
|
207
|
+
const byArea = new Map();
|
|
208
|
+
for (const file of files) {
|
|
209
|
+
if (!file.editableWords) continue;
|
|
210
|
+
const area = file.areas.length === 1 ? file.areas[0] : 'shared';
|
|
211
|
+
byArea.set(area, [...(byArea.get(area) ?? []), file]);
|
|
212
|
+
}
|
|
213
|
+
const groups = [];
|
|
214
|
+
for (const [area, members] of [...byArea].sort(([a], [b]) => a.localeCompare(b))) {
|
|
215
|
+
const chunks = [];
|
|
216
|
+
let current = null;
|
|
217
|
+
for (const file of members.sort((a, b) => a.path.localeCompare(b.path))) {
|
|
218
|
+
if (!current || (current.words + file.editableWords > groupWords && current.files.length)) chunks.push(current = { words: 0, files: [] });
|
|
219
|
+
current.files.push(file);
|
|
220
|
+
current.words += file.editableWords;
|
|
221
|
+
}
|
|
222
|
+
chunks.forEach((chunk, index) => groups.push({
|
|
223
|
+
id: chunks.length > 1 ? `${area}-${index + 1}` : area,
|
|
224
|
+
area,
|
|
225
|
+
files: chunk.files.map((file) => file.path),
|
|
226
|
+
words: chunk.words,
|
|
227
|
+
findings: chunk.files.reduce((sum, file) => sum + file.findings, 0),
|
|
228
|
+
revise: chunk.files.reduce((sum, file) => sum + file.revise, 0),
|
|
229
|
+
banned: chunk.files.reduce((sum, file) => sum + file.banned, 0),
|
|
230
|
+
}));
|
|
231
|
+
}
|
|
232
|
+
return groups.sort((a, b) => b.findings - a.findings || b.words - a.words || a.id.localeCompare(b.id));
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* The site report. `configPath` overrides roughen.config discovery; without
|
|
237
|
+
* it each file uses its nearest config, like the linter.
|
|
238
|
+
*/
|
|
239
|
+
export async function planSite(input, { configPath, groupWords = 6000, maxExcerpts = 8 } = {}) {
|
|
240
|
+
const root = path.resolve(input);
|
|
241
|
+
let app = null;
|
|
242
|
+
for (const dir of [path.join(root, 'app'), path.join(root, 'src', 'app')]) {
|
|
243
|
+
try { if ((await lstat(dir)).isDirectory()) { app = dir; break; } } catch {}
|
|
244
|
+
}
|
|
245
|
+
if (!app) throw new Error(`No app/ or src/app/ directory in ${root}: roughen site reads Next.js app-router projects`);
|
|
246
|
+
const aliases = await loadAliases(root);
|
|
247
|
+
const all = await walkFiles(root, root);
|
|
248
|
+
const entries = all.filter((file) => file.startsWith(app + path.sep) && routeFile.test(path.basename(file)))
|
|
249
|
+
.map((file) => ({ file, route: routePath(app, file), kind: path.basename(file).split('.')[0] }));
|
|
250
|
+
|
|
251
|
+
// One parse per file, shared by import tracing, copy extraction and the missed-copy scan.
|
|
252
|
+
const parsed = new Map();
|
|
253
|
+
const parse = async (file) => {
|
|
254
|
+
if (!parsed.has(file)) {
|
|
255
|
+
const source = await readFile(file, 'utf8');
|
|
256
|
+
let ast = null; let error = null;
|
|
257
|
+
if (formatFor(file) === 'copy') { try { ast = parseSource(source, file); } catch (caught) { error = caught.message; } }
|
|
258
|
+
parsed.set(file, { source, ast, error });
|
|
259
|
+
}
|
|
260
|
+
return parsed.get(file);
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
const reach = new Map(); // file → Set of entry indexes
|
|
264
|
+
const unresolvedTemplates = [];
|
|
265
|
+
for (const [index, entry] of entries.entries()) {
|
|
266
|
+
const queue = [entry.file];
|
|
267
|
+
const seen = new Set();
|
|
268
|
+
while (queue.length) {
|
|
269
|
+
const file = queue.pop();
|
|
270
|
+
if (seen.has(file)) continue;
|
|
271
|
+
seen.add(file);
|
|
272
|
+
if (!reach.has(file)) reach.set(file, new Set());
|
|
273
|
+
reach.get(file).add(index);
|
|
274
|
+
const { ast } = await parse(file);
|
|
275
|
+
if (!ast) continue;
|
|
276
|
+
for (const found of importsOf(ast)) {
|
|
277
|
+
if (found.specifier) {
|
|
278
|
+
const target = await resolveImport(file, found.specifier, aliases);
|
|
279
|
+
if (target && target.startsWith(root + path.sep) && formatFor(target) && !seen.has(target)) queue.push(target);
|
|
280
|
+
} else {
|
|
281
|
+
const targets = await resolveTemplate(file, found.template, aliases);
|
|
282
|
+
if (!targets.length) unresolvedTemplates.push({ file: path.relative(root, file), template: found.template.join('${…}') });
|
|
283
|
+
for (const target of targets) if (!seen.has(target)) queue.push(target);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const relative = (file) => path.relative(root, file).split(path.sep).join('/');
|
|
290
|
+
const files = [];
|
|
291
|
+
const unreachable = [];
|
|
292
|
+
const missed = new Map();
|
|
293
|
+
const configs = new Map();
|
|
294
|
+
const configFor = async (file) => {
|
|
295
|
+
const loaded = await loadConfig(file, configPath);
|
|
296
|
+
configs.set(loaded.dir, true);
|
|
297
|
+
return loaded;
|
|
298
|
+
};
|
|
299
|
+
for (const file of [...new Set([...reach.keys(), ...all])].sort()) {
|
|
300
|
+
const { source, ast, error } = await parse(file);
|
|
301
|
+
const format = formatFor(file);
|
|
302
|
+
const loaded = await configFor(file);
|
|
303
|
+
const entryIndexes = reach.get(file);
|
|
304
|
+
const record = { path: relative(file), format: format === 'copy' ? 'copy' : format, routes: entryIndexes ? [...entryIndexes].map((index) => entries[index].route).filter((route, i, list) => list.indexOf(route) === i).sort() : [] };
|
|
305
|
+
if (error) { files.push({ ...record, error, words: { body: 0, short: 0, protected: 0 }, editableWords: 0, findings: 0, revise: 0, banned: 0, protected: 0, areas: [], rules: {} }); continue; }
|
|
306
|
+
if (!included(file, loaded)) { if (entryIndexes) files.push({ ...record, excluded: true, words: { body: 0, short: 0, protected: 0 }, editableWords: 0, findings: 0, revise: 0, banned: 0, protected: 0, areas: [], rules: {} }); continue; }
|
|
307
|
+
const result = lintSource(source, { file, format, config: loaded.config, ...(ast ? { ast } : {}) });
|
|
308
|
+
const words = result.copy?.words ?? { body: result.metrics.wordCount, short: 0, protected: 0 };
|
|
309
|
+
const total = words.body + words.short + words.protected;
|
|
310
|
+
if (!entryIndexes) {
|
|
311
|
+
if (total && !(file.startsWith(app + path.sep) && handlerFile.test(path.basename(file)))) unreachable.push({ path: relative(file), words: total });
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
const reviewed = review(source, { file, format, config: loaded.config, maxExcerpts, linted: result });
|
|
315
|
+
if (ast) {
|
|
316
|
+
for (const miss of missedCopy(source, ast, result.copy?.strings ?? [])) {
|
|
317
|
+
const entry = missed.get(miss.context) ?? { context: miss.context, words: 0, files: new Set() };
|
|
318
|
+
entry.words += miss.words; entry.files.add(relative(file));
|
|
319
|
+
missed.set(miss.context, entry);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
const areas = [...new Set([...entryIndexes].map((index) => areaOf(entries[index])))].sort();
|
|
323
|
+
files.push({ ...record, areas, words, editableWords: words.body + words.short, ...tally(result, reviewed), rules: byRule(result.findings.filter((finding) => finding.role !== 'protected')), brief: reviewed.brief.text || null });
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const shipped = files.filter((file) => !file.excluded && !file.error);
|
|
327
|
+
const fileByPath = new Map(shipped.map((file) => [file.path, file]));
|
|
328
|
+
const routes = entries.map((entry, index) => {
|
|
329
|
+
const members = [...reach].filter(([, set]) => set.has(index)).map(([file]) => fileByPath.get(relative(file))).filter(Boolean);
|
|
330
|
+
const sum = (key) => members.reduce((total, file) => total + (typeof file[key] === 'number' ? file[key] : 0), 0);
|
|
331
|
+
return { route: entry.route, kind: entry.kind, file: relative(entry.file), files: members.length, words: members.reduce((total, file) => total + file.words.body + file.words.short + file.words.protected, 0), editableWords: sum('editableWords'), findings: sum('findings'), revise: sum('revise'), banned: sum('banned') };
|
|
332
|
+
}).sort((a, b) => a.route.localeCompare(b.route) || a.kind.localeCompare(b.kind));
|
|
333
|
+
|
|
334
|
+
const totals = { files: shipped.length, words: { body: 0, short: 0, protected: 0 }, findings: 0, revise: 0, banned: 0, protected: 0, rules: {} };
|
|
335
|
+
for (const file of shipped) {
|
|
336
|
+
for (const role of ['body', 'short', 'protected']) totals.words[role] += file.words[role];
|
|
337
|
+
for (const key of ['findings', 'revise', 'banned', 'protected']) totals[key] += file[key];
|
|
338
|
+
for (const [rule, count] of Object.entries(file.rules)) totals.rules[rule] = (totals.rules[rule] ?? 0) + count;
|
|
339
|
+
}
|
|
340
|
+
return {
|
|
341
|
+
root,
|
|
342
|
+
app: relative(app),
|
|
343
|
+
configs: [...configs.keys()].map((dir) => relative(dir) || '.'),
|
|
344
|
+
routes,
|
|
345
|
+
files: shipped.sort((a, b) => b.findings - a.findings || b.editableWords - a.editableWords || a.path.localeCompare(b.path)),
|
|
346
|
+
skipped: files.filter((file) => file.excluded || file.error).map(({ path: file, excluded, error }) => ({ path: file, ...(excluded ? { excluded } : { error }) })),
|
|
347
|
+
unreachable: unreachable.sort((a, b) => b.words - a.words || a.path.localeCompare(b.path)),
|
|
348
|
+
unresolvedImports: unresolvedTemplates,
|
|
349
|
+
missedCopy: [...missed.values()].map((entry) => ({ context: entry.context, words: entry.words, files: entry.files.size })).sort((a, b) => b.words - a.words).slice(0, 20),
|
|
350
|
+
totals,
|
|
351
|
+
plan: workPlan(shipped, { groupWords }),
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const number = (value) => value.toLocaleString('en-US');
|
|
356
|
+
const count = (value, singular, plural = `${singular}s`) => `${number(value)} ${value === 1 ? singular : plural}`;
|
|
357
|
+
|
|
358
|
+
/** The plan as Markdown: summary, routes, the work groups, unreachable copy. */
|
|
359
|
+
export function renderPlan(site, { top = 25 } = {}) {
|
|
360
|
+
const { totals } = site;
|
|
361
|
+
const words = totals.words.body + totals.words.short + totals.words.protected;
|
|
362
|
+
const pages = site.routes.filter((route) => route.kind === 'page');
|
|
363
|
+
const lines = [
|
|
364
|
+
`# Roughen site plan: ${path.basename(site.root)}`,
|
|
365
|
+
'',
|
|
366
|
+
`${count(pages.length, 'page')} and ${count(site.routes.length - pages.length, 'other route file')} ship ${count(totals.files, 'file')} with copy: ${count(words, 'word')} (${number(totals.words.body)} body, ${number(totals.words.short)} short, ${number(totals.words.protected)} protected).`,
|
|
367
|
+
`Findings an editor can act on: ${number(totals.findings)}${totals.banned ? `, ${number(totals.banned)} of them banned characters or patterns` : ''}. The briefs ask for at least ${count(totals.revise, 'rewrite')}.${totals.protected ? ` ${count(totals.protected, 'more finding', 'more findings')} sit in protected strings.` : ''}`,
|
|
368
|
+
`${count(site.unreachable.length, 'file')} with copy ${site.unreachable.length === 1 ? "isn't" : "aren't"} reachable from any route${site.unreachable.length ? ` (${count(site.unreachable.reduce((sum, file) => sum + file.words, 0), 'word')})` : ''}.`,
|
|
369
|
+
'',
|
|
370
|
+
'## Findings by rule',
|
|
371
|
+
'',
|
|
372
|
+
...Object.entries(totals.rules).sort((a, b) => b[1] - a[1]).map(([rule, count]) => `- ${rule}: ${number(count)}`),
|
|
373
|
+
'',
|
|
374
|
+
'## Routes',
|
|
375
|
+
'',
|
|
376
|
+
'| Route | File | Files | Words | Findings | Rewrites |',
|
|
377
|
+
'|---|---|---|---|---|---|',
|
|
378
|
+
...site.routes.map((route) => `| ${route.route}${route.kind === 'page' ? '' : ` (${route.kind})`} | ${route.file} | ${route.files} | ${number(route.words)} | ${number(route.findings)} | ${number(route.revise)} |`),
|
|
379
|
+
'',
|
|
380
|
+
`## Work plan: ${site.plan.length} groups, no file in two`,
|
|
381
|
+
'',
|
|
382
|
+
...site.plan.flatMap((group, index) => [
|
|
383
|
+
`${index + 1}. **${group.id}**: ${count(group.files.length, 'file')}, ${count(group.words, 'editable word')}, ${count(group.findings, 'finding')}, ${count(group.revise, 'rewrite')}`,
|
|
384
|
+
...group.files.map((file) => {
|
|
385
|
+
const record = site.files.find((item) => item.path === file);
|
|
386
|
+
return ` - ${file} (${count(record.editableWords, 'word')}, ${count(record.findings, 'finding')})`;
|
|
387
|
+
}),
|
|
388
|
+
]),
|
|
389
|
+
'',
|
|
390
|
+
`## Top files by findings`,
|
|
391
|
+
'',
|
|
392
|
+
...site.files.filter((file) => file.findings).slice(0, top).map((file) => `- ${file.path}: ${count(file.findings, 'finding')}, ${count(file.editableWords, 'editable word')}, shipped by ${count(file.routes.length, 'route')}`),
|
|
393
|
+
];
|
|
394
|
+
if (site.unreachable.length) lines.push('', '## Copy no route ships', '', ...site.unreachable.slice(0, top).map((file) => `- ${file.path} (${count(file.words, 'word')})`), ...(site.unreachable.length > top ? [`- …and ${site.unreachable.length - top} more`] : []));
|
|
395
|
+
if (site.missedCopy.length) lines.push('', '## Prose Roughen didn\'t read as copy', '', 'Strings that read as prose in places the copy reader skips. If one is copy, add its key to `copy.body` (or `copy.short`) in roughen.config.', '', ...site.missedCopy.slice(0, 10).map((entry) => `- ${entry.context}: ${count(entry.words, 'word')} in ${count(entry.files, 'file')}`));
|
|
396
|
+
if (site.unresolvedImports.length) lines.push('', '## Dynamic imports Roughen couldn\'t follow', '', ...site.unresolvedImports.map((item) => `- ${item.file}: import(\`${item.template}\`)`));
|
|
397
|
+
if (site.skipped.length) lines.push('', '## Skipped', '', ...site.skipped.map((item) => `- ${item.path}: ${item.excluded ? 'excluded by roughen.config' : `doesn't parse (${item.error})`}`));
|
|
398
|
+
return `${lines.join('\n')}\n`;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/** One brief per work group, for handing a group to an editor. */
|
|
402
|
+
export function renderGroupBrief(site, group) {
|
|
403
|
+
const lines = [`# ${group.id}: ${count(group.files.length, 'file')}, ${count(group.words, 'editable word')}`, '', 'Edit only these files. Other editors own the other groups.', ''];
|
|
404
|
+
for (const file of group.files) {
|
|
405
|
+
const record = site.files.find((item) => item.path === file);
|
|
406
|
+
lines.push(`## ${file}`, '', record.brief ? record.brief : 'Nothing flagged. Read it once for habits Roughen doesn\'t measure, and leave it alone if it reads well.', '');
|
|
407
|
+
}
|
|
408
|
+
return `${lines.join('\n')}\n`;
|
|
409
|
+
}
|