From 64b900e45262016279615d8d486c5f32dc7c8293 Mon Sep 17 00:00:00 2001 From: Alexey Sudilovskiy Date: Fri, 10 Jul 2026 21:12:10 +0100 Subject: [PATCH 1/8] chore: add writing-unit-tests skill --- .claude/skills/writing-unit-tests/SKILL.md | 50 ++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 .claude/skills/writing-unit-tests/SKILL.md diff --git a/.claude/skills/writing-unit-tests/SKILL.md b/.claude/skills/writing-unit-tests/SKILL.md new file mode 100644 index 0000000..8dd98f0 --- /dev/null +++ b/.claude/skills/writing-unit-tests/SKILL.md @@ -0,0 +1,50 @@ +--- +name: writing-unit-tests +description: Use when writing or editing *.test.ts files in this repo (@gravity-ui/create). +--- + +# Writing Unit Tests (gravity-ui/create) + +## Overview + +Reference skill distilled from this repo's AGENTS.md files. Native `node:test`, colocated `*.test.ts`, never touch real disk. + +## Quick Reference + +| Rule | Why | +|---|---| +| Use `test.describe` + `test(name, (t: TestContext) => ...)` with `t.assert.*` | Not top-level `node:assert/strict` + nested `await t.test()` — lint/prettier normalizes to the former anyway | +| Colocate `foo.test.ts` next to `foo.ts` | Repo convention, no `__tests__` dirs | +| Generators: never call real `node:fs` | Always take a `FileSystem` param; tests pass `memfs.ts` | +| Generator tests: `runGenerators({dryRun: true})`, assert on captured files | Never write to real disk in a test | +| `nvm use && npm test` after any behavior change | `.nvmrc` pins Node; wrong Node breaks the TS loader | +| Chicago school: assert on state (captured files, return values), not call-args | London-style interaction mocks aren't used anywhere in this repo's tests | + +## Chicago school (state-based), not London (interaction-based) + +Verify state/output of the code under test, not "was function X called with args Y" on its collaborators. Concretely in this repo: + +- `mock.module('node:fs', ...)` in `destination.test.ts` mocks a builtin, but still fits Chicago school: it substitutes a working fake (backed by `memfs` from the `memfs` npm package) at the one true I/O boundary, and the tests assert on `validateDestination`'s return value (state) — never on whether `existsSync` was called. The distinction is state-vs-interaction verification, not "mocking is banned." +- Don't add call-count/call-args assertions (`t.mock.fn`/`t.mock.method` + `assert(called with...)`) for code that takes a `FileSystem` param — pass a real `memfs` instance and assert on what it captured instead. + +## Mocking `node:fs` directly + +`eslint.config.js`'s `no-restricted-imports` scopes which file(s) may import `node:fs`/`node:fs/promises` directly — check that rule to find the current file(s). When testing one of those: + +`mock.module('node:fs', ...)` only rebinds modules that import the mocked specifier *directly*. Once such a module has been evaluated and its top-level `import {existsSync} from 'node:fs'` linked, `mock.restore()` + a fresh `mock.module()` does **not** rebind it — Node's ESM cache never re-evaluates the module for the same specifier. Remocking per test silently reuses the *first* test's mock in every later test, with no error. + +**Working pattern**: +- Mock `node:fs` **once** at module top-level, backed by a single `memfs()` volume. +- Import the module under test once. +- Give each test its own path namespace (`/case-/cwd/...`) inside that shared volume instead of remocking per test. + +## Testing pure logic split out of I/O + +When a module mixes pure decision logic with unavoidable I/O (terminal prompts, `p.note` formatting), pull the decision logic into a small pure function over plain data and unit-test that directly — e.g. `buildNextSteps(model, cwd)` extracted from `main()` in `src/index.ts`. Don't try to test the I/O wrapper. + +## Common Mistakes + +- Writing to real disk in a generator test instead of `{dryRun: true}` + memfs. +- Remocking `node:fs` per test instead of once at module top-level. +- Nested `await t.test()` instead of flat `test.describe`/`test`. +- Mocking a direct import of the module under test (e.g. `mock.module`-ing `utils/isModulePackage.js` while testing `generators/base.ts`) instead of driving it with real input data. These are plain functions over the `model`/args already in scope — construct a `model` that produces the branch you want, don't intercept the import. From 8f4bc47b1dbf311d724116fdaf0dd20fe97c3e2f Mon Sep 17 00:00:00 2001 From: Alexey Sudilovskiy Date: Fri, 10 Jul 2026 21:12:27 +0100 Subject: [PATCH 2/8] chore: ignore generated files --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index c09ef2f..6df86bd 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,5 @@ /test-results /playwright-report /blob-report +/docs/superpowers/ +/lcov.info \ No newline at end of file From affa98d12ee1e1bab2d47c83bd3e6a9252408abb Mon Sep 17 00:00:00 2001 From: Alexey Sudilovskiy Date: Mon, 13 Jul 2026 13:57:51 +0100 Subject: [PATCH 3/8] chore: add basic agents.md for coding --- AGENTS.md | 86 ++++++++++++++++++++++++++++++ CLAUDE.md | 1 + e2e/AGENTS.md | 30 +++++++++++ src/generators/templates/AGENTS.md | 5 ++ 4 files changed, 122 insertions(+) create mode 100644 AGENTS.md create mode 100644 CLAUDE.md create mode 100644 e2e/AGENTS.md create mode 100644 src/generators/templates/AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..8a2d346 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,86 @@ +# AGENTS.md + +## What this is + +`@gravity-ui/create` — CLI that scaffolds a new project on the gravity-ui stack. Asks a few questions (or reads CLI flags), builds a `ProjectModel`, then runs generators that write files into the chosen destination folder. + +## Source layout + +- `src/cli` — arg parsing, help/version/dry-run rendering. Flag behavior itself lives in `CliSchema` (see Conventions). +- `src/prompts` — interactive questionnaire (`@clack/prompts`). +- `src/model` — `ProjectModel`: mutable accumulator, built up by prompts, consumed and further mutated by generators. +- `src/generators` — one file per feature. Each generator registers deps/scripts on the model (via `utils/pm.ts`) and writes files through the `FileSystem` abstraction. + - `templates/` — `.hbs` sources; see `templates/AGENTS.md` for the precompile hook and an eslint-config-specific gotcha. +- `src/utils` — `fs.ts` is the only file eslint allows to import `node:fs`/`node:fs/promises`/`fs` (`no-restricted-imports`, scoped to this one file, covers sync too) — any new fs-touching helper must live here. +- `e2e` — Playwright specs driving CLI-scaffolded output, run via `.github/workflows/e2e.yml`. See `e2e/AGENTS.md`. +- Any `__fixtures__` dir anywhere under `src/` is test-only, excluded from the publish build (`tsconfig.publish.json`'s glob exclude) — never put anything meant to ship in `lib` there. + +## Checks + +All `npm`/`node`/`npx` commands below MUST be prefixed with `nvm use &&` — wrong Node version breaks the TS loader. + +- To run a one-off `.ts` file directly, plain `node file.ts` fails on module resolution — source imports use `.js` extensions (NodeNext) that don't exist on disk. Use: `node --import ./scripts/test-runner-register.js file.ts` (same loader hook `npm test` registers). +- `npm test` — run after any behavior change, always. +- `npm test -- --experimental-test-coverage --test-reporter=lcov --test-reporter-destination=lcov.info --test-coverage-exclude='**/*.test.ts' --test-coverage-exclude='**/__fixtures__/**' --test-coverage-exclude='**/*.hbs.ts' --test-coverage-exclude='**/.*.hbs.ts' --test-coverage-include='src/**'` — coverage report, on demand. + - **Gap**: node's coverage only instruments files actually `import`ed during the run — an untouched file is silently absent from `lcov.info` (not listed at 0%), inflating the reported %. Find genuinely untested files: diff `find src -name '*.ts' ! -name '*.test.ts' ! -path '*/__fixtures__/*' ! -name '*.hbs.ts'` against `grep '^SF:' lcov.info | sed 's/^SF://'` — anything only on the left was never touched by a test. +- `npx tsc --noEmit` — run after any change, always. Running it directly (vs `npm run typecheck`) skips the `pretypecheck` hook that regenerates `.hbs.ts` — see the `.hbs` note below if templates changed. +- `npm run lint -- --fix` and `npm run lint:prettier` — run once a file is believed done (style pass, not per-edit). +- `npm run knip` — run before declaring work finished, to catch unused exports left behind by a refactor. The dummy `E2E_APP_DIR=.` in its script exists only so knip's Playwright plugin can import `playwright.config.ts` (which throws if unset) — it's never used to actually launch anything. +- `.hbs` templates compile to gitignored `.hbs.ts` (never hand-edit). Regen is hooked into `pretest`/`pretypecheck`/`prebuild`/`preknip`, so `npm test`/`npm run knip` always see fresh output — `npx tsc --noEmit` doesn't, run `npm run precompile-templates` manually first if `.hbs` files changed. See `templates/AGENTS.md`. + +## Conventions + +- **Types**: put `types.ts` next to the code that owns it. Only hoist to a shared parent (`generators/types.ts`, `utils/types.ts`) when colocating would cause a circular import. +- **Filesystem**: generators never touch `node:fs` directly — always take a `FileSystem` param, so `--dry-run` and tests can swap in `memfs` instead of writing to disk. +- **CLI flags**: add fields to `CliSchema` only — parsing, per-flag help text (description/placeholder), and validation all derive from it automatically. Don't hand-wire a new flag into `parseArgs` separately. Known gap: `help.ts`'s `Examples` section is hand-written prose (command strings with literal flag names), not derived from the schema — renaming a flag silently leaves it stale, so grep the old name there too. +- **Tests**: native `node:test` (`npm test`), colocated as `*.test.ts` next to the source under test. See `writing-unit-tests` skill for conventions/gotchas. +- **Style**: never hand-fix styling/formatting (import order, quotes, indentation) — run the lint/prettier commands from `## Checks` instead, even for a single misplaced import. +- **View/logic split for testability**: when a module mixes pure data-building with rendering (e.g. terminal `p.note` formatting), separate the two — e.g. `src/cli/dryRun.ts`'s `buildDryRunSummary` (pure, unit-tested) vs `renderDryRunSummary`/`formatDryRunSummary` (terminal output). +- **Derived flags**: a simple single-field check (e.g. `model.hasBackend`) stays inline. Once a decision needs more than one `ProjectModel` field (e.g. `hasAppBuilder = hasBackend || hasFrontend`), add it to `calculateFlags(model)` (`src/utils/calculateFlags.ts`) instead of a one-off predicate. +- **Generated files**: every JS/TS output file goes through a `.hbs` template — no generator writes JS/TS content directly. JSON files are the exception, built via `writeJson`/`JSON.stringify` instead. + +## Generated project structure + +Three shapes: app-builder layout (frontend and/or backend selected), plain TS entry (neither, `ts`), plain JS entry (neither, `js`). + +**App-builder layout.** Worked example below is `ts` / backend / frontend + styles + react (all features on); annotations mark what's conditional: + +``` +. +|-- .gitignore +|-- .prettierrc.js +|-- .stylelintrc.json # only with styles +|-- README.md +|-- app-builder.config.ts +|-- eslint.config.mjs +|-- package.json +|-- tsconfig.json # references ui/ and/or server/; both languages +`-- src/ + |-- server/ # only if hasBackend + | |-- index.ts # expresskit+nodekit+app-layout init; ext by language + | `-- tsconfig.json + `-- ui/ # only if frontend enabled + |-- tsconfig.json + |-- entries/ + | `-- my-app-app.tsx # entry (>=1 allowed); name from project; ext/case by react+language + |-- components/ # only if react enabled + | |-- index.ts # re-exports App; ext by language + | `-- App/ + | `-- App.tsx # demo component; ext by language + `-- types/ + `-- assets.d.ts # only ts+react +``` + +Dev/build scripts pick a side via `--target ` when only one of ui/server exists. + +**Neither frontend nor backend, TypeScript** — plain `src/index.ts`, no app-builder, straight `tsc` compile. + +**Neither frontend nor backend, JavaScript** — plain `index.js` at project root, not under `src/`. Intentional: with no bundler and no frontend/backend, there's no convention to commit to, so the entry stays at the root instead of guessing a layout. + +## Valid parameter combinations + +`styles`/`react` are only settable when frontend is enabled — enforced twice: `CliSchema`'s `.refine()` checks (`schema.ts`) and structurally by `runPromptFlow.ts` (styles/react prompts only run `if (model.frontend)`). Keep both in sync if this changes. So the frontend axis collapses to 5 states — `false`, or enabled × `{none, styles-only, react-only, styles+react}` — not a raw 2×2 sub-cross. + +Combined with `language` (2) and `hasBackend` (2): **20** distinct configs. `registry` (default vs. custom URL) is an independent axis, only toggling whether `generateBase` writes `.npmrc`. No package-manager (npm/yarn/pnpm) axis exists. + +What's actually observable at runtime per combo (browser vs stdout vs nothing), and current e2e coverage gaps: see `e2e/AGENTS.md`. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/e2e/AGENTS.md b/e2e/AGENTS.md new file mode 100644 index 0000000..36c6917 --- /dev/null +++ b/e2e/AGENTS.md @@ -0,0 +1,30 @@ +# e2e/AGENTS.md + +Playwright specs that assert on CLI-scaffolded output rendered in a real browser. Not run by `npm test` — driven by `.github/workflows/e2e.yml`, which builds the CLI, scaffolds one or more throwaway projects into `/tmp`, and points Playwright at whichever one is under test. + +## Targeting + +- All specs flat in one `testDir` (config) — no per-shape split. +- CI job picks target app by setting `E2E_APP_DIR`/`APP_PORT` before `npx playwright test`, not by pointing at a different testDir. +- Since one unfiltered run would hit every spec against whatever's scaffolded, each spec is tagged `{tag: '@name'}` (e.g. `@frontend-react`/`@frontend-no-react`) and each CI job filters `--grep @tag`. New spec file → add its tag to the right job's grep, else other jobs pick it up and fail. + +## What's actually observable per combo (verified by running each, not just reading templates) + +`language` and `styles` never affect runtime output — only `hasFrontend`/`hasBackend`/`hasReact` do. That collapses the 20 configs in the root AGENTS.md's parameter matrix to 4 buckets: + +| hasFrontend | hasBackend | what you can assert | where | +|---|---|---|---| +| false | false | stdout `Hello, world!` after `npm run build && npm start` | plain entry (`src/index.ts` or root `index.js`), CI `test-cli` job — **ts only, no js coverage** | +| false | true | HTTP body is the literal string `Hello, world!`, **no HTML markup at all** despite `Content-Type: text/html` — verified via curl. A Playwright `h1` locator finds nothing; must assert raw body text | not covered by any CI job | +| true | false | **nothing app-related to assert.** `npm run dev` (`app-builder dev --target client`) only builds the JS bundle to `dist/public/build/`; hitting `/` in a browser gets webpack-dev-server's raw directory listing, not the app — there's no HTML shell without a backend to render `@gravity-ui/app-layout` | not covered, not coverable as scaffolded | +| true | true | `GET /` renders the `@gravity-ui/app-layout` shell with a real `h1` — assert via `assertHeading` fixture (below). Text is `Hello, Gravity UI!` for `--react`, `Hello, world!` for `--no-react`; `--styles` never changes it | CI: `test-e2e-browser` (react, ts only) + `test-e2e-no-react` (no-react, ts **and** js) | + +Gaps if adding coverage: no js-language run of the CLI-stdout or react-frontend jobs, and the backend-only / frontend-only buckets have no test at all. + +## `--out` must resolve inside the invoking process's cwd + +`validateDestination` (`src/utils/destination.ts`) rejects `--out` resolving outside cwd. So `.github/workflows/e2e.yml` scaffolds via `working-directory: /tmp` + relative `--out`, calling CLI by absolute `$GITHUB_WORKSPACE/lib/index.js`. Changing either side: check the other didn't break. + +## Fixtures + +`fixtures.ts` extends Playwright's base `test` with an `assertHeading(expected)` fixture (goto `/` + assert `h1` text). New spec asserting `h1` after `goto('/')`: use `assertHeading`, don't inline goto+expect. Different assertion shape (different element, multiple assertions, no goto): add a new fixture to `fixtures.ts`, not inline boilerplate — specs stay tag + expected value only. diff --git a/src/generators/templates/AGENTS.md b/src/generators/templates/AGENTS.md new file mode 100644 index 0000000..d75d67e --- /dev/null +++ b/src/generators/templates/AGENTS.md @@ -0,0 +1,5 @@ +# templates/ + +- New generated file uses Node APIs but isn't under `src/server` (which gets node globals via `serverConfig`)? Add it to `eslint.config.js.hbs`'s `files: [...]` block (gate by right `{{#if}}`) — else `no-undef`/`process is not defined`. +- Check blank-line/conditional rendering across flag combos: `npm run precompile-templates`, then use root AGENTS.md's one-off `.ts` runner to import the compiled `.hbs.ts` default with different context objects. Faster than eyeballing `.hbs` source. +- Never name a `.hbs` file so precompiled output contains `.d.ts` mid-filename (e.g. `assets.d.ts.hbs` → `assets.d.ts.hbs.ts`) — tsc treats it as ambient, silently skips emit. Fix: avoid substring in source name (e.g. `assets.d-ts.hbs`). Detect: diff `find src/generators/templates -name '*.hbs'` vs `find lib/generators/templates -name '*.js'` after build. From 3d4f7d18d31be5d1c74f6cad4121add2eb8fdce9 Mon Sep 17 00:00:00 2001 From: Alexey Sudilovskiy Date: Mon, 13 Jul 2026 15:48:04 +0100 Subject: [PATCH 4/8] chore: generate readme script --- AGENTS.md | 35 ++-------------- package.json | 1 + scripts/generate-readme.test.ts | 21 ++++++++++ scripts/generate-readme.ts | 42 ++++++++++++++++++++ scripts/md.d.ts | 4 ++ scripts/readme/flagsTable.hbs | 10 +++++ scripts/readme/readme.hbs | 68 ++++++++++++++++++++++++++++++++ scripts/ts-extension-override.js | 20 +++++++++- tsconfig.json | 1 + 9 files changed, 168 insertions(+), 34 deletions(-) create mode 100644 scripts/generate-readme.test.ts create mode 100644 scripts/generate-readme.ts create mode 100644 scripts/md.d.ts create mode 100644 scripts/readme/flagsTable.hbs create mode 100644 scripts/readme/readme.hbs diff --git a/AGENTS.md b/AGENTS.md index 8a2d346..9dc76c0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,6 +27,7 @@ All `npm`/`node`/`npx` commands below MUST be prefixed with `nvm use &&` — wro - `npm run lint -- --fix` and `npm run lint:prettier` — run once a file is believed done (style pass, not per-edit). - `npm run knip` — run before declaring work finished, to catch unused exports left behind by a refactor. The dummy `E2E_APP_DIR=.` in its script exists only so knip's Playwright plugin can import `playwright.config.ts` (which throws if unset) — it's never used to actually launch anything. - `.hbs` templates compile to gitignored `.hbs.ts` (never hand-edit). Regen is hooked into `pretest`/`pretypecheck`/`prebuild`/`preknip`, so `npm test`/`npm run knip` always see fresh output — `npx tsc --noEmit` doesn't, run `npm run precompile-templates` manually first if `.hbs` files changed. See `templates/AGENTS.md`. +- `README.md` is generated (see header comment) — `scripts/generate-readme.test.ts` catches drift. ## Conventions @@ -41,41 +42,11 @@ All `npm`/`node`/`npx` commands below MUST be prefixed with `nvm use &&` — wro ## Generated project structure -Three shapes: app-builder layout (frontend and/or backend selected), plain TS entry (neither, `ts`), plain JS entry (neither, `js`). - -**App-builder layout.** Worked example below is `ts` / backend / frontend + styles + react (all features on); annotations mark what's conditional: - -``` -. -|-- .gitignore -|-- .prettierrc.js -|-- .stylelintrc.json # only with styles -|-- README.md -|-- app-builder.config.ts -|-- eslint.config.mjs -|-- package.json -|-- tsconfig.json # references ui/ and/or server/; both languages -`-- src/ - |-- server/ # only if hasBackend - | |-- index.ts # expresskit+nodekit+app-layout init; ext by language - | `-- tsconfig.json - `-- ui/ # only if frontend enabled - |-- tsconfig.json - |-- entries/ - | `-- my-app-app.tsx # entry (>=1 allowed); name from project; ext/case by react+language - |-- components/ # only if react enabled - | |-- index.ts # re-exports App; ext by language - | `-- App/ - | `-- App.tsx # demo component; ext by language - `-- types/ - `-- assets.d.ts # only ts+react -``` +See [README.md](./README.md#what-gets-generated) for the three shapes and the app-builder directory layout. Dev/build scripts pick a side via `--target ` when only one of ui/server exists. -**Neither frontend nor backend, TypeScript** — plain `src/index.ts`, no app-builder, straight `tsc` compile. - -**Neither frontend nor backend, JavaScript** — plain `index.js` at project root, not under `src/`. Intentional: with no bundler and no frontend/backend, there's no convention to commit to, so the entry stays at the root instead of guessing a layout. +Plain-JS shape's entry stays at project root, not under `src/`: with no bundler and no frontend/backend, there's no convention to commit to, so it stays at the root instead of guessing a layout. ## Valid parameter combinations diff --git a/package.json b/package.json index 9b012d7..04e1b32 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "lint": "eslint --quiet", "lint:prettier": "prettier --list-different '**/*.ts'", "precompile-templates": "node scripts/precompile-templates.ts", + "generate-readme": "node --import ./scripts/test-runner-register.js scripts/generate-readme.ts > README.md", "pretest": "npm run precompile-templates", "test": "node --experimental-test-module-mocks --import ./scripts/test-runner-register.js --test", "test:e2e": "playwright test", diff --git a/scripts/generate-readme.test.ts b/scripts/generate-readme.test.ts new file mode 100644 index 0000000..045a031 --- /dev/null +++ b/scripts/generate-readme.test.ts @@ -0,0 +1,21 @@ +import {execFile as execFileCb} from 'node:child_process'; +import path from 'node:path'; +import {type TestContext, test} from 'node:test'; +import {promisify} from 'node:util'; + +import readme from '../README.md' with {type: 'text'}; + +const execFile = promisify(execFileCb); +const rootDir = path.join(import.meta.dirname, '..'); + +test.describe('generate-readme', () => { + test('README.md matches generated output', async (t: TestContext) => { + const {stdout} = await execFile( + process.execPath, + ['--import', './scripts/test-runner-register.js', 'scripts/generate-readme.ts'], + {cwd: rootDir}, + ); + + t.assert.strictEqual(stdout, readme); + }); +}); diff --git a/scripts/generate-readme.ts b/scripts/generate-readme.ts new file mode 100644 index 0000000..140d802 --- /dev/null +++ b/scripts/generate-readme.ts @@ -0,0 +1,42 @@ +import {readFile} from 'node:fs/promises'; +import path from 'node:path'; + +import Handlebars from 'handlebars'; + +import {buildHelpData} from '../src/cli/help.js'; +import type {FlagSignature} from '../src/cli/types.js'; + +const rootDir = path.join(import.meta.dirname, '..'); +const templatesDir = path.join(import.meta.dirname, 'readme'); + +function formatSignature({parts, placeholder}: FlagSignature): string { + return placeholder ? `${parts.join(', ')} ${placeholder}` : parts.join(', '); +} + +function buildContext(pkg: {engines: {node: string}}) { + const {groups, examples} = buildHelpData(); + + return { + groups: groups.map(({label, flags}) => ({ + label, + flags: flags.map((flag) => ({ + signature: formatSignature(flag.signature), + description: flag.choices + ? `${flag.description} (${flag.choices.join('|')})` + : flag.description, + })), + })), + examples: examples.map(([cmd, comment]) => ({cmd, comment})), + engines: pkg.engines.node, + }; +} + +const [readmeSource, flagsTableSource, pkgSource] = await Promise.all([ + readFile(path.join(templatesDir, 'readme.hbs'), 'utf8'), + readFile(path.join(templatesDir, 'flagsTable.hbs'), 'utf8'), + readFile(path.join(rootDir, 'package.json'), 'utf8'), +]); + +Handlebars.registerPartial('flagsTable', flagsTableSource); +const template = Handlebars.compile(readmeSource); +process.stdout.write(template(buildContext(JSON.parse(pkgSource)))); diff --git a/scripts/md.d.ts b/scripts/md.d.ts new file mode 100644 index 0000000..827adb7 --- /dev/null +++ b/scripts/md.d.ts @@ -0,0 +1,4 @@ +declare module '*.md' { + const content: string; + export default content; +} diff --git a/scripts/readme/flagsTable.hbs b/scripts/readme/flagsTable.hbs new file mode 100644 index 0000000..dee217f --- /dev/null +++ b/scripts/readme/flagsTable.hbs @@ -0,0 +1,10 @@ +{{#each groups}} +**{{this.label}}** + +| Flag | Description | +| --- | --- | +{{#each this.flags}} +| `{{{this.signature}}}` | {{{this.description}}} | +{{/each}} + +{{/each}} diff --git a/scripts/readme/readme.hbs b/scripts/readme/readme.hbs new file mode 100644 index 0000000..3225381 --- /dev/null +++ b/scripts/readme/readme.hbs @@ -0,0 +1,68 @@ + + +# @gravity-ui/create + +> Scaffold a Gravity UI stack project in seconds. + +## Quick start + +```bash +npm create @gravity-ui +``` + +## Flags + +{{> flagsTable}} + +## Examples + +{{#each examples}} +```bash +{{this.cmd}} +``` + +{{this.comment}} + +{{/each}} +## What gets generated + +Three shapes: app-builder layout (frontend and/or backend selected), plain TS entry (neither, `ts`), plain JS entry (neither, `js`). + +**App-builder layout.** Worked example below is `ts` / backend / frontend + styles + react (all features on); annotations mark what's conditional: + +``` +. +|-- .gitignore +|-- .prettierrc.js +|-- .stylelintrc.json # only with styles +|-- README.md +|-- app-builder.config.ts +|-- eslint.config.mjs +|-- package.json +|-- tsconfig.json # references ui/ and/or server/; both languages +`-- src/ + |-- server/ # only if hasBackend + | |-- index.ts # expresskit+nodekit+app-layout init; ext by language + | `-- tsconfig.json + `-- ui/ # only if frontend enabled + |-- tsconfig.json + |-- entries/ + | `-- my-app-app.tsx # entry (>=1 allowed); name from project; ext/case by react+language + |-- components/ # only if react enabled + | |-- index.ts # re-exports App; ext by language + | `-- App/ + | `-- App.tsx # demo component; ext by language + `-- types/ + `-- assets.d.ts # only ts+react +``` + +**Neither frontend nor backend, TypeScript** — plain `src/index.ts`, no app-builder, straight `tsc` compile. + +**Neither frontend nor backend, JavaScript** — plain `index.js` at project root. + +## Requirements + +Node.js `{{{engines}}}`. diff --git a/scripts/ts-extension-override.js b/scripts/ts-extension-override.js index 750ca26..05620c4 100644 --- a/scripts/ts-extension-override.js +++ b/scripts/ts-extension-override.js @@ -1,4 +1,5 @@ -export async function resolve(specifier, context, nextResolve) { +/** @type {import('node:module').ResolveHook} */ +export const resolve = async (specifier, context, nextResolve) => { if (specifier.endsWith('.js')) { const tsSpecifier = specifier.replace(/\.js$/, '.ts'); try { @@ -9,4 +10,19 @@ export async function resolve(specifier, context, nextResolve) { } } return nextResolve(specifier, context); -} +}; + +/** @type {import('node:module').LoadHook} */ +export const load = async (url, context, nextLoad) => { + if (url.endsWith('.md')) { + const {readFile} = await import('node:fs/promises'); + const {fileURLToPath} = await import('node:url'); + const source = await readFile(fileURLToPath(url), 'utf8'); + return { + format: 'module', + source: `export default ${JSON.stringify(source)};`, + shortCircuit: true, + }; + } + return nextLoad(url, context); +}; diff --git a/tsconfig.json b/tsconfig.json index b8ca721..0107c78 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,6 +6,7 @@ "moduleResolution": "NodeNext", "outDir": "./lib", "resolveJsonModule": true, + "checkJs": true, "types": ["node"] }, "include": ["src/**/*", "scripts/**/*", "e2e/**/*", "playwright.config.ts"] From bf72766104b7267af8919629340338762f5b153f Mon Sep 17 00:00:00 2001 From: Alexey Sudilovskiy Date: Mon, 13 Jul 2026 15:48:25 +0100 Subject: [PATCH 5/8] docs: generate readme --- README.md | 106 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 106 insertions(+) diff --git a/README.md b/README.md index fdc7723..ce6645b 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,8 @@ + + # @gravity-ui/create > Scaffold a Gravity UI stack project in seconds. @@ -7,3 +12,104 @@ ```bash npm create @gravity-ui ``` + +## Flags + +**Project options** + +| Flag | Description | +| --- | --- | +| `--out ` | Destination folder for the new project | +| `--language ` | Project language (ts|js) | +| `--frontend, --no-frontend` | Include frontend setup | +| `--styles, --no-styles` | Include stylelint (requires --frontend) | +| `--react, --no-react` | Include React + JSX transform (requires --frontend) | +| `--backend, --no-backend` | Include nodekit backend | +| `--registry ` | Custom npm registry | + +**Mode options** + +| Flag | Description | +| --- | --- | +| `-y, --yes` | Accept defaults, skip prompts | +| `--dry-run` | Show what would be generated without writing files | + +**Other** + +| Flag | Description | +| --- | --- | +| `-h, --help` | Show this help and exit | +| `-v, --version` | Print version and exit | + + +## Examples + +```bash +npm create @gravity-ui +``` + +fully interactive + +```bash +npm create @gravity-ui -- --out my-app +``` + +specify path + +```bash +npm create @gravity-ui -- --out my-package --language ts --no-frontend --no-backend -y +``` + +basic TypeScript package + +```bash +npm create @gravity-ui -- --out my-api --language ts --no-frontend --backend -y +``` + +TypeScript backend service + +```bash +npm create @gravity-ui -- --out my-app --dry-run +``` + +preview without writing + +## What gets generated + +Three shapes: app-builder layout (frontend and/or backend selected), plain TS entry (neither, `ts`), plain JS entry (neither, `js`). + +**App-builder layout.** Worked example below is `ts` / backend / frontend + styles + react (all features on); annotations mark what's conditional: + +``` +. +|-- .gitignore +|-- .prettierrc.js +|-- .stylelintrc.json # only with styles +|-- README.md +|-- app-builder.config.ts +|-- eslint.config.mjs +|-- package.json +|-- tsconfig.json # references ui/ and/or server/; both languages +`-- src/ + |-- server/ # only if hasBackend + | |-- index.ts # expresskit+nodekit+app-layout init; ext by language + | `-- tsconfig.json + `-- ui/ # only if frontend enabled + |-- tsconfig.json + |-- entries/ + | `-- my-app-app.tsx # entry (>=1 allowed); name from project; ext/case by react+language + |-- components/ # only if react enabled + | |-- index.ts # re-exports App; ext by language + | `-- App/ + | `-- App.tsx # demo component; ext by language + `-- types/ + `-- assets.d.ts # only ts+react +``` + +**Neither frontend nor backend, TypeScript** — plain `src/index.ts`, no app-builder, straight `tsc` compile. + +**Neither frontend nor backend, JavaScript** — plain `index.js` at project root. + +## Requirements + +Node.js `^22.13.0 || >=23.5.0`. From 3a8af756b5d67ad4673c4cb818d232e6f0d6460f Mon Sep 17 00:00:00 2001 From: Alexey Sudilovskiy Date: Mon, 13 Jul 2026 15:59:56 +0100 Subject: [PATCH 6/8] fix: escape table cell data escaping --- README.md | 2 +- scripts/generate-readme.ts | 4 ++++ scripts/readme/flagsTable.hbs | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index ce6645b..98574ba 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ npm create @gravity-ui | Flag | Description | | --- | --- | | `--out ` | Destination folder for the new project | -| `--language ` | Project language (ts|js) | +| `--language ` | Project language (ts\|js) | | `--frontend, --no-frontend` | Include frontend setup | | `--styles, --no-styles` | Include stylelint (requires --frontend) | | `--react, --no-react` | Include React + JSX transform (requires --frontend) | diff --git a/scripts/generate-readme.ts b/scripts/generate-readme.ts index 140d802..74e5e00 100644 --- a/scripts/generate-readme.ts +++ b/scripts/generate-readme.ts @@ -37,6 +37,10 @@ const [readmeSource, flagsTableSource, pkgSource] = await Promise.all([ readFile(path.join(rootDir, 'package.json'), 'utf8'), ]); +Handlebars.registerHelper( + 'tableCell', + (value: string) => new Handlebars.SafeString(value.replaceAll('|', '\\|')), +); Handlebars.registerPartial('flagsTable', flagsTableSource); const template = Handlebars.compile(readmeSource); process.stdout.write(template(buildContext(JSON.parse(pkgSource)))); diff --git a/scripts/readme/flagsTable.hbs b/scripts/readme/flagsTable.hbs index dee217f..179e65b 100644 --- a/scripts/readme/flagsTable.hbs +++ b/scripts/readme/flagsTable.hbs @@ -4,7 +4,7 @@ | Flag | Description | | --- | --- | {{#each this.flags}} -| `{{{this.signature}}}` | {{{this.description}}} | +| `{{tableCell this.signature}}` | {{tableCell this.description}} | {{/each}} {{/each}} From 51aa961d634858e66128c3ac0302ad8b265b0ccb Mon Sep 17 00:00:00 2001 From: Alexey Sudilovskiy Date: Mon, 13 Jul 2026 16:04:15 +0100 Subject: [PATCH 7/8] chore: new line --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 6df86bd..e2ab9bd 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,4 @@ /playwright-report /blob-report /docs/superpowers/ -/lcov.info \ No newline at end of file +/lcov.info From 3dd9a21083f845bacc6521881d915ebb9ccdfd72 Mon Sep 17 00:00:00 2001 From: Alexey Sudilovskiy Date: Mon, 13 Jul 2026 16:17:55 +0100 Subject: [PATCH 8/8] chore: mark readme.md as generated --- .gitattributes | 1 + 1 file changed, 1 insertion(+) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..4981a65 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +README.md linguist-generated=true