@rtorcato/repo-tooling 4.0.0 → 4.1.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.
@@ -257,8 +257,8 @@ export const BASE_FIXERS = [
257
257
  // image paths — hand-edited art is never clobbered.
258
258
  riskLevel: 'safe-merge',
259
259
  canFixDrift: true,
260
- async run({ targetDir, pkg }) {
261
- const filesWritten = await generateBrand(pkg, targetDir);
260
+ async run({ targetDir, pkg, lock }) {
261
+ const filesWritten = await generateBrand(pkg, targetDir, lock?.rules?.brand?.tagline);
262
262
  if (filesWritten.some((f) => f.endsWith('.svg'))) {
263
263
  console.error(chalk.dim(' next: run `brand/render.sh` to render the PNGs (needs librsvg — `brew install librsvg`)'));
264
264
  }
@@ -209,6 +209,7 @@ export const CONFIG_SCHEMA = {
209
209
  turborepo: { type: 'boolean' },
210
210
  nx: { type: 'boolean' },
211
211
  tailwind: { type: 'boolean' },
212
+ docsSite: { type: 'boolean' },
212
213
  bun: { type: 'boolean' },
213
214
  },
214
215
  };
@@ -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 and
10
- * tagline from its package.json, accent from its own docs theme or favicon —
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
- export async function resolveBrandMeta(pkg, targetDir) {
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 one-line tagline to package.json "description".',
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,24 +65,25 @@ async function writeIfMissing(targetDir, rel, contents) {
64
65
  await fs.writeFile(file, contents);
65
66
  return rel;
66
67
  }
67
- /** Ensure `pnpm-workspace.yaml` lists `apps/*` (idempotent). */
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
- if (await fs.pathExists(file)) {
72
- const body = await fs.readFile(file, 'utf8');
73
- // Already a workspace covering apps/* (either `apps/*` or a broader glob).
74
- if (/^\s*-\s*['"]?apps\/\*/m.test(body))
75
- return null;
76
- if (/^packages:/m.test(body)) {
77
- const next = body.replace(/^packages:\n/m, "packages:\n - 'apps/*'\n");
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
- await fs.writeFile(file, "packages:\n - 'apps/*'\n");
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
89
  /** The GitHub Pages base path Docusaurus serves under, e.g. `/repo-tooling/`. */
@@ -380,6 +382,16 @@ export default defineConfig({
380
382
  })
381
383
  `;
382
384
  }
385
+ // routeBasePath is '/docs', so the site root has no page of its own and the
386
+ // navbar logo links to a 404 on every page (#664). Redirect it to the docs.
387
+ // Tabs/no semicolons to match the Biome preset the consuming repo is linted with.
388
+ const HOME_PAGE = `import { Redirect } from '@docusaurus/router'
389
+ import useBaseUrl from '@docusaurus/useBaseUrl'
390
+
391
+ export default function Home() {
392
+ \treturn <Redirect to={useBaseUrl('/docs')} />
393
+ }
394
+ `;
383
395
  const SMOKE_SPEC = `import { expect, test } from '@playwright/test'
384
396
 
385
397
  // Smoke test: assert the built site serves and its core UI renders. Deliberately
@@ -426,6 +438,7 @@ export async function generateDocsSite(pkg, targetDir, options = {}) {
426
438
  [`${DOCS_APP}/sidebars.ts`, SIDEBARS],
427
439
  [`${DOCS_APP}/tsconfig.json`, TSCONFIG],
428
440
  [`${DOCS_APP}/src/css/custom.css`, customCss(accent)],
441
+ [`${DOCS_APP}/src/pages/index.tsx`, HOME_PAGE],
429
442
  [`${DOCS_APP}/docs/intro.md`, introDoc(meta, badges)],
430
443
  [`${DOCS_APP}/playwright.config.ts`, playwrightConfig(meta)],
431
444
  [`${DOCS_APP}/tests/smoke.spec.ts`, SMOKE_SPEC],
@@ -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;
@@ -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
  },
@@ -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.bundler !== 'none';
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.bundler !== 'none';
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: 'tsup',
138
+ bundler: detectBundler(deps, dir),
113
139
  };
114
140
  }
115
141
  export async function readPackageJson(dir) {
@@ -879,7 +905,9 @@ export const FIXERS = [
879
905
  console.error(chalk.yellow(' no package.json found — skipping'));
880
906
  return { filesWritten: [] };
881
907
  }
882
- const config = lock ? lock.record.config : inferProjectConfig(pkg);
908
+ const config = lock
909
+ ? lock.record.config
910
+ : inferProjectConfig(pkg, targetDir);
883
911
  // Recorded hashes win: they capture the pristine content at copy time,
884
912
  // which a byte-match against today's shipped asset can only approximate.
885
913
  const assets = { ...(await identifiablePresetHashes(targetDir)), ...lock?.record.assets };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rtorcato/repo-tooling",
3
- "version": "4.0.0",
3
+ "version": "4.1.0",
4
4
  "description": "One CLI to scaffold, audit and fix your repo's whole toolchain — linting, tests, commits, releases & CI.",
5
5
  "type": "module",
6
6
  "keywords": [