Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 1 addition & 5 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
113 changes: 105 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -33,40 +68,102 @@ 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
```

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

```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
14 changes: 13 additions & 1 deletion bin.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
#!/usr/bin/env node

import { readFileSync } from 'node:fs'
import { chooseFeatures } from './prompts.ts'
import {
createApp,
detectPackageManager,
Expand All @@ -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
Expand All @@ -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.`)
}
Expand Down
19 changes: 19 additions & 0 deletions defaults.ts
Original file line number Diff line number Diff line change
@@ -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)
}
17 changes: 17 additions & 0 deletions dependencies.test.ts
Original file line number Diff line number Diff line change
@@ -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/)
})
11 changes: 11 additions & 0 deletions dependencies.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>
}

export function dependencyVersion (name: string): string {
const version = manifest.devDependencies[name]
if (!version) throw new Error(`Missing generator devDependency: ${name}`)
return version
}
89 changes: 89 additions & 0 deletions deployment.test.ts
Original file line number Diff line number Diff line change
@@ -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/)
}
}
})
}
}
Loading