@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 +21 -4
- package/src/cli.mjs +124 -0
- package/src/commands/generate.mjs +120 -0
- package/src/commands/new.mjs +140 -0
- package/src/commands/run.mjs +64 -0
- package/src/commands/update.mjs +159 -0
- package/src/lib.mjs +223 -0
- package/src/templates.mjs +523 -0
- package/README.md +0 -3
package/package.json
CHANGED
|
@@ -1,6 +1,23 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@preact-mvc/cli",
|
|
3
|
-
"version": "0.0.0
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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