@preact-mvc/cli 0.0.0-stage → 0.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,23 @@
1
1
  {
2
2
  "name": "@preact-mvc/cli",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.0.0",
4
+ "description": "Project scaffolding and maintenance CLI for preact-mvc: new, generate, serve/build/test, add, update",
5
+ "type": "module",
6
+ "bin": {
7
+ "pmvc": "./src/cli.mjs",
8
+ "preact-mvc": "./src/cli.mjs"
9
+ },
10
+ "exports": {
11
+ "./package.json": "./package.json"
12
+ },
13
+ "files": [
14
+ "src"
15
+ ],
16
+ "publishConfig": {
17
+ "access": "public"
18
+ },
19
+ "engines": {
20
+ "node": ">=18.17"
21
+ },
22
+ "license": "MIT"
23
+ }
package/src/cli.mjs ADDED
@@ -0,0 +1,124 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * pmvc — the preact-mvc CLI. Angular-CLI-shaped: new / generate /
4
+ * serve / build / test / add / update / version / docs.
5
+ *
6
+ * Dependency-free on purpose: the CLI must run before a project has
7
+ * node_modules, and it ships runnable .mjs (other @preact-mvc packages
8
+ * ship ./src/*.ts for bundler resolution — a bin needs JS).
9
+ */
10
+ import { execSync } from 'node:child_process';
11
+ import { args, cliVersion, findProjectRoot, frameworkDeps, log } from './lib.mjs';
12
+ import { cmdNew } from './commands/new.mjs';
13
+ import { cmdGenerate } from './commands/generate.mjs';
14
+ import { cmdBuild, cmdServe, cmdTest } from './commands/run.mjs';
15
+ import { cmdAdd, cmdUpdate } from './commands/update.mjs';
16
+ const HELP = `pmvc — the preact-mvc CLI
17
+
18
+ Usage: pmvc <command> [args] [flags]
19
+
20
+ Project creation
21
+ new <dir> Scaffold a new application
22
+ --islands include a worker-island demo (contract + worker + route)
23
+ --tailwind wire Tailwind v4 (deps + vite plugin + styles)
24
+ --skip-install don't npm install
25
+ --skip-tests don't emit the sample test
26
+ -d, --dry-run print what would be created
27
+ --force overwrite existing files
28
+
29
+ Generators
30
+ g|generate <kind> <name> Scaffold into an existing project (run inside one)
31
+ view|component src/app/<name>/<name>.view.tsx (+ .test.tsx)
32
+ controller <name>.controller.ts — @Controller view logic
33
+ service <name>.service.ts — @Injectable model/service
34
+ module <name>.module.ts — feature module
35
+ island islands/<name>.contract.ts + workers/<name>.worker.tsx
36
+ --path <dir> override the target directory
37
+ --skip-tests / -d / --force
38
+
39
+ Build & dev (delegates to the project's vite/vitest)
40
+ serve|dev [vite args] Dev server
41
+ build [vite args] tsc --noEmit + vite build (--no-typecheck to skip)
42
+ test [vitest args] vitest run (--watch for watch mode)
43
+
44
+ Maintenance
45
+ add <recipe> Install + wire an integration: tailwind | islands
46
+ update Bump @preact-mvc/* deps to latest (or --to <ver>)
47
+ --include-atoll also bump @atolljs/* deps
48
+ -d / --skip-install
49
+
50
+ Other
51
+ version|v|(--version|-v) CLI + detected project versions
52
+ docs [--open] Where the docs live
53
+ help|(--help|-h) This message
54
+ `;
55
+
56
+ function cmdVersion() {
57
+ log(`@preact-mvc/cli ${cliVersion()}`);
58
+ const found = findProjectRoot();
59
+ if (found) {
60
+ const deps = { ...found.pkg.dependencies, ...found.pkg.devDependencies };
61
+ for (const dep of frameworkDeps(found.pkg)) {
62
+ log(` ${dep}: ${deps[dep]}`);
63
+ }
64
+ if (deps['@atolljs/core']) log(` @atolljs/core: ${deps['@atolljs/core']}`);
65
+ if (deps.preact) log(` preact: ${deps.preact}`);
66
+ if (deps.vite) log(` vite: ${deps.vite}`);
67
+ }
68
+ }
69
+
70
+ function cmdDocs(a) {
71
+ const url = 'https://github.com/jwhenry3/preact-mvc';
72
+ if (a.open) {
73
+ try {
74
+ const opener =
75
+ process.platform === 'win32'
76
+ ? `start "" "${url}"`
77
+ : process.platform === 'darwin'
78
+ ? `open "${url}"`
79
+ : `xdg-open "${url}"`;
80
+ execSync(opener, { stdio: 'ignore', shell: true });
81
+ return;
82
+ } catch {
83
+ // fall through to printing
84
+ }
85
+ }
86
+ log(`Docs site lives in this repo (docs-consumer/) — sources of truth:`);
87
+ log(` repo: ${url}`);
88
+ log(` local: npm run dev:docs (in the framework checkout)`);
89
+ }
90
+
91
+ const COMMANDS = {
92
+ new: cmdNew,
93
+ g: cmdGenerate,
94
+ generate: cmdGenerate,
95
+ add: cmdAdd,
96
+ update: cmdUpdate,
97
+ docs: cmdDocs,
98
+ };
99
+
100
+ // serve/build/test pass ALL args through to vite/vitest — raw argv, not
101
+ // parsed flags (a `--port 3000` belongs to vite, not pmvc).
102
+ const RAW_COMMANDS = { serve: cmdServe, dev: cmdServe, build: cmdBuild, test: cmdTest };
103
+
104
+ const argv = process.argv.slice(2);
105
+ const cmd = argv[0];
106
+
107
+ if (!cmd || cmd === 'help' || cmd === '--help' || cmd === '-h') {
108
+ process.stdout.write(HELP);
109
+ } else if (cmd === 'version' || cmd === 'v' || cmd === '--version' || cmd === '-v') {
110
+ cmdVersion();
111
+ } else if (RAW_COMMANDS[cmd]) {
112
+ RAW_COMMANDS[cmd](argv.slice(1));
113
+ } else if (COMMANDS[cmd]) {
114
+ const a = args(argv.slice(1), { d: 'dry-run', h: 'help' });
115
+ if (a.help) {
116
+ process.stdout.write(HELP);
117
+ } else {
118
+ COMMANDS[cmd](a);
119
+ }
120
+ } else {
121
+ console.error(`Unknown command "${cmd}".\n`);
122
+ process.stdout.write(HELP);
123
+ process.exit(1);
124
+ }
@@ -0,0 +1,120 @@
1
+ /**
2
+ * `pmvc generate|g <schematic> <name>` — file generators, the `ng g`
3
+ * equivalent. Schematics: view|component, controller, service, module,
4
+ * island. Flags: --path <dir>, --skip-tests, -d/--dry-run, --force.
5
+ */
6
+ import { existsSync } from 'node:fs';
7
+ import { join } from 'node:path';
8
+ import {
9
+ featureName,
10
+ findProjectRoot,
11
+ render,
12
+ toCamel,
13
+ toPascal,
14
+ writeFile,
15
+ fail,
16
+ warn,
17
+ log,
18
+ } from '../lib.mjs';
19
+ import * as T from '../templates.mjs';
20
+
21
+ /** words → sentence case for <h1>s: 'user-card' → 'User card' */
22
+ function title(kebab) {
23
+ const s = kebab.replace(/-/g, ' ');
24
+ return s[0].toUpperCase() + s.slice(1);
25
+ }
26
+
27
+ function tokensFor(name, dirName) {
28
+ const KEBAB = name;
29
+ const CAMEL = toCamel(name);
30
+ const CLASS = toPascal(name);
31
+ return { KEBAB, CAMEL, CLASS, TITLE: title(dirName ?? name) };
32
+ }
33
+
34
+ const SCHEMATICS = {
35
+ view: (root, name, a) => {
36
+ const dir = a.path ?? `src/app/${name}`;
37
+ const tk = tokensFor(name);
38
+ writeFile(root, `${dir}/${name}.view.tsx`, render(T.G_VIEW, tk), a);
39
+ if (!a['skip-tests']) {
40
+ writeFile(root, `${dir}/${name}.view.test.tsx`, render(T.G_VIEW_TEST, tk), a);
41
+ }
42
+ log(`\nMount it: add a route in src/app/app.routes.ts, or declare it in a module's \`components\`.`);
43
+ },
44
+
45
+ component: (root, name, a) => SCHEMATICS.view(root, name, a),
46
+
47
+ controller: (root, name, a) => {
48
+ const dir = a.path ?? `src/app/${name}`;
49
+ const tk = tokensFor(name);
50
+ writeFile(root, `${dir}/${name}.controller.ts`, render(T.G_CONTROLLER, tk), a);
51
+ log(`\nUse it: @Component({ controllers: [${tk.CLASS}Controller] }) or inject(${tk.CLASS}Controller) (transient).`);
52
+ },
53
+
54
+ service: (root, name, a) => {
55
+ const dir = a.path ?? `src/app/${name}`;
56
+ const tk = tokensFor(name);
57
+ writeFile(root, `${dir}/${name}.service.ts`, render(T.G_SERVICE, tk), a);
58
+ log(`\nInject it: inject(${tk.CLASS}Service) inside a view or controller constructor/field.`);
59
+ },
60
+
61
+ module: (root, name, a) => {
62
+ const dir = a.path ?? `src/app/${name}`;
63
+ const tk = tokensFor(name);
64
+ writeFile(root, `${dir}/${name}.module.ts`, render(T.G_MODULE, tk), a);
65
+ log(`\nRegister it: imports: [${tk.CLASS}Module] on the root module, or { path, module: ${tk.CLASS}Module } on a route.`);
66
+ },
67
+
68
+ island: (root, name, a) => {
69
+ const tk = tokensFor(name);
70
+ const contract = `src/islands/${name}.contract.ts`;
71
+ const worker = `src/workers/${name}.worker.tsx`;
72
+ writeFile(root, contract, render(T.T_ISLAND_CONTRACT, tk), a);
73
+ writeFile(root, worker, render(T.T_ISLAND_WORKER, tk), a);
74
+ log(`
75
+ Mount it from any view:
76
+
77
+ import { islandComponent } from '@preact-mvc/atoll-island';
78
+ import { ${tk.CAMEL}Contract } from '../../islands/${name}.contract';
79
+
80
+ const ${tk.CLASS}Island = islandComponent(${tk.CAMEL}Contract);
81
+ // <${tk.CLASS}Island onEvent={(n, p) => …} />`);
82
+ },
83
+ };
84
+
85
+ export function cmdGenerate(a) {
86
+ const schematic = a._[0];
87
+ const rawName = a._[1];
88
+ if (!schematic || !rawName) {
89
+ fail(
90
+ 'usage: pmvc generate <view|controller|service|module|island> <name>\n' +
91
+ ` schematics: ${Object.keys(SCHEMATICS).join(', ')}`,
92
+ );
93
+ }
94
+ const gen = SCHEMATICS[schematic];
95
+ if (!gen) {
96
+ fail(`unknown schematic "${schematic}" — expected one of: ${Object.keys(SCHEMATICS).join(', ')}`);
97
+ }
98
+
99
+ const found = findProjectRoot();
100
+ if (!found) fail('not inside a preact-mvc project (no @preact-mvc/core dependency found)');
101
+ const name = featureName(rawName);
102
+
103
+ if (schematic === 'island') {
104
+ const deps = { ...found.pkg.dependencies, ...found.pkg.devDependencies };
105
+ if (!deps['@preact-mvc/atoll-island']) {
106
+ warn(
107
+ '@preact-mvc/atoll-island is not a dependency — run `pmvc add islands` first.',
108
+ );
109
+ }
110
+ }
111
+
112
+ log('');
113
+ gen(found.root, name, {
114
+ dry: !!a['dry-run'],
115
+ force: !!a.force,
116
+ 'skip-tests': !!a['skip-tests'],
117
+ path: a.path,
118
+ });
119
+ log('');
120
+ }
@@ -0,0 +1,140 @@
1
+ /**
2
+ * `pmvc new <dir>` — scaffold a preact-mvc application, the `ng new`
3
+ * equivalent. Flags: --islands, --tailwind, --skip-install,
4
+ * --skip-tests, -d/--dry-run, --force (overwrite existing files).
5
+ */
6
+ import { resolve } from 'node:path';
7
+ import {
8
+ VERSIONS,
9
+ frameworkRange,
10
+ render,
11
+ run,
12
+ toKebab,
13
+ writeFile,
14
+ fail,
15
+ log,
16
+ } from '../lib.mjs';
17
+ import * as T from '../templates.mjs';
18
+
19
+ export function cmdNew(argv) {
20
+ const a = argv; // pre-parsed by cli.mjs
21
+ const dir = a._[0];
22
+ if (!dir) fail('usage: pmvc new <directory> [--islands] [--tailwind] [--skip-install]');
23
+ const name = toKebab(dir.split(/[\\/]/).pop());
24
+ const root = resolve(dir);
25
+ const opts = {
26
+ dry: !!a['dry-run'],
27
+ force: !!a.force,
28
+ };
29
+ const islands = !!a.islands;
30
+ const tailwind = !!a.tailwind;
31
+ const skipTests = !!a['skip-tests'];
32
+
33
+ const fw = frameworkRange();
34
+ const dependencies = {
35
+ '@preact-mvc/core': fw,
36
+ preact: VERSIONS.preact,
37
+ '@preact/signals': VERSIONS['@preact/signals'],
38
+ };
39
+ const devDependencies = {
40
+ '@preact/preset-vite': VERSIONS['@preact/preset-vite'],
41
+ '@testing-library/preact': VERSIONS['@testing-library/preact'],
42
+ '@types/node': VERSIONS['@types/node'],
43
+ jsdom: VERSIONS.jsdom,
44
+ typescript: VERSIONS.typescript,
45
+ vite: VERSIONS.vite,
46
+ vitest: VERSIONS.vitest,
47
+ };
48
+ if (islands) {
49
+ dependencies['@preact-mvc/atoll'] = fw;
50
+ dependencies['@preact-mvc/atoll-island'] = fw;
51
+ dependencies['@atolljs/core'] = VERSIONS['@atolljs/core'];
52
+ }
53
+ if (tailwind) {
54
+ devDependencies.tailwindcss = VERSIONS.tailwindcss;
55
+ devDependencies['@tailwindcss/vite'] = VERSIONS['@tailwindcss/vite'];
56
+ }
57
+
58
+ const packageJson = JSON.stringify(
59
+ {
60
+ name,
61
+ version: '0.1.0',
62
+ private: true,
63
+ type: 'module',
64
+ scripts: {
65
+ dev: 'vite',
66
+ build: 'tsc --noEmit && vite build',
67
+ preview: 'vite preview',
68
+ test: 'vitest run',
69
+ 'test:watch': 'vitest',
70
+ typecheck: 'tsc --noEmit',
71
+ },
72
+ dependencies,
73
+ devDependencies,
74
+ },
75
+ null,
76
+ 2,
77
+ );
78
+
79
+ const tokens = {
80
+ NAME: name,
81
+ TITLE: name,
82
+ TAILWIND_IMPORT: tailwind ? "import tailwindcss from '@tailwindcss/vite';\n" : '',
83
+ TAILWIND_PLUGIN: tailwind ? ', tailwindcss()' : '',
84
+ ISLANDS_IMPORT: islands
85
+ ? "import { TickerDemoView } from './island-demo/island-demo.view';\n"
86
+ : '',
87
+ ISLANDS_ROUTE: islands ? " { path: '/island', component: TickerDemoView },\n" : '',
88
+ ISLANDS_NAV: islands ? " { to: '/island', match: '/island', label: 'Island' },\n" : '',
89
+ KEBAB: 'ticker',
90
+ CAMEL: 'ticker',
91
+ CLASS: 'Ticker',
92
+ };
93
+
94
+ const files = {
95
+ 'package.json': packageJson + '\n',
96
+ 'tsconfig.json': T.T_TSCONFIG,
97
+ 'vite.config.ts': render(T.T_VITE_CONFIG, tokens),
98
+ 'index.html': render(T.T_INDEX_HTML, tokens),
99
+ '.gitignore': T.T_GITIGNORE,
100
+ '.npmrc': T.T_NPMRC,
101
+ 'README.md': render(T.T_README, tokens),
102
+ 'src/main.tsx': T.T_MAIN,
103
+ 'src/styles.css': tailwind ? T.T_STYLES_TAILWIND : T.T_STYLES,
104
+ 'src/test-setup.ts': T.T_TEST_SETUP,
105
+ 'src/app/app.module.ts': render(T.T_APP_MODULE, tokens),
106
+ 'src/app/app.shell.tsx': render(T.T_APP_SHELL, tokens),
107
+ 'src/app/app.routes.ts': render(T.T_APP_ROUTES, tokens),
108
+ 'src/app/core/tokens.ts': T.T_TOKENS,
109
+ 'src/app/hello/hello.view.tsx': T.T_HELLO_VIEW,
110
+ 'src/app/counter/counter.view.tsx': T.T_COUNTER_VIEW,
111
+ };
112
+ if (!skipTests) files['src/app/hello/hello.view.test.tsx'] = T.T_HELLO_TEST;
113
+ if (islands) {
114
+ files['src/islands/ticker.contract.ts'] = render(T.T_ISLAND_CONTRACT, tokens);
115
+ files['src/workers/ticker.worker.tsx'] = render(T.T_ISLAND_WORKER, tokens);
116
+ files['src/app/island-demo/island-demo.view.tsx'] = render(
117
+ T.T_ISLAND_DEMO_VIEW,
118
+ tokens,
119
+ );
120
+ }
121
+
122
+ log(`\nScaffolding a preact-mvc app in ${root}\n`);
123
+ for (const [rel, contents] of Object.entries(files)) {
124
+ writeFile(root, rel, contents, opts);
125
+ }
126
+
127
+ if (!a['skip-install']) {
128
+ log('\nInstalling dependencies…');
129
+ const code = run('npm', ['install'], { cwd: root, dry: opts.dry });
130
+ if (code !== 0) fail('npm install failed — run it manually in the new directory.');
131
+ }
132
+
133
+ log(`
134
+ Done. Next:
135
+
136
+ cd ${dir}
137
+ ${a['skip-install'] ? 'npm install\n ' : ''}pmvc serve # start the dev server
138
+ pmvc g view my-page # scaffold a new view
139
+ `);
140
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * `pmvc serve|build|test` — delegate to the project's vite/vitest, the
3
+ * `ng serve` / `ng build` / `ng test` equivalents. All args pass through
4
+ * to the underlying tool except the few pmvc owns (--watch for test,
5
+ * --no-typecheck for build).
6
+ */
7
+ import { existsSync } from 'node:fs';
8
+ import { join } from 'node:path';
9
+ import { findProjectRoot, readJson, run, fail } from '../lib.mjs';
10
+
11
+ function projectRoot() {
12
+ const found = findProjectRoot();
13
+ if (!found) fail('not inside a preact-mvc project (no @preact-mvc/core dependency found)');
14
+ return found.root;
15
+ }
16
+
17
+ /**
18
+ * Run a devDependency's bin via `node <bin>` — resolves through the
19
+ * package's own manifest, so it works cross-platform without npx/shell
20
+ * shims and never falls back to a registry download.
21
+ */
22
+ function execBin(pkg, argv, cwd) {
23
+ const pkgJson = join(cwd, 'node_modules', pkg, 'package.json');
24
+ if (!existsSync(pkgJson)) {
25
+ fail(`${pkg} is not installed — run \`npm install\` (or check package.json devDependencies)`);
26
+ }
27
+ const manifest = readJson(pkgJson);
28
+ const bin =
29
+ typeof manifest.bin === 'string'
30
+ ? manifest.bin
31
+ : (manifest.bin?.[pkg] ?? manifest.bin?.[Object.keys(manifest.bin ?? {})[0]]);
32
+ if (!bin) fail(`${pkg} declares no executable bin`);
33
+ return run('node', [join(cwd, 'node_modules', pkg, bin), ...argv], { cwd });
34
+ }
35
+
36
+ /** Strip pmvc-owned flags from a passthrough argv; returns {rest, had}. */
37
+ function take(argv, flag) {
38
+ const i = argv.indexOf(flag);
39
+ return { rest: argv.filter((_, j) => j !== i), had: i !== -1 };
40
+ }
41
+
42
+ export function cmdServe(argv) {
43
+ process.exitCode = execBin('vite', argv, projectRoot());
44
+ }
45
+
46
+ export function cmdBuild(argv) {
47
+ const root = projectRoot();
48
+ const { rest, had: noTypecheck } = take(argv, '--no-typecheck');
49
+ // Mirror the repo convention: typecheck is part of the build.
50
+ if (!noTypecheck) {
51
+ const code = execBin('typescript', ['--noEmit'], root);
52
+ if (code !== 0) {
53
+ process.exitCode = code;
54
+ return;
55
+ }
56
+ }
57
+ process.exitCode = execBin('vite', ['build', ...rest], root);
58
+ }
59
+
60
+ export function cmdTest(argv) {
61
+ const root = projectRoot();
62
+ const { rest, had: watch } = take(argv, '--watch');
63
+ process.exitCode = execBin('vitest', [...(watch ? [] : ['run']), ...rest], root);
64
+ }
@@ -0,0 +1,159 @@
1
+ /**
2
+ * `pmvc update` — bump @preact-mvc/* dependencies, the `ng update`
3
+ * equivalent. Flags: --to <version>, --include-atoll, -d/--dry-run,
4
+ * --skip-install.
5
+ *
6
+ * `pmvc add <recipe>` — install + wire an integration, the `ng add`
7
+ * equivalent. Recipes: tailwind, islands.
8
+ */
9
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
10
+ import { join } from 'node:path';
11
+ import {
12
+ VERSIONS,
13
+ frameworkDeps,
14
+ frameworkRange,
15
+ findProjectRoot,
16
+ npmViewVersion,
17
+ run,
18
+ writeFile,
19
+ fail,
20
+ warn,
21
+ log,
22
+ } from '../lib.mjs';
23
+ import * as T from '../templates.mjs';
24
+
25
+ function project() {
26
+ const found = findProjectRoot();
27
+ if (!found) fail('not inside a preact-mvc project (no @preact-mvc/core dependency found)');
28
+ return found;
29
+ }
30
+
31
+ /* ── update ───────────────────────────────────────────────────────── */
32
+
33
+ // Future migrations: { to: 'x.y.z', run(root, pkg) {} }. When a release
34
+ // needs a code mod, append an entry here — `pmvc update` applies every
35
+ // migration whose `to` is newer than the dep's current range.
36
+ const MIGRATIONS = [];
37
+
38
+ export function cmdUpdate(a) {
39
+ const { root, pkg } = project();
40
+ const dry = !!a['dry-run'];
41
+ const targets = new Set(frameworkDeps(pkg));
42
+ if (a['include-atoll']) {
43
+ for (const d of Object.keys({ ...pkg.dependencies, ...pkg.devDependencies })) {
44
+ if (d.startsWith('@atolljs/')) targets.add(d);
45
+ }
46
+ }
47
+ if (targets.size === 0) fail('no @preact-mvc/* dependencies in this project');
48
+
49
+ const to = a.to === true ? null : a.to; // `--to v` or nothing
50
+ const changes = [];
51
+ for (const dep of targets) {
52
+ const next = to ?? npmViewVersion(dep) ?? 'latest';
53
+ for (const field of ['dependencies', 'devDependencies']) {
54
+ const cur = pkg[field]?.[dep];
55
+ if (!cur) continue;
56
+ // Semver-ish targets get a caret; dist-tags like 'latest' pass bare.
57
+ const range = /^[\d^~>=]/.test(next) ? `^${next.replace(/^[\^~]/, '')}` : next;
58
+ if (cur !== range) changes.push({ field, dep, cur, range });
59
+ }
60
+ }
61
+
62
+ if (changes.length === 0) {
63
+ log('All @preact-mvc dependencies are already at the target version.');
64
+ return;
65
+ }
66
+ log('');
67
+ for (const c of changes) log(` ${c.dep}: ${c.cur} → ${c.range}`);
68
+ log('');
69
+
70
+ if (!dry) {
71
+ for (const c of changes) pkg[c.field][c.dep] = c.range;
72
+ writeFileSync(join(root, 'package.json'), JSON.stringify(pkg, null, 2) + '\n');
73
+ }
74
+
75
+ if (MIGRATIONS.length) {
76
+ for (const m of MIGRATIONS) {
77
+ log(` migration → ${m.to}`);
78
+ if (!dry) m.run(root, pkg);
79
+ }
80
+ }
81
+
82
+ if (!a['skip-install']) {
83
+ const code = run('npm', ['install'], { cwd: root, dry });
84
+ if (code !== 0) fail('npm install failed — resolve and re-run, or use --skip-install.');
85
+ }
86
+ log('Update complete.');
87
+ }
88
+
89
+ /* ── add ──────────────────────────────────────────────────────────── */
90
+
91
+ function installDeps(root, deps, { dev = true, dry = false } = {}) {
92
+ const args = ['install', ...(dev ? ['-D'] : [])];
93
+ for (const [name, range] of Object.entries(deps)) args.push(`${name}@${range}`);
94
+ const code = run('npm', args, { cwd: root, dry });
95
+ if (code !== 0) fail('npm install failed');
96
+ }
97
+
98
+ /** Insert the tailwind plugin into a generated-shape vite.config. */
99
+ function patchViteConfig(root, dry) {
100
+ const path = join(root, 'vite.config.ts');
101
+ if (!existsSync(path)) {
102
+ warn('no vite.config.ts — add `tailwindcss()` to your plugins manually');
103
+ return;
104
+ }
105
+ let src = readFileSync(path, 'utf8');
106
+ if (src.includes('@tailwindcss/vite')) {
107
+ log(' SKIP vite.config.ts (tailwindcss already configured)');
108
+ return;
109
+ }
110
+ const next = src
111
+ .replace(
112
+ /(import preact from '@preact\/preset-vite';)/,
113
+ `$1\nimport tailwindcss from '@tailwindcss/vite';`,
114
+ )
115
+ .replace(/plugins:\s*\[preact\(\)/, 'plugins: [preact(), tailwindcss()');
116
+ if (next === src) {
117
+ warn('vite.config.ts has an unrecognized shape — add `tailwindcss()` to plugins manually');
118
+ return;
119
+ }
120
+ log(` UPDATE vite.config.ts`);
121
+ if (!dry) writeFileSync(path, next);
122
+ }
123
+
124
+ const RECIPES = {
125
+ tailwind(root, a) {
126
+ installDeps(root, {
127
+ tailwindcss: VERSIONS.tailwindcss,
128
+ '@tailwindcss/vite': VERSIONS['@tailwindcss/vite'],
129
+ }, { dev: true, dry: a['dry-run'] });
130
+ patchViteConfig(root, a['dry-run']);
131
+ writeFile(root, 'src/styles.css', T.T_STYLES_TAILWIND, {
132
+ dry: a['dry-run'],
133
+ force: false,
134
+ });
135
+ log('\nTailwind v4 is wired. Utility classes are now available in views.');
136
+ },
137
+
138
+ islands(root, a) {
139
+ const fw = frameworkRange();
140
+ installDeps(root, {
141
+ '@preact-mvc/atoll': fw,
142
+ '@preact-mvc/atoll-island': fw,
143
+ '@atolljs/core': VERSIONS['@atolljs/core'],
144
+ }, { dev: false, dry: a['dry-run'] });
145
+ log('\nWorker islands installed. Scaffold your first one:');
146
+ log(' pmvc g island ticker');
147
+ },
148
+ };
149
+
150
+ export function cmdAdd(a) {
151
+ const recipe = a._[0];
152
+ if (!recipe || !RECIPES[recipe]) {
153
+ fail(`usage: pmvc add <${Object.keys(RECIPES).join('|')}>`);
154
+ }
155
+ const { root } = project();
156
+ log('');
157
+ RECIPES[recipe](root, a);
158
+ log('');
159
+ }
package/src/lib.mjs ADDED
@@ -0,0 +1,223 @@
1
+ /**
2
+ * Shared helpers: naming transforms, file writing with dry-run logging,
3
+ * package.json IO, project-root discovery, version maps, process spawn.
4
+ */
5
+ import { spawnSync } from 'node:child_process';
6
+ import {
7
+ existsSync,
8
+ mkdirSync,
9
+ readFileSync,
10
+ writeFileSync,
11
+ } from 'node:fs';
12
+ import { dirname, join, resolve, sep } from 'node:path';
13
+ import { fileURLToPath } from 'node:url';
14
+
15
+ export const CLI_ROOT = dirname(fileURLToPath(import.meta.url));
16
+
17
+ /* ── Versions ───────────────────────────────────────────────────────
18
+ * External dep ranges for generated projects. @preact-mvc/* ranges come
19
+ * from the CLI's own version (the published package releases in
20
+ * lockstep) — 0.0.0 means "unreleased checkout", so fall back to
21
+ * `latest` for local/linked runs.
22
+ */
23
+ export const VERSIONS = {
24
+ preact: '^10.24.0',
25
+ '@preact/signals': '^1.3.0',
26
+ '@preact/preset-vite': '^2.9.0',
27
+ '@atolljs/core': '0.1.11',
28
+ typescript: '~5.5.0',
29
+ vite: '^6.4.3',
30
+ vitest: '^3.2.4',
31
+ jsdom: '^25.0.0',
32
+ '@types/node': '^24.19.2',
33
+ '@testing-library/preact': '^3.2.4',
34
+ tailwindcss: '^4.3.3',
35
+ '@tailwindcss/vite': '^4.3.3',
36
+ };
37
+
38
+ export function cliVersion() {
39
+ const pkg = JSON.parse(
40
+ readFileSync(join(CLI_ROOT, '..', 'package.json'), 'utf8'),
41
+ );
42
+ return pkg.version;
43
+ }
44
+
45
+ /** The range a generated project should use for @preact-mvc/* deps. */
46
+ export function frameworkRange() {
47
+ const v = cliVersion();
48
+ return v === '0.0.0' ? 'latest' : `^${v}`;
49
+ }
50
+
51
+ /* ── Naming ──────────────────────────────────────────────────────── */
52
+
53
+ /** `FooBar` / `fooBar` / `foo_bar` / `fooBar-baz` → `foo-bar` */
54
+ export function toKebab(name) {
55
+ return String(name)
56
+ .replace(/([a-z0-9])([A-Z])/g, '$1-$2')
57
+ .replace(/[_\s]+/g, '-')
58
+ .replace(/-+/g, '-')
59
+ .toLowerCase()
60
+ .replace(/^-|-$/g, '');
61
+ }
62
+
63
+ /** `foo-bar` → `FooBar` */
64
+ export function toPascal(name) {
65
+ return toKebab(name)
66
+ .split('-')
67
+ .filter(Boolean)
68
+ .map((w) => w[0].toUpperCase() + w.slice(1))
69
+ .join('');
70
+ }
71
+
72
+ /** `foo-bar` → `fooBar` */
73
+ export function toCamel(name) {
74
+ const p = toPascal(name);
75
+ return p ? p[0].toLowerCase() + p.slice(1) : p;
76
+ }
77
+
78
+ /* ── FS / logging ────────────────────────────────────────────────── */
79
+
80
+ export const log = (...a) => console.log(...a);
81
+ export const warn = (...a) => console.warn(`WARN`, ...a);
82
+ export const fail = (msg) => {
83
+ console.error(`Error: ${msg}`);
84
+ process.exit(1);
85
+ };
86
+
87
+ /**
88
+ * Write `contents` at `root/rel`, mkdir -p'ing parents. `--dry-run`
89
+ * prints instead. Existing files are skipped unless `force`; never
90
+ * silently clobber user code.
91
+ */
92
+ export function writeFile(root, rel, contents, { dry = false, force = false } = {}) {
93
+ const abs = join(root, rel);
94
+ if (existsSync(abs) && !force) {
95
+ const tag = dry ? 'SKIP (dry)' : 'SKIP';
96
+ log(` ${tag} ${rel} (already exists)`);
97
+ return false;
98
+ }
99
+ const tag = dry ? 'CREATE (dry)' : 'CREATE';
100
+ log(` ${tag} ${rel} (${Buffer.byteLength(contents)} bytes)`);
101
+ if (!dry) {
102
+ mkdirSync(dirname(abs), { recursive: true });
103
+ writeFileSync(abs, contents);
104
+ }
105
+ return true;
106
+ }
107
+
108
+ /** Render a %%TOKEN%% template. Tokens are uppercase-only. */
109
+ export function render(tpl, tokens) {
110
+ let out = tpl;
111
+ for (const [k, v] of Object.entries(tokens)) {
112
+ out = out.replaceAll(`%%${k}%%`, String(v));
113
+ }
114
+ const leftovers = out.match(/%%[A-Z_]+%%/g);
115
+ if (leftovers) fail(`unfilled template tokens: ${leftovers.join(', ')}`);
116
+ return out;
117
+ }
118
+
119
+ /* ── Project discovery ───────────────────────────────────────────── */
120
+
121
+ export function readJson(path) {
122
+ return JSON.parse(readFileSync(path, 'utf8'));
123
+ }
124
+
125
+ /** Walk up from `dir` to the nearest package.json depending on
126
+ * @preact-mvc/core — the project's root. Returns {root, pkg} or null. */
127
+ export function findProjectRoot(dir = process.cwd()) {
128
+ let cur = resolve(dir);
129
+ for (;;) {
130
+ const pkgPath = join(cur, 'package.json');
131
+ if (existsSync(pkgPath)) {
132
+ const pkg = readJson(pkgPath);
133
+ const deps = { ...pkg.dependencies, ...pkg.devDependencies };
134
+ if (deps['@preact-mvc/core']) return { root: cur, pkg };
135
+ }
136
+ const parent = dirname(cur);
137
+ if (parent === cur) return null;
138
+ cur = parent;
139
+ }
140
+ }
141
+
142
+ /** All @preact-mvc/* dep names declared in a package.json object. */
143
+ export function frameworkDeps(pkg) {
144
+ return Object.keys({ ...pkg.dependencies, ...pkg.devDependencies }).filter(
145
+ (d) => d.startsWith('@preact-mvc/'),
146
+ );
147
+ }
148
+
149
+ /* ── Process ─────────────────────────────────────────────────────── */
150
+
151
+ /** Spawn a command in the foreground. Joins into a single shell string
152
+ * so npm's .cmd shims resolve on Windows (args with spaces are quoted).
153
+ * Returns the exit code. */
154
+ export function run(cmd, args = [], { cwd = process.cwd(), dry = false } = {}) {
155
+ const shown = [cmd, ...args]
156
+ .map((a) => (/[\s"']/.test(a) ? `"${a.replace(/"/g, '\\"')}"` : a))
157
+ .join(' ');
158
+ if (dry) {
159
+ log(` RUN (dry) ${shown}`);
160
+ return 0;
161
+ }
162
+ const res = spawnSync(shown, { cwd, stdio: 'inherit', shell: true });
163
+ return res.status ?? 1;
164
+ }
165
+
166
+ /** `npm view <pkg> version` → string, or null on failure/offline. */
167
+ export function npmViewVersion(name) {
168
+ const res = spawnSync('npm', ['view', name, 'version'], {
169
+ encoding: 'utf8',
170
+ shell: true,
171
+ });
172
+ if (res.status !== 0) return null;
173
+ return res.stdout.trim() || null;
174
+ }
175
+
176
+ /* ── Arg parsing (tiny, dependency-free) ─────────────────────────── */
177
+
178
+ /**
179
+ * parseArgs-lite: returns { _: [positionals], flags }. Supports
180
+ * `--flag`, `--flag value`, `--flag=value`, `-x` (boolean shorts only),
181
+ * and `--no-flag` negation.
182
+ */
183
+ export function args(argv, flags = {}) {
184
+ const out = { _: [] };
185
+ const aliases = { ...flags };
186
+ for (let i = 0; i < argv.length; i++) {
187
+ const a = argv[i];
188
+ if (a === '--') {
189
+ out._.push(...argv.slice(i + 1));
190
+ break;
191
+ }
192
+ if (a.startsWith('--no-')) {
193
+ out[a.slice(5)] = false;
194
+ } else if (a.startsWith('--')) {
195
+ const eq = a.indexOf('=');
196
+ if (eq !== -1) {
197
+ out[a.slice(2, eq)] = a.slice(eq + 1);
198
+ } else if (argv[i + 1] !== undefined && !argv[i + 1].startsWith('-')) {
199
+ out[a.slice(2)] = argv[++i];
200
+ } else {
201
+ out[a.slice(2)] = true;
202
+ }
203
+ } else if (a.startsWith('-') && a.length > 1) {
204
+ const key = aliases[a.slice(1)] ?? a.slice(1);
205
+ out[key] = true;
206
+ } else {
207
+ out._.push(a);
208
+ }
209
+ }
210
+ for (const [short, long] of Object.entries(flags)) {
211
+ if (out[short] !== undefined && out[long] === undefined) out[long] = out[short];
212
+ }
213
+ return out;
214
+ }
215
+
216
+ /** Normalize to a kebab-cased feature name; rejects paths/empties. */
217
+ export function featureName(raw) {
218
+ const name = toKebab(raw ?? '');
219
+ if (!name || name.includes('..') || name.includes('/') || name.includes(sep)) {
220
+ fail(`"${raw}" is not a valid name — expected something like "user-card"`);
221
+ }
222
+ return name;
223
+ }
@@ -0,0 +1,523 @@
1
+ /**
2
+ * File templates for `pmvc new` and `pmvc generate`. Tokens use the
3
+ * %%UPPER_SNAKE%% form — safe inside JSX braces. Every template is
4
+ * plain text; render() substitutes tokens and fails on leftovers.
5
+ */
6
+
7
+ /* ── pmvc new ──────────────────────────────────────────────────────── */
8
+
9
+ export const T_TSCONFIG = `{
10
+ "compilerOptions": {
11
+ "target": "ES2020",
12
+ "module": "ESNext",
13
+ "moduleResolution": "bundler",
14
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
15
+ "jsx": "react-jsx",
16
+ "jsxImportSource": "preact",
17
+ "experimentalDecorators": true,
18
+ "useDefineForClassFields": false,
19
+ "strict": true,
20
+ "skipLibCheck": true,
21
+ "isolatedModules": true,
22
+ "noEmit": true,
23
+ "types": ["vite/client", "node"],
24
+ "paths": {
25
+ "@/*": ["./src/*"]
26
+ }
27
+ },
28
+ "include": ["src", "vite.config.ts"]
29
+ }
30
+ `;
31
+
32
+ export const T_VITE_CONFIG = `/// <reference types="vitest" />
33
+ import { fileURLToPath } from 'node:url';
34
+ import { defineConfig } from 'vite';
35
+ import preact from '@preact/preset-vite';
36
+ %%TAILWIND_IMPORT%%export default defineConfig({
37
+ plugins: [preact()%%TAILWIND_PLUGIN%%],
38
+ resolve: {
39
+ alias: [{ find: '@', replacement: fileURLToPath(new URL('./src', import.meta.url)) }],
40
+ },
41
+ test: {
42
+ environment: 'jsdom',
43
+ globals: true,
44
+ setupFiles: ['src/test-setup.ts'],
45
+ // @preact-mvc/* ship .ts sources, so vitest inlines them while plain
46
+ // JS deps externalize natively — splitting preact (and its
47
+ // jsx-runtime) into two module realms. The view interceptor patches
48
+ // one shared options object, so the whole preact graph must stay in
49
+ // the vite realm during tests.
50
+ server: {
51
+ deps: {
52
+ inline: [
53
+ /@preact-mvc[/]/,
54
+ 'preact',
55
+ 'preact/jsx-runtime',
56
+ '@preact/signals',
57
+ '@testing-library/preact',
58
+ 'preact-router',
59
+ ],
60
+ },
61
+ },
62
+ },
63
+ });
64
+ `;
65
+
66
+ export const T_INDEX_HTML = `<!doctype html>
67
+ <html lang="en">
68
+ <head>
69
+ <meta charset="UTF-8" />
70
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
71
+ <link rel="icon" href="data:," />
72
+ <title>%%TITLE%%</title>
73
+ </head>
74
+ <body>
75
+ <div id="app"></div>
76
+ <script type="module" src="/src/main.tsx"></script>
77
+ </body>
78
+ </html>
79
+ `;
80
+
81
+ export const T_GITIGNORE = `node_modules
82
+ dist
83
+ dist-ssr
84
+ *.local
85
+ .DS_Store
86
+ `;
87
+
88
+ // Consumer-friendly subset of the repo's installable-project policy:
89
+ // no min-release-age — it would ETARGET whenever a dep release is
90
+ // younger than the window, which is a bad first-run failure mode.
91
+ export const T_NPMRC = `ignore-scripts=true
92
+ save-exact=true
93
+ `;
94
+
95
+ export const T_README = `# %%NAME%%
96
+
97
+ A [preact-mvc](https://github.com/jwhenry3/preact-mvc) application —
98
+ Angular-style modules, DI, and MVC on class-based JSX views rendered by
99
+ Preact, with signal reactivity.
100
+
101
+ \`\`\`bash
102
+ pmvc serve # dev server (vite)
103
+ pmvc build # production build
104
+ pmvc test # vitest
105
+ pmvc g view my-page # scaffold a view
106
+ pmvc g module admin # scaffold a feature module
107
+ \`\`\`
108
+ `;
109
+
110
+ export const T_MAIN = `import { bootstrap } from '@preact-mvc/core';
111
+ import { AppModule } from './app/app.module';
112
+ import './styles.css';
113
+
114
+ bootstrap(AppModule, document.getElementById('app')!);
115
+ `;
116
+
117
+ export const T_STYLES = `body {
118
+ margin: 0;
119
+ font-family: ui-sans-serif, system-ui, sans-serif;
120
+ }
121
+
122
+ .nav-link {
123
+ opacity: 0.7;
124
+ color: inherit;
125
+ text-decoration: none;
126
+ }
127
+ .nav-link.active {
128
+ opacity: 1;
129
+ font-weight: 600;
130
+ }
131
+ `;
132
+
133
+ export const T_STYLES_TAILWIND = `@import "tailwindcss";
134
+
135
+ .nav-link {
136
+ opacity: 0.7;
137
+ color: inherit;
138
+ text-decoration: none;
139
+ }
140
+ .nav-link.active {
141
+ opacity: 1;
142
+ font-weight: 600;
143
+ }
144
+ `;
145
+
146
+ // jsdom test stubs — the same pattern the repo itself uses.
147
+ export const T_TEST_SETUP = `// Node 22+ exposes an experimental \`localStorage\` global that is
148
+ // undefined unless --localstorage-file is passed. It shadows jsdom's
149
+ // storage (window === globalThis in vitest's jsdom env), so redefine it
150
+ // with a minimal in-memory Storage implementation for tests.
151
+ class MemoryStorage implements Storage {
152
+ private map = new Map<string, string>();
153
+ get length() {
154
+ return this.map.size;
155
+ }
156
+ clear() {
157
+ this.map.clear();
158
+ }
159
+ getItem(key: string) {
160
+ return this.map.has(key) ? this.map.get(key)! : null;
161
+ }
162
+ key(index: number) {
163
+ return [...this.map.keys()][index] ?? null;
164
+ }
165
+ removeItem(key: string) {
166
+ this.map.delete(key);
167
+ }
168
+ setItem(key: string, value: string) {
169
+ this.map.set(key, String(value));
170
+ }
171
+ }
172
+
173
+ Object.defineProperty(globalThis, 'localStorage', {
174
+ value: new MemoryStorage(),
175
+ configurable: true,
176
+ });
177
+ Object.defineProperty(globalThis, 'sessionStorage', {
178
+ value: new MemoryStorage(),
179
+ configurable: true,
180
+ });
181
+
182
+ // jsdom has no matchMedia — a fixed "light" stub suffices for tests.
183
+ if (typeof globalThis.matchMedia !== 'function') {
184
+ Object.defineProperty(globalThis, 'matchMedia', {
185
+ value: (query: string): MediaQueryList =>
186
+ ({
187
+ matches: false,
188
+ media: query,
189
+ onchange: null,
190
+ addListener: () => {},
191
+ removeListener: () => {},
192
+ addEventListener: () => {},
193
+ removeEventListener: () => {},
194
+ dispatchEvent: () => false,
195
+ }) as MediaQueryList,
196
+ configurable: true,
197
+ });
198
+ }
199
+ `;
200
+
201
+ export const T_APP_MODULE = `import { Module, RouterModule } from '@preact-mvc/core';
202
+ import { AppShell } from './app.shell';
203
+ import { routes } from './app.routes';
204
+ import { APP_TITLE } from './core/tokens';
205
+
206
+ @Module({
207
+ imports: [RouterModule.forRoot(routes)],
208
+ providers: [{ provide: APP_TITLE, useValue: '%%TITLE%%' }],
209
+ bootstrap: [AppShell],
210
+ })
211
+ export class AppModule {}
212
+ `;
213
+
214
+ export const T_TOKENS = `import { InjectionToken } from '@preact-mvc/core';
215
+
216
+ export const APP_TITLE = new InjectionToken<string>('app title');
217
+ `;
218
+
219
+ export const T_APP_SHELL = `import { Component, View, inject, RouterOutlet, Link, Router } from '@preact-mvc/core';
220
+ import { APP_TITLE } from './core/tokens';
221
+
222
+ const NAV = [
223
+ { to: '/hello/world', match: '/hello', label: 'Hello' },
224
+ { to: '/counter', match: '/counter', label: 'Counter' },
225
+ %%ISLANDS_NAV%%];
226
+
227
+ @Component()
228
+ export class AppShell extends View {
229
+ private router = inject(Router);
230
+ private title = inject(APP_TITLE);
231
+
232
+ render() {
233
+ const url = this.router.url.value;
234
+ return (
235
+ <div style="min-height:100vh;display:flex;flex-direction:column">
236
+ <header style="display:flex;align-items:center;gap:1rem;padding:0 1.25rem;height:3.5rem;border-bottom:1px solid #e2e2e8">
237
+ <strong>{this.title}</strong>
238
+ <nav style="display:flex;gap:1rem">
239
+ {NAV.map((n) => (
240
+ <Link
241
+ to={n.to}
242
+ class={url.startsWith(n.match) ? 'nav-link active' : 'nav-link'}
243
+ >
244
+ {n.label}
245
+ </Link>
246
+ ))}
247
+ </nav>
248
+ </header>
249
+ <main style="flex:1;padding:1.5rem">
250
+ <RouterOutlet />
251
+ </main>
252
+ </div>
253
+ );
254
+ }
255
+ }
256
+ `;
257
+
258
+ export const T_APP_ROUTES = `import { h } from 'preact';
259
+ import type { Route } from '@preact-mvc/core';
260
+ import { HelloView } from './hello/hello.view';
261
+ import { CounterView } from './counter/counter.view';
262
+ %%ISLANDS_IMPORT%%
263
+ export const routes: Route[] = [
264
+ { path: '/', component: HelloView },
265
+ { path: '/hello/:name', component: HelloView },
266
+ { path: '/counter', component: CounterView },
267
+ %%ISLANDS_ROUTE%% { path: '*', component: () => h('p', null, 'Not found.') },
268
+ ];
269
+ `;
270
+
271
+ export const T_HELLO_VIEW = `import { Component, View, inject, computed, Router } from '@preact-mvc/core';
272
+
273
+ // Standalone view: declared by no module, mounts by route. Route params
274
+ // arrive on the injected Router's params signal.
275
+ @Component()
276
+ export class HelloView extends View {
277
+ private router = inject(Router);
278
+ private name = computed(() => this.router.params.value.name ?? 'world');
279
+
280
+ render() {
281
+ return (
282
+ <section>
283
+ <h1>Hello, {this.name.value}!</h1>
284
+ <p>
285
+ Try <code>/hello/ada</code> — the name is a <code>:name</code> route param.
286
+ </p>
287
+ </section>
288
+ );
289
+ }
290
+ }
291
+ `;
292
+
293
+ export const T_HELLO_TEST = `import { h } from 'preact';
294
+ import { render } from '@testing-library/preact';
295
+ import { describe, expect, it } from 'vitest';
296
+ import { Injector, InjectorContext, Router } from '@preact-mvc/core';
297
+ import { routes } from '../app.routes';
298
+ import { HelloView } from './hello.view';
299
+
300
+ // Views mount inside an injector scope — stand in for bootstrap() with a
301
+ // bare injector providing whatever the view injects (HelloView needs the
302
+ // Router for its :name param).
303
+ function mount(node: any, injector = new Injector()) {
304
+ return render(h(InjectorContext.Provider, { value: injector }, node));
305
+ }
306
+
307
+ describe('HelloView', () => {
308
+ it('renders a greeting', () => {
309
+ const inj = new Injector();
310
+ inj.provide({ provide: Router, useValue: new Router(routes) });
311
+ const { container } = mount(<HelloView />, inj);
312
+ expect(container.querySelector('h1')?.textContent).toContain('Hello');
313
+ });
314
+ });
315
+ `;
316
+
317
+ export const T_COUNTER_VIEW = `import { Component, View, signal, computed } from '@preact-mvc/core';
318
+ import type { OnInit } from '@preact-mvc/core';
319
+
320
+ @Component()
321
+ export class CounterView extends View implements OnInit {
322
+ count = signal(0);
323
+ doubled = computed(() => this.count.value * 2);
324
+
325
+ onInit() {}
326
+
327
+ render() {
328
+ return (
329
+ <section>
330
+ <h1>Counter</h1>
331
+ <div style="display:flex;align-items:center;gap:1rem">
332
+ <button onClick={() => (this.count.value -= 1)}>−</button>
333
+ <span style="font-size:1.5rem;font-variant-numeric:tabular-nums">
334
+ {this.count.value}
335
+ </span>
336
+ <button onClick={() => (this.count.value += 1)}>+</button>
337
+ </div>
338
+ <p>doubled: {this.doubled.value}</p>
339
+ </section>
340
+ );
341
+ }
342
+ }
343
+ `;
344
+
345
+ /* ── pmvc new --islands ────────────────────────────────────────────── */
346
+
347
+ export const T_ISLAND_CONTRACT = `/**
348
+ * %%CLASS%% island contract — the one module BOTH sides import. It names
349
+ * the registry app, declares the emit payload shape (validated
350
+ * worker-side), and carries the worker factory, so the shell mounts
351
+ * islandComponent(%%CAMEL%%Contract) with no worker plumbing.
352
+ *
353
+ * Never import the .worker.tsx entry here: defining a worker installs
354
+ * instance globals. The URL literal inside new Worker(...) stays
355
+ * bundler-detectable — do not hoist or compute it.
356
+ */
357
+ import { defineIslandContract, z } from '@preact-mvc/atoll-island';
358
+
359
+ export const %%CAMEL%%Contract = defineIslandContract({
360
+ app: '%%KEBAB%%',
361
+ events: {
362
+ 'worker-ping': z.object({ count: z.number() }),
363
+ },
364
+ worker: () =>
365
+ new Worker(new URL('../workers/%%KEBAB%%.worker.tsx', import.meta.url), {
366
+ type: 'module',
367
+ }),
368
+ });
369
+ `;
370
+
371
+ export const T_ISLAND_WORKER = `/**
372
+ * Worker entry — bundler-detectable (spawned with the literal
373
+ * new URL(..., import.meta.url) form in %%KEBAB%%.contract). Hosts the
374
+ * island apps below; everything here runs inside the worker against the
375
+ * proxy DOM, View classes and all.
376
+ */
377
+ import { Component, View, signal } from '@preact-mvc/core';
378
+ import {
379
+ defineViewPolyWorker,
380
+ viewIslandApp,
381
+ emit,
382
+ } from '@preact-mvc/atoll-island/worker';
383
+ import { %%CAMEL%%Contract } from '../islands/%%KEBAB%%.contract';
384
+
385
+ @Component()
386
+ export class %%CLASS%%Island extends View {
387
+ private count = signal(0);
388
+ private timer: ReturnType<typeof setInterval> | undefined;
389
+
390
+ onInit() {
391
+ // Out-of-task signal writes: each tick re-renders in the worker and
392
+ // pushes an op batch — the shell never coordinates it.
393
+ this.timer = setInterval(() => {
394
+ this.count.value++;
395
+ if (this.count.value % 10 === 0) emit('worker-ping', { count: this.count.value });
396
+ }, 1000);
397
+ }
398
+ onDestroy() {
399
+ clearInterval(this.timer);
400
+ }
401
+
402
+ render() {
403
+ return (
404
+ <div style="font-variant-numeric:tabular-nums">
405
+ <strong>%%KEBAB%% island</strong> — worker uptime: {this.count.value}s
406
+ </div>
407
+ );
408
+ }
409
+ }
410
+
411
+ export const worker = defineViewPolyWorker({
412
+ apps: { '%%KEBAB%%': viewIslandApp(%%CLASS%%Island, { contract: %%CAMEL%%Contract }) },
413
+ });
414
+ `;
415
+
416
+ export const T_ISLAND_DEMO_VIEW = `import { Component, View } from '@preact-mvc/core';
417
+ import { islandComponent } from '@preact-mvc/atoll-island';
418
+ import { %%CAMEL%%Contract } from '../../islands/%%KEBAB%%.contract';
419
+
420
+ // The contract carries the worker factory — no worker plumbing here.
421
+ const %%CLASS%%Island = islandComponent(%%CAMEL%%Contract);
422
+
423
+ @Component()
424
+ export class %%CLASS%%DemoView extends View {
425
+ render() {
426
+ return (
427
+ <section>
428
+ <h1>Worker island</h1>
429
+ <p>
430
+ This box renders inside a Web Worker and emits typed events back
431
+ to the shell.
432
+ </p>
433
+ <%%CLASS%%Island
434
+ onEvent={(name, payload) => console.log('island event', name, payload)}
435
+ />
436
+ </section>
437
+ );
438
+ }
439
+ }
440
+ `;
441
+
442
+ /* ── pmvc generate ─────────────────────────────────────────────────── */
443
+
444
+ export const G_VIEW = `import { Component, View, signal } from '@preact-mvc/core';
445
+ import type { OnInit, OnDestroy } from '@preact-mvc/core';
446
+
447
+ @Component()
448
+ export class %%CLASS%%View extends View implements OnInit, OnDestroy {
449
+ // Signals drive rendering — reads inside render() are auto-tracked.
450
+ private loading = signal(true);
451
+
452
+ onInit() {
453
+ this.loading.value = false;
454
+ }
455
+
456
+ onDestroy() {}
457
+
458
+ render() {
459
+ return (
460
+ <section>
461
+ <h1>%%TITLE%%</h1>
462
+ {this.loading.value ? <p>Loading…</p> : <p>Ready.</p>}
463
+ </section>
464
+ );
465
+ }
466
+ }
467
+ `;
468
+
469
+ export const G_VIEW_TEST = `import { h } from 'preact';
470
+ import { render } from '@testing-library/preact';
471
+ import { describe, expect, it } from 'vitest';
472
+ import { Injector, InjectorContext } from '@preact-mvc/core';
473
+ import { %%CLASS%%View } from './%%KEBAB%%.view';
474
+
475
+ // Views mount inside an injector scope — a bare Injector stands in for
476
+ // bootstrap(). provide() whatever the view injects.
477
+ function mount(node: any, injector = new Injector()) {
478
+ return render(h(InjectorContext.Provider, { value: injector }, node));
479
+ }
480
+
481
+ describe('%%CLASS%%View', () => {
482
+ it('renders', () => {
483
+ const { container } = mount(<%%CLASS%%View />);
484
+ expect(container.querySelector('h1')?.textContent).toBe('%%TITLE%%');
485
+ });
486
+ });
487
+ `;
488
+
489
+ export const G_CONTROLLER = `import { Controller } from '@preact-mvc/core';
490
+ import type { OnInit, OnDestroy } from '@preact-mvc/core';
491
+
492
+ // Injectable view logic. Declare it on a view (@Component({ controllers }))
493
+ // or module (@Module({ controllers })) to share one instance down that
494
+ // scope — injected undeclared, it is transient per injecting scope.
495
+ @Controller()
496
+ export class %%CLASS%%Controller implements OnInit, OnDestroy {
497
+ onInit() {}
498
+
499
+ onDestroy() {}
500
+ }
501
+ `;
502
+
503
+ export const G_SERVICE = `import { Injectable, inject, DestroyRef } from '@preact-mvc/core';
504
+
505
+ // Root-provided by default via @Injectable. inject() and scope-bound
506
+ // effects work in field initializers and the constructor.
507
+ @Injectable()
508
+ export class %%CLASS%%Service {
509
+ private destroyRef = inject(DestroyRef);
510
+ }
511
+ `;
512
+
513
+ export const G_MODULE = `import { Module } from '@preact-mvc/core';
514
+
515
+ // Declare views under \`components\` so ownership is enforced, and view
516
+ // logic under \`controllers\` to share one instance across this scope.
517
+ @Module({
518
+ providers: [],
519
+ components: [],
520
+ controllers: [],
521
+ })
522
+ export class %%CLASS%%Module {}
523
+ `;
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.