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

Issue:
🐛 Bug: Repo API failure: "description control characters are not allowed"
### 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

Running `npx create-typescript-app` should work on repos with long descriptions.

### Actual

Running on https://github.com/JoshuaKGoldberg/debug-for-file/tree/017f4f35803c2fb970acf6236b110ed051fde005, after putting a `console.log({ description })` in `blockRepositorySettings` > `produce`:

```plaintext
joshgoldberg ~/repos/debug-for-file $ npx create-typescript-app --add-cta-transitions
┌  ✨ create-typescript-app@2.15.6 ✨
│
●  Learn more on:
│    https://github.com/JoshuaKGoldberg/create-typescript-app
│
◇  Running with mode --transition using the config file:
│    create-typescript-app
│
{ description: '' }
{ description: '' }
{ description: '' }
│
◇  Detected --preset everything from existing files on disk.
│  
◇  Inferred options from existing repository
│
{
  description: "debug() wrapper that creates a string based on your file's path within the\n" +
    'package. 🧶'
}
◇  Ran create-typescript-app
│
■  The PATCH /repos/{owner}/{repo} request failed. You should re-run it and fix its complaints.
│  HttpError: Validation Failed: {"resource":"Repository","code":"custom","field":"description","message":"description control characters are not allowed"} - https://docs.github.com/rest/repos/repos#update-a-repository
│      at fetchWrapper (file:///Users/josh/repos/debug-for-file/node_modules/.pnpm/@octokit+request@9.2.2/node_modules/@octokit/request/dist-bundle/index.js:122:11)
│      at process.processTicksAndRejections (node:internal/process/task_queues:105:5)
│      at async requestWithGraphqlErrorHandling (file:///Users/josh/repos/debug-for-file/node_modules/.pnpm/@octokit+plugin-retry@7.2.0_@octokit+core@6.1.4/node_modules/@octokit/plugin-retry/dist-bundle/index.js:36:20)
│      at async Job.doExecute (/Users/josh/repos/debug-for-file/node_modules/.pnpm/bottleneck@2.19.5/node_modules/bottleneck/light.js:405:18)
│
└  Done. Enjoy your updated repository! 💝
```

Looks like a `\n` is getting added in.

### Additional Info

🎁 

Pull request:
fix: don't word-wrap long repo descriptions in API calls
## PR Checklist

- [x] Addresses an existing open issue: fixes #2046
- [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

Reuses the existing `wordwrap: false` logic in a new shared `htmlToTextSafe` function. The fact that I didn't do this already is an indication of why it should be centralized.

🎁 

Patch:
diff --git a/src/blocks/blockPackageJson.ts b/src/blocks/blockPackageJson.ts
index 0b5bad8..11d5f5a 100644
--- a/src/blocks/blockPackageJson.ts
+++ b/src/blocks/blockPackageJson.ts
@@ -1,4 +1,3 @@
-import * as htmlToText from "html-to-text";
 import removeUndefinedObjects from "remove-undefined-objects";
 import semver from "semver";
 import sortPackageJson from "sort-package-json";
@@ -7,6 +6,7 @@ import { PackageJson } from "zod-package-json";
 
 import { base } from "../base.js";
 import { blockRemoveFiles } from "./blockRemoveFiles.js";
+import { htmlToTextSafe } from "./html/htmlToTextSafe.js";
 import { CommandPhase } from "./phases.js";
 
 const PackageJsonWithNullableScripts = PackageJson.partial().extend({
@@ -35,9 +35,7 @@ export const blockPackageJson = base.createBlock({
 				...addons.properties.devDependencies,
 			},
 		);
-		const description = htmlToText.convert(options.description, {
-			wordwrap: false,
-		});
+		const description = htmlToTextSafe(options.description);
 
 		return {
 			files: {
@@ -119,15 +117,20 @@ function collectBinFiles(bin: Record<string, string> | string | undefined) {
 }
 
 function removeRangePrefix(version: string) {
-	return version.replaceAll(/[\^~><=]/gu, "").split(" ")[0];
+	const raw = version.replaceAll(/[\^~><=]/gu, "").split(" ")[0];
+
+	return semver.coerce(raw) ?? raw;
 }
 
 function useLargerVersion(existing: string | undefined, replacement: string) {
-	if (!existing) {
+	if (!existing || existing === replacement) {
 		return replacement;
 	}
 
-	return semver.gt(removeRangePrefix(existing), removeRangePrefix(replacement))
+	const existingCoerced = semver.coerce(removeRangePrefix(existing));
+
+	return existingCoerced &&
+		semver.gt(existingCoerced, removeRangePrefix(replacement))
 		? existing
 		: replacement;
 }
diff --git a/src/blocks/blockRepositorySettings.ts b/src/blocks/blockRepositorySettings.ts
index 71a8351..6831868 100644
--- a/src/blocks/blockRepositorySettings.ts
+++ b/src/blocks/blockRepositorySettings.ts
@@ -1,13 +1,12 @@
-import * as htmlToText from "html-to-text";
-
 import { base } from "../base.js";
+import { htmlToTextSafe } from "./html/htmlToTextSafe.js";
 
 export const blockRepositorySettings = base.createBlock({
 	about: {
 		name: "Repository Settings",
 	},
 	produce({ options }) {
-		const description = htmlToText.convert(options.description);
+		const description = htmlToTextSafe(options.description);
 
 		return {
 			requests: [
diff --git a/src/blocks/html/htmlToTextSafe.ts b/src/blocks/html/htmlToTextSafe.ts
new file mode 100644
index 0000000..4f2e493
--- /dev/null
+++ b/src/blocks/html/htmlToTextSafe.ts
@@ -0,0 +1,5 @@
+import { convert } from "html-to-text";
+
+export function htmlToTextSafe(raw: string) {
+	return convert(raw, { wordwrap: false });
+}


Test patch:
diff --git a/src/blocks/blockPackageJson.test.ts b/src/blocks/blockPackageJson.test.ts
index 58bcf73..4bf7135 100644
--- a/src/blocks/blockPackageJson.test.ts
+++ b/src/blocks/blockPackageJson.test.ts
@@ -260,7 +260,7 @@ describe("blockPackageJson", () => {
 		`);
 	});
 
-	it("preserves an existing dependency when the addon has an older pinned version", () => {
+	it("preserves an existing dependency when the addon has an invalid version", () => {
 		const dependency = "test-dependency";
 		const creation = testBlock(blockPackageJson, {
 			addons: {
@@ -318,6 +318,36 @@ describe("blockPackageJson", () => {
 		`);
 	});
 
+	it("uses the addon's version when the existing equivalent is invalid semver", () => {
+		const dependency = "test-dependency";
+		const creation = testBlock(blockPackageJson, {
+			addons: {
+				properties: {
+					dependencies: {
+						[dependency]: "1.0.0",
+					},
+				},
+			},
+			options: {
+				...optionsBase,
+				packageData: {
+					dependencies: {
+						[dependency]: "next",
+					},
+				},
+			},
+		});
+
+		expect(
+			// eslint-disable-next-line @typescript-eslint/no-non-null-assertion, @typescript-eslint/no-unsafe-member-access
+			JSON.parse(creation.files!["package.json"] as string).dependencies,
+		).toMatchInlineSnapshot(`
+			{
+			  "test-dependency": "1.0.0",
+			}
+		`);
+	});
+
 	it("preserves an existing dependency when the addon has an older version minimum", () => {
 		const dependency = "test-dependency";
 		const creation = testBlock(blockPackageJson, {
diff --git a/src/blocks/blockRepositorySettings.test.ts b/src/blocks/blockRepositorySettings.test.ts
new file mode 100644
index 0000000..202bd0f
--- /dev/null
+++ b/src/blocks/blockRepositorySettings.test.ts
@@ -0,0 +1,86 @@
+import { testBlock } from "bingo-stratum-testers";
+import { describe, expect, test } from "vitest";
+
+import { blockRepositorySettings } from "./blockRepositorySettings.js";
+import { optionsBase } from "./options.fakes.js";
+
+describe("blockRepositorySettings", () => {
+	test("with a short description", () => {
+		const creation = testBlock(blockRepositorySettings, {
+			options: optionsBase,
+		});
+
+		expect(creation).toMatchInlineSnapshot(`
+			{
+			  "requests": [
+			    {
+			      "endpoint": "PATCH /repos/{owner}/{repo}",
+			      "parameters": {
+			        "allow_auto_merge": true,
+			        "allow_merge_commit": false,
+			        "allow_rebase_merge": false,
+			        "allow_squash_merge": true,
+			        "delete_branch_on_merge": true,
+			        "description": "Test description",
+			        "has_wiki": false,
+			        "owner": "test-owner",
+			        "repo": "test-repository",
+			        "security_and_analysis": {
+			          "secret_scanning": {
+			            "status": "enabled",
+			          },
+			          "secret_scanning_push_protection": {
+			            "status": "enabled",
+			          },
+			        },
+			        "squash_merge_commit_message": "PR_BODY",
+			        "squash_merge_commit_title": "PR_TITLE",
+			      },
+			      "type": "octokit",
+			    },
+			  ],
+			}
+		`);
+	});
+
+	test("with a long description", () => {
+		const creation = testBlock(blockRepositorySettings, {
+			options: {
+				...optionsBase,
+				description: `A very very very very very very very very very very very very very very very very long <em><code>HTML-ish</code> description</em> ending with an emoji. 🧵`,
+			},
+		});
+
+		expect(creation).toMatchInlineSnapshot(`
+			{
+			  "requests": [
+			    {
+			      "endpoint": "PATCH /repos/{owner}/{repo}",
+			      "parameters": {
+			        "allow_auto_merge": true,
+			        "allow_merge_commit": false,
+			        "allow_rebase_merge": false,
+			        "allow_squash_merge": true,
+			        "delete_branch_on_merge": true,
+			        "description": "A very very very very very very very very very very very very very very very very long HTML-ish description ending with an emoji. 🧵",
+			        "has_wiki": false,
+			        "owner": "test-owner",
+			        "repo": "test-repository",
+			        "security_and_analysis": {
+			          "secret_scanning": {
+			            "status": "enabled",
+			          },
+			          "secret_scanning_push_protection": {
+			            "status": "enabled",
+			          },
+			        },
+			        "squash_merge_commit_message": "PR_BODY",
+			        "squash_merge_commit_title": "PR_TITLE",
+			      },
+			      "type": "octokit",
+			    },
+			  ],
+			}
+		`);
+	});
+});