diff --git a/README.md b/README.md index 853ca1eb..200ff669 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@

Create TypeScript App

-

Quickstart-friendly TypeScript template with comprehensive, configurable, opinionated tooling. ๐Ÿ’

+

Quickstart-friendly TypeScript template with comprehensive, configurable, opinionated tooling. โค๏ธโ€๐Ÿ”ฅ

diff --git a/cspell.json b/cspell.json index 407cbdf6..d1714a77 100644 --- a/cspell.json +++ b/cspell.json @@ -12,10 +12,12 @@ ], "words": [ "allcontributors", + "Anson", "apexskier", "arethetypeswrong", "automerge", "codespace", + "dbaeumer", "execa", "infile", "joshuakgoldberg", @@ -23,6 +25,7 @@ "mtfoley", "outro", "tada", - "tseslint" + "tseslint", + "wontfix" ] } diff --git a/eslint.config.js b/eslint.config.js index fbc683f1..648b4ee1 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -97,9 +97,7 @@ export default tseslint.config( { extends: [vitest.configs.recommended], files: ["**/*.test.*"], - rules: { - "@typescript-eslint/no-unsafe-assignment": "off", - }, + rules: { "@typescript-eslint/no-unsafe-assignment": "off" }, }, { extends: [yml.configs["flat/recommended"], yml.configs["flat/prettier"]], diff --git a/knip.json b/knip.json index 5f263db2..02ce57e1 100644 --- a/knip.json +++ b/knip.json @@ -1,6 +1,6 @@ { "$schema": "https://unpkg.com/knip@latest/schema.json", - "entry": ["src/index.ts!", "script/*e2e.js"], + "entry": ["script/*e2e.js", "src/index.ts!", "src/**/*.test.*"], "ignoreExportsUsedInFile": { "interface": true, "type": true }, "project": ["src/**/*.ts!", "script/**/*.js"] } diff --git a/package.json b/package.json index 6d249993..16627e70 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "create-typescript-app", "version": "1.75.0", - "description": "Quickstart-friendly TypeScript template with comprehensive, configurable, opinionated tooling. ๐Ÿ’", + "description": "Quickstart-friendly TypeScript template with comprehensive, configurable, opinionated tooling. โค๏ธโ€๐Ÿ”ฅ", "repository": { "type": "git", "url": "https://github.com/JoshuaKGoldberg/create-typescript-app" @@ -45,6 +45,7 @@ "@prettier/sync": "^0.5.2", "all-contributors-for-repository": "^0.3.0", "chalk": "^5.3.0", + "create": "0.1.0-alpha.0", "execa": "^9.5.1", "get-github-auth-token": "^0.1.0", "git-remote-origin-url": "^4.0.0", @@ -54,6 +55,7 @@ "npm-user": "^6.1.1", "octokit": "^4.0.2", "parse-author": "^2.0.0", + "parse-package-name": "^1.0.0", "prettier": "^3.4.1", "replace-in-file": "^8.2.0", "rimraf": "^6.0.1", @@ -76,6 +78,7 @@ "@vitest/eslint-plugin": "1.1.14", "c8": "10.1.2", "console-fail-test": "0.5.0", + "create-testers": "0.1.0-alpha.0", "cspell": "8.16.1", "eslint": "9.16.0", "eslint-plugin-jsdoc": "50.6.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 75d87811..0f2e47a7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,6 +20,9 @@ importers: chalk: specifier: ^5.3.0 version: 5.3.0 + create: + specifier: 0.1.0-alpha.0 + version: 0.1.0-alpha.0 execa: specifier: ^9.5.1 version: 9.5.1 @@ -47,6 +50,9 @@ importers: parse-author: specifier: ^2.0.0 version: 2.0.0 + parse-package-name: + specifier: ^1.0.0 + version: 1.0.0 prettier: specifier: ^3.4.1 version: 3.4.1 @@ -108,6 +114,9 @@ importers: console-fail-test: specifier: 0.5.0 version: 0.5.0 + create-testers: + specifier: 0.1.0-alpha.0 + version: 0.1.0-alpha.0(create@0.1.0-alpha.0) cspell: specifier: 8.16.1 version: 8.16.1 @@ -1868,6 +1877,17 @@ packages: typescript: optional: true + create-testers@0.1.0-alpha.0: + resolution: {integrity: sha512-SZdBwCHlBCoOkBGh6NLpNiFAQipphQb7a4CvYPdpDxSoBHv3w99W2NNQMSZjFIdGma9qgBeMthMHW6Z0u/+5vA==} + engines: {node: '>=18'} + peerDependencies: + create: 0.1.0-alpha.0 + + create@0.1.0-alpha.0: + resolution: {integrity: sha512-OqEAIZHN6P53uufGrm8Vmxe0rWghvAAI+6bhpL5fOG2pQ+jfvwHUulyKOpdzxHee4J40zu2KzG0hb14au5dFZw==} + engines: {node: '>=18'} + hasBin: true + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -3159,6 +3179,9 @@ packages: resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==} engines: {node: '>=18'} + parse-package-name@1.0.0: + resolution: {integrity: sha512-kBeTUtcj+SkyfaW4+KBe0HtsloBJ/mKTPoxpVdA57GZiPerREsUWJOhVj9anXweFiJkm5y8FG1sxFZkZ0SN6wg==} + parse-path@7.0.0: resolution: {integrity: sha512-Euf9GG8WT9CdqwuWJGdf3RkUcTBArppHABkO7Lm8IzRQp0e2r/kkFnmhu4TSK30Wcu5rVAZLmfPKSBBi9tWFog==} @@ -5540,6 +5563,15 @@ snapshots: optionalDependencies: typescript: 5.7.2 + create-testers@0.1.0-alpha.0(create@0.1.0-alpha.0): + dependencies: + create: 0.1.0-alpha.0 + + create@0.1.0-alpha.0: + dependencies: + execa: 9.5.1 + zod: 3.23.8 + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -7058,6 +7090,8 @@ snapshots: parse-ms@4.0.0: {} + parse-package-name@1.0.0: {} + parse-path@7.0.0: dependencies: protocols: 2.0.1 diff --git a/script/__snapshots__/migrate-test-e2e.ts.snap b/script/__snapshots__/migrate-test-e2e.ts.snap index 1d49e6d2..18a6dbc6 100644 --- a/script/__snapshots__/migrate-test-e2e.ts.snap +++ b/script/__snapshots__/migrate-test-e2e.ts.snap @@ -114,42 +114,6 @@ exports[`expected file changes > README.md 1`] = ` +> ๐Ÿ’™ This package was templated with [\`create-typescript-app\`](https://github.com/JoshuaKGoldberg/create-typescript-app)." `; -exports[`expected file changes > cspell.json 1`] = ` -"--- a/cspell.json -+++ b/cspell.json -@@ ... @@ - ".all-contributorsrc", - ".github", - "CHANGELOG.md", -- "coverage*", -+ "coverage", - "lib", - "node_modules", -- "pnpm-lock.yaml", -- "script/__snapshots__" -+ "pnpm-lock.yaml" - ], - "words": [ - "allcontributors", -@@ ... @@ - "arethetypeswrong", - "automerge", - "codespace", -- "execa", -+ "contributorsrc", - "infile", - "joshuakgoldberg", - "markdownlintignore", - "mtfoley", - "outro", - "tada", -- "tseslint" -+ "tseslint", -+ "wontfix" - ] - }" -`; - exports[`expected file changes > eslint.config.js 1`] = ` "--- a/eslint.config.js +++ b/eslint.config.js @@ -238,7 +202,7 @@ exports[`expected file changes > knip.json 1`] = ` @@ ... @@ { "$schema": "https://unpkg.com/knip@latest/schema.json", -- "entry": ["src/index.ts!", "script/*e2e.js"], +- "entry": ["script/*e2e.js", "src/index.ts!", "src/**/*.test.*"], + "entry": ["src/index.ts!"], "ignoreExportsUsedInFile": { "interface": true, "type": true }, - "project": ["src/**/*.ts!", "script/**/*.js"] @@ -246,6 +210,19 @@ exports[`expected file changes > knip.json 1`] = ` }" `; +exports[`expected file changes > package.json 1`] = ` +"--- a/package.json ++++ b/package.json +@@ ... @@ + "lint-staged": "15.2.10", + "markdownlint": "0.36.1", + "markdownlint-cli": "0.43.0", ++ "prettier": "^3.4.1", + "prettier-plugin-curly": "0.3.1", + "prettier-plugin-packagejson": "2.5.6", + "prettier-plugin-sh": "0.14.0"," +`; + exports[`expected file changes > tsconfig.json 1`] = ` "--- a/tsconfig.json +++ b/tsconfig.json diff --git a/script/migrate-test-e2e.ts b/script/migrate-test-e2e.ts index 929c5e6f..e713d67b 100644 --- a/script/migrate-test-e2e.ts +++ b/script/migrate-test-e2e.ts @@ -11,12 +11,15 @@ const filesExpectedToBeChanged = [ ".github/workflows/ci.yml", ".gitignore", ".prettierignore", - "cspell.json", "eslint.config.js", + "package.json", "tsconfig.json", ]; const filesThatMightBeChanged = new Set([ + // For now, ignore typos cspell is picking up from migration snapshots. + "cspell.json", + "script/__snapshots__/migrate-test-e2e.ts.snap", ...filesExpectedToBeChanged, ]); diff --git a/src/create/createWithOptions.ts b/src/create/createWithOptions.ts index 2a0407a0..d0848b36 100644 --- a/src/create/createWithOptions.ts +++ b/src/create/createWithOptions.ts @@ -1,8 +1,13 @@ import { $ } from "execa"; -import { withSpinner, withSpinners } from "../shared/cli/spinners.js"; +import { + LabeledSpinnerTask, + withSpinner, + withSpinners, +} from "../shared/cli/spinners.js"; import { createCleanupCommands } from "../shared/createCleanupCommands.js"; import { doesRepositoryExist } from "../shared/doesRepositoryExist.js"; +import { isUsingCreateEngine } from "../shared/isUsingCreateEngine.js"; import { GitHubAndOptions } from "../shared/options/readOptions.js"; import { addToolAllContributors } from "../steps/addToolAllContributors.js"; import { clearLocalGitTags } from "../steps/clearLocalGitTags.js"; @@ -20,12 +25,16 @@ export async function createWithOptions({ github, options }: GitHubAndOptions) { await writeStructure(options); }, ], - [ - "Writing README.md", - async () => { - await writeReadme(options); - }, - ], + ...(isUsingCreateEngine() + ? [] + : [ + [ + "Writing README.md", + async () => { + await writeReadme(options); + }, + ] satisfies LabeledSpinnerTask, + ]), ]); if (!options.excludeAllContributors && !options.skipAllContributorsApi) { diff --git a/src/index.ts b/src/index.ts index a39b40fa..51dd24e4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,2 +1,8 @@ export * from "./greet.js"; + +// If you're using create-typescript-app as a template, ignore these. +// They're plumbing for the create engine. :) +export * from "./next/blocks/index.js"; +export { default } from "./next/template.js"; + export * from "./types.js"; diff --git a/src/next/base.ts b/src/next/base.ts new file mode 100644 index 00000000..9d0d5345 --- /dev/null +++ b/src/next/base.ts @@ -0,0 +1,175 @@ +import { BaseOptionsFor, createBase } from "create"; +import { execaCommand } from "execa"; +import gitRemoteOriginUrl from "git-remote-origin-url"; +import gitUrlParse from "git-url-parse"; +import lazyValue from "lazy-value"; +import npmUser from "npm-user"; +import { z } from "zod"; + +import { parsePackageAuthor } from "../shared/options/createOptionDefaults/parsePackageAuthor.js"; +import { readDefaultsFromReadme } from "../shared/options/createOptionDefaults/readDefaultsFromReadme.js"; +import { readEmails } from "../shared/options/createOptionDefaults/readEmails.js"; +import { readFunding } from "../shared/options/createOptionDefaults/readFunding.js"; +import { readGuide } from "../shared/options/createOptionDefaults/readGuide.js"; +import { readPackageData } from "../shared/packages.js"; +import { tryCatchLazyValueAsync } from "../shared/tryCatchLazyValueAsync.js"; +import { AllContributorsData } from "../shared/types.js"; +import { inputJSONFile } from "./inputs/inputJSONFile.js"; +import { inputTextFile } from "./inputs/inputTextFile.js"; + +export const base = createBase({ + options: { + access: z.union([z.literal("public"), z.literal("restricted")]).optional(), + author: z.string().optional(), + bin: z.string().optional(), + contributors: z + .array( + z.object({ + avatar_url: z.string(), + contributions: z.array(z.string()), + login: z.string(), + name: z.string(), + profile: z.string(), + }), + ) + .optional(), + description: z.string(), + documentation: z.string().optional(), + email: z + .union([ + z.string(), + z.object({ + github: z.string(), + npm: z.string(), + }), + ]) + .transform((email) => + typeof email === "string" ? { github: email, npm: email } : email, + ), + funding: z.string().optional(), + guide: z + .object({ + href: z.string(), + title: z.string(), + }) + .optional(), + hideTemplatedBy: z.boolean().optional(), + keywords: z.array(z.string()).optional(), + login: z.string().optional(), + logo: z + .object({ + alt: z.string(), + src: z.string(), + }) + .optional(), + node: z + .object({ + minimum: z.string(), + pinned: z.string().optional(), + }) + .optional(), + owner: z.string(), + packageData: z + .object({ + dependencies: z.record(z.string(), z.string()).optional(), + devDependencies: z.record(z.string(), z.string()).optional(), + scripts: z.record(z.string(), z.string()).optional(), + }) + .optional(), + preserveGeneratedFrom: z.boolean().optional(), + repository: z.string(), + title: z.string(), + version: z.string().optional(), + }, + produce({ options, take }) { + const allContributors = lazyValue(async () => { + const contributions = (await take(inputJSONFile, { + filePath: ".all-contributorsrc", + })) as AllContributorsData | undefined; + + return contributions?.contributors; + }); + + const documentation = lazyValue( + async () => + await take(inputTextFile, { + filePath: ".github/DEVELOPMENT.md", + }), + ); + + const nvmrc = lazyValue( + async () => + await take(inputTextFile, { + filePath: ".nvmrc", + }), + ); + + // TODO: Make these all use take + + const gitDefaults = tryCatchLazyValueAsync(async () => + gitUrlParse(await gitRemoteOriginUrl()), + ); + + const npmDefaults = tryCatchLazyValueAsync(async () => { + const whoami = (await execaCommand(`npm whoami`)).stdout; + return whoami ? await npmUser(whoami) : undefined; + }); + + const packageData = lazyValue(readPackageData); + const packageAuthor = lazyValue(async () => + parsePackageAuthor(await packageData()), + ); + + const author = lazyValue(async () => + ( + (await packageAuthor()).author ?? + (await npmDefaults())?.name ?? + options.owner + )?.toLowerCase(), + ); + + const node = lazyValue(async () => { + const { engines } = await packageData(); + + return { + minimum: + (engines?.node && /[\d+.]+/.exec(engines.node))?.[0] ?? "18.3.0", + pinned: (await nvmrc()) ?? "20.18.0", + }; + }); + + const version = lazyValue(async () => (await packageData()).version); + + return { + author, + bin: async () => (await packageData()).bin, + contributors: allContributors, + description: async () => (await packageData()).description, + documentation, + email: async () => readEmails(npmDefaults, packageAuthor), + funding: readFunding, + guide: readGuide, + login: author, + node, + owner: async () => + (await gitDefaults())?.organization ?? (await packageAuthor()).author, + packageData: async () => { + const original = await packageData(); + + return { + dependencies: original.dependencies, + devDependencies: original.devDependencies, + scripts: original.scripts, + }; + }, + repository: async () => + options.repository ?? + (await gitDefaults())?.name ?? + (await packageData()).name, + ...readDefaultsFromReadme(), + version, + }; + }, +}); + +export type BaseOptions = BaseOptionsFor; diff --git a/src/next/blocks/blockAllContributors.ts b/src/next/blocks/blockAllContributors.ts new file mode 100644 index 00000000..33b0dbf8 --- /dev/null +++ b/src/next/blocks/blockAllContributors.ts @@ -0,0 +1,60 @@ +import { createSoloWorkflowFile } from "../../steps/writing/creation/dotGitHub/createSoloWorkflowFile.js"; +import { base } from "../base.js"; +import { blockPrettier } from "./blockPrettier.js"; + +export const blockAllContributors = base.createBlock({ + about: { + name: "AllContributors", + }, + produce({ options }) { + return { + addons: [ + blockPrettier({ + ignores: ["/.all-contributorsrc"], + }), + ], + commands: + options.login === "JoshuaKGoldberg" + ? [`npx -y all-contributors-cli add JoshuaKGoldberg tool`] + : undefined, + files: { + ".all-contributorsrc": JSON.stringify({ + badgeTemplate: + ' ๐Ÿ‘ช All Contributors: <%= contributors.length %>', + commit: false, + commitConvention: "angular", + commitType: "docs", + contributors: options.contributors ?? [], + contributorsPerLine: 7, + contributorsSortAlphabetically: true, + files: ["README.md"], + imageSize: 100, + projectName: options.repository, + projectOwner: options.owner, + repoHost: "https://github.com", + repoType: "github", + }), + ".github": { + workflows: { + "contributors.yml": createSoloWorkflowFile({ + name: "Contributors", + on: { + push: { + branches: ["main"], + }, + }, + steps: [ + { uses: "actions/checkout@v4", with: { "fetch-depth": 0 } }, + { uses: "./.github/actions/prepare" }, + { + env: { GITHUB_TOKEN: "${{ secrets.ACCESS_TOKEN }}" }, + uses: `JoshuaKGoldberg/all-contributors-auto-action@v0.5.0`, + }, + ], + }), + }, + }, + }, + }; + }, +}); diff --git a/src/next/blocks/blockAreTheTypesWrong.ts b/src/next/blocks/blockAreTheTypesWrong.ts new file mode 100644 index 00000000..79b32105 --- /dev/null +++ b/src/next/blocks/blockAreTheTypesWrong.ts @@ -0,0 +1,26 @@ +import { base } from "../base.js"; +import { blockGitHubActionsCI } from "./blockGitHubActionsCI.js"; + +export const blockAreTheTypesWrong = base.createBlock({ + about: { + name: "README.md", + }, + produce() { + return { + addons: [ + blockGitHubActionsCI({ + jobs: [ + { + name: "Are The Types Wrong?", + steps: [ + { + run: "npx --yes @arethetypeswrong/cli --pack . --ignore-rules cjs-resolves-to-esm", + }, + ], + }, + ], + }), + ], + }; + }, +}); diff --git a/src/next/blocks/blockCSpell.ts b/src/next/blocks/blockCSpell.ts new file mode 100644 index 00000000..b7ce0ffb --- /dev/null +++ b/src/next/blocks/blockCSpell.ts @@ -0,0 +1,76 @@ +import { z } from "zod"; + +import { base } from "../base.js"; +import { blockDevelopmentDocs } from "./blockDevelopmentDocs.js"; +import { blockGitHubActionsCI } from "./blockGitHubActionsCI.js"; +import { blockPackageJson } from "./blockPackageJson.js"; +import { blockVSCode } from "./blockVSCode.js"; +import { getPackageDependencies } from "./packageData.js"; + +export const blockCSpell = base.createBlock({ + about: { + name: "CSpell", + }, + addons: { + ignores: z.array(z.string()).default([]), + words: z.array(z.string()).default([]), + }, + produce({ addons }) { + const { ignores, words } = addons; + + return { + addons: [ + blockDevelopmentDocs({ + sections: { + Linting: { + contents: { + items: [ + `- \`pnpm lint:spelling\` ([cspell](https://cspell.org)): Spell checks across all source files`, + ], + }, + }, + }, + }), + blockVSCode({ + extensions: ["streetsidesoftware.code-spell-checker"], + }), + blockGitHubActionsCI({ + jobs: [ + { + name: "Lint Spelling", + steps: [{ run: "pnpm lint:spelling" }], + }, + ], + }), + blockPackageJson({ + properties: { + devDependencies: getPackageDependencies("cspell"), + scripts: { + "lint:spelling": 'cspell "**" ".github/**/*"', + }, + }, + }), + ], + files: { + "cspell.json": JSON.stringify({ + dictionaries: ["npm", "node", "typescript"], + ignorePaths: [ + ".github", + "CHANGELOG.md", + "lib", + "node_modules", + "pnpm-lock.yaml", + ...ignores, + ].sort(), + ...(words.length && { words: words.sort() }), + }), + }, + package: { + devDependencies: getPackageDependencies("cspell"), + scripts: { + "lint:spelling": 'cspell "**" ".github/**/*"', + }, + }, + }; + }, +}); diff --git a/src/next/blocks/blockContributingDocs.ts b/src/next/blocks/blockContributingDocs.ts new file mode 100644 index 00000000..a5290571 --- /dev/null +++ b/src/next/blocks/blockContributingDocs.ts @@ -0,0 +1,113 @@ +import { base } from "../base.js"; + +export const blockContributingDocs = base.createBlock({ + about: { + name: "Contributing Docs", + }, + produce({ options }) { + return { + files: { + ".github": { + "CONTRIBUTING.md": `# Contributing + +Thanks for your interest in contributing to \`${options.repository}\`! ๐Ÿ’– + +> After this page, see [DEVELOPMENT.md](./DEVELOPMENT.md) for local development instructions. + +## Code of Conduct + +This project contains a [Contributor Covenant code of conduct](./CODE_OF_CONDUCT.md) all contributors are expected to follow. + +## Reporting Issues + +Please do [report an issue on the issue tracker](https://github.com/${options.owner}/${options.repository}/issues/new/choose) if there's any bugfix, documentation improvement, or general enhancement you'd like to see in the repository! Please fully fill out all required fields in the most appropriate issue form. + +## Sending Contributions + +Sending your own changes as contribution is always appreciated! +There are two steps involved: + +1. [Finding an Issue](#finding-an-issue) +2. [Sending a Pull Request](#sending-a-pull-request) + +### Finding an Issue + +With the exception of very small typos, all changes to this repository generally need to correspond to an [unassigned open issue marked as \`status: accepting prs\` on the issue tracker](https://github.com/${options.owner}/${options.repository}/issues?q=is%3Aissue+is%3Aopen+label%3A%22status%3A+accepting+prs%22+no%3Aassignee+). +If this is your first time contributing, consider searching for [unassigned issues that also have the \`good first issue\` label](https://github.com/${options.owner}/${options.repository}/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22+label%3A%22status%3A+accepting+prs%22+no%3Aassignee+). +If the issue you'd like to fix isn't found on the issue, see [Reporting Issues](#reporting-issues) for filing your own (please do!). + +#### Issue Claiming + +We don't use any kind of issue claiming system. +We've found in the past that they result in accidental ["licked cookie"](https://devblogs.microsoft.com/oldnewthing/20091201-00/?p=15843) situations where contributors claim an issue but run out of time or energy trying before sending a PR. + +If an unassigned issue has been marked as \`status: accepting prs\` and an open PR does not exist, feel free to send a PR. +Please don't post comments asking for permission or stating you will work on an issue. + +### Sending a Pull Request + +Once you've identified an open issue accepting PRs that doesn't yet have a PR sent, you're free to send a pull request. +Be sure to fill out the pull request template's requested information -- otherwise your PR will likely be closed. + +PRs are also expected to have a title that adheres to [conventional commits](https://www.conventionalcommits.org/en/v1.0.0). +Only PR titles need to be in that format, not individual commits. +Don't worry if you get this wrong: you can always change the PR title after sending it. +Check [previously merged PRs](https://github.com/${options.owner}/${options.repository}/pulls?q=is%3Apr+is%3Amerged+-label%3Adependencies+) for reference. + +#### Draft PRs + +If you don't think your PR is ready for review, [set it as a draft](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/changing-the-stage-of-a-pull-request#converting-a-pull-request-to-a-draft). +Draft PRs won't be reviewed. + +#### Granular PRs + +Please keep pull requests single-purpose: in other words, don't attempt to solve multiple unrelated problems in one pull request. +Send one PR per area of concern. +Multi-purpose pull requests are harder and slower to review, block all changes from being merged until the whole pull request is reviewed, and are difficult to name well with semantic PR titles. + +#### Pull Request Reviews + +When a PR is not in draft, it's considered ready for review. +Please don't manually \`@\` tag anybody to request review. +A maintainer will look at it when they're next able to. + +PRs should have passing [GitHub status checks](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/collaborating-on-repositories-with-code-quality-features/about-status-checks) before review is requested (unless there are explicit questions asked in the PR about any failures). + +#### Asking Questions + +If you need help and/or have a question, posting a comment in the PR is a great way to do so. +There's no need to tag anybody individually. +One of us will drop by and help when we can. + +Please post comments as [line comments](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/reviewing-changes-in-pull-requests/commenting-on-a-pull-request#adding-line-comments-to-a-pull-request) when possible, so that they can be threaded. +You can [resolve conversations](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/reviewing-changes-in-pull-requests/commenting-on-a-pull-request#resolving-conversations) on your own when you feel they're resolved - no need to comment explicitly and/or wait for a maintainer. + +#### Requested Changes + +After a maintainer reviews your PR, they may request changes on it. +Once you've made those changes, [re-request review on GitHub](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/reviewing-changes-in-pull-requests/about-pull-request-reviews#re-requesting-a-review). + +Please try not to force-push commits to PRs that have already been reviewed. +Doing so makes it harder to review the changes. +We squash merge all commits so there's no need to try to preserve Git history within a PR branch. + +Once you've addressed all our feedback by making code changes and/or started a followup discussion, [re-request review](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/reviewing-changes-in-pull-requests/about-pull-request-reviews#re-requesting-a-review) from each maintainer whose feedback you addressed. + +Once all feedback is addressed and the PR is approved, we'll ensure the branch is up to date with \`main\` and merge it for you. + +#### Post-Merge Recognition + +Once your PR is merged, if you haven't yet been added to the [_Contributors_ table in the README.md](../README.md#contributors) for its [type of contribution](https://allcontributors.org/docs/en/emoji-key "Allcontributors emoji key"), you should be soon. +Please do ping the maintainer who merged your PR if that doesn't happen within 24 hours - it was likely an oversight on our end! + +## Emojis & Appreciation + +If you made it all the way to the end, bravo dear user, we love you. +Please include your favorite emoji in the bottom of your issues and PRs to signal to us that you did in fact read this file and are trying to conform to it as best as possible. +๐Ÿ’– is a good starter if you're not sure which to use. +`, + }, + }, + }; + }, +}); diff --git a/src/next/blocks/blockContributorCovenant.ts b/src/next/blocks/blockContributorCovenant.ts new file mode 100644 index 00000000..2af703c2 --- /dev/null +++ b/src/next/blocks/blockContributorCovenant.ts @@ -0,0 +1,148 @@ +import { base } from "../base.js"; + +export const blockContributorCovenant = base.createBlock({ + about: { + name: "Contributor Covenant", + }, + produce({ options }) { + return { + files: { + ".github": { + "CODE_OF_CONDUCT.md": `# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, caste, color, religion, or sexual +identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +- Demonstrating empathy and kindness toward other people +- Being respectful of differing opinions, viewpoints, and experiences +- Giving and gracefully accepting constructive feedback +- Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +- Focusing on what is best not just for us as individuals, but for the overall + community + +Examples of unacceptable behavior include: + +- The use of sexualized language or imagery, and sexual attention or advances of +any kind +- Trolling, insulting or derogatory comments, and personal or political attacks +- Public or private harassment +- Publishing others' private information, such as a physical or email address, +without their explicit permission +- Other conduct which could reasonably be considered inappropriate in a +professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official e-mail address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement at +${options.email.github}. +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series of +actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or permanent +ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within the +community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.1, available at +[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. + +Community Impact Guidelines were inspired by +[Mozilla's code of conduct enforcement ladder][mozilla coc]. + +For answers to common questions about this code of conduct, see the FAQ at +[https://www.contributor-covenant.org/faq][faq]. Translations are available at +[https://www.contributor-covenant.org/translations][translations]. + +[homepage]: https://www.contributor-covenant.org +[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html +[mozilla coc]: https://github.com/mozilla/diversity +[faq]: https://www.contributor-covenant.org/faq +[translations]: https://www.contributor-covenant.org/translations +`, + }, + }, + }; + }, +}); diff --git a/src/next/blocks/blockDevelopmentDocs.ts b/src/next/blocks/blockDevelopmentDocs.ts new file mode 100644 index 00000000..597676d9 --- /dev/null +++ b/src/next/blocks/blockDevelopmentDocs.ts @@ -0,0 +1,106 @@ +import { z } from "zod"; + +import { base } from "../base.js"; + +const zInnerSection = z.object({ + contents: z.string(), + heading: z.string(), +}); + +type InnerSection = z.infer; + +function printInnerSection(innerSection: InnerSection) { + return [`### ${innerSection.heading}`, ``, innerSection.contents]; +} + +const zSection = z.object({ + contents: z + .union([ + z.string(), + z.object({ + after: z.array(z.string()).optional(), + before: z.string().optional(), + items: z.array(z.string()).optional(), + plural: z.string().optional(), + }), + ]) + .optional(), + innerSections: z.array(zInnerSection).default([]).optional(), +}); + +type Section = z.infer; + +function printSection(heading: string, section: Section) { + if (typeof section === "string") { + return section; + } + + const innerSections = section.innerSections?.flatMap(printInnerSection) ?? []; + + if (section.contents === undefined) { + return innerSections; + } + + const contents = + typeof section.contents === "string" + ? { before: section.contents } + : section.contents; + + return [ + `## ${heading}`, + ``, + ...(contents.before ? [contents.before] : []), + ...(contents.items?.sort((a, b) => + a.replaceAll("`", "").localeCompare(b.replaceAll("`", "")), + ) ?? []), + ...(contents.items?.length && contents.plural ? [``, contents.plural] : []), + ...(contents.after ?? []), + ...innerSections, + ]; +} + +export const blockDevelopmentDocs = base.createBlock({ + about: { + name: "Development Docs", + }, + addons: { + hints: z.array(z.string()).default([]), + sections: z.record(z.string(), zSection).default({}), + }, + produce({ addons, options }) { + const lines = [ + `# Development`, + ``, + ...(options.guide + ? [ + `> If you'd like a more guided walkthrough, see [${options.guide.title}](${options.guide.href}).`, + `> It'll walk you through the common activities you'll need to contribute.`, + ] + : []), + ``, + `After [forking the repo from GitHub](https://help.github.com/articles/fork-a-repo) and [installing pnpm](https://pnpm.io/installation):`, + ``, + `\`\`\`shell`, + `git clone https://github.com//${options.repository}`, + `cd ${options.repository}`, + `pnpm install`, + `\`\`\``, + ``, + `> This repository includes a list of suggested VS Code extensions.`, + `> It's a good idea to use [VS Code](https://code.visualstudio.com) and accept its suggestion to install them, as they'll help with development.`, + ``, + ...Object.entries(addons.sections) + .sort(([a], [b]) => a.localeCompare(b)) + .flatMap(([heading, section]) => printSection(heading, section)), + ...(options.documentation ? [options.documentation] : []), + ]; + + return { + files: { + ".github": { + "DEVELOPMENT.md": lines.join("\n"), + }, + }, + }; + }, +}); diff --git a/src/next/blocks/blockESLint.ts b/src/next/blocks/blockESLint.ts new file mode 100644 index 00000000..2ef4c168 --- /dev/null +++ b/src/next/blocks/blockESLint.ts @@ -0,0 +1,245 @@ +// @ts-expect-error -- https://github.com/egoist/parse-package-name/issues/30 +import { parse as parsePackageName } from "parse-package-name"; +import { z } from "zod"; + +import { base } from "../base.js"; +import { blockCSpell } from "./blockCSpell.js"; +import { blockDevelopmentDocs } from "./blockDevelopmentDocs.js"; +import { blockGitHubActionsCI } from "./blockGitHubActionsCI.js"; +import { blockPackageJson } from "./blockPackageJson.js"; +import { blockVSCode } from "./blockVSCode.js"; +import { getPackageDependencies } from "./packageData.js"; + +const zRuleOptions = z.union([ + z.literal("error"), + z.literal("off"), + z.literal("warn"), + z.union([ + z.tuple([z.union([z.literal("error"), z.literal("warn")]), z.unknown()]), + z.tuple([ + z.union([z.literal("error"), z.literal("warn")]), + z.unknown(), + z.unknown(), + ]), + ]), +]); + +const zExtensionRules = z.union([ + z.record(z.string(), zRuleOptions), + z.array( + z.object({ + comment: z.string().optional(), + entries: z.record(z.string(), zRuleOptions), + }), + ), +]); + +const zExtension = z.object({ + extends: z.array(z.string()).optional(), + files: z.array(z.string()).optional(), + languageOptions: z.unknown().optional(), + linterOptions: z.unknown().optional(), + plugins: z.record(z.string(), z.string()).optional(), + rules: zExtensionRules.optional(), + settings: z.record(z.string(), z.unknown()).optional(), +}); + +const zPackageImport = z.object({ + source: z.string(), + specifier: z.string(), + types: z.boolean().optional(), +}); + +export const blockESLint = base.createBlock({ + about: { + name: "ESLint", + }, + addons: { + beforeLint: z.string().optional(), + extensions: z.array(z.union([z.string(), zExtension])).default([]), + ignores: z.array(z.string()).default([]), + imports: z.array(zPackageImport).default([]), + rules: zExtensionRules.optional(), + settings: z.record(z.string(), z.unknown()).optional(), + }, + produce({ addons, options }) { + const { extensions, ignores, imports, rules, settings } = addons; + + const importLines = [ + 'import eslint from "@eslint/js";', + 'import tseslint from "typescript-eslint";', + ...imports.map( + (packageImport) => + `import ${packageImport.specifier} from "${packageImport.source}"`, + ), + ].sort((a, b) => + a.replace(/.+from/, "").localeCompare(b.replace(/.+from/, "")), + ); + + const ignoreLines = ["lib", "node_modules", "pnpm-lock.yaml", ...ignores] + .map((ignore) => JSON.stringify(ignore)) + .sort(); + + const extensionLines = [ + printExtension({ + extends: [ + "tseslint.configs.strictTypeChecked", + "tseslint.configs.stylisticTypeChecked", + ], + files: ["**/*.js", "**/*.ts"], + languageOptions: { + parserOptions: { + projectService: { + allowDefaultProject: ["*.config.*s"], + }, + tsconfigRootDir: "import.meta.dirname", + }, + }, + ...(rules && { rules }), + ...(settings && { settings }), + }), + ...extensions.map((extension) => + typeof extension === "string" ? extension : printExtension(extension), + ), + ] + .sort((a, b) => processForSort(a).localeCompare(processForSort(b))) + .map((t) => t); + + return { + addons: [ + blockCSpell({ + words: ["tseslint"], + }), + blockDevelopmentDocs({ + sections: { + Linting: { + contents: { + after: [ + ` +For example, ESLint can be run with \`--fix\` to auto-fix some lint rule complaints: + +\`\`\`shell +pnpm run lint --fix +\`\`\` +`, + ...(addons.beforeLint ? [addons.beforeLint] : []), + ], + before: ` +This package includes several forms of linting to enforce consistent code quality and styling. +Each should be shown in VS Code, and can be run manually on the command-line: +`, + items: [ + `- \`pnpm lint\` ([ESLint](https://eslint.org) with [typescript-eslint](https://typescript-eslint.io)): Lints JavaScript and TypeScript source files`, + ], + plural: `Read the individual documentation for each linter to understand how it can be configured and used best.`, + }, + }, + }, + }), + blockGitHubActionsCI({ + jobs: [ + { + name: "Lint", + steps: [ + ...(options.bin ? [{ run: "pnpm build" }] : []), + { run: "pnpm lint" }, + ], + }, + ], + }), + blockPackageJson({ + properties: { + devDependencies: getPackageDependencies( + "@eslint/js", + "@types/node", + "eslint", + "typescript-eslint", + ...imports.flatMap(({ source, types }) => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-call -- https://github.com/egoist/parse-package-name/issues/30 + const { name } = parsePackageName(source) as { name: string }; + return types ? [name, `@types/${name}`] : [name]; + }), + ), + scripts: { + lint: "eslint . --max-warnings 0", + }, + }, + }), + blockVSCode({ + extensions: ["dbaeumer.vscode-eslint"], + settings: { + "editor.codeActionsOnSave": { + "source.fixAll.eslint": "explicit", + }, + "eslint.probe": [ + "javascript", + "javascriptreact", + "json", + "jsonc", + "markdown", + "typescript", + "typescriptreact", + "yaml", + ], + "eslint.rules.customizations": [{ rule: "*", severity: "warn" }], + }, + }), + ], + files: { + "eslint.config.js": `${importLines.join("\n")} + +export default tseslint.config( + { ignores: [${ignoreLines.join(", ")}] }, + ${printExtension({ + linterOptions: { reportUnusedDisableDirectives: "error" }, + })}, + eslint.configs.recommended, + ${extensionLines.join(",")} +);`, + }, + }; + }, +}); + +function printExtension(extension: z.infer) { + return [ + "{", + extension.extends && `extends: [${extension.extends.join(", ")}],`, + extension.files && + `files: [${extension.files.map((glob) => JSON.stringify(glob)).join(", ")}],`, + extension.languageOptions && + `languageOptions: ${JSON.stringify(extension.languageOptions).replace('"import.meta.dirname"', "import.meta.dirname")},`, + extension.linterOptions && + `linterOptions: ${JSON.stringify(extension.linterOptions)}`, + extension.rules && `rules: ${printExtensionRules(extension.rules)},`, + extension.settings && `settings: ${JSON.stringify(extension.settings)},`, + "}", + ] + .filter(Boolean) + .join(" "); +} + +function printExtensionRules(rules: z.infer) { + if (!Array.isArray(rules)) { + return JSON.stringify(rules); + } + + return [ + "{", + ...rules.flatMap((group) => [ + group.comment ? `// ${group.comment}\n` : "", + ...Object.entries(group.entries).map( + ([ruleName, options]) => `"${ruleName}": ${JSON.stringify(options)},`, + ), + ]), + "}", + ].join(""); +} + +function processForSort(line: string) { + if (line.startsWith("...") || /\w+/.test(line[0])) { + return `A\n${line.replaceAll(/\W+/g, "")}`; + } + + return `B\n${(/files: (.+)/.exec(line)?.[1] ?? line).replaceAll(/\W+/g, "")}`; +} diff --git a/src/next/blocks/blockESLintComments.ts b/src/next/blocks/blockESLintComments.ts new file mode 100644 index 00000000..c8cf6de8 --- /dev/null +++ b/src/next/blocks/blockESLintComments.ts @@ -0,0 +1,23 @@ +import { base } from "../base.js"; +import { blockESLint } from "./blockESLint.js"; + +export const blockESLintComments = base.createBlock({ + about: { + name: "ESLint Comments Plugin", + }, + produce() { + return { + addons: [ + blockESLint({ + extensions: ["comments.recommended"], + imports: [ + { + source: "@eslint-community/eslint-plugin-eslint-comments/configs", + specifier: "comments", + }, + ], + }), + ], + }; + }, +}); diff --git a/src/next/blocks/blockESLintJSDoc.ts b/src/next/blocks/blockESLintJSDoc.ts new file mode 100644 index 00000000..a91a6b3b --- /dev/null +++ b/src/next/blocks/blockESLintJSDoc.ts @@ -0,0 +1,22 @@ +import { base } from "../base.js"; +import { blockESLint } from "./blockESLint.js"; + +export const blockESLintJSDoc = base.createBlock({ + about: { + name: "ESLint JSDoc Plugin", + }, + produce() { + return { + addons: [ + blockESLint({ + extensions: [ + 'jsdoc.configs["flat/contents-typescript-error"]', + 'jsdoc.configs["flat/logical-typescript-error"]', + 'jsdoc.configs["flat/stylistic-typescript-error"]', + ], + imports: [{ source: "eslint-plugin-jsdoc", specifier: "jsdoc" }], + }), + ], + }; + }, +}); diff --git a/src/next/blocks/blockESLintJSONC.ts b/src/next/blocks/blockESLintJSONC.ts new file mode 100644 index 00000000..471294ca --- /dev/null +++ b/src/next/blocks/blockESLintJSONC.ts @@ -0,0 +1,18 @@ +import { base } from "../base.js"; +import { blockESLint } from "./blockESLint.js"; + +export const blockESLintJSONC = base.createBlock({ + about: { + name: "ESLint JSONC Plugin", + }, + produce() { + return { + addons: [ + blockESLint({ + extensions: [`jsonc.configs["flat/recommended-with-json"]`], + imports: [{ source: "eslint-plugin-jsonc", specifier: "jsonc" }], + }), + ], + }; + }, +}); diff --git a/src/next/blocks/blockESLintMarkdown.ts b/src/next/blocks/blockESLintMarkdown.ts new file mode 100644 index 00000000..976f0381 --- /dev/null +++ b/src/next/blocks/blockESLintMarkdown.ts @@ -0,0 +1,24 @@ +import { base } from "../base.js"; +import { blockESLint } from "./blockESLint.js"; + +export const blockESLintMarkdown = base.createBlock({ + about: { + name: "ESLint Markdown Plugin", + }, + produce() { + return { + addons: [ + blockESLint({ + extensions: ["markdown.configs.recommended"], + imports: [ + { + source: "eslint-plugin-markdown", + specifier: "markdown", + types: true, + }, + ], + }), + ], + }; + }, +}); diff --git a/src/next/blocks/blockESLintMoreStyling.ts b/src/next/blocks/blockESLintMoreStyling.ts new file mode 100644 index 00000000..66ce542e --- /dev/null +++ b/src/next/blocks/blockESLintMoreStyling.ts @@ -0,0 +1,31 @@ +import { base } from "../base.js"; +import { blockESLint } from "./blockESLint.js"; + +export const blockESLintMoreStyling = base.createBlock({ + about: { + name: "ESLint More Styling", + }, + produce() { + return { + addons: [ + blockESLint({ + rules: [ + { + comment: "Stylistic concerns that don't interfere with Prettier", + entries: { + "logical-assignment-operators": [ + "error", + "always", + { enforceForIfStatements: true }, + ], + "no-useless-rename": "error", + "object-shorthand": "error", + "operator-assignment": "error", + }, + }, + ], + }), + ], + }; + }, +}); diff --git a/src/next/blocks/blockESLintNode.ts b/src/next/blocks/blockESLintNode.ts new file mode 100644 index 00000000..19762c5b --- /dev/null +++ b/src/next/blocks/blockESLintNode.ts @@ -0,0 +1,30 @@ +import { base } from "../base.js"; +import { blockESLint } from "./blockESLint.js"; + +export const blockESLintNode = base.createBlock({ + about: { + name: "ESLint Node Plugin", + }, + produce({ options }) { + return { + addons: [ + blockESLint({ + extensions: [ + 'n.configs["flat/recommended"]', + { + extends: ["tseslint.configs.disableTypeChecked"], + files: ["**/*.md/*.ts"], + rules: { + "n/no-missing-import": [ + "error", + { allowModules: [options.repository] }, + ], + }, + }, + ], + imports: [{ source: "eslint-plugin-n", specifier: "n" }], + }), + ], + }; + }, +}); diff --git a/src/next/blocks/blockESLintPackageJson.ts b/src/next/blocks/blockESLintPackageJson.ts new file mode 100644 index 00000000..24ce5420 --- /dev/null +++ b/src/next/blocks/blockESLintPackageJson.ts @@ -0,0 +1,23 @@ +import { base } from "../base.js"; +import { blockESLint } from "./blockESLint.js"; + +export const blockESLintPackageJson = base.createBlock({ + about: { + name: "ESLint package.json Plugin", + }, + produce() { + return { + addons: [ + blockESLint({ + extensions: ["packageJson"], + imports: [ + { + source: "eslint-plugin-package-json/configs/recommended", + specifier: "packageJson", + }, + ], + }), + ], + }; + }, +}); diff --git a/src/next/blocks/blockESLintPerfectionist.ts b/src/next/blocks/blockESLintPerfectionist.ts new file mode 100644 index 00000000..ef2527e7 --- /dev/null +++ b/src/next/blocks/blockESLintPerfectionist.ts @@ -0,0 +1,29 @@ +import { base } from "../base.js"; +import { blockESLint } from "./blockESLint.js"; + +export const blockESLintPerfectionist = base.createBlock({ + about: { + name: "ESLint Perfectionist Plugin", + }, + produce() { + return { + addons: [ + blockESLint({ + extensions: [`perfectionist.configs["recommended-natural"]`], + imports: [ + { + source: "eslint-plugin-perfectionist", + specifier: "perfectionist", + }, + ], + settings: { + perfectionist: { + partitionByComment: true, + type: "natural", + }, + }, + }), + ], + }; + }, +}); diff --git a/src/next/blocks/blockESLintRegexp.ts b/src/next/blocks/blockESLintRegexp.ts new file mode 100644 index 00000000..c33d0aa8 --- /dev/null +++ b/src/next/blocks/blockESLintRegexp.ts @@ -0,0 +1,20 @@ +import { base } from "../base.js"; +import { blockESLint } from "./blockESLint.js"; + +export const blockESLintRegexp = base.createBlock({ + about: { + name: "ESLint Regexp Plugin", + }, + produce() { + return { + addons: [ + blockESLint({ + extensions: [`regexp.configs["flat/recommended"]`], + imports: [ + { source: "eslint-plugin-regexp", specifier: "* as regexp" }, + ], + }), + ], + }; + }, +}); diff --git a/src/next/blocks/blockESLintYML.ts b/src/next/blocks/blockESLintYML.ts new file mode 100644 index 00000000..15b3f1b0 --- /dev/null +++ b/src/next/blocks/blockESLintYML.ts @@ -0,0 +1,43 @@ +import { base } from "../base.js"; +import { blockESLint } from "./blockESLint.js"; + +export const blockESLintYML = base.createBlock({ + about: { + name: "ESLint YML Plugin", + }, + produce() { + return { + addons: [ + blockESLint({ + extensions: [ + { + extends: [ + 'yml.configs["flat/recommended"]', + 'yml.configs["flat/prettier"]', + ], + files: ["**/*.{yml,yaml}"], + rules: { + "yml/file-extension": ["error", { extension: "yml" }], + "yml/sort-keys": [ + "error", + { + order: { type: "asc" }, + pathPattern: "^.*$", + }, + ], + "yml/sort-sequence-values": [ + "error", + { + order: { type: "asc" }, + pathPattern: "^.*$", + }, + ], + }, + }, + ], + imports: [{ source: "eslint-plugin-yml", specifier: "yml" }], + }), + ], + }; + }, +}); diff --git a/src/next/blocks/blockFunding.ts b/src/next/blocks/blockFunding.ts new file mode 100644 index 00000000..934d3ed1 --- /dev/null +++ b/src/next/blocks/blockFunding.ts @@ -0,0 +1,18 @@ +import { formatYaml } from "../../steps/writing/creation/formatters/formatYaml.js"; +import { base } from "../base.js"; + +export const blockFunding = base.createBlock({ + about: { + name: "Funding", + }, + produce({ options }) { + return { + files: { + ".github": { + "FUNDING.yml": + options.funding && formatYaml({ github: options.funding }), + }, + }, + }; + }, +}); diff --git a/src/next/blocks/blockGitHubActionsCI.ts b/src/next/blocks/blockGitHubActionsCI.ts new file mode 100644 index 00000000..bca43602 --- /dev/null +++ b/src/next/blocks/blockGitHubActionsCI.ts @@ -0,0 +1,125 @@ +import jsYaml from "js-yaml"; +import { z } from "zod"; + +import { createMultiWorkflowFile } from "../../steps/writing/creation/dotGitHub/createMultiWorkflowFile.js"; +import { createSoloWorkflowFile } from "../../steps/writing/creation/dotGitHub/createSoloWorkflowFile.js"; +import { base } from "../base.js"; + +export const blockGitHubActionsCI = base.createBlock({ + about: { + name: "GitHub Actions CI", + }, + addons: { + jobs: z + .array( + z.object({ + name: z.string(), + steps: z.array( + z.intersection( + z.object({ + if: z.string().optional(), + with: z.record(z.string(), z.string()).optional(), + }), + z.union([ + z.object({ run: z.string() }), + z.object({ uses: z.string() }), + ]), + ), + ), + }), + ) + .optional(), + }, + produce({ addons }) { + const { jobs } = addons; + + return { + files: { + ".github": { + actions: { + prepare: { + "action.yml": jsYaml + .dump({ + description: "Prepares the repo for a typical CI job", + name: "Prepare", + runs: { + steps: [ + { + uses: "pnpm/action-setup@v4", + with: { version: 9 }, + }, + { + uses: "actions/setup-node@v4", + with: { cache: "pnpm", "node-version": "20" }, + }, + { + run: "pnpm install --frozen-lockfile", + shell: "bash", + }, + ], + using: "composite", + }, + }) + .replaceAll(/\n(\S)/g, "\n\n$1"), + }, + }, + workflows: { + "accessibility-alt-text-bot.yml": createSoloWorkflowFile({ + if: "${{ !endsWith(github.actor, '[bot]') }}", + name: "Accessibility Alt Text Bot", + on: { + issue_comment: { + types: ["created", "edited"], + }, + issues: { + types: ["edited", "opened"], + }, + pull_request: { + types: ["edited", "opened"], + }, + }, + permissions: { + issues: "write", + "pull-requests": "write", + }, + steps: [ + { + uses: "github/accessibility-alt-text-bot@v1.4.0", + }, + ], + }), + "ci.yml": + jobs && + createMultiWorkflowFile({ + jobs: jobs.sort((a, b) => a.name.localeCompare(b.name)), + name: "CI", + }), + "pr-review-requested.yml": createSoloWorkflowFile({ + name: "PR Review Requested", + on: { + pull_request_target: { + types: ["review_requested"], + }, + }, + permissions: { + "pull-requests": "write", + }, + steps: [ + { + uses: "actions-ecosystem/action-remove-labels@v1", + with: { + labels: "status: waiting for author", + }, + }, + { + if: "failure()", + run: 'echo "Don\'t worry if the previous step failed."\necho "See https://github.com/actions-ecosystem/action-remove-labels/issues/221."\n', + }, + ], + }), + }, + }, + }, + }; + }, +}); diff --git a/src/next/blocks/blockGitHubIssueTemplates.ts b/src/next/blocks/blockGitHubIssueTemplates.ts new file mode 100644 index 00000000..23bd3e97 --- /dev/null +++ b/src/next/blocks/blockGitHubIssueTemplates.ts @@ -0,0 +1,226 @@ +import { formatYaml } from "../../steps/writing/creation/formatters/formatYaml.js"; +import { base } from "../base.js"; + +export const blockGitHubIssueTemplates = base.createBlock({ + about: { + name: "GitHub Issue Templates", + }, + produce({ options }) { + return { + files: { + ".github": { + ISSUE_TEMPLATE: { + "01-bug.yml": formatYaml({ + body: [ + { + attributes: { + description: + "If any of these required steps are not taken, we may not be able to review your issue. Help us to help you!", + label: "Bug Report Checklist", + options: [ + { + label: + "I have tried restarting my IDE and the issue persists.", + required: true, + }, + { + label: + "I have pulled the latest `main` branch of the repository.", + required: true, + }, + { + label: `I have [searched for related issues](https://github.com/${options.owner}/${options.repository}/issues?q=is%3Aissue) and found none that matched my issue.`, + required: true, + }, + ], + }, + type: "checkboxes", + }, + { + attributes: { + description: "What did you expect to happen?", + label: "Expected", + }, + type: "textarea", + validations: { + required: true, + }, + }, + { + attributes: { + description: "What happened instead?", + label: "Actual", + }, + type: "textarea", + validations: { + required: true, + }, + }, + { + attributes: { + description: "Any additional info you'd like to provide.", + label: "Additional Info", + }, + type: "textarea", + }, + ], + description: "Report a bug trying to run the code", + labels: ["type: bug"], + name: "๐Ÿ› Bug", + title: "๐Ÿ› Bug: ", + }), + "02-documentation.yml": formatYaml({ + body: [ + { + attributes: { + description: + "If any of these required steps are not taken, we may not be able to review your issue. Help us to help you!", + label: "Bug Report Checklist", + options: [ + { + label: + "I have pulled the latest `main` branch of the repository.", + required: true, + }, + { + label: `I have [searched for related issues](https://github.com/${options.owner}/${options.repository}/issues?q=is%3Aissue) and found none that matched my issue.`, + required: true, + }, + ], + }, + type: "checkboxes", + }, + { + attributes: { + description: "What would you like to report?", + label: "Overview", + }, + type: "textarea", + validations: { + required: true, + }, + }, + { + attributes: { + description: "Any additional info you'd like to provide.", + label: "Additional Info", + }, + type: "textarea", + }, + ], + description: "Report a typo or missing area of documentation", + labels: ["area: documentation"], + name: "๐Ÿ“ Documentation", + title: "๐Ÿ“ Documentation: ", + }), + "03-feature.yml": formatYaml({ + body: [ + { + attributes: { + description: + "If any of these required steps are not taken, we may not be able to review your issue. Help us to help you!", + label: "Bug Report Checklist", + options: [ + { + label: + "I have pulled the latest `main` branch of the repository.", + required: true, + }, + { + label: `I have [searched for related issues](https://github.com/${options.owner}/${options.repository}/issues?q=is%3Aissue) and found none that matched my issue.`, + required: true, + }, + ], + }, + type: "checkboxes", + }, + { + attributes: { + description: "What did you expect to be able to do?", + label: "Overview", + }, + type: "textarea", + validations: { + required: true, + }, + }, + { + attributes: { + description: "Any additional info you'd like to provide.", + label: "Additional Info", + }, + type: "textarea", + }, + ], + description: + "Request that a new feature be added or an existing feature improved", + labels: ["type: feature"], + name: "๐Ÿš€ Feature", + title: "๐Ÿš€ Feature: ", + }), + "04-tooling.yml": formatYaml({ + body: [ + { + attributes: { + description: + "If any of these required steps are not taken, we may not be able to review your issue. Help us to help you!", + label: "Bug Report Checklist", + options: [ + { + label: + "I have tried restarting my IDE and the issue persists.", + required: true, + }, + { + label: + "I have pulled the latest `main` branch of the repository.", + required: true, + }, + { + label: `I have [searched for related issues](https://github.com/${options.owner}/${options.repository}/issues?q=is%3Aissue) and found none that matched my issue.`, + required: true, + }, + ], + }, + type: "checkboxes", + }, + { + attributes: { + description: "What did you expect to be able to do?", + label: "Overview", + }, + type: "textarea", + validations: { + required: true, + }, + }, + { + attributes: { + description: "Any additional info you'd like to provide.", + label: "Additional Info", + }, + type: "textarea", + }, + ], + description: + "Report a bug or request an enhancement in repository tooling", + labels: ["area: tooling"], + name: "๐Ÿ›  Tooling", + title: "๐Ÿ›  Tooling: ", + }), + }, + "ISSUE_TEMPLATE.md": ` + + + + + +## Overview + +... +`, + }, + }, + }; + }, +}); diff --git a/src/next/blocks/blockGitHubPRTemplate.ts b/src/next/blocks/blockGitHubPRTemplate.ts new file mode 100644 index 00000000..87dcf903 --- /dev/null +++ b/src/next/blocks/blockGitHubPRTemplate.ts @@ -0,0 +1,29 @@ +import { base } from "../base.js"; + +export const blockGitHubPRTemplate = base.createBlock({ + about: { + name: "GitHub Issue Templates", + }, + produce({ options }) { + return { + files: { + ".github": { + "PULL_REQUEST_TEMPLATE.md": ` + +## PR Checklist + +- [ ] Addresses an existing open issue: fixes #000 +- [ ] That issue was marked as [\`status: accepting prs\`](https://github.com/${options.owner}/${options.repository}/issues?q=is%3Aopen+is%3Aissue+label%3A%22status%3A+accepting+prs%22) +- [ ] Steps in [CONTRIBUTING.md](https://github.com/${options.owner}/${options.repository}/blob/main/.github/CONTRIBUTING.md) were taken + +## Overview + + +`, + }, + }, + }; + }, +}); diff --git a/src/next/blocks/blockGitignore.ts b/src/next/blocks/blockGitignore.ts new file mode 100644 index 00000000..b10e4bf6 --- /dev/null +++ b/src/next/blocks/blockGitignore.ts @@ -0,0 +1,22 @@ +import { z } from "zod"; + +import { formatIgnoreFile } from "../../steps/writing/creation/formatters/formatIgnoreFile.js"; +import { base } from "../base.js"; + +export const blockGitignore = base.createBlock({ + about: { + name: "Gitignore", + }, + addons: { + ignores: z.array(z.string()).default([]), + }, + produce({ addons }) { + const { ignores } = addons; + + return { + files: { + ".gitignore": formatIgnoreFile(["/node_modules", ...ignores].sort()), + }, + }; + }, +}); diff --git a/src/next/blocks/blockKnip.ts b/src/next/blocks/blockKnip.ts new file mode 100644 index 00000000..212e8fee --- /dev/null +++ b/src/next/blocks/blockKnip.ts @@ -0,0 +1,55 @@ +import { base } from "../base.js"; +import { blockDevelopmentDocs } from "./blockDevelopmentDocs.js"; +import { blockGitHubActionsCI } from "./blockGitHubActionsCI.js"; +import { blockPackageJson } from "./blockPackageJson.js"; +import { getPackageDependencies } from "./packageData.js"; + +export const blockKnip = base.createBlock({ + about: { + name: "Knip", + }, + produce() { + return { + addons: [ + blockDevelopmentDocs({ + sections: { + Linting: { + contents: { + items: [ + `- \`pnpm lint:knip\` ([knip](https://github.com/webpro/knip)): Detects unused files, dependencies, and code exports`, + ], + }, + }, + }, + }), + blockGitHubActionsCI({ + jobs: [ + { + name: "Lint Knip", + steps: [{ run: "pnpm lint:knip" }], + }, + ], + }), + blockPackageJson({ + properties: { + devDependencies: getPackageDependencies("knip"), + scripts: { + "lint:knip": "knip", + }, + }, + }), + ], + files: { + "knip.json": JSON.stringify({ + $schema: "https://unpkg.com/knip@latest/schema.json", + entry: ["src/index.ts!"], + ignoreExportsUsedInFile: { + interface: true, + type: true, + }, + project: ["src/**/*.ts!"], + }), + }, + }; + }, +}); diff --git a/src/next/blocks/blockMITLicense.ts b/src/next/blocks/blockMITLicense.ts new file mode 100644 index 00000000..6029d5dc --- /dev/null +++ b/src/next/blocks/blockMITLicense.ts @@ -0,0 +1,43 @@ +import { base } from "../base.js"; +import { blockPackageJson } from "./blockPackageJson.js"; + +export const blockMITLicense = base.createBlock({ + about: { + name: "MIT License", + }, + produce() { + return { + addons: [ + blockPackageJson({ + properties: { + files: ["LICENSE.md"], + license: "MIT", + }, + }), + ], + files: { + "LICENSE.md": `# MIT License + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +'Software'), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +`, + }, + }; + }, +}); diff --git a/src/next/blocks/blockMarkdownlint.ts b/src/next/blocks/blockMarkdownlint.ts new file mode 100644 index 00000000..a1a35ed8 --- /dev/null +++ b/src/next/blocks/blockMarkdownlint.ts @@ -0,0 +1,79 @@ +import { z } from "zod"; + +import { base } from "../base.js"; +import { blockCSpell } from "./blockCSpell.js"; +import { blockDevelopmentDocs } from "./blockDevelopmentDocs.js"; +import { blockGitHubActionsCI } from "./blockGitHubActionsCI.js"; +import { blockPackageJson } from "./blockPackageJson.js"; +import { blockVSCode } from "./blockVSCode.js"; +import { getPackageDependencies } from "./packageData.js"; + +export const blockMarkdownlint = base.createBlock({ + about: { + name: "Markdownlint", + }, + addons: { + ignores: z.array(z.string()).default([]), + }, + produce({ addons }) { + const { ignores } = addons; + + return { + addons: [ + blockCSpell({ + words: ["markdownlintignore"], + }), + blockDevelopmentDocs({ + sections: { + Linting: { + contents: { + items: [ + `- \`pnpm lint:md\` ([Markdownlint](https://github.com/DavidAnson/markdownlint)): Checks Markdown source files`, + ], + }, + }, + }, + }), + blockGitHubActionsCI({ + jobs: [ + { + name: "Lint Markdown", + steps: [{ run: "pnpm lint:md" }], + }, + ], + }), + blockPackageJson({ + properties: { + devDependencies: getPackageDependencies( + "markdownlint", + "markdownlint-cli", + "sentences-per-line", + ), + scripts: { + "lint:md": + 'markdownlint "**/*.md" ".github/**/*.md" --rules sentences-per-line', + }, + }, + }), + blockVSCode({ + extensions: ["DavidAnson.vscode-markdownlint"], + }), + ], + files: { + ".markdownlint.json": JSON.stringify({ + extends: "markdownlint/style/prettier", + "first-line-h1": false, + "no-inline-html": false, + }), + ".markdownlintignore": [ + ".github/CODE_OF_CONDUCT.md", + "CHANGELOG.md", + "node_modules/", + ...ignores, + ] + .sort() + .join("\n"), + }, + }; + }, +}); diff --git a/src/next/blocks/blockNvmrc.test.ts b/src/next/blocks/blockNvmrc.test.ts new file mode 100644 index 00000000..32d77b48 --- /dev/null +++ b/src/next/blocks/blockNvmrc.test.ts @@ -0,0 +1,40 @@ +import { testBlock } from "create-testers"; +import { describe, expect, it } from "vitest"; + +import { blockNvmrc } from "./blockNvmrc.js"; +import { blockPrettier } from "./blockPrettier.js"; +import { optionsBase } from "./options.fakes.js"; + +describe("blockNvmrc", () => { + it("only includes blockPackageJson addons when options.node does not exist", () => { + const creation = testBlock(blockNvmrc, { options: optionsBase }); + + expect(creation).toEqual({ + addons: [ + blockPrettier({ + overrides: [{ files: ".nvmrc", options: { parser: "yaml" } }], + }), + ], + }); + }); + + it("also includes files when options.node exists", () => { + const creation = testBlock(blockNvmrc, { + options: { + ...optionsBase, + node: { minimum: ">=18.3.0", pinned: "20.18.0" }, + }, + }); + + expect(creation).toEqual({ + addons: [ + blockPrettier({ + overrides: [{ files: ".nvmrc", options: { parser: "yaml" } }], + }), + ], + files: { + ".nvmrc": `20.18.0\n`, + }, + }); + }); +}); diff --git a/src/next/blocks/blockNvmrc.ts b/src/next/blocks/blockNvmrc.ts new file mode 100644 index 00000000..413d7f67 --- /dev/null +++ b/src/next/blocks/blockNvmrc.ts @@ -0,0 +1,22 @@ +import { base } from "../base.js"; +import { blockPrettier } from "./blockPrettier.js"; + +export const blockNvmrc = base.createBlock({ + about: { + name: "Nvmrc", + }, + produce({ options }) { + return { + addons: [ + blockPrettier({ + overrides: [{ files: ".nvmrc", options: { parser: "yaml" } }], + }), + ], + ...(options.node?.pinned && { + files: { + ".nvmrc": `${options.node.pinned}\n`, + }, + }), + }; + }, +}); diff --git a/src/next/blocks/blockPRCompliance.ts b/src/next/blocks/blockPRCompliance.ts new file mode 100644 index 00000000..eac9a64c --- /dev/null +++ b/src/next/blocks/blockPRCompliance.ts @@ -0,0 +1,45 @@ +import { createSoloWorkflowFile } from "../../steps/writing/creation/dotGitHub/createSoloWorkflowFile.js"; +import { base } from "../base.js"; + +export const blockPRCompliance = base.createBlock({ + about: { + name: "PR Compliance", + }, + produce() { + return { + files: { + ".github": { + workflows: { + "compliance.yml": createSoloWorkflowFile({ + name: "Compliance", + on: { + pull_request: { + branches: ["main"], + types: ["edited", "opened", "reopened", "synchronize"], + }, + }, + permissions: { + "pull-requests": "write", + }, + steps: [ + { + uses: "mtfoley/pr-compliance-action@main", + with: { + "body-auto-close": false, + "ignore-authors": [ + "allcontributors", + "allcontributors[bot]", + "renovate", + "renovate[bot]", + ].join("\n"), + "ignore-team-members": false, + }, + }, + ], + }), + }, + }, + }, + }; + }, +}); diff --git a/src/next/blocks/blockPackageJson.ts b/src/next/blocks/blockPackageJson.ts new file mode 100644 index 00000000..91488c68 --- /dev/null +++ b/src/next/blocks/blockPackageJson.ts @@ -0,0 +1,82 @@ +import sortPackageJson from "sort-package-json"; +import { z } from "zod"; + +import { base } from "../base.js"; + +export const blockPackageJson = base.createBlock({ + about: { + name: "Package JSON", + }, + addons: { + // TODO: Find a zod package for this? + properties: z + .intersection( + z.object({ + dependencies: z.record(z.string(), z.string()).optional(), + devDependencies: z.record(z.string(), z.string()).optional(), + files: z.array(z.string()).optional(), + peerDependencies: z.record(z.string(), z.string()).optional(), + scripts: z.record(z.string(), z.string()).optional(), + }), + z.record(z.string(), z.unknown()), + ) + .default({}), + }, + produce({ addons, options }) { + return { + commands: [ + { + phase: 0, // TODO: ??? + script: "pnpm i", + }, + ], + files: { + "package.json": sortPackageJson( + JSON.stringify({ + ...addons.properties, + author: { email: options.email.npm, name: options.author }, + bin: options.bin, + dependencies: { + ...options.packageData?.dependencies, + ...addons.properties.dependencies, + }, + description: options.description, + devDependencies: { + ...options.packageData?.devDependencies, + ...addons.properties.devDependencies, + }, + ...(options.node && { + engines: { + node: `>=${options.node.minimum}`, + }, + }), + files: [ + options.bin?.replace(/^\.\//, ""), + ...(addons.properties.files ?? []), + "package.json", + "README.md", + ] + .filter(Boolean) + .sort(), + keywords: options.keywords?.flatMap((keyword) => + keyword.split(/ /), + ), + license: "MIT", + main: "./lib/index.js", + name: options.repository, + repository: { + type: "git", + url: `https://github.com/${options.owner}/${options.repository}`, + }, + scripts: { + ...options.packageData?.scripts, + ...addons.properties.scripts, + }, + type: "module", + version: options.version ?? "0.0.0", + }), + ), + }, + }; + }, +}); diff --git a/src/next/blocks/blockPnpmDedupe.ts b/src/next/blocks/blockPnpmDedupe.ts new file mode 100644 index 00000000..d5971f58 --- /dev/null +++ b/src/next/blocks/blockPnpmDedupe.ts @@ -0,0 +1,48 @@ +import { base } from "../base.js"; +import { blockDevelopmentDocs } from "./blockDevelopmentDocs.js"; +import { blockGitHubActionsCI } from "./blockGitHubActionsCI.js"; +import { blockPackageJson } from "./blockPackageJson.js"; + +export const blockPnpmDedupe = base.createBlock({ + about: { + name: "pnpm Dedupe", + }, + produce() { + return { + addons: [ + blockDevelopmentDocs({ + sections: { + Linting: { + contents: { + items: [ + `- \`pnpm lint:packages\` ([pnpm dedupe --check](https://pnpm.io/cli/dedupe)): Checks for unnecessarily duplicated packages in the \`pnpm-lock.yml\` file`, + ], + }, + }, + }, + }), + blockGitHubActionsCI({ + jobs: [ + { + name: "Lint Packages", + steps: [{ run: "pnpm lint:packages" }], + }, + ], + }), + blockPackageJson({ + properties: { + scripts: { + "lint:packages": "pnpm dedupe --check", + }, + }, + }), + ], + commands: [ + { + phase: 1, + script: "pnpm dedupe", + }, + ], + }; + }, +}); diff --git a/src/next/blocks/blockPrettier.ts b/src/next/blocks/blockPrettier.ts new file mode 100644 index 00000000..a7448655 --- /dev/null +++ b/src/next/blocks/blockPrettier.ts @@ -0,0 +1,106 @@ +import { z } from "zod"; + +import { base } from "../base.js"; +import { blockCSpell } from "./blockCSpell.js"; +import { blockDevelopmentDocs } from "./blockDevelopmentDocs.js"; +import { blockGitHubActionsCI } from "./blockGitHubActionsCI.js"; +import { blockPackageJson } from "./blockPackageJson.js"; +import { blockVSCode } from "./blockVSCode.js"; +import { getPackageDependencies } from "./packageData.js"; + +export const blockPrettier = base.createBlock({ + about: { + name: "Prettier", + }, + addons: { + ignores: z.array(z.string()).default([]), + overrides: z + .array( + z.object({ + files: z.string(), + options: z.object({ + parser: z.string(), + }), + }), + ) + .default([]), + plugins: z.array(z.string()).default([]), + }, + produce({ addons }) { + const { ignores, overrides, plugins } = addons; + + return { + addons: [ + blockCSpell({ + ignores: [".all-contributorsrc"], + }), + blockDevelopmentDocs({ + sections: { + Formatting: { + contents: ` +[Prettier](https://prettier.io) is used to format code. +It should be applied automatically when you save files in VS Code or make a Git commit. + +To manually reformat all files, you can run: + +\`\`\`shell +pnpm format --write +\`\`\` +`, + }, + }, + }), + blockGitHubActionsCI({ + jobs: [ + { + name: "Prettier", + steps: [{ run: "pnpm format --list-different" }], + }, + ], + }), + blockPackageJson({ + properties: { + devDependencies: getPackageDependencies( + ...plugins, + "husky", + "lint-staged", + "prettier", + ), + "lint-staged": { + "*": "prettier --ignore-unknown --write", + }, + scripts: { + format: "prettier .", + prepare: "husky", + }, + }, + }), + blockVSCode({ + extensions: ["esbenp.prettier-vscode"], + settings: { "editor.defaultFormatter": "esbenp.prettier-vscode" }, + }), + ], + commands: [ + { + phase: 2, // TODO: ??? + script: "pnpm format --write", + }, + ], + files: { + ".husky": { + ".gitignore": "_", + "pre-commit": "npx lint-staged", + }, + ".prettierignore": ["/.husky", "/lib", "/pnpm-lock.yaml", ...ignores] + .sort() + .join("\n"), + ".prettierrc.json": JSON.stringify({ + $schema: "http://json.schemastore.org/prettierrc", + ...(overrides.length && { overrides: overrides.sort() }), + ...(plugins.length && { plugins: plugins.sort() }), + useTabs: true, + }), + }, + }; + }, +}); diff --git a/src/next/blocks/blockPrettierPluginCurly.ts b/src/next/blocks/blockPrettierPluginCurly.ts new file mode 100644 index 00000000..f459a82b --- /dev/null +++ b/src/next/blocks/blockPrettierPluginCurly.ts @@ -0,0 +1,17 @@ +import { base } from "../base.js"; +import { blockPrettier } from "./blockPrettier.js"; + +export const blockPrettierPluginCurly = base.createBlock({ + about: { + name: "Prettier Plugin Curly", + }, + produce() { + return { + addons: [ + blockPrettier({ + plugins: ["prettier-plugin-curly"], + }), + ], + }; + }, +}); diff --git a/src/next/blocks/blockPrettierPluginPackageJson.ts b/src/next/blocks/blockPrettierPluginPackageJson.ts new file mode 100644 index 00000000..55bf4062 --- /dev/null +++ b/src/next/blocks/blockPrettierPluginPackageJson.ts @@ -0,0 +1,17 @@ +import { base } from "../base.js"; +import { blockPrettier } from "./blockPrettier.js"; + +export const blockPrettierPluginPackageJson = base.createBlock({ + about: { + name: "Prettier Plugin Package JSON", + }, + produce() { + return { + addons: [ + blockPrettier({ + plugins: ["prettier-plugin-packagejson"], + }), + ], + }; + }, +}); diff --git a/src/next/blocks/blockPrettierPluginSh.ts b/src/next/blocks/blockPrettierPluginSh.ts new file mode 100644 index 00000000..4897a12d --- /dev/null +++ b/src/next/blocks/blockPrettierPluginSh.ts @@ -0,0 +1,17 @@ +import { base } from "../base.js"; +import { blockPrettier } from "./blockPrettier.js"; + +export const blockPrettierPluginSh = base.createBlock({ + about: { + name: "Prettier Plugin Sh", + }, + produce() { + return { + addons: [ + blockPrettier({ + plugins: ["prettier-plugin-sh"], + }), + ], + }; + }, +}); diff --git a/src/next/blocks/blockREADME.ts b/src/next/blocks/blockREADME.ts new file mode 100644 index 00000000..c1418c46 --- /dev/null +++ b/src/next/blocks/blockREADME.ts @@ -0,0 +1,61 @@ +import { z } from "zod"; + +import { base } from "../base.js"; + +export const blockREADME = base.createBlock({ + about: { + name: "README.md", + }, + addons: { + notices: z.array(z.string()).default([]), + }, + produce({ addons, options }) { + const { notices } = addons; + + return { + files: { + "README.md": `

${options.title}

+ +

${options.description}

+ +

+ + + + + ๐Ÿค Code of Conduct: Kept + ๐Ÿงช Coverage + ๐Ÿ“ License: MIT + ๐Ÿ“ฆ npm version + ๐Ÿ’ช TypeScript: Strict +

+ +${options.logo ? `${options.logo.alt}` : ""} + +## Usage + +\`\`\`shell +npm i ${options.repository} +\`\`\` +\`\`\`ts +import { greet } from "${options.repository}"; + +greet("Hello, world! ๐Ÿ’–"); +\`\`\` + +## Development + +See [\`.github/CONTRIBUTING.md\`](./.github/CONTRIBUTING.md), then [\`.github/DEVELOPMENT.md\`](./.github/DEVELOPMENT.md). +Thanks! ๐Ÿ’– + +## Contributors + + + + + +${notices.length ? `\n${notices.map((notice) => notice.trim()).join("\n\n")}` : ""}`, + }, + }; + }, +}); diff --git a/src/next/blocks/blockReleaseIt.ts b/src/next/blocks/blockReleaseIt.ts new file mode 100644 index 00000000..82782830 --- /dev/null +++ b/src/next/blocks/blockReleaseIt.ts @@ -0,0 +1,119 @@ +import { createSoloWorkflowFile } from "../../steps/writing/creation/dotGitHub/createSoloWorkflowFile.js"; +import { base } from "../base.js"; +import { blockCSpell } from "./blockCSpell.js"; +import { blockPackageJson } from "./blockPackageJson.js"; + +export const blockReleaseIt = base.createBlock({ + about: { + name: "release-it", + }, + produce({ options }) { + return { + addons: [ + blockCSpell({ + words: ["apexskier"], + }), + blockPackageJson({ + properties: { + publishConfig: { + provenance: true, + }, + }, + }), + ], + files: { + ".github": { + workflows: { + "post-release.yml": createSoloWorkflowFile({ + name: "Post Release", + on: { + release: { + types: ["published"], + }, + }, + steps: [ + { uses: "actions/checkout@v4", with: { "fetch-depth": 0 } }, + { + run: `echo "npm_version=$(npm pkg get version | tr -d '"')" >> "$GITHUB_ENV"`, + }, + { + uses: "apexskier/github-release-commenter@v1", + with: { + "comment-template": ` + :tada: This is included in version {release_link} :tada: + + The release is available on: + + * [GitHub releases](https://github.com/${options.owner}/${options.repository}/releases/tag/{release_tag}) + * [npm package (@latest dist-tag)](https://www.npmjs.com/package/${options.repository}/v/\${{ env.npm_version }}) + + Cheers! ๐Ÿ“ฆ๐Ÿš€ + `, + GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}", + }, + }, + ], + }), + "release.yml": createSoloWorkflowFile({ + concurrency: { + group: "${{ github.workflow }}", + }, + name: "Release", + on: { + push: { + branches: ["main"], + }, + }, + permissions: { + contents: "write", + "id-token": "write", + }, + steps: [ + { + uses: "actions/checkout@v4", + with: { + "fetch-depth": 0, + ref: "main", + }, + }, + { + uses: "./.github/actions/prepare", + }, + { + run: "pnpm build", + }, + { + env: { + GITHUB_TOKEN: "${{ secrets.ACCESS_TOKEN }}", + NPM_TOKEN: "${{ secrets.NPM_TOKEN }}", + }, + uses: "JoshuaKGoldberg/release-it-action@v0.2.2", + }, + ], + }), + }, + }, + ".release-it.json": JSON.stringify({ + git: { + commitMessage: "chore: release v${version}", + requireCommits: true, + }, + github: { + autoGenerate: true, + release: true, + releaseName: "v${version}", + }, + npm: { + publishArgs: [`--access ${options.access}`, "--provenance"], + }, + plugins: { + "@release-it/conventional-changelog": { + infile: "CHANGELOG.md", + preset: "angular", + }, + }, + }), + }, + }; + }, +}); diff --git a/src/next/blocks/blockRenovate.ts b/src/next/blocks/blockRenovate.ts new file mode 100644 index 00000000..b639119b --- /dev/null +++ b/src/next/blocks/blockRenovate.ts @@ -0,0 +1,31 @@ +import { base } from "../base.js"; +import { blockCSpell } from "./blockCSpell.js"; + +export const blockRenovate = base.createBlock({ + about: { + name: "Renovate", + }, + produce() { + return { + addons: [ + blockCSpell({ + words: ["automerge"], + }), + ], + files: { + ".github": { + "renovate.json": JSON.stringify({ + $schema: "https://docs.renovatebot.com/renovate-schema.json", + automerge: true, + extends: ["config:best-practices", "replacements:all"], + ignoreDeps: ["codecov/codecov-action"], + labels: ["dependencies"], + minimumReleaseAge: "7 days", + patch: { enabled: false }, + postUpdateOptions: ["pnpmDedupe"], + }), + }, + }, + }; + }, +}); diff --git a/src/next/blocks/blockSecurityDocs.ts b/src/next/blocks/blockSecurityDocs.ts new file mode 100644 index 00000000..2ee06ac7 --- /dev/null +++ b/src/next/blocks/blockSecurityDocs.ts @@ -0,0 +1,25 @@ +import { base } from "../base.js"; + +export const blockSecurityDocs = base.createBlock({ + about: { + name: "Security Docs", + }, + produce({ options }) { + return { + files: { + ".github": { + "SECURITY.md": `# Security Policy + +We take all security vulnerabilities seriously. +If you have a vulnerability or other security issues to disclose: + +- Thank you very much, please do! +- Please send them to us by emailing \`${options.email.github}\` + +We appreciate your efforts and responsible disclosure and will make every effort to acknowledge your contributions. +`, + }, + }, + }; + }, +}); diff --git a/src/next/blocks/blockTSup.ts b/src/next/blocks/blockTSup.ts new file mode 100644 index 00000000..e3d24303 --- /dev/null +++ b/src/next/blocks/blockTSup.ts @@ -0,0 +1,77 @@ +import { z } from "zod"; + +import { base } from "../base.js"; +import { blockDevelopmentDocs } from "./blockDevelopmentDocs.js"; +import { blockESLint } from "./blockESLint.js"; +import { blockGitHubActionsCI } from "./blockGitHubActionsCI.js"; +import { blockPackageJson } from "./blockPackageJson.js"; +import { getPackageDependencies } from "./packageData.js"; + +export const blockTSup = base.createBlock({ + about: { + name: "tsup", + }, + addons: { + entry: z.array(z.string()).default([]), + }, + produce({ addons }) { + const { entry } = addons; + + return { + addons: [ + blockDevelopmentDocs({ + sections: { + Building: { + contents: ` +Run [**tsup**](https://tsup.egoist.dev) locally to build source files from \`src/\` into output files in \`lib/\`: + +\`\`\`shell +pnpm build +\`\`\` + +Add \`--watch\` to run the builder in a watch mode that continuously cleans and recreates \`lib/\` as you save files: + +\`\`\`shell +pnpm build --watch +\`\`\` +`, + }, + }, + }), + blockESLint({ + beforeLint: `Note that you'll need to run \`pnpm build\` before \`pnpm lint\` so that lint rules which check the file system can pick up on any built files.`, + }), + blockGitHubActionsCI({ + jobs: [ + { + name: "Build", + steps: [{ run: "pnpm build" }, { run: "node ./lib/index.js" }], + }, + ], + }), + blockPackageJson({ + properties: { + devDependencies: getPackageDependencies("tsup"), + scripts: { + build: "tsup", + }, + }, + }), + ], + files: { + "tsup.config.ts": `import { defineConfig } from "tsup"; + +export default defineConfig({ + bundle: false, + clean: true, + dts: true, + entry: ${JSON.stringify(["src/**/*.ts", ...entry])}, + format: "esm", + outDir: "lib", + sourcemap: true, +}); +`, + }, + }; + }, +}); diff --git a/src/next/blocks/blockTemplatedBy.ts b/src/next/blocks/blockTemplatedBy.ts new file mode 100644 index 00000000..bcf3aab7 --- /dev/null +++ b/src/next/blocks/blockTemplatedBy.ts @@ -0,0 +1,32 @@ +import { z } from "zod"; + +import { base } from "../base.js"; +import { blockCSpell } from "./blockCSpell.js"; +import { blockREADME } from "./blockREADME.js"; + +export const blockTemplatedBy = base.createBlock({ + about: { + name: "Templated By Notice", + }, + addons: { + notices: z.array(z.string()).default([]), + }, + produce() { + return { + addons: [ + blockCSpell({ + words: ["joshuakgoldberg"], + }), + blockREADME({ + notices: [ + ` + + +> ๐Ÿ’ This package was templated with [\`create-typescript-app\`](https://github.com/JoshuaKGoldberg/create-typescript-app) using the [\`create\` engine](https://github.com/JoshuaKGoldberg/create). +`, + ], + }), + ], + }; + }, +}); diff --git a/src/next/blocks/blockTypeScript.ts b/src/next/blocks/blockTypeScript.ts new file mode 100644 index 00000000..65f3cee5 --- /dev/null +++ b/src/next/blocks/blockTypeScript.ts @@ -0,0 +1,108 @@ +import { base } from "../base.js"; +import { blockDevelopmentDocs } from "./blockDevelopmentDocs.js"; +import { blockGitHubActionsCI } from "./blockGitHubActionsCI.js"; +import { blockGitignore } from "./blockGitignore.js"; +import { blockMarkdownlint } from "./blockMarkdownlint.js"; +import { blockPackageJson } from "./blockPackageJson.js"; +import { blockVitest } from "./blockVitest.js"; +import { blockVSCode } from "./blockVSCode.js"; +import { getPackageDependencies } from "./packageData.js"; + +export const blockTypeScript = base.createBlock({ + about: { + name: "TypeScript", + }, + produce({ options }) { + return { + addons: [ + blockDevelopmentDocs({ + sections: { + "Type Checking": { + contents: ` +You should be able to see suggestions from [TypeScript](https://typescriptlang.org) in your editor for all open files. + +However, it can be useful to run the TypeScript command-line (\`tsc\`) to type check all files in \`src/\`: + +\`\`\`shell +pnpm tsc +\`\`\` + +Add \`--watch\` to keep the type checker running in a watch mode that updates the display as you save files: + +\`\`\`shell +pnpm tsc --watch +\`\`\` +`, + }, + }, + }), + blockGitignore({ + ignores: ["/lib"], + }), + blockGitHubActionsCI({ + jobs: [{ name: "Type Check", steps: [{ run: "pnpm tsc" }] }], + }), + blockMarkdownlint({ + ignores: ["lib/"], + }), + blockPackageJson({ + properties: { + devDependencies: getPackageDependencies("typescript"), + files: ["lib/"], + main: "./lib/index.js", + scripts: { + tsc: "tsc", + }, + }, + }), + blockVitest({ + exclude: ["lib"], + include: ["src"], + }), + blockVSCode({ + debuggers: options.bin + ? [ + { + name: "Debug Program", + preLaunchTask: "build", + program: options.bin, + request: "launch", + skipFiles: ["/**"], + type: "node", + }, + ] + : [], + settings: { + "typescript.tsdk": "node_modules/typescript/lib", + }, + tasks: [ + { + detail: "Build the project", + label: "build", + script: "build", + type: "npm", + }, + ], + }), + ], + files: { + "tsconfig.json": JSON.stringify({ + compilerOptions: { + declaration: true, + declarationMap: true, + esModuleInterop: true, + module: "NodeNext", + moduleResolution: "NodeNext", + noEmit: true, + resolveJsonModule: true, + skipLibCheck: true, + sourceMap: true, + strict: true, + target: "ES2022", + }, + include: ["src"], + }), + }, + }; + }, +}); diff --git a/src/next/blocks/blockVSCode.ts b/src/next/blocks/blockVSCode.ts new file mode 100644 index 00000000..d1779288 --- /dev/null +++ b/src/next/blocks/blockVSCode.ts @@ -0,0 +1,98 @@ +import { z } from "zod"; + +import { base } from "../base.js"; +import { sortObject } from "../utils/sortObject.js"; +import { blockDevelopmentDocs } from "./blockDevelopmentDocs.js"; + +export const blockVSCode = base.createBlock({ + about: { + name: "VS Code", + }, + addons: { + debuggers: z + .array( + z.intersection( + z.record(z.string(), z.unknown()), + z.object({ name: z.string() }), + ), + ) + .optional(), + extensions: z.array(z.string()).optional(), + settings: z.record(z.string(), z.unknown()).default({}), + tasks: z + .array( + z.intersection( + z.object({ detail: z.string() }), + z.record(z.string(), z.unknown()), + ), + ) + .optional(), + }, + produce({ addons, options }) { + const { debuggers, extensions, settings, tasks } = addons; + + return { + addons: [ + blockDevelopmentDocs({ + sections: { + Building: { + innerSections: options.bin + ? [ + { + contents: ` +This repository includes a [VS Code launch configuration](https://code.visualstudio.com/docs/editor/debugging) for debugging. +To debug a \`bin\` app, add a breakpoint to your code, then run _Debug Program_ from the VS Code Debug panel (or press F5). +VS Code will automatically run the \`build\` task in the background before running \`${options.bin}\`. +`, + heading: "Built App Debugging", + }, + ] + : [], + }, + Testing: { + innerSections: [ + { + contents: ` +This repository includes a [VS Code launch configuration](https://code.visualstudio.com/docs/editor/debugging) for debugging unit tests. +To launch it, open a test file, then run _Debug Current Test File_ from the VS Code Debug panel (or press F5). +`, + heading: "Debugging Tests", + }, + ], + }, + }, + }), + ], + files: { + ".vscode": { + "extensions.json": + extensions && + JSON.stringify({ + recommendations: [...extensions].sort(), + }), + "launch.json": + debuggers && + JSON.stringify({ + configurations: [...debuggers].sort((a, b) => + a.name.localeCompare(b.name), + ), + version: "0.2.0", + }), + "settings.json": JSON.stringify( + sortObject({ + "editor.formatOnSave": true, + "editor.rulers": [80], + ...settings, + }), + ), + "tasks.json": + tasks && + JSON.stringify({ + tasks: tasks.sort((a, b) => a.detail.localeCompare(b.detail)), + version: "2.0.0", + }), + }, + }, + }; + }, +}); diff --git a/src/next/blocks/blockVitest.ts b/src/next/blocks/blockVitest.ts new file mode 100644 index 00000000..d034b199 --- /dev/null +++ b/src/next/blocks/blockVitest.ts @@ -0,0 +1,155 @@ +import { z } from "zod"; + +import { base } from "../base.js"; +import { blockCSpell } from "./blockCSpell.js"; +import { blockDevelopmentDocs } from "./blockDevelopmentDocs.js"; +import { blockESLint } from "./blockESLint.js"; +import { blockGitHubActionsCI } from "./blockGitHubActionsCI.js"; +import { blockGitignore } from "./blockGitignore.js"; +import { blockPackageJson } from "./blockPackageJson.js"; +import { blockPrettier } from "./blockPrettier.js"; +import { blockTSup } from "./blockTSup.js"; +import { blockVSCode } from "./blockVSCode.js"; +import { getPackageDependencies } from "./packageData.js"; + +export const blockVitest = base.createBlock({ + about: { + name: "Vitest", + }, + addons: { + coverage: z + .object({ + directory: z.string(), + flags: z.string().optional(), + }) + .default({ directory: "coverage" }), + exclude: z.array(z.string()).default([]), + include: z.array(z.string()).default([]), + }, + produce({ addons }) { + const { coverage, exclude = [], include = [] } = addons; + const excludeText = JSON.stringify(exclude); + const includeText = JSON.stringify(include); + + return { + addons: [ + blockCSpell({ + ignores: [coverage.directory], + }), + blockESLint({ + extensions: [ + { + extends: ["vitest.configs.recommended"], + files: ["**/*.test.*"], + rules: [ + { + entries: { + "@typescript-eslint/no-unsafe-assignment": "off", + }, + }, + ], + }, + ], + ignores: [coverage.directory, "**/*.snap"], + imports: [{ source: "@vitest/eslint-plugin", specifier: "vitest" }], + }), + blockDevelopmentDocs({ + sections: { + Testing: { + contents: ` +[Vitest](https://vitest.dev) is used for tests. +You can run it locally on the command-line: + +\`\`\`shell +pnpm run test +\`\`\` + +Add the \`--coverage\` flag to compute test coverage and place reports in the \`coverage/\` directory: + +\`\`\`shell +pnpm run test --coverage +\`\`\` + +Note that [console-fail-test](https://github.com/JoshuaKGoldberg/console-fail-test) is enabled for all test runs. +Calls to \`console.log\`, \`console.warn\`, and other console methods will cause a test to fail. + + + `, + }, + }, + }), + blockGitignore({ + ignores: [`/${coverage.directory}`], + }), + blockGitHubActionsCI({ + jobs: [ + { + name: "Test", + steps: [ + { run: "pnpm run test --coverage" }, + { + if: "always()", + uses: "codecov/codecov-action@v3", + ...(coverage.flags && { with: { flags: coverage.flags } }), + }, + ], + }, + ], + }), + blockPackageJson({ + properties: { + devDependencies: getPackageDependencies( + "@vitest/coverage-v8", + "@vitest/eslint-plugin", + "console-fail-test", + "vitest", + ), + scripts: { + test: "vitest", + }, + }, + }), + blockPrettier({ + ignores: [`/${coverage.directory}`], + }), + blockTSup({ + entry: ["!src/**/*.test.*"], + }), + blockVSCode({ + debuggers: [ + { + args: ["run", "${relativeFile}"], + autoAttachChildProcesses: true, + console: "integratedTerminal", + name: "Debug Current Test File", + program: "${workspaceRoot}/node_modules/vitest/vitest.mjs", + request: "launch", + skipFiles: ["/**", "**/node_modules/**"], + smartStep: true, + type: "node", + }, + ], + extensions: ["vitest.explorer"], + }), + ], + files: { + "vitest.config.ts": `import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + clearMocks: true, + coverage: { + all: true, + exclude: ${excludeText}, + include: ${includeText}, + reporter: ["html", "lcov"], + }, + exclude: [${excludeText.slice(1, excludeText.length - 1)}, "node_modules"], + setupFiles: ["console-fail-test/setup"], + }, +}); + `, + }, + }; + }, +}); diff --git a/src/next/blocks/index.ts b/src/next/blocks/index.ts new file mode 100644 index 00000000..26d79f04 --- /dev/null +++ b/src/next/blocks/index.ts @@ -0,0 +1,42 @@ +export * from "./blockAllContributors.js"; +export * from "./blockAreTheTypesWrong.js"; +export * from "./blockContributingDocs.js"; +export * from "./blockContributorCovenant.js"; +export * from "./blockCSpell.js"; +export * from "./blockDevelopmentDocs.js"; +export * from "./blockESLint.js"; +export * from "./blockESLintComments.js"; +export * from "./blockESLintJSDoc.js"; +export * from "./blockESLintJSONC.js"; +export * from "./blockESLintMarkdown.js"; +export * from "./blockESLintMoreStyling.js"; +export * from "./blockESLintNode.js"; +export * from "./blockESLintPackageJson.js"; +export * from "./blockESLintPerfectionist.js"; +export * from "./blockESLintRegexp.js"; +export * from "./blockESLintYML.js"; +export * from "./blockFunding.js"; +export * from "./blockGitHubActionsCI.js"; +export * from "./blockGitHubIssueTemplates.js"; +export * from "./blockGitHubPRTemplate.js"; +export * from "./blockGitignore.js"; +export * from "./blockKnip.js"; +export * from "./blockMarkdownlint.js"; +export * from "./blockMITLicense.js"; +export * from "./blockNvmrc.js"; +export * from "./blockPackageJson.js"; +export * from "./blockPnpmDedupe.js"; +export * from "./blockPRCompliance.js"; +export * from "./blockPrettier.js"; +export * from "./blockPrettierPluginCurly.js"; +export * from "./blockPrettierPluginPackageJson.js"; +export * from "./blockPrettierPluginSh.js"; +export * from "./blockREADME.js"; +export * from "./blockReleaseIt.js"; +export * from "./blockRenovate.js"; +export * from "./blockSecurityDocs.js"; +export * from "./blockTemplatedBy.js"; +export * from "./blockTSup.js"; +export * from "./blockTypeScript.js"; +export * from "./blockVitest.js"; +export * from "./blockVSCode.js"; diff --git a/src/next/blocks/options.fakes.ts b/src/next/blocks/options.fakes.ts new file mode 100644 index 00000000..75f172a5 --- /dev/null +++ b/src/next/blocks/options.fakes.ts @@ -0,0 +1,13 @@ +import { BaseOptions } from "../base.js"; + +export const optionsBase = { + access: "public", + description: "Test description", + email: { + github: "github@email.com", + npm: "npm@email.com", + }, + owner: "test-owner", + repository: "test-repository", + title: "Test Title", +} satisfies BaseOptions; diff --git a/src/next/blocks/packageData.ts b/src/next/blocks/packageData.ts new file mode 100644 index 00000000..bbec5e03 --- /dev/null +++ b/src/next/blocks/packageData.ts @@ -0,0 +1,34 @@ +import { createRequire } from "node:module"; + +const require = createRequire(import.meta.url); + +const packageData = + // Importing from above src/ would expand the TS build rootDir + + require("../../../package.json") as typeof import("../../../package.json"); + +const getPackageInner = ( + key: "dependencies" | "devDependencies", + name: string, +) => { + const inner = packageData[key]; + + return inner[name as keyof typeof inner] as string | undefined; +}; + +export const getPackageDependencies = (...names: string[]) => + Object.fromEntries( + names.map((name) => { + const version = + getPackageInner("devDependencies", name) ?? + getPackageInner("dependencies", name); + + if (!version) { + throw new Error( + `'${name} is neither in package.json's dependencies nor its devDependencies.`, + ); + } + + return [name, version]; + }), + ); diff --git a/src/next/inputs/inputJSONFile.ts b/src/next/inputs/inputJSONFile.ts new file mode 100644 index 00000000..b51af466 --- /dev/null +++ b/src/next/inputs/inputJSONFile.ts @@ -0,0 +1,15 @@ +import { createInput } from "create"; +import { z } from "zod"; + +export const inputJSONFile = createInput({ + args: { + filePath: z.string(), + }, + async produce({ args, fs }) { + try { + return JSON.parse(await fs.readFile(args.filePath)) as unknown; + } catch { + return undefined; + } + }, +}); diff --git a/src/next/inputs/inputTextFile.ts b/src/next/inputs/inputTextFile.ts new file mode 100644 index 00000000..e9416f6b --- /dev/null +++ b/src/next/inputs/inputTextFile.ts @@ -0,0 +1,15 @@ +import { createInput } from "create"; +import { z } from "zod"; + +export const inputTextFile = createInput({ + args: { + filePath: z.string(), + }, + async produce({ args, fs }) { + try { + return await fs.readFile(args.filePath); + } catch { + return undefined; + } + }, +}); diff --git a/src/next/presetCommon.ts b/src/next/presetCommon.ts new file mode 100644 index 00000000..8a7ae549 --- /dev/null +++ b/src/next/presetCommon.ts @@ -0,0 +1,19 @@ +import { base } from "./base.js"; +import { blockAllContributors } from "./blocks/blockAllContributors.js"; +import { blockReleaseIt } from "./blocks/blockReleaseIt.js"; +import { blockVitest } from "./blocks/blockVitest.js"; +import { presetMinimal } from "./presetMinimal.js"; + +export const presetCommon = base.createPreset({ + about: { + description: + "Bare starters plus testing and automation for all-contributors and releases.", + name: "Common", + }, + blocks: [ + ...presetMinimal.blocks, + blockAllContributors, + blockReleaseIt, + blockVitest, + ], +}); diff --git a/src/next/presetEverything.ts b/src/next/presetEverything.ts new file mode 100644 index 00000000..57640329 --- /dev/null +++ b/src/next/presetEverything.ts @@ -0,0 +1,57 @@ +import { base } from "./base.js"; +import { blockCSpell } from "./blocks/blockCSpell.js"; +import { blockESLintComments } from "./blocks/blockESLintComments.js"; +import { blockESLintJSDoc } from "./blocks/blockESLintJSDoc.js"; +import { blockESLintJSONC } from "./blocks/blockESLintJSONC.js"; +import { blockESLintMarkdown } from "./blocks/blockESLintMarkdown.js"; +import { blockESLintMoreStyling } from "./blocks/blockESLintMoreStyling.js"; +import { blockESLintNode } from "./blocks/blockESLintNode.js"; +import { blockESLintPackageJson } from "./blocks/blockESLintPackageJson.js"; +import { blockESLintPerfectionist } from "./blocks/blockESLintPerfectionist.js"; +import { blockESLintRegexp } from "./blocks/blockESLintRegexp.js"; +import { blockESLintYML } from "./blocks/blockESLintYML.js"; +import { blockKnip } from "./blocks/blockKnip.js"; +import { blockMarkdownlint } from "./blocks/blockMarkdownlint.js"; +import { blockNvmrc } from "./blocks/blockNvmrc.js"; +import { blockPnpmDedupe } from "./blocks/blockPnpmDedupe.js"; +import { blockPRCompliance } from "./blocks/blockPRCompliance.js"; +import { blockPrettierPluginCurly } from "./blocks/blockPrettierPluginCurly.js"; +import { blockPrettierPluginPackageJson } from "./blocks/blockPrettierPluginPackageJson.js"; +import { blockPrettierPluginSh } from "./blocks/blockPrettierPluginSh.js"; +import { blockRenovate } from "./blocks/blockRenovate.js"; +import { blockSecurityDocs } from "./blocks/blockSecurityDocs.js"; +import { blockVSCode } from "./blocks/blockVSCode.js"; +import { presetCommon } from "./presetCommon.js"; + +export const presetEverything = base.createPreset({ + about: { + description: + "The most comprehensive tooling imaginable: sorting, spellchecking, and more!", + name: "Everything", + }, + blocks: [ + ...presetCommon.blocks, + blockCSpell, + blockESLintComments, + blockESLintJSDoc, + blockESLintJSONC, + blockESLintMarkdown, + blockESLintMoreStyling, + blockESLintNode, + blockESLintPackageJson, + blockESLintPerfectionist, + blockESLintRegexp, + blockESLintYML, + blockKnip, + blockMarkdownlint, + blockNvmrc, + blockPnpmDedupe, + blockPRCompliance, + blockPrettierPluginCurly, + blockPrettierPluginPackageJson, + blockPrettierPluginSh, + blockRenovate, + blockSecurityDocs, + blockVSCode, + ], +}); diff --git a/src/next/presetMinimal.ts b/src/next/presetMinimal.ts new file mode 100644 index 00000000..04cbe662 --- /dev/null +++ b/src/next/presetMinimal.ts @@ -0,0 +1,43 @@ +import { base } from "./base.js"; +import { blockContributingDocs } from "./blocks/blockContributingDocs.js"; +import { blockContributorCovenant } from "./blocks/blockContributorCovenant.js"; +import { blockDevelopmentDocs } from "./blocks/blockDevelopmentDocs.js"; +import { blockESLint } from "./blocks/blockESLint.js"; +import { blockFunding } from "./blocks/blockFunding.js"; +import { blockGitHubActionsCI } from "./blocks/blockGitHubActionsCI.js"; +import { blockGitHubIssueTemplates } from "./blocks/blockGitHubIssueTemplates.js"; +import { blockGitHubPRTemplate } from "./blocks/blockGitHubPRTemplate.js"; +import { blockGitignore } from "./blocks/blockGitignore.js"; +import { blockMITLicense } from "./blocks/blockMITLicense.js"; +import { blockPackageJson } from "./blocks/blockPackageJson.js"; +import { blockPrettier } from "./blocks/blockPrettier.js"; +import { blockREADME } from "./blocks/blockREADME.js"; +import { blockTemplatedBy } from "./blocks/blockTemplatedBy.js"; +import { blockTSup } from "./blocks/blockTSup.js"; +import { blockTypeScript } from "./blocks/blockTypeScript.js"; + +export const presetMinimal = base.createPreset({ + about: { + description: + "Just bare starter tooling: building, formatting, linting, and type checking.", + name: "Minimal", + }, + blocks: [ + blockContributingDocs, + blockContributorCovenant, + blockDevelopmentDocs, + blockESLint, + blockFunding, + blockGitHubActionsCI, + blockGitHubIssueTemplates, + blockGitHubPRTemplate, + blockGitignore, + blockMITLicense, + blockPackageJson, + blockPrettier, + blockREADME, + blockTemplatedBy, + blockTSup, + blockTypeScript, + ], +}); diff --git a/src/next/template.ts b/src/next/template.ts new file mode 100644 index 00000000..3165d95b --- /dev/null +++ b/src/next/template.ts @@ -0,0 +1,19 @@ +import { createTemplate } from "create"; + +import { presetCommon } from "./presetCommon.js"; +import { presetEverything } from "./presetEverything.js"; +import { presetMinimal } from "./presetMinimal.js"; + +export const template = createTemplate({ + about: { + name: "TypeScript App", + }, + default: "common", + presets: [ + { label: "common", preset: presetCommon }, + { label: "everything", preset: presetEverything }, + { label: "minimal", preset: presetMinimal }, + ], +}); + +export default template; diff --git a/src/next/utils/removeTrailingSlash.ts b/src/next/utils/removeTrailingSlash.ts new file mode 100644 index 00000000..12dd8500 --- /dev/null +++ b/src/next/utils/removeTrailingSlash.ts @@ -0,0 +1,3 @@ +export function removeTrailingSlash(text: string) { + return text.replace(/\/$/, ""); +} diff --git a/src/next/utils/sortObject.ts b/src/next/utils/sortObject.ts new file mode 100644 index 00000000..03d7902b --- /dev/null +++ b/src/next/utils/sortObject.ts @@ -0,0 +1,6 @@ +// TODO: move to npm package? +export function sortObject(value: object | Record) { + return Object.fromEntries( + Object.entries(value).sort(([a], [b]) => a.localeCompare(b)), + ); +} diff --git a/src/shared/isUsingCreateEngine.ts b/src/shared/isUsingCreateEngine.ts new file mode 100644 index 00000000..da251858 --- /dev/null +++ b/src/shared/isUsingCreateEngine.ts @@ -0,0 +1,5 @@ +export function isUsingCreateEngine() { + return ( + !!process.env.CTA_CREATE_ENGINE && Boolean(process.env.CTA_CREATE_ENGINE) + ); +} diff --git a/src/shared/options/createOptionDefaults/readDefaultsFromReadme.test.ts b/src/shared/options/createOptionDefaults/readDefaultsFromReadme.test.ts index f733e175..712300e7 100644 --- a/src/shared/options/createOptionDefaults/readDefaultsFromReadme.test.ts +++ b/src/shared/options/createOptionDefaults/readDefaultsFromReadme.test.ts @@ -104,7 +104,7 @@ nothing. it("parses when found after an h1 and many badge images", async () => { mockReadFileSafe.mockResolvedValue(`

Create TypeScript App

-

Quickstart-friendly TypeScript template with comprehensive, configurable, opinionated tooling. ๐Ÿ’

+

Quickstart-friendly TypeScript template with comprehensive, configurable, opinionated tooling. โค๏ธโ€๐Ÿ”ฅ

diff --git a/src/shared/types.ts b/src/shared/types.ts index dec00a13..45619f65 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -6,6 +6,8 @@ export interface AllContributorContributor { avatar_url: string; contributions: string[]; login: string; + name: string; + profile: string; } export interface AllContributorsData { diff --git a/src/steps/finalizeDependencies.ts b/src/steps/finalizeDependencies.ts index 2ef643d4..518f2baa 100644 --- a/src/steps/finalizeDependencies.ts +++ b/src/steps/finalizeDependencies.ts @@ -1,66 +1,73 @@ import { execaCommand } from "execa"; +import { isUsingCreateEngine } from "../shared/isUsingCreateEngine.js"; import { readPackageData, removeDependencies } from "../shared/packages.js"; import { Options } from "../shared/types.js"; export async function finalizeDependencies(options: Options) { - const devDependencies = [ - "@eslint/js", - "@types/node", - "eslint", - "eslint-plugin-n", - "husky", - "lint-staged", - "prettier", - "prettier-plugin-curly", - "prettier-plugin-sh", - "prettier-plugin-packagejson", - "tsup", - "typescript", - "typescript-eslint", - ...(options.excludeAllContributors ? [] : ["all-contributors-cli"]), - ...(options.excludeLintESLint - ? [] - : ["@eslint-community/eslint-plugin-eslint-comments"]), - ...(options.excludeLintJSDoc ? [] : ["eslint-plugin-jsdoc"]), - ...(options.excludeLintJson ? [] : ["eslint-plugin-jsonc"]), - ...(options.excludeLintKnip ? [] : ["knip"]), - ...(options.excludeLintMd - ? [] - : [ - "@types/eslint-plugin-markdown", - "eslint-plugin-markdown", - "markdownlint", - "markdownlint-cli", - "sentences-per-line", - ]), - ...(options.excludeLintPackageJson ? [] : ["eslint-plugin-package-json"]), - ...(options.excludeLintPerfectionist - ? [] - : ["eslint-plugin-perfectionist"]), - ...(options.excludeLintRegex ? [] : ["eslint-plugin-regexp"]), - ...(options.excludeLintSpelling ? [] : ["cspell"]), - ...(options.excludeLintYml ? [] : ["eslint-plugin-yml"]), - ...(options.excludeReleases - ? [] - : ["@release-it/conventional-changelog", "release-it"]), - ...(options.excludeTests - ? [] - : [ - "@vitest/coverage-v8", - "@vitest/eslint-plugin", - "console-fail-test", - "vitest", - ]), - ] - .filter(Boolean) - .sort() - .map((packageName) => `${packageName}@latest`) - .join(" "); + if (isUsingCreateEngine()) { + // TODO: How to switch from "latest" to the actual versions? + // Maybe an eslint-plugin-package-json lint rule? + await execaCommand("pnpm install"); + } else { + const devDependencies = [ + "@eslint/js", + "@types/node", + "eslint-plugin-n", + "eslint", + "husky", + "lint-staged", + "prettier-plugin-curly", + "prettier-plugin-packagejson", + "prettier-plugin-sh", + "prettier", + "tsup", + "typescript-eslint", + "typescript", + ...(options.excludeAllContributors ? [] : ["all-contributors-cli"]), + ...(options.excludeLintESLint + ? [] + : ["@eslint-community/eslint-plugin-eslint-comments"]), + ...(options.excludeLintJSDoc ? [] : ["eslint-plugin-jsdoc"]), + ...(options.excludeLintJson ? [] : ["eslint-plugin-jsonc"]), + ...(options.excludeLintKnip ? [] : ["knip"]), + ...(options.excludeLintMd + ? [] + : [ + "@types/eslint-plugin-markdown", + "eslint-plugin-markdown", + "markdownlint", + "markdownlint-cli", + "sentences-per-line", + ]), + ...(options.excludeLintPackageJson ? [] : ["eslint-plugin-package-json"]), + ...(options.excludeLintPerfectionist + ? [] + : ["eslint-plugin-perfectionist"]), + ...(options.excludeLintRegex ? [] : ["eslint-plugin-regexp"]), + ...(options.excludeLintSpelling ? [] : ["cspell"]), + ...(options.excludeLintYml ? [] : ["eslint-plugin-yml"]), + ...(options.excludeReleases + ? [] + : ["@release-it/conventional-changelog", "release-it"]), + ...(options.excludeTests + ? [] + : [ + "@vitest/coverage-v8", + "@vitest/eslint-plugin", + "console-fail-test", + "vitest", + ]), + ] + .filter(Boolean) + .sort() + .map((packageName) => `${packageName}@latest`) + .join(" "); - await execaCommand( - `pnpm add ${devDependencies} -D${options.offline ? " --offline" : ""}`, - ); + await execaCommand( + `pnpm add ${devDependencies} -D${options.offline ? " --offline" : ""}`, + ); + } if (!options.excludeAllContributors) { await execaCommand(`npx all-contributors-cli generate`); diff --git a/src/steps/removeSetupScripts.ts b/src/steps/removeSetupScripts.ts index c38a61b8..69bc66a8 100644 --- a/src/steps/removeSetupScripts.ts +++ b/src/steps/removeSetupScripts.ts @@ -1,5 +1,7 @@ import * as fs from "node:fs/promises"; +import { formatTypeScript } from "./writing/creation/formatters/formatTypeScript.js"; + const globPaths = [ "./bin", "./docs", @@ -8,6 +10,7 @@ const globPaths = [ "./src/create", "./src/initialize", "./src/migrate", + "./src/next", "./src/shared", "./src/steps", ]; @@ -16,4 +19,11 @@ export async function removeSetupScripts() { for (const globPath of globPaths) { await fs.rm(globPath, { force: true, recursive: true }); } + + await fs.writeFile( + "./src/index.ts", + await formatTypeScript( + [`export * from "./greet.js";`, `export * from "./types.js";`].join("\n"), + ), + ); } diff --git a/src/steps/uninstallPackages.ts b/src/steps/uninstallPackages.ts index 9c44b7a3..bba702ad 100644 --- a/src/steps/uninstallPackages.ts +++ b/src/steps/uninstallPackages.ts @@ -11,6 +11,7 @@ export async function uninstallPackages(offline: boolean | undefined) { "@prettier/sync", "all-contributors-for-repository", "chalk", + "create", "execa", "get-github-auth-token", "git-remote-origin-url", @@ -20,6 +21,7 @@ export async function uninstallPackages(offline: boolean | undefined) { "npm-user", "octokit", "parse-author", + "parse-package-name", "prettier", "replace-in-file", "rimraf", @@ -39,6 +41,7 @@ export async function uninstallPackages(offline: boolean | undefined) { "@types/parse-author", "all-contributors-cli", "c8", + "create-testers", "eslint-config-prettier", "globby", "tsx", diff --git a/src/steps/writing/creation/createESLintConfig.test.ts b/src/steps/writing/creation/createESLintConfig.test.ts index dc6bb88d..0b2e5fd0 100644 --- a/src/steps/writing/creation/createESLintConfig.test.ts +++ b/src/steps/writing/creation/createESLintConfig.test.ts @@ -148,9 +148,7 @@ describe("createESLintConfig", () => { { extends: [vitest.configs.recommended], files: ["**/*.test.*"], - rules: { - "@typescript-eslint/no-unsafe-assignment": "off", - }, + rules: { "@typescript-eslint/no-unsafe-assignment": "off" }, }, { extends: [yml.configs["flat/recommended"], yml.configs["flat/prettier"]], diff --git a/src/steps/writing/creation/createESLintConfig.ts b/src/steps/writing/creation/createESLintConfig.ts index 9f9ba731..a640cf08 100644 --- a/src/steps/writing/creation/createESLintConfig.ts +++ b/src/steps/writing/creation/createESLintConfig.ts @@ -111,9 +111,7 @@ export default tseslint.config( { extends: [vitest.configs.recommended], files: ["**/*.test.*"], - rules: { - "@typescript-eslint/no-unsafe-assignment": "off", - }, + rules: { "@typescript-eslint/no-unsafe-assignment": "off" }, },` }${ options.excludeLintYml diff --git a/src/steps/writing/creation/index.test.ts b/src/steps/writing/creation/index.test.ts new file mode 100644 index 00000000..46549aac --- /dev/null +++ b/src/steps/writing/creation/index.test.ts @@ -0,0 +1,291 @@ +import { describe, expect, it } from "vitest"; + +import { getPackageDependencies } from "../../../next/blocks/packageData.js"; +import { Options } from "../../../shared/types.js"; +import { createStructure } from "./index.js"; + +/* eslint-disable @typescript-eslint/no-dynamic-delete */ + +const documentation = ` +## Setup Scripts + +As described in the \`README.md\` file and \`docs/\`, this template repository comes with three scripts that can set up an existing or new repository. + +Each follows roughly the same general flow: + +1. \`bin/index.ts\` uses \`bin/mode.ts\` to determine which of the three setup scripts to run +2. \`readOptions\` parses in options from local files, Git commands, npm APIs, and/or files on disk +3. \`runOrRestore\` wraps the setup script's main logic in a friendly prompt wrapper +4. The setup script wraps each portion of its main logic with \`withSpinner\` + - Each step of setup logic is generally imported from within \`src/steps\` +5. A call to \`outro\` summarizes the results for the user + +> **Warning** +> Each setup script overrides many files in the directory they're run in. +> Make sure to save any changes you want to preserve before running them. + +### The Creation Script + +> ๐Ÿ“ See [\`docs/Creation.md\`](../docs/Creation.md) for user documentation on the creation script. + +This template's "creation" script is located in \`src/create/\`. +You can run it locally with \`node bin/index.js --mode create\`. +Note that files need to be built with \`pnpm run build\` beforehand. + +#### Testing the Creation Script + +You can run the end-to-end test for creation locally on the command-line. +Note that the files need to be built with \`pnpm run build\` beforehand. + +\`\`\`shell +pnpm run test:create +\`\`\` + +That end-to-end test executes \`script/create-test-e2e.ts\`, which: + +1. Runs the creation script to create a new \`test-repository\` child directory and repository, capturing code coverage +2. Asserts that commands such as \`build\` and \`lint\` each pass + +The \`pnpm run test:create\` script is run in CI to ensure that templating changes are in sync with the template's actual files. +See \`.github/workflows/ci.yml\`'s \`test_creation_script\` job. + +### The Initialization Script + +> ๐Ÿ“ See [\`docs/Initialization.md\`](../docs/Initialization.md) for user documentation on the initialization script. + +This template's "initialization" script is located in \`src/initialize/\`. +You can run it locally with \`pnpm run initialize\`. +It uses [\`tsx\`](https://github.com/esbuild-kit/tsx) so you don't need to build files before running. + +\`\`\`shell +pnpm run initialize +\`\`\` + +#### Testing the Initialization Script + +You can run the end-to-end test for initializing locally on the command-line. +Note that files need to be built with \`pnpm run build\` beforehand. + +\`\`\`shell +pnpm run test:initialize +\`\`\` + +That end-to-end test executes \`script/initialize-test-e2e.ts\`, which: + +1. Runs the initialization script using \`--skip-github-api\` and other skip flags +2. Checks that the local repository's files were changed correctly (e.g. removed initialization-only files) +3. Runs \`pnpm run lint:knip\` to make sure no excess dependencies or files were left over +4. Resets everything +5. Runs initialization a second time, capturing test coverage + +The \`pnpm run test:initialize\` script is run in CI to ensure that templating changes are in sync with the template's actual files. +See \`.github/workflows/ci.yml\`'s \`test_initialization_script\` job. + +### The Migration Script + +> ๐Ÿ“ See [\`docs/Migration.md\`](../docs/Migration.md) for user documentation on the migration script. + +This template's "migration" script is located in \`src/migrate/\`. +Note that files need to be built with \`pnpm run build\` beforehand. + +To test out the script locally, run it from a different repository's directory: + +\`\`\`shell +cd ../other-repo +node ../create-typescript-app/bin/migrate.js +\`\`\` + +The migration script will work on any directory. +You can try it out in a blank directory with scripts like: + +\`\`\`shell +cd .. +mkdir temp +cd temp +node ../create-typescript-app/bin/migrate.js +\`\`\` + +#### Testing the Migration Script + +> ๐Ÿ’ก Seeing \`Oh no! Running the migrate script unexpectedly modified:\` errors? +> _[Unexpected File Modifications](#unexpected-file-modifications)_ covers that below. + +You can run the end-to-end test for migrating locally on the command-line: + +\`\`\`shell +pnpm run test:migrate +\`\`\` + +That end-to-end test executes \`script/migrate-test-e2e.ts\`, which: + +1. Runs the migration script using \`--skip-github-api\` and other skip flags, capturing code coverage +2. Checks that only a small list of allowed files were changed +3. Checks that the local repository's files were changed correctly (e.g. removed initialization-only files) + +The \`pnpm run test:migrate\` script is run in CI to ensure that templating changes are in sync with the template's actual files. +See \`.github/workflows/ci.yml\`'s \`test_migration_script\` job. + +> Tip: if the migration test is failing in CI and you don't see any errors, try [downloading the full logs](https://docs.github.com/en/actions/monitoring-and-troubleshooting-workflows/using-workflow-run-logs#downloading-logs). + +##### Migration Snapshot Failures + +The migration test uses the [Vitest file snapshot](https://vitest.dev/guide/snapshot#file-snapshots) in \`script/__snapshots__/migrate-test-e2e.ts.snap\` to store expected differences to this repository after running the migration script. +The end-to-end migration test will fail any changes that don't keep the same differences in that snapshot. + +You can update the snapshot file by: + +1. Committing any changes to your local repository +2. Running \`pnpm i\` and \`pnpm build\` if any updates have been made to the \`package.json\` or \`src/\` files, respectively +3. Running \`pnpm run test:migrate -u\` to update the snapshot + +At this point there will be some files changed: + +- \`script/__snapshots__/migrate-test-e2e.ts.snap\` will have updates if any files mismatched templates +- The actual updated files on disk will be there too + +If the snapshot file changes are what you expected, then you can commit them. +The rest of the file changes can be reverted. + +> [๐Ÿš€ Feature: Add a way to apply known file changes after migration #1184](https://github.com/JoshuaKGoldberg/create-typescript-app/issues/1184) tracks turning the test snapshot into a feature. + +##### Unexpected File Modifications + +The migration test also asserts that no files were unexpectedly changed. +If you see a failure like: + +\`\`\`plaintext +Oh no! Running the migrate script unexpectedly modified: + - ... +\`\`\` + +...then that means the file generated from templates differs from what's checked into the repository. +This is most often caused by changes to templates not being applied to checked-in files too. + +Templates for files are generally stored in [\`src/steps/writing/creation\`] under a path roughly corresponding to the file they describe. +For example, the template for \`tsup.config.ts\` is stored in [\`src/steps/writing/creation/createTsupConfig.ts\`](../src/steps/writing/creation/createTsupConfig.ts). +If the \`createTsupConfig\` function were to be modified without an equivalent change to \`tsup.config.ts\` -or vice-versa- then the migration test would report: + +\`\`\`plaintext +Oh no! Running the migrate script unexpectedly modified: + - tsup.config.ts +\`\`\` +`; + +const optionsBaseline: Options = { + access: "public", + author: "Test Author", + base: "everything", + bin: "bin/test.js", + description: "Test Description", + directory: "test-directory", + email: { github: "github@example.com", npm: "npm@example.com" }, + funding: "Test Funding", + guide: { + href: "https://www.joshuakgoldberg.com/blog/contributing-to-a-create-typescript-app-repository", + title: "Contributing to a create-typescript-app Repository", + }, + keywords: ["test", "keywords"], + logo: { alt: "Test Alt", src: "test.png" }, + mode: "create", + owner: "Test Owner", + repository: "test-repo", + title: "Test Title", +}; + +describe("createStructure", () => { + describe.each(["minimal", "common", "everything"])("base %s", () => { + it("matches current and next", async () => { + const optionsNext = { + ...optionsBaseline, + documentation, + packageData: { + dependencies: getPackageDependencies( + "@clack/prompts", + "@prettier/sync", + "all-contributors-for-repository", + "chalk", + "create", + "execa", + "get-github-auth-token", + "git-remote-origin-url", + "git-url-parse", + "js-yaml", + "lazy-value", + "npm-user", + "octokit", + "parse-author", + "parse-package-name", + "prettier", + "replace-in-file", + "rimraf", + "sort-package-json", + "title-case", + "zod", + "zod-validation-error", + ), + devDependencies: getPackageDependencies( + "@octokit/request-error", + "@release-it/conventional-changelog", + "@types/git-url-parse", + "@types/js-yaml", + "@types/parse-author", + "c8", + "create-testers", + "cspell", + "globby", + "release-it", + "tsx", + ), + scripts: { + initialize: "tsx ./bin/index.js --mode initialize", + "test:create": "npx tsx script/create-test-e2e.ts", + "test:initialize": "npx tsx script/initialize-test-e2e.ts", + "test:migrate": "vitest run -r script/", + }, + }, + }; + + const baseline = await createStructure(optionsBaseline, false); + const next = await createStructure(optionsNext, true); + + // Test display cleaning: just don't show values that are the same + deleteEqualValuesDeep(baseline, next); + + for (const rootFile of [ + // This is created separately + "README.md", + ]) { + delete baseline[rootFile]; + delete next[rootFile]; + } + + expect(next).toEqual(baseline); + }); + }); +}); + +function deleteEqualValues(a: T, b: T) { + for (const i in a) { + if (a[i] === b[i]) { + delete b[i]; + delete a[i]; + } + } +} + +function deleteEqualValuesDeep(a: T, b: T) { + deleteEqualValues(a, b); + + for (const i in a) { + if (a[i] && typeof a[i] === "object" && b[i] && typeof b[i] === "object") { + deleteEqualValuesDeep(a[i], b[i]); + + if (Object.keys(a[i]).length === 0 && Object.keys(b[i]).length === 0) { + delete a[i]; + delete b[i]; + } + } + } +} + +/* eslint-enable @typescript-eslint/no-dynamic-delete */ diff --git a/src/steps/writing/creation/index.ts b/src/steps/writing/creation/index.ts index 897e6b35..e02b58c7 100644 --- a/src/steps/writing/creation/index.ts +++ b/src/steps/writing/creation/index.ts @@ -1,3 +1,9 @@ +import { CreatedFiles, producePreset } from "create"; +import prettier from "prettier"; + +import { presetCommon } from "../../../next/presetCommon.js"; +import { presetEverything } from "../../../next/presetEverything.js"; +import { presetMinimal } from "../../../next/presetMinimal.js"; import { Options } from "../../../shared/types.js"; import { Structure } from "../types.js"; import { createDotGitHub } from "./dotGitHub/index.js"; @@ -6,7 +12,32 @@ import { createDotVSCode } from "./dotVSCode.js"; import { createRootFiles } from "./rootFiles.js"; import { createSrc } from "./src.js"; -export async function createStructure(options: Options): Promise { +const presets = { + common: presetCommon, + everything: presetEverything, + minimal: presetMinimal, +}; + +export async function createStructure( + options: Options, + useNextEngine: boolean, +): Promise { + const preset = + useNextEngine && + options.base && + options.base !== "prompt" && + presets[options.base]; + + if (preset) { + const creation = await producePreset(preset, { options }); + + return await recursivelyFormat({ + ...creation.files, + // TODO: Add a "starting files" option in create Presets/Templates? + src: await createSrc(options), + }); + } + return { ".github": await createDotGitHub(options), ".husky": createDotHusky(), @@ -15,3 +46,40 @@ export async function createStructure(options: Options): Promise { ...(await createRootFiles(options)), }; } + +async function recursivelyFormat(files: CreatedFiles): Promise { + const result: Structure = {}; + + for (const [key, value] of Object.entries(files)) { + switch (typeof value) { + case "object": + result[key] = await recursivelyFormat(value); + break; + case "string": + result[key] = await formatCreatedFile(key, value); + break; + } + } + + return result; +} + +const asYaml = new Set([ + ".gitignore", + ".markdownlintignore", + ".nvmrc", + ".prettierignore", + "pre-commit", +]); + +async function formatCreatedFile(filepath: string, entry: string) { + // For now, explicit yml files internally already have formatting applied + if (filepath.endsWith(".yml")) { + return entry; + } + + return await prettier.format(entry, { + useTabs: true, + ...(asYaml.has(filepath) ? { parser: "yaml" } : { filepath }), + }); +} diff --git a/src/steps/writing/creation/writePackageJson.ts b/src/steps/writing/creation/writePackageJson.ts index 7324eb0e..dc651eb6 100644 --- a/src/steps/writing/creation/writePackageJson.ts +++ b/src/steps/writing/creation/writePackageJson.ts @@ -53,7 +53,11 @@ export async function writePackageJson(options: Options) { : undefined, // We copy all existing dev dependencies except those we know are not used anymore - devDependencies: copyDevDependencies(existingPackageJson), + devDependencies: { + ...copyDevDependencies(existingPackageJson), + // prettier is a special case of being switched to a devDependency + prettier: existingPackageJson.dependencies?.prettier, + }, // Remove fields we know we don't want, such as old or redundant configs eslintConfig: undefined, diff --git a/src/steps/writing/writeStructure.ts b/src/steps/writing/writeStructure.ts index 1f5fd7e5..f6962ffb 100644 --- a/src/steps/writing/writeStructure.ts +++ b/src/steps/writing/writeStructure.ts @@ -1,11 +1,15 @@ import { $ } from "execa"; +import { isUsingCreateEngine } from "../../shared/isUsingCreateEngine.js"; import { Options } from "../../shared/types.js"; import { createStructure } from "./creation/index.js"; import { writeStructureWorker } from "./writeStructureWorker.js"; export async function writeStructure(options: Options) { - await writeStructureWorker(await createStructure(options), "."); + await writeStructureWorker( + await createStructure(options, isUsingCreateEngine()), + ".", + ); try { // https://github.com/JoshuaKGoldberg/create-typescript-app/issues/718