Skip to content

Commit 3c01a91

Browse files
authored
feat: add self-contained starter template (#271)
1 parent bb69bde commit 3c01a91

35 files changed

Lines changed: 1079 additions & 1 deletion

AGENTS.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ The `pnpm test` script intentionally runs `build` first so `tsnapi` snapshots co
3636

3737
Ahead-of-time build artifacts that live under `src/` - the shadow-root stylesheets in `packages/hub-ui/src/client/.generated/` and `packages/json-render-ui/src/.generated/` - are **generated, not committed** (`.generated` is gitignored). Each owning package builds its own with `pnpm run build:css`, and three things guarantee the file is on disk before anything imports it: the root `postinstall` runs `turbo run build:css`, the Turbo `typecheck` task depends on both `build:css` tasks, and each package's `build` script chains `build:css` first. A new generated-under-`src` artifact follows the same shape - its own build script, declared `outputs` in `turbo.json`, and a `typecheck` dependency - rather than being checked in, since a minified single-line blob conflicts on every concurrent edit.
3838

39+
**`starter/`** is the top-level, self-contained template for creating a new devframe integration (Vanilla TS, Vite client, playgrounds, tests). It uses real versions in its `package.json` (no catalogs, no `workspace:*`) so it's copy-paste ready for users. Pnpm links its `devframe`/`@devframes/*` dependencies to the local workspace copies during development. When `bumpp -r` bumps the repo versions, `bumpp.config.ts` runs `scripts/sync-starter-version.ts` to update the starter's dependencies to match.
40+
3941
`pnpm knip` finds unused files, dependencies, and exports across every workspace (config in `knip.jsonc`). It runs against source directly - no prior build needed. Most workspaces need no configuration; `knip.jsonc` only carries per-workspace overrides for cases knip's defaults can't infer on their own: a package's non-`index.ts` `exports` subpaths (knip's package.json→`dist`→`src` source mapping needs a workspace `tsconfig.json` `outDir`, which conflicts with this repo's cross-workspace `src/*.ts` imports, so multi-entry packages list their `exports`-mapped entry files explicitly instead - keep that list in sync with each `tsdown.config.ts`), config files knip's plugins don't discover in a nested location (a Next.js app rooted below the workspace root, `storybook-solidjs-vite` not matching the Storybook plugin trigger), and dependencies referenced dynamically outside its static import graph (icon collections consumed by UnoCSS at build time, plugin packages loaded via a runtime `import()` string). Prefer fixing the underlying gap or a scoped `ignoreDependencies`/`entry` override over a blanket `ignore`.
4042

4143
## Conventions

bump.config.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { defineConfig } from 'bumpp'
2+
import { syncStarterVersion } from './scripts/sync-starter-version.ts'
3+
4+
export default defineConfig({
5+
// `starter/` pins real `devframe`/`@devframes/*` versions (it's a
6+
// copy-paste-ready template, not a workspace member consuming
7+
// `catalog:`/`workspace:*`), so `bumpp -r` can't reach it on its own -
8+
// sync it here, before the version-bump commit is made.
9+
execute: operation => syncStarterVersion(operation.state.newVersion),
10+
})

pnpm-lock.yaml

Lines changed: 124 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pnpm-workspace.yaml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@ minimumReleaseAgeExclude:
1616
- tsdown@0.22.14
1717
- verkit@0.3.0
1818
- structured-clone-es@2.0.1
19+
- '@devframes/hub-ui@0.9.4'
20+
- '@devframes/hub@0.9.4'
21+
- '@devframes/json-render@0.9.4'
22+
- '@devframes/vite@0.9.4'
1923
shamefullyHoist: true
2024
shellEmulator: true
2125
strictPeerDependencies: false
@@ -28,6 +32,7 @@ packages:
2832
- plugins/*/assets-pkg
2933
- services/*
3034
- examples/*
35+
- starter
3136
- storybook
3237
- docs
3338

scripts/sync-starter-version.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { readFile, writeFile } from 'node:fs/promises'
2+
import path from 'node:path'
3+
import process from 'node:process'
4+
import { fileURLToPath } from 'node:url'
5+
6+
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
7+
const starterPkgPath = path.resolve(rootDir, 'starter/package.json')
8+
9+
/**
10+
* Rewrites `starter/package.json`'s `devframe`/`@devframes/*` dependency
11+
* ranges to `^<version>` — the starter is a self-contained, copy-paste-ready
12+
* template that pins real versions rather than `catalog:`/`workspace:*`, so
13+
* a repo-wide bump has to touch it explicitly. Called from `bump.config.ts`'s
14+
* `execute` hook so `bumpp -r` keeps it in lockstep automatically.
15+
*/
16+
export async function syncStarterVersion(version: string): Promise<void> {
17+
const raw = await readFile(starterPkgPath, 'utf-8')
18+
const pkg = JSON.parse(raw)
19+
20+
for (const name of Object.keys(pkg.dependencies ?? {})) {
21+
if (name === 'devframe' || name.startsWith('@devframes/'))
22+
pkg.dependencies[name] = `^${version}`
23+
}
24+
25+
await writeFile(starterPkgPath, `${JSON.stringify(pkg, null, 2)}\n`)
26+
}
27+
28+
// Allow standalone invocation: `tsx scripts/sync-starter-version.ts <version>`.
29+
if (import.meta.url === `file://${process.argv[1]}`) {
30+
const version = process.argv[2]
31+
if (!version) {
32+
console.error('Usage: tsx scripts/sync-starter-version.ts <version>')
33+
process.exit(1)
34+
}
35+
await syncStarterVersion(version)
36+
}

scripts/verify-typecheck-coverage.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ const rootDir = resolve(dirname(fileURLToPath(import.meta.url)), '..')
2222
/**
2323
* Workspace globs, mirrored from `pnpm-workspace.yaml`'s `packages:` list.
2424
*/
25-
const WORKSPACE_PATTERNS = ['packages/*', 'plugins/*', 'examples/*', 'storybook', 'docs']
25+
const WORKSPACE_PATTERNS = ['packages/*', 'plugins/*', 'examples/*', 'starter', 'storybook', 'docs']
2626

2727
/**
2828
* Packages with a `tsconfig.json` that intentionally don't have a

starter/.gitignore

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
.DS_Store
2+
.turbo
3+
*.log
4+
coverage
5+
dist
6+
node_modules
7+
playwright-report
8+
test-results
9+
temp

starter/README.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
# devframe-starter
2+
3+
Self-contained starter for a single [devframe](https://github.com/devframes/devframe) integration: a vanilla-TS Vite client, a CLI dev/build/MCP shell, single + hub playgrounds, and unit + e2e tests. Copy this folder out of the monorepo as the seed for a new devframe - it pins real dependency versions (no `catalog:`/`workspace:*`) so it installs standalone.
4+
5+
## Run
6+
7+
```sh
8+
pnpm install
9+
pnpm run build # build the vanilla-TS SPA into dist/client
10+
pnpm run dev # CLI dev server - http://localhost:7391/__devframe-starter/
11+
pnpm run cli:build # static deploy in ./dist/static
12+
pnpm run play:single # Vite playground: SPA at /, RPC bridge at /__devframe-starter/
13+
pnpm run play:hub # Vite playground: mounted as a dock inside a devframes hub
14+
pnpm run test # unit tests (vitest)
15+
pnpm run test:e2e # e2e tests (playwright, against the single playground)
16+
pnpm run lint
17+
pnpm run typecheck
18+
```
19+
20+
`pnpm run dev` and both playgrounds gate by default: opening the printed URL walks you through devframe's interactive OTP handshake (a 6-digit code) before the SPA can call RPC. That's intentional - see the `auth` comments in `src/devframe.ts` and `playground/*/vite.config.ts` before reaching for `auth: false`, which trusts every connection that can reach the port. For a one-off loopback-only session, pass `--no-auth` to the CLI instead (`pnpm run dev -- --no-auth`).
21+
22+
## File map
23+
24+
| Path | Purpose |
25+
|------|---------|
26+
| `src/devframe.ts` | The single `DevframeDefinition` every surface below consumes. |
27+
| `src/rpc/` | The one RPC function (`get-state` - a query+snapshot returning runtime info and a directory listing) and its namespace declaration. |
28+
| `src/client/` | The vanilla-TS SPA: `index.html`, `main.ts`, `app.ts`, `styles.css`. |
29+
| `src/shared/base-path.ts` | The devframe's base path, shared between the node-side definition and browser-side client entries. |
30+
| `bin.mjs` | `createCac(devframe).parse()` - exposes `dev`, `build`, `mcp`. |
31+
| `playground/single/` | Vite dev-serves the SPA (with HMR) while `devframeViteBridge` answers RPC/discovery at the devframe's own base - see the comment in its `vite.config.ts` for why the two can't share one base. |
32+
| `playground/hub/` | A minimal `@devframes/hub` host that mounts this devframe as an iframe dock (requires `pnpm run build` first). |
33+
| `test/` | Unit tests - RPC functions over a real WebSocket, no browser. |
34+
| `e2e/` | Playwright tests against the single playground, using the checked-in `e2e/fixtures/` directory as a fixed working directory. |
35+
36+
## Versioning
37+
38+
Dependencies here are real semver ranges, not the monorepo's pnpm catalog - so this folder is copy-paste ready outside the workspace. When developed in-repo, pnpm links `devframe`/`@devframes/*` to the local workspace packages automatically. See the root `AGENTS.md`'s `starter/` note for how a repo-wide `bumpp -r` release keeps these versions in sync.

starter/bin.mjs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
#!/usr/bin/env node
2+
import process from 'node:process'
3+
import { createCac } from 'devframe/adapters/cac'
4+
import devframe from './src/devframe.ts'
5+
6+
async function main() {
7+
const cli = createCac(devframe)
8+
await cli.parse()
9+
}
10+
11+
main().catch((error) => {
12+
console.error(error)
13+
process.exit(1)
14+
})

starter/e2e/app.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import process from 'node:process'
2+
import { expect, test } from '@playwright/test'
3+
4+
// Exercises the single playground end to end: the vanilla-TS SPA connects
5+
// over the real WebSocket RPC bridge and renders the `get-state` result for
6+
// the fixed `./fixtures` directory (wired via `playwright.config.ts`'s
7+
// `webServer.env.DEVFRAME_E2E_CWD`).
8+
test('connects and renders the list of items', async ({ page }) => {
9+
await page.goto('/')
10+
11+
const list = page.locator('ul')
12+
await expect(list).toBeVisible()
13+
14+
const items = list.locator('li')
15+
await expect(items).toHaveCount(2)
16+
await expect(items.nth(0)).toContainText('dir1')
17+
await expect(items.nth(0)).toContainText('dir')
18+
await expect(items.nth(1)).toContainText('file1.txt')
19+
await expect(items.nth(1)).toContainText('file')
20+
21+
// The "node" info line renders from the same `get-state` call.
22+
await expect(page.locator('.meta code').first()).toContainText(process.version)
23+
})
24+
25+
test('refresh re-fetches the list without a page reload', async ({ page }) => {
26+
await page.goto('/')
27+
28+
const refreshButton = page.getByRole('button', { name: 'Refresh' })
29+
await expect(refreshButton).toBeEnabled()
30+
await refreshButton.click()
31+
32+
await expect(page.locator('ul li')).toHaveCount(2)
33+
})

0 commit comments

Comments
 (0)