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

Issue:
🚀 Feature: Add web-ext builder Block for browser extensions
### Feature Request Checklist

- [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.

### Overview

For projects of mine such as https://github.com/JoshuaKGoldberg/refined-saved-replies, I'd like to be able to stick with CTA fully. But that means swapping out the default tsup builder Block for a [web-ext](https://github.com/mozilla/web-ext) one.

### Additional Info

🎁 

Pull request description:
feat: add blockWebExt for Web-ext builds
## PR Checklist

- [x] Addresses an existing open issue: fixes #2031
- [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 an opt-in Block akin to `blockNcc`. It's based on the existing dev structure in https://github.com/JoshuaKGoldberg/refined-saved-replies.

🎁 

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index dc319ad..ef0198f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,11 @@
 # Changelog
 
+# [2.27.0](https://github.com/JoshuaKGoldberg/create-typescript-app/compare/2.26.7...2.27.0) (2025-04-04)
+
+### Features
+
+- allow customizing Vitest environment ([#2149](https://github.com/JoshuaKGoldberg/create-typescript-app/issues/2149)) ([81708cc](https://github.com/JoshuaKGoldberg/create-typescript-app/commit/81708cc7d628c65a9788115197f617e0893746cd)), closes [#2146](https://github.com/JoshuaKGoldberg/create-typescript-app/issues/2146)
+
 ## [2.26.7](https://github.com/JoshuaKGoldberg/create-typescript-app/compare/2.26.6...2.26.7) (2025-04-04)
 
 ### Bug Fixes
diff --git a/docs/Blocks.md b/docs/Blocks.md
index e08bc5c..13fd964 100644
--- a/docs/Blocks.md
+++ b/docs/Blocks.md
@@ -53,6 +53,7 @@ This table summarizes each block and which base levels they're included in:
 | TypeScript                   | `--add-typescript`, `--exclude-typescript`                                     | ✔️      | ✅     | 💯         |
 | Vitest                       | `--add-vitest`, `--exclude-vitest`                                             |         | ✅     | 💯         |
 | VS Code                      | `--add-vs-code`, `--exclude-vs-code`                                           |         |        | 💯         |
+| Web-ext                      | `--add-web-ext`, `--exclude-web-ext`                                           |         |        |            |
 
 For example, this uses ncc instead of the default tsup builder:
 
diff --git a/package.json b/package.json
index 3b4d2ba..d7b951e 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
 	"name": "create-typescript-app",
-	"version": "2.26.7",
+	"version": "2.27.0",
 	"description": "Quickstart-friendly TypeScript template with comprehensive, configurable, opinionated tooling. 🎁",
 	"repository": {
 		"type": "git",
diff --git a/src/blocks/blockWebExt.ts b/src/blocks/blockWebExt.ts
new file mode 100644
index 0000000..05f4a51
--- /dev/null
+++ b/src/blocks/blockWebExt.ts
@@ -0,0 +1,122 @@
+import { base } from "../base.js";
+import { blockCSpell } from "./blockCSpell.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 { blockPrettier } from "./blockPrettier.js";
+
+export const blockWebExt = base.createBlock({
+	about: {
+		name: "Web-ext",
+	},
+	produce() {
+		return {
+			addons: [
+				blockCSpell({
+					ignores: ["assets"],
+				}),
+				blockDevelopmentDocs({
+					sections: {
+						Building: {
+							contents: `
+Run [ESBuild](https://esbuild.github.io) locally to build source files:
+
+\`\`\`shell
+pnpm dev
+\`\`\`
+
+Add \`--watch\` to run the builder in a watch mode that continuously re-builds as you save files:
+
+\`\`\`shell
+pnpm dev --watch
+\`\`\`
+`,
+							innerSections: [
+								{
+									contents: `
+Follow Google Chrome's _[Load an unpacked extension](https://developer.chrome.com/docs/extensions/mv3/getstarted/#unpacked)_ guide to load this repository's directory as an extension locally.
+
+> ♻️ Remember to reload the extension in \`chrome://extensions\` whenever you make changes locally!
+`,
+									heading: "Local Development with Chrome",
+								},
+								{
+									contents: `
+Follow Firefox's _[Temporary installation in Firefox](https://extensionworkshop.com/documentation/develop/temporary-installation-in-firefox/)_ guide to load this repository's directory as an extension locally.
+
+You'll then need to authorize the extension to run on <https://github.com>:
+
+1. Find and select the _Extensions_ icon in the top right of Firefox
+2. Select the _"Manage Extension"_ gear icon next to _Refined Saved Replies_
+3. Select the _"Always Allow on github.com"_ option
+
+> ♻️ Remember to reload the extension in \`about:debugging#/runtime/this-firefox\` whenever you make changes locally!
+`,
+									heading: "Local Development with Firefox",
+								},
+								{
+									contents: `
+Run [\`web-ext\`](https://extensionworkshop.com) to build a production-ready \`.zip\` under \`./web-ext-artifacts/\`:
+
+\`\`\`shell
+pnpm build
+\`\`\`
+
+Then upload that \`./web-ext-artifacts/refined_saved_replies-*.zip\` file to:
+
+- [Chrome Web Store Developer Dashboard](https://chrome.google.com/webstore/devconsole)
+- [Firefox Add-ons Developer Hub](https://addons.mozilla.org/en-US/developers/addon/submit/distribution)
+`,
+									heading: "Production Builds",
+								},
+							],
+						},
+						Linting: {
+							contents: {
+								items: [
+									`- \`pnpm lint:web-ext\` ([web-ext](https://extensionworkshop.com)): Lints browser extension metadata after \`pnpm dev\` creates local files`,
+								],
+							},
+						},
+					},
+				}),
+				blockGitHubActionsCI({
+					jobs: [
+						{
+							name: "Build",
+							steps: [{ run: "pnpm build" }],
+						},
+						{
+							name: "Lint Web-ext",
+							steps: [{ run: "pnpm dev" }, { run: "pnpm lint:web-ext" }],
+						},
+					],
+				}),
+				blockGitignore({
+					ignores: ["*.zip", "web-ext-artifacts"],
+				}),
+				blockMarkdownlint({
+					ignores: ["assets/"],
+				}),
+				blockPackageJson({
+					properties: {
+						devDependencies: {
+							esbuild: "^0.25.0",
+							"web-ext": "^8.3.0",
+						},
+						scripts: {
+							build: "web-ext build --overwrite-dest",
+							dev: "esbuild src/content-script.ts --bundle --minify --outfile=lib/content-script.js --sourcemap",
+							"lint:web-ext": "web-ext lint",
+						},
+					},
+				}),
+				blockPrettier({
+					ignores: ["assets/"],
+				}),
+			],
+		};
+	},
+});
diff --git a/src/blocks/index.ts b/src/blocks/index.ts
index 9e705fd..f085f79 100644
--- a/src/blocks/index.ts
+++ b/src/blocks/index.ts
@@ -44,6 +44,7 @@ import { blockTSup } from "./blockTSup.js";
 import { blockTypeScript } from "./blockTypeScript.js";
 import { blockVitest } from "./blockVitest.js";
 import { blockVSCode } from "./blockVSCode.js";
+import { blockWebExt } from "./blockWebExt.js";
 
 export const blocks = {
 	blockAllContributors,
@@ -92,6 +93,7 @@ export const blocks = {
 	blockTypeScript,
 	blockVitest,
 	blockVSCode,
+	blockWebExt,
 };
 
 export { blockAllContributors } from "./blockAllContributors.js";
@@ -141,3 +143,4 @@ export { blockTSup } from "./blockTSup.js";
 export { blockTypeScript } from "./blockTypeScript.js";
 export { blockVitest } from "./blockVitest.js";
 export { blockVSCode } from "./blockVSCode.js";
+export { blockWebExt } from "./blockWebExt.js";
diff --git a/src/template.ts b/src/template.ts
index 9c7e5e4..19205f2 100644
--- a/src/template.ts
+++ b/src/template.ts
@@ -5,6 +5,7 @@ import { blockESLintPlugin } from "./blocks/blockESLintPlugin.js";
 import { blockNcc } from "./blocks/blockNcc.js";
 import { blockRemoveDependencies } from "./blocks/blockRemoveDependencies.js";
 import { blockRemoveFiles } from "./blocks/blockRemoveFiles.js";
+import { blockWebExt } from "./blocks/blockWebExt.js";
 import { presetCommon } from "./presets/common.js";
 import { presetEverything } from "./presets/everything.js";
 import { presetMinimal } from "./presets/minimal.js";
@@ -24,6 +25,7 @@ export const template = base.createStratumTemplate({
 		blockNcc,
 		blockRemoveDependencies,
 		blockRemoveFiles,
+		blockWebExt,
 	],
 	presets: [presetMinimal, presetCommon, presetEverything],
 	suggested: presetCommon,


Test patch:
diff --git a/src/blocks/blockVitest.test.ts b/src/blocks/blockVitest.test.ts
index a45c476..0955da1 100644
--- a/src/blocks/blockVitest.test.ts
+++ b/src/blocks/blockVitest.test.ts
@@ -521,6 +521,7 @@ describe("blockVitest", () => {
 					exclude: ["other"],
 					include: ["src/"],
 				},
+				environment: "happy-dom",
 				exclude: ["lib/"],
 				flags: ["--typecheck"],
 			},
@@ -750,6 +751,7 @@ describe("blockVitest", () => {
 						include: ["src/"],
 						reporter: ["html", "lcov"],
 					},
+					environment: "happy-dom",
 					exclude: ["lib/","node_modules"],
 					setupFiles: ["console-fail-test/setup"],
 				},
diff --git a/src/blocks/blockVitest.ts b/src/blocks/blockVitest.ts
index 2ba25b7..7091719 100644
--- a/src/blocks/blockVitest.ts
+++ b/src/blocks/blockVitest.ts
@@ -46,6 +46,7 @@ export const blockVitest = base.createBlock({
 	addons: {
 		actionSteps: z.array(zActionStep).default([]),
 		coverage: zCoverage.default({}),
+		environment: z.string().optional(),
 		exclude: zExclude.default([]),
 		flags: z.array(z.string()).default([]),
 	},
@@ -77,7 +78,7 @@ export const blockVitest = base.createBlock({
 		};
 	},
 	produce({ addons }) {
-		const { actionSteps, coverage, exclude = [] } = addons;
+		const { actionSteps, coverage, environment, exclude = [] } = addons;
 		const excludeText = JSON.stringify(
 			Array.from(new Set(["node_modules", ...exclude])).sort(),
 		);
@@ -243,7 +244,12 @@ export default defineConfig({
 					: ""
 			}include: ${JSON.stringify(coverage.include)},
 			reporter: ["html", "lcov"],
-		},
+		},${
+			environment
+				? `
+		environment: "${environment}",`
+				: ""
+		}
 		exclude: [${excludeText.slice(1, excludeText.length - 1)}],
 		setupFiles: ["console-fail-test/setup"],
 	},
diff --git a/src/blocks/blockWebExt.test.ts b/src/blocks/blockWebExt.test.ts
new file mode 100644
index 0000000..810713a
--- /dev/null
+++ b/src/blocks/blockWebExt.test.ts
@@ -0,0 +1,163 @@
+import { testBlock } from "bingo-stratum-testers";
+import { describe, expect, test } from "vitest";
+
+import { blockWebExt } from "./blockWebExt.js";
+import { optionsBase } from "./options.fakes.js";
+
+describe("blockWebExt", () => {
+	test("production", () => {
+		const creation = testBlock(blockWebExt, {
+			options: optionsBase,
+		});
+
+		expect(creation).toMatchInlineSnapshot(`
+			{
+			  "addons": [
+			    {
+			      "addons": {
+			        "ignores": [
+			          "assets",
+			        ],
+			      },
+			      "block": [Function],
+			    },
+			    {
+			      "addons": {
+			        "sections": {
+			          "Building": {
+			            "contents": "
+			Run [ESBuild](https://esbuild.github.io) locally to build source files:
+
+			\`\`\`shell
+			pnpm dev
+			\`\`\`
+
+			Add \`--watch\` to run the builder in a watch mode that continuously re-builds as you save files:
+
+			\`\`\`shell
+			pnpm dev --watch
+			\`\`\`
+			",
+			            "innerSections": [
+			              {
+			                "contents": "
+			Follow Google Chrome's _[Load an unpacked extension](https://developer.chrome.com/docs/extensions/mv3/getstarted/#unpacked)_ guide to load this repository's directory as an extension locally.
+
+			> ♻️ Remember to reload the extension in \`chrome://extensions\` whenever you make changes locally!
+			",
+			                "heading": "Local Development with Chrome",
+			              },
+			              {
+			                "contents": "
+			Follow Firefox's _[Temporary installation in Firefox](https://extensionworkshop.com/documentation/develop/temporary-installation-in-firefox/)_ guide to load this repository's directory as an extension locally.
+
+			You'll then need to authorize the extension to run on <https://github.com>:
+
+			1. Find and select the _Extensions_ icon in the top right of Firefox
+			2. Select the _"Manage Extension"_ gear icon next to _Refined Saved Replies_
+			3. Select the _"Always Allow on github.com"_ option
+
+			> ♻️ Remember to reload the extension in \`about:debugging#/runtime/this-firefox\` whenever you make changes locally!
+			",
+			                "heading": "Local Development with Firefox",
+			              },
+			              {
+			                "contents": "
+			Run [\`web-ext\`](https://extensionworkshop.com) to build a production-ready \`.zip\` under \`./web-ext-artifacts/\`:
+
+			\`\`\`shell
+			pnpm build
+			\`\`\`
+
+			Then upload that \`./web-ext-artifacts/refined_saved_replies-*.zip\` file to:
+
+			- [Chrome Web Store Developer Dashboard](https://chrome.google.com/webstore/devconsole)
+			- [Firefox Add-ons Developer Hub](https://addons.mozilla.org/en-US/developers/addon/submit/distribution)
+			",
+			                "heading": "Production Builds",
+			              },
+			            ],
+			          },
+			          "Linting": {
+			            "contents": {
+			              "items": [
+			                "- \`pnpm lint:web-ext\` ([web-ext](https://extensionworkshop.com)): Lints browser extension metadata after \`pnpm dev\` creates local files",
+			              ],
+			            },
+			          },
+			        },
+			      },
+			      "block": [Function],
+			    },
+			    {
+			      "addons": {
+			        "jobs": [
+			          {
+			            "name": "Build",
+			            "steps": [
+			              {
+			                "run": "pnpm build",
+			              },
+			            ],
+			          },
+			          {
+			            "name": "Lint Web-ext",
+			            "steps": [
+			              {
+			                "run": "pnpm dev",
+			              },
+			              {
+			                "run": "pnpm lint:web-ext",
+			              },
+			            ],
+			          },
+			        ],
+			      },
+			      "block": [Function],
+			    },
+			    {
+			      "addons": {
+			        "ignores": [
+			          "*.zip",
+			          "web-ext-artifacts",
+			        ],
+			      },
+			      "block": [Function],
+			    },
+			    {
+			      "addons": {
+			        "ignores": [
+			          "assets/",
+			        ],
+			      },
+			      "block": [Function],
+			    },
+			    {
+			      "addons": {
+			        "properties": {
+			          "devDependencies": {
+			            "esbuild": "^0.25.0",
+			            "web-ext": "^8.3.0",
+			          },
+			          "scripts": {
+			            "build": "web-ext build --overwrite-dest",
+			            "dev": "esbuild src/content-script.ts --bundle --minify --outfile=lib/content-script.js --sourcemap",
+			            "lint:web-ext": "web-ext lint",
+			          },
+			        },
+			      },
+			      "block": [Function],
+			    },
+			    {
+			      "addons": {
+			        "ignores": [
+			          "assets/",
+			        ],
+			      },
+			      "block": [Function],
+			    },
+			  ],
+			}
+		`);
+	});
+});