@rtorcato/repo-tooling 3.2.3 → 3.2.5

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.
@@ -0,0 +1,410 @@
1
+ import path from 'node:path';
2
+ import fs from 'fs-extra';
3
+ import { BADGE_START, hasPublicOnlyBadges } from '../cli/generators/badges.js';
4
+ export async function checkFile(dir, spec) {
5
+ for (const candidate of spec.candidates) {
6
+ const filepath = path.join(dir, candidate);
7
+ if (!(await fs.pathExists(filepath)))
8
+ continue;
9
+ const contents = await fs.readFile(filepath, 'utf-8');
10
+ if (spec.matcher.test(contents)) {
11
+ return {
12
+ check: spec.check,
13
+ status: 'ok',
14
+ detail: `${candidate} ${spec.expected}`,
15
+ };
16
+ }
17
+ return {
18
+ check: spec.check,
19
+ status: 'drift',
20
+ detail: `${candidate} found but does not ${spec.expected}`,
21
+ hint: spec.hint,
22
+ };
23
+ }
24
+ return {
25
+ check: spec.check,
26
+ status: spec.optional ? 'optional-missing' : 'missing',
27
+ detail: `no ${spec.candidates.join(' / ')} found`,
28
+ hint: spec.hint,
29
+ };
30
+ }
31
+ export async function checkEditorConfig(dir) {
32
+ const exists = await fs.pathExists(path.join(dir, '.editorconfig'));
33
+ return {
34
+ check: 'EditorConfig',
35
+ status: exists ? 'ok' : 'optional-missing',
36
+ detail: exists ? '.editorconfig found' : 'no .editorconfig',
37
+ hint: exists ? undefined : 'Add an .editorconfig for cross-editor formatting consistency',
38
+ };
39
+ }
40
+ export async function checkCodeowners(dir) {
41
+ for (const candidate of ['CODEOWNERS', '.github/CODEOWNERS', 'docs/CODEOWNERS']) {
42
+ if (await fs.pathExists(path.join(dir, candidate))) {
43
+ return {
44
+ check: 'CODEOWNERS',
45
+ status: 'ok',
46
+ detail: `${candidate} found`,
47
+ };
48
+ }
49
+ }
50
+ return {
51
+ check: 'CODEOWNERS',
52
+ status: 'optional-missing',
53
+ detail: 'no CODEOWNERS file',
54
+ hint: 'Run `npx @rtorcato/repo-tooling fix codeowners` to scaffold .github/CODEOWNERS',
55
+ };
56
+ }
57
+ export async function checkCommunityHealth(dir) {
58
+ const anchors = ['CONTRIBUTING.md', 'SECURITY.md'];
59
+ const present = await Promise.all(anchors.map((f) => fs.pathExists(path.join(dir, f))));
60
+ if (present.every(Boolean)) {
61
+ return {
62
+ check: 'Community health',
63
+ status: 'ok',
64
+ detail: 'CONTRIBUTING.md and SECURITY.md found',
65
+ };
66
+ }
67
+ return {
68
+ check: 'Community health',
69
+ status: 'optional-missing',
70
+ detail: 'missing community-health files (CONTRIBUTING/SECURITY/templates)',
71
+ hint: 'Run `npx @rtorcato/repo-tooling fix community-health` to scaffold them',
72
+ };
73
+ }
74
+ export async function checkGitHubActions(dir) {
75
+ const workflowsDir = path.join(dir, '.github', 'workflows');
76
+ if (!(await fs.pathExists(workflowsDir))) {
77
+ return {
78
+ check: 'GitHub Actions',
79
+ status: 'optional-missing',
80
+ detail: 'no .github/workflows/',
81
+ hint: 'Run `npx @rtorcato/repo-tooling setup` to scaffold a CI workflow',
82
+ };
83
+ }
84
+ try {
85
+ const files = await fs.readdir(workflowsDir);
86
+ const workflows = files.filter((f) => f.endsWith('.yml') || f.endsWith('.yaml'));
87
+ if (workflows.length === 0) {
88
+ return {
89
+ check: 'GitHub Actions',
90
+ status: 'optional-missing',
91
+ detail: '.github/workflows/ is empty',
92
+ hint: 'Add a workflow file (e.g. ci.yml) under .github/workflows/',
93
+ };
94
+ }
95
+ return {
96
+ check: 'GitHub Actions',
97
+ status: 'ok',
98
+ detail: `${workflows.length} workflow${workflows.length === 1 ? '' : 's'} in .github/workflows/`,
99
+ };
100
+ }
101
+ catch {
102
+ return {
103
+ check: 'GitHub Actions',
104
+ status: 'optional-missing',
105
+ detail: 'unable to read .github/workflows/',
106
+ };
107
+ }
108
+ }
109
+ export async function checkDependabot(dir) {
110
+ for (const candidate of ['.github/dependabot.yml', '.github/dependabot.yaml']) {
111
+ const candidatePath = path.join(dir, candidate);
112
+ if (await fs.pathExists(candidatePath)) {
113
+ // The canonical standard (apps/docs/docs/guides/dependabot-strategy.md) is
114
+ // the safe-tier + major-tier grouping plus the auto-merge workflow that
115
+ // depends on it. Flag any config that predates it so `fix dependabot` can
116
+ // bring the pair up to standard.
117
+ const content = await fs.readFile(candidatePath, 'utf8');
118
+ const deltas = [];
119
+ for (const group of ['production-minor', 'dev-minor', 'major-updates']) {
120
+ if (!new RegExp(`^\\s*${group}:`, 'm').test(content)) {
121
+ deltas.push(`missing \`${group}\` group`);
122
+ }
123
+ }
124
+ const automergePath = path.join(dir, '.github', 'workflows', 'dependabot-automerge.yml');
125
+ if (!(await fs.pathExists(automergePath))) {
126
+ deltas.push('missing dependabot-automerge workflow');
127
+ }
128
+ if (deltas.length > 0) {
129
+ return {
130
+ check: 'Dependabot',
131
+ status: 'drift',
132
+ detail: `${candidate} drifts from canonical (${deltas.join('; ')})`,
133
+ hint: 'Run `npx @rtorcato/repo-tooling fix dependabot` to apply the canonical grouping + auto-merge workflow',
134
+ };
135
+ }
136
+ return {
137
+ check: 'Dependabot',
138
+ status: 'ok',
139
+ detail: `${candidate} + auto-merge workflow`,
140
+ };
141
+ }
142
+ }
143
+ for (const candidate of [
144
+ 'renovate.json',
145
+ 'renovate.json5',
146
+ '.github/renovate.json',
147
+ '.github/renovate.json5',
148
+ '.renovaterc',
149
+ '.renovaterc.json',
150
+ ]) {
151
+ if (await fs.pathExists(path.join(dir, candidate))) {
152
+ return {
153
+ check: 'Dependabot',
154
+ status: 'ok',
155
+ detail: `${candidate} found (Renovate)`,
156
+ };
157
+ }
158
+ }
159
+ return {
160
+ check: 'Dependabot',
161
+ status: 'optional-missing',
162
+ detail: 'no Dependabot or Renovate config',
163
+ hint: 'Run `npx @rtorcato/repo-tooling fix dependabot` (or `fix renovate`) to scaffold weekly dep updates',
164
+ };
165
+ }
166
+ export async function checkCodeQL(dir) {
167
+ const workflowsDir = path.join(dir, '.github', 'workflows');
168
+ if (!(await fs.pathExists(workflowsDir))) {
169
+ return {
170
+ check: 'CodeQL',
171
+ status: 'optional-missing',
172
+ detail: 'no .github/workflows/',
173
+ hint: 'Run `npx @rtorcato/repo-tooling fix codeql` to scaffold CodeQL security scanning',
174
+ };
175
+ }
176
+ for (const candidate of ['codeql.yml', 'codeql.yaml']) {
177
+ if (await fs.pathExists(path.join(workflowsDir, candidate))) {
178
+ return {
179
+ check: 'CodeQL',
180
+ status: 'ok',
181
+ detail: `.github/workflows/${candidate} found`,
182
+ };
183
+ }
184
+ }
185
+ try {
186
+ const files = await fs.readdir(workflowsDir);
187
+ for (const f of files) {
188
+ if (!(f.endsWith('.yml') || f.endsWith('.yaml')))
189
+ continue;
190
+ const content = await fs.readFile(path.join(workflowsDir, f), 'utf-8');
191
+ if (/github\/codeql-action/.test(content)) {
192
+ return {
193
+ check: 'CodeQL',
194
+ status: 'ok',
195
+ detail: `codeql-action referenced in ${f}`,
196
+ };
197
+ }
198
+ }
199
+ }
200
+ catch {
201
+ // fall through to optional-missing
202
+ }
203
+ return {
204
+ check: 'CodeQL',
205
+ status: 'optional-missing',
206
+ detail: 'no codeql workflow found',
207
+ hint: 'Run `npx @rtorcato/repo-tooling fix codeql` to scaffold CodeQL security scanning',
208
+ };
209
+ }
210
+ // A README that advertises a Codecov badge but a CI that never uploads coverage
211
+ // leaves the badge permanently red. Only flags when the badge is actually present
212
+ // (no badge → nothing to back, so it's not applicable).
213
+ export async function checkCoverageUpload(dir) {
214
+ const readmePath = path.join(dir, 'README.md');
215
+ const readme = (await fs.pathExists(readmePath)) ? await fs.readFile(readmePath, 'utf8') : '';
216
+ if (!/codecov\.io/.test(readme)) {
217
+ return {
218
+ check: 'Coverage upload',
219
+ status: 'ok',
220
+ detail: 'no coverage badge in README (nothing to back)',
221
+ };
222
+ }
223
+ const workflowsDir = path.join(dir, '.github', 'workflows');
224
+ if (await fs.pathExists(workflowsDir)) {
225
+ try {
226
+ const files = (await fs.readdir(workflowsDir)).filter((f) => f.endsWith('.yml') || f.endsWith('.yaml'));
227
+ for (const f of files) {
228
+ const content = await fs.readFile(path.join(workflowsDir, f), 'utf-8');
229
+ if (/codecov\/codecov-action/.test(content)) {
230
+ return {
231
+ check: 'Coverage upload',
232
+ status: 'ok',
233
+ detail: `coverage badge backed by codecov-action in .github/workflows/${f}`,
234
+ };
235
+ }
236
+ }
237
+ }
238
+ catch {
239
+ // fall through to drift
240
+ }
241
+ }
242
+ return {
243
+ check: 'Coverage upload',
244
+ status: 'drift',
245
+ detail: 'README has a Codecov badge but no CI step uploads coverage (badge stays red)',
246
+ hint: 'Run `npx @rtorcato/repo-tooling fix github-actions` to regenerate ci.yml with a Codecov upload step',
247
+ };
248
+ }
249
+ export async function checkAiSetup(dir) {
250
+ // Consider AI setup present if AGENTS.md carries the js-tooling block or the
251
+ // Claude skill is installed — the two primary markers `fix ai` writes.
252
+ const agentsPath = path.join(dir, 'AGENTS.md');
253
+ const hasAgentsBlock = (await fs.pathExists(agentsPath)) &&
254
+ (await fs.readFile(agentsPath, 'utf8')).includes('<!-- js-tooling:start -->');
255
+ const hasSkill = (await fs.pathExists(path.join(dir, '.claude', 'skills', 'repo-tooling.md'))) ||
256
+ // Pre-rename name — still counts as present until `fix` migrates it.
257
+ (await fs.pathExists(path.join(dir, '.claude', 'skills', 'js-tooling.md')));
258
+ if (hasAgentsBlock || hasSkill) {
259
+ return {
260
+ check: 'AI setup',
261
+ status: 'ok',
262
+ detail: hasAgentsBlock ? 'AGENTS.md has the js-tooling block' : '.claude skill installed',
263
+ };
264
+ }
265
+ return {
266
+ check: 'AI setup',
267
+ status: 'optional-missing',
268
+ detail: 'no AI agent files (AGENTS.md, CLAUDE.md, Cursor/Copilot rules, Claude skill)',
269
+ hint: 'Run `npx @rtorcato/repo-tooling fix ai` to scaffold agent rules for every AI tool',
270
+ };
271
+ }
272
+ /**
273
+ * Conventional Commits is a repo convention, not a JavaScript one — the config
274
+ * file is the same in any repo that has node available to run commitlint, so
275
+ * the spec lives here rather than in the JS module's FILE_CHECKS (#309).
276
+ */
277
+ export const COMMITLINT_FILE_CHECK = {
278
+ check: 'Commitlint',
279
+ candidates: ['commitlint.config.js', 'commitlint.config.mjs', 'commitlint.config.cjs'],
280
+ expected: 'exports "@rtorcato/repo-tooling/commitlint/config"',
281
+ matcher: /@rtorcato\/(?:js|repo)-tooling\/commitlint\/config/,
282
+ optional: true,
283
+ };
284
+ /**
285
+ * True when a shell hook has an uncommented line matching `pattern`. A
286
+ * commented-out line (e.g. `# pnpm verify`) doesn't count — the command never
287
+ * runs, so it isn't real wiring.
288
+ */
289
+ export function hookHasUncommented(contents, pattern) {
290
+ return contents.split('\n').some((line) => {
291
+ const trimmed = line.trim();
292
+ return trimmed.length > 0 && !trimmed.startsWith('#') && pattern.test(trimmed);
293
+ });
294
+ }
295
+ /** `swift test` → /\bswift\s+test\b/, so extra whitespace in a hook still matches. */
296
+ function commandMatcher(command) {
297
+ const escaped = command.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/\s+/g, '\\s+');
298
+ return new RegExp(`\\b${escaped}\\b`);
299
+ }
300
+ export async function checkGitHooks(dir, p) {
301
+ const check = 'Git hooks';
302
+ const hint = `Run \`npx @rtorcato/repo-tooling fix ${p.fixTarget}\` to enable git hooks`;
303
+ const hooksDir = await fs.pathExists(path.join(dir, p.dir));
304
+ // With no committed install signal, the hooks directory itself is the whole
305
+ // answer — there's no second half that can drift out of sync with it.
306
+ const installed = p.install === null ? hooksDir : p.install.present;
307
+ if (hooksDir && installed) {
308
+ return {
309
+ check,
310
+ status: 'ok',
311
+ detail: p.install ? `${p.dir}/ and ${p.install.label} configured` : `${p.dir}/ found`,
312
+ };
313
+ }
314
+ if (hooksDir || installed) {
315
+ return {
316
+ check,
317
+ status: 'drift',
318
+ detail: hooksDir
319
+ ? `${p.dir}/ exists but no ${p.install?.label}`
320
+ : `${p.install?.label} set but no ${p.dir}/ directory`,
321
+ hint: `${hint} (scaffolds both halves)`,
322
+ };
323
+ }
324
+ return { check, status: 'optional-missing', detail: 'git hooks not configured', hint };
325
+ }
326
+ /**
327
+ * The pre-push gate. Only asserts that the hook runs the language's verify
328
+ * command — whether that command itself is well-formed is the language module's
329
+ * business (JS keeps a `verify script` check for exactly that).
330
+ */
331
+ export async function checkPrePushHook(dir, p) {
332
+ const check = 'Pre-push hook';
333
+ if (!(await fs.pathExists(path.join(dir, p.dir)))) {
334
+ return {
335
+ check,
336
+ status: 'optional-missing',
337
+ detail: 'git hooks not configured',
338
+ hint: `Run \`npx @rtorcato/repo-tooling fix ${p.fixTarget}\` to enable git hooks (includes pre-push)`,
339
+ };
340
+ }
341
+ const hookPath = path.join(dir, p.dir, 'pre-push');
342
+ if (!(await fs.pathExists(hookPath))) {
343
+ return {
344
+ check,
345
+ status: 'optional-missing',
346
+ detail: `no ${p.dir}/pre-push`,
347
+ hint: `Run \`npx @rtorcato/repo-tooling fix ${p.fixTarget}\` to scaffold a pre-push hook that runs \`${p.verifyCommand}\``,
348
+ };
349
+ }
350
+ const contents = await fs.readFile(hookPath, 'utf-8');
351
+ if (hookHasUncommented(contents, commandMatcher(p.verifyCommand))) {
352
+ return { check, status: 'ok', detail: `${p.dir}/pre-push runs \`${p.verifyCommand}\`` };
353
+ }
354
+ return {
355
+ check,
356
+ status: 'drift',
357
+ detail: `${p.dir}/pre-push exists but does not run \`${p.verifyCommand}\``,
358
+ hint: `Run \`npx @rtorcato/repo-tooling fix ${p.fixTarget}\` to align the hook with \`${p.verifyCommand}\``,
359
+ };
360
+ }
361
+ export async function checkReadmeBadges(dir, audience,
362
+ /** `fix` target that rebuilds the badge block, or null when the language has none. */
363
+ fixTarget) {
364
+ const check = 'README badges';
365
+ const hint = fixTarget
366
+ ? `Run \`npx @rtorcato/repo-tooling fix ${fixTarget}\` to add CI/npm/coverage/license badges`
367
+ : 'Add CI and license badges to README.md';
368
+ const readmePath = path.join(dir, 'README.md');
369
+ const readme = (await fs.pathExists(readmePath)) ? await fs.readFile(readmePath, 'utf8') : '';
370
+ if (audience === 'private') {
371
+ // Only a problem if a private/app repo carries badges that would 404.
372
+ if (readme && hasPublicOnlyBadges(readme)) {
373
+ return {
374
+ check,
375
+ status: 'drift',
376
+ detail: 'README has npm/coverage badges but the package is private (they 404)',
377
+ hint: fixTarget
378
+ ? `Run \`npx @rtorcato/repo-tooling fix ${fixTarget}\` to rebuild badges for a private repo`
379
+ : 'Remove the npm/coverage badges — they 404 for a private package',
380
+ };
381
+ }
382
+ return { check, status: 'ok', detail: 'not applicable (private package)' };
383
+ }
384
+ if (audience === 'not-applicable') {
385
+ return { check, status: 'ok', detail: 'not applicable (no published exports)' };
386
+ }
387
+ const hasBadges = readme.includes(BADGE_START) ||
388
+ /img\.shields\.io|badge\.svg|badge\.fury\.io|codecov\.io/.test(readme);
389
+ if (hasBadges) {
390
+ return { check, status: 'ok', detail: 'README carries status badges' };
391
+ }
392
+ return { check, status: 'optional-missing', detail: 'no status badges in README', hint };
393
+ }
394
+ export async function checkGitLabCI(dir) {
395
+ for (const candidate of ['.gitlab-ci.yml', '.gitlab-ci.yaml']) {
396
+ if (await fs.pathExists(path.join(dir, candidate))) {
397
+ return {
398
+ check: 'GitLab CI',
399
+ status: 'ok',
400
+ detail: `${candidate} found`,
401
+ };
402
+ }
403
+ }
404
+ return {
405
+ check: 'GitLab CI',
406
+ status: 'optional-missing',
407
+ detail: 'no .gitlab-ci.yml',
408
+ hint: 'Run `npx @rtorcato/repo-tooling fix gitlab-ci` to scaffold a starter GitLab pipeline',
409
+ };
410
+ }
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Language-agnostic CI skeletons (#283).
3
+ *
4
+ * The *shell* of a pipeline — triggers, concurrency, the skip gate, job
5
+ * scaffolding, stage derivation — is identical whether the repo is JS, Swift,
6
+ * Perl or Python. Only the steps inside each job differ. This module owns the
7
+ * shell; language modules (`src/languages/<id>/ci.ts`) supply the steps.
8
+ *
9
+ * Steps arrive pre-rendered rather than as a YAML AST: the generators' whole
10
+ * job is emitting text, and a tree we'd immediately flatten buys nothing but a
11
+ * serializer to maintain.
12
+ */
13
+ /** Every job is gated on the skip-CI check; extra conditions are ANDed on. */
14
+ const SKIP_GUARD = "needs.check-skip.outputs.should-skip != 'true'";
15
+ /** Header through `jobs:` — triggers and concurrency are language-independent. */
16
+ const WORKFLOW_HEADER = `name: 🚀 CI/CD Pipeline
17
+
18
+ on:
19
+ push:
20
+ branches: [main, release, develop]
21
+ pull_request:
22
+ branches: [main]
23
+ workflow_dispatch:
24
+
25
+ concurrency:
26
+ group: \${{ github.workflow }}-\${{ github.ref }}
27
+ cancel-in-progress: true
28
+
29
+ jobs:
30
+ `;
31
+ /**
32
+ * Honours `[ci skip]` / `[skip ci]` in the head commit message. Pure shell
33
+ * plumbing — no toolchain involved, so every language shares it verbatim.
34
+ */
35
+ const CHECK_SKIP_JOB = ` check-skip:
36
+ runs-on: ubuntu-latest
37
+ outputs:
38
+ should-skip: \${{ steps.skip-check.outputs.should-skip }}
39
+ steps:
40
+ - name: Check for skip CI
41
+ id: skip-check
42
+ run: |
43
+ if [[ "\${{ github.event.head_commit.message }}" =~ \\[(ci skip|skip ci)\\] ]]; then
44
+ echo "should-skip=true" >> $GITHUB_OUTPUT
45
+ else
46
+ echo "should-skip=false" >> $GITHUB_OUTPUT
47
+ fi`;
48
+ function renderJob(job) {
49
+ // A lone dependency stays a scalar (`needs: check-skip`) — that's the form
50
+ // the hand-written workflows used, and Actions treats both identically.
51
+ const needs = [...(job.needs ?? []), 'check-skip'];
52
+ const needsYaml = needs.length === 1 ? needs[0] : `[${needs.join(', ')}]`;
53
+ const condition = job.if ? `${SKIP_GUARD} && ${job.if}` : SKIP_GUARD;
54
+ return ` ${job.id}:
55
+ runs-on: ${job.runsOn ?? 'ubuntu-latest'}
56
+ needs: ${needsYaml}
57
+ if: ${condition}
58
+ ${job.extra ? `${job.extra}\n` : ''} steps:
59
+ ${job.steps}`;
60
+ }
61
+ /** Wrap language-supplied jobs in the shared workflow shell. */
62
+ export function renderGitHubWorkflow(jobs) {
63
+ return `${WORKFLOW_HEADER}${[CHECK_SKIP_JOB, ...jobs.map(renderJob)].join('\n\n')}\n`;
64
+ }
65
+ /**
66
+ * The CodeQL workflow. Only the matrix languages vary, so the whole file lives
67
+ * here and each language module contributes its `codeqlLanguages`.
68
+ */
69
+ export function renderCodeQLWorkflow(languages) {
70
+ return `name: CodeQL
71
+
72
+ on:
73
+ push:
74
+ branches: [main]
75
+ pull_request:
76
+ branches: [main]
77
+ schedule:
78
+ - cron: '0 6 * * 1'
79
+
80
+ jobs:
81
+ analyze:
82
+ name: Analyze
83
+ runs-on: ubuntu-latest
84
+ permissions:
85
+ actions: read
86
+ contents: read
87
+ security-events: write
88
+
89
+ strategy:
90
+ fail-fast: false
91
+ matrix:
92
+ language: [${languages.join(', ')}]
93
+
94
+ steps:
95
+ - name: Checkout
96
+ uses: actions/checkout@v7
97
+
98
+ - name: Initialize CodeQL
99
+ uses: github/codeql-action/init@v3
100
+ with:
101
+ languages: \${{ matrix.language }}
102
+
103
+ - name: Perform CodeQL Analysis
104
+ uses: github/codeql-action/analyze@v3
105
+ with:
106
+ category: "/language:\${{ matrix.language }}"
107
+ `;
108
+ }
109
+ /**
110
+ * Wrap language-supplied jobs in the shared `.gitlab-ci.yml` shell. Stages are
111
+ * derived from the jobs in first-appearance order, so a language module never
112
+ * has to keep a stage list in sync with the jobs it emits.
113
+ */
114
+ export function renderGitLabCI({ image, preamble, jobs }) {
115
+ const stages = [...new Set(jobs.map((job) => job.stage))];
116
+ // A pipeline with no jobs still needs a valid `stages:` key.
117
+ if (stages.length === 0)
118
+ stages.push('test');
119
+ const blocks = jobs.map((job) => `${job.id}:
120
+ stage: ${job.stage}
121
+ script:
122
+ ${job.script.map((line) => ` - ${line}`).join('\n')}${job.extra ? `\n${job.extra}` : ''}`);
123
+ return `# .gitlab-ci.yml — generated by @rtorcato/repo-tooling
124
+ # Customize stages and jobs to fit your pipeline.
125
+
126
+ image: ${image}
127
+
128
+ stages:
129
+ ${stages.map((s) => ` - ${s}`).join('\n')}
130
+
131
+ ${[preamble, ...blocks].join('\n\n')}
132
+ `;
133
+ }