create-obsidian-arrow 0.4.1 → 0.5.1
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 +1 -1
- package/template/AGENTS.md +52 -17
- package/template/README.md +12 -14
- package/template/_gitignore +3 -0
- package/template/docs/prompts/agent-setup.md +2 -0
- package/template/docs/workflow.md +13 -7
- package/template/package.json +9 -2
- package/template/pnpm-lock.yaml +3 -0
- package/template/porting.config.example.json +6 -0
- package/template/scripts/check-orphaned-css.mjs +62 -0
- package/template/scripts/check-scope-classes.mjs +77 -0
- package/template/scripts/check-view-imports.mjs +133 -0
- package/template/scripts/component-hash.mjs +12 -1
- package/template/scripts/create-component.mjs +101 -0
- package/template/scripts/create-view.mjs +75 -0
- package/template/scripts/port-css.mjs +118 -0
- package/template/src/components/EmptyState/EmptyState.css +30 -0
- package/template/src/components/EmptyState/EmptyState.ts +35 -0
- package/template/src/components/LoadingState.ts +12 -0
- package/template/src/components/icons.ts +17 -0
- package/template/src/utilities.css +101 -1
- package/template/stories/components/EmptyState.stories.ts +25 -0
- package/template/stories/components/LoadingState.stories.ts +11 -0
- package/template/test/viewer-derive.test.mjs +6 -0
- package/template/test/viewer-stories.test.mjs +26 -0
- package/template/tools/router/client.ts +14 -3
- package/template/tools/router/routeToPage.ts +14 -2
- package/template/tools/sandbox/sandbox.css +13 -0
- package/template/tools/viewer/StoryPage.ts +35 -16
- package/template/tools/viewer/discovery.ts +6 -0
- package/template/tools/viewer/stories.ts +16 -0
package/package.json
CHANGED
package/template/AGENTS.md
CHANGED
|
@@ -11,6 +11,7 @@ This file is the hub — everything else is linked from here:
|
|
|
11
11
|
- [`docs/workflow.md`](docs/workflow.md) — fresh-machine → running workflow.
|
|
12
12
|
- [`skills/`](skills/) — installable domain skills (`pnpm skills:install`):
|
|
13
13
|
- `obsidian-arrow-sandbox` — running the sandbox, CSS scoping, porting basics.
|
|
14
|
+
- `obsidian-arrow-composition` — **run before designing any view area**: surveys codebase, shows file trees for confirmation, asks questions, checks DRY/primitive use, produces a locked hierarchy doc.
|
|
14
15
|
- `obsidian-arrow-stories` — **component + story authoring workflow**: `defineStories` API, variants, children, status flag, DRY patterns, utilities.
|
|
15
16
|
- `obsidian-arrow-css` — **CSS decision hierarchy**: Obsidian classes → oas-* utilities → custom CSS; token reference; specificity scoping; overrides via variables; auditing for excess CSS.
|
|
16
17
|
- `arrow-js-obsidian-templates` — Arrow v1.0.6 template syntax + footguns.
|
|
@@ -84,19 +85,39 @@ in `boundary()`.
|
|
|
84
85
|
|
|
85
86
|
## Conventions
|
|
86
87
|
|
|
87
|
-
- **Check `/reference/classes` first** — Obsidian has semantic classes for most
|
|
88
|
-
|
|
89
|
-
- **
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
88
|
+
- **Check `/reference/classes` first** — Obsidian has semantic classes for most patterns.
|
|
89
|
+
- **Use `oas-*` utilities second** (`src/utilities.css`) — flex, gap, padding, etc.
|
|
90
|
+
- **Custom CSS last** — co-locate as `ComponentName.css`, import it from `ComponentName.ts`.
|
|
91
|
+
|
|
92
|
+
### Directory structure
|
|
93
|
+
|
|
94
|
+
- `src/views/<ViewName>/` — full-pane components mounted from `ItemView.onOpen()`. Named `*View.ts` or `*Panel.ts`. May have CSS, state.ts, and view-specific sub-components.
|
|
95
|
+
- `src/components/<ComponentName>/` — reusable primitives. Folder when multiple files needed; flat `.ts` when single file.
|
|
96
|
+
- **View-specific components** (only used by one view) live inside that view's folder.
|
|
97
|
+
- **Promotion rule:** if a file inside `src/views/<View>/` is imported by 2+ views, move it to `src/components/`.
|
|
98
|
+
- **Import boundary:** nothing inside `src/views/<View>/` is imported from outside that folder. Enforced by lint scripts:
|
|
99
|
+
- `pnpm check:imports` — catches cross-view boundary violations
|
|
100
|
+
- `pnpm check:css` — catches .css files with no importer (dead CSS)
|
|
101
|
+
- `pnpm check:scope` — catches CSS ancestor selectors never applied in templates
|
|
102
|
+
- **Shared icons:** import from `src/components/icons.ts` — do not declare local ICON_MAP copies.
|
|
103
|
+
|
|
104
|
+
### Stories
|
|
105
|
+
|
|
106
|
+
- `stories/views/` — stories for views (`kind: "view"`)
|
|
107
|
+
- `stories/components/` — stories for primitives (`kind: "component"`)
|
|
108
|
+
- Mirror the `src/` structure. `kind` auto-detected from path; set explicitly to override.
|
|
109
|
+
- `kind` — specifies story type (`"view"` or `"component"`).
|
|
110
|
+
- `decorator?: (content: ArrowExpression) => ArrowExpression` — wraps the rendered variant in ancestor context when a component requires a parent class to render correctly.
|
|
111
|
+
- Scaffold: `pnpm create:view <Name>` / `pnpm create:component <Name>`
|
|
112
|
+
|
|
113
|
+
### Composition analysis (extracting shared primitives)
|
|
114
|
+
|
|
115
|
+
When multiple views share a pattern, extract a primitive — but do it in this order:
|
|
116
|
+
|
|
117
|
+
1. **Survey all implementations first** — read every view before designing the primitive.
|
|
118
|
+
2. **Map shared shapes at the template level** — find the repeated HTML structure, not just the props.
|
|
119
|
+
3. **Let the primitive emerge from actual shared code** — don't design a generic API upfront.
|
|
120
|
+
4. **Extract and migrate simultaneously** — the primitive + its consumers must be shorter than the originals. If they aren't, the abstraction hasn't paid for itself.
|
|
100
121
|
|
|
101
122
|
## CSS scoping
|
|
102
123
|
|
|
@@ -114,12 +135,26 @@ in `boundary()`.
|
|
|
114
135
|
pnpm typecheck # tsc --noEmit
|
|
115
136
|
pnpm test # node:test
|
|
116
137
|
pnpm lint # biome
|
|
117
|
-
pnpm check # all of the above
|
|
138
|
+
pnpm check # all of the above + CSS orphan + import boundary + scope checks
|
|
118
139
|
```
|
|
119
140
|
|
|
120
|
-
Then confirm the actual render
|
|
121
|
-
|
|
122
|
-
|
|
141
|
+
Then confirm the actual render:
|
|
142
|
+
|
|
143
|
+
1. **Cold-load required.** Close and reopen the browser tab — do not just navigate
|
|
144
|
+
within the SPA. Vite's dev server keeps CSS module state across in-tab navigation,
|
|
145
|
+
which masks orphaned CSS and missing ancestor classes until a genuine cold load.
|
|
146
|
+
|
|
147
|
+
2. **Check a computed style.** For each CSS change, assert at least one computed property
|
|
148
|
+
(`display`, `flexDirection`, `padding`) in the browser console — element count and
|
|
149
|
+
console-clean alone do not prove styles are applied:
|
|
150
|
+
```js
|
|
151
|
+
getComputedStyle(document.querySelector('.my-class')).display // → "flex"
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
3. **Console clean.** No `[viewer]` warnings, no Arrow errors, no unhandled rejections.
|
|
155
|
+
|
|
156
|
+
Do not claim a component works on typecheck alone. Arrow's footguns and CSS adoption bugs
|
|
157
|
+
only surface at render time.
|
|
123
158
|
|
|
124
159
|
## Porting a component into the plugin
|
|
125
160
|
|
package/template/README.md
CHANGED
|
@@ -92,6 +92,7 @@ skills under [`skills/`](skills/) — it's a skill marketplace. Scaffolds **don'
|
|
|
92
92
|
vendor copies**; they pull from this published repo, so installs are always
|
|
93
93
|
current.
|
|
94
94
|
|
|
95
|
+
- `obsidian-arrow-composition` — **run before designing any view area**: surveys codebase, shows proposed file trees, asks targeted questions, checks DRY and primitive use, produces a locked hierarchy doc for the migration agent to execute.
|
|
95
96
|
- `obsidian-arrow-sandbox` — running and using this sandbox, CSS scoping, porting basics.
|
|
96
97
|
- `obsidian-arrow-stories` — **component + story authoring workflow**: the complete `defineStories` API (variants, children, status flag, notes), DRY patterns, utility classes, story viewing, and how to structure sub-components.
|
|
97
98
|
- `obsidian-arrow-css` — **CSS decision hierarchy**: Obsidian classes first, `oas-*` utilities second, custom CSS last; the live token reference (`/reference`), class catalog (`/reference/classes`), specificity scoping, overrides via CSS variables, and auditing components to minimize hand-written CSS.
|
|
@@ -142,25 +143,22 @@ swatches, size bars, a filter input, theme-aware resolved values, and a copy
|
|
|
142
143
|
button. `/reference/classes` is a curated catalog of Obsidian pattern classes
|
|
143
144
|
with live previews.
|
|
144
145
|
|
|
145
|
-
### Add a
|
|
146
|
+
### Add a view
|
|
146
147
|
|
|
147
|
-
|
|
148
|
-
|
|
148
|
+
```sh
|
|
149
|
+
pnpm create:view ChatView
|
|
150
|
+
```
|
|
149
151
|
|
|
150
|
-
|
|
151
|
-
import { defineStories } from "../tools/viewer/stories";
|
|
152
|
-
import { MyThing } from "../src/components/MyThing";
|
|
152
|
+
Creates `src/views/ChatView/` (ts + css + state.ts) and `stories/views/ChatView.stories.ts`.
|
|
153
153
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
154
|
+
### Add a component
|
|
155
|
+
|
|
156
|
+
```sh
|
|
157
|
+
pnpm create:component Composer # primitive → src/components/Composer/
|
|
158
|
+
pnpm create:component ChatView/Widget # view-specific → src/views/ChatView/Widget.ts
|
|
159
159
|
```
|
|
160
160
|
|
|
161
|
-
|
|
162
|
-
shown in the viewer is derived from the filename. Stories are sandbox-only —
|
|
163
|
-
they never port to the plugin.
|
|
161
|
+
View stories use `kind: "view"` — full pane frame. Component stories use `kind: "component"` — centered canvas. Both auto-detected from the `stories/views/` vs `stories/components/` path.
|
|
164
162
|
|
|
165
163
|
## Porting a component into the plugin
|
|
166
164
|
|
package/template/_gitignore
CHANGED
|
@@ -44,6 +44,8 @@ READ FIRST
|
|
|
44
44
|
- AGENTS.md (root) — operating guide + docs map (links everything below).
|
|
45
45
|
- docs/workflow.md — fresh-machine → running workflow.
|
|
46
46
|
- skills/*/SKILL.md — seven installable skills:
|
|
47
|
+
obsidian-arrow-composition design component hierarchy: surveys codebase, shows file trees,
|
|
48
|
+
asks questions, checks DRY/primitives, produces locked plan
|
|
47
49
|
obsidian-arrow-sandbox running the sandbox, CSS scoping, porting basics
|
|
48
50
|
obsidian-arrow-stories component + story authoring: defineStories API,
|
|
49
51
|
variants, children, status, DRY patterns, utilities
|
|
@@ -58,19 +58,25 @@ in `src/` — never `src/components/` or `stories/`):
|
|
|
58
58
|
```sh
|
|
59
59
|
# 1. Check /reference/classes in the running sandbox — does Obsidian have a class
|
|
60
60
|
# for your pattern? Use it before writing custom CSS.
|
|
61
|
-
# 2.
|
|
62
|
-
|
|
63
|
-
pnpm
|
|
61
|
+
# 2. Scaffold (or add manually):
|
|
62
|
+
pnpm create:view ChatView # full-pane view → src/views/ChatView/ + stories/views/
|
|
63
|
+
pnpm create:component Composer # reusable primitive → src/components/Composer/ + stories/components/
|
|
64
|
+
pnpm create:component ChatView/Widget # view-specific sub-component (not promoted yet)
|
|
65
|
+
# 3. Iterate with HMR
|
|
66
|
+
pnpm dev
|
|
64
67
|
# /components → index of all discovered stories
|
|
65
|
-
# /components/my-thing → story for MyThing
|
|
68
|
+
# /components/my-thing → story for MyThing (kind: "component" → centered canvas)
|
|
69
|
+
# /components/chat-view → story for ChatView (kind: "view" → full pane)
|
|
66
70
|
# /reference → live Obsidian token reference
|
|
67
71
|
# /reference/classes → curated class catalog with live previews
|
|
68
72
|
pnpm run ci # biome + typecheck + tests + build before trusting it
|
|
69
73
|
```
|
|
70
74
|
|
|
71
|
-
Always confirm the actual render in the browser — Arrow's footguns
|
|
72
|
-
at render
|
|
73
|
-
|
|
75
|
+
Always confirm the actual render in the browser — Arrow's footguns and CSS scoping bugs
|
|
76
|
+
surface only at render time. **Close and reopen the tab** before each verification step
|
|
77
|
+
(do not rely on Vite's hot-module cache, which keeps CSS registered across SPA
|
|
78
|
+
navigation). Assert at least one computed CSS property per component, not just "console
|
|
79
|
+
clean." See [`AGENTS.md`](../AGENTS.md) for the full verification checklist.
|
|
74
80
|
|
|
75
81
|
For the complete story authoring workflow (`defineStories` API, variants, children,
|
|
76
82
|
status flag, DRY patterns) see the `obsidian-arrow-stories` skill.
|
package/template/package.json
CHANGED
|
@@ -11,12 +11,18 @@
|
|
|
11
11
|
"preview": "vite preview",
|
|
12
12
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
13
13
|
"pull-css": "node scripts/pull-app-css.mjs",
|
|
14
|
+
"port:css": "node scripts/port-css.mjs",
|
|
14
15
|
"create:sync": "node create-obsidian-arrow/scripts/sync-template.mjs",
|
|
16
|
+
"create:component": "node scripts/create-component.mjs",
|
|
17
|
+
"create:view": "node scripts/create-view.mjs",
|
|
15
18
|
"lint": "biome check .",
|
|
16
19
|
"format": "biome check --write .",
|
|
17
20
|
"test": "node --experimental-strip-types --test test/*.test.mjs",
|
|
18
|
-
"check": "
|
|
19
|
-
"
|
|
21
|
+
"check:css": "node scripts/check-orphaned-css.mjs",
|
|
22
|
+
"check:scope": "node scripts/check-scope-classes.mjs",
|
|
23
|
+
"check:imports": "node scripts/check-view-imports.mjs",
|
|
24
|
+
"check": "biome check . && pnpm typecheck && pnpm test && pnpm check:css && pnpm check:scope && pnpm check:imports",
|
|
25
|
+
"ci": "biome ci . && pnpm typecheck && pnpm test && pnpm check:css && pnpm check:scope && pnpm check:imports && pnpm build",
|
|
20
26
|
"skills:install": "node scripts/install-skills.mjs --force",
|
|
21
27
|
"skills:update": "node scripts/install-skills.mjs --update",
|
|
22
28
|
"postinstall": "node scripts/install-skills.mjs",
|
|
@@ -38,6 +44,7 @@
|
|
|
38
44
|
"@types/node": "^22.16.5",
|
|
39
45
|
"husky": "^9.1.6",
|
|
40
46
|
"lint-staged": "^15.2.10",
|
|
47
|
+
"postcss": "^8.5.16",
|
|
41
48
|
"typescript": "^5.9.3",
|
|
42
49
|
"vite": "^8.0.0"
|
|
43
50
|
},
|
package/template/pnpm-lock.yaml
CHANGED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Finds .css files under src/ and tools/ that have no TypeScript importer.
|
|
4
|
+
* A .css file with no importer is dead CSS — safe to delete.
|
|
5
|
+
* Exit 1 on violations.
|
|
6
|
+
*/
|
|
7
|
+
import fs from "node:fs";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import { fileURLToPath } from "node:url";
|
|
10
|
+
|
|
11
|
+
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
12
|
+
const srcDir = path.join(root, "src");
|
|
13
|
+
const toolsDir = path.join(root, "tools");
|
|
14
|
+
|
|
15
|
+
function walk(dir, results = []) {
|
|
16
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
17
|
+
const full = path.join(dir, entry.name);
|
|
18
|
+
if (entry.isDirectory()) walk(full, results);
|
|
19
|
+
else results.push(full);
|
|
20
|
+
}
|
|
21
|
+
return results;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const allFiles = [];
|
|
25
|
+
if (fs.existsSync(srcDir)) {
|
|
26
|
+
allFiles.push(...walk(srcDir));
|
|
27
|
+
}
|
|
28
|
+
if (fs.existsSync(toolsDir)) {
|
|
29
|
+
allFiles.push(...walk(toolsDir));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const cssFiles = allFiles.filter((f) => f.endsWith(".css"));
|
|
33
|
+
const tsFiles = allFiles.filter((f) => f.endsWith(".ts") || f.endsWith(".tsx"));
|
|
34
|
+
|
|
35
|
+
// Build a set of all imported CSS paths (resolved)
|
|
36
|
+
const importedCss = new Set();
|
|
37
|
+
const importRe = /import\s+["']([^"']+\.css)["']/g;
|
|
38
|
+
|
|
39
|
+
for (const tsFile of tsFiles) {
|
|
40
|
+
const content = fs.readFileSync(tsFile, "utf8");
|
|
41
|
+
importRe.lastIndex = 0;
|
|
42
|
+
let m = importRe.exec(content);
|
|
43
|
+
while (m !== null) {
|
|
44
|
+
const resolved = path.resolve(path.dirname(tsFile), m[1]);
|
|
45
|
+
importedCss.add(resolved);
|
|
46
|
+
m = importRe.exec(content);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const orphaned = cssFiles.filter((f) => !importedCss.has(f));
|
|
51
|
+
|
|
52
|
+
if (orphaned.length === 0) {
|
|
53
|
+
console.log("check:css — no orphaned CSS files found ✓");
|
|
54
|
+
process.exit(0);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
console.error(`check:css — ${orphaned.length} orphaned CSS file(s) found:`);
|
|
58
|
+
for (const f of orphaned) {
|
|
59
|
+
console.error(` ${path.relative(root, f)}`);
|
|
60
|
+
}
|
|
61
|
+
console.error("Each .css file must be imported by a .ts file in the same directory.");
|
|
62
|
+
process.exit(1);
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Checks that every class used as an ancestor selector in CSS
|
|
4
|
+
* (e.g. ".my-shell .descendant" — the ".my-shell" part) also appears
|
|
5
|
+
* in at least one Arrow template in src/ or tools/.
|
|
6
|
+
*
|
|
7
|
+
* Catches "designed layer never wired up" bugs: CSS exists, is imported,
|
|
8
|
+
* but the ancestor class is never applied to a component root.
|
|
9
|
+
*
|
|
10
|
+
* Exit 1 on violations (ancestor classes with zero template occurrences).
|
|
11
|
+
*/
|
|
12
|
+
import fs from "node:fs";
|
|
13
|
+
import path from "node:path";
|
|
14
|
+
import { fileURLToPath } from "node:url";
|
|
15
|
+
|
|
16
|
+
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
17
|
+
const srcDir = path.join(root, "src");
|
|
18
|
+
const toolsDir = path.join(root, "tools");
|
|
19
|
+
|
|
20
|
+
function walk(dir, ext, results = []) {
|
|
21
|
+
if (!fs.existsSync(dir)) return results;
|
|
22
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
23
|
+
const full = path.join(dir, entry.name);
|
|
24
|
+
if (entry.isDirectory()) walk(full, ext, results);
|
|
25
|
+
else if (entry.name.endsWith(ext)) results.push(full);
|
|
26
|
+
}
|
|
27
|
+
return results;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Collect all ancestor selector classes from CSS files in src/ and tools/
|
|
31
|
+
// Pattern: .ancestor-class followed by whitespace and another selector
|
|
32
|
+
const ancestorRe = /\.([\w-]+)\s+[.#\[a-zA-Z]/g;
|
|
33
|
+
const cssFiles = [...walk(srcDir, ".css"), ...walk(toolsDir, ".css")];
|
|
34
|
+
const ancestorClasses = new Map(); // class → [cssFile, ...]
|
|
35
|
+
|
|
36
|
+
for (const cssFile of cssFiles) {
|
|
37
|
+
const content = fs.readFileSync(cssFile, "utf8");
|
|
38
|
+
ancestorRe.lastIndex = 0;
|
|
39
|
+
let m = ancestorRe.exec(content);
|
|
40
|
+
while (m !== null) {
|
|
41
|
+
const cls = m[1];
|
|
42
|
+
if (!ancestorClasses.has(cls)) ancestorClasses.set(cls, []);
|
|
43
|
+
ancestorClasses.get(cls).push(path.relative(root, cssFile));
|
|
44
|
+
m = ancestorRe.exec(content);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (ancestorClasses.size === 0) {
|
|
49
|
+
console.log("check:scope — no ancestor selectors found ✓");
|
|
50
|
+
process.exit(0);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Check each ancestor class appears in at least one .ts template in src/ or tools/
|
|
54
|
+
const tsFiles = [...walk(srcDir, ".ts"), ...walk(toolsDir, ".ts")];
|
|
55
|
+
const tsContent = tsFiles.map((f) => fs.readFileSync(f, "utf8")).join("\n");
|
|
56
|
+
|
|
57
|
+
let violations = 0;
|
|
58
|
+
for (const [cls, cssFilePaths] of ancestorClasses) {
|
|
59
|
+
// Check if the class string appears anywhere in templates
|
|
60
|
+
// (as a class="..." value or in a reactive class expression)
|
|
61
|
+
if (!tsContent.includes(cls)) {
|
|
62
|
+
console.error(
|
|
63
|
+
`VIOLATION: .${cls} used as ancestor selector in CSS but never applied in any template:`
|
|
64
|
+
);
|
|
65
|
+
for (const f of cssFilePaths) console.error(` defined in: ${f}`);
|
|
66
|
+
violations++;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (violations > 0) {
|
|
71
|
+
console.error(
|
|
72
|
+
`check:scope — ${violations} ancestor class(es) never applied in templates.\nAdd the class to a component root or remove the CSS rules.`
|
|
73
|
+
);
|
|
74
|
+
process.exit(1);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
console.log(`check:scope — all ${ancestorClasses.size} ancestor classes verified in templates ✓`);
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Two checks:
|
|
4
|
+
* 1. Cross-view boundary: files inside src/views/<View>/ must not be imported
|
|
5
|
+
* from outside src/views/<View>/. Exit 1 on violation.
|
|
6
|
+
* 2. Primitive promotion: files inside any src/views/<View>/ imported by 2+
|
|
7
|
+
* different view directories — suggest promotion to src/components/.
|
|
8
|
+
* Warning only, exit 0.
|
|
9
|
+
*/
|
|
10
|
+
import fs from "node:fs";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import { fileURLToPath } from "node:url";
|
|
13
|
+
|
|
14
|
+
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
15
|
+
const srcDir = path.join(root, "src");
|
|
16
|
+
const viewsDir = path.join(srcDir, "views");
|
|
17
|
+
const toolsDir = path.join(root, "tools");
|
|
18
|
+
|
|
19
|
+
function walk(dir, results = []) {
|
|
20
|
+
if (!fs.existsSync(dir)) return results;
|
|
21
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
22
|
+
const full = path.join(dir, entry.name);
|
|
23
|
+
if (entry.isDirectory()) walk(full, results);
|
|
24
|
+
else if (entry.name.endsWith(".ts") || entry.name.endsWith(".mjs")) results.push(full);
|
|
25
|
+
}
|
|
26
|
+
return results;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Collect all .ts and .mjs files from src/ and tools/
|
|
30
|
+
const allTs = [...walk(srcDir), ...walk(toolsDir)];
|
|
31
|
+
|
|
32
|
+
// Map: absolute path → set of importing files
|
|
33
|
+
const importedBy = new Map();
|
|
34
|
+
|
|
35
|
+
const importRe = /(?:import|from)\s+["']([^"']+)["']/g;
|
|
36
|
+
|
|
37
|
+
for (const file of allTs) {
|
|
38
|
+
const content = fs.readFileSync(file, "utf8");
|
|
39
|
+
importRe.lastIndex = 0;
|
|
40
|
+
let m = importRe.exec(content);
|
|
41
|
+
while (m !== null) {
|
|
42
|
+
const imp = m[1];
|
|
43
|
+
if (imp.startsWith(".")) {
|
|
44
|
+
const dir = path.dirname(file);
|
|
45
|
+
const resolved = path.resolve(dir, imp);
|
|
46
|
+
|
|
47
|
+
// Try with .ts extension if not already present
|
|
48
|
+
let targetPath = resolved;
|
|
49
|
+
if (!resolved.endsWith(".ts") && !resolved.endsWith(".mjs") && !fs.existsSync(resolved)) {
|
|
50
|
+
if (fs.existsSync(`${resolved}.ts`)) {
|
|
51
|
+
targetPath = `${resolved}.ts`;
|
|
52
|
+
} else if (fs.existsSync(`${resolved}.mjs`)) {
|
|
53
|
+
targetPath = `${resolved}.mjs`;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (!importedBy.has(targetPath)) importedBy.set(targetPath, new Set());
|
|
58
|
+
importedBy.get(targetPath).add(file);
|
|
59
|
+
}
|
|
60
|
+
m = importRe.exec(content);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Get all view directories
|
|
65
|
+
const viewDirs = fs.existsSync(viewsDir)
|
|
66
|
+
? fs
|
|
67
|
+
.readdirSync(viewsDir, { withFileTypes: true })
|
|
68
|
+
.filter((e) => e.isDirectory())
|
|
69
|
+
.map((e) => path.join(viewsDir, e.name))
|
|
70
|
+
: [];
|
|
71
|
+
|
|
72
|
+
if (viewDirs.length === 0) {
|
|
73
|
+
console.log("check:imports — no cross-view boundary violations ✓");
|
|
74
|
+
process.exit(0);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
let violations = 0;
|
|
78
|
+
const promotionCandidates = [];
|
|
79
|
+
|
|
80
|
+
for (const viewDir of viewDirs) {
|
|
81
|
+
const viewFiles = walk(viewDir);
|
|
82
|
+
for (const viewFile of viewFiles) {
|
|
83
|
+
const importers = importedBy.get(viewFile) ?? new Set();
|
|
84
|
+
|
|
85
|
+
// Check 1: any importer outside this view dir?
|
|
86
|
+
for (const importer of importers) {
|
|
87
|
+
if (!importer.startsWith(viewDir)) {
|
|
88
|
+
console.error(
|
|
89
|
+
`VIOLATION: ${path.relative(root, viewFile)} imported from outside its view folder:\n` +
|
|
90
|
+
` by ${path.relative(root, importer)}`
|
|
91
|
+
);
|
|
92
|
+
violations++;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Check 2: imported by 2+ different view dirs?
|
|
97
|
+
const importingViewDirs = new Set(
|
|
98
|
+
[...importers]
|
|
99
|
+
.map((imp) => {
|
|
100
|
+
const rel = path.relative(viewsDir, imp);
|
|
101
|
+
if (rel.startsWith("..")) return null;
|
|
102
|
+
if (!rel.includes("/")) return null;
|
|
103
|
+
return path.join(viewsDir, rel.split("/")[0]);
|
|
104
|
+
})
|
|
105
|
+
.filter(Boolean)
|
|
106
|
+
);
|
|
107
|
+
if (importingViewDirs.size >= 2) {
|
|
108
|
+
promotionCandidates.push({
|
|
109
|
+
file: path.relative(root, viewFile),
|
|
110
|
+
importedBy: importingViewDirs.size,
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (promotionCandidates.length > 0) {
|
|
117
|
+
console.warn(
|
|
118
|
+
"\nPromotion candidates (used in 2+ view directories — consider moving to src/components/):"
|
|
119
|
+
);
|
|
120
|
+
for (const c of promotionCandidates) {
|
|
121
|
+
console.warn(` ${c.file} (${c.importedBy} view directories)`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (violations > 0) {
|
|
126
|
+
console.error(
|
|
127
|
+
`\ncheck:imports — ${violations} cross-view boundary violation(s). Move the file to src/components/ or keep it internal.`
|
|
128
|
+
);
|
|
129
|
+
process.exit(1);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
console.log("check:imports — no cross-view boundary violations ✓");
|
|
133
|
+
process.exit(0);
|
|
@@ -38,7 +38,18 @@ function fail(message) {
|
|
|
38
38
|
|
|
39
39
|
function hashFile(file) {
|
|
40
40
|
try {
|
|
41
|
-
|
|
41
|
+
let source = fs.readFileSync(file, "utf8");
|
|
42
|
+
|
|
43
|
+
// For .ts files, include co-located CSS if it exists
|
|
44
|
+
if (file.endsWith(".ts")) {
|
|
45
|
+
const cssPath = file.replace(/\.ts$/, ".css");
|
|
46
|
+
if (fs.existsSync(cssPath)) {
|
|
47
|
+
const cssSource = fs.readFileSync(cssPath, "utf8");
|
|
48
|
+
source = `${source}\n/* CSS */\n${cssSource}`;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return hashSource(source);
|
|
42
53
|
} catch (error) {
|
|
43
54
|
fail(`cannot read ${file} (${error.code ?? error.message})`);
|
|
44
55
|
return ""; // unreachable; fail() exits
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Usage:
|
|
4
|
+
* pnpm create:component Composer → src/components/Composer/ (with CSS)
|
|
5
|
+
* pnpm create:component icons → src/components/icons.ts (flat, no args)
|
|
6
|
+
* pnpm create:component ChatView/ChatComposer → src/views/ChatView/ChatComposer.ts (flat)
|
|
7
|
+
* pnpm create:component ChatView/Widget --css → src/views/ChatView/Widget/ (with CSS)
|
|
8
|
+
*/
|
|
9
|
+
import fs from "node:fs";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
import { fileURLToPath } from "node:url";
|
|
12
|
+
|
|
13
|
+
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
14
|
+
const args = process.argv.slice(2).filter((a) => !a.startsWith("--"));
|
|
15
|
+
const flags = process.argv.slice(2).filter((a) => a.startsWith("--"));
|
|
16
|
+
const withCss = flags.includes("--css");
|
|
17
|
+
|
|
18
|
+
if (!args[0]) {
|
|
19
|
+
console.error("Usage: pnpm create:component <[ViewName/]ComponentName> [--css]");
|
|
20
|
+
process.exit(1);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const parts = args[0].split("/");
|
|
24
|
+
const componentName = parts.pop();
|
|
25
|
+
const parentView = parts[0]; // e.g. "ChatView" or undefined
|
|
26
|
+
|
|
27
|
+
let srcDir;
|
|
28
|
+
let storyPath;
|
|
29
|
+
let isFolder;
|
|
30
|
+
|
|
31
|
+
if (parentView) {
|
|
32
|
+
// View-specific: flat by default, folder with --css
|
|
33
|
+
const viewDir = path.join(root, "src", "views", parentView);
|
|
34
|
+
isFolder = withCss;
|
|
35
|
+
srcDir = isFolder ? path.join(viewDir, componentName) : viewDir;
|
|
36
|
+
storyPath = path.join(root, "stories", "views", parentView, `${componentName}.stories.ts`);
|
|
37
|
+
} else {
|
|
38
|
+
// Primitive: folder with CSS by default
|
|
39
|
+
isFolder = true;
|
|
40
|
+
srcDir = path.join(root, "src", "components", componentName);
|
|
41
|
+
storyPath = path.join(root, "stories", "components", `${componentName}.stories.ts`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const tsFile = path.join(srcDir, `${componentName}.ts`);
|
|
45
|
+
const cssFile = isFolder ? path.join(srcDir, `${componentName}.css`) : null;
|
|
46
|
+
|
|
47
|
+
// Component .ts stub
|
|
48
|
+
const cssImport = cssFile ? `import "./${componentName}.css";\n` : "";
|
|
49
|
+
const tsContent = `${cssImport}import { html } from "@arrow-js/core";
|
|
50
|
+
import type { ArrowExpression } from "@arrow-js/core";
|
|
51
|
+
|
|
52
|
+
export function ${componentName}(): ArrowExpression {
|
|
53
|
+
return html\`<div class="${componentName
|
|
54
|
+
.replace(/([A-Z])/g, (m, l, i) => (i ? "-" : "") + l.toLowerCase())
|
|
55
|
+
.replace(/^-/, "")}">TODO</div>\`;
|
|
56
|
+
}
|
|
57
|
+
`;
|
|
58
|
+
|
|
59
|
+
// Story stub
|
|
60
|
+
const importPath = "../../tools/viewer/stories";
|
|
61
|
+
const componentImport = parentView
|
|
62
|
+
? `../../src/views/${parentView}/${componentName}`
|
|
63
|
+
: `../../src/components/${isFolder ? `${componentName}/` : ""}${componentName}`;
|
|
64
|
+
const kindLine = parentView ? `\tkind: "component",\n` : "";
|
|
65
|
+
|
|
66
|
+
const storyContent = `import { defineStories } from "${importPath}";
|
|
67
|
+
import { ${componentName} } from "${componentImport}";
|
|
68
|
+
|
|
69
|
+
export default defineStories({
|
|
70
|
+
${kindLine}\tdescription: "TODO: describe ${componentName}.",
|
|
71
|
+
\tstatus: "draft",
|
|
72
|
+
\tvariants: {
|
|
73
|
+
\t\tdefault: () => ${componentName}(),
|
|
74
|
+
\t},
|
|
75
|
+
});
|
|
76
|
+
`;
|
|
77
|
+
|
|
78
|
+
// Write files
|
|
79
|
+
if (isFolder) fs.mkdirSync(srcDir, { recursive: true });
|
|
80
|
+
else fs.mkdirSync(path.dirname(tsFile), { recursive: true });
|
|
81
|
+
|
|
82
|
+
if (fs.existsSync(tsFile)) {
|
|
83
|
+
console.error(`Already exists: ${tsFile}`);
|
|
84
|
+
process.exit(1);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
fs.writeFileSync(tsFile, tsContent);
|
|
88
|
+
console.log(`Created: ${path.relative(root, tsFile)}`);
|
|
89
|
+
|
|
90
|
+
if (cssFile) {
|
|
91
|
+
fs.writeFileSync(cssFile, `/* ${componentName} styles */\n`);
|
|
92
|
+
console.log(`Created: ${path.relative(root, cssFile)}`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
fs.mkdirSync(path.dirname(storyPath), { recursive: true });
|
|
96
|
+
if (!fs.existsSync(storyPath)) {
|
|
97
|
+
fs.writeFileSync(storyPath, storyContent);
|
|
98
|
+
console.log(`Created: ${path.relative(root, storyPath)}`);
|
|
99
|
+
} else {
|
|
100
|
+
console.log(`Story already exists, skipped: ${path.relative(root, storyPath)}`);
|
|
101
|
+
}
|