Repository: JoshuaKGoldberg/create-typescript-app (ts)

Issue:
🐛 Bug: Transition mode clobbers cspell.json words
### Bug Report Checklist

- [x] I have tried restarting my IDE and the issue persists.
- [x] I have pulled the latest `main` branch of the repository.
- [x] I have [searched for related issues](https://github.com/JoshuaKGoldberg/create-typescript-app/issues?q=is%3Aissue) and found none that matched my issue.

### Expected

If a repo adds `words` defined in its `cspell.json`, they should be preserved across transition mode

### Actual

https://github.com/JoshuaKGoldberg/are-docs-informative/pull/690/commits/8994a52a233d75db0271f4fd2996ee8c6fb8bd00 shows that they are not.

### Additional Info

🎁 

Pull request description:
feat: add --words with default from cspell.json words
## PR Checklist

- [x] Addresses an existing open issue: fixes #2020
- [x] That issue was marked as [`status: accepting prs`](https://github.com/JoshuaKGoldberg/create-typescript-app/issues?q=is%3Aopen+is%3Aissue+label%3A%22status%3A+accepting+prs%22)
- [x] Steps in [CONTRIBUTING.md](https://github.com/JoshuaKGoldberg/create-typescript-app/blob/main/.github/CONTRIBUTING.md) were taken

## Overview

Adds a `words?: string[]` option that defaults to reading `cspell.json` as JSON. `blockCSpell` will add in any words from it to what it produces.

🎁 

Patch:
diff --git a/docs/CLI.md b/docs/CLI.md
index 728c980..50d1ff5 100644
--- a/docs/CLI.md
+++ b/docs/CLI.md
@@ -43,20 +43,21 @@ See [Bingo > Stratum > Concepts > Templates > `--preset`](https://www.create.bin
 The following flags may be provided on the CLI to customize their values.
 Each defaults to a value based on the running system, including an repository if transitioning one.
 
-| Flag           | Type       | Description                                                                                                         | Default                                                                               |
-| -------------- | ---------- | ------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- |
-| `--access`     | `string`   | Which [`npm publish --access`](https://docs.npmjs.com/cli/commands/npm-publish#access) to release npm packages with | `"public"`                                                                            |
-| `--author`     | `string`   | Username on npm to publish packages under                                                                           | An existing npm author, or the currently logged in npm user, or `owner.toLowerCase()` |
-| `--bin`        | `string`   | Value to set in `package.json`'s `"bin"` property, per [FAQs > How can I use `bin`?](./FAQs.md#how-can-i-use-bin)   | _(none)_                                                                              |
-| `--email`      | `string`   | Email address to be listed as the point of contact in docs and packages (e.g. `example@joshuakgoldberg.com`)        | Yours from `gh`, `git config`, or `npm whoami`                                        |
-| `--emoji`      | `string`   | decorative emoji to use in descriptions and docs                                                                    | The last emoji from `description`, or `"💖"`                                          |
-| `--funding`    | `string`   | GitHub organization or username to mention in `funding.yml`                                                         | The same as `owner`                                                                   |
-| `--keywords`   | `string[]` | Any number of keywords to include in `package.json`                                                                 | _(none)_                                                                              |
-| `--owner`      | `string`   | Organization or user owning the repository                                                                          | Yours from `gh` or `git config`                                                       |
-| `--pnpm`       | `string`   | pnpm version for `package.json`'s `packageManager` field                                                            | Existing value in `package.json` if it exists                                         |
-| `--repository` | `string`   | Name for the new repository                                                                                         | The same as `--directory`                                                             |
-| `--title`      | `string`   | 'Title Case' title for the repository                                                                               | Title-cased `repository`                                                              |
-| `--version`    | `string`   | package version to publish as and store in `package.json`                                                           | Existing value in `package.json` if it exists, or `"0.0.0"`                           |
+| Flag           | Type       | Description                                                                                                         | Default                                                                                        |
+| -------------- | ---------- | ------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- |
+| `--access`     | `string`   | Which [`npm publish --access`](https://docs.npmjs.com/cli/commands/npm-publish#access) to release npm packages with | `"public"`                                                                                     |
+| `--author`     | `string`   | Username on npm to publish packages under                                                                           | An existing npm author, or the currently logged in npm user, or `owner.toLowerCase()`          |
+| `--bin`        | `string`   | Value to set in `package.json`'s `"bin"` property, per [FAQs > How can I use `bin`?](./FAQs.md#how-can-i-use-bin)   | _(none)_                                                                                       |
+| `--email`      | `string`   | Email address to be listed as the point of contact in docs and packages (e.g. `example@joshuakgoldberg.com`)        | Yours from `gh`, `git config`, or `npm whoami`                                                 |
+| `--emoji`      | `string`   | decorative emoji to use in descriptions and docs                                                                    | The last emoji from `description`, or `"💖"`                                                   |
+| `--funding`    | `string`   | GitHub organization or username to mention in `funding.yml`                                                         | The same as `owner`                                                                            |
+| `--keywords`   | `string[]` | Any number of keywords to include in `package.json`                                                                 | _(none)_                                                                                       |
+| `--owner`      | `string`   | Organization or user owning the repository                                                                          | Yours from `gh` or `git config`                                                                |
+| `--pnpm`       | `string`   | pnpm version for `package.json`'s `packageManager` field                                                            | Existing value in `package.json` if it exists                                                  |
+| `--repository` | `string`   | Name for the new repository                                                                                         | The same as `--directory`                                                                      |
+| `--title`      | `string`   | 'Title Case' title for the repository                                                                               | Title-cased `repository`                                                                       |
+| `--version`    | `string`   | package version to publish as and store in `package.json`                                                           | Existing value in `package.json` if it exists, or `"0.0.0"`                                    |
+| `--words`      | `string[]` | additional words to add to the CSpell dictionary                                                                    | Existing `words` in a `cspell.json` file if it exists, and any new words in from other options |
 
 For example, customizing the npm author and funding source to different values than what would be inferred:
 
diff --git a/src/base.ts b/src/base.ts
index d0e6224..11aa280 100644
--- a/src/base.ts
+++ b/src/base.ts
@@ -31,6 +31,7 @@ import { readRepository } from "./options/readRepository.js";
 import { readRulesetId } from "./options/readRulesetId.js";
 import { readTitle } from "./options/readTitle.js";
 import { readUsage } from "./options/readUsage.js";
+import { readWords } from "./options/readWords.js";
 import { readWorkflowsVersions } from "./options/readWorkflowsVersions.js";
 import { zContributor, zWorkflowsVersions } from "./schemas.js";
 
@@ -158,6 +159,10 @@ export const base = createBase({
 			.string()
 			.optional()
 			.describe("package version to publish as and store in `package.json`"),
+		words: z
+			.array(z.string())
+			.optional()
+			.describe("additional words to add to the CSpell dictionary"),
 		workflowsVersions: zWorkflowsVersions
 			.optional()
 			.describe("existing versions of GitHub Actions workflows used"),
@@ -273,6 +278,8 @@ export const base = createBase({
 
 		const getVersion = lazyValue(async () => (await getPackageData()).version);
 
+		const getWords = lazyValue(async () => await readWords(take));
+
 		const getWorkflowData = lazyValue(
 			async () => await readWorkflowsVersions(take),
 		);
@@ -300,6 +307,7 @@ export const base = createBase({
 			title: getTitle,
 			usage: getUsage,
 			version: getVersion,
+			words: getWords,
 			workflowsVersions: getWorkflowData,
 		};
 	},
diff --git a/src/blocks/blockCSpell.ts b/src/blocks/blockCSpell.ts
index 1a98522..0ff5280 100644
--- a/src/blocks/blockCSpell.ts
+++ b/src/blocks/blockCSpell.ts
@@ -20,9 +20,13 @@ export const blockCSpell = base.createBlock({
 		ignores: z.array(z.string()).default([]),
 		words: z.array(z.string()).default([]),
 	},
-	produce({ addons }) {
+	produce({ addons, options }) {
 		const { ignores, words } = addons;
 
+		const allWords = Array.from(
+			new Set([...(options.words ?? []), ...words]),
+		).sort();
+
 		return {
 			addons: [
 				blockDevelopmentDocs({
@@ -68,7 +72,7 @@ export const blockCSpell = base.createBlock({
 						"pnpm-lock.yaml",
 						...ignores,
 					].sort(),
-					...(words.length && { words: words.sort() }),
+					...(allWords.length && { words: allWords }),
 				}),
 			},
 		};
diff --git a/src/options/readWords.ts b/src/options/readWords.ts
new file mode 100644
index 0000000..239f60c
--- /dev/null
+++ b/src/options/readWords.ts
@@ -0,0 +1,15 @@
+import { TakeInput } from "bingo";
+import { inputFromFileJSON } from "input-from-file-json";
+
+import { swallowErrorAsync } from "../utils/swallowErrorAsync.js";
+
+export async function readWords(take: TakeInput) {
+	const cspell =
+		(await swallowErrorAsync(
+			take(inputFromFileJSON, {
+				filePath: "./cspell.json",
+			}),
+		)) || {};
+
+	return (cspell as { words?: string[] }).words;
+}


Test patch:
diff --git a/src/base.test.ts b/src/base.test.ts
index c15fc17..7b40e82 100644
--- a/src/base.test.ts
+++ b/src/base.test.ts
@@ -53,6 +53,8 @@ describe("base", () => {
 			title: "Create TypeScript App",
 			usage: expect.any(String),
 			version: expect.any(String),
+			// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-require-imports
+			words: require("../cspell.json").words,
 			workflowsVersions: expect.any(Object),
 		});
 	});
diff --git a/src/blocks/blockCSpell.test.ts b/src/blocks/blockCSpell.test.ts
index 6789976..24ac7df 100644
--- a/src/blocks/blockCSpell.test.ts
+++ b/src/blocks/blockCSpell.test.ts
@@ -9,7 +9,7 @@ vi.mock("../utils/resolveBin.js", () => ({
 }));
 
 describe("blockCSpell", () => {
-	test("without addons", () => {
+	test("without addons or options", () => {
 		const creation = testBlock(blockCSpell, {
 			options: optionsBase,
 		});
@@ -153,6 +153,79 @@ describe("blockCSpell", () => {
 		`);
 	});
 
+	test("with options", () => {
+		const creation = testBlock(blockCSpell, {
+			options: {
+				...optionsBase,
+				words: ["joshuakgoldberg"],
+			},
+		});
+
+		expect(creation).toMatchInlineSnapshot(`
+			{
+			  "addons": [
+			    {
+			      "addons": {
+			        "sections": {
+			          "Linting": {
+			            "contents": {
+			              "items": [
+			                "- \`pnpm lint:spelling\` ([cspell](https://cspell.org)): Spell checks across all source files",
+			              ],
+			            },
+			          },
+			        },
+			      },
+			      "block": [Function],
+			    },
+			    {
+			      "addons": {
+			        "extensions": [
+			          "streetsidesoftware.code-spell-checker",
+			        ],
+			      },
+			      "block": [Function],
+			    },
+			    {
+			      "addons": {
+			        "jobs": [
+			          {
+			            "name": "Lint Spelling",
+			            "steps": [
+			              {
+			                "run": "pnpm lint:spelling",
+			              },
+			            ],
+			          },
+			        ],
+			        "removedWorkflows": [
+			          "lint-spelling",
+			          "spelling",
+			        ],
+			      },
+			      "block": [Function],
+			    },
+			    {
+			      "addons": {
+			        "properties": {
+			          "devDependencies": {
+			            "cspell": "8.17.5",
+			          },
+			          "scripts": {
+			            "lint:spelling": "cspell "**" ".github/**/*"",
+			          },
+			        },
+			      },
+			      "block": [Function],
+			    },
+			  ],
+			  "files": {
+			    "cspell.json": "{"dictionaries":["npm","node","typescript"],"ignorePaths":[".github","CHANGELOG.md","lib","node_modules","pnpm-lock.yaml"],"words":["joshuakgoldberg"]}",
+			  },
+			}
+		`);
+	});
+
 	test("setup mode", () => {
 		const creation = testBlock(blockCSpell, {
 			mode: "setup",
diff --git a/src/options/readWords.test.ts b/src/options/readWords.test.ts
new file mode 100644
index 0000000..db0a5b8
--- /dev/null
+++ b/src/options/readWords.test.ts
@@ -0,0 +1,30 @@
+import { describe, expect, it, vi } from "vitest";
+
+import { readWords } from "./readWords.js";
+
+describe(readWords, () => {
+	it("returns undefined when the file does not exist", async () => {
+		const take = vi.fn().mockRejectedValueOnce(new Error("Oh no!"));
+
+		const actual = await readWords(take);
+
+		expect(actual).toBeUndefined();
+	});
+
+	it("returns undefined when the file has no words", async () => {
+		const take = vi.fn().mockResolvedValueOnce({});
+
+		const actual = await readWords(take);
+
+		expect(actual).toBeUndefined();
+	});
+
+	it("returns the words when the file has words", async () => {
+		const words = ["abc", "def"];
+		const take = vi.fn().mockResolvedValueOnce({ words });
+
+		const actual = await readWords(take);
+
+		expect(actual).toEqual(words);
+	});
+});