@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/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Upforge
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# @roughen/cli
|
|
2
|
+
|
|
3
|
+
Lint prose files, page copy or stdin locally with Roughen, plan a whole site's copy pass, and verify the rewrite. No uploads, model calls, or telemetry.
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
roughen ./content --json
|
|
7
|
+
roughen ./content --fix
|
|
8
|
+
roughen ./src --copy --brief
|
|
9
|
+
roughen --stdin --format md --brief
|
|
10
|
+
roughen site . --out ../copy-pass
|
|
11
|
+
roughen verify --git
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Safe fixes are the default; `--fix --careful` also applies explicitly careful edits. Manual findings never auto-apply. `--brief` prints a revision brief instead of individual findings: what to rewrite, why, and what must not change. Hand it to a writer or a model.
|
|
15
|
+
|
|
16
|
+
## Page copy
|
|
17
|
+
|
|
18
|
+
Named `.tsx`, `.jsx`, `.ts` and `.js` files are read as page copy. That means JSX text, copy attributes such as `alt`, and copy-named properties such as `description`, `answer`, `detail` and `rows`. It also covers FAQ `{ q, a }` pairs, conditional JSX branches, string concatenations, arrays mapped into JSX, and any property whose value reads as prose. In directories, source files are included only with `--copy`. Fixes are never written to source files. Parsing uses `@babel/parser`, the CLI's only dependency beyond Roughen.
|
|
19
|
+
|
|
20
|
+
Every copy string has a role, listed with its line and key under `copy.strings` in `--json`:
|
|
21
|
+
|
|
22
|
+
- **body**: paragraphs and descriptions. Every rule applies.
|
|
23
|
+
- **short**: headings, labels, buttons, alt text. Too short for density rules, but still editable.
|
|
24
|
+
- **protected**: leave exactly as written. H1s, `title`/`metaTitle`/`headline`, FAQ questions, `keywords`, schema names, and verbatim words: quotes, testimonials, reviews, citations, and data modules named for them.
|
|
25
|
+
|
|
26
|
+
Each finding carries the role of the string it sits in. The brief covers only what an editor may rewrite, and names any findings inside protected strings for the owner.
|
|
27
|
+
|
|
28
|
+
## Config
|
|
29
|
+
|
|
30
|
+
Config resolves upward from each file via roughen.config.js, .mjs, or .cjs:
|
|
31
|
+
|
|
32
|
+
```js
|
|
33
|
+
export default {
|
|
34
|
+
extends: ['marketing'], // register: 'marketing' — list saturation against human marketing pages
|
|
35
|
+
voice: {
|
|
36
|
+
bannedCharacters: ['—'], // every occurrence is an error, however rare
|
|
37
|
+
bannedPatterns: [{ pattern: '\\bgenuinely\\b', flags: 'i' }],
|
|
38
|
+
banned: [{ term: 'utilize', use: 'use' }],
|
|
39
|
+
required: ['Upforge'],
|
|
40
|
+
},
|
|
41
|
+
copy: {
|
|
42
|
+
body: ['soWhat'], // project keys that hold copy
|
|
43
|
+
protected: ['serviceLabel'], // project keys to leave alone
|
|
44
|
+
ignore: ['internalNote'],
|
|
45
|
+
protectedFiles: ['lib/data/citations.js'],
|
|
46
|
+
},
|
|
47
|
+
exclude: ['docs/**'],
|
|
48
|
+
};
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## `roughen site <dir>`
|
|
52
|
+
|
|
53
|
+
Use this on a Next.js app-router project. It follows static, dynamic and template-literal imports from every route file (page, layout, template, not-found, error, loading), resolving tsconfig/jsconfig `paths`, and reports:
|
|
54
|
+
|
|
55
|
+
- copy words and findings per route and per file;
|
|
56
|
+
- copy no route ships;
|
|
57
|
+
- prose in places the reader skips, such as call arguments and unknown variables, so you can add a key to `copy.body`;
|
|
58
|
+
- a work plan: groups of files by route area, around `--group-words` editable words each (default 6,000). Files more than one area ships get their own `shared` groups. No file appears in two groups, so editors can work in parallel.
|
|
59
|
+
|
|
60
|
+
`--json` includes each file's revision brief. `--out <dir>` writes `PLAN.md`, `plan.json` and `briefs/<group>.md` to a new directory outside the site. Nothing else is written.
|
|
61
|
+
|
|
62
|
+
## `roughen verify`
|
|
63
|
+
|
|
64
|
+
`roughen verify --git [<ref>] [<path> ...]` compares every changed file with its version at `<ref>`, which defaults to `HEAD`. `roughen verify --before a.md --after b.md` compares two files. For component copy it pairs strings through the AST with every string blanked, then fails on:
|
|
65
|
+
|
|
66
|
+
- a code change (reported at its line), an edited import path or non-copy string, or a file that no longer parses;
|
|
67
|
+
- an edited protected string;
|
|
68
|
+
- a number, link or quotation lost, or a number or link added;
|
|
69
|
+
- a new banned character;
|
|
70
|
+
- rewritten copy more than 15% longer or shorter (with ten words of slack), or flagged habits that grew.
|
|
71
|
+
|
|
72
|
+
It warns on strings outside ±15%, changed headings and new em dashes. Prose files get core's `judgeRevision` whole. New, deleted and untracked files are listed, not judged. Verify is read-only.
|
|
73
|
+
|
|
74
|
+
## Exit codes and output
|
|
75
|
+
|
|
76
|
+
Exit codes: 0 for no errors, 1 for lint or verify errors, 2 for invalid input/configuration. JSON output is `{ version, results }` for lint, the plan for `site`, and `{ version, files, errors, warnings, ok }` for `verify`. Findings after fixing point into the fixed text. Directory traversal skips symlinks and build/dependency folders. MIT.
|
package/bin/roughen.mjs
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFile, readdir, lstat, writeFile, chmod, rename, unlink, mkdir } from 'node:fs/promises';
|
|
3
|
+
import { createRequire } from 'node:module';
|
|
4
|
+
import { existsSync } from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { parseArgs } from 'node:util';
|
|
7
|
+
import { randomUUID } from 'node:crypto';
|
|
8
|
+
import { version } from '@roughen/core';
|
|
9
|
+
import { loadConfig, included } from '../lib/config.mjs';
|
|
10
|
+
import { formatFor, lintSource, proseFormats, sourceExtensions } from '../lib/lint-file.mjs';
|
|
11
|
+
import { review } from '../lib/review.mjs';
|
|
12
|
+
import { planSite, renderPlan, renderGroupBrief } from '../lib/site.mjs';
|
|
13
|
+
import { verifyGit, verifyFiles, renderVerify, isCommit } from '../lib/verify.mjs';
|
|
14
|
+
|
|
15
|
+
const cliVersion = createRequire(import.meta.url)('../package.json').version;
|
|
16
|
+
const help = `Roughen ${cliVersion} (core ${version}) — local prose linting, with explained findings.
|
|
17
|
+
|
|
18
|
+
Usage: roughen <file|directory ...> [--fix] [--careful] [--json] [--brief] [--copy]
|
|
19
|
+
roughen --stdin [--format text|md|mdx|html|copy] [--fix] [--brief]
|
|
20
|
+
roughen site <dir> [--json] [--out <dir>] [--group-words <n>]
|
|
21
|
+
roughen verify [--git [<ref>]] [<path> ...] [--json]
|
|
22
|
+
roughen verify --before <file> --after <file> [--json]
|
|
23
|
+
|
|
24
|
+
--fix Apply safe fixes; stdin writes the cleaned text to stdout.
|
|
25
|
+
--careful Also apply careful fixes (requires --fix). Never applies manual edits.
|
|
26
|
+
--brief Print a revision brief: what to rewrite, why, and what must not change.
|
|
27
|
+
--copy In directories, also read copy from .tsx/.jsx/.ts/.js source.
|
|
28
|
+
--config <path> Load a local roughen.config.js, .mjs, or .cjs file.
|
|
29
|
+
--json Machine-readable results, including residual findings after --fix.
|
|
30
|
+
--help Show this help.
|
|
31
|
+
|
|
32
|
+
Without --config, look upward from each file for roughen.config.{js,mjs,cjs}.
|
|
33
|
+
Directories include .txt, .md, .mdx, and .html (plus source with --copy);
|
|
34
|
+
symlinks are not followed. Named .tsx/.jsx/.ts/.js files are always read as copy:
|
|
35
|
+
JSX text, copy attributes (alt, title, …) and copy-named properties. Fixes are
|
|
36
|
+
never written to source files.
|
|
37
|
+
site: for a Next.js app-router project, follow imports from every route file
|
|
38
|
+
(tsconfig paths included) and report copy words and findings per route and
|
|
39
|
+
per file, copy no route ships, and a work plan: groups of files, no file in
|
|
40
|
+
two, with a revision brief for each. --out writes PLAN.md, plan.json and
|
|
41
|
+
one brief per group to a new directory; nothing else is written.
|
|
42
|
+
|
|
43
|
+
verify: compare copy before and after a rewrite, file by file. With --git
|
|
44
|
+
(the default) the before is <ref> (default HEAD) and the after is the working
|
|
45
|
+
tree. Fails on code changes, files that no longer parse, edited protected
|
|
46
|
+
strings (H1s, titles, FAQ questions, keywords, quotes), lost or added
|
|
47
|
+
numbers and links, new banned characters, and body length beyond 15%.
|
|
48
|
+
Read-only.
|
|
49
|
+
|
|
50
|
+
Exit status: 0 no errors, 1 lint errors, 2 invalid input/configuration.
|
|
51
|
+
No network calls, model calls, or telemetry. Calibration scores are unavailable.
|
|
52
|
+
`;
|
|
53
|
+
|
|
54
|
+
async function filesAt(input, options, discovered = false) {
|
|
55
|
+
const file = path.resolve(input);
|
|
56
|
+
const stat = await lstat(file);
|
|
57
|
+
if (stat.isSymbolicLink()) {
|
|
58
|
+
if (discovered) return [];
|
|
59
|
+
throw new Error(`Refusing to follow a symlink: ${file}`);
|
|
60
|
+
}
|
|
61
|
+
if (stat.isDirectory()) {
|
|
62
|
+
const files = [];
|
|
63
|
+
for (const name of (await readdir(file)).sort()) {
|
|
64
|
+
if (name.startsWith('.') || ['node_modules', 'dist', 'build', 'coverage', 'out'].includes(name)) continue;
|
|
65
|
+
files.push(...await filesAt(path.join(file, name), options, true));
|
|
66
|
+
}
|
|
67
|
+
return files;
|
|
68
|
+
}
|
|
69
|
+
if (!stat.isFile()) return [];
|
|
70
|
+
const extension = path.extname(file).toLowerCase();
|
|
71
|
+
if (discovered && !proseFormats[extension] && !(options.copy && sourceExtensions.has(extension) && formatFor(file))) return [];
|
|
72
|
+
return [file];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function replaceFile(file, original, replacement) {
|
|
76
|
+
if (original === replacement) return;
|
|
77
|
+
if (await readFile(file, 'utf8') !== original) throw new Error(`File changed during linting: ${file}`);
|
|
78
|
+
const temporary = path.join(path.dirname(file), `.roughen-${randomUUID()}.tmp`);
|
|
79
|
+
try {
|
|
80
|
+
const stat = await lstat(file);
|
|
81
|
+
if (!stat.isFile()) throw new Error(`Not a regular file: ${file}`);
|
|
82
|
+
await writeFile(temporary, replacement, { flag: 'wx', mode: stat.mode });
|
|
83
|
+
// The umask narrows the create mode; restore the original bits exactly.
|
|
84
|
+
await chmod(temporary, stat.mode & 0o7777);
|
|
85
|
+
await rename(temporary, file);
|
|
86
|
+
} finally {
|
|
87
|
+
await unlink(temporary).catch((error) => { if (error.code !== 'ENOENT') throw error; });
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function readStdin() {
|
|
92
|
+
process.stdin.setEncoding('utf8');
|
|
93
|
+
let source = '';
|
|
94
|
+
for await (const chunk of process.stdin) source += chunk;
|
|
95
|
+
return source;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function site(args) {
|
|
99
|
+
const { values, positionals } = parseArgs({ args, allowPositionals: true, options: {
|
|
100
|
+
json: { type: 'boolean' }, out: { type: 'string' }, 'group-words': { type: 'string' }, config: { type: 'string' },
|
|
101
|
+
} });
|
|
102
|
+
if (positionals.length !== 1) throw new Error('Usage: roughen site <dir>; see --help');
|
|
103
|
+
const groupWords = values['group-words'] ? Number(values['group-words']) : 6000;
|
|
104
|
+
if (!Number.isInteger(groupWords) || groupWords < 100) throw new Error('--group-words must be a whole number of at least 100');
|
|
105
|
+
const plan = await planSite(positionals[0], { configPath: values.config, groupWords });
|
|
106
|
+
if (values.out) {
|
|
107
|
+
const out = path.resolve(values.out);
|
|
108
|
+
let existing = null;
|
|
109
|
+
try { existing = await readdir(out); } catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
110
|
+
if (existing?.length) throw new Error(`${out} isn't empty; give --out a new directory`);
|
|
111
|
+
if (out === plan.root || out.startsWith(`${plan.root}${path.sep}`)) throw new Error('--out must be outside the site; Roughen never writes into the project it reads');
|
|
112
|
+
await mkdir(path.join(out, 'briefs'), { recursive: true });
|
|
113
|
+
await writeFile(path.join(out, 'PLAN.md'), renderPlan(plan));
|
|
114
|
+
await writeFile(path.join(out, 'plan.json'), `${JSON.stringify({ version, ...plan }, null, 2)}\n`);
|
|
115
|
+
for (const group of plan.plan) await writeFile(path.join(out, 'briefs', `${group.id}.md`), renderGroupBrief(plan, group));
|
|
116
|
+
}
|
|
117
|
+
if (values.json) process.stdout.write(`${JSON.stringify({ version, ...plan }, null, 2)}\n`);
|
|
118
|
+
else process.stdout.write(renderPlan(plan));
|
|
119
|
+
if (values.out && !values.json) process.stdout.write(`\nWrote PLAN.md, plan.json and ${plan.plan.length} group briefs to ${path.resolve(values.out)}.\n`);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function main() {
|
|
123
|
+
const [command, ...rest] = process.argv.slice(2);
|
|
124
|
+
if (command === 'site') return site(rest);
|
|
125
|
+
if (command === 'verify') return verifyCommand(rest);
|
|
126
|
+
const { values, positionals } = parseArgs({ allowPositionals: true, options: {
|
|
127
|
+
fix: { type: 'boolean' }, careful: { type: 'boolean' }, json: { type: 'boolean' }, brief: { type: 'boolean' }, copy: { type: 'boolean' },
|
|
128
|
+
stdin: { type: 'boolean' }, help: { type: 'boolean', short: 'h' }, version: { type: 'boolean' },
|
|
129
|
+
format: { type: 'string' }, config: { type: 'string' },
|
|
130
|
+
} });
|
|
131
|
+
if (values.help) { process.stdout.write(help); return; }
|
|
132
|
+
if (values.version) { process.stdout.write(`roughen ${cliVersion} (core ${version})\n`); return; }
|
|
133
|
+
if (values.careful && !values.fix) throw new Error('--careful requires --fix');
|
|
134
|
+
if (values.stdin && positionals.length) throw new Error('Use files or --stdin, not both');
|
|
135
|
+
if (!values.stdin && !positionals.length) throw new Error('Provide a file/directory or --stdin; see --help');
|
|
136
|
+
if (values.format && !['text', 'md', 'mdx', 'html', 'copy'].includes(values.format)) throw new Error(`Unsupported format: ${values.format}`);
|
|
137
|
+
const files = values.stdin ? [null] : [...new Set((await Promise.all(positionals.map((file) => filesAt(file, values)))).flat())].sort();
|
|
138
|
+
if (!files.length) throw new Error('No supported prose files found');
|
|
139
|
+
const reports = [];
|
|
140
|
+
for (const file of files) {
|
|
141
|
+
const loaded = await loadConfig(file, values.config);
|
|
142
|
+
if (file && !included(file, loaded)) continue;
|
|
143
|
+
const { config } = loaded;
|
|
144
|
+
const detected = file ? formatFor(file) : 'text';
|
|
145
|
+
// JS/TS is always read as copy; a config's format applies to prose files and stdin.
|
|
146
|
+
const format = values.format ?? (detected === 'copy' ? 'copy' : config.format ?? detected);
|
|
147
|
+
if (!format) throw new Error(`Unsupported file extension: ${file}; use --format for explicit prose input`);
|
|
148
|
+
const source = file ? await readFile(file, 'utf8') : await readStdin();
|
|
149
|
+
const result = lintSource(source, { file: file ?? 'stdin.tsx', format, config, fix: values.fix && format !== 'copy', careful: values.careful });
|
|
150
|
+
if (file && values.fix && result.fixed !== undefined) await replaceFile(file, source, result.fixed);
|
|
151
|
+
// The brief describes the text as it now stands, after any fixes.
|
|
152
|
+
const reviewed = values.brief ? review(result.fixed ?? source, { file: file ?? 'stdin.tsx', format, config }) : null;
|
|
153
|
+
reports.push({ file: file ?? '<stdin>', ...result, ...(reviewed ? { brief: reviewed.brief, mechanical: reviewed.mechanical.length } : {}) });
|
|
154
|
+
}
|
|
155
|
+
if (!reports.length) throw new Error('no files matched include/exclude');
|
|
156
|
+
if (values.json) process.stdout.write(JSON.stringify({ version, results: reports }, null, 2) + '\n');
|
|
157
|
+
else {
|
|
158
|
+
if (values.stdin && values.fix) process.stdout.write(reports[0]?.fixed ?? '');
|
|
159
|
+
const stream = values.stdin && values.fix ? process.stderr : process.stdout;
|
|
160
|
+
for (const report of reports) {
|
|
161
|
+
if (values.brief) {
|
|
162
|
+
stream.write(report.brief.text ? `${report.file}\n${report.brief.text}\n` : `${report.file}: nothing to revise.\n`);
|
|
163
|
+
if (report.mechanical) stream.write(`${report.file}: ${report.mechanical} mechanical fix(es) available with --fix.\n`);
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
// Each rule's explanation prints once per file; density rules can flag dozens of passages.
|
|
167
|
+
const explained = new Set();
|
|
168
|
+
for (const finding of report.findings) {
|
|
169
|
+
stream.write(`${report.file}:${finding.loc.line}:${finding.loc.column} ${finding.severity} ${finding.ruleId}: ${finding.message}\n`);
|
|
170
|
+
if (!explained.has(finding.ruleId)) { stream.write(` ${finding.why}\n`); explained.add(finding.ruleId); }
|
|
171
|
+
}
|
|
172
|
+
stream.write(`${report.file}: ${report.findings.length} finding(s)${report.fixes ? `, ${report.fixes.applied} fix(es) applied` : ''}\n`);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
process.exitCode = reports.some((report) => report.findings.some((finding) => finding.severity === 'error')) ? 1 : 0;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function verifyCommand(args) {
|
|
179
|
+
// --git takes an optional ref: `--git`, `--git HEAD~2`, `--git=main`. A following path stays a path.
|
|
180
|
+
let ref = 'HEAD';
|
|
181
|
+
const rest = [];
|
|
182
|
+
for (let i = 0; i < args.length; i++) {
|
|
183
|
+
const arg = args[i];
|
|
184
|
+
if (arg.startsWith('--git=')) { ref = arg.slice(6); continue; }
|
|
185
|
+
if (arg === '--git') {
|
|
186
|
+
const next = args[i + 1];
|
|
187
|
+
if (next && !next.startsWith('-') && !existsSync(next) && isCommit(process.cwd(), next)) { ref = next; i++; }
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
rest.push(arg);
|
|
191
|
+
}
|
|
192
|
+
const { values, positionals } = parseArgs({ args: rest, allowPositionals: true, options: {
|
|
193
|
+
json: { type: 'boolean' }, before: { type: 'string' }, after: { type: 'string' }, config: { type: 'string' }, ref: { type: 'string' },
|
|
194
|
+
} });
|
|
195
|
+
if (values.ref) ref = values.ref;
|
|
196
|
+
if (!!values.before !== !!values.after) throw new Error('--before and --after go together');
|
|
197
|
+
const result = values.before
|
|
198
|
+
? await verifyFiles({ before: values.before, after: values.after, configPath: values.config })
|
|
199
|
+
: await verifyGit({ paths: positionals.length ? positionals : ['.'], ref, configPath: values.config });
|
|
200
|
+
process.stdout.write(values.json ? `${JSON.stringify({ version, ...result }, null, 2)}\n` : renderVerify(result));
|
|
201
|
+
process.exitCode = result.ok ? 0 : 1;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
main().catch((error) => { process.stderr.write(`roughen: ${error.message}\n`); process.exitCode = 2; });
|
package/lib/config.mjs
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { lstat } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { pathToFileURL } from 'node:url';
|
|
4
|
+
import * as presets from '@roughen/config';
|
|
5
|
+
import { protectedByName } from './jsx.mjs';
|
|
6
|
+
|
|
7
|
+
/** Single source of truth for roughen.config resolution, shared by the CLI and the Claude plugin. */
|
|
8
|
+
export const configNames = ['roughen.config.js', 'roughen.config.mjs', 'roughen.config.cjs'];
|
|
9
|
+
|
|
10
|
+
function merge(base, extra) {
|
|
11
|
+
return { ...base, ...extra, rules: { ...base.rules, ...extra.rules }, voice: { ...base.voice, ...extra.voice } };
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
async function exists(file) {
|
|
15
|
+
try { await lstat(file); return true; } catch (error) { if (error.code === 'ENOENT') return false; throw error; }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const cache = new Map();
|
|
19
|
+
|
|
20
|
+
/** The nearest roughen.config above `file` (or `explicit`), merged over its presets. */
|
|
21
|
+
export async function loadConfig(file, explicit) {
|
|
22
|
+
let configFile = explicit ? path.resolve(explicit) : null;
|
|
23
|
+
if (!configFile) {
|
|
24
|
+
let dir = file ? path.dirname(path.resolve(file)) : process.cwd();
|
|
25
|
+
while (true) {
|
|
26
|
+
for (const name of configNames) {
|
|
27
|
+
if (await exists(path.join(dir, name))) { configFile = path.join(dir, name); break; }
|
|
28
|
+
}
|
|
29
|
+
if (configFile || path.dirname(dir) === dir) break;
|
|
30
|
+
dir = path.dirname(dir);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
if (!configFile) return { config: {}, dir: process.cwd() };
|
|
34
|
+
if (cache.has(configFile)) return cache.get(configFile);
|
|
35
|
+
const raw = (await import(pathToFileURL(configFile).href)).default;
|
|
36
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw new Error(`${configFile} must export a config object`);
|
|
37
|
+
const supported = new Set(['extends', 'format', 'register', 'include', 'exclude', 'rules', 'voice', 'copy']);
|
|
38
|
+
for (const key of Object.keys(raw)) if (!supported.has(key)) throw new Error(`Unsupported config option: ${key}`);
|
|
39
|
+
let config = {};
|
|
40
|
+
const extensions = raw.extends ?? [];
|
|
41
|
+
if (!Array.isArray(extensions)) throw new Error('Config extends must be an array');
|
|
42
|
+
for (const name of extensions) {
|
|
43
|
+
const key = String(name).replace(/^@roughen\/config\//, '');
|
|
44
|
+
if (!Object.hasOwn(presets, key)) throw new Error(`Unknown preset: ${name}`);
|
|
45
|
+
config = merge(config, presets[key]);
|
|
46
|
+
}
|
|
47
|
+
for (const key of ['include', 'exclude']) if (raw[key] && (!Array.isArray(raw[key]) || !raw[key].every((value) => typeof value === 'string'))) throw new Error(`${key} must be an array of glob strings`);
|
|
48
|
+
validateCopy(raw.copy);
|
|
49
|
+
const merged = merge(config, raw);
|
|
50
|
+
// Protected-file globs resolve against the config's directory, like include and exclude.
|
|
51
|
+
if (raw.copy) merged.copy = { ...raw.copy, base: path.dirname(configFile) };
|
|
52
|
+
const loaded = { config: merged, dir: path.dirname(configFile) };
|
|
53
|
+
cache.set(configFile, loaded);
|
|
54
|
+
return loaded;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const copyRoles = ['body', 'short', 'protected', 'ignore', 'protectedFiles'];
|
|
58
|
+
|
|
59
|
+
/** `copy`: keys a project adds to a role ({ body, short, protected, ignore }) and files whose copy is all protected. */
|
|
60
|
+
function validateCopy(copy) {
|
|
61
|
+
if (copy === undefined) return;
|
|
62
|
+
if (!copy || typeof copy !== 'object' || Array.isArray(copy)) throw new Error('copy must be an object');
|
|
63
|
+
for (const [key, value] of Object.entries(copy)) {
|
|
64
|
+
if (!copyRoles.includes(key)) throw new Error(`Unsupported copy option: ${key}; use ${copyRoles.join(', ')}`);
|
|
65
|
+
if (!Array.isArray(value) || !value.every((item) => typeof item === 'string' && item)) throw new Error(`copy.${key} must be an array of strings`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Extraction options for a file: the project's copy keys, and whether the
|
|
71
|
+
* file's copy is all protected (a copy.protectedFiles glob, or a data module
|
|
72
|
+
* named for citations, testimonials, reviews or quotes).
|
|
73
|
+
*/
|
|
74
|
+
export function copyOptions(file, config) {
|
|
75
|
+
const copy = config?.copy;
|
|
76
|
+
const relative = file && copy?.base ? path.relative(copy.base, path.resolve(file)).split(path.sep).join('/') : null;
|
|
77
|
+
const listed = !!relative && !!copy.protectedFiles?.some((glob) => path.matchesGlob(relative, glob));
|
|
78
|
+
return { ...(copy ? { keys: copy } : {}), protectedFile: listed || protectedByName(file) };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Whether a config's include/exclude globs admit `file`, relative to the config's directory. */
|
|
82
|
+
export function included(file, { config, dir }) {
|
|
83
|
+
const relative = path.relative(dir, file).split(path.sep).join('/');
|
|
84
|
+
if (config.exclude?.some((glob) => path.matchesGlob(relative, glob))) return false;
|
|
85
|
+
return !config.include?.length || config.include.some((glob) => path.matchesGlob(relative, glob));
|
|
86
|
+
}
|