oasbox 0.1.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/LICENSE +21 -0
- package/README.md +54 -0
- package/package.json +25 -0
- package/src/cli.mjs +25 -0
- package/src/commands/dev.mjs +20 -0
- package/src/commands/generate.mjs +406 -0
- package/src/commands/test.mjs +20 -0
- package/src/commands/validate.mjs +30 -0
- package/src/lib/detect-pm.mjs +21 -0
- package/src/lib/emit.mjs +51 -0
- package/src/lib/project.mjs +18 -0
- package/src/lib/run.mjs +17 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Kyle Brodeur
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# oasbox
|
|
2
|
+
|
|
3
|
+
The in-project CLI for [Obsidian Arrow](https://github.com/kylebrodeur/obsidian-arrow-sandbox)
|
|
4
|
+
projects — a small, deterministic generator and validator for components, views,
|
|
5
|
+
and stories. Package-manager agnostic (pnpm / npm / bun / yarn) and installed as
|
|
6
|
+
a local devDependency by `create-obsidian-arrow`, so its behavior is pinned and
|
|
7
|
+
reproducible for humans and agents alike.
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
Scaffolds created with `create-obsidian-arrow` already include `oasbox` as a
|
|
12
|
+
devDependency. To add it manually:
|
|
13
|
+
|
|
14
|
+
```sh
|
|
15
|
+
pnpm add -D oasbox # or: npm i -D oasbox / bun add -d oasbox
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Commands
|
|
19
|
+
|
|
20
|
+
| Command | What it does |
|
|
21
|
+
|---|---|
|
|
22
|
+
| `oasbox generate component <Name> [--css]` | Scaffold a primitive → `src/components/<Name>/`. `Parent/Child` → view-scoped component under `src/views/<Parent>/`. |
|
|
23
|
+
| `oasbox generate view <Name> [--editor]` | Scaffold a full-pane view (+ story). `--editor` → `surface: "editor"` (readable line width). |
|
|
24
|
+
| `oasbox generate story <Name> [--kind view\|component]` | Story file only, for existing source. |
|
|
25
|
+
| `oasbox validate [--json]` | Run CSS-orphan / scope / import checks + typecheck. `--json` for machine-readable output. |
|
|
26
|
+
| `oasbox test` / `oasbox dev` | Pass-throughs to the project's package manager (auto-detected from the lockfile). |
|
|
27
|
+
|
|
28
|
+
Run via the local bin (`oasbox …`) or your package manager:
|
|
29
|
+
`pnpm oasbox …` · `npx oasbox …` · `bunx oasbox …`.
|
|
30
|
+
|
|
31
|
+
## What `generate` produces
|
|
32
|
+
|
|
33
|
+
```
|
|
34
|
+
oasbox generate view Notes oasbox generate component Badge --css
|
|
35
|
+
└─ src/views/Notes/ └─ src/components/Badge/
|
|
36
|
+
├─ Notes.ts (component) ├─ Badge.ts
|
|
37
|
+
├─ Notes.css └─ Badge.css
|
|
38
|
+
└─ state.ts └─ stories/components/Badge.stories.ts
|
|
39
|
+
└─ stories/views/Notes.stories.ts
|
|
40
|
+
(kind: "view", surface: "panel")
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
```mermaid
|
|
44
|
+
flowchart LR
|
|
45
|
+
M["command flags"] --> K{"kind ?"}
|
|
46
|
+
K -- "component" --> C["src/components/… + story"]
|
|
47
|
+
K -- "view" --> S{"--editor ?"}
|
|
48
|
+
S -- "no" --> P["view · surface: panel (full-bleed)"]
|
|
49
|
+
S -- "yes" --> E["view · surface: editor (oas-readable-width)"]
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## License
|
|
53
|
+
|
|
54
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "oasbox",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "In-project CLI for obsidian-arrow projects — generate, validate, test.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"oasbox": "src/cli.mjs"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"src",
|
|
11
|
+
"templates"
|
|
12
|
+
],
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"chalk": "^5.6.2",
|
|
15
|
+
"commander": "^12.1.0"
|
|
16
|
+
},
|
|
17
|
+
"engines": {
|
|
18
|
+
"node": ">=20"
|
|
19
|
+
},
|
|
20
|
+
"scripts": {
|
|
21
|
+
"test": "node --test test/**/*.test.mjs",
|
|
22
|
+
"check": "node --test test/**/*.test.mjs",
|
|
23
|
+
"ci": "node --test test/**/*.test.mjs"
|
|
24
|
+
}
|
|
25
|
+
}
|
package/src/cli.mjs
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { program } from "commander";
|
|
3
|
+
import { registerDev } from "./commands/dev.mjs";
|
|
4
|
+
import { registerGenerate } from "./commands/generate.mjs";
|
|
5
|
+
import { registerTest } from "./commands/test.mjs";
|
|
6
|
+
import { registerValidate } from "./commands/validate.mjs";
|
|
7
|
+
|
|
8
|
+
program
|
|
9
|
+
.name("oasbox")
|
|
10
|
+
.description("obsidian-arrow in-project CLI")
|
|
11
|
+
.option("--json", "machine-readable output")
|
|
12
|
+
.showHelpAfterError();
|
|
13
|
+
|
|
14
|
+
registerGenerate(program);
|
|
15
|
+
registerValidate(program);
|
|
16
|
+
registerTest(program);
|
|
17
|
+
registerDev(program);
|
|
18
|
+
|
|
19
|
+
// No command given → print help and exit 0.
|
|
20
|
+
if (process.argv.length <= 2) {
|
|
21
|
+
program.outputHelp();
|
|
22
|
+
process.exit(0);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
program.parseAsync(process.argv);
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { run } from "../lib/run.mjs";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Start the dev server.
|
|
5
|
+
* @param {{ runner?: function }} options
|
|
6
|
+
* @returns {number} exit status from the dev process
|
|
7
|
+
*/
|
|
8
|
+
export function dev({ runner = run } = {}) {
|
|
9
|
+
return runner("dev");
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function registerDev(program) {
|
|
13
|
+
program
|
|
14
|
+
.command("dev")
|
|
15
|
+
.description("start the dev server")
|
|
16
|
+
.action(() => {
|
|
17
|
+
const status = dev({ runner: run });
|
|
18
|
+
process.exit(status);
|
|
19
|
+
});
|
|
20
|
+
}
|
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { emit } from "../lib/emit.mjs";
|
|
4
|
+
import { resolveProjectRoot } from "../lib/project.mjs";
|
|
5
|
+
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// Helpers
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Write a file, refusing to overwrite an existing one.
|
|
12
|
+
* @param {string} absPath
|
|
13
|
+
* @param {string} content
|
|
14
|
+
*/
|
|
15
|
+
function writeNew(absPath, content) {
|
|
16
|
+
if (fs.existsSync(absPath)) {
|
|
17
|
+
throw new Error(`already exists: ${absPath}`);
|
|
18
|
+
}
|
|
19
|
+
fs.mkdirSync(path.dirname(absPath), { recursive: true });
|
|
20
|
+
fs.writeFileSync(absPath, content);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Convert a PascalCase or camelCase name to kebab-case.
|
|
25
|
+
* e.g. "MyComponent" → "my-component"
|
|
26
|
+
*/
|
|
27
|
+
function toKebab(name) {
|
|
28
|
+
return name.replace(/([A-Z])/g, (m, l, i) => (i ? "-" : "") + l.toLowerCase()).replace(/^-/, "");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// Component generator
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Generate a component scaffold.
|
|
37
|
+
*
|
|
38
|
+
* Supports view-scoped components via `Parent/Child` syntax:
|
|
39
|
+
* "Foo" → src/components/Foo/Foo.ts (always a folder)
|
|
40
|
+
* "ChatView/Composer" → src/views/ChatView/Composer.ts (flat by default)
|
|
41
|
+
* "ChatView/Widget --css" → src/views/ChatView/Widget/Widget.ts + .css
|
|
42
|
+
*
|
|
43
|
+
* @param {string} root - absolute project root
|
|
44
|
+
* @param {string} name - component name, optionally "ParentView/Child"
|
|
45
|
+
* @param {{ css?: boolean }} opts
|
|
46
|
+
* @returns {{ created: string[] }}
|
|
47
|
+
*/
|
|
48
|
+
export function generateComponent(root, name, opts = {}) {
|
|
49
|
+
const withCss = Boolean(opts.css);
|
|
50
|
+
|
|
51
|
+
// Detect view-scoped "Parent/Child" syntax
|
|
52
|
+
const slashIdx = name.indexOf("/");
|
|
53
|
+
if (slashIdx !== -1) {
|
|
54
|
+
return generateViewScopedComponent(root, name, withCss);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const srcDir = path.join(root, "src", "components", name);
|
|
58
|
+
const tsAbs = path.join(srcDir, `${name}.ts`);
|
|
59
|
+
const cssAbs = withCss ? path.join(srcDir, `${name}.css`) : null;
|
|
60
|
+
const storyAbs = path.join(root, "stories", "components", `${name}.stories.ts`);
|
|
61
|
+
|
|
62
|
+
// Check for existing files before writing any
|
|
63
|
+
if (fs.existsSync(tsAbs)) {
|
|
64
|
+
throw new Error(`already exists: ${tsAbs}`);
|
|
65
|
+
}
|
|
66
|
+
if (fs.existsSync(storyAbs)) {
|
|
67
|
+
throw new Error(`already exists: ${storyAbs}`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const cssImport = withCss ? `import "./${name}.css";\n` : "";
|
|
71
|
+
const tsContent = `${cssImport}import { html } from "@arrow-js/core";
|
|
72
|
+
import type { ArrowExpression } from "@arrow-js/core";
|
|
73
|
+
|
|
74
|
+
export function ${name}(): ArrowExpression {
|
|
75
|
+
\treturn html\`<div class="${toKebab(name)}">TODO</div>\`;
|
|
76
|
+
}
|
|
77
|
+
`;
|
|
78
|
+
|
|
79
|
+
const componentImport = `../../src/components/${name}/${name}`;
|
|
80
|
+
const storyContent = `import { ${name} } from "${componentImport}";
|
|
81
|
+
import { defineStories } from "../../tools/viewer/stories";
|
|
82
|
+
|
|
83
|
+
export default defineStories({
|
|
84
|
+
\tdescription: "TODO: describe ${name}.",
|
|
85
|
+
\tstatus: "draft",
|
|
86
|
+
\tvariants: {
|
|
87
|
+
\t\tdefault: () => ${name}(),
|
|
88
|
+
\t},
|
|
89
|
+
});
|
|
90
|
+
`;
|
|
91
|
+
|
|
92
|
+
const created = [];
|
|
93
|
+
|
|
94
|
+
writeNew(tsAbs, tsContent);
|
|
95
|
+
created.push(`src/components/${name}/${name}.ts`);
|
|
96
|
+
|
|
97
|
+
if (cssAbs) {
|
|
98
|
+
writeNew(cssAbs, `/* ${name} styles */\n`);
|
|
99
|
+
created.push(`src/components/${name}/${name}.css`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
writeNew(storyAbs, storyContent);
|
|
103
|
+
created.push(`stories/components/${name}.stories.ts`);
|
|
104
|
+
|
|
105
|
+
return { created };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Generate a view-scoped component scaffold.
|
|
110
|
+
* `fullName` is "ParentView/ComponentName".
|
|
111
|
+
*
|
|
112
|
+
* Flat by default (no folder); --css adds a folder with a CSS file.
|
|
113
|
+
* Story lands at stories/views/<Parent>/<Component>.stories.ts.
|
|
114
|
+
*
|
|
115
|
+
* @param {string} root
|
|
116
|
+
* @param {string} fullName - e.g. "ChatView/Composer"
|
|
117
|
+
* @param {boolean} withCss
|
|
118
|
+
* @returns {{ created: string[] }}
|
|
119
|
+
*/
|
|
120
|
+
function generateViewScopedComponent(root, fullName, withCss) {
|
|
121
|
+
const parts = fullName.split("/");
|
|
122
|
+
const componentName = parts[parts.length - 1];
|
|
123
|
+
const parentView = parts.slice(0, -1).join("/");
|
|
124
|
+
|
|
125
|
+
const viewDir = path.join(root, "src", "views", parentView);
|
|
126
|
+
|
|
127
|
+
// Flat (no folder) by default; folder only when --css
|
|
128
|
+
let srcDir;
|
|
129
|
+
let tsAbs;
|
|
130
|
+
let cssAbs;
|
|
131
|
+
|
|
132
|
+
if (withCss) {
|
|
133
|
+
srcDir = path.join(viewDir, componentName);
|
|
134
|
+
tsAbs = path.join(srcDir, `${componentName}.ts`);
|
|
135
|
+
cssAbs = path.join(srcDir, `${componentName}.css`);
|
|
136
|
+
} else {
|
|
137
|
+
srcDir = viewDir;
|
|
138
|
+
tsAbs = path.join(viewDir, `${componentName}.ts`);
|
|
139
|
+
cssAbs = null;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Story is at stories/views/<parentView>/<componentName>.stories.ts
|
|
143
|
+
// That's 3 levels deep (stories/views/Parent/), so prefix is ../../../
|
|
144
|
+
const storyAbs = path.join(root, "stories", "views", parentView, `${componentName}.stories.ts`);
|
|
145
|
+
|
|
146
|
+
if (fs.existsSync(tsAbs)) {
|
|
147
|
+
throw new Error(`already exists: ${tsAbs}`);
|
|
148
|
+
}
|
|
149
|
+
if (fs.existsSync(storyAbs)) {
|
|
150
|
+
throw new Error(`already exists: ${storyAbs}`);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const cssImport = withCss ? `import "./${componentName}.css";\n` : "";
|
|
154
|
+
const tsContent = `${cssImport}import { html } from "@arrow-js/core";
|
|
155
|
+
import type { ArrowExpression } from "@arrow-js/core";
|
|
156
|
+
|
|
157
|
+
export function ${componentName}(): ArrowExpression {
|
|
158
|
+
\treturn html\`<div class="${toKebab(componentName)}">TODO</div>\`;
|
|
159
|
+
}
|
|
160
|
+
`;
|
|
161
|
+
|
|
162
|
+
// stories/views/Parent/ is 3 dirs deep → ../../../ to reach root
|
|
163
|
+
const componentImport = `../../../src/views/${parentView}/${componentName}`;
|
|
164
|
+
const storyContent = `import { ${componentName} } from "${componentImport}";
|
|
165
|
+
import { defineStories } from "../../../tools/viewer/stories";
|
|
166
|
+
|
|
167
|
+
export default defineStories({
|
|
168
|
+
\tkind: "component",
|
|
169
|
+
\tdescription: "TODO: describe ${componentName}.",
|
|
170
|
+
\tstatus: "draft",
|
|
171
|
+
\tvariants: {
|
|
172
|
+
\t\tdefault: () => ${componentName}(),
|
|
173
|
+
\t},
|
|
174
|
+
});
|
|
175
|
+
`;
|
|
176
|
+
|
|
177
|
+
const created = [];
|
|
178
|
+
|
|
179
|
+
if (withCss) {
|
|
180
|
+
fs.mkdirSync(srcDir, { recursive: true });
|
|
181
|
+
} else {
|
|
182
|
+
fs.mkdirSync(viewDir, { recursive: true });
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
writeNew(tsAbs, tsContent);
|
|
186
|
+
if (withCss) {
|
|
187
|
+
created.push(`src/views/${parentView}/${componentName}/${componentName}.ts`);
|
|
188
|
+
} else {
|
|
189
|
+
created.push(`src/views/${parentView}/${componentName}.ts`);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (cssAbs) {
|
|
193
|
+
writeNew(cssAbs, `/* ${componentName} styles */\n`);
|
|
194
|
+
created.push(`src/views/${parentView}/${componentName}/${componentName}.css`);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
fs.mkdirSync(path.dirname(storyAbs), { recursive: true });
|
|
198
|
+
writeNew(storyAbs, storyContent);
|
|
199
|
+
created.push(`stories/views/${parentView}/${componentName}.stories.ts`);
|
|
200
|
+
|
|
201
|
+
return { created };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// ---------------------------------------------------------------------------
|
|
205
|
+
// View generator
|
|
206
|
+
// ---------------------------------------------------------------------------
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Generate a view scaffold.
|
|
210
|
+
*
|
|
211
|
+
* @param {string} root - absolute project root
|
|
212
|
+
* @param {string} name - view name (PascalCase)
|
|
213
|
+
* @param {{ editor?: boolean }} opts
|
|
214
|
+
* @returns {{ created: string[] }}
|
|
215
|
+
*/
|
|
216
|
+
export function generateView(root, name, opts = {}) {
|
|
217
|
+
const isEditor = Boolean(opts.editor);
|
|
218
|
+
|
|
219
|
+
const viewDir = path.join(root, "src", "views", name);
|
|
220
|
+
const tsAbs = path.join(viewDir, `${name}.ts`);
|
|
221
|
+
const cssAbs = path.join(viewDir, `${name}.css`);
|
|
222
|
+
const stateAbs = path.join(viewDir, "state.ts");
|
|
223
|
+
const storyAbs = path.join(root, "stories", "views", `${name}.stories.ts`);
|
|
224
|
+
|
|
225
|
+
// Check for existing files before writing any
|
|
226
|
+
if (fs.existsSync(tsAbs)) {
|
|
227
|
+
throw new Error(`already exists: ${tsAbs}`);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Settings-block stub indented for a given base tab depth
|
|
231
|
+
const settingsBlock = (baseTabs) => {
|
|
232
|
+
const p = "\t".repeat(baseTabs);
|
|
233
|
+
return [
|
|
234
|
+
`${p}<div class="oas-settings">`,
|
|
235
|
+
`${p}\t<div class="setting-item setting-item-heading">`,
|
|
236
|
+
`${p}\t\t<div class="setting-item-info">`,
|
|
237
|
+
`${p}\t\t\t<div class="setting-item-name">${name}</div>`,
|
|
238
|
+
`${p}\t\t</div>`,
|
|
239
|
+
`${p}\t</div>`,
|
|
240
|
+
`${p}</div>`,
|
|
241
|
+
].join("\n");
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
const inner = isEditor
|
|
245
|
+
? `\t\t<div class="oas-readable-width">\n${settingsBlock(3)}\n\t\t</div>`
|
|
246
|
+
: settingsBlock(2);
|
|
247
|
+
|
|
248
|
+
const tsContent = `import "./${name}.css";
|
|
249
|
+
import { component, html } from "@arrow-js/core";
|
|
250
|
+
import type { ArrowTemplate } from "@arrow-js/core";
|
|
251
|
+
|
|
252
|
+
export const ${name} = component((): ArrowTemplate => {
|
|
253
|
+
\treturn html\`
|
|
254
|
+
${inner}
|
|
255
|
+
\t\`;
|
|
256
|
+
});
|
|
257
|
+
`;
|
|
258
|
+
|
|
259
|
+
const cssContent = `/* ${name} layout and chrome */\n`;
|
|
260
|
+
|
|
261
|
+
const stateContent = `import { reactive } from "@arrow-js/core";
|
|
262
|
+
|
|
263
|
+
export const state = reactive({
|
|
264
|
+
\t// TODO: add view state
|
|
265
|
+
});
|
|
266
|
+
`;
|
|
267
|
+
|
|
268
|
+
const surfaceLine = isEditor ? '\tsurface: "editor",\n' : '\tsurface: "panel",\n';
|
|
269
|
+
const storyContent = `import { ${name} } from "../../src/views/${name}/${name}";
|
|
270
|
+
import { defineStories } from "../../tools/viewer/stories";
|
|
271
|
+
|
|
272
|
+
export default defineStories({
|
|
273
|
+
\tkind: "view",
|
|
274
|
+
${surfaceLine}\tdescription: "TODO: describe ${name}.",
|
|
275
|
+
\tstatus: "draft",
|
|
276
|
+
\tvariants: {
|
|
277
|
+
\t\tdefault: () => ${name}(),
|
|
278
|
+
\t},
|
|
279
|
+
});
|
|
280
|
+
`;
|
|
281
|
+
|
|
282
|
+
const created = [];
|
|
283
|
+
|
|
284
|
+
writeNew(tsAbs, tsContent);
|
|
285
|
+
created.push(`src/views/${name}/${name}.ts`);
|
|
286
|
+
|
|
287
|
+
writeNew(cssAbs, cssContent);
|
|
288
|
+
created.push(`src/views/${name}/${name}.css`);
|
|
289
|
+
|
|
290
|
+
writeNew(stateAbs, stateContent);
|
|
291
|
+
created.push(`src/views/${name}/state.ts`);
|
|
292
|
+
|
|
293
|
+
writeNew(storyAbs, storyContent);
|
|
294
|
+
created.push(`stories/views/${name}.stories.ts`);
|
|
295
|
+
|
|
296
|
+
return { created };
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// ---------------------------------------------------------------------------
|
|
300
|
+
// Story generator
|
|
301
|
+
// ---------------------------------------------------------------------------
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Generate a story file only (for an existing component/view).
|
|
305
|
+
*
|
|
306
|
+
* @param {string} root - absolute project root
|
|
307
|
+
* @param {string} name - component/view name (PascalCase)
|
|
308
|
+
* @param {{ kind?: "view"|"component" }} opts
|
|
309
|
+
* @returns {{ created: string[] }}
|
|
310
|
+
*/
|
|
311
|
+
export function generateStory(root, name, opts = {}) {
|
|
312
|
+
const kind = opts.kind === "view" ? "view" : "component";
|
|
313
|
+
|
|
314
|
+
let storyAbs;
|
|
315
|
+
let componentImport;
|
|
316
|
+
let kindLine;
|
|
317
|
+
let surfaceLine;
|
|
318
|
+
|
|
319
|
+
if (kind === "view") {
|
|
320
|
+
storyAbs = path.join(root, "stories", "views", `${name}.stories.ts`);
|
|
321
|
+
componentImport = `../../src/views/${name}/${name}`;
|
|
322
|
+
kindLine = '\tkind: "view",\n';
|
|
323
|
+
surfaceLine = '\tsurface: "panel",\n';
|
|
324
|
+
} else {
|
|
325
|
+
storyAbs = path.join(root, "stories", "components", `${name}.stories.ts`);
|
|
326
|
+
componentImport = `../../src/components/${name}/${name}`;
|
|
327
|
+
kindLine = "";
|
|
328
|
+
surfaceLine = "";
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
if (fs.existsSync(storyAbs)) {
|
|
332
|
+
throw new Error(`already exists: ${storyAbs}`);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const storyContent = `import { ${name} } from "${componentImport}";
|
|
336
|
+
import { defineStories } from "../../tools/viewer/stories";
|
|
337
|
+
|
|
338
|
+
export default defineStories({
|
|
339
|
+
${kindLine}${surfaceLine}\tdescription: "TODO: describe ${name}.",
|
|
340
|
+
\tstatus: "draft",
|
|
341
|
+
\tvariants: {
|
|
342
|
+
\t\tdefault: () => ${name}(),
|
|
343
|
+
\t},
|
|
344
|
+
});
|
|
345
|
+
`;
|
|
346
|
+
|
|
347
|
+
writeNew(storyAbs, storyContent);
|
|
348
|
+
|
|
349
|
+
const relPath =
|
|
350
|
+
kind === "view" ? `stories/views/${name}.stories.ts` : `stories/components/${name}.stories.ts`;
|
|
351
|
+
|
|
352
|
+
return { created: [relPath] };
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// ---------------------------------------------------------------------------
|
|
356
|
+
// CLI registration
|
|
357
|
+
// ---------------------------------------------------------------------------
|
|
358
|
+
|
|
359
|
+
export function registerGenerate(program) {
|
|
360
|
+
const g = program.command("generate").description("scaffold components/views/stories");
|
|
361
|
+
|
|
362
|
+
g.command("component <name>")
|
|
363
|
+
.description("generate a component scaffold")
|
|
364
|
+
.option("--css", "add a CSS file")
|
|
365
|
+
.action((name, opts, cmd) => {
|
|
366
|
+
const json = cmd.optsWithGlobals().json;
|
|
367
|
+
try {
|
|
368
|
+
const root = resolveProjectRoot();
|
|
369
|
+
const result = generateComponent(root, name, opts);
|
|
370
|
+
emit(result, { json });
|
|
371
|
+
} catch (err) {
|
|
372
|
+
emit({ errors: [err.message] }, { json });
|
|
373
|
+
process.exit(1);
|
|
374
|
+
}
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
g.command("view <name>")
|
|
378
|
+
.description("generate a view scaffold")
|
|
379
|
+
.option("--editor", "wrap body in oas-readable-width for editor surface")
|
|
380
|
+
.action((name, opts, cmd) => {
|
|
381
|
+
const json = cmd.optsWithGlobals().json;
|
|
382
|
+
try {
|
|
383
|
+
const root = resolveProjectRoot();
|
|
384
|
+
const result = generateView(root, name, opts);
|
|
385
|
+
emit(result, { json });
|
|
386
|
+
} catch (err) {
|
|
387
|
+
emit({ errors: [err.message] }, { json });
|
|
388
|
+
process.exit(1);
|
|
389
|
+
}
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
g.command("story <name>")
|
|
393
|
+
.description("generate a story file for an existing component or view")
|
|
394
|
+
.option("--kind <kind>", "story kind: view or component (default: component)")
|
|
395
|
+
.action((name, opts, cmd) => {
|
|
396
|
+
const json = cmd.optsWithGlobals().json;
|
|
397
|
+
try {
|
|
398
|
+
const root = resolveProjectRoot();
|
|
399
|
+
const result = generateStory(root, name, opts);
|
|
400
|
+
emit(result, { json });
|
|
401
|
+
} catch (err) {
|
|
402
|
+
emit({ errors: [err.message] }, { json });
|
|
403
|
+
process.exit(1);
|
|
404
|
+
}
|
|
405
|
+
});
|
|
406
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { run } from "../lib/run.mjs";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Run the project's test script.
|
|
5
|
+
* @param {{ runner?: function }} options
|
|
6
|
+
* @returns {number} exit status from the test runner
|
|
7
|
+
*/
|
|
8
|
+
export function test({ runner = run } = {}) {
|
|
9
|
+
return runner("test");
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function registerTest(program) {
|
|
13
|
+
program
|
|
14
|
+
.command("test")
|
|
15
|
+
.description("run the project test suite")
|
|
16
|
+
.action(() => {
|
|
17
|
+
const status = test({ runner: run });
|
|
18
|
+
process.exit(status);
|
|
19
|
+
});
|
|
20
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { emit } from "../lib/emit.mjs";
|
|
2
|
+
import { run } from "../lib/run.mjs";
|
|
3
|
+
|
|
4
|
+
const CHECK_SCRIPTS = ["check:css", "check:scope", "check:imports", "typecheck"];
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Run all project check scripts and return a structured result.
|
|
8
|
+
* @param {{ runner?: function }} options
|
|
9
|
+
* @returns {{ ok: boolean, checks: { name: string, ok: boolean }[] }}
|
|
10
|
+
*/
|
|
11
|
+
export function validate({ runner = run } = {}) {
|
|
12
|
+
const checks = CHECK_SCRIPTS.map((name) => {
|
|
13
|
+
const status = runner(name);
|
|
14
|
+
return { name, ok: status === 0 };
|
|
15
|
+
});
|
|
16
|
+
const ok = checks.every((c) => c.ok);
|
|
17
|
+
return { ok, checks };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function registerValidate(program) {
|
|
21
|
+
program
|
|
22
|
+
.command("validate")
|
|
23
|
+
.description("run type-check, lint, and structural checks")
|
|
24
|
+
.action((_, cmd) => {
|
|
25
|
+
const { json } = cmd.optsWithGlobals();
|
|
26
|
+
const result = validate({ runner: run });
|
|
27
|
+
emit(result, { json });
|
|
28
|
+
if (!result.ok) process.exit(1);
|
|
29
|
+
});
|
|
30
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
const LOCK = [
|
|
5
|
+
["bun.lockb", "bun"],
|
|
6
|
+
["pnpm-lock.yaml", "pnpm"],
|
|
7
|
+
["yarn.lock", "yarn"],
|
|
8
|
+
["package-lock.json", "npm"],
|
|
9
|
+
];
|
|
10
|
+
|
|
11
|
+
export function detectPM(dir = process.cwd()) {
|
|
12
|
+
for (const [file, name] of LOCK) {
|
|
13
|
+
if (fs.existsSync(path.join(dir, file))) return { name, exec: runner(name) };
|
|
14
|
+
}
|
|
15
|
+
return { name: "npm", exec: runner("npm") };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function runner(name) {
|
|
19
|
+
if (name === "yarn") return ["yarn"];
|
|
20
|
+
return [name, "run"];
|
|
21
|
+
}
|
package/src/lib/emit.mjs
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Emit a result either as machine-readable JSON or a human summary.
|
|
5
|
+
*
|
|
6
|
+
* When `json` is true, writes `JSON.stringify(data) + "\n"` to stdout.
|
|
7
|
+
* Otherwise, writes a chalk-formatted human summary: green ✓ for created
|
|
8
|
+
* files / ok statuses, red for errors.
|
|
9
|
+
*/
|
|
10
|
+
export function emit(data, { json = false } = {}) {
|
|
11
|
+
if (json) {
|
|
12
|
+
process.stdout.write(`${JSON.stringify(data)}\n`);
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
process.stdout.write(`${humanize(data)}\n`);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function humanize(data) {
|
|
19
|
+
if (data == null) return chalk.green("✓ ok");
|
|
20
|
+
|
|
21
|
+
const lines = [];
|
|
22
|
+
|
|
23
|
+
if (Array.isArray(data.errors) && data.errors.length > 0) {
|
|
24
|
+
for (const err of data.errors) lines.push(chalk.red(`✗ ${format(err)}`));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (Array.isArray(data.created) && data.created.length > 0) {
|
|
28
|
+
for (const file of data.created) lines.push(chalk.green(`✓ ${format(file)}`));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (typeof data.message === "string") {
|
|
32
|
+
lines.push(chalk.green(`✓ ${data.message}`));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (lines.length === 0) {
|
|
36
|
+
// Fall back to a readable, non-JSON key/value dump.
|
|
37
|
+
if (typeof data === "object") {
|
|
38
|
+
for (const [key, value] of Object.entries(data)) {
|
|
39
|
+
lines.push(`${chalk.dim(key)}: ${format(value)}`);
|
|
40
|
+
}
|
|
41
|
+
} else {
|
|
42
|
+
lines.push(chalk.green(`✓ ${format(data)}`));
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return lines.join("\n") || chalk.green("✓ ok");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function format(value) {
|
|
50
|
+
return typeof value === "string" ? value : String(value);
|
|
51
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Walk up from `cwd` to the nearest ancestor directory containing a
|
|
6
|
+
* `package.json`. Throws if none is found before the filesystem root.
|
|
7
|
+
* @returns {string} absolute path to the project root directory.
|
|
8
|
+
*/
|
|
9
|
+
export function resolveProjectRoot(cwd = process.cwd()) {
|
|
10
|
+
let dir = path.resolve(cwd);
|
|
11
|
+
while (true) {
|
|
12
|
+
if (fs.existsSync(path.join(dir, "package.json"))) return dir;
|
|
13
|
+
const parent = path.dirname(dir);
|
|
14
|
+
if (parent === dir) break;
|
|
15
|
+
dir = parent;
|
|
16
|
+
}
|
|
17
|
+
throw new Error("not inside a project (no package.json found)");
|
|
18
|
+
}
|
package/src/lib/run.mjs
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { detectPM } from "./detect-pm.mjs";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Spawn `<pm> run <script> ...args` in cwd, forwarding stdio.
|
|
6
|
+
* @returns {number} the child process exit status (0 on success).
|
|
7
|
+
*/
|
|
8
|
+
export function run(script, args = [], { cwd = process.cwd() } = {}) {
|
|
9
|
+
const { exec } = detectPM(cwd);
|
|
10
|
+
const [command, ...prefixArgs] = exec;
|
|
11
|
+
const result = spawnSync(command, [...prefixArgs, script, ...args], {
|
|
12
|
+
cwd,
|
|
13
|
+
stdio: "inherit",
|
|
14
|
+
shell: false,
|
|
15
|
+
});
|
|
16
|
+
return result.status ?? 0;
|
|
17
|
+
}
|