Open JoshuaKGoldberg opened 8 months ago
OK, I'm pretty sure I've figured out how this should roughly work. I think there are five layers that'll need to be made:
create
: the end-user runtime that receives all that info and creates or updates a repositoryI'm thinking the π create
package will be what manages those tooling layers. Pieces of each will be published as packages that you can then pull into your project. Any customizations done in a package will need to be applied as options to a project pulls in. This will solve the issue of applying changes after migration (#1184).
On top of all that will be end-user templates such as β create-typescript-app
. They'd include the actual 𧱠blocks, π§° addons, and π presets end-users will tell π create
to build their repositories with.
π·οΈ Inputs will be small metadata-driven functions that provide any data needed to inform 𧱠blocks and π§° addons later. These may include:
.json
npm whoami
π create
will manage providing π·οΈ inputs with a runtime context containing a file system, network fetcher, and shell runner.
For example, an π·οΈ input that retrieves the current running time:
import { createInput } from "@create/input";
export const inputJSONFile = createInput({
produce: () => performance.now(),
});
Note that 𧱠blocks and π§° addons won't be required to use π·οΈ inputs to source data. Doing so just makes that data easier to mock out in tests later on.
π·οΈ Inputs will need to be reusable and able to take in π₯ options. They'll describe those options as the properties of a Zod object schema. That will let them validate provided values and infer types from an options
property in their context.
For example, an π·οΈ input that retrieves JSON data from a file on disk using the provided virtual file system:
import { createInput } from "@create/input";
import { z } from "zod";
export const inputJSONFile = createInput({
options: {
fileName: z.string(),
},
async produce({ fs, options }) {
try {
return JSON.parse((await fs.readFile(options.fileName)).toString());
} catch {
return undefined;
}
},
});
Later on, 𧱠blocks and 𧰠addons that use the input will be able to provide those options
.
The create
ecosystem will include testing utilities that provide mock data to an π·οΈ input under test.
For example, testing the previous inputJSONFile
:
import { createMockInputContext } from "@create/input-tester";
import { inputJSONFile } from "./inputJSONFile.ts";
describe("inputJSONFile", () => {
it("returns package data when the file on disk contains valid JSON", () => {
const expected = { name: "mock-package" };
const context = createMockInputContext({
files: {
"package.json": JSON.stringify(expected),
},
options: {
fileName: "package.json",
},
});
const actual = await inputJSONFile(context);
expect(actual).toEqual(expected);
});
});
π·οΈ Inputs should be composable: meaning each can take data from other inputs. π create
will include a take
function in contexts that calls another π·οΈ input with the current context.
For example, an π·οΈ input that determines the npm username based on either npm whoami
or package.json
π·οΈ inputs:
import { createInput } from "@create/input";
import { inputJSONFile } from "@example/input-json-data";
import { inputNpmWhoami } from "@example/input-npm-whoami";
export const inputNpmUsername = createInput({
async produce({ fs, take }) {
return (
(await take(inputNpmWhoami)) ??
(await take(inputJSONFile, { fileName: "package.json" })).author
);
},
});
The main logic for template contents will be stored in 𧱠blocks. Each will define its shape of π·οΈ inputs, user-provided options, and resultant outputs.
Resultant outputs will be passed to create
to be merged with other 𧱠blocks' outputs and applied. Outputs may include:
For example, a 𧱠block that adds a .nvmrc
file:
import { createBlock } from "@create/block";
export const blockNvmrc = createBlock({
async produce() {
return {
files: {
".nvmrc": "20.12.2",
},
};
},
});
The create
ecosystem will include testing utilities that provide mock data to a 𧱠block under test:
import { createMockBlockContext } from "@create/block-tester";
import { blockNvmrc } from "./blockNvmrc.ts";
describe("blockNvmrc", () => {
it("returns an .nvmrc", () => {
const context = createMockInputContext();
const actual = await blockNvmrc(context);
expect(actual).toEqual({ ".nvmrc": "20.12.2" });
});
});
Blocks can take in data from π·οΈ inputs. π create
will handle lazily evaluating π·οΈ inputs and retrieving user-provided inputs. They'll receive the same take
function in their context that executes an π·οΈ input.
For example, a 𧱠block that adds all-contributors recognition using a JSON file π·οΈ input:
import { BlockContext, BlockOutput } from "@create/block";
import { formatYml } from "format-yml"; // todo: make package
export const blockAllContributors = createBlock({
async produce({ take }) {
const existing = await take(inputJSONFile, {
fileName: "package.json",
});
return {
files: {
".all-contributorsrc": JSON.parse({
// ...
contributors: existing?.contributors ?? [],
// ...
}),
".github": {
workflows: {
"contributors.yml": formatYml({
// ...
name: "Contributors",
// ...
}),
},
},
},
};
},
});
𧱠Blocks may be configurable with user options similar to π·οΈ inputs. They will define them as the properties for a Zod object schema and then receive them in their context.
For example, a 𧱠block that adds Prettier formatting with optional Prettier options:
import { createBlock } from "@create/block";
import prettier from "prettier";
import { prettierSchema } from "zod-prettier-schema"; // todo: make package
import { z } from "zod";
export const blockPrettier = createBlock({
options: {
config: prettierSchema.optional(),
},
async produce({ options }) {
return {
files: {
".prettierrc.json":
options.config &&
JSON.stringify({
$schema: "http://json.schemastore.org/prettierrc",
...config,
}),
},
packages: {
devDependencies: ["prettier"],
},
scripts: {
format: "prettier .",
},
};
},
});
𧱠Block π₯ options will then be testable with the same mock context utilities as before:
import { createMockBlockContext } from "@create/block-tester";
import { blockPrettier } from "./blockPrettier.ts";
describe("blockPrettier", () => {
it("creates a .prettierrc.json when provided options", () => {
const prettierConfig = {
useTabs: true,
};
const context = createMockInputContext({
options: {
config: prettierConfig,
},
});
const actual = await blockPrettier(context);
expect(actual).toEqual({
files: {
".prettierrc.json": JSON.stringify({
$schema: "http://json.schemastore.org/prettierrc",
...prettierConfig,
}),
},
packages: {
devDependencies: ["prettier"],
},
scripts: {
format: "prettier .",
},
});
});
});
𧱠Blocks should be able to signal added metadata on the system that other blocks will need to handle. They can do so by returning properties in a metadata
object.
Metadata may include:
documentation
: A Record<string, string>
of docs entries to add to .md
file(s)files
: An array of objects containing glob: string
and type: FileType
of Config
, Source
, or Test
For example, this Vitest 𧱠block indicates that there can now be src/**/*.test.*
test files, as documented in .github/DEVELOPMENT.md
:
import { BlockOutput, FileType } from "@create/block";
export function blockVitest(): BlockOutput {
return {
files: {
"vitest.config.ts": `import { defineConfig } from "vitest/config"; ...`,
},
metadata: {
documentation: {
".github/DEVELOPMENT.md": `## Testing ...`,
},
files: [{ glob: "src/**/*.test.*", type: FileType.Test }],
},
};
}
In order to use πͺͺ metadata provided by other blocks, block outputs can each be provided as a function. That function will be called with an object containing all previously generated πͺͺ metadata.
For example, this Tsup 𧱠block reacts to πͺͺ metadata to exclude test files from its entry
:
import { BlockContext, BlockOutput, FileType } from "@create/block";
export function blockTsup(): BlockOutput {
return {
fs: ({ metadata }: BlockContext) => {
return {
"tsup.config.ts": `import { defineConfig } from "tsup";
// ...
entry: [${JSON.stringify([
"src/**/*.ts",
...metadata.files
.filter(file.type === FileType.Test)
.map((file) => file.glob),
])}],
// ...
`,
};
},
};
}
In other words, 𧱠blocks will be executed in two phases:
It would be nice to figure out a way to simplify them into one phase, while still allowing πͺͺ metadata to be dependent on π₯ options. A future design iteration might figure that out.
𧱠Blocks should be able to describe how to bump from previous versions to the current. Those descriptions will be stored as 𧹠migrations detailing the actions to take to migrate from previous versions.
For example, a 𧱠block adding in Knip that switches from knip.jsonc
to knip.json
:
import { BlockContext, BlockOutput } from "@create/block";
export function blockKnip({ fs }: BlockKnip): BlockOutput {
return {
files: {
"knip.json": JSON.stringify({
$schema: "https://unpkg.com/knip@latest/schema.json",
}),
},
migrations: [
{
name: "Rename knip.jsonc to knip.json",
run: async () => {
try {
await fs.rename("knip.jsonc", "knip.json");
} catch {
// Ignore failures if knip.jsonc doesn't exist
}
},
},
],
};
}
Migrations will allow create
to be run in an idempotent --migrate
mode that can keep a repository up-to-date automatically.
There will often be times when sets of 𧱠block options would be useful to package together. For example, many packages consuming an ESLint 𧱠block might want to add on JSDoc linting rules.
Reusable generators for π₯ options will be available as π§° addons. Their produced π₯ options will then be merged together by π create
and then passed to 𧱠blocks at runtime.
For example, a JSDoc linting 𧰠addon for a rudimentary ESLint linting 𧱠block with options for adding plugins:
import { createAddon } from "@create/addon";
import { blockESLint, BlockESLintOptions } from "@example/block-eslint";
export const addonESLintJSDoc = createAddon({
produce(): AddonOutput<BlockESLintOptions> {
return {
options: {
configs: [`jsdoc.configs["flat/recommended-typescript-error"]`],
imports: [`import jsdoc from "eslint-plugin-jsdoc"`],
rules: {
"jsdoc/informative-docs": "error",
"jsdoc/lines-before-block": "off",
},
},
};
},
});
Options produced by π§° addons will be merged together by ...
spreading, both for arrays and objects.
The create
ecosystem will include testing utilities that provide mock data to an π§° addon under test:
import { createMockAddonContext } from "@create/addon-tester";
import { addonESLintJSDoc } from "./addonESLintJSDoc.ts";
describe("addonESLintJSDoc", () => {
it("returns configs, imports, and rules", () => {
const context = createMockAddonContext();
const actual = await addonESLintJSDoc(context);
expect(actual).toEqual({
options: {
configs: [`jsdoc.configs["flat/recommended-typescript-error"]`],
imports: [`import jsdoc from "eslint-plugin-jsdoc"`],
rules: {
"jsdoc/informative-docs": "error",
"jsdoc/lines-before-block": "off",
},
},
});
});
});
π§° Addons may be configurable with user options similar to π·οΈ inputs and 𧱠blocks. They should be able to describe their options as the properties for a Zod object schema, then infer types for their context.
For example, a Perfectionist linting 𧰠addon for a rudimentary ESLint linting 𧱠block with options for partitioning objects:
import { createAddon } from "@create/addon";
import { blockESLint, BlockESLintOptions } from "@example/block-eslint";
import { z } from "zod";
export const addonESLintPerfectionist = createAddon({
options: {
partitionByComment: z.boolean(),
},
produce({ options }): AddonOutput<BlockESLintOptions> {
return {
options: {
configs: [`perfectionist.configs["recommended-natural"]`],
imports: `import perfectionist from "eslint-plugin-perfectionist"`,
rules: options.partitionByComment && {
"perfectionist/sort-objects": [
"error",
{
order: "asc",
partitionByComment: true,
type: "natural",
},
],
},
},
};
},
});
π§° Addon π₯ options will then be testable with the same mock context utilities as before:
import { createMockAddonContext } from "@create/addon-tester";
import { addonESLintPerfectionist } from "./addonESLintPerfectionist.ts";
describe("addonESLintPerfectionist", () => {
it("includes perfectionist/sort-objects configuration when options.partitionByComment is provided", () => {
const context = createMockAddonContext({
options: {
partitionByComment: true,
},
});
const actual = await addonESLintPerfectionist(context);
expect(actual).toEqual({
options: {
configs: [`perfectionist.configs["recommended-natural"]`],
imports: `import perfectionist from "eslint-plugin-perfectionist"`,
rules: {
"perfectionist/sort-objects": [
"error",
{
order: "asc",
partitionByComment: true,
type: "natural",
},
],
},
},
});
});
});
Users won't want to manually configure 𧱠blocks and π§° addons in all of their projects. π Presets that configure broadly used or organization-wide configurations will help share setups.
For example, a π preset that configures ESLint, README.md with logo, and Vitest 𧱠blocks with JSONC linting, JSDoc linting, and test linting π§° addons:
import { createPreset } from "@create/preset";
import { blockESLint } from "@example/block-eslint";
import { blockReadme } from "@example/block-readme";
import { blockVitest } from "@example/block-vitest";
export const myPreset = createPreset({
produce() {
return [
blockESLint({
addons: [addonESLintJSDoc(), addonESLintJSONC(), addonESLintVitest()],
}),
blockReadme({
logo: "./docs/my-logo.png",
}),
blockVitest(),
];
},
});
π Presets will need to be able to take in options. As with previous layers, they'll describe their options as the properties for a Zod object schema.
For example, a π preset that takes in keywords and forwards them to a package.json
𧱠block:
import { createPreset } from "@create/preset";
import { blockPackageJson } from "@example/block-package-json";
import { z } from "zod";
export const myPreset = createPreset({
options: {
keywords: z.array(z.string()),
},
produce({ options }) {
return [
blockPackageJson({
keywords: options.keywords,
}),
];
},
});
For example, the scaffolding of a 𧱠block that generates documentation for a preset from its entry point:
import { createBlock } from "@create/block";
import { z } from "zod";
createBlock({
options: {
entry: z.string().default("./src/index.ts"),
},
produce({ options }) {
return {
metadata: {
documentation: {
"README.md": `## Preset Options ...`,
},
},
};
},
});
Users may opt to keep a GitHub template repository storing a canonical representation of their template. The template can reference that repository's locator. Projects created from the template can then be createdΒ from the template.
For example, a preset referencing a GitHub repository:
import { createPreset } from "@create/preset";
export const myTemplatePreset = createPreset({
repository: "https://github.com/owner/repository",
produce() {
// ...
},
});
This is necessary for including the "generated from" notice on repositories for a template. The repository containing a preset might be built with a different preset. For example, a repository containing presets for different native app builders might itself use a general TypeScript preset.
create
Internally, create
will:
There may need to be options provided for changing when pieces run. For example, there may be migrations that depend on being run before stored file operations.
create
CLIsInitializing a new repository can be done by running create
on the CLI. Zod arguments will be automatically converted to Node.js parseArgs
args.
Using the earlier myPreset
example as a package named my-create-preset
:
npx create --preset my-create-preset --preset-option-keywords abc --preset-option-keywords def
The result of running the CLI will be a repository that's ready to be developed on immediately.
create
ConfigurationUsers will alternately be able to set up the 𧱠blocks and 𧰠addons in a file like create.config.ts
. They will have the user default-export calling a createConfig
function with an array of blocks.
For example, a small project that only configures one TypeScript 𧱠block to have a specific compiler option:
// create.config.ts
import { createConfig } from "@create/config";
import { blockTsc } from "@example/block-tsc";
export default createConfig([
blockTsc({
compilerOptions: {
target: "ES2024",
},
}),
]);
A more realistic example would be this equivalent to the create-typescript-app
"common" base with a logo and bin using a dedicated π preset:
// create.config.ts
import { createConfig } from "@create/config";
import { presetTypeScriptPackageCommon } from "@example/preset-typescript-package-common";
export default createConfig(
presetTypeScriptPackageCommon({
bin: "./bin/index.js",
readme: {
logo: "./docs/my-logo.png",
},
})
);
Running a command like npx create
will detect the create.config.ts
and re-run π create
for the repository. Any π§Ή migrations will clean up out-of-date files.
create
CLI PromptsIt's common for template builders to include a CLI prompt for options. π create
will provide a dedicated CLI package that prompts users for options based on the Zod options
schema for a π preset.
For example, given a π preset that describes its name and other documentation:
import { createPreset } from "@create/preset";
export const myPreset = createPreset({
documentation: {
name: "My Preset",
},
options: {
access: z
.union([z.literal("public"), z.literal("private")])
.default("public"),
description: z.string(),
},
produce() {
/* ... */
},
});
...a π create
CLI would be able to prompt a running user for each of those options:
npx create-my-preset
Let's β¨ create β¨ a repository for you based on My Preset!
> Enter a value for access.
Allowed values: "public", "private" (default: "public")
...
> Enter a value for description:
...
> Would you like to make a create.config.ts file to pull in template updates later?
y/n
Future versions of π create
could provide hooks to customize those CLIs, such as adding more documentation options in createPreset
.
create
Monorepo SupportAdding explicit handling for monorepos is not something I plan for a v1 of π create
. I'll want to have experience maintaining a few more of my own monorepos before seriously investigating what that would look like.
This does not block end-users from writing monorepo-tailored 𧱠blocks or π presets. They can always write two versions of their logic for the ones that need it, such as:
@example/block-tsc
@example/block-tsc-references
Alternately, individual packages can always configure π create
tooling on their own.
create-typescript-app
π create
will be a general engine.
It won't have any specific 𧱠blocks or π presets built-in.
Instead, external packages such as β create-typescript-app
will take on the responsibility of creating their own framework-/library-specific 𧱠blocks and π presets.
For example, a non-exhaustive list of β create-typescript-app
packages might look like:
@create-typescript/block-all-contributors
@create-typescript/block-compliance
@create-typescript/block-contributing
@create-typescript/block-cspell
@create-typescript/block-eslint
@create-typescript/block-github-alt-text
@create-typescript/block-husky
@create-typescript/block-knip
@create-typescript/block-markdownlint
@create-typescript/block-package-json
@create-typescript/block-pnpm
@create-typescript/block-prettier
@create-typescript/block-license-mit
@create-typescript/block-readme
@create-typescript/block-release-it
@create-typescript/block-renovate
@create-typescript/block-tsc
@create-typescript/block-tsup
@create-typescript/block-vitest
@create-typescript/addon-all-contributors-auto-action
@create-typescript/addon-eslint-comments
@create-typescript/addon-eslint-jsdoc
@create-typescript/addon-eslint-jsonc
@create-typescript/addon-eslint-eslint
@create-typescript/addon-eslint-md
@create-typescript/addon-eslint-regexp
@create-typescript/addon-eslint-perfectionist
@create-typescript/addon-eslint-vitest
@create-typescript/addon-markdownlint-sentences-per-line
@create-typescript/addon-pnpm-dedupe
@create-typescript/addon-prettier-plugin-curly
@create-typescript/addon-prettier-plugin-sh
@create-typescript/addon-prettier-plugin-packagejson
@create-typescript/addon-tsup-bin
@create-typescript/addon-vitest-console-fail-test
@create-typescript/addon-vitest-coverage
@create-typescript/preset-minimal
@create-typescript/preset-common
@create-typescript/preset-everything
The π presets will be configurable with π₯ options to swap out pieces as needed for repositories. For example, some repositories will want to swap out the Tsup 𧱠block for a different builder.
Over time, @create-typescript
will encompass all common TypeScript package types from repositories I (Josh) use. That will include browser extensions, GitHub actions, and web frameworks such as Astro.
I need time to digest this, but off the bat this looks beautiful and well thought out. Your emoji game is on point β€οΈ
For example, a 𧱠block that adds a
.nvmrc
file:
This is amazing! Read through core parts to get an idea on things. DAMN!
cool, looks like a similar approach like https://github.com/projen/projen
I will respond to βοΈ soon - finishing up some drafts!
In the meantime, @DonIsaac pointed me to https://nx.dev/features/generate-code. I'll comment on the differences with that too!
Bug Report Checklist
main
branch of the repository.Overview
create-typescript-app has come a long way in the last two (!) years! It started as a small template for me to unify my disparate repository config files. Now it's a full project with >500 stars, repeat community contributors, and offshoot projects to help it work smoothly. I love this. π
I plan on continuing to prioritize create-typescript-app over at least the next year. I see itsΒ progress as evolving through at least three distinct stages:
My hope is that in the next year or so, folks will be able to make their own shared templates that mix-and-match pieces of tooling. For example, a GitHub Actions flavor of create-typescript-app might use all the same pieces as this one except it'd swap out the builder from tsup to web-ext. See also #1175.
I don't know exactly how this would look - but I am excited to find out π.
Filing this issue to track placing a slightly more solidified form of this explanation in the docs.
Additional Info
No response