From 04569864cb1eafbde364dc5ab9ef1fd3e5df246a Mon Sep 17 00:00:00 2001 From: Ivan Banov Date: Wed, 15 Jul 2026 13:59:40 +0200 Subject: [PATCH 1/9] feat: add pnpm scaffold for generating primitive packages Introduces a code-generation script that copies a tokenized template tree (currently one kind, "full": the core/dom/react trio for a new primitive) into packages/, substituting the primitive's name, then derives the tsconfig `paths` and tsdown `workspace` wiring from the packages it created. Template stubs under scripts/templates/** use a __name__ token that isn't valid TS/lintable source until scaffolded, so they're excluded from oxfmt, oxlint, vitest, and knip. --- .oxfmtrc.json | 3 +- .oxlintrc.json | 2 +- knip.config.ts | 4 + package.json | 1 + scripts/scaffold.ts | 178 ++++++++++++++++++ scripts/templates/README.md | 46 +++++ .../full/packages/core/__name__/README.md | 49 +++++ .../full/packages/core/__name__/SPECS.md | 43 +++++ .../full/packages/core/__name__/package.json | 39 ++++ .../packages/core/__name__/src/connect.ts | 38 ++++ .../full/packages/core/__name__/src/index.ts | 9 + .../packages/core/__name__/src/machine.ts | 51 +++++ .../full/packages/core/__name__/src/types.ts | 31 +++ .../core/__name__/tests/machine.test.ts | 27 +++ .../full/packages/dom/__name__/README.md | 36 ++++ .../full/packages/dom/__name__/SPECS.md | 35 ++++ .../full/packages/dom/__name__/package.json | 40 ++++ .../dom/__name__/src/create-__name__.ts | 71 +++++++ .../full/packages/dom/__name__/src/index.ts | 2 + .../__name__/tests/create-__name__.test.ts | 29 +++ .../full/packages/react/__name__/README.md | 24 +++ .../full/packages/react/__name__/SPECS.md | 21 +++ .../full/packages/react/__name__/package.json | 50 +++++ .../full/packages/react/__name__/src/index.ts | 2 + .../react/__name__/src/use-__name__.ts | 36 ++++ .../__name__/tests/use-__name__.test.tsx | 29 +++ vitest.config.ts | 4 +- 27 files changed, 897 insertions(+), 3 deletions(-) create mode 100644 scripts/scaffold.ts create mode 100644 scripts/templates/README.md create mode 100644 scripts/templates/full/packages/core/__name__/README.md create mode 100644 scripts/templates/full/packages/core/__name__/SPECS.md create mode 100644 scripts/templates/full/packages/core/__name__/package.json create mode 100644 scripts/templates/full/packages/core/__name__/src/connect.ts create mode 100644 scripts/templates/full/packages/core/__name__/src/index.ts create mode 100644 scripts/templates/full/packages/core/__name__/src/machine.ts create mode 100644 scripts/templates/full/packages/core/__name__/src/types.ts create mode 100644 scripts/templates/full/packages/core/__name__/tests/machine.test.ts create mode 100644 scripts/templates/full/packages/dom/__name__/README.md create mode 100644 scripts/templates/full/packages/dom/__name__/SPECS.md create mode 100644 scripts/templates/full/packages/dom/__name__/package.json create mode 100644 scripts/templates/full/packages/dom/__name__/src/create-__name__.ts create mode 100644 scripts/templates/full/packages/dom/__name__/src/index.ts create mode 100644 scripts/templates/full/packages/dom/__name__/tests/create-__name__.test.ts create mode 100644 scripts/templates/full/packages/react/__name__/README.md create mode 100644 scripts/templates/full/packages/react/__name__/SPECS.md create mode 100644 scripts/templates/full/packages/react/__name__/package.json create mode 100644 scripts/templates/full/packages/react/__name__/src/index.ts create mode 100644 scripts/templates/full/packages/react/__name__/src/use-__name__.ts create mode 100644 scripts/templates/full/packages/react/__name__/tests/use-__name__.test.tsx diff --git a/.oxfmtrc.json b/.oxfmtrc.json index c5d62d5..126ca0e 100644 --- a/.oxfmtrc.json +++ b/.oxfmtrc.json @@ -8,5 +8,6 @@ "useTabs": false, "semi": false, "singleQuote": true, - "trailingComma": "all" + "trailingComma": "all", + "ignorePatterns": ["scripts/templates/**"] } diff --git a/.oxlintrc.json b/.oxlintrc.json index 8b1c478..78492fc 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -44,5 +44,5 @@ } }, "globals": {}, - "ignorePatterns": ["**/dist/**"] + "ignorePatterns": ["**/dist/**", "scripts/templates/**"] } diff --git a/knip.config.ts b/knip.config.ts index e329dc7..46a42b4 100644 --- a/knip.config.ts +++ b/knip.config.ts @@ -6,6 +6,10 @@ const config: KnipConfig = { // their own module — keep those out of the "unused exports" report. ignoreExportsUsedInFile: true, + // Scaffolding template stubs — not part of the dependency graph until copied + // out by scripts/scaffold.ts. + ignore: ['scripts/templates/**'], + workspaces: { '.': { // pnpm script names knip mistakes for binaries when it parses diff --git a/package.json b/package.json index 52d08eb..775c11e 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "format": "oxfmt .", "format:check": "oxfmt --check .", "knip": "knip", + "scaffold": "node scripts/scaffold.ts", "changeset": "changeset", "prepare": "husky" }, diff --git a/scripts/scaffold.ts b/scripts/scaffold.ts new file mode 100644 index 0000000..4f50fcc --- /dev/null +++ b/scripts/scaffold.ts @@ -0,0 +1,178 @@ +import { execFileSync } from 'node:child_process' +import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs' +import { dirname, join, relative } from 'node:path' +import { fileURLToPath } from 'node:url' + +// Scaffolds a new primitive from a template kind. Templates live as real +// (`__name__`-tokenized) files under scripts/templates/, so they stay +// lintable/formattable and diffable against the packages they mirror. The script +// copies the tree with token substitution, then derives the workspace wiring +// (tsconfig paths + tsdown workspace) from the packages it created — add a +// substrate later by dropping a new folder into a kind, no script change needed. +// +// pnpm scaffold e.g. pnpm scaffold full toggle + +const ROOT = fileURLToPath(new URL('..', import.meta.url)) +const TEMPLATES = join(ROOT, 'scripts', 'templates') + +const NAME_PATTERN = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/ + +interface Tokens { + kebab: string + pascal: string + camel: string +} + +interface CreatedPackage { + name: string + private: boolean + dir: string + src: string +} + +function fail(message: string): never { + throw new Error(message) +} + +function toPascal(kebab: string): string { + return kebab + .split('-') + .map(part => part.charAt(0).toUpperCase() + part.slice(1)) + .join('') +} + +function toCamel(pascal: string): string { + return pascal.charAt(0).toLowerCase() + pascal.slice(1) +} + +function applyTokens(text: string, tokens: Tokens): string { + return text + .replaceAll('__camelName__', tokens.camel) + .replaceAll('__Name__', tokens.pascal) + .replaceAll('__name__', tokens.kebab) +} + +function walk(dir: string, visit: (file: string) => void): void { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = join(dir, entry.name) + if (entry.isDirectory()) walk(full, visit) + else visit(full) + } +} + +function collectJobs( + templateDir: string, + tokens: Tokens, +): Array<{ src: string; dest: string; rel: string }> { + const jobs: Array<{ src: string; dest: string; rel: string }> = [] + walk(templateDir, src => { + const rel = relative(templateDir, src).replaceAll('__name__', tokens.kebab) + jobs.push({ src, dest: join(ROOT, rel), rel }) + }) + return jobs +} + +function describeCreatedPackages(jobs: Array<{ dest: string; rel: string }>): CreatedPackage[] { + return jobs + .filter(job => job.rel.startsWith('packages/') && job.rel.endsWith('package.json')) + .map(job => { + const dir = dirname(job.dest) + const pkg = JSON.parse(readFileSync(job.dest, 'utf8')) + return { + name: pkg.name, + private: pkg.private === true, + dir: relative(ROOT, dir), + src: relative(ROOT, join(dir, 'src')), + } + }) +} + +// tsconfig.json is plain JSON but authored with inline single-element path arrays; +// a JSON round-trip would expand them and churn the diff. Insert new entries as +// text, right after the `paths` opener, matching the existing style. +function wireTsconfig(created: CreatedPackage[]): void { + const path = join(ROOT, 'tsconfig.json') + let text = readFileSync(path, 'utf8') + const lines = created + .filter(pkg => !text.includes(`"${pkg.name}":`)) + .map(pkg => ` "${pkg.name}": ["./${pkg.src}"],`) + if (lines.length === 0) return + text = text.replace('"paths": {\n', `"paths": {\n${lines.join('\n')}\n`) + writeFileSync(path, text) +} + +// The tsdown workspace is an explicit list of publishable packages. Rebuild it as +// a sorted, deduped multiline array (private packages are never published). +function wireTsdown(created: CreatedPackage[]): void { + const path = join(ROOT, 'tsdown.config.ts') + const text = readFileSync(path, 'utf8') + const match = text.match(/workspace:\s*\[([\s\S]*?)\]/) + if (!match) fail('could not find the workspace array in tsdown.config.ts') + const existing = [...match[1].matchAll(/'([^']+)'/g)].map(m => m[1]) + const additions = created.filter(pkg => !pkg.private).map(pkg => pkg.dir) + const all = [...new Set([...existing, ...additions])].sort() + const rebuilt = `workspace: [\n${all.map(entry => ` '${entry}',`).join('\n')}\n ]` + writeFileSync(path, text.replace(match[0], rebuilt)) +} + +// Templates are excluded from oxfmt (the `__name__` token collides with markdown +// bold), and oxfmt sorts package.json keys — an order the substituted names won't +// match. The wired root files are hand-edited as text (inline vs multiline depends +// on entry count). Format all of it so the result passes format:check straight away. +function formatOutputs(created: CreatedPackage[]): void { + const bin = join(ROOT, 'node_modules', '.bin', 'oxfmt') + if (!existsSync(bin)) return + const targets = [...created.map(pkg => pkg.dir), 'tsconfig.json', 'tsdown.config.ts'] + execFileSync(bin, targets, { cwd: ROOT, stdio: 'inherit' }) +} + +function main(): void { + const [kind, rawName] = process.argv.slice(2) + const available = existsSync(TEMPLATES) + ? readdirSync(TEMPLATES, { withFileTypes: true }) + .filter(entry => entry.isDirectory()) + .map(entry => entry.name) + : [] + + if (!kind || !rawName) { + fail(`usage: pnpm scaffold \navailable kinds: ${available.join(', ') || '(none)'}`) + } + const templateDir = join(TEMPLATES, kind) + if (!existsSync(templateDir)) { + fail(`unknown kind "${kind}". available kinds: ${available.join(', ') || '(none)'}`) + } + if (!NAME_PATTERN.test(rawName)) { + fail(`name "${rawName}" must be kebab-case (e.g. toggle, toggle-button)`) + } + + const pascal = toPascal(rawName) + const tokens: Tokens = { kebab: rawName, pascal, camel: toCamel(pascal) } + + const jobs = collectJobs(templateDir, tokens) + for (const job of jobs) { + if (existsSync(job.dest)) fail(`refusing to overwrite existing ${job.rel}`) + } + for (const job of jobs) { + mkdirSync(dirname(job.dest), { recursive: true }) + writeFileSync(job.dest, applyTokens(readFileSync(job.src, 'utf8'), tokens)) + } + + const created = describeCreatedPackages(jobs) + wireTsconfig(created) + wireTsdown(created) + formatOutputs(created) + + console.log(`\nScaffolded "${rawName}" (${kind}):\n`) + for (const pkg of created) console.log(` ${pkg.name} -> ${pkg.dir}`) + console.log('\nNext:') + console.log(' 1. pnpm install # link the new workspace packages') + console.log(' 2. write each SPECS.md, then the tests, then the implementation') + console.log(' 3. pnpm test && pnpm typecheck && pnpm lint && pnpm build\n') +} + +try { + main() +} catch (error) { + console.error(error instanceof Error ? error.message : error) + process.exitCode = 1 +} diff --git a/scripts/templates/README.md b/scripts/templates/README.md new file mode 100644 index 0000000..87fab00 --- /dev/null +++ b/scripts/templates/README.md @@ -0,0 +1,46 @@ +# Scaffolding templates + +`pnpm scaffold ` copies `scripts/templates//` into the repo, +substituting the primitive's name, then derives the workspace wiring (tsconfig +`paths` + tsdown `workspace`) from the packages it created. See +[`../scaffold.ts`](../scaffold.ts). + +``` +pnpm scaffold full toggle # single word +pnpm scaffold full toggle-button # multi-word (kebab-case) +``` + +## Tokens + +Template files (and file/dir names) are real, tokenized source. Three tokens are +substituted from the kebab-case name argument: + +| Token | Becomes | Example (`toggle-button`) | +| ---------------- | ---------------------- | ------------------------- | +| `__name__` | the name, kebab-case | `toggle-button` | +| `__Name__` | PascalCase | `ToggleButton` | +| `__camelName__` | camelCase | `toggleButton` | + +Only `__name__` appears in file and directory names (e.g. +`src/create-__name__.ts`). + +## Adding a kind + +A "kind" is a folder under `scripts/templates/`. Its contents mirror the repo +root, so a package at `/packages/native/__name__/` lands at +`packages/native/__name__/`. To add one (say, a native driver group): + +1. Create `scripts/templates//packages/.../__name__/` with a + tokenized `package.json`, `src/`, `tests/`, `README.md`, and `SPECS.md`. +2. That's it — the script discovers the created packages from the copied + `package.json` files and wires them automatically. A package with + `"private": true` is added to tsconfig `paths` but skipped in the tsdown + publish set. + +## Why this dir is tool-excluded + +`scripts/templates/**` is excluded from oxfmt, oxlint, vitest, and knip (see the +root configs): the `__name__` token collides with Markdown bold (`__`), the +underscore filenames fail the kebab-case lint rule, and the `.test.ts` stubs +only resolve once scaffolded. The generated output lands under `packages/`, +where every check applies in full — and the scaffold formats it on the way out. diff --git a/scripts/templates/full/packages/core/__name__/README.md b/scripts/templates/full/packages/core/__name__/README.md new file mode 100644 index 0000000..e2c6fcc --- /dev/null +++ b/scripts/templates/full/packages/core/__name__/README.md @@ -0,0 +1,49 @@ +# @dunky.dev/__name__ + +The framework-agnostic __name__ interaction, modeled as a state machine on +`@dunky.dev/state-machine`. Pure logic — no DOM, no framework. Pair it with a +driver: [`@dunky.dev/dom-__name__`](../../dom/__name__) for any web framework, +[`@dunky.dev/react-__name__`](../../react/__name__) for the React hook. + +The behavior contract — scenarios, guarantees, driver obligations — lives in +[SPECS.md](./SPECS.md). + +## How the package works + +``` +src/ + types.ts public options/callbacks types + machine context/events + machine.ts create__Name__Config(options) -> the state graph + connect.ts __camelName__Connect (snapshot -> api) + the callback reactions +tests/ + machine.test.ts drives the machine with raw events; asserts callbacks +``` + +Two idioms this package is built on (kept identical to `press`): + +- **Emission mailboxes.** The machine never sees consumer callbacks. Context + carries one nullable slot per callback; an action fires a callback by writing + a NEW value into its slot and suppresses it by keeping the OLD reference. + `connect.ts` registers one reaction per slot; reactions run in registration + order, so that order IS the callback-order contract. Consequence: every + action performs exactly one `setContext`. +- **The machine never sees props.** Config flags are seeded into context at + build time; callbacks fire through the connector. See `ARCHITECTURE.md`. + +## Usage + +Driving the machine directly (tests, new drivers): + +```ts +import { machine, connector } from '@dunky.dev/state-machine' +import { create__Name__Config, __camelName__Connect } from '@dunky.dev/__name__' + +const service = machine(create__Name__Config(options)) +connector(service, __camelName__Connect, options) // wires the consumer callbacks +service.start() +service.send({ type: 'ACTIVATE' }) +``` + +Consumers should use a driver instead — see +[`@dunky.dev/dom-__name__`](../../dom/__name__) and +[`@dunky.dev/react-__name__`](../../react/__name__). diff --git a/scripts/templates/full/packages/core/__name__/SPECS.md b/scripts/templates/full/packages/core/__name__/SPECS.md new file mode 100644 index 0000000..7648abd --- /dev/null +++ b/scripts/templates/full/packages/core/__name__/SPECS.md @@ -0,0 +1,43 @@ +# __name__ — spec + +## Intent + + + +TODO: describe the __name__ interaction and why it exists. + +## The interaction + + + +TODO: describe the states and transitions. + +## What the consumer observes + + + +TODO: table of scenario -> observed callbacks, plus guarantees. + +## Driver obligations + + + +TODO: list the driver contract. + +## Positions + +Substrate-independent design decisions, with their standing. These rows are +never deleted — a change of heart flips the status with reasoning. +Statuses: `adopted` (differs from a source, on purpose) · `deferred` +(consciously not built; says what breaks) · `rejected` (considered and +refused; says why). + +| Position | Why | Status | +| -------- | ---- | ------ | +| TODO | TODO | TODO | diff --git a/scripts/templates/full/packages/core/__name__/package.json b/scripts/templates/full/packages/core/__name__/package.json new file mode 100644 index 0000000..9ae0cb2 --- /dev/null +++ b/scripts/templates/full/packages/core/__name__/package.json @@ -0,0 +1,39 @@ +{ + "name": "@dunky.dev/__name__", + "version": "0.0.0", + "description": "Framework-agnostic __name__ interaction, modeled as a state machine.", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/dunky-dev/ui.git", + "directory": "packages/core/__name__" + }, + "files": [ + "dist" + ], + "type": "module", + "sideEffects": false, + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "publishConfig": { + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "access": "public" + }, + "scripts": { + "build": "tsdown" + }, + "dependencies": { + "@dunky.dev/state-machine": "^0.2.0" + } +} diff --git a/scripts/templates/full/packages/core/__name__/src/connect.ts b/scripts/templates/full/packages/core/__name__/src/connect.ts new file mode 100644 index 0000000..7b479f4 --- /dev/null +++ b/scripts/templates/full/packages/core/__name__/src/connect.ts @@ -0,0 +1,38 @@ +import { makeReaction, type Connect } from '@dunky.dev/state-machine' +import type { + __Name__Context, + __Name__MachineEvent, + __Name__Options, + __Name__StateName, +} from './types' + +/** The view-facing surface a driver reads from the running __name__ machine. */ +export interface __Name__Api { + disabled: boolean +} + +export const __camelName__Connect: Connect< + __Name__StateName, + __Name__Context, + __Name__MachineEvent, + __Name__Options, + __Name__Api +> = ({ context }) => ({ disabled: context.disabled }) + +const reaction = makeReaction< + __Name__StateName, + __Name__Context, + __Name__MachineEvent, + __Name__Options +>() + +// One reaction per consumer callback. Reactions fire in registration order within +// a single setContext — that order is the callback-order contract. See SPECS.md. +__camelName__Connect.reactions = [ + reaction( + m => m.context.activateEvent, + (event, props) => { + if (event) props.onActivate?.() + }, + ), +] diff --git a/scripts/templates/full/packages/core/__name__/src/index.ts b/scripts/templates/full/packages/core/__name__/src/index.ts new file mode 100644 index 0000000..10143f3 --- /dev/null +++ b/scripts/templates/full/packages/core/__name__/src/index.ts @@ -0,0 +1,9 @@ +export { create__Name__Config } from './machine' +export { __camelName__Connect, type __Name__Api } from './connect' +export type { + __Name__Callbacks, + __Name__Options, + __Name__StateName, + __Name__Context, + __Name__MachineEvent, +} from './types' diff --git a/scripts/templates/full/packages/core/__name__/src/machine.ts b/scripts/templates/full/packages/core/__name__/src/machine.ts new file mode 100644 index 0000000..1d28caf --- /dev/null +++ b/scripts/templates/full/packages/core/__name__/src/machine.ts @@ -0,0 +1,51 @@ +import { setup, type Action, type Guard, type TransitionConfig } from '@dunky.dev/state-machine' +import type { + __Name__Context, + __Name__MachineEvent, + __Name__Options, + __Name__StateName, +} from './types' + +// TODO(spec): replace this placeholder lifecycle with the real statechart. The +// skeleton models a single `idle` state that activates in place — enough to +// compile, test, and drive end-to-end. Describe the intended behavior in +// SPECS.md first, then grow the states/transitions to match it. + +type __Name__Action = Action<__Name__Context, __Name__MachineEvent> +type __Name__Guard = Guard<__Name__Context, __Name__MachineEvent> + +// Emit onActivate by writing a fresh token; the connector reaction fires on the +// reference change (see connect.ts). Each action performs exactly one setContext. +const activate: __Name__Action = ({ setContext }) => setContext({ activateEvent: {} }) + +const disable: __Name__Action = ({ setContext }) => setContext({ disabled: true }) +const enable: __Name__Action = ({ setContext }) => setContext({ disabled: false }) + +const notDisabled: __Name__Guard = ({ context }) => !context.disabled +const isDisableEvent: __Name__Guard = ({ event }) => + event.type === 'SET_DISABLED' && event.disabled === true + +export function create__Name__Config( + options: __Name__Options, +): TransitionConfig<__Name__StateName, __Name__Context, __Name__MachineEvent> { + // Annotated so createMachine infers Context as __Name__Context, not the narrowed literal. + const context: __Name__Context = { + disabled: options.disabled ?? false, + activateEvent: null, + } + + return setup.as<__Name__Context, __Name__MachineEvent>().createMachine({ + initial: 'idle', + context, + states: { + idle: { + on: { + ACTIVATE: { guard: notDisabled, actions: activate }, + }, + }, + }, + on: { + SET_DISABLED: [{ guard: isDisableEvent, actions: disable }, { actions: enable }], + }, + }) +} diff --git a/scripts/templates/full/packages/core/__name__/src/types.ts b/scripts/templates/full/packages/core/__name__/src/types.ts new file mode 100644 index 0000000..9ea975a --- /dev/null +++ b/scripts/templates/full/packages/core/__name__/src/types.ts @@ -0,0 +1,31 @@ +// Public + machine-facing types for the framework-agnostic __name__ primitive. +// The state machine is substrate-free: all DOM/native event reading lives in a +// per-substrate driver (see @dunky.dev/dom-__name__). + +export interface __Name__Callbacks { + /** Fired when the primitive activates. */ + onActivate?: () => void +} + +export interface __Name__Options extends __Name__Callbacks { + /** No events fire while disabled; an in-flight interaction is cancelled. */ + disabled?: boolean +} + +export type __Name__StateName = 'idle' + +/** + * Machine context. Config flags (`disabled`) are seeded from options at build + * time — the machine itself never sees props/callbacks. `activateEvent` is an + * emission mailbox: writing a NEW value into it fires the matching connector + * reaction, which is how the consumer callback is dispatched. See SPECS.md. + */ +export interface __Name__Context { + disabled: boolean + /** Emission mailbox: a fresh token fires onActivate; an unchanged one suppresses it. */ + activateEvent: object | null +} + +export type __Name__MachineEvent = + | { type: 'ACTIVATE' } + | { type: 'SET_DISABLED'; disabled: boolean } diff --git a/scripts/templates/full/packages/core/__name__/tests/machine.test.ts b/scripts/templates/full/packages/core/__name__/tests/machine.test.ts new file mode 100644 index 0000000..f46dd59 --- /dev/null +++ b/scripts/templates/full/packages/core/__name__/tests/machine.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it, vi } from 'vitest' +import { connector, machine } from '@dunky.dev/state-machine' +import { create__Name__Config, __camelName__Connect } from '@dunky.dev/__name__' +import type { __Name__Options } from '@dunky.dev/__name__' + +function harness(options: __Name__Options = {}) { + const service = machine(create__Name__Config(options)) + connector(service, __camelName__Connect, options) + service.start() + return { service, send: service.send } +} + +describe('__name__ machine', () => { + it('fires onActivate on ACTIVATE', () => { + const onActivate = vi.fn() + const { send } = harness({ onActivate }) + send({ type: 'ACTIVATE' }) + expect(onActivate).toHaveBeenCalledTimes(1) + }) + + it('does not activate while disabled', () => { + const onActivate = vi.fn() + const { send } = harness({ disabled: true, onActivate }) + send({ type: 'ACTIVATE' }) + expect(onActivate).not.toHaveBeenCalled() + }) +}) diff --git a/scripts/templates/full/packages/dom/__name__/README.md b/scripts/templates/full/packages/dom/__name__/README.md new file mode 100644 index 0000000..723d224 --- /dev/null +++ b/scripts/templates/full/packages/dom/__name__/README.md @@ -0,0 +1,36 @@ +# @dunky.dev/dom-__name__ + +The vanilla DOM driver for [`@dunky.dev/__name__`](../../core/__name__): +`create__Name__()` binds one element, wires its listeners, translates raw +browser events into machine events, and owns the browser-quirk workarounds. +Framework-free — the React/Vue/Solid/Svelte bindings are thin wrappers over +this one driver. + +The behavior it implements is specified in +[`../../core/__name__/SPECS.md`](../../core/__name__/SPECS.md); this package's +own [SPECS.md](./SPECS.md) covers the driver contract (instance lifecycle, +event wiring, what lives here vs in the machine). + +## How the package works + +``` +src/ + create-__name__.ts the driver factory (instance lifecycle + event wiring) +tests/ + create-__name__.test.ts jsdom wiring tests +``` + +## Usage + +```ts +import { create__Name__ } from '@dunky.dev/dom-__name__' + +const instance = create__Name__({ onActivate: () => console.log('activated') }) +instance.attach(button) +// later: +instance.setOptions({ ...options, disabled: true }) // cancels an in-flight interaction +instance.detach() +``` + +Canonical framework wrappers (Vue composable, Solid signals, Svelte action) +live in [`sandbox/`](../../../sandbox). diff --git a/scripts/templates/full/packages/dom/__name__/SPECS.md b/scripts/templates/full/packages/dom/__name__/SPECS.md new file mode 100644 index 0000000..d27e927 --- /dev/null +++ b/scripts/templates/full/packages/dom/__name__/SPECS.md @@ -0,0 +1,35 @@ +# dom-__name__ — spec + +The behavior contract lives in +[`../../core/__name__/SPECS.md`](../../core/__name__/SPECS.md). This spec covers +the DOM driver: instance lifecycle, event wiring, and what lives here vs in the +machine. + +## Instance lifecycle + + + +TODO: describe the instance lifecycle. + +## Event wiring + + + +TODO: describe the listener topology. + +## Browser quirks + + + +TODO: list the quirks. + +## Positions + +Statuses: `adopted` · `deferred` · `rejected`. + +| Position | Why | Status | +| -------- | ---- | ------ | +| TODO | TODO | TODO | diff --git a/scripts/templates/full/packages/dom/__name__/package.json b/scripts/templates/full/packages/dom/__name__/package.json new file mode 100644 index 0000000..2cb181e --- /dev/null +++ b/scripts/templates/full/packages/dom/__name__/package.json @@ -0,0 +1,40 @@ +{ + "name": "@dunky.dev/dom-__name__", + "version": "0.0.0", + "description": "Vanilla DOM driver for @dunky.dev/__name__ — usable from any web framework.", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/dunky-dev/ui.git", + "directory": "packages/dom/__name__" + }, + "files": [ + "dist" + ], + "type": "module", + "sideEffects": false, + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "publishConfig": { + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "access": "public" + }, + "scripts": { + "build": "tsdown" + }, + "dependencies": { + "@dunky.dev/__name__": "workspace:^", + "@dunky.dev/state-machine": "^0.2.0" + } +} diff --git a/scripts/templates/full/packages/dom/__name__/src/create-__name__.ts b/scripts/templates/full/packages/dom/__name__/src/create-__name__.ts new file mode 100644 index 0000000..ef27028 --- /dev/null +++ b/scripts/templates/full/packages/dom/__name__/src/create-__name__.ts @@ -0,0 +1,71 @@ +import { connector, machine } from '@dunky.dev/state-machine' +import { + create__Name__Config, + __camelName__Connect, + type __Name__Options, +} from '@dunky.dev/__name__' + +/** + * A live, framework-free __name__ binding. `attach` an element and the instance + * wires its DOM listeners and drives the agnostic __name__ machine. One instance + * per element; restartable across attach/detach (safe for React StrictMode remounts). + */ +export interface __Name__Instance { + attach: (element: HTMLElement) => void + detach: () => void + /** Swap options/callbacks (fresh closures each render); `disabled` is synced into the machine. */ + setOptions: (options: __Name__Options) => void +} + +export function create__Name__(initialOptions: __Name__Options = {}): __Name__Instance { + let options = initialOptions + const service = machine(create__Name__Config(options)) + const connection = connector(service, __camelName__Connect, options) + + let element: HTMLElement | null = null + const elementCleanups: Array<() => void> = [] + + function listen( + target: HTMLElement, + type: K, + handler: (event: HTMLElementEventMap[K]) => void, + ): () => void { + target.addEventListener(type, handler as EventListener) + return () => target.removeEventListener(type, handler as EventListener) + } + + // TODO(spec): translate the real substrate events into machine events. This + // placeholder activates on a plain click — enough to drive the skeleton. + const onClick = () => { + if (options.disabled) return + service.send({ type: 'ACTIVATE' }) + } + + function detach() { + if (!element) return + for (const cleanup of elementCleanups) cleanup() + elementCleanups.length = 0 + element = null + service.stop() + } + + function attach(next: HTMLElement) { + if (element === next) return + if (element) detach() + element = next + elementCleanups.push(listen(next, 'click', onClick)) + service.start() + } + + return { + attach, + detach, + setOptions(next) { + const wasDisabled = options.disabled ?? false + options = next + connection.setProps(next) + const isDisabled = next.disabled ?? false + if (wasDisabled !== isDisabled) service.send({ type: 'SET_DISABLED', disabled: isDisabled }) + }, + } +} diff --git a/scripts/templates/full/packages/dom/__name__/src/index.ts b/scripts/templates/full/packages/dom/__name__/src/index.ts new file mode 100644 index 0000000..f25c1be --- /dev/null +++ b/scripts/templates/full/packages/dom/__name__/src/index.ts @@ -0,0 +1,2 @@ +export { create__Name__, type __Name__Instance } from './create-__name__' +export type { __Name__Callbacks, __Name__Options } from '@dunky.dev/__name__' diff --git a/scripts/templates/full/packages/dom/__name__/tests/create-__name__.test.ts b/scripts/templates/full/packages/dom/__name__/tests/create-__name__.test.ts new file mode 100644 index 0000000..e109f4b --- /dev/null +++ b/scripts/templates/full/packages/dom/__name__/tests/create-__name__.test.ts @@ -0,0 +1,29 @@ +// @vitest-environment jsdom +import { describe, expect, it, vi } from 'vitest' +import { create__Name__ } from '@dunky.dev/dom-__name__' + +describe('create__Name__', () => { + it('activates on click', () => { + const onActivate = vi.fn() + const button = document.createElement('button') + document.body.append(button) + const instance = create__Name__({ onActivate }) + instance.attach(button) + button.click() + expect(onActivate).toHaveBeenCalledTimes(1) + instance.detach() + button.remove() + }) + + it('does not activate while disabled', () => { + const onActivate = vi.fn() + const button = document.createElement('button') + document.body.append(button) + const instance = create__Name__({ onActivate, disabled: true }) + instance.attach(button) + button.click() + expect(onActivate).not.toHaveBeenCalled() + instance.detach() + button.remove() + }) +}) diff --git a/scripts/templates/full/packages/react/__name__/README.md b/scripts/templates/full/packages/react/__name__/README.md new file mode 100644 index 0000000..cfaad79 --- /dev/null +++ b/scripts/templates/full/packages/react/__name__/README.md @@ -0,0 +1,24 @@ +# @dunky.dev/react-__name__ + +React binding for [`@dunky.dev/__name__`](../../core/__name__): `use__Name__()` +wraps the framework-free [`@dunky.dev/dom-__name__`](../../dom/__name__) driver — +a ref callback for attach/detach and an effect that re-syncs options every +render so inline callbacks stay fresh. + +Behavior contract: [`../../core/__name__/SPECS.md`](../../core/__name__/SPECS.md). +Hook-specific guarantees: [SPECS.md](./SPECS.md). + +## Usage + +```tsx +import { use__Name__ } from '@dunky.dev/react-__name__' + +function Component() { + const { ref } = use__Name__({ onActivate: () => {} }) + return +} +``` + +Vue, Solid, and Svelte don't need a package — the driver maps directly onto a +composable / signals / a `use:` action; canonical patterns live in +[`sandbox/`](../../../sandbox). diff --git a/scripts/templates/full/packages/react/__name__/SPECS.md b/scripts/templates/full/packages/react/__name__/SPECS.md new file mode 100644 index 0000000..02e8d24 --- /dev/null +++ b/scripts/templates/full/packages/react/__name__/SPECS.md @@ -0,0 +1,21 @@ +# react-__name__ — spec + +The behavior contract lives in +[`../../core/__name__/SPECS.md`](../../core/__name__/SPECS.md); the driver +contract in [`../../dom/__name__/SPECS.md`](../../dom/__name__/SPECS.md). This +spec covers hook-specific guarantees only. + +## Hook contract + + + +TODO: describe the hook contract. + +## Positions + +Statuses: `adopted` · `deferred` · `rejected`. + +| Position | Why | Status | +| -------- | ---- | ------ | +| TODO | TODO | TODO | diff --git a/scripts/templates/full/packages/react/__name__/package.json b/scripts/templates/full/packages/react/__name__/package.json new file mode 100644 index 0000000..ccdf603 --- /dev/null +++ b/scripts/templates/full/packages/react/__name__/package.json @@ -0,0 +1,50 @@ +{ + "name": "@dunky.dev/react-__name__", + "version": "0.0.0", + "description": "React binding for @dunky.dev/__name__.", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/dunky-dev/ui.git", + "directory": "packages/react/__name__" + }, + "files": [ + "dist" + ], + "type": "module", + "sideEffects": false, + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "publishConfig": { + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "access": "public" + }, + "scripts": { + "build": "tsdown" + }, + "dependencies": { + "@dunky.dev/__name__": "workspace:^", + "@dunky.dev/dom-__name__": "workspace:^" + }, + "devDependencies": { + "@testing-library/react": "^16.1.0", + "@types/react": "^19.2.15", + "@types/react-dom": "^19.2.3", + "react": "^19.2.6", + "react-dom": "^19.2.6" + }, + "peerDependencies": { + "react": "*" + } +} diff --git a/scripts/templates/full/packages/react/__name__/src/index.ts b/scripts/templates/full/packages/react/__name__/src/index.ts new file mode 100644 index 0000000..b5566a8 --- /dev/null +++ b/scripts/templates/full/packages/react/__name__/src/index.ts @@ -0,0 +1,2 @@ +export { use__Name__, type Use__Name__Result } from './use-__name__' +export type { __Name__Callbacks, __Name__Options } from '@dunky.dev/__name__' diff --git a/scripts/templates/full/packages/react/__name__/src/use-__name__.ts b/scripts/templates/full/packages/react/__name__/src/use-__name__.ts new file mode 100644 index 0000000..21b0722 --- /dev/null +++ b/scripts/templates/full/packages/react/__name__/src/use-__name__.ts @@ -0,0 +1,36 @@ +import { useCallback, useEffect, useState } from 'react' +import { create__Name__, type __Name__Instance } from '@dunky.dev/dom-__name__' +import type { __Name__Options } from '@dunky.dev/__name__' + +export interface Use__Name__Result { + /** Attach to the element: ` +} + +// RTL auto-cleanup needs vitest globals; this repo runs with globals: false. +afterEach(cleanup) + +describe('use__Name__', () => { + it('activates on click under StrictMode', () => { + const onActivate = vi.fn() + const { getByRole } = render( + + + , + ) + act(() => { + getByRole('button').click() + }) + expect(onActivate).toHaveBeenCalledTimes(1) + }) +}) diff --git a/vitest.config.ts b/vitest.config.ts index c3bca12..a15831b 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -4,6 +4,8 @@ export default defineConfig({ test: { globals: false, environment: 'node', - exclude: ['**/node_modules/**', '**/dist/**'], + // scripts/templates holds __name__-tokenized scaffolding stubs — real files, + // but not runnable tests (their imports resolve only once scaffolded). + exclude: ['**/node_modules/**', '**/dist/**', 'scripts/templates/**'], }, }) From 3fb8816a52841422a5593d1e9d7764fa01eefe04 Mon Sep 17 00:00:00 2001 From: Ivan Banov Date: Wed, 15 Jul 2026 14:13:54 +0200 Subject: [PATCH 2/9] chore: wire up build, test, and release tooling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brings the repo-level toolchain to a working, CI-green state without adding any primitive packages or sandbox apps: - package.json — rename to `ui`; add `build` (tsdown), `test:ci` (vitest run), and `changeset:version` / `changeset:publish`. Adds tsdown, publint, and @changesets/changelog-github. Omits jsdom (only the React binding's DOM tests need it) and the sandbox:* scripts. - tsdown.config.ts — one root config that builds the publish set in workspace mode. tsdown errors on an empty workspace, so it points at the existing placeholder `packages/core` for now; the first real primitive replaces that entry. - tsconfig.json — reformatted; empty `paths` (the package aliases land with the packages). - .changeset/config.json — schema bump, GitHub changelog generator (repo dunky-dev/ui), baseBranch origin/main to match ci.yml. Drops the `fixed` group, `@sandbox/*` ignore, and the peer-dependent experimental flag — all of which only apply once the packages/sandbox exist. - knip.config.ts — drop the now-redundant `build` ignoreBinaries (it resolves to a real script now); keep the scaffold-templates ignore. - .oxfmtrc.json — also ignore **/dist/** build output. - CONTRIBUTING.md — apply a pending oxfmt table-alignment fix so format:check is green. All seven ci.yml jobs (build, test:ci, typecheck, lint, format, knip, changeset status) pass locally. --- .changeset/config.json | 6 +- .oxfmtrc.json | 2 +- CONTRIBUTING.md | 16 +- knip.config.ts | 18 +- package.json | 9 +- pnpm-lock.yaml | 793 +++++++++++++++++++++++++++++++++++++++++ tsconfig.json | 8 +- tsdown.config.ts | 28 ++ 8 files changed, 847 insertions(+), 33 deletions(-) create mode 100644 tsdown.config.ts diff --git a/.changeset/config.json b/.changeset/config.json index fce1c26..87d24a6 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -1,11 +1,11 @@ { - "$schema": "https://unpkg.com/@changesets/config@3.0.0/schema.json", - "changelog": "@changesets/cli/changelog", + "$schema": "https://unpkg.com/@changesets/config@3.1.4/schema.json", + "changelog": ["@changesets/changelog-github", { "repo": "dunky-dev/ui" }], "commit": false, "fixed": [], "linked": [], "access": "public", - "baseBranch": "main", + "baseBranch": "origin/main", "updateInternalDependencies": "patch", "ignore": [] } diff --git a/.oxfmtrc.json b/.oxfmtrc.json index 126ca0e..c6c4abb 100644 --- a/.oxfmtrc.json +++ b/.oxfmtrc.json @@ -9,5 +9,5 @@ "semi": false, "singleQuote": true, "trailingComma": "all", - "ignorePatterns": ["scripts/templates/**"] + "ignorePatterns": ["**/dist/**", "scripts/templates/**"] } diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c648608..e562d4d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -15,14 +15,14 @@ pnpm install ## Commands -| Command | What it does | -| ------------------------------- | ---------------------------------------------------------- | -| `pnpm test` | Full test suite, watch mode | -| `pnpm typecheck` | `tsc --noEmit` across the whole workspace | -| `pnpm lint` | `oxlint` | -| `pnpm format` / `format:check` | `oxfmt` | -| `pnpm knip` | Unused exports/dependencies | -| `pnpm changeset` | Add a changeset — see [Versioning](./AGENTS.md#versioning) | +| Command | What it does | +| ------------------------------ | ---------------------------------------------------------- | +| `pnpm test` | Full test suite, watch mode | +| `pnpm typecheck` | `tsc --noEmit` across the whole workspace | +| `pnpm lint` | `oxlint` | +| `pnpm format` / `format:check` | `oxfmt` | +| `pnpm knip` | Unused exports/dependencies | +| `pnpm changeset` | Add a changeset — see [Versioning](./AGENTS.md#versioning) | Target one package or one file instead of the whole workspace: diff --git a/knip.config.ts b/knip.config.ts index 46a42b4..8288595 100644 --- a/knip.config.ts +++ b/knip.config.ts @@ -10,21 +10,9 @@ const config: KnipConfig = { // out by scripts/scaffold.ts. ignore: ['scripts/templates/**'], - workspaces: { - '.': { - // pnpm script names knip mistakes for binaries when it parses - // `pnpm -C