From c624d73c165d442aa5ad314e4e1dcb1ed4aa7c44 Mon Sep 17 00:00:00 2001 From: Bret Comnes Date: Sun, 13 Sep 2026 10:56:55 -0700 Subject: [PATCH] Add interactive starter options and deployment setup --- .github/dependabot.yml | 6 +- README.md | 113 ++++++++++++++++++++++++++++--- bin.ts | 14 +++- defaults.ts | 19 ++++++ dependencies.test.ts | 17 +++++ dependencies.ts | 11 +++ deployment.test.ts | 89 ++++++++++++++++++++++++ deployment.ts | 128 +++++++++++++++++++++++++++++++++++ eject.test.ts | 76 +++++++++++++++++++++ index.test.ts | 26 ++++--- index.ts | 123 ++++++++++++++++++++++++++++++--- package.json | 19 +++++- prompts.test.ts | 68 +++++++++++++++++++ prompts.ts | 41 +++++++++++ smoke.ts | 123 +++++++++++++++++++++++++++++++++ template.test.ts | 69 +++++++++++++++++++ template.ts | 150 +++++++++++++++++++++++++++++++++++++++++ 17 files changed, 1055 insertions(+), 37 deletions(-) create mode 100644 defaults.ts create mode 100644 dependencies.test.ts create mode 100644 dependencies.ts create mode 100644 deployment.test.ts create mode 100644 deployment.ts create mode 100644 eject.test.ts create mode 100644 prompts.test.ts create mode 100644 prompts.ts create mode 100644 smoke.ts create mode 100644 template.test.ts create mode 100644 template.ts diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 6fe00e6..cce8d1d 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -8,11 +8,7 @@ updates: interval: "daily" cooldown: default-days: 1 - ignore: - - dependency-name: "*" - update-types: - - "version-update:semver-minor" - - "version-update:semver-patch" + versioning-strategy: increase groups: typescript: patterns: diff --git a/README.md b/README.md index 7f44cad..af3b0f9 100644 --- a/README.md +++ b/README.md @@ -13,14 +13,49 @@ npm create @domstack/app@latest my-app ``` The npm `create` convention maps `@domstack/app` to this package, `@domstack/create-app`. -The command creates the project and installs its dependencies. +In an interactive terminal, the command asks for the source language (TypeScript by default), browser JSX runtime (none by default, Preact recommended when wanted, or React), Tailwind CSS (off by default), and deployment (none by default, GitHub Pages, or Neocities). +Explicit flags skip their corresponding questions. +Use `--yes` to skip all questions; non-interactive input also uses defaults for unspecified choices. +The command creates the project, installs its dependencies, and ejects DOMStack's customizable default layout, global stylesheet, and client script in the selected language. -To create the files without installing dependencies: +```sh +npm create @domstack/app@latest my-app -- --yes --language ts --framework preact --tailwind --deploy github-pages +``` + +### Options + +| Flag | Choices / behavior | +| --- | --- | +| `--language` | `ts` (default), `js` | +| `--framework` | `none` (default), `preact`, `react` | +| `--tailwind` / `--no-tailwind` | Enable / disable Tailwind CSS (default: disabled) | +| `--deploy` | `none` (default), `github-pages`, `neocities` | +| `-y`, `--yes` | Skip prompts, retaining explicit choices | +| `--no-install` | Write starter files without installing or ejecting | +| `-h`, `--help` | Show usage | +| `-v`, `--version` | Show the generator version | + +The target directory defaults to `domstack-app` and must be empty if it already exists. + +### Upstream release requirement + +This implementation requires DOMStack's new `--eject --language ts|js --yes` interface from `feat/typescript-default-eject`. +The declared registry range remains unchanged; the currently declared `^12.0.0-beta.5` is not a guarantee that the resolved published package contains those flags. +Before publishing this generator, update its DOMStack dependency to a verified published release containing that interface and repeat the integration checks. +No future release version is assumed here. + +To create the starter files without installing dependencies or ejecting DOMStack's defaults: ```sh npm create @domstack/app@latest my-app -- --no-install ``` +With `--no-install`, first run `npm install`, `npx domstack --eject --language ts --yes`, and `npm install` inside the project to install DOMStack, eject its defaults, and install the added dependencies. +Use `--language js` instead for a JavaScript project. +Eject overwrites the default layout, stylesheet, and client, so only use it in a fresh project or after backing up customizations. +For Tailwind projects, manual eject also replaces `src/globals/global.css`; restore that file to `@import "tailwindcss" source("../");` afterward. +Automatic setup restores the Tailwind stylesheet for you. + Then start the development server: ```sh @@ -33,10 +68,16 @@ npm run dev ```text my-app/ ├── src/ +│ ├── globals/ +│ │ ├── global.client.ts +│ │ └── global.css +│ ├── layouts/ +│ │ └── root.layout.ts │ ├── page.md │ └── style.css ├── .gitignore ├── package.json +├── tsconfig.json └── README.md ``` @@ -44,16 +85,42 @@ The generated scripts are: - `npm run dev` starts DOMStack in watch mode. - `npm run build` builds the site into `public/`. -- `npm run preview` serves the production build. - -The generated project currently follows the `beta` release of `@domstack/static`. +- `npm run preview` builds once and serves the site. +- `npm run typecheck` checks TypeScript projects without emitting files. + +JavaScript selection generates `.js` layout/client files and omits TypeScript configuration and tooling. +Preact or React adds a browser counter at `/interactive/` using `.tsx` or `.jsx`, plus an esbuild settings file with the selected JSX runtime. +Tailwind adds the esbuild plugin and a global Tailwind import; starter styles use a base layer so utility classes can override them. + +The generated project uses the `@domstack/static` version range declared in this generator's `devDependencies`. +Its default layout, global stylesheet, and client script are ejected into `src/` so they can be customized. + +## Deployment + +`--deploy github-pages` generates `.github/workflows/github-pages.yml` and a language-appropriate `src/globals/global.vars.ts` or `.js`. +Select **GitHub Actions** in repository Settings → Pages. +The workflow obtains `base_path` from `actions/configure-pages` and passes it through `DOMSTACK_BASE_PATH` to DOMStack's `basePath` variable, supporting repository sites, user/organization sites, and configured custom domains. +The default layout uses this variable for script and stylesheet URLs; it does not rewrite arbitrary page links or image URLs. +Use relative content URLs or account for `vars.basePath` in rendered content. +Local builds use an empty base path. + +`--deploy neocities` generates `.github/workflows/neocities.yml` using [bcomnes/deploy-to-neocities@v3](https://github.com/bcomnes/deploy-to-neocities), the GitHub Action built on [async-neocities](https://github.com/bcomnes/async-neocities). +`async-neocities` itself is an npm API client and interactive CLI, not an action to put in `uses:`. +The generated action needs no additional npm dependency. +Add your site's Neocities API key as the repository Actions secret `NEOCITIES_API_TOKEN`; never commit it. +The workflow uploads `public/`, preserves orphaned remote files (`cleanup: false`), and leaves Supporter-only file support disabled. +Enable those settings only when appropriate for your site and account. + +Both workflows run on pushes to `main` or manual dispatch, build with Node.js 24, and serialize deployments without cancelling an active deployment. +Change the branch if needed. +They use `npm install` so a lockfile is not required; commit a npm lockfile and switch to `npm ci` for reproducible installs, or adapt the workflow to your preferred package manager. +Generated READMEs include provider-specific setup instructions. +No hosting account is created and no deployment occurs during scaffolding. ## Other package managers ```sh pnpm create @domstack/app@latest my-app -yarn create @domstack/app my-app -bun create @domstack/app@latest my-app ``` ## Programmatic API @@ -61,12 +128,42 @@ bun create @domstack/app@latest my-app ```js import { createApp } from '@domstack/create-app' -createApp({ +await createApp({ targetDirectory: 'my-app', install: false, + language: 'ts', + framework: 'preact', + tailwind: true, + deploy: 'github-pages', }) ``` +`createApp` is asynchronous and returns a promise with `directory`, `packageName`, `packageManager`, `installed`, and `ejected` fields. +It does not prompt; unspecified features use the same defaults as `--yes`. +Set `eject: false` to install without ejecting, or `packageManager` to `npm`, `pnpm`, `yarn`, or `bun` to override detection. +With `install: false`, no eject runs regardless of the `eject` option. + +## Maintaining starter dependencies + +Generated dependency ranges are read from this package's `devDependencies` in `package.json`, rather than duplicated in templates. +Dependabot updates those ranges, and subsequent generator releases pass them on to newly created apps. +The generator reads manifest metadata only; its development dependencies are not installed when users run the published CLI. +Browser runtimes still go into the generated app's `dependencies`, while build and type-checking tools go into its `devDependencies`. +Dependencies added by DOMStack's eject command remain controlled by DOMStack itself. + +## Validation + +```sh +npm test +npm run smoke:local -- ../domstack +``` + +The optional local smoke check requires a DOMStack checkout with development dependencies installed and the new eject interface. +It builds declarations in a disposable upstream copy (never in that checkout), builds and packs this generator, installs the packed CLI, and creates six projects covering JS/TS, no JSX/Preact/React, Tailwind, and all deployment selections. +It installs a local packed DOMStack through a temporary npm wrapper without changing the generated registry dependency range, then checks builds, TS typechecking, Tailwind output, and GitHub Pages asset prefixes. +The check installs dependencies from npm, requires network access unless cached, and does not deploy anything. +Temporary projects and generated declaration/JavaScript build outputs are cleaned afterward. + ## License MIT diff --git a/bin.ts b/bin.ts index 9bb993c..a420842 100644 --- a/bin.ts +++ b/bin.ts @@ -1,6 +1,7 @@ #!/usr/bin/env node import { readFileSync } from 'node:fs' +import { chooseFeatures } from './prompts.ts' import { createApp, detectPackageManager, @@ -14,6 +15,11 @@ Usage: npm create @domstack/app@latest [directory] [options] Options: + --language ts|js Source language (default: ts) + --framework none|preact|react Browser JSX runtime (default: none) + --tailwind / --no-tailwind Enable or disable Tailwind CSS + --deploy none|github-pages|neocities Deployment workflow (default: none) + -y, --yes Skip prompts and use defaults --no-install Create the project without installing dependencies -h, --help Show this help -v, --version Show the installed version @@ -30,18 +36,24 @@ try { ) console.log(packageJson.version) } else { + const features = await chooseFeatures(options) const packageManager = detectPackageManager() console.log(`Creating a DOMStack app in ${options.targetDirectory}...`) - const result = createApp({ + const result = await createApp({ + ...features, targetDirectory: options.targetDirectory, install: options.install, packageManager, }) console.log(`\nCreated ${result.packageName} in ${result.directory}.`) + console.log(`Change into ${result.directory} first.`) if (!result.installed) { console.log(`Run ${packageManager} install to install dependencies.`) + console.log(`Then run the local domstack --eject --language ${features.language} --yes command to eject the customizable defaults.`) + if (features.tailwind) console.log('After manual eject, restore src/globals/global.css as described in README.md for Tailwind.') + console.log(`Run ${packageManager} install again to install the added dependencies.`) } console.log(`Run ${packageManagerRunCommand(packageManager)} to get started.`) } diff --git a/defaults.ts b/defaults.ts new file mode 100644 index 0000000..04a40e3 --- /dev/null +++ b/defaults.ts @@ -0,0 +1,19 @@ +import { writeFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { projectFeatures } from './template.ts' + +/** Restore the selected styling after upstream eject writes its default CSS. */ +export function configureDefaults ( + directory: string, + language: 'ts' | 'js', + tailwind: boolean +): void { + if (!tailwind) return + + const { files } = projectFeatures({ language, framework: 'none', tailwind }) + const stylesheet = files['src/globals/global.css'] + if (stylesheet === undefined) { + throw new Error('Tailwind project features must provide src/globals/global.css.') + } + writeFileSync(resolve(directory, 'src/globals/global.css'), stylesheet) +} diff --git a/dependencies.test.ts b/dependencies.test.ts new file mode 100644 index 0000000..762bc72 --- /dev/null +++ b/dependencies.test.ts @@ -0,0 +1,17 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' +import { dependencyVersion } from './dependencies.ts' +import { domstackVersion } from './index.ts' + +test('reads exact declared ranges, not installed versions', () => { + const { devDependencies } = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf8')) + for (const [name, range] of Object.entries(devDependencies)) { + assert.equal(dependencyVersion(name), range) + } + assert.equal(domstackVersion, devDependencies['@domstack/static']) +}) + +test('fails clearly for an undeclared dependency', () => { + assert.throws(() => dependencyVersion('missing-starter-dependency'), /Missing generator devDependency/) +}) diff --git a/dependencies.ts b/dependencies.ts new file mode 100644 index 0000000..9d3414b --- /dev/null +++ b/dependencies.ts @@ -0,0 +1,11 @@ +import { readFileSync } from 'node:fs' + +const manifest = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf8')) as { + devDependencies: Record +} + +export function dependencyVersion (name: string): string { + const version = manifest.devDependencies[name] + if (!version) throw new Error(`Missing generator devDependency: ${name}`) + return version +} diff --git a/deployment.test.ts b/deployment.test.ts new file mode 100644 index 0000000..46b1f8c --- /dev/null +++ b/deployment.test.ts @@ -0,0 +1,89 @@ +import assert from 'node:assert/strict' +import { mkdtempSync, readFileSync, readdirSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import test from 'node:test' +import { createApp, parseArguments } from './index.ts' +import { projectDeployment } from './deployment.ts' +import type { Deployment } from './deployment.ts' +import type { Language } from './template.ts' + +const deployments: Deployment[] = ['none', 'github-pages', 'neocities'] +const languages: Language[] = ['ts', 'js'] + +test('parses deployment and feature flags, rejecting missing and invalid values', () => { + for (const deploy of deployments) { + const options = parseArguments(['site', '--deploy', deploy, '--language', 'js', '--framework', 'preact', '--tailwind', '--yes']) + assert.equal(options.deploy, deploy) + assert.equal(options.language, 'js') + assert.equal(options.framework, 'preact') + assert.equal(options.tailwind, true) + assert.equal(options.yes, true) + assert.equal(options.targetDirectory, 'site') + } + for (const flag of ['--deploy', '--language', '--framework']) { + assert.throws(() => parseArguments([flag]), /must be/) + assert.throws(() => parseArguments([flag, 'invalid']), /must be/) + assert.throws(() => parseArguments([flag, '--yes']), /must be/) + } + assert.equal(parseArguments(['--tailwind', '--no-tailwind']).tailwind, false) +}) + +for (const language of languages) { + for (const deploy of deployments) { + test(`generates ${language}/${deploy} project deployment files and instructions`, async (t) => { + const parent = mkdtempSync(join(tmpdir(), 'domstack-deploy-')) + t.after(() => rmSync(parent, { recursive: true, force: true })) + const directory = join(parent, 'site') + await createApp({ targetDirectory: directory, language, deploy, install: false, framework: 'preact', tailwind: true }) + const result = projectDeployment(deploy, language) + for (const [path, content] of Object.entries(result.files)) { + assert.equal(readFileSync(join(directory, path), 'utf8'), content) + } + const readme = readFileSync(join(directory, 'README.md'), 'utf8') + assert.ok(readme.includes(`--eject --language ${language} --yes`)) + assert.match(readme, /after manual eject, restore/) + assert.match(readFileSync(join(directory, 'src/interactive/page.html'), 'utf8'), /href="\.\.\/"/) + if (deploy === 'none') { + assert.deepEqual(result, { files: {}, readme: '' }) + assert.ok(!readdirSync(directory).includes('.github')) + assert.doesNotMatch(readme, /## .* deployment/) + } else { + assert.ok(readme.endsWith(result.readme)) + const workflow = result.files[`.github/workflows/${deploy}.yml`] ?? '' + assert.match(workflow, /branches: \[main\]/) + assert.match(workflow, /workflow_dispatch:/) + assert.match(workflow, /run: npm install/) + assert.match(workflow, /run: npm run build/) + assert.match(workflow, /cancel-in-progress: false/) + if (deploy === 'github-pages') { + assert.match(workflow, /pages: write/) + assert.match(workflow, /id-token: write/) + assert.match(workflow, /DOMSTACK_BASE_PATH: \$\{\{ steps.pages.outputs.base_path }}/) + assert.match(workflow, /path: public/) + assert.match(readme, /does not automatically rewrite/) + const source = result.files[`src/globals/global.vars.${language}`] ?? '' + const previous = process.env['DOMSTACK_BASE_PATH'] + t.after(() => { + if (previous === undefined) delete process.env['DOMSTACK_BASE_PATH'] + else process.env['DOMSTACK_BASE_PATH'] = previous + }) + for (const [input, expected] of [[undefined, ''], ['', ''], ['/', ''], ['/repo/', '/repo'], ['/repo', '/repo']]) { + if (input === undefined) delete process.env['DOMSTACK_BASE_PATH'] + else process.env['DOMSTACK_BASE_PATH'] = input + const { default: vars } = await import(`data:text/javascript,${encodeURIComponent(source)}#${String(input)}`) + assert.equal(vars.basePath, expected) + } + } else { + assert.match(workflow, /uses: bcomnes\/deploy-to-neocities@v3/) + assert.match(workflow, /api_key: \$\{\{ secrets.NEOCITIES_API_TOKEN }}/) + assert.match(workflow, /dist_dir: public/) + assert.match(workflow, /cleanup: false/) + assert.match(workflow, /neocities_supporter: false/) + assert.doesNotMatch(workflow, /uses: .*async-neocities/) + assert.match(readme, /not the action name/) + } + } + }) + } +} diff --git a/deployment.ts b/deployment.ts new file mode 100644 index 0000000..a21eb82 --- /dev/null +++ b/deployment.ts @@ -0,0 +1,128 @@ +import type { Language } from './template.ts' + +export type Deployment = 'none' | 'github-pages' | 'neocities' + +const buildSteps = ` - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '24' + - run: npm install +` + +export function projectDeployment (deploy: Deployment, language: Language): { + files: Record + readme: string +} { + if (deploy === 'none') return { files: {}, readme: '' } + + const workflowNotes = `The workflow runs on pushes to \`main\` and can also be run manually from the Actions tab. +Change the branch in the workflow if your default branch has another name. +CI uses Node.js 24 and \`npm install\`, so it also works before a lockfile is committed. +For reproducible installs, commit a \`package-lock.json\` and change the install step to \`npm ci\`, or adapt the workflow to your chosen package manager and lockfile. +` + + if (deploy === 'github-pages') { + return { + files: { + [`src/globals/global.vars.${language}`]: `export default { + basePath: (process.env['DOMSTACK_BASE_PATH'] ?? '').replace(/\\/+$/, ''), +} +`, + '.github/workflows/github-pages.yml': `name: Deploy to GitHub Pages + +on: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: github-pages + cancel-in-progress: false + +jobs: + deploy: + runs-on: ubuntu-latest + environment: + name: github-pages + url: \${{ steps.deployment.outputs.page_url }} + steps: +${buildSteps} - uses: actions/configure-pages@v5 + id: pages + - run: npm run build + env: + DOMSTACK_BASE_PATH: \${{ steps.pages.outputs.base_path }} + - uses: actions/upload-pages-artifact@v3 + with: + path: public + - uses: actions/deploy-pages@v4 + id: deployment +`, + }, + readme: ` +## GitHub Pages deployment + +In the repository's Settings → Pages, select **GitHub Actions** as the build and deployment source. +Commit and push this project, including \`.github/workflows/github-pages.yml\`. +${workflowNotes} +The workflow uses \`actions/configure-pages\` to obtain the site's base path, including repository subpaths and custom-domain configuration, rather than guessing from the repository name. +It passes that value as \`DOMSTACK_BASE_PATH\` to \`src/globals/global.vars.${language}\`, which exposes DOMStack's \`basePath\` variable without a trailing slash. +The ejected root layout prefixes root-relative script and stylesheet URLs with \`basePath\`. +Local builds default to an empty base path. +DOMStack does not automatically rewrite links or image URLs in page content; use relative URLs or incorporate \`vars.basePath\` in rendered content where needed. +For a custom domain, configure it in Settings → Pages before deploying. +`, + } + } + + return { + files: { + '.github/workflows/neocities.yml': `name: Deploy to Neocities + +on: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: neocities + cancel-in-progress: false + +jobs: + deploy: + runs-on: ubuntu-latest + steps: +${buildSteps} - run: npm run build + - uses: bcomnes/deploy-to-neocities@v3 + with: + api_key: \${{ secrets.NEOCITIES_API_TOKEN }} + dist_dir: public + cleanup: false + neocities_supporter: false + preview_before_deploy: true +`, + }, + readme: ` +## Neocities deployment + +Get your site's API key from https://neocities.org/settings/YOUR-SITE#api_key and add it as the repository Actions secret \`NEOCITIES_API_TOKEN\`. +Never commit the key. +Commit and push this project, including \`.github/workflows/neocities.yml\`. +${workflowNotes} +The workflow builds \`public/\` and uploads it with [bcomnes/deploy-to-neocities@v3](https://github.com/bcomnes/deploy-to-neocities). +This action uses [async-neocities](https://github.com/bcomnes/async-neocities), which is the underlying npm API client and interactive CLI, not the action name. +No extra npm deployment dependency is required for the generated workflow. +Only new or changed files are uploaded; \`cleanup: false\` preserves remote files absent from the build. +Enable cleanup only if you intend to delete those remote files. +Set \`neocities_supporter: true\` only for a paid Supporter account when you want to upload otherwise unsupported file types. +Neocities deployments are not atomic; the workflow serializes deployments without cancelling an in-progress upload. +`, + } +} diff --git a/eject.test.ts b/eject.test.ts new file mode 100644 index 0000000..d3b1ef5 --- /dev/null +++ b/eject.test.ts @@ -0,0 +1,76 @@ +import assert from 'node:assert/strict' +import childProcess from 'node:child_process' +import { EventEmitter } from 'node:events' +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { syncBuiltinESMExports } from 'node:module' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import test from 'node:test' +import { createApp } from './index.ts' +import type { PackageManager } from './index.ts' +import type { Language } from './template.ts' + +const managers: PackageManager[] = ['npm', 'pnpm', 'yarn', 'bun'] +const languages: Language[] = ['ts', 'js'] + +for (const packageManager of managers) { + for (const language of languages) { + test(`ejects ${language} with ${packageManager} non-interactively without replacing upstream code`, async (t) => { + const directory = mkdtempSync(join(tmpdir(), 'domstack-eject-')) + t.after(() => rmSync(directory, { recursive: true, force: true })) + const calls: string[][] = [] + const install = t.mock.method(childProcess, 'execFileSync', (command: string, args: string[], options: { cwd: string }) => { + assert.equal(command, packageManager) + assert.equal(options.cwd, directory) + calls.push(args) + }) + const spawn = t.mock.method(childProcess, 'spawn', (command: string, args: string[], options: { cwd: string, stdio: string[] }) => { + assert.equal(command, packageManager) + assert.equal(options.cwd, directory) + assert.deepEqual(options.stdio, ['ignore', 'inherit', 'inherit']) + const prefix = packageManager === 'npm' ? ['exec', '--'] : packageManager === 'bun' ? ['x'] : ['exec'] + assert.deepEqual(args, [...prefix, 'domstack', '--eject', '--language', language, '--yes']) + mkdirSync(join(directory, 'src/layouts'), { recursive: true }) + mkdirSync(join(directory, 'src/globals'), { recursive: true }) + writeFileSync(join(directory, `src/layouts/root.layout.${language}`), 'upstream layout') + writeFileSync(join(directory, `src/globals/global.client.${language}`), 'upstream client') + writeFileSync(join(directory, 'src/globals/global.css'), 'upstream CSS') + const child = new EventEmitter() + process.nextTick(() => child.emit('close', 0, null)) + return child + }) + syncBuiltinESMExports() + t.after(() => { install.mock.restore(); spawn.mock.restore(); syncBuiltinESMExports() }) + const resultPromise = createApp({ targetDirectory: directory, packageManager, language, tailwind: true }) + assert.ok(resultPromise instanceof Promise) + const result = await resultPromise + assert.equal(result.ejected, true) + assert.equal(result.installed, true) + assert.deepEqual(calls, packageManager === 'yarn' ? [[], []] : [['install'], ['install']]) + assert.equal(readFileSync(join(directory, `src/layouts/root.layout.${language}`), 'utf8'), 'upstream layout') + assert.equal(readFileSync(join(directory, `src/globals/global.client.${language}`), 'utf8'), 'upstream client') + assert.equal(readFileSync(join(directory, 'src/globals/global.css'), 'utf8'), '@import "tailwindcss" source("../");\n') + assert.equal(existsSync(join(directory, `src/layouts/root.layout.${language === 'ts' ? 'js' : 'ts'}`)), false) + }) + } +} + +for (const failure of ['code', 'signal', 'error']) { + test(`rejects eject ${failure} failures without a second install`, async (t) => { + const directory = mkdtempSync(join(tmpdir(), 'domstack-eject-failure-')) + t.after(() => rmSync(directory, { recursive: true, force: true })) + const install = t.mock.method(childProcess, 'execFileSync', () => {}) + const spawn = t.mock.method(childProcess, 'spawn', () => { + const child = new EventEmitter() + process.nextTick(() => { + if (failure === 'error') child.emit('error', new Error('spawn failed')) + else child.emit('close', failure === 'code' ? 1 : null, failure === 'signal' ? 'SIGTERM' : null) + }) + return child + }) + syncBuiltinESMExports() + t.after(() => { install.mock.restore(); spawn.mock.restore(); syncBuiltinESMExports() }) + await assert.rejects(createApp({ targetDirectory: directory }), failure === 'error' ? /spawn failed/ : failure === 'code' ? /code 1/ : /signal SIGTERM/) + assert.equal(install.mock.callCount(), 1) + }) +} diff --git a/index.test.ts b/index.test.ts index c220602..0e88fc9 100644 --- a/index.test.ts +++ b/index.test.ts @@ -12,6 +12,7 @@ import test from 'node:test' import { createApp, defaultTargetDirectory, + domstackVersion, detectPackageManager, packageManagerRunCommand, parseArguments, @@ -21,12 +22,12 @@ function temporaryDirectory (): string { return mkdtempSync(join(tmpdir(), 'create-domstack-app-')) } -test('creates a DOMStack project without installing dependencies', (t) => { +test('creates a DOMStack project without installing dependencies', async (t) => { const parentDirectory = temporaryDirectory() t.after(() => rmSync(parentDirectory, { recursive: true, force: true })) const targetDirectory = join(parentDirectory, 'My Site') - const result = createApp({ + const result = await createApp({ targetDirectory, install: false, packageManager: 'npm', @@ -38,9 +39,10 @@ test('creates a DOMStack project without installing dependencies', (t) => { assert.equal(result.packageName, 'my-site') assert.equal(result.installed, false) + assert.equal(result.ejected, false) assert.equal(packageJson.name, 'my-site') assert.equal(packageJson.private, true) - assert.equal(packageJson.devDependencies['@domstack/static'], 'beta') + assert.equal(packageJson.devDependencies['@domstack/static'], domstackVersion) assert.equal(packageJson.scripts.dev, 'domstack --watch') assert.match( readFileSync(join(targetDirectory, 'src/page.md'), 'utf8'), @@ -52,35 +54,35 @@ test('creates a DOMStack project without installing dependencies', (t) => { ) }) -test('normalizes a dot-prefixed target name', (t) => { +test('normalizes a dot-prefixed target name', async (t) => { const parentDirectory = temporaryDirectory() t.after(() => rmSync(parentDirectory, { recursive: true, force: true })) const targetDirectory = join(parentDirectory, '.preview-app') - const result = createApp({ targetDirectory, install: false }) + const result = await createApp({ targetDirectory, install: false }) assert.equal(result.packageName, 'preview-app') }) -test('validates the package name before creating a directory', (t) => { +test('validates the package name before creating a directory', async (t) => { const parentDirectory = temporaryDirectory() t.after(() => rmSync(parentDirectory, { recursive: true, force: true })) const targetDirectory = join(parentDirectory, '!!!') - assert.throws( - () => createApp({ targetDirectory, install: false }), + await assert.rejects( + createApp({ targetDirectory, install: false }), /Cannot derive a valid package name/ ) assert.equal(existsSync(targetDirectory), false) }) -test('refuses to write into a non-empty directory', (t) => { +test('refuses to write into a non-empty directory', async (t) => { const targetDirectory = temporaryDirectory() t.after(() => rmSync(targetDirectory, { recursive: true, force: true })) writeFileSync(join(targetDirectory, 'existing.txt'), 'keep me') - assert.throws( - () => createApp({ targetDirectory, install: false }), + await assert.rejects( + createApp({ targetDirectory, install: false }), /target directory is not empty/ ) assert.equal(readFileSync(join(targetDirectory, 'existing.txt'), 'utf8'), 'keep me') @@ -88,12 +90,14 @@ test('refuses to write into a non-empty directory', (t) => { test('parses CLI arguments', () => { assert.deepEqual(parseArguments([]), { + yes: false, targetDirectory: defaultTargetDirectory, install: true, help: false, version: false, }) assert.deepEqual(parseArguments(['website', '--no-install']), { + yes: false, targetDirectory: 'website', install: false, help: false, diff --git a/index.ts b/index.ts index 6a0626a..08454db 100644 --- a/index.ts +++ b/index.ts @@ -1,4 +1,4 @@ -import { execFileSync } from 'node:child_process' +import { execFileSync, spawn } from 'node:child_process' import { existsSync, mkdirSync, @@ -7,13 +7,19 @@ import { writeFileSync, } from 'node:fs' import { basename, dirname, resolve } from 'node:path' +import { configureDefaults } from './defaults.ts' +import { dependencyVersion } from './dependencies.ts' +import { projectDeployment } from './deployment.ts' +import { projectFeatures } from './template.ts' +import type { Features } from './template.ts' export const defaultTargetDirectory = 'domstack-app' -export const domstackVersion = 'beta' +export const domstackVersion = dependencyVersion('@domstack/static') -export interface CreateAppOptions { +export interface CreateAppOptions extends Partial { targetDirectory: string install?: boolean + eject?: boolean packageManager?: PackageManager } @@ -22,9 +28,11 @@ export interface CreateAppResult { packageName: string packageManager: PackageManager installed: boolean + ejected: boolean } -export interface CliOptions { +export interface CliOptions extends Partial { + yes: boolean targetDirectory: string install: boolean help: boolean @@ -65,8 +73,37 @@ export function parseArguments (arguments_: string[]): CliOptions { let install = true let help = false let version = false - - for (const argument of arguments_) { + let yes = false + const features: Partial = {} + + for (const [index, argument] of arguments_.entries()) { + if (['--language', '--framework', '--deploy'].includes(arguments_[index - 1] ?? '')) continue + if (argument === '--deploy') { + const value = arguments_[index + 1] + if (value !== 'none' && value !== 'github-pages' && value !== 'neocities') throw new Error('--deploy must be none, github-pages, or neocities.') + features.deploy = value + continue + } + if (argument === '--language') { + const value = arguments_[index + 1] + if (value !== 'ts' && value !== 'js') throw new Error('--language must be ts or js.') + features.language = value + continue + } + if (argument === '--framework') { + const value = arguments_[index + 1] + if (value !== 'none' && value !== 'preact' && value !== 'react') throw new Error('--framework must be none, preact, or react.') + features.framework = value + continue + } + if (argument === '--tailwind' || argument === '--no-tailwind') { + features.tailwind = argument === '--tailwind' + continue + } + if (argument === '--yes' || argument === '-y') { + yes = true + continue + } if (argument === '--') continue if (argument === '--no-install') { install = false @@ -91,6 +128,8 @@ export function parseArguments (arguments_: string[]): CliOptions { } return { + ...features, + yes, targetDirectory: positionalArguments[0] ?? defaultTargetDirectory, install, help, @@ -107,7 +146,9 @@ export function detectPackageManager ( return 'npm' } -export function createApp (options: CreateAppOptions): CreateAppResult { +export async function createApp ( + options: CreateAppOptions +): Promise { const targetDirectory = options.targetDirectory.trim() if (!targetDirectory) throw new Error('The target directory cannot be empty.') @@ -118,6 +159,15 @@ export function createApp (options: CreateAppOptions): CreateAppResult { mkdirSync(directory, { recursive: true }) const packageManager = options.packageManager ?? detectPackageManager() const install = options.install ?? true + const eject = options.eject ?? true + const features: Features = { + language: options.language ?? 'ts', + framework: options.framework ?? 'none', + tailwind: options.tailwind ?? false, + deploy: options.deploy ?? 'none', + } + const template = projectFeatures(features) + const deployment = projectDeployment(features.deploy, features.language) const packageJson = { name: packageName, @@ -128,8 +178,11 @@ export function createApp (options: CreateAppOptions): CreateAppResult { dev: 'domstack --watch', build: 'domstack', preview: 'domstack --serve', + ...template.scripts, }, + dependencies: template.dependencies, devDependencies: { + ...template.devDependencies, '@domstack/static': domstackVersion, }, } @@ -140,19 +193,27 @@ export function createApp (options: CreateAppOptions): CreateAppResult { `${JSON.stringify(packageJson, null, 2)}\n` ) - for (const [relativePath, contents] of Object.entries(starterFiles)) { + for (const [relativePath, contents] of Object.entries({ ...starterFiles, ...template.files, ...deployment.files })) { writeProjectFile(directory, relativePath, contents) } - writeProjectFile(directory, 'README.md', projectReadme(packageName)) + writeProjectFile(directory, 'README.md', projectReadme(packageName, features) + deployment.readme) - if (install) installDependencies(directory, packageManager) + if (install) { + installDependencies(directory, packageManager) + if (eject) { + await ejectDefaults(directory, packageManager, features.language) + configureDefaults(directory, features.language, features.tailwind) + installDependencies(directory, packageManager) + } + } return { directory, packageName, packageManager, installed: install, + ejected: install && eject, } } @@ -207,7 +268,43 @@ function installDependencies ( }) } -function projectReadme (packageName: string): string { +async function ejectDefaults ( + directory: string, + packageManager: PackageManager, + language: Features['language'] +): Promise { + const flags = ['--eject', '--language', language, '--yes'] + const argumentsByPackageManager = { + npm: ['exec', '--', 'domstack', ...flags], + pnpm: ['exec', 'domstack', ...flags], + yarn: ['exec', 'domstack', ...flags], + bun: ['x', 'domstack', ...flags], + } + + const child = spawn( + packageManager, + argumentsByPackageManager[packageManager], + { + cwd: directory, + stdio: ['ignore', 'inherit', 'inherit'], + } + ) + + await new Promise((resolve, reject) => { + child.once('error', reject) + child.once('close', (code, signal) => { + if (code === 0) { + resolve() + return + } + + const result = signal === null ? `code ${code}` : `signal ${signal}` + reject(new Error(`DOMStack eject exited with ${result}.`)) + }) + }) +} + +function projectReadme (packageName: string, features: Features): string { return `# ${packageName} A static site built with [DOMStack](https://github.com/bcomnes/domstack). @@ -225,5 +322,9 @@ npm run build \`\`\` The generated site is written to \`public/\`. +With default setup, \`src/layouts/\` and \`src/globals/\` contain customizable DOMStack defaults. +If setup used \`--no-install\`, run \`npm install\`, \`npx domstack --eject --language ${features.language} --yes\`, and \`npm install\` to eject them and install their dependencies. +${features.tailwind ? 'Eject replaces `src/globals/global.css`; after manual eject, restore its contents to `@import "tailwindcss" source("../");` before building.\n' : ''}Only eject into a fresh project: eject overwrites the default layout, stylesheet, and client files. +This setup requires a DOMStack release supporting \`--eject --language ts|js --yes\`. ` } diff --git a/package.json b/package.json index 6bc3d65..a5d4584 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,14 @@ "dependencies": {}, "devDependencies": { "@voxpelli/tsconfig": "^16.1.0", - "@types/node": "^26.0.0", + "@types/node": "^26.0.1", + "@domstack/static": "^12.0.0-beta.5", + "@types/react": "^19.1.10", + "@types/react-dom": "^19.1.7", + "preact": "^10.29.6", + "react": "^19.1.1", + "react-dom": "^19.1.1", + "esbuild-plugin-tailwindcss": "2.2.0", "neostandard": "^0.13.0", "npm-run-all2": "^9.0.0", "releasearoni": "^0.2.0", @@ -27,6 +34,15 @@ ], "files": [ "bin.js", + "defaults.js", + "dependencies.js", + "deployment.js", + "deployment.d.ts", + "deployment.d.ts.map", + "prompts.js", + "template.js", + "template.d.ts", + "template.d.ts.map", "index.js", "index.d.ts", "index.d.ts.map", @@ -55,6 +71,7 @@ "test:tsc": "tsc", "test:node-test": "node --experimental-test-coverage --test-reporter=spec --test-reporter=lcov --test-reporter-destination=stdout --test-reporter-destination=lcov.info --test", "version": "releasearoni version", + "smoke:local": "node smoke.ts", "build": "npm run clean && run-p build:*", "build:declaration": "tsc -p declaration.tsconfig.json", "clean": "run-p clean:*", diff --git a/prompts.test.ts b/prompts.test.ts new file mode 100644 index 0000000..f0d7cd5 --- /dev/null +++ b/prompts.test.ts @@ -0,0 +1,68 @@ +import assert from 'node:assert/strict' +import { syncBuiltinESMExports } from 'node:module' +import readline from 'node:readline/promises' +import test from 'node:test' +import { parseArguments } from './index.ts' +import { chooseFeatures } from './prompts.ts' + +function tty (value: boolean): () => void { + const input = Object.getOwnPropertyDescriptor(process.stdin, 'isTTY') + const output = Object.getOwnPropertyDescriptor(process.stdout, 'isTTY') + Object.defineProperty(process.stdin, 'isTTY', { configurable: true, value }) + Object.defineProperty(process.stdout, 'isTTY', { configurable: true, value }) + return () => { + if (input) Object.defineProperty(process.stdin, 'isTTY', input) + else Reflect.deleteProperty(process.stdin, 'isTTY') + if (output) Object.defineProperty(process.stdout, 'isTTY', output) + else Reflect.deleteProperty(process.stdout, 'isTTY') + } +} + +const defaults = { language: 'ts', framework: 'none', tailwind: false, deploy: 'none' } + +test('non-interactive input uses defaults and preserves explicit choices', async (t) => { + t.after(tty(false)) + assert.deepEqual(await chooseFeatures(parseArguments([])), defaults) + assert.deepEqual(await chooseFeatures(parseArguments(['--language', 'js', '--framework', 'react', '--tailwind', '--deploy', 'neocities'])), { + language: 'js', framework: 'react', tailwind: true, deploy: 'neocities', + }) +}) + +test('--yes skips questions even in a terminal', async (t) => { + t.after(tty(true)) + const mock = t.mock.method(readline, 'createInterface', () => { throw new Error('Unexpected prompt') }) + syncBuiltinESMExports() + t.after(() => { mock.mock.restore(); syncBuiltinESMExports() }) + assert.deepEqual(await chooseFeatures(parseArguments(['--yes'])), defaults) + assert.equal((await chooseFeatures(parseArguments(['--yes', '--deploy', 'github-pages']))).deploy, 'github-pages') +}) + +test('interactive defaults, numbered choices, retry, and explicit-flag skipping', async (t) => { + t.after(tty(true)) + let answers = ['', '', '', ''] + const questions: string[] = [] + let closed = 0 + const mock = t.mock.method(readline, 'createInterface', () => ({ + on () {}, + async question (question: string) { + questions.push(question) + const answer = answers.shift() + assert.notEqual(answer, undefined, 'unexpected question') + return answer + }, + close () { closed++ }, + })) + syncBuiltinESMExports() + t.after(() => { mock.mock.restore(); syncBuiltinESMExports() }) + assert.deepEqual(await chooseFeatures(parseArguments([])), defaults) + assert.equal(questions.length, 4) + assert.match(questions[3] ?? '', /Deployment/) + answers = ['invalid', '3'] + questions.length = 0 + assert.deepEqual(await chooseFeatures(parseArguments(['--language', 'js', '--framework', 'preact', '--no-tailwind'])), { + language: 'js', framework: 'preact', tailwind: false, deploy: 'neocities', + }) + assert.equal(questions.length, 2) + assert.ok(questions.every(question => question.startsWith('Deployment:'))) + assert.equal(closed, 2) +}) diff --git a/prompts.ts b/prompts.ts new file mode 100644 index 0000000..51fddb1 --- /dev/null +++ b/prompts.ts @@ -0,0 +1,41 @@ +import { createInterface } from 'node:readline/promises' +import type { CliOptions } from './index.ts' +import type { Features } from './template.ts' + +export async function chooseFeatures (options: CliOptions): Promise { + const defaults: Features = { language: 'ts', framework: 'none', tailwind: false, deploy: 'none' } + if (options.yes || !process.stdin.isTTY || !process.stdout.isTTY) { + return { + language: options.language ?? defaults.language, + framework: options.framework ?? defaults.framework, + tailwind: options.tailwind ?? defaults.tailwind, + deploy: options.deploy ?? defaults.deploy, + } + } + + const rl = createInterface({ input: process.stdin, output: process.stdout }) + const controller = new AbortController() + rl.on('SIGINT', () => controller.abort()) + try { + async function choose (question: string, values: T[], fallback: T): Promise { + while (true) { + const answer = (await rl.question(question, { signal: controller.signal })).trim().toLowerCase() + if (!answer) return fallback + const value = values.find((value, index) => value === answer || String(index + 1) === answer) + if (value) return value + console.log(`Choose ${values.join(', ')} or a listed number.`) + } + } + + const language = options.language ?? await choose('Language: 1) TypeScript 2) JavaScript [1]: ', ['ts', 'js'], 'ts') + const framework = options.framework ?? await choose('JSX/TSX: 1) None 2) Preact (recommended) 3) React [1]: ', ['none', 'preact', 'react'], 'none') + const tailwind = options.tailwind ?? (await choose('Set up Tailwind CSS? 1) No 2) Yes [1]: ', ['no', 'yes'], 'no')) === 'yes' + const deploy = options.deploy ?? await choose('Deployment: 1) None 2) GitHub Pages 3) Neocities [1]: ', ['none', 'github-pages', 'neocities'], 'none') + return { language, framework, tailwind, deploy } + } catch (error) { + if (controller.signal.aborted) throw new Error('Setup cancelled; no files were created.') + throw error + } finally { + rl.close() + } +} diff --git a/smoke.ts b/smoke.ts new file mode 100644 index 0000000..7bda374 --- /dev/null +++ b/smoke.ts @@ -0,0 +1,123 @@ +import assert from 'node:assert/strict' +import { execFileSync } from 'node:child_process' +import { chmodSync, cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' + +const upstreamArgument = process.argv[2] +if (!upstreamArgument) throw new Error('Usage: npm run smoke:local -- /path/to/domstack (with its development dependencies installed)') +const upstream = resolve(upstreamArgument) +const root = import.meta.dirname +const npm = process.env['npm_execpath'] +if (!npm) throw new Error('Run this smoke check with npm run smoke:local.') +const temporary = mkdtempSync(join(tmpdir(), 'domstack-packed-smoke-')) + +function run (args: string[], cwd: string, env = process.env): void { + execFileSync(process.execPath, [npm as string, ...args], { cwd, env, stdio: 'inherit', timeout: 120_000 }) +} + +function pack (cwd: string): string { + const output = execFileSync(process.execPath, [npm as string, 'pack', '--ignore-scripts', '--json', '--pack-destination', temporary], { + cwd, encoding: 'utf8', timeout: 120_000, + }) + return join(temporary, JSON.parse(output)[0].filename) +} + +try { + // Build only a disposable upstream copy; never emit into the upstream checkout. + const copy = join(temporary, 'upstream') + mkdirSync(copy) + for (const path of ['bin.js', 'index.js', 'types.ts', 'types', 'lib', 'package.json', 'tsconfig.json', 'declaration.tsconfig.json']) { + cpSync(join(upstream, path), join(copy, path), { recursive: true }) + } + symlinkSync(join(upstream, 'node_modules'), join(copy, 'node_modules'), 'dir') + run(['run', 'build:declaration'], copy) + const upstreamTarball = pack(copy) + run(['run', 'build'], root) + const generatorTarball = pack(root) + const consumer = join(temporary, 'consumer') + mkdirSync(consumer) + writeFileSync(join(consumer, 'package.json'), '{"private":true,"type":"module"}\n') + run(['install', '--ignore-scripts', '--no-audit', '--no-fund', generatorTarball], consumer) + const generator = join(consumer, 'node_modules/@domstack/create-app') + const manifest = JSON.parse(readFileSync(join(generator, 'package.json'), 'utf8')) + const range = manifest.devDependencies['@domstack/static'] + assert.ok(!range.startsWith('file:')) + for (const path of ['index.js', 'index.d.ts', 'template.js', 'template.d.ts', 'deployment.js', 'deployment.d.ts', 'defaults.js', 'dependencies.js', 'prompts.js']) { + assert.ok(existsSync(join(generator, path)), `missing packed ${path}`) + } + + writeFileSync(join(consumer, 'api.ts'), `import { createApp } from '@domstack/create-app' +import type { CreateAppResult } from '@domstack/create-app' + +const result: Promise = createApp({ + targetDirectory: 'site', language: 'ts', framework: 'preact', tailwind: true, + deploy: 'github-pages', install: false, +}) +void result +`) + execFileSync(process.execPath, [join(root, 'node_modules/typescript/bin/tsc'), '--noEmit', '--strict', '--module', 'nodenext', '--target', 'es2024', 'api.ts'], { + cwd: consumer, stdio: 'inherit', timeout: 120_000, + }) + + // Override only the install command in this test environment, preserving the + // generated registry range while installing the unpublished local tarball. + const bin = join(temporary, 'bin') + mkdirSync(bin) + const wrapper = join(bin, 'npm') + writeFileSync(wrapper, `#!${process.execPath} +const { spawnSync } = require('node:child_process') +const args = process.argv.slice(2) +if (args[0] === 'install') args.push('--no-save', '--no-audit', '--no-fund', ${JSON.stringify(upstreamTarball)}) +const result = spawnSync(${JSON.stringify(process.execPath)}, [${JSON.stringify(npm)}, ...args], { stdio: 'inherit', env: process.env }) +if (result.error) throw result.error +process.exit(result.status ?? 1) +`) + chmodSync(wrapper, 0o755) + const env = { ...process.env, PATH: `${bin}:${process.env['PATH'] ?? ''}`, npm_config_user_agent: 'npm/11' } + const cases = [ + { language: 'ts', framework: 'none', tailwind: false, deploy: 'none' }, + { language: 'js', framework: 'none', tailwind: false, deploy: 'none' }, + { language: 'ts', framework: 'preact', tailwind: true, deploy: 'github-pages' }, + { language: 'js', framework: 'react', tailwind: true, deploy: 'github-pages' }, + { language: 'ts', framework: 'react', tailwind: true, deploy: 'neocities' }, + { language: 'js', framework: 'preact', tailwind: true, deploy: 'neocities' }, + ] + for (const features of cases) { + const { language, framework, tailwind, deploy } = features + const directory = join(temporary, `${language}-${framework}-${deploy}`) + console.log(`\nSmoke: ${JSON.stringify(features)}`) + execFileSync(process.execPath, [join(generator, 'bin.js'), directory, '--yes', '--language', language, '--framework', framework, tailwind ? '--tailwind' : '--no-tailwind', '--deploy', deploy], { + cwd: consumer, env, stdio: 'inherit', timeout: 120_000, + }) + const generatedManifest = JSON.parse(readFileSync(join(directory, 'package.json'), 'utf8')) + assert.equal(generatedManifest.devDependencies['@domstack/static'], range) + const layout = readFileSync(join(directory, `src/layouts/root.layout.${language}`), 'utf8') + assert.ok(layout.includes('defaultRootLayout')) + assert.ok(existsSync(join(directory, `src/globals/global.client.${language}`))) + assert.ok(!existsSync(join(directory, `src/layouts/root.layout.${language === 'ts' ? 'js' : 'ts'}`))) + if (tailwind) assert.equal(readFileSync(join(directory, 'src/globals/global.css'), 'utf8'), '@import "tailwindcss" source("../");\n') + run(['run', 'build'], directory) + assert.match(readFileSync(join(directory, 'public/index.html'), 'utf8'), /Welcome to DOMStack/) + if (framework !== 'none') { + const html = readFileSync(join(directory, 'public/interactive/index.html'), 'utf8') + assert.match(html, /counter-root/) + const stylesheet = /href="(\/[^" ]+\.css)"/.exec(html)?.[1] + assert.ok(stylesheet) + assert.match(readFileSync(join(directory, 'public', stylesheet), 'utf8'), /\.bg-blue-600/) + } + if (language === 'ts') run(['run', 'typecheck'], directory) + if (deploy === 'github-pages') { + run(['run', 'build'], directory, { ...process.env, DOMSTACK_BASE_PATH: '/example-repo/' }) + const html = readFileSync(join(directory, 'public/interactive/index.html'), 'utf8') + assert.match(html, /src="\/example-repo\//) + assert.match(html, /href="\/example-repo\//) + assert.match(html, /href="\.\.\/"/) + assert.doesNotMatch(html, /\/example-repo\/\//) + } + } + console.log('\nPacked generator and local upstream integration passed (6 projects).') +} finally { + run(['run', 'clean'], root) + rmSync(temporary, { recursive: true, force: true }) +} diff --git a/template.test.ts b/template.test.ts new file mode 100644 index 0000000..c55f18a --- /dev/null +++ b/template.test.ts @@ -0,0 +1,69 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { test } from 'node:test' +import { projectFeatures } from './template.ts' +import type { Framework, Language } from './template.ts' + +const versions = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf8')).devDependencies + +const languages: Language[] = ['ts', 'js'] +const frameworks: Framework[] = ['none', 'preact', 'react'] + +for (const language of languages) { + for (const framework of frameworks) { + for (const tailwind of [false, true]) { + test(`${language}/${framework}/tailwind=${tailwind}`, () => { + const result = projectFeatures({ language, framework, tailwind }) + const { files, dependencies, devDependencies, scripts } = result + assert.equal(Boolean(files['tsconfig.json']), language === 'ts') + assert.equal(Boolean(scripts['typecheck']), language === 'ts') + if (language === 'ts') { + const config = JSON.parse(files['tsconfig.json'] ?? '') + assert.equal(config.extends, undefined) + assert.deepEqual(config.include, ['src']) + assert.equal(config.compilerOptions.strict, true) + assert.equal(config.compilerOptions.noEmit, true) + assert.equal(config.compilerOptions.allowJs, true) + assert.equal(config.compilerOptions.checkJs, false) + assert.equal(config.compilerOptions.moduleResolution, 'bundler') + assert.deepEqual(config.compilerOptions.types, ['node']) + assert.ok(config.compilerOptions.lib.includes('DOM')) + assert.equal(config.compilerOptions.jsxImportSource, framework === 'none' ? undefined : framework) + assert.equal(devDependencies['typescript'], versions['typescript']) + assert.equal(devDependencies['@types/node'], versions['@types/node']) + } + + assert.equal(dependencies['preact'], framework === 'preact' ? versions['preact'] : undefined) + assert.equal(dependencies['react'], framework === 'react' ? versions['react'] : undefined) + assert.equal(dependencies['react-dom'], framework === 'react' ? versions['react-dom'] : undefined) + assert.equal(devDependencies['@types/react'], framework === 'react' && language === 'ts' ? versions['@types/react'] : undefined) + assert.equal(devDependencies['@types/react-dom'], framework === 'react' && language === 'ts' ? versions['@types/react-dom'] : undefined) + + const settings = files[`src/globals/esbuild.settings.${language}`] + assert.equal(Boolean(settings), framework !== 'none' || tailwind) + assert.equal(files[`src/globals/esbuild.settings.${language === 'ts' ? 'js' : 'ts'}`], undefined) + if (framework !== 'none') { + assert.match(settings ?? '', /settings.jsx = 'automatic'/) + assert.ok(settings?.includes(`settings.jsxImportSource = '${framework}'`)) + assert.match(files['src/interactive/page.html'] ?? '', /id="counter-root"/) + const client = files[`src/interactive/client.${language === 'ts' ? 'tsx' : 'jsx'}`] ?? '' + assert.match(client, /useState\(0\)/) + assert.match(client, /setCount\(value => value \+ 1\)/) + assert.ok(client.includes(framework === 'preact' ? 'render(, container)' : 'createRoot(container).render()')) + } else { + assert.equal(files['src/interactive/page.html'], undefined) + } + + assert.equal(devDependencies['esbuild-plugin-tailwindcss'], tailwind ? versions['esbuild-plugin-tailwindcss'] : undefined) + assert.equal(Boolean(files['src/globals/global.css']), tailwind) + assert.equal(Boolean(files['src/style.css']), tailwind) + if (tailwind) { + assert.match(settings ?? '', /settings.plugins = \[\.\.\.\(settings.plugins \?\? \[\]\), tailwindPlugin\(\)\]/) + assert.match(files['src/globals/global.css'] ?? '', /@import "tailwindcss" source\("\.\.\/"\)/) + assert.match(files['src/style.css'] ?? '', /@layer base/) + } + assert.deepEqual(result, projectFeatures({ language, framework, tailwind })) + }) + } + } +} diff --git a/template.ts b/template.ts new file mode 100644 index 0000000..124958d --- /dev/null +++ b/template.ts @@ -0,0 +1,150 @@ +import { dependencyVersion } from './dependencies.ts' +import type { Deployment } from './deployment.ts' + +export type Language = 'ts' | 'js' +export type Framework = 'none' | 'preact' | 'react' + +export interface Features { + language: Language + framework: Framework + tailwind: boolean + deploy: Deployment +} + +export function projectFeatures (features: Omit): { + files: Record + dependencies: Record + devDependencies: Record + scripts: Record +} { + const { language, framework, tailwind } = features + const files: Record = {} + const dependencies: Record = {} + const devDependencies: Record = {} + const scripts: Record = {} + + if (language === 'ts') { + devDependencies['typescript'] = dependencyVersion('typescript') + devDependencies['@types/node'] = dependencyVersion('@types/node') + scripts['typecheck'] = 'tsc --noEmit' + files['tsconfig.json'] = `${JSON.stringify({ + compilerOptions: { + target: 'ES2024', + module: 'ESNext', + moduleResolution: 'bundler', + lib: ['ES2024', 'DOM', 'DOM.Iterable'], + types: ['node'], + strict: true, + noEmit: true, + allowJs: true, + checkJs: false, + allowImportingTsExtensions: true, + resolveJsonModule: true, + esModuleInterop: true, + isolatedModules: true, + verbatimModuleSyntax: true, + skipLibCheck: true, + ...(framework === 'none' +? {} +: { + jsx: 'react-jsx', + jsxImportSource: framework, + }), + }, + include: ['src'], + exclude: ['node_modules', 'public'], + }, null, 2)}\n` + } + + if (framework === 'preact') { + dependencies['preact'] = dependencyVersion('preact') + } else if (framework === 'react') { + dependencies['react'] = dependencyVersion('react') + dependencies['react-dom'] = dependencyVersion('react-dom') + if (language === 'ts') { + devDependencies['@types/react'] = dependencyVersion('@types/react') + devDependencies['@types/react-dom'] = dependencyVersion('@types/react-dom') + } + } + + if (tailwind) { + devDependencies['esbuild-plugin-tailwindcss'] = dependencyVersion('esbuild-plugin-tailwindcss') + files['src/globals/global.css'] = `@import "tailwindcss" source("../"); +` + // Keep starter styles in a layer so utilities can override them. + files['src/style.css'] = `@layer base { + :root { + font-family: system-ui, sans-serif; + line-height: 1.5; + } + + body { + margin: 0 auto; + max-width: 48rem; + padding: 4rem 1.5rem; + } +} +` + } + + if (framework !== 'none' || tailwind) { + const typeImport = language === 'ts' + ? "import type { BuildOptions } from '@domstack/static/types.js'\n" + : "/** @import { BuildOptions } from '@domstack/static/types.js' */\n" + const pluginImport = tailwind ? "import tailwindPlugin from 'esbuild-plugin-tailwindcss'\n" : '' + const annotation = language === 'js' ? '/** @param {BuildOptions} settings */\n' : '' + const parameter = language === 'ts' ? 'settings: BuildOptions' : 'settings' + const returnType = language === 'ts' ? ': BuildOptions' : '' + const jsxSettings = framework === 'none' + ? '' + : ` settings.jsx = 'automatic' + settings.jsxImportSource = '${framework}' +` + const pluginSettings = tailwind + ? ` settings.plugins = [...(settings.plugins ?? []), tailwindPlugin()] +` + : '' + files[`src/globals/esbuild.settings.${language}`] = `${typeImport}${pluginImport} +${annotation}export default function esbuildSettingsOverride (${parameter})${returnType} { +${jsxSettings}${pluginSettings} return settings +} +` + } + + if (framework !== 'none') { + const name = framework === 'preact' ? 'Preact' : 'React' + const extension = language === 'ts' ? 'tsx' : 'jsx' + files['src/interactive/page.html'] = `

${name} counter

+

Edit src/interactive/client.${extension} to get started.

+
+ +

Back home

+` + const imports = framework === 'preact' + ? "import { render } from 'preact'\nimport { useState } from 'preact/hooks'" + : "import { useState } from 'react'\nimport { createRoot } from 'react-dom/client'" + const buttonClasses = tailwind ? ' className="rounded bg-blue-600 px-4 py-2 text-white hover:bg-blue-700"' : '' + const mount = framework === 'preact' + ? 'render(, container)' + : 'createRoot(container).render()' + files[`src/interactive/client.${extension}`] = `${imports} + +function Counter () { + const [count, setCount] = useState(0) + + return ( + + ) +} + +const container = document.getElementById('counter-root') +if (container) { + ${mount} +} +` + } + + return { files, dependencies, devDependencies, scripts } +}