@rtorcato/repo-tooling 4.0.0 → 4.2.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/dist/base/fixers.js +7 -2
- package/dist/cli/commands/setup-presets.js +1 -0
- package/dist/cli/generators/brand.js +26 -6
- package/dist/cli/generators/docs-site.js +130 -159
- package/dist/cli/generators/pnpm-workspace.js +26 -0
- package/dist/cli/index.js +6 -34
- package/dist/cli/self-repo.js +47 -0
- package/dist/cli/utils/lockfile.js +11 -0
- package/dist/languages/js/ci.js +9 -2
- package/dist/languages/js/fixers.js +34 -3
- package/package.json +1 -1
package/dist/base/fixers.js
CHANGED
|
@@ -75,6 +75,7 @@ export const BASE_FIXERS = [
|
|
|
75
75
|
},
|
|
76
76
|
{
|
|
77
77
|
target: 'editorconfig',
|
|
78
|
+
selfSafe: true,
|
|
78
79
|
description: 'Scaffold .editorconfig (UTF-8, LF, tab indent)',
|
|
79
80
|
appliesTo: ['EditorConfig'],
|
|
80
81
|
outputs: ['.editorconfig'],
|
|
@@ -145,6 +146,7 @@ export const BASE_FIXERS = [
|
|
|
145
146
|
},
|
|
146
147
|
{
|
|
147
148
|
target: 'codeql',
|
|
149
|
+
selfSafe: true,
|
|
148
150
|
description: 'Scaffold .github/workflows/codeql.yml (security scanning)',
|
|
149
151
|
appliesTo: ['CodeQL'],
|
|
150
152
|
outputs: ['.github/workflows/codeql.yml'],
|
|
@@ -214,6 +216,7 @@ export const BASE_FIXERS = [
|
|
|
214
216
|
},
|
|
215
217
|
{
|
|
216
218
|
target: 'codeowners',
|
|
219
|
+
selfSafe: true,
|
|
217
220
|
description: 'Scaffold .github/CODEOWNERS with commented examples',
|
|
218
221
|
appliesTo: ['CODEOWNERS'],
|
|
219
222
|
outputs: ['.github/CODEOWNERS'],
|
|
@@ -226,6 +229,7 @@ export const BASE_FIXERS = [
|
|
|
226
229
|
},
|
|
227
230
|
{
|
|
228
231
|
target: 'community-health',
|
|
232
|
+
selfSafe: true,
|
|
229
233
|
description: 'Scaffold CONTRIBUTING.md, SECURITY.md, PR + issue templates',
|
|
230
234
|
appliesTo: ['Community health'],
|
|
231
235
|
outputs: [
|
|
@@ -244,6 +248,7 @@ export const BASE_FIXERS = [
|
|
|
244
248
|
},
|
|
245
249
|
{
|
|
246
250
|
target: 'brand',
|
|
251
|
+
selfSafe: true,
|
|
247
252
|
description: 'Scaffold brand/ — banner, mobile-banner and social-card SVG sources + render.sh, and repoint a README still on root-level banner paths',
|
|
248
253
|
appliesTo: ['Brand assets'],
|
|
249
254
|
outputs: [
|
|
@@ -257,8 +262,8 @@ export const BASE_FIXERS = [
|
|
|
257
262
|
// image paths — hand-edited art is never clobbered.
|
|
258
263
|
riskLevel: 'safe-merge',
|
|
259
264
|
canFixDrift: true,
|
|
260
|
-
async run({ targetDir, pkg }) {
|
|
261
|
-
const filesWritten = await generateBrand(pkg, targetDir);
|
|
265
|
+
async run({ targetDir, pkg, lock }) {
|
|
266
|
+
const filesWritten = await generateBrand(pkg, targetDir, lock?.rules?.brand?.tagline);
|
|
262
267
|
if (filesWritten.some((f) => f.endsWith('.svg'))) {
|
|
263
268
|
console.error(chalk.dim(' next: run `brand/render.sh` to render the PNGs (needs librsvg — `brew install librsvg`)'));
|
|
264
269
|
}
|
|
@@ -6,8 +6,9 @@
|
|
|
6
6
|
* PNG, so a banner can be recoloured, retitled or resized instead of being a
|
|
7
7
|
* committed binary nobody can regenerate.
|
|
8
8
|
*
|
|
9
|
-
* Everything in the emitted SVGs is derived from the consuming repo — name
|
|
10
|
-
*
|
|
9
|
+
* Everything in the emitted SVGs is derived from the consuming repo — name from
|
|
10
|
+
* its package.json, tagline from `rules.brand.tagline` in .repo-tooling.json or
|
|
11
|
+
* else the package.json description (#666), accent from its own docs theme or favicon —
|
|
11
12
|
* and falls back to a neutral grey. Nothing about any particular org is baked
|
|
12
13
|
* in; the templates are meant to be hand-edited afterwards.
|
|
13
14
|
*/
|
|
@@ -94,14 +95,29 @@ async function accentFromFavicon(targetDir) {
|
|
|
94
95
|
}
|
|
95
96
|
return null;
|
|
96
97
|
}
|
|
97
|
-
|
|
98
|
+
/**
|
|
99
|
+
* The narrowest tagline budget any canvas uses (the mobile banner). A tagline
|
|
100
|
+
* that needs more than two lines of it crowds the layout and gets cut off.
|
|
101
|
+
*/
|
|
102
|
+
const TAGLINE_MAX_CHARS = 42;
|
|
103
|
+
/** True when `tagline` fits in two lines on every canvas, without an ellipsis. */
|
|
104
|
+
export function taglineFits(tagline) {
|
|
105
|
+
const words = tagline.split(/\s+/).filter(Boolean).join(' ');
|
|
106
|
+
return wrapText(tagline, TAGLINE_MAX_CHARS, 2).join(' ') === words;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* `tagline` is `rules.brand.tagline` from .repo-tooling.json: a short line
|
|
110
|
+
* written for the banner. Without it the package.json description stands in,
|
|
111
|
+
* which is often a full sentence too long for the canvas (#666).
|
|
112
|
+
*/
|
|
113
|
+
export async function resolveBrandMeta(pkg, targetDir, tagline) {
|
|
98
114
|
const pkgName = typeof pkg?.name === 'string' ? pkg.name : undefined;
|
|
99
115
|
const name = pkgName?.split('/').pop() ?? path.basename(path.resolve(targetDir));
|
|
100
116
|
const description = typeof pkg?.description === 'string' ? pkg.description : '';
|
|
101
117
|
const accent = (await accentFromDocsTheme(targetDir)) ?? (await accentFromFavicon(targetDir)) ?? NEUTRAL_ACCENT;
|
|
102
118
|
return {
|
|
103
119
|
name,
|
|
104
|
-
tagline: description || 'Add a
|
|
120
|
+
tagline: tagline || description || 'Add a short tagline as rules.brand.tagline in .repo-tooling.json.',
|
|
105
121
|
accent,
|
|
106
122
|
install: pkgName && pkg?.private !== true ? pkgName : null,
|
|
107
123
|
};
|
|
@@ -268,8 +284,8 @@ export async function repointReadmeBanners(targetDir) {
|
|
|
268
284
|
* README still on the old root-level paths. Every file is written only when
|
|
269
285
|
* absent, so `fix brand` is idempotent.
|
|
270
286
|
*/
|
|
271
|
-
export async function generateBrand(pkg, targetDir) {
|
|
272
|
-
const meta = await resolveBrandMeta(pkg, targetDir);
|
|
287
|
+
export async function generateBrand(pkg, targetDir, tagline) {
|
|
288
|
+
const meta = await resolveBrandMeta(pkg, targetDir, tagline);
|
|
273
289
|
const written = [];
|
|
274
290
|
const files = [
|
|
275
291
|
['brand/banner.svg', bannerSvg(meta)],
|
|
@@ -282,6 +298,10 @@ export async function generateBrand(pkg, targetDir) {
|
|
|
282
298
|
if (w)
|
|
283
299
|
written.push(w);
|
|
284
300
|
}
|
|
301
|
+
// stderr, not stdout: `fix --json` owns stdout (#357).
|
|
302
|
+
if (written.some((f) => f.endsWith('.svg')) && !taglineFits(meta.tagline)) {
|
|
303
|
+
console.error(' warning: the tagline needs more than two lines and will be cut off on the mobile banner — set a shorter one as rules.brand.tagline in .repo-tooling.json');
|
|
304
|
+
}
|
|
285
305
|
const readme = await repointReadmeBanners(targetDir);
|
|
286
306
|
if (readme)
|
|
287
307
|
written.push(readme);
|
|
@@ -3,6 +3,7 @@ import fs from 'fs-extra';
|
|
|
3
3
|
import selfPackageJson from '../../../package.json' with { type: 'json' };
|
|
4
4
|
import { copyPreset, PRESETS } from '../utils/copy-preset.js';
|
|
5
5
|
import { buildBadgeRow, parseRepository } from './badges.js';
|
|
6
|
+
import { DOCS_SITE_BUILDS, mergeAllowBuilds } from './pnpm-workspace.js';
|
|
6
7
|
import { inferSubpathsFromExports } from './treeshake.js';
|
|
7
8
|
/**
|
|
8
9
|
* What a scaffolded docs site should depend on for *this* CLI — read from the
|
|
@@ -64,29 +65,37 @@ async function writeIfMissing(targetDir, rel, contents) {
|
|
|
64
65
|
await fs.writeFile(file, contents);
|
|
65
66
|
return rel;
|
|
66
67
|
}
|
|
67
|
-
/**
|
|
68
|
+
/**
|
|
69
|
+
* Ensure `pnpm-workspace.yaml` lists `apps/*` and approves the site's build
|
|
70
|
+
* scripts (idempotent).
|
|
71
|
+
*/
|
|
68
72
|
async function ensureWorkspace(targetDir) {
|
|
69
73
|
const rel = 'pnpm-workspace.yaml';
|
|
70
74
|
const file = path.join(targetDir, rel);
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
await fs.writeFile(file, next);
|
|
79
|
-
return rel;
|
|
80
|
-
}
|
|
81
|
-
await fs.writeFile(file, `packages:\n - 'apps/*'\n\n${body}`);
|
|
82
|
-
return rel;
|
|
75
|
+
const body = (await fs.pathExists(file)) ? await fs.readFile(file, 'utf8') : '';
|
|
76
|
+
let next = body;
|
|
77
|
+
// Already a workspace covering apps/* (either `apps/*` or a broader glob).
|
|
78
|
+
if (!/^\s*-\s*['"]?apps\/\*/m.test(body)) {
|
|
79
|
+
next = /^packages:/m.test(body)
|
|
80
|
+
? body.replace(/^packages:\n/m, "packages:\n - 'apps/*'\n")
|
|
81
|
+
: `packages:\n - 'apps/*'\n${body ? `\n${body}` : ''}`;
|
|
83
82
|
}
|
|
84
|
-
|
|
83
|
+
next = mergeAllowBuilds(next, DOCS_SITE_BUILDS);
|
|
84
|
+
if (next === body)
|
|
85
|
+
return null;
|
|
86
|
+
await fs.writeFile(file, next);
|
|
85
87
|
return rel;
|
|
86
88
|
}
|
|
87
|
-
/**
|
|
88
|
-
|
|
89
|
-
|
|
89
|
+
/**
|
|
90
|
+
* A JS string literal in the Biome preset's quote style: single quotes, unless
|
|
91
|
+
* the value holds more single than double quotes — the same pick Biome makes.
|
|
92
|
+
*/
|
|
93
|
+
function jsString(value) {
|
|
94
|
+
const singles = value.split("'").length - 1;
|
|
95
|
+
const doubles = value.split('"').length - 1;
|
|
96
|
+
if (singles > doubles)
|
|
97
|
+
return JSON.stringify(value);
|
|
98
|
+
return `'${JSON.stringify(value).slice(1, -1).replaceAll('\\"', '"').replaceAll("'", "\\'")}'`;
|
|
90
99
|
}
|
|
91
100
|
function docusaurusConfig(meta, typedocModules) {
|
|
92
101
|
const owner = meta.owner ?? 'your-org';
|
|
@@ -98,111 +107,111 @@ function docusaurusConfig(meta, typedocModules) {
|
|
|
98
107
|
? "import { getTypedocPlugins } from '@rtorcato/repo-tooling/docusaurus'\n"
|
|
99
108
|
: '';
|
|
100
109
|
const typedocPlugins = typedocModules.length
|
|
101
|
-
?
|
|
110
|
+
? `\t\t...getTypedocPlugins([${typedocModules.map(jsString).join(', ')}]),\n`
|
|
102
111
|
: '';
|
|
103
112
|
return `import type * as Preset from '@docusaurus/preset-classic'
|
|
104
113
|
import type { Config } from '@docusaurus/types'
|
|
105
114
|
import { themes as prismThemes } from 'prism-react-renderer'
|
|
106
115
|
${typedocImport}
|
|
107
116
|
const config: Config = {
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
117
|
+
\ttitle: '${meta.title}',
|
|
118
|
+
\ttagline: ${jsString(meta.tagline)},
|
|
119
|
+
\tfavicon: 'img/favicon.ico',
|
|
111
120
|
|
|
112
|
-
|
|
113
|
-
|
|
121
|
+
\turl: 'https://${owner}.github.io',
|
|
122
|
+
\tbaseUrl: '/${repo}/',
|
|
114
123
|
|
|
115
|
-
|
|
116
|
-
|
|
124
|
+
\torganizationName: '${owner}',
|
|
125
|
+
\tprojectName: '${repo}',
|
|
117
126
|
|
|
118
|
-
|
|
127
|
+
\tonBrokenLinks: 'warn',
|
|
119
128
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
129
|
+
\tmarkdown: {
|
|
130
|
+
\t\tformat: 'detect',
|
|
131
|
+
\t\thooks: {
|
|
132
|
+
\t\t\tonBrokenMarkdownLinks: 'warn',
|
|
133
|
+
\t\t},
|
|
134
|
+
\t},
|
|
126
135
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
136
|
+
\ti18n: {
|
|
137
|
+
\t\tdefaultLocale: 'en',
|
|
138
|
+
\t\tlocales: ['en'],
|
|
139
|
+
\t},
|
|
131
140
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
141
|
+
\tpresets: [
|
|
142
|
+
\t\t[
|
|
143
|
+
\t\t\t'classic',
|
|
144
|
+
\t\t\t{
|
|
145
|
+
\t\t\t\tdocs: {
|
|
146
|
+
\t\t\t\t\tsidebarPath: './sidebars.ts',
|
|
147
|
+
\t\t\t\t\trouteBasePath: '/docs',
|
|
148
|
+
\t\t\t\t\teditUrl: '${ghUrl}/edit/main/apps/docs/',
|
|
149
|
+
\t\t\t\t},
|
|
150
|
+
\t\t\t\tblog: false,
|
|
151
|
+
\t\t\t\ttheme: {
|
|
152
|
+
\t\t\t\t\tcustomCss: './src/css/custom.css',
|
|
153
|
+
\t\t\t\t},
|
|
154
|
+
\t\t\t} satisfies Preset.Options,
|
|
155
|
+
\t\t],
|
|
156
|
+
\t],
|
|
148
157
|
|
|
149
|
-
|
|
150
|
-
${typedocPlugins}
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
158
|
+
\tplugins: [
|
|
159
|
+
${typedocPlugins}\t\t[
|
|
160
|
+
\t\t\t'@easyops-cn/docusaurus-search-local',
|
|
161
|
+
\t\t\t{
|
|
162
|
+
\t\t\t\thashed: true,
|
|
163
|
+
\t\t\t\tindexDocs: true,
|
|
164
|
+
\t\t\t\tindexBlog: false,
|
|
165
|
+
\t\t\t\tdocsRouteBasePath: '/docs',
|
|
166
|
+
\t\t\t\thighlightSearchTermsOnTargetPage: true,
|
|
167
|
+
\t\t\t\tsearchBarShortcutHint: false,
|
|
168
|
+
\t\t\t},
|
|
169
|
+
\t\t],
|
|
170
|
+
\t],
|
|
162
171
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
172
|
+
\tthemeConfig: {
|
|
173
|
+
\t\tcolorMode: {
|
|
174
|
+
\t\t\tdefaultMode: 'dark',
|
|
175
|
+
\t\t\trespectPrefersColorScheme: true,
|
|
176
|
+
\t\t},
|
|
177
|
+
\t\tnavbar: {
|
|
178
|
+
\t\t\ttitle: '${meta.title}',
|
|
179
|
+
\t\t\titems: [
|
|
180
|
+
\t\t\t\t{ to: '/docs', position: 'left', label: 'Docs' },
|
|
181
|
+
\t\t\t\t{
|
|
182
|
+
\t\t\t\t\thref: '${ghUrl}',
|
|
183
|
+
\t\t\t\t\tlabel: 'GitHub',
|
|
184
|
+
\t\t\t\t\tposition: 'right',
|
|
185
|
+
\t\t\t\t},
|
|
186
|
+
\t\t\t],
|
|
187
|
+
\t\t},
|
|
188
|
+
\t\tfooter: {
|
|
189
|
+
\t\t\tstyle: 'dark',
|
|
190
|
+
\t\t\tlinks: [
|
|
191
|
+
\t\t\t\t{
|
|
192
|
+
\t\t\t\t\ttitle: 'Docs',
|
|
193
|
+
\t\t\t\t\titems: [{ label: 'Getting Started', to: '/docs' }],
|
|
194
|
+
\t\t\t\t},
|
|
195
|
+
\t\t\t\t{
|
|
196
|
+
\t\t\t\t\ttitle: 'More',
|
|
197
|
+
\t\t\t\t\titems: [
|
|
198
|
+
\t\t\t\t\t\t{ label: 'GitHub', href: '${ghUrl}' },
|
|
199
|
+
\t\t\t\t\t\t{ label: 'Issues', href: '${ghUrl}/issues' },
|
|
200
|
+
\t\t\t\t\t],
|
|
201
|
+
\t\t\t\t},
|
|
202
|
+
\t\t\t],
|
|
203
|
+
\t\t\tcopyright: \`Copyright © \${new Date().getFullYear()} ${meta.title}. Built with Docusaurus.\`,
|
|
204
|
+
\t\t},
|
|
205
|
+
\t\t// \`theme\` is the LIGHT-mode Prism theme and \`darkTheme\` the dark one. Both
|
|
206
|
+
\t\t// were vsDark here, which is why the shared stylesheet had to pin fenced
|
|
207
|
+
\t\t// blocks dark in light mode too (#324). Keep this pairing and the CSS in
|
|
208
|
+
\t\t// step — vsDark tokens on a light surface are unreadable.
|
|
209
|
+
\t\tprism: {
|
|
210
|
+
\t\t\ttheme: prismThemes.vsLight,
|
|
211
|
+
\t\t\tdarkTheme: prismThemes.vsDark,
|
|
212
|
+
\t\t\tadditionalLanguages: ['bash', 'json', 'typescript'],
|
|
213
|
+
\t\t},
|
|
214
|
+
\t} satisfies Preset.ThemeConfig,
|
|
206
215
|
}
|
|
207
216
|
|
|
208
217
|
export default config
|
|
@@ -213,7 +222,7 @@ const SIDEBARS = `import type { SidebarsConfig } from '@docusaurus/plugin-conten
|
|
|
213
222
|
// Autogenerated from the docs/ folder structure — add markdown files and they
|
|
214
223
|
// appear here. Swap for an explicit list when you want to control ordering.
|
|
215
224
|
const sidebars: SidebarsConfig = {
|
|
216
|
-
|
|
225
|
+
\tdocs: [{ type: 'autogenerated', dirName: '.' }],
|
|
217
226
|
}
|
|
218
227
|
|
|
219
228
|
export default sidebars
|
|
@@ -244,13 +253,13 @@ function customCss(accent) {
|
|
|
244
253
|
@import "./_jt-tokens.css";
|
|
245
254
|
|
|
246
255
|
:root {
|
|
247
|
-
|
|
248
|
-
|
|
256
|
+
\t--ifm-color-primary: ${accent.light};
|
|
257
|
+
\t--jt-accent: ${accent.light};
|
|
249
258
|
}
|
|
250
259
|
|
|
251
260
|
[data-theme="dark"] {
|
|
252
|
-
|
|
253
|
-
|
|
261
|
+
\t--ifm-color-primary: ${accent.dark};
|
|
262
|
+
\t--jt-accent: ${accent.dark};
|
|
254
263
|
}
|
|
255
264
|
`;
|
|
256
265
|
}
|
|
@@ -277,9 +286,6 @@ function docsPackageJson(meta, typedoc) {
|
|
|
277
286
|
serve: 'docusaurus serve',
|
|
278
287
|
clear: 'docusaurus clear',
|
|
279
288
|
typecheck: 'tsc --noEmit',
|
|
280
|
-
// Opt-in smoke test — builds, serves, and checks the site renders. Heavy
|
|
281
|
-
// browser install, so it's a manual/CI-gated run, not part of `build`.
|
|
282
|
-
'test:e2e': 'playwright test',
|
|
283
289
|
},
|
|
284
290
|
dependencies: {
|
|
285
291
|
'@docusaurus/core': '^3.10.2',
|
|
@@ -295,7 +301,6 @@ function docsPackageJson(meta, typedoc) {
|
|
|
295
301
|
'@docusaurus/module-type-aliases': '^3.10.2',
|
|
296
302
|
'@docusaurus/tsconfig': '^3.8.1',
|
|
297
303
|
'@docusaurus/types': '^3.10.2',
|
|
298
|
-
'@playwright/test': '^1.49.0',
|
|
299
304
|
'@rtorcato/repo-tooling': SELF_RANGE,
|
|
300
305
|
'@types/react': '^19.0.0',
|
|
301
306
|
typescript: '~5.6.3',
|
|
@@ -352,48 +357,15 @@ jobs:
|
|
|
352
357
|
build-filter: '${meta.docsPkgName}'
|
|
353
358
|
`;
|
|
354
359
|
}
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
*/
|
|
361
|
-
function playwrightConfig(meta) {
|
|
362
|
-
const url = `http://localhost:3000${siteBaseUrl(meta)}`;
|
|
363
|
-
return `import { defineConfig, devices } from '@playwright/test'
|
|
364
|
-
import base from '@rtorcato/repo-tooling/playwright'
|
|
360
|
+
// routeBasePath is '/docs', so the site root has no page of its own and the
|
|
361
|
+
// navbar logo links to a 404 on every page (#664). Redirect it to the docs.
|
|
362
|
+
// Tabs/no semicolons to match the Biome preset the consuming repo is linted with.
|
|
363
|
+
const HOME_PAGE = `import { Redirect } from '@docusaurus/router'
|
|
364
|
+
import useBaseUrl from '@docusaurus/useBaseUrl'
|
|
365
365
|
|
|
366
|
-
export default
|
|
367
|
-
|
|
368
|
-
testDir: './tests',
|
|
369
|
-
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
|
|
370
|
-
use: {
|
|
371
|
-
...base.use,
|
|
372
|
-
baseURL: process.env.PLAYWRIGHT_BASE_URL ?? '${url}',
|
|
373
|
-
},
|
|
374
|
-
webServer: {
|
|
375
|
-
command: 'pnpm run build && pnpm exec docusaurus serve --port 3000',
|
|
376
|
-
url: process.env.PLAYWRIGHT_BASE_URL ?? '${url}',
|
|
377
|
-
reuseExistingServer: !process.env.CI,
|
|
378
|
-
timeout: 180_000,
|
|
379
|
-
},
|
|
380
|
-
})
|
|
381
|
-
`;
|
|
366
|
+
export default function Home() {
|
|
367
|
+
\treturn <Redirect to={useBaseUrl('/docs')} />
|
|
382
368
|
}
|
|
383
|
-
const SMOKE_SPEC = `import { expect, test } from '@playwright/test'
|
|
384
|
-
|
|
385
|
-
// Smoke test: assert the built site serves and its core UI renders. Deliberately
|
|
386
|
-
// content-agnostic — it validates "the site builds and boots", not copy.
|
|
387
|
-
test('homepage responds and renders the shell', async ({ page }) => {
|
|
388
|
-
const res = await page.goto('./')
|
|
389
|
-
expect(res?.ok()).toBeTruthy()
|
|
390
|
-
await expect(page.locator('.navbar')).toBeVisible()
|
|
391
|
-
})
|
|
392
|
-
|
|
393
|
-
test('the starter doc renders a heading', async ({ page }) => {
|
|
394
|
-
await page.goto('./')
|
|
395
|
-
await expect(page.locator('h1')).toBeVisible()
|
|
396
|
-
})
|
|
397
369
|
`;
|
|
398
370
|
/**
|
|
399
371
|
* Scaffold the Docusaurus docs site. Writes each file only when missing and
|
|
@@ -426,9 +398,8 @@ export async function generateDocsSite(pkg, targetDir, options = {}) {
|
|
|
426
398
|
[`${DOCS_APP}/sidebars.ts`, SIDEBARS],
|
|
427
399
|
[`${DOCS_APP}/tsconfig.json`, TSCONFIG],
|
|
428
400
|
[`${DOCS_APP}/src/css/custom.css`, customCss(accent)],
|
|
401
|
+
[`${DOCS_APP}/src/pages/index.tsx`, HOME_PAGE],
|
|
429
402
|
[`${DOCS_APP}/docs/intro.md`, introDoc(meta, badges)],
|
|
430
|
-
[`${DOCS_APP}/playwright.config.ts`, playwrightConfig(meta)],
|
|
431
|
-
[`${DOCS_APP}/tests/smoke.spec.ts`, SMOKE_SPEC],
|
|
432
403
|
['.github/workflows/docs.yml', docsWorkflow(meta)],
|
|
433
404
|
];
|
|
434
405
|
// TypeDoc emits docs/api/<id> on build — keep the generated tree out of git.
|
|
@@ -156,6 +156,32 @@ function insertUnder(yaml, key, item) {
|
|
|
156
156
|
lines.splice(at + 1, 0, item);
|
|
157
157
|
return lines.join('\n');
|
|
158
158
|
}
|
|
159
|
+
/**
|
|
160
|
+
* The build-script decisions a Docusaurus site needs under pnpm 11 (#663): it
|
|
161
|
+
* pulls in all four, and an undecided one fails the install. core-js's
|
|
162
|
+
* postinstall is only a banner, so it is declined; esbuild and sharp compile.
|
|
163
|
+
*/
|
|
164
|
+
export const DOCS_SITE_BUILDS = {
|
|
165
|
+
'core-js': false,
|
|
166
|
+
'core-js-pure': false,
|
|
167
|
+
esbuild: true,
|
|
168
|
+
sharp: true,
|
|
169
|
+
};
|
|
170
|
+
/**
|
|
171
|
+
* Merge `builds` into the `allowBuilds:` map, adding only the packages that
|
|
172
|
+
* carry no decision yet — an existing value, whichever way it went, stays.
|
|
173
|
+
*/
|
|
174
|
+
export function mergeAllowBuilds(yaml, builds) {
|
|
175
|
+
const entries = Object.entries(builds)
|
|
176
|
+
.filter(([name]) => !approved(yaml, name))
|
|
177
|
+
.map(([name, allow]) => ` ${asKey(name)}: ${allow}`)
|
|
178
|
+
.join('\n');
|
|
179
|
+
if (!entries)
|
|
180
|
+
return yaml;
|
|
181
|
+
if (section(yaml, 'allowBuilds'))
|
|
182
|
+
return insertUnder(yaml, 'allowBuilds', entries);
|
|
183
|
+
return `${yaml.replace(/\n*$/, '\n')}\nallowBuilds:\n${entries}\n`;
|
|
184
|
+
}
|
|
159
185
|
/** Merge every missing managed setting into `yaml` and return the new contents. */
|
|
160
186
|
export function upsertPnpmSettings(yaml, needsEsbuild, glob) {
|
|
161
187
|
let next = yaml;
|
package/dist/cli/index.js
CHANGED
|
@@ -1,22 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import path from 'node:path';
|
|
3
2
|
import chalk from 'chalk';
|
|
4
3
|
import { Command } from 'commander';
|
|
5
|
-
import fs from 'fs-extra';
|
|
6
4
|
import { doctorCommand } from './commands/doctor.js';
|
|
7
5
|
import { fixCommand } from './commands/fix.js';
|
|
8
6
|
import { setupProject } from './commands/setup.js';
|
|
7
|
+
import { selfRepoRefusal } from './self-repo.js';
|
|
9
8
|
import { copyPreset, PRESETS } from './utils/copy-preset.js';
|
|
10
9
|
import { getToolVersion } from './utils/version.js';
|
|
11
|
-
async function isSelfRepo(dir) {
|
|
12
|
-
try {
|
|
13
|
-
const pkg = await fs.readJson(path.join(dir, 'package.json'));
|
|
14
|
-
return pkg.name === '@rtorcato/repo-tooling';
|
|
15
|
-
}
|
|
16
|
-
catch {
|
|
17
|
-
return false;
|
|
18
|
-
}
|
|
19
|
-
}
|
|
20
10
|
const program = new Command();
|
|
21
11
|
program
|
|
22
12
|
.name('@rtorcato/repo-tooling')
|
|
@@ -351,29 +341,11 @@ program
|
|
|
351
341
|
process.exitCode = 1;
|
|
352
342
|
});
|
|
353
343
|
program.hook('preAction', async (_, actionCommand) => {
|
|
354
|
-
const
|
|
355
|
-
if (
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
// Dogfood escape hatch (#273): allow read-only `doctor` against this repo
|
|
360
|
-
// so CI can audit our own config the same way it does consumers'. Scoped
|
|
361
|
-
// to doctor — the mutating setup/fix stay blocked even with the flag set.
|
|
362
|
-
if (name === 'doctor' && process.env.REPO_TOOLING_ALLOW_SELF === '1')
|
|
363
|
-
return;
|
|
364
|
-
// One mutating exception (#531): `fix lockfile` writes only
|
|
365
|
-
// .repo-tooling.json — no scaffolding — so our own lockfile can be
|
|
366
|
-
// migrated by the fixer we ship instead of by hand.
|
|
367
|
-
if (name === 'fix' &&
|
|
368
|
-
actionCommand.args[0] === 'lockfile' &&
|
|
369
|
-
process.env.REPO_TOOLING_ALLOW_SELF === '1')
|
|
370
|
-
return;
|
|
371
|
-
const dir = actionCommand.opts().directory ?? process.cwd();
|
|
372
|
-
if (await isSelfRepo(dir)) {
|
|
373
|
-
console.log(chalk.yellow('\n⚠️ This command cannot be run inside the @rtorcato/repo-tooling repo itself.\n'));
|
|
374
|
-
console.log(chalk.gray(' setup and doctor are for consumer projects, not for the tooling repo.\n'));
|
|
375
|
-
process.exit(0);
|
|
376
|
-
}
|
|
344
|
+
const refusal = await selfRepoRefusal(actionCommand.name(), actionCommand.args[0], actionCommand.opts());
|
|
345
|
+
if (refusal) {
|
|
346
|
+
console.log(chalk.yellow('\n⚠️ This command cannot be run inside the @rtorcato/repo-tooling repo itself.\n'));
|
|
347
|
+
console.log(chalk.gray(` ${refusal}\n`));
|
|
348
|
+
process.exit(0);
|
|
377
349
|
}
|
|
378
350
|
});
|
|
379
351
|
// Handle unknown commands
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import fs from 'fs-extra';
|
|
3
|
+
import { getFixers } from './commands/fix.js';
|
|
4
|
+
export async function isSelfRepo(dir) {
|
|
5
|
+
try {
|
|
6
|
+
const pkg = await fs.readJson(path.join(dir, 'package.json'));
|
|
7
|
+
return pkg.name === '@rtorcato/repo-tooling';
|
|
8
|
+
}
|
|
9
|
+
catch {
|
|
10
|
+
return false;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Why `setup` / `doctor` / `fix` must not run in `dir`, or null when it may.
|
|
15
|
+
* Most fixers write configs that import `@rtorcato/repo-tooling/...`, which
|
|
16
|
+
* this repo can't depend on, so inside it everything is refused unless
|
|
17
|
+
* `REPO_TOOLING_ALLOW_SELF=1` is set. With the flag, read-only `doctor` (#273)
|
|
18
|
+
* and `fix <target>` for a `selfSafe` target (#673) get through. A bare `fix`
|
|
19
|
+
* stays refused even then: it walks every fixer, self-safe or not.
|
|
20
|
+
*/
|
|
21
|
+
export async function selfRepoRefusal(command, target, opts, env = process.env) {
|
|
22
|
+
if (command !== 'setup' && command !== 'doctor' && command !== 'fix')
|
|
23
|
+
return null;
|
|
24
|
+
// `fix --list` is read-only and safe to run anywhere, including this repo.
|
|
25
|
+
if (command === 'fix' && opts.list)
|
|
26
|
+
return null;
|
|
27
|
+
if (!(await isSelfRepo(opts.directory ?? process.cwd())))
|
|
28
|
+
return null;
|
|
29
|
+
const allowSelf = env.REPO_TOOLING_ALLOW_SELF === '1';
|
|
30
|
+
if (allowSelf && command === 'doctor')
|
|
31
|
+
return null;
|
|
32
|
+
if (allowSelf && command === 'fix') {
|
|
33
|
+
if (!target) {
|
|
34
|
+
const safe = getFixers()
|
|
35
|
+
.filter((f) => f.selfSafe)
|
|
36
|
+
.map((f) => f.target);
|
|
37
|
+
return `a bare \`fix\` runs every fixer, including ones whose output imports @rtorcato/repo-tooling — name a self-safe target instead: ${safe.join(', ')}.`;
|
|
38
|
+
}
|
|
39
|
+
const fixer = getFixers().find((f) => f.target.toLowerCase() === target.toLowerCase());
|
|
40
|
+
if (fixer?.selfSafe)
|
|
41
|
+
return null;
|
|
42
|
+
return fixer
|
|
43
|
+
? `\`fix ${target}\` is not self-safe: its output imports or depends on @rtorcato/repo-tooling, which this repo cannot depend on.`
|
|
44
|
+
: `\`fix ${target}\` is not a known self-safe target.`;
|
|
45
|
+
}
|
|
46
|
+
return 'setup and doctor are for consumer projects, not for the tooling repo.';
|
|
47
|
+
}
|
|
@@ -160,6 +160,17 @@ export function lockfileSchema() {
|
|
|
160
160
|
additionalProperties: { type: 'string', minLength: 1 },
|
|
161
161
|
description: 'Declared exceptions: doctor check name → the reason this repo deliberately deviates. The reason is mandatory and non-empty — doctor shows the check as `declared` with it (never hidden) and stops failing the run for it. An entry naming a check doctor does not run is itself reported as drift.',
|
|
162
162
|
},
|
|
163
|
+
brand: {
|
|
164
|
+
type: 'object',
|
|
165
|
+
additionalProperties: false,
|
|
166
|
+
description: 'Inputs to `fix brand`, which scaffolds the banner and social-card SVGs.',
|
|
167
|
+
properties: {
|
|
168
|
+
tagline: {
|
|
169
|
+
type: 'string',
|
|
170
|
+
description: 'Short line for the banners and social card, used in place of the package.json description. Keep it to two lines of about 42 characters; `fix brand` warns when it is longer.',
|
|
171
|
+
},
|
|
172
|
+
},
|
|
173
|
+
},
|
|
163
174
|
},
|
|
164
175
|
},
|
|
165
176
|
},
|
package/dist/languages/js/ci.js
CHANGED
|
@@ -78,6 +78,13 @@ export function scriptsOf(pkg) {
|
|
|
78
78
|
function hasScript(opts, script) {
|
|
79
79
|
return !opts.scripts || script in opts.scripts;
|
|
80
80
|
}
|
|
81
|
+
/**
|
|
82
|
+
* A bundler, or a known `build` script — a plain-`tsc` repo records `bundler:
|
|
83
|
+
* 'none'` (#661) but still has a build to run.
|
|
84
|
+
*/
|
|
85
|
+
function buildsSomething(config, opts) {
|
|
86
|
+
return config.bundler !== 'none' || (opts.scripts !== undefined && 'build' in opts.scripts);
|
|
87
|
+
}
|
|
81
88
|
/** Emit a step only when the script it runs exists (or we can't know yet). */
|
|
82
89
|
function stepFor(opts, script, step) {
|
|
83
90
|
return hasScript(opts, script) ? step : null;
|
|
@@ -95,7 +102,7 @@ function jobSteps(steps) {
|
|
|
95
102
|
export function githubJobs(config, opts = {}) {
|
|
96
103
|
const hasTypeScript = config.typescript.enabled;
|
|
97
104
|
const hasTests = config.testing.framework !== 'none';
|
|
98
|
-
const hasBuild = config
|
|
105
|
+
const hasBuild = buildsSomething(config, opts);
|
|
99
106
|
const isLibrary = config.projectType === 'library';
|
|
100
107
|
const hasCoverage = usesCoverage(config);
|
|
101
108
|
const jobs = [DEPENDENCIES_JOB];
|
|
@@ -230,7 +237,7 @@ export function gitlabSpec(config, opts = {}) {
|
|
|
230
237
|
const hasTypeScript = config.typescript.enabled;
|
|
231
238
|
const hasTests = config.testing.framework !== 'none';
|
|
232
239
|
const hasLint = config.linting.tool !== 'none';
|
|
233
|
-
const hasBuild = config
|
|
240
|
+
const hasBuild = buildsSomething(config, opts);
|
|
234
241
|
const test = gitlabTest(config);
|
|
235
242
|
const jobs = [
|
|
236
243
|
...(hasLint && hasScript(opts, lintScript(config))
|
|
@@ -79,8 +79,33 @@ import { LOCKFILE_NAME, writeLockfile } from '../../cli/utils/lockfile.js';
|
|
|
79
79
|
// The fixer contract moved to src/base/fixers.ts when Swift became the second
|
|
80
80
|
// module (#286) — import it from there.
|
|
81
81
|
import { FixerAbort } from '../../base/fixers.js';
|
|
82
|
+
// The bundlers ProjectConfig can record, in the order a repo carrying more than
|
|
83
|
+
// one is attributed to (a vite app that also pulls in esbuild is a vite app).
|
|
84
|
+
const BUNDLERS = ['tsup', 'vite', 'rolldown', 'rollup', 'esbuild'];
|
|
85
|
+
const BUNDLER_CONFIGS = {
|
|
86
|
+
tsup: [
|
|
87
|
+
'tsup.config.ts',
|
|
88
|
+
'tsup.config.mts',
|
|
89
|
+
'tsup.config.js',
|
|
90
|
+
'tsup.config.mjs',
|
|
91
|
+
'tsup.config.json',
|
|
92
|
+
],
|
|
93
|
+
vite: ['vite.config.ts', 'vite.config.mts', 'vite.config.js', 'vite.config.mjs'],
|
|
94
|
+
rolldown: ['rolldown.config.ts', 'rolldown.config.mjs', 'rolldown.config.js'],
|
|
95
|
+
rollup: ['rollup.config.ts', 'rollup.config.mjs', 'rollup.config.js'],
|
|
96
|
+
esbuild: ['build.mjs'],
|
|
97
|
+
};
|
|
98
|
+
/**
|
|
99
|
+
* The bundler the repo actually uses, from its deps or (given `dir`) its config
|
|
100
|
+
* file — `none` when there is neither. Never a preset default: a plain-`tsc`
|
|
101
|
+
* repo used to be recorded as tsup (#661).
|
|
102
|
+
*/
|
|
103
|
+
function detectBundler(deps, dir) {
|
|
104
|
+
return (BUNDLERS.find((b) => b in deps ||
|
|
105
|
+
(dir !== undefined && BUNDLER_CONFIGS[b].some((f) => fs.existsSync(path.join(dir, f))))) ?? 'none');
|
|
106
|
+
}
|
|
82
107
|
/** Exported so doctor can render the preset ci.yml it compares against (#349). */
|
|
83
|
-
export function inferProjectConfig(pkg) {
|
|
108
|
+
export function inferProjectConfig(pkg, dir) {
|
|
84
109
|
const deps = {
|
|
85
110
|
...(pkg?.dependencies ?? {}),
|
|
86
111
|
...(pkg?.devDependencies ?? {}),
|
|
@@ -92,6 +117,7 @@ export function inferProjectConfig(pkg) {
|
|
|
92
117
|
projectType = 'react-app';
|
|
93
118
|
return {
|
|
94
119
|
projectName: pkg?.name ?? 'project',
|
|
120
|
+
language: 'js',
|
|
95
121
|
projectType,
|
|
96
122
|
typescript: {
|
|
97
123
|
enabled: true,
|
|
@@ -109,7 +135,7 @@ export function inferProjectConfig(pkg) {
|
|
|
109
135
|
commitLint: true,
|
|
110
136
|
semanticRelease: pkg?.private !== true,
|
|
111
137
|
securityAutomation: true,
|
|
112
|
-
bundler:
|
|
138
|
+
bundler: detectBundler(deps, dir),
|
|
113
139
|
};
|
|
114
140
|
}
|
|
115
141
|
export async function readPackageJson(dir) {
|
|
@@ -520,6 +546,7 @@ export const FIXERS = [
|
|
|
520
546
|
},
|
|
521
547
|
{
|
|
522
548
|
target: 'vscode-extensions',
|
|
549
|
+
selfSafe: true,
|
|
523
550
|
description: 'Recommend the VS Code extensions matching the enabled tools (.vscode/extensions.json)',
|
|
524
551
|
appliesTo: ['VS Code extensions'],
|
|
525
552
|
outputs: ['.vscode/extensions.json'],
|
|
@@ -533,6 +560,7 @@ export const FIXERS = [
|
|
|
533
560
|
},
|
|
534
561
|
{
|
|
535
562
|
target: 'nvmrc',
|
|
563
|
+
selfSafe: true,
|
|
536
564
|
description: 'Scaffold .nvmrc pinned to Node 22',
|
|
537
565
|
appliesTo: ['Node version pin'],
|
|
538
566
|
outputs: ['.nvmrc'],
|
|
@@ -866,6 +894,7 @@ export const FIXERS = [
|
|
|
866
894
|
},
|
|
867
895
|
{
|
|
868
896
|
target: 'lockfile',
|
|
897
|
+
selfSafe: true,
|
|
869
898
|
description: `Scaffold ${LOCKFILE_NAME} recording current tool choices`,
|
|
870
899
|
appliesTo: ['lockfile'],
|
|
871
900
|
outputs: [LOCKFILE_NAME],
|
|
@@ -879,7 +908,9 @@ export const FIXERS = [
|
|
|
879
908
|
console.error(chalk.yellow(' no package.json found — skipping'));
|
|
880
909
|
return { filesWritten: [] };
|
|
881
910
|
}
|
|
882
|
-
const config = lock
|
|
911
|
+
const config = lock
|
|
912
|
+
? lock.record.config
|
|
913
|
+
: inferProjectConfig(pkg, targetDir);
|
|
883
914
|
// Recorded hashes win: they capture the pristine content at copy time,
|
|
884
915
|
// which a byte-match against today's shipped asset can only approximate.
|
|
885
916
|
const assets = { ...(await identifiablePresetHashes(targetDir)), ...lock?.record.assets };
|
package/package.json
CHANGED