-
-
Notifications
You must be signed in to change notification settings - Fork 75
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
📝 Documentation: Long-term project vision #1181
Comments
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:
I'm thinking the 💝 On top of all that will be end-user templates such as ➕ 🏷️ Inputs🏷️ Inputs will be small metadata-driven functions that provide any data needed to inform 🧱 blocks and 🧰 addons later.
💝 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 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 🏷️ Input 🧪 TestingThe For example, testing the previous 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. 💝 For example, an 🏷️ input that determines the npm username based on either 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
);
},
}); 🧱 BlocksThe 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
For example, a 🧱 block that adds a import { createBlock } from "@create/block";
export const blockNvmrc = createBlock({
async produce() {
return {
files: {
".nvmrc": "20.12.2",
},
};
},
}); The 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 🏷️ InputsBlocks can take in data from 🏷️ inputs. 💝 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 may include:
For example, this Vitest 🧱 block indicates that there can now be 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. For example, this Tsup 🧱 block reacts to 🪪 metadata to exclude test files from its 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. 🧱 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 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 🧰 AddonsThere 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 💝 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 The 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",
},
],
},
},
});
});
}); 🎁 PresetsUsers 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 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 📄 DocumentationFor 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 RepositoriesUsers 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. 💝
|
I need time to digest this, but off the bat this looks beautiful and well thought out. Your emoji game is on point ❤️ |
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! |
OK! In order... Projen is really interesting, thanks for pointing me at it @JohannesKonings! I think it's a step in the right direction from Yeomen, similar to my vision for create. There are a few significant points I think we differ on:
Ultimately, even though Projen looks great and has an active team behind it, I don't think it's a match for what I'm looking to use in Another ding against using Projen is that we're just very different maintenance crews. Projen is still 0.x and it would take a lot of time for me to ramp up on it & get integrated with the team. Pragmatically speaking, it'd be easier for me to 'tunnel vision' on building my own thing that focuses on my use case. Then later on, if I discover why another project is better, the core building blocks of I took a dive and this isn't what I'm looking for here. From https://nx.dev/extending-nx/recipes/local-generators, generators "automate many tasks you regularly perform as part of your development workflow" - but that's generally scoped within a repository. They're not targeted to composable, reusable templates. I put a very rough starting sketch of a What'll likely happen next is:
|
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>1000 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
The text was updated successfully, but these errors were encountered: