JoshuaKGoldberg / create-typescript-app

Quickstart-friendly TypeScript template with comprehensive, configurable, opinionated tooling. πŸ’
MIT License
908 stars 70 forks source link

πŸ“ Documentation: Long-term project vision #1181

Open JoshuaKGoldberg opened 8 months ago

JoshuaKGoldberg commented 8 months ago

Bug Report Checklist

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:

  1. (~2022) Personal template: used just in my own personal repos
  2. (~2023) Shared template: Adding in more comprehensive docs, options, and generally more flexibility so folks other than me can use it on their repos too
  3. (~2024) Shared engine: Splitting out a new package so that other templates like this one can be made

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

JoshuaKGoldberg commented 1 month 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:

  1. 🏷️ Inputs: read in data from the creation context
  2. 🧱 Blocks: each individual dev tooling piece, optionally with data from 🏷️ inputs b. πŸͺͺ Metadata: signals output from the block that can be used in other blocks b. 🧹 Migrations: descriptions of how to clean up from previous versions
  3. 🧰 Addons: extensions that provide additional input to a block, optionally with data from 🏷️ inputs
  4. 🎁 Presets: configurable groups of 🧱 blocks and 🧰 addons
  5. πŸ’ create: the end-user runtime that receives all that info and creates or updates a repository

I'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

🏷️ Inputs will be small metadata-driven functions that provide any data needed to inform 🧱 blocks and 🧰 addons later. These may include:

πŸ’ 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.

🏷️ Input πŸ“₯ Options

🏷️ 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.

🏷️ Input πŸ§ͺ Testing

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);
  });
});

🏷️ Input Composition

🏷️ 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
    );
  },
});

🧱 Blocks

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 and 🏷️ Inputs

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",
              // ...
            }),
          },
        },
      },
    };
  },
});

🧱 Block πŸ“₯ Options

🧱 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 .",
      },
    });
  });
});

🧱 Block πŸͺͺ Metadata

🧱 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:

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:

  1. An initial, metadata-less phase that can produce outputs and metadata
  2. A second, metadata-provided phase that can produce more outputs

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.

🧱 Block 🧹 Migrations

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

🧰 Addons

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",
        },
      },
    });
  });
});

🧰 Addon πŸ“₯ Options

🧰 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",
            },
          ],
        },
      },
    });
  });
});

🎁 Presets

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(),
    ];
  },
});

🎁 Preset πŸ“₯ Options

🎁 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,
      }),
    ];
  },
});

🎁 Preset πŸ“„ Documentation

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 ...`,
        },
      },
    };
  },
});

Template Repositories

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:

  1. Initialize shared context: the built-in options, file system, network fetcher, and shell runner
  2. Run 🧱 blocks in order with their portions of the context
    • File, network, and shell operations are stored so they can be run later
    • Migrations are also stored to be run later
    • Any metadata are stored and merged internally
  3. Run any stored migrations
  4. Run delayed portions of 🧱 blocks in order that required metadata
  5. Run all stored file, network, and shell operations

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 CLIs

Initializing 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 Configuration

Users 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 Prompts

It'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 Support

Adding 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:

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:

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.

johnnyreilly commented 1 month ago

I need time to digest this, but off the bat this looks beautiful and well thought out. Your emoji game is on point ❀️

tobySolutions commented 1 month ago

For example, a 🧱 block that adds a .nvmrc file:

This is amazing! Read through core parts to get an idea on things. DAMN!

JohannesKonings commented 1 month ago

cool, looks like a similar approach like https://github.com/projen/projen

JoshuaKGoldberg commented 3 days ago

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!