create-obsidian-arrow 0.1.5 → 0.2.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/README.md +40 -4
- package/index.mjs +161 -48
- package/package.json +1 -1
- package/template/AGENTS.md +11 -1
- package/template/README.md +41 -13
- package/template/_gitignore +4 -0
- package/template/docs/prompts/agent-setup.md +14 -4
- package/template/docs/workflow.md +8 -1
- package/template/scripts/install-skills.mjs +79 -30
- package/template/skills/arrow-js-obsidian-patterns/SKILL.md +0 -94
- package/template/skills/arrow-js-obsidian-porting/SKILL.md +0 -92
- package/template/skills/arrow-js-obsidian-templates/SKILL.md +0 -101
- package/template/skills/obsidian-arrow-sandbox/SKILL.md +0 -64
package/README.md
CHANGED
|
@@ -24,18 +24,54 @@ pnpm dev
|
|
|
24
24
|
> `public/app.css` is git-ignored and never bundled — it's Obsidian's proprietary
|
|
25
25
|
> CSS, so each developer extracts it from their own install via `pnpm pull-css`.
|
|
26
26
|
|
|
27
|
-
|
|
27
|
+
### Install the agent skills
|
|
28
|
+
|
|
29
|
+
Skills aren't vendored into the scaffold — `skills:install` pulls them from the
|
|
30
|
+
published repo (source of truth, always current) via the
|
|
31
|
+
[`skills`](https://github.com/vercel-labs/skills) CLI:
|
|
32
|
+
|
|
33
|
+
```sh
|
|
34
|
+
pnpm skills:install --yes # non-interactive — installs all
|
|
35
|
+
pnpm skills:install # interactive picker
|
|
36
|
+
pnpm skills:install --yes --project-dir=<root> # install at another repo root (nesting)
|
|
37
|
+
pnpm skills:update # update an existing setup
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
If you scaffold **inside an existing repo**, skills install scoped to the
|
|
41
|
+
scaffold folder (the CLI is cwd-relative); use `--project-dir=<outer-repo>` (or
|
|
42
|
+
`--global`) to install where an agent at the outer repo looks. The scaffolder
|
|
43
|
+
prints this hint when it detects nesting.
|
|
44
|
+
|
|
45
|
+
## Update an existing project
|
|
46
|
+
|
|
47
|
+
The scaffolder is create-only, but `update` refreshes an existing project's
|
|
48
|
+
**managed** tooling (`scripts/`, `skills/`, `docs/`, `.github/`, `.husky/`,
|
|
49
|
+
`biome.json`, agent guides) and merges new `package.json` scripts/deps — it never
|
|
50
|
+
touches your `src/`, `public/`, `index.html`, or build configs:
|
|
51
|
+
|
|
52
|
+
```sh
|
|
53
|
+
npx create-obsidian-arrow update # in the project (or: update <dir>)
|
|
54
|
+
npx create-obsidian-arrow update --dry-run # preview
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Then `pnpm install && pnpm check`.
|
|
58
|
+
|
|
59
|
+
## Local dev of the initializer
|
|
60
|
+
|
|
61
|
+
From the sandbox repo, before publishing:
|
|
28
62
|
|
|
29
63
|
```sh
|
|
30
|
-
node create-obsidian-arrow/index.mjs ../my-app
|
|
64
|
+
node create-obsidian-arrow/index.mjs ../my-app # scaffold
|
|
65
|
+
node create-obsidian-arrow/index.mjs update ../my-app # update
|
|
31
66
|
```
|
|
32
67
|
|
|
33
68
|
## What you get
|
|
34
69
|
|
|
35
70
|
A full sandbox: client-only Vite + TS, `@arrow-js/core` + `@arrow-js/framework`
|
|
36
71
|
(no SSR), `routeToPage` + Navigation-API router with an `/example` demo, Biome +
|
|
37
|
-
husky pre-commit + `node:test` + GitHub Actions CI,
|
|
38
|
-
`pull-css` script that extracts
|
|
72
|
+
husky pre-commit + `node:test` + GitHub Actions CI, a `skills:install` that pulls
|
|
73
|
+
the agent skills from the published repo, and the `pull-css` script that extracts
|
|
74
|
+
Obsidian's `app.css`.
|
|
39
75
|
|
|
40
76
|
## Maintaining the template
|
|
41
77
|
|
package/index.mjs
CHANGED
|
@@ -1,13 +1,21 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* create-obsidian-arrow — scaffold
|
|
3
|
+
* create-obsidian-arrow — scaffold or update an Obsidian-styled Arrow.js sandbox.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
5
|
+
* create-obsidian-arrow <dir> scaffold a new project into <dir>
|
|
6
|
+
* create-obsidian-arrow update [dir] refresh an existing project's tooling
|
|
7
|
+
* (default dir: cwd) — preserves your code
|
|
7
8
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
9
|
+
* pnpm create obsidian-arrow my-app
|
|
10
|
+
* node create-obsidian-arrow/index.mjs my-app # locally
|
|
11
|
+
* node create-obsidian-arrow/index.mjs update ./my-app
|
|
12
|
+
*
|
|
13
|
+
* Scaffold copies the vendored template/, restores .gitignore (vendored as
|
|
14
|
+
* _gitignore), names the project, and runs `git init`.
|
|
15
|
+
*
|
|
16
|
+
* Update refreshes only the *managed* tooling files from the template and merges
|
|
17
|
+
* package.json scripts + missing deps — it never touches your src/, public/,
|
|
18
|
+
* index.html, or the core build configs. Use --dry-run to preview.
|
|
11
19
|
*/
|
|
12
20
|
import { spawnSync } from "node:child_process";
|
|
13
21
|
import fs from "node:fs";
|
|
@@ -17,60 +25,165 @@ import { fileURLToPath } from "node:url";
|
|
|
17
25
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
18
26
|
const templateDir = path.join(here, "template");
|
|
19
27
|
|
|
28
|
+
// Files/dirs the template owns and `update` may overwrite/merge. Everything else
|
|
29
|
+
// (src/, public/, index.html, vite.config.ts, tsconfig.json, lockfile, .gitignore,
|
|
30
|
+
// port-parity.json, …) is treated as user-owned and left alone. Skills aren't
|
|
31
|
+
// here — they're pulled from the published repo via `pnpm skills:update`, not
|
|
32
|
+
// vendored into the scaffold.
|
|
33
|
+
const MANAGED = ["scripts", "docs", ".github", ".husky", "biome.json", "AGENTS.md", "CLAUDE.md"];
|
|
34
|
+
|
|
35
|
+
const argv = process.argv.slice(2);
|
|
36
|
+
const dryRun = argv.includes("--dry-run");
|
|
37
|
+
|
|
20
38
|
function fail(message) {
|
|
21
39
|
console.error(`create-obsidian-arrow: ${message}`);
|
|
22
40
|
process.exit(1);
|
|
23
41
|
}
|
|
24
42
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
fail("usage: create-obsidian-arrow <directory>");
|
|
43
|
+
if (!fs.existsSync(templateDir)) {
|
|
44
|
+
fail("template/ is missing — run scripts/sync-template.mjs to build it.");
|
|
28
45
|
}
|
|
29
46
|
|
|
30
|
-
|
|
31
|
-
|
|
47
|
+
/** Nearest ancestor *above* `dir` that is a git repo, or null. */
|
|
48
|
+
function outerRepoAbove(dir) {
|
|
49
|
+
let current = path.dirname(path.resolve(dir));
|
|
50
|
+
const fsRoot = path.parse(current).root;
|
|
51
|
+
while (current && current !== fsRoot) {
|
|
52
|
+
if (fs.existsSync(path.join(current, ".git"))) {
|
|
53
|
+
return current;
|
|
54
|
+
}
|
|
55
|
+
current = path.dirname(current);
|
|
56
|
+
}
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
32
59
|
|
|
33
|
-
|
|
34
|
-
|
|
60
|
+
/** Recursively copy src→dest; returns the list of written file paths (relative
|
|
61
|
+
* to `base`). Honors --dry-run (records but doesn't write). */
|
|
62
|
+
function copyTree(src, dest, base, written) {
|
|
63
|
+
const stat = fs.statSync(src);
|
|
64
|
+
if (stat.isDirectory()) {
|
|
65
|
+
if (!dryRun) {
|
|
66
|
+
fs.mkdirSync(dest, { recursive: true });
|
|
67
|
+
}
|
|
68
|
+
for (const entry of fs.readdirSync(src)) {
|
|
69
|
+
copyTree(path.join(src, entry), path.join(dest, entry), base, written);
|
|
70
|
+
}
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
written.push(path.relative(base, dest));
|
|
74
|
+
if (!dryRun) {
|
|
75
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
76
|
+
fs.copyFileSync(src, dest);
|
|
77
|
+
}
|
|
35
78
|
}
|
|
36
|
-
|
|
37
|
-
|
|
79
|
+
|
|
80
|
+
function scaffold(targetArg) {
|
|
81
|
+
const destRoot = path.resolve(process.cwd(), targetArg);
|
|
82
|
+
const appName = path.basename(destRoot);
|
|
83
|
+
|
|
84
|
+
if (fs.existsSync(destRoot) && fs.readdirSync(destRoot).length > 0) {
|
|
85
|
+
fail(`target "${targetArg}" already exists and is not empty (use \`update\` to refresh it).`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const written = [];
|
|
89
|
+
copyTree(templateDir, destRoot, destRoot, written);
|
|
90
|
+
// Restore .gitignore (npm omits .gitignore from packages; vendored as _gitignore).
|
|
91
|
+
const vendoredIgnore = path.join(destRoot, "_gitignore");
|
|
92
|
+
if (fs.existsSync(vendoredIgnore)) {
|
|
93
|
+
fs.renameSync(vendoredIgnore, path.join(destRoot, ".gitignore"));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Personalize the project name via targeted replace (keeps Biome formatting).
|
|
97
|
+
const pkgPath = path.join(destRoot, "package.json");
|
|
98
|
+
const renamed = fs
|
|
99
|
+
.readFileSync(pkgPath, "utf8")
|
|
100
|
+
.replace(/("name":\s*)"[^"]*"/, (_m, prefix) => `${prefix}${JSON.stringify(appName)}`);
|
|
101
|
+
fs.writeFileSync(pkgPath, renamed);
|
|
102
|
+
|
|
103
|
+
spawnSync("git", ["init", "-q"], { cwd: destRoot, stdio: "ignore" });
|
|
104
|
+
|
|
105
|
+
console.log(`\nScaffolded ${appName} in ${path.relative(process.cwd(), destRoot) || "."}\n`);
|
|
106
|
+
console.log("Next steps:");
|
|
107
|
+
console.log(` cd ${targetArg}`);
|
|
108
|
+
console.log(" pnpm install");
|
|
109
|
+
console.log(" pnpm pull-css # extract Obsidian's app.css (macOS auto-detect)");
|
|
110
|
+
console.log(" pnpm dev\n");
|
|
111
|
+
|
|
112
|
+
const outer = outerRepoAbove(destRoot);
|
|
113
|
+
if (outer) {
|
|
114
|
+
console.log(`Note: this project is nested inside the repo at ${outer}.`);
|
|
115
|
+
console.log(" Bundled skills install scoped to THIS project. To install them at the");
|
|
116
|
+
console.log(` outer repo instead: pnpm skills:install --yes --project-dir=${outer}\n`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Merge template package.json scripts + missing deps into the target's,
|
|
121
|
+
* preserving name/version/identity and existing dep versions. */
|
|
122
|
+
function mergePackageJson(targetPkgPath) {
|
|
123
|
+
const tpl = JSON.parse(fs.readFileSync(path.join(templateDir, "package.json"), "utf8"));
|
|
124
|
+
const pkg = JSON.parse(fs.readFileSync(targetPkgPath, "utf8"));
|
|
125
|
+
const changes = [];
|
|
126
|
+
|
|
127
|
+
pkg.scripts ??= {};
|
|
128
|
+
for (const [name, cmd] of Object.entries(tpl.scripts ?? {})) {
|
|
129
|
+
if (pkg.scripts[name] !== cmd) {
|
|
130
|
+
changes.push(`script ${name}`);
|
|
131
|
+
pkg.scripts[name] = cmd;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
for (const field of ["dependencies", "devDependencies"]) {
|
|
135
|
+
pkg[field] ??= {};
|
|
136
|
+
for (const [name, version] of Object.entries(tpl[field] ?? {})) {
|
|
137
|
+
if (!(name in pkg[field])) {
|
|
138
|
+
changes.push(`${field}: ${name}`);
|
|
139
|
+
pkg[field][name] = version;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (changes.length > 0 && !dryRun) {
|
|
145
|
+
fs.writeFileSync(targetPkgPath, `${JSON.stringify(pkg, null, "\t")}\n`);
|
|
146
|
+
}
|
|
147
|
+
return changes;
|
|
38
148
|
}
|
|
39
149
|
|
|
40
|
-
function
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
150
|
+
function update(targetArg) {
|
|
151
|
+
const root = path.resolve(process.cwd(), targetArg ?? ".");
|
|
152
|
+
if (!fs.existsSync(path.join(root, "package.json"))) {
|
|
153
|
+
fail(`no package.json in ${root} — is this a scaffolded project?`);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const written = [];
|
|
157
|
+
for (const name of MANAGED) {
|
|
158
|
+
const src = path.join(templateDir, name);
|
|
159
|
+
if (fs.existsSync(src)) {
|
|
160
|
+
copyTree(src, path.join(root, name), root, written);
|
|
51
161
|
}
|
|
52
162
|
}
|
|
163
|
+
const pkgChanges = mergePackageJson(path.join(root, "package.json"));
|
|
164
|
+
|
|
165
|
+
const verb = dryRun ? "Would refresh" : "Refreshed";
|
|
166
|
+
console.log(
|
|
167
|
+
`${verb} ${written.length} managed file(s) in ${path.relative(process.cwd(), root) || "."}:`
|
|
168
|
+
);
|
|
169
|
+
for (const file of written) {
|
|
170
|
+
console.log(` ${file}`);
|
|
171
|
+
}
|
|
172
|
+
if (pkgChanges.length > 0) {
|
|
173
|
+
console.log(`package.json: ${dryRun ? "would update" : "updated"} ${pkgChanges.join(", ")}`);
|
|
174
|
+
}
|
|
175
|
+
console.log(
|
|
176
|
+
dryRun
|
|
177
|
+
? "\n(dry run — nothing written.)"
|
|
178
|
+
: "\nLeft alone: src/, public/, index.html, vite.config.ts, tsconfig.json, .gitignore.\nRun `pnpm install` then `pnpm check`. Update skills separately with `pnpm skills:update`.\n"
|
|
179
|
+
);
|
|
53
180
|
}
|
|
54
181
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
/("name":\s*)"[^"]*"/,
|
|
64
|
-
(_match, prefix) => `${prefix}${JSON.stringify(appName)}`
|
|
65
|
-
);
|
|
66
|
-
fs.writeFileSync(pkgPath, renamed);
|
|
67
|
-
|
|
68
|
-
// Initialize a git repo (best-effort; ignore if git is unavailable).
|
|
69
|
-
spawnSync("git", ["init", "-q"], { cwd: destRoot, stdio: "ignore" });
|
|
70
|
-
|
|
71
|
-
console.log(`\nScaffolded ${appName} in ${path.relative(process.cwd(), destRoot) || "."}\n`);
|
|
72
|
-
console.log("Next steps:");
|
|
73
|
-
console.log(` cd ${targetArg}`);
|
|
74
|
-
console.log(" pnpm install");
|
|
75
|
-
console.log(" pnpm pull-css # extract Obsidian's app.css (macOS auto-detect)");
|
|
76
|
-
console.log(" pnpm dev\n");
|
|
182
|
+
const command = argv[0];
|
|
183
|
+
if (command === "update") {
|
|
184
|
+
update(argv.find((a) => a !== "update" && !a.startsWith("--")));
|
|
185
|
+
} else if (!command || command.startsWith("--")) {
|
|
186
|
+
fail("usage: create-obsidian-arrow <directory> | update [directory]");
|
|
187
|
+
} else {
|
|
188
|
+
scaffold(command);
|
|
189
|
+
}
|
package/package.json
CHANGED
package/template/AGENTS.md
CHANGED
|
@@ -11,7 +11,8 @@ 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, arrow-js-obsidian-templates, arrow-js-obsidian-patterns,
|
|
14
|
-
arrow-js-obsidian-porting (sandbox→plugin parity check)
|
|
14
|
+
arrow-js-obsidian-porting (sandbox→plugin parity check), obsidian-arrow-maintenance
|
|
15
|
+
(updating an existing project).
|
|
15
16
|
- [`docs/prompts/agent-setup.md`](docs/prompts/agent-setup.md) — prompt for
|
|
16
17
|
briefing a fresh agent (scaffold + orient).
|
|
17
18
|
|
|
@@ -40,6 +41,15 @@ pnpm dev # Vite + HMR
|
|
|
40
41
|
`public/app.css` is **git-ignored** (Obsidian's proprietary CSS — not
|
|
41
42
|
redistributed); run `pnpm pull-css` once before `pnpm dev`.
|
|
42
43
|
|
|
44
|
+
Install the skills (pulled from the published repo, not vendored):
|
|
45
|
+
`pnpm skills:install --yes` (non-interactive, all skills) or `pnpm skills:install`
|
|
46
|
+
(TUI); update with `pnpm skills:update`. Scope
|
|
47
|
+
flags: `--agent <name>`, `--project-dir=<path>`, `--global`. **Nested inside
|
|
48
|
+
another repo?** Skills install cwd-relative — use `--project-dir=<outer-repo>` so
|
|
49
|
+
they land where an agent at the outer repo looks. To refresh an existing
|
|
50
|
+
project's tooling, run `npx create-obsidian-arrow update` (see the
|
|
51
|
+
obsidian-arrow-maintenance skill).
|
|
52
|
+
|
|
43
53
|
## Arrow v1.0.6 footguns — READ BEFORE WRITING TEMPLATES
|
|
44
54
|
|
|
45
55
|
These are hard runtime errors, not style nits. They are encoded in CI
|
package/template/README.md
CHANGED
|
@@ -24,6 +24,23 @@ Then `cd my-app && pnpm install && pnpm pull-css && pnpm dev`. A freshly
|
|
|
24
24
|
scaffolded project passes `pnpm run ci` out of the box. The initializer's
|
|
25
25
|
template is generated from this repo (`pnpm create:sync`), so it never drifts.
|
|
26
26
|
|
|
27
|
+
**Update an existing project's tooling** (refreshes the managed files — scripts,
|
|
28
|
+
skills, docs, agent guides, CI, `biome.json` — and merges new `package.json`
|
|
29
|
+
scripts/deps; never touches `src/`, `public/`, `index.html`, or build configs):
|
|
30
|
+
|
|
31
|
+
```sh
|
|
32
|
+
npx create-obsidian-arrow update # in the project (or: update <dir>)
|
|
33
|
+
npx create-obsidian-arrow update --dry-run # preview first
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
> **Nested in another repo?** If you scaffold inside an existing repo, skills
|
|
37
|
+
> install scoped to the scaffold folder (the `skills` CLI is cwd-relative), so the
|
|
38
|
+
> session/`skill://` registry — which only indexes the **repo root** + global —
|
|
39
|
+
> won't see them. Install at the outer repo instead:
|
|
40
|
+
> `pnpm skills:install --yes --project-dir=<outer-repo>` (or, from the outer repo,
|
|
41
|
+
> `npx skills add kylebrodeur/obsidian-arrow-sandbox --all --yes`), then **reload
|
|
42
|
+
> the session**. The scaffolder prints this hint when it detects nesting.
|
|
43
|
+
|
|
27
44
|
> This repo (the full sandbox) is **not** published to npm — only the
|
|
28
45
|
> `create-obsidian-arrow/` initializer is. An agent-onboarding prompt lives in
|
|
29
46
|
> [`docs/prompts/agent-setup.md`](docs/prompts/agent-setup.md).
|
|
@@ -66,10 +83,12 @@ Obsidian install.
|
|
|
66
83
|
A husky `pre-commit` hook runs `lint-staged` (Biome on staged files) + a full
|
|
67
84
|
typecheck. CI (`.github/workflows/ci.yml`) runs the `ci` script on push/PR.
|
|
68
85
|
|
|
69
|
-
##
|
|
86
|
+
## Agent skills
|
|
70
87
|
|
|
71
|
-
This repo
|
|
72
|
-
under [`skills/`](skills/) — it
|
|
88
|
+
This repo is the source of truth for five [`skills`](https://github.com/vercel-labs/skills)-compatible
|
|
89
|
+
skills under [`skills/`](skills/) — it's a skill marketplace. Scaffolds **don't
|
|
90
|
+
vendor copies**; they pull from this published repo, so installs are always
|
|
91
|
+
current.
|
|
73
92
|
|
|
74
93
|
- `obsidian-arrow-sandbox` — running and using this sandbox, and porting to a plugin.
|
|
75
94
|
- `arrow-js-obsidian-templates` — Arrow v1.0.6 template rules + footguns.
|
|
@@ -78,23 +97,32 @@ under [`skills/`](skills/) — it doubles as a local skill marketplace:
|
|
|
78
97
|
- `arrow-js-obsidian-porting` — content-addressed porting parity: the
|
|
79
98
|
`component-hash` tool + a husky/CI check that the plugin copy hasn't drifted
|
|
80
99
|
from the sandbox source.
|
|
100
|
+
- `obsidian-arrow-maintenance` — updating an existing project: `create-obsidian-arrow
|
|
101
|
+
update`, `skills:update`, nesting/`--project-dir`, re-pull styling.
|
|
81
102
|
|
|
82
|
-
Install them into your agent
|
|
103
|
+
Install them into your agent (pulls from the published repo; the sandbox repo
|
|
104
|
+
itself uses its local `skills/`):
|
|
83
105
|
|
|
84
106
|
```sh
|
|
85
|
-
pnpm skills:install
|
|
86
|
-
pnpm skills:install --yes
|
|
87
|
-
pnpm skills:install --yes --agent claude-code
|
|
88
|
-
pnpm skills:
|
|
107
|
+
pnpm skills:install # interactive picker (TUI) on a terminal
|
|
108
|
+
pnpm skills:install --yes # non-interactive — installs all skills
|
|
109
|
+
pnpm skills:install --yes --agent claude-code # install for one agent only
|
|
110
|
+
pnpm skills:install --yes --project-dir=<repo-root> # install into another project root
|
|
111
|
+
pnpm skills:update # update an existing setup to the latest
|
|
89
112
|
```
|
|
90
113
|
|
|
114
|
+
Anywhere, with no project at all:
|
|
115
|
+
`npx skills add kylebrodeur/obsidian-arrow-sandbox --all --yes`.
|
|
116
|
+
|
|
91
117
|
`postinstall` offers the picker automatically after `pnpm install`, but only in
|
|
92
118
|
an interactive terminal — in CI / non-TTY it just prints how to install (never
|
|
93
|
-
hangs). For agents/CI, use `--yes
|
|
94
|
-
`--agent <name>` (
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
119
|
+
hangs). For agents/CI, use `--yes`.
|
|
120
|
+
Scope flags: `--agent <name>` (one agent), `--project-dir=<path>` (install into a
|
|
121
|
+
different project root, e.g. an outer repo a scaffold is nested in), `--global`
|
|
122
|
+
(user-level, available everywhere). `pnpm skills:update` runs
|
|
123
|
+
`npx skills update -y`. The auto `postinstall` step takes no CLI args, so the env
|
|
124
|
+
forms `SKILLS_AGENT` / `SKILLS_PROJECT_DIR` / `SKILLS_GLOBAL` (and
|
|
125
|
+
`SKIP_SKILLS_INSTALL=1`) influence *that* path.
|
|
98
126
|
|
|
99
127
|
## Porting a component into the plugin
|
|
100
128
|
|
package/template/_gitignore
CHANGED
|
@@ -3,6 +3,10 @@ dist
|
|
|
3
3
|
.DS_Store
|
|
4
4
|
*.tgz
|
|
5
5
|
|
|
6
|
+
# `skills` install artifacts (regenerated by `pnpm skills:install`); the source
|
|
7
|
+
# of truth is skills/. (skills-lock.json is intentionally kept tracked.)
|
|
8
|
+
.agents/
|
|
9
|
+
|
|
6
10
|
# Obsidian's app.css is proprietary — extract it locally with `pnpm pull-css`,
|
|
7
11
|
# don't commit/redistribute it.
|
|
8
12
|
public/app.css
|
|
@@ -32,16 +32,21 @@ Then:
|
|
|
32
32
|
# or set OBSIDIAN_ASAR=<path>.
|
|
33
33
|
pnpm dev # open the printed URL: / is the examples index, /example the demo.
|
|
34
34
|
# The toolbar slider/presets + edge drag handle resize the panel.
|
|
35
|
-
pnpm skills:install --yes # install
|
|
36
|
-
#
|
|
35
|
+
pnpm skills:install --yes # install all agent skills non-interactively, pulled
|
|
36
|
+
# from the published repo (not vendored) — this loads
|
|
37
37
|
# the domain knowledge. Drop --yes for an interactive picker.
|
|
38
|
+
# NESTED inside another repo? Skills install cwd-relative,
|
|
39
|
+
# so add --project-dir=<outer-repo> (or --global) to put them
|
|
40
|
+
# where an agent at the outer repo will find them.
|
|
38
41
|
|
|
39
42
|
READ FIRST
|
|
40
43
|
- AGENTS.md (root) — operating guide + docs map (links everything below).
|
|
41
44
|
- docs/workflow.md — fresh-machine → running workflow.
|
|
42
45
|
- skills/*/SKILL.md — obsidian-arrow-sandbox (workflow), arrow-js-obsidian-
|
|
43
46
|
templates (template syntax + footguns), arrow-js-obsidian-patterns (icons via
|
|
44
|
-
Lucide/data-icon sweep, CSS scoping, mount/unmount lifecycle, reactive state)
|
|
47
|
+
Lucide/data-icon sweep, CSS scoping, mount/unmount lifecycle, reactive state),
|
|
48
|
+
arrow-js-obsidian-porting (sandbox→plugin parity check),
|
|
49
|
+
obsidian-arrow-maintenance (updating an existing project).
|
|
45
50
|
|
|
46
51
|
ARROW v1.0.6 FOOTGUNS — do not relearn these the hard way:
|
|
47
52
|
1. NO literal HTML comments inside html`` templates — Arrow treats HTML comments
|
|
@@ -76,7 +81,12 @@ PORTING TO A PLUGIN
|
|
|
76
81
|
Copy the component file into the plugin's view dir and mount from
|
|
77
82
|
ItemView.onOpen() via `template(this.contentEl)`. If it uses boundary()/async
|
|
78
83
|
components, add @arrow-js/framework to the plugin and the side-effect
|
|
79
|
-
`import '@arrow-js/framework'`. Leave src/sandbox/* behind.
|
|
84
|
+
`import '@arrow-js/framework'`. Leave src/sandbox/* behind. Guard against drift
|
|
85
|
+
with the porting-parity check (see the arrow-js-obsidian-porting skill).
|
|
86
|
+
|
|
87
|
+
MAINTENANCE (existing project)
|
|
88
|
+
Refresh tooling later with `npx create-obsidian-arrow update` (preserves src/),
|
|
89
|
+
update skills with `pnpm skills:update`. See the obsidian-arrow-maintenance skill.
|
|
80
90
|
|
|
81
91
|
Start by scaffolding, running setup steps, then read AGENTS.md and confirm
|
|
82
92
|
`pnpm dev` renders /example correctly. Report what you see.
|
|
@@ -37,13 +37,20 @@ From inside the scaffolded project:
|
|
|
37
37
|
|
|
38
38
|
```sh
|
|
39
39
|
pnpm skills:install # interactive picker (TUI) on a terminal
|
|
40
|
-
pnpm skills:install --yes # non-interactive — installs
|
|
40
|
+
pnpm skills:install --yes # non-interactive — installs all skills from the published repo (agents/CI)
|
|
41
41
|
pnpm skills:update # update an already-installed setup to the latest
|
|
42
42
|
```
|
|
43
43
|
|
|
44
44
|
Then point the agent at [`AGENTS.md`](../AGENTS.md), or brief a fresh agent with
|
|
45
45
|
[`docs/prompts/agent-setup.md`](prompts/agent-setup.md).
|
|
46
46
|
|
|
47
|
+
**Nested inside another repo?** Skills install scoped to the current folder (the
|
|
48
|
+
`skills` CLI is cwd-relative). To install them at the outer repo instead:
|
|
49
|
+
`pnpm skills:install --yes --project-dir=<outer-repo>` (or `--global`).
|
|
50
|
+
|
|
51
|
+
**Refresh an existing project's tooling** (scripts, skills, docs, CI — never your
|
|
52
|
+
`src/`): `npx create-obsidian-arrow update` (add `--dry-run` to preview).
|
|
53
|
+
|
|
47
54
|
## Build → verify → port loop
|
|
48
55
|
|
|
49
56
|
```sh
|
|
@@ -1,36 +1,46 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* Install / update the
|
|
4
|
-
*
|
|
5
|
-
*
|
|
3
|
+
* Install / update the obsidian-arrow agent skills via the `skills` CLI
|
|
4
|
+
* (https://github.com/vercel-labs/skills).
|
|
5
|
+
*
|
|
6
|
+
* Skills are NOT vendored into scaffolds — they're pulled from the published
|
|
7
|
+
* repo (the source of truth), so installs are always current and don't depend on
|
|
8
|
+
* a bundled copy. The sandbox repo itself (which has a local `skills/` dir) uses
|
|
9
|
+
* that local copy instead, so you can test edits before publishing.
|
|
6
10
|
*
|
|
7
11
|
* Modes (auto-detected):
|
|
8
12
|
* - Interactive terminal → opens the picker TUI (choose what to install).
|
|
9
|
-
* - Non-interactive / CI / -y → installs ALL
|
|
13
|
+
* - Non-interactive / CI / -y → installs ALL skills, no prompts.
|
|
10
14
|
* - --update → updates an already-installed setup in place.
|
|
11
15
|
*
|
|
12
16
|
* Wiring:
|
|
13
17
|
* - `postinstall` (auto on `pnpm install`): only acts in an interactive
|
|
14
18
|
* terminal; in CI/non-TTY it prints how to install and exits 0 (never hangs).
|
|
15
|
-
* - `pnpm skills:install`
|
|
16
|
-
* - `pnpm skills:
|
|
17
|
-
* - `pnpm skills:update` → update installed skills to the latest.
|
|
19
|
+
* - `pnpm skills:install [--yes]` → picker, or non-interactive install of all.
|
|
20
|
+
* - `pnpm skills:update` → update installed skills to the latest.
|
|
18
21
|
*
|
|
19
22
|
* Flags / env:
|
|
23
|
+
* --source <ref> | SKILLS_SOURCE=<ref> where to pull skills from (default: the
|
|
24
|
+
* published repo; a local `skills/` dir is used if present)
|
|
20
25
|
* --update / -u update installed skills (runs `skills update -y`)
|
|
21
|
-
* --yes / -y non-interactive install of all
|
|
22
|
-
* --agent <name> | --agent=<name> install for one agent
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
+
* --yes / -y non-interactive install of all skills
|
|
27
|
+
* --agent <name> | --agent=<name> install for one agent instead of all
|
|
28
|
+
* --project-dir=<path> install into another project root (e.g. the outer repo
|
|
29
|
+
* a scaffold is nested in); project-scoped there
|
|
30
|
+
* --global / -g install at user level (~/.<agent>/…), everywhere
|
|
31
|
+
* SKILLS_AGENT / SKILLS_PROJECT_DIR / SKILLS_GLOBAL env forms (for the auto
|
|
32
|
+
* `postinstall` step, which can't take CLI args, and CI)
|
|
26
33
|
* --dry-run / SKILLS_DRY_RUN=1 print the command instead of running it
|
|
27
34
|
* SKIP_SKILLS_INSTALL=1 opt out of the postinstall auto-step
|
|
28
35
|
*/
|
|
29
36
|
import { spawnSync } from "node:child_process";
|
|
37
|
+
import fs from "node:fs";
|
|
38
|
+
import path from "node:path";
|
|
30
39
|
import process from "node:process";
|
|
31
40
|
|
|
32
|
-
const
|
|
33
|
-
|
|
41
|
+
const REPO = "kylebrodeur/obsidian-arrow-sandbox";
|
|
42
|
+
const SKILLS =
|
|
43
|
+
"obsidian-arrow-sandbox, arrow-js-obsidian-templates, arrow-js-obsidian-patterns, arrow-js-obsidian-porting, obsidian-arrow-maintenance";
|
|
34
44
|
|
|
35
45
|
const has = (flag) => process.argv.includes(flag);
|
|
36
46
|
// Accepts both `--flag value` and `--flag=value`.
|
|
@@ -43,29 +53,61 @@ const flagValue = (flag) => {
|
|
|
43
53
|
return i >= 0 ? process.argv[i + 1] : undefined;
|
|
44
54
|
};
|
|
45
55
|
|
|
56
|
+
/** Nearest ancestor *above* `dir` that is a git repo, or null. */
|
|
57
|
+
function outerRepoAbove(dir) {
|
|
58
|
+
let current = path.dirname(path.resolve(dir));
|
|
59
|
+
const fsRoot = path.parse(current).root;
|
|
60
|
+
while (current && current !== fsRoot) {
|
|
61
|
+
if (fs.existsSync(path.join(current, ".git"))) {
|
|
62
|
+
return current;
|
|
63
|
+
}
|
|
64
|
+
current = path.dirname(current);
|
|
65
|
+
}
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
|
|
46
69
|
const forced = has("--force"); // set by `pnpm skills:install`
|
|
47
70
|
const update = has("--update") || has("-u");
|
|
48
71
|
const yes = has("--yes") || has("-y");
|
|
72
|
+
const global = has("--global") || has("-g") || process.env.SKILLS_GLOBAL === "1";
|
|
49
73
|
const agent = flagValue("--agent") || process.env.SKILLS_AGENT;
|
|
74
|
+
const projectDir = flagValue("--project-dir") || process.env.SKILLS_PROJECT_DIR;
|
|
50
75
|
const dryRun = has("--dry-run") || process.env.SKILLS_DRY_RUN === "1";
|
|
51
76
|
const isCI = Boolean(process.env.CI);
|
|
52
77
|
const interactive = Boolean(process.stdout.isTTY && process.stdin.isTTY);
|
|
53
78
|
const optedOut = process.env.SKIP_SKILLS_INSTALL === "1";
|
|
54
79
|
|
|
55
|
-
|
|
80
|
+
// Pull from the published repo by default; prefer a local `skills/` dir if
|
|
81
|
+
// present (the sandbox repo itself); honor an explicit --source override.
|
|
82
|
+
const explicitSource = flagValue("--source") || process.env.SKILLS_SOURCE;
|
|
83
|
+
const hasLocalSkills = fs.existsSync(path.join(process.cwd(), "skills"));
|
|
84
|
+
const baseSource = explicitSource ?? (hasLocalSkills ? "." : REPO);
|
|
85
|
+
// A repo ref is cwd-independent; a local "." must be made absolute when
|
|
86
|
+
// --project-dir redirects cwd so it still resolves.
|
|
87
|
+
const source = projectDir && baseSource === "." ? path.resolve(".") : baseSource;
|
|
88
|
+
const targetCwd = projectDir ? path.resolve(projectDir) : process.cwd();
|
|
89
|
+
|
|
90
|
+
if (projectDir && !fs.existsSync(targetCwd)) {
|
|
91
|
+
console.error(`[skills] --project-dir not found: ${targetCwd}`);
|
|
92
|
+
process.exit(1);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function run(args, cwd = process.cwd()) {
|
|
96
|
+
const where = cwd === process.cwd() ? "" : ` (in ${cwd})`;
|
|
56
97
|
const pretty = ["npx", "skills", ...args].join(" ");
|
|
57
98
|
if (dryRun) {
|
|
58
|
-
console.log(`[skills] (dry-run) would run: ${pretty}`);
|
|
99
|
+
console.log(`[skills] (dry-run) would run: ${pretty}${where}`);
|
|
59
100
|
process.exit(0);
|
|
60
101
|
}
|
|
61
|
-
console.log(`[skills] ${pretty}`);
|
|
102
|
+
console.log(`[skills] ${pretty}${where}`);
|
|
62
103
|
const result = spawnSync("npx", ["--yes", "skills", ...args], {
|
|
104
|
+
cwd,
|
|
63
105
|
stdio: "inherit",
|
|
64
106
|
shell: process.platform === "win32",
|
|
65
107
|
});
|
|
66
108
|
if (result.error) {
|
|
67
109
|
console.error(
|
|
68
|
-
`[skills] could not run npx skills (${result.error.message}). Install manually: npx skills add
|
|
110
|
+
`[skills] could not run npx skills (${result.error.message}). Install manually: npx skills add ${REPO} --all --yes`
|
|
69
111
|
);
|
|
70
112
|
process.exit(0); // never fail an install over an optional convenience step
|
|
71
113
|
}
|
|
@@ -87,23 +129,30 @@ if (update) {
|
|
|
87
129
|
// `pnpm install` in CI never hangs or installs things unprompted.
|
|
88
130
|
if (!forced && (isCI || !interactive)) {
|
|
89
131
|
console.log(
|
|
90
|
-
`[skills]
|
|
132
|
+
`[skills] Agent skills: ${SKILLS}\n Install: pnpm skills:install (interactive picker on a terminal)\n pnpm skills:install --yes (non-interactive — installs all)\n Update: pnpm skills:update`
|
|
91
133
|
);
|
|
92
134
|
process.exit(0);
|
|
93
135
|
}
|
|
94
136
|
|
|
95
|
-
// Non-interactive (CI/agent/no TTY, or --yes, or an agent target): install
|
|
96
|
-
//
|
|
97
|
-
if (!interactive || yes || agent) {
|
|
98
|
-
console.log(`[skills] Installing all
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
137
|
+
// Non-interactive (CI/agent/no TTY, or --yes, or an agent/global target): install
|
|
138
|
+
// ALL skills with no prompts. Target one agent if asked, else all agents.
|
|
139
|
+
if (!interactive || yes || agent || global || projectDir) {
|
|
140
|
+
console.log(`[skills] Installing all skills non-interactively from ${source}: ${SKILLS}`);
|
|
141
|
+
const outer = global || projectDir ? null : outerRepoAbove(process.cwd());
|
|
142
|
+
if (outer) {
|
|
143
|
+
console.log(
|
|
144
|
+
`[skills] note: this folder is nested inside ${outer}, and skills install scoped to cwd. For session/skill:// discovery they must live at the repo root — install there with:\n pnpm skills:install --yes --project-dir=${outer}\n (or, from ${outer}: npx skills add ${REPO} --all --yes). Reload the session afterwards.`
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
// `--all` is shorthand for `-s * -a * -y`; to target one agent we spell out
|
|
148
|
+
// all-skills + that agent explicitly.
|
|
149
|
+
const args = agent ? ["add", source, "-s", "*", "-a", agent, "-y"] : ["add", source, "--all"];
|
|
150
|
+
if (global) {
|
|
151
|
+
args.push("--global");
|
|
103
152
|
}
|
|
104
|
-
run(
|
|
153
|
+
run(args, targetCwd);
|
|
105
154
|
}
|
|
106
155
|
|
|
107
156
|
// Interactive terminal: let the user pick in the TUI.
|
|
108
|
-
console.log(`[skills] Opening the skills picker.
|
|
109
|
-
run(["add",
|
|
157
|
+
console.log(`[skills] Opening the skills picker (source: ${source}). Skills: ${SKILLS}`);
|
|
158
|
+
run(["add", source]);
|
|
@@ -1,94 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: arrow-js-obsidian-patterns
|
|
3
|
-
description: Use when building reactive Obsidian plugin UI with @arrow-js/core beyond basic templates — icons (Obsidian uses Lucide; the data-icon sweep since setIcon can't run inside templates), CSS scoping/specificity against Obsidian's global rules, component mount/unmount lifecycle in an ItemView, and organizing shared reactive state. Complements arrow-js-obsidian-templates (syntax) with integration patterns.
|
|
4
|
-
---
|
|
5
|
-
|
|
6
|
-
# Arrow.js + Obsidian integration patterns
|
|
7
|
-
|
|
8
|
-
How to build real components (not just template syntax) for an Obsidian plugin
|
|
9
|
-
view with `@arrow-js/core`. Pairs with **arrow-js-obsidian-templates** (the
|
|
10
|
-
template rules + footguns).
|
|
11
|
-
|
|
12
|
-
## Mount / unmount lifecycle
|
|
13
|
-
|
|
14
|
-
A view mounts a component imperatively, the same call the sandbox makes:
|
|
15
|
-
|
|
16
|
-
```ts
|
|
17
|
-
import { html } from "@arrow-js/core"
|
|
18
|
-
// in ItemView.onOpen():
|
|
19
|
-
this.contentEl.empty()
|
|
20
|
-
html`${MyComponent()}`(this.contentEl)
|
|
21
|
-
```
|
|
22
|
-
|
|
23
|
-
- `html\`\`(container)` does **not** reliably return an unmount function — don't
|
|
24
|
-
gate cleanup on its return value. Always `container.empty()` before remounting.
|
|
25
|
-
- Re-rendering on route/state change: clear the container, then mount fresh.
|
|
26
|
-
Reactive bindings (`${() => …}`) update in place — don't remount the whole tree
|
|
27
|
-
on every state change; let Arrow patch the slots that read changed state.
|
|
28
|
-
|
|
29
|
-
## Shared reactive state
|
|
30
|
-
|
|
31
|
-
One `reactive()` object is the single source of truth; components read it via
|
|
32
|
-
getters so they stay in sync without prop-drilling.
|
|
33
|
-
|
|
34
|
-
```ts
|
|
35
|
-
import { reactive } from "@arrow-js/core"
|
|
36
|
-
export const state = reactive({ model: "", streaming: false, items: [] })
|
|
37
|
-
// pass getters into components: Toggle(() => state.streaming, () => { ... })
|
|
38
|
-
```
|
|
39
|
-
|
|
40
|
-
## Icons (Obsidian uses Lucide)
|
|
41
|
-
|
|
42
|
-
`setIcon(el, name)` needs a real DOM element, so it **cannot** be called inside
|
|
43
|
-
an Arrow template expression (the element doesn't exist yet). Two options:
|
|
44
|
-
|
|
45
|
-
1. **In-plugin — the data-icon sweep.** Emit a placeholder, then sweep after
|
|
46
|
-
mount:
|
|
47
|
-
|
|
48
|
-
```ts
|
|
49
|
-
html`<span class="svg-icon" data-icon="copy"></span>`
|
|
50
|
-
// after template(container) — scope the query to the container, not document:
|
|
51
|
-
for (const el of Array.from(container.querySelectorAll<HTMLElement>("[data-icon]"))) {
|
|
52
|
-
const name = el.getAttribute("data-icon")
|
|
53
|
-
if (name) setIcon(el, name) // setIcon from "obsidian"
|
|
54
|
-
}
|
|
55
|
-
```
|
|
56
|
-
For sections that open after mount (dropdowns/popovers), run the sweep in a
|
|
57
|
-
`nextTick(...)` after they render, scoped to that section's element.
|
|
58
|
-
|
|
59
|
-
2. **In the sandbox (no `obsidian` module).** Use a Lucide import or inline SVG,
|
|
60
|
-
or plain text/emoji for chrome. The baseline uses text glyphs to stay
|
|
61
|
-
shim-free; add a Lucide-backed `setIcon` shim (aliased as `obsidian`) when you
|
|
62
|
-
port icon-using components in.
|
|
63
|
-
|
|
64
|
-
## CSS scoping & specificity (the big one)
|
|
65
|
-
|
|
66
|
-
Obsidian's `app.css` applies global rules like
|
|
67
|
-
`button:not(.clickable-icon) { background: var(--interactive-normal) }` at
|
|
68
|
-
specificity **(0,1,1)**. A plain `.my-btn` selector is **(0,1,0)** and loses
|
|
69
|
-
regardless of cascade order.
|
|
70
|
-
|
|
71
|
-
```css
|
|
72
|
-
/* (0,1,0) — LOSES to Obsidian's global button rule */
|
|
73
|
-
.my-action { background: var(--interactive-accent); }
|
|
74
|
-
|
|
75
|
-
/* (0,2,1) — WINS, and can't leak */
|
|
76
|
-
.my-panel button.my-action { background: var(--interactive-accent); }
|
|
77
|
-
```
|
|
78
|
-
|
|
79
|
-
Rules: prefer Obsidian's own classes (`.setting-item`, `.clickable-icon`,
|
|
80
|
-
`.workspace-leaf`, `.vertical-tab-*`, `.modal`) and `var(--…)` tokens first; only
|
|
81
|
-
add custom CSS where there's no Obsidian class; always scope custom rules under a
|
|
82
|
-
container class + element type.
|
|
83
|
-
|
|
84
|
-
## Common Obsidian layout classes
|
|
85
|
-
|
|
86
|
-
- `.workspace-leaf` > `.workspace-leaf-content` — a view pane.
|
|
87
|
-
- `.view-header` / `.view-content` — pane header + body.
|
|
88
|
-
- `.setting-item` (+ `.setting-item-info` / `.setting-item-name` /
|
|
89
|
-
`.setting-item-description` / `.setting-item-control`) — settings rows.
|
|
90
|
-
- `.checkbox-container` (+ `.is-enabled`) — toggle.
|
|
91
|
-
- `.clickable-icon` — icon button (escapes the global button background rule).
|
|
92
|
-
- `.vertical-tab-header` / `.vertical-tab-nav-item` (+ `.is-active`) — tab nav.
|
|
93
|
-
- `.modal-container` > `.modal` — modal/popover.
|
|
94
|
-
- `.mod-cta` — primary call-to-action button.
|
|
@@ -1,92 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: arrow-js-obsidian-porting
|
|
3
|
-
description: Use when porting an Arrow component from the obsidian-arrow-sandbox into the real Obsidian plugin, or wiring a check that the plugin copy hasn't drifted from the sandbox source. Covers the content-addressed porting-parity tool (scripts/component-hash.mjs), the canonical-form rules, a port-parity manifest, and a husky/CI pre-commit hook. The sandbox is the source of truth; the plugin copy must match.
|
|
4
|
-
---
|
|
5
|
-
|
|
6
|
-
# Porting parity (sandbox → Obsidian plugin)
|
|
7
|
-
|
|
8
|
-
The sandbox is the **source of truth** for a component; the plugin gets a
|
|
9
|
-
near-verbatim copy. The risk is **drift** — someone hand-edits the plugin copy,
|
|
10
|
-
or a port silently diverges. This makes components **content-addressed**: a
|
|
11
|
-
canonical hash of the component's portable body must match on both sides, the
|
|
12
|
-
same idea as "never hand-edit generated artifacts."
|
|
13
|
-
|
|
14
|
-
## Write portable components
|
|
15
|
-
|
|
16
|
-
So the body is byte-identical across sandbox and plugin and only the *mount site*
|
|
17
|
-
differs:
|
|
18
|
-
|
|
19
|
-
- Take data via **props / getters**, not hard-wired sources. The sandbox passes a
|
|
20
|
-
stub (`loadStatus()`); the plugin passes the real source (rpc) — but that wiring
|
|
21
|
-
lives at the **call site** (`main.ts` / `ItemView.onOpen()`), not in the
|
|
22
|
-
component body.
|
|
23
|
-
- Use Obsidian classes + `var(--…)` tokens (already the rule). Keep
|
|
24
|
-
`obsidian`-API calls (`setIcon`, …) out of the component body where possible.
|
|
25
|
-
|
|
26
|
-
## The tool: `scripts/component-hash.mjs`
|
|
27
|
-
|
|
28
|
-
Dependency-free Node; canonicalizer in `scripts/lib/canonical-source.mjs`.
|
|
29
|
-
|
|
30
|
-
```sh
|
|
31
|
-
node scripts/component-hash.mjs <file> # print canonical hash
|
|
32
|
-
node scripts/component-hash.mjs --verify <sandbox> <plugin> # PARITY OK | DRIFT (exit 0/1)
|
|
33
|
-
node scripts/component-hash.mjs --check port-parity.json # check all; exit 1 on drift
|
|
34
|
-
node scripts/component-hash.mjs --check port-parity.json --update # re-bless baselines
|
|
35
|
-
```
|
|
36
|
-
|
|
37
|
-
**Canonical form** (define it so both sides agree by construction):
|
|
38
|
-
- `import …;` statements are stripped (single- and multi-line) — imports are the
|
|
39
|
-
main legit delta (stub vs real data, the `obsidian` API).
|
|
40
|
-
- Line endings → `\n`, trailing whitespace trimmed, blank-line runs collapsed,
|
|
41
|
-
leading/trailing blanks trimmed.
|
|
42
|
-
- Comments are **kept** (they're part of the component; the sandbox owns them too).
|
|
43
|
-
- SHA-256 of the result.
|
|
44
|
-
|
|
45
|
-
So two copies that differ only in imports/formatting hash equal; any change to
|
|
46
|
-
the body/template/comments shows as drift.
|
|
47
|
-
|
|
48
|
-
## Wiring the check in the plugin
|
|
49
|
-
|
|
50
|
-
The hook lives in the **plugin** repo (that's where ported copies drift). Copy the
|
|
51
|
-
two dependency-free files in (`scripts/lib/canonical-source.mjs`,
|
|
52
|
-
`scripts/component-hash.mjs`) and add a manifest:
|
|
53
|
-
|
|
54
|
-
```jsonc
|
|
55
|
-
// port-parity.json — paths resolve relative to THIS file
|
|
56
|
-
{
|
|
57
|
-
"entries": [
|
|
58
|
-
{
|
|
59
|
-
"plugin": "src/chat/arrow/SettingsPanel.ts",
|
|
60
|
-
"sandbox": "../../arrow-ui/obsidian-arrow-sandbox/src/components/SettingsPanel.ts", // optional
|
|
61
|
-
"hash": "<sha256>" // fallback when sandbox isn't checked out
|
|
62
|
-
}
|
|
63
|
-
]
|
|
64
|
-
}
|
|
65
|
-
```
|
|
66
|
-
|
|
67
|
-
- If the `sandbox` path exists (sibling checkout), the check compares **plugin
|
|
68
|
-
copy ↔ live sandbox source** — catches drift in both directions.
|
|
69
|
-
- If it doesn't, it falls back to the recorded `hash` (catches hand-edits to the
|
|
70
|
-
plugin copy). Record/refresh it with `--update` after an intentional re-port.
|
|
71
|
-
|
|
72
|
-
husky `pre-commit` (and CI):
|
|
73
|
-
|
|
74
|
-
```sh
|
|
75
|
-
node scripts/component-hash.mjs --check port-parity.json
|
|
76
|
-
```
|
|
77
|
-
|
|
78
|
-
On drift the commit fails: *edit the sandbox component and re-port* (don't
|
|
79
|
-
hand-edit the copy); use `--update` only to intentionally re-bless.
|
|
80
|
-
|
|
81
|
-
## What this does NOT do (by design)
|
|
82
|
-
|
|
83
|
-
- **No runtime component-DOM hashing in Obsidian.** Source parity already
|
|
84
|
-
guarantees same code → same Arrow DOM, and the sandbox vs real Obsidian DOM
|
|
85
|
-
*intentionally* differ (icon stub vs `setIcon`, `MarkdownRenderer` nodes, live
|
|
86
|
-
theme), so a runtime markup hash would be tautological *and* full of false
|
|
87
|
-
mismatches. The real runtime risk is visual/CSS — verify that by **loading in
|
|
88
|
-
Obsidian and looking**, not with a hash.
|
|
89
|
-
- **Planned (not built): a styling-freshness check.** A small helper that hashes
|
|
90
|
-
the *live Obsidian token set* and compares it to the sandbox's pulled `app.css`
|
|
91
|
-
snapshot, to flag "re-run `pnpm pull-css`." That's the one runtime hash worth
|
|
92
|
-
having — environment parity, not component parity. Add it when needed.
|
|
@@ -1,101 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: arrow-js-obsidian-templates
|
|
3
|
-
description: Use when writing @arrow-js/core (v1.0.6) html template literals — reactive vs static (wrap reactive reads in arrow functions), full-value attribute binding where returning false removes the attribute, .property and @event binding, keyed lists, and async component(fn, { fallback }) via boundary(); plus the footguns — no literal HTML comments and no partial attribute values (both throw Invalid HTML position at render), and @event handlers must type the param as Event not a narrowed subtype like MouseEvent (TS2345).
|
|
4
|
-
---
|
|
5
|
-
|
|
6
|
-
# Arrow.js Templates (v1.0.6)
|
|
7
|
-
|
|
8
|
-
Rules for writing `html\`\`` templates that render correctly in the browser and
|
|
9
|
-
inside an Obsidian plugin. Several of these are hard runtime errors, not style.
|
|
10
|
-
|
|
11
|
-
## Reactive vs static — the core rule
|
|
12
|
-
|
|
13
|
-
```ts
|
|
14
|
-
import { html, reactive } from "@arrow-js/core"
|
|
15
|
-
const data = reactive({ count: 0 })
|
|
16
|
-
|
|
17
|
-
html`<span>${data.count}</span>` // ❌ renders ONCE — never updates
|
|
18
|
-
html`<span>${() => data.count}</span>` // ✅ tracked — updates only this slot
|
|
19
|
-
```
|
|
20
|
-
|
|
21
|
-
`${value}` is read once at mount. `${() => value}` is tracked: Arrow records the
|
|
22
|
-
reactive reads inside the function and re-runs **only that slot** when they
|
|
23
|
-
change. Forgetting `() =>` is the #1 "why isn't it updating" bug.
|
|
24
|
-
|
|
25
|
-
## Attributes
|
|
26
|
-
|
|
27
|
-
An attribute expression must be the **entire** attribute value:
|
|
28
|
-
|
|
29
|
-
```ts
|
|
30
|
-
html`<div class="${() => (active() ? "tab is-active" : "tab")}">` // ✅
|
|
31
|
-
html`<div class="tab ${() => active() ? "is-active" : ""}">` // ❌ THROWS
|
|
32
|
-
```
|
|
33
|
-
|
|
34
|
-
Partial values (`"static ${…}"`) are not registered as placeholders and throw
|
|
35
|
-
`Invalid HTML position`. Build the full string in one expression.
|
|
36
|
-
|
|
37
|
-
Returning `false` from an attribute expression **removes** the attribute (vs `""`
|
|
38
|
-
which keeps it present-but-empty) — the clean way to toggle `disabled`/`hidden`:
|
|
39
|
-
|
|
40
|
-
```ts
|
|
41
|
-
html`<button disabled="${() => !canSubmit()}">Save</button>`
|
|
42
|
-
```
|
|
43
|
-
|
|
44
|
-
## Properties, events, lists
|
|
45
|
-
|
|
46
|
-
```ts
|
|
47
|
-
html`<input .value="${() => data.text}" />` // .prop = IDL property
|
|
48
|
-
html`<button @click="${() => data.count++}">+</button>` // @event
|
|
49
|
-
html`${() => data.items.map((i) => html`<li>${i.text}</li>`.key(i.id))}` // keyed list
|
|
50
|
-
```
|
|
51
|
-
|
|
52
|
-
Keyed lists preserve DOM across reorders; mutate item fields in place for
|
|
53
|
-
fine-grained updates instead of replacing the whole array.
|
|
54
|
-
|
|
55
|
-
## Async sections
|
|
56
|
-
|
|
57
|
-
```ts
|
|
58
|
-
import { boundary } from "@arrow-js/framework"
|
|
59
|
-
const Card = component(
|
|
60
|
-
async () => { const d = await load(); return html`<div>${d.label}</div>` },
|
|
61
|
-
{ fallback: html`<div>Loading…</div>` } // shown while pending
|
|
62
|
-
)
|
|
63
|
-
html`${boundary(Card())}`
|
|
64
|
-
```
|
|
65
|
-
|
|
66
|
-
Works client-side with no SSR. `boundary()` only takes `{ idPrefix }`; the
|
|
67
|
-
visible loading state comes from the async component's `fallback` option.
|
|
68
|
-
|
|
69
|
-
## Event handler typing
|
|
70
|
-
|
|
71
|
-
An `@event` handler must be assignable to `(e: Event) => void`. A handler typed
|
|
72
|
-
with a narrowed subtype fails (parameter contravariance) — `tsc` reports
|
|
73
|
-
`TS2345 … not assignable to 'ArrowExpression'`. Type the param `Event` and
|
|
74
|
-
narrow inside; no-arg handlers are always fine.
|
|
75
|
-
|
|
76
|
-
```ts
|
|
77
|
-
// ❌ TS2345 — narrowed param
|
|
78
|
-
html`<div @mousedown="${(e: MouseEvent) => resize(e)}">`
|
|
79
|
-
// ✅ widen, narrow inside
|
|
80
|
-
html`<div @mousedown="${(e: Event) => resize(e as MouseEvent)}">`
|
|
81
|
-
```
|
|
82
|
-
|
|
83
|
-
(document.addEventListener handlers are unaffected — they're DOM-lib typed.)
|
|
84
|
-
|
|
85
|
-
## Hard footguns
|
|
86
|
-
|
|
87
|
-
Render-time (pass `tsc`, fail only at render — always verify in a browser):
|
|
88
|
-
|
|
89
|
-
1. **No literal HTML comments inside templates.** Arrow uses HTML comments as
|
|
90
|
-
expression-slot markers, so a literal `<!-- … -->` inflates the slot count and
|
|
91
|
-
throws `Invalid HTML position`. Use JS `//` comments outside the template.
|
|
92
|
-
2. **No partial attribute values** — the expression must be the whole value
|
|
93
|
-
(see Attributes above), else `Invalid HTML position`.
|
|
94
|
-
|
|
95
|
-
Type-time (caught by `tsc`):
|
|
96
|
-
|
|
97
|
-
3. **No narrowed `Event` subtype in `@event` handlers** (see Event handler
|
|
98
|
-
typing above).
|
|
99
|
-
|
|
100
|
-
CI guards all three: `test/template-footguns.test.mjs` scans for `<!--` and for
|
|
101
|
-
inline handlers typed with a narrowed Event subtype; `tsc` covers the rest.
|
|
@@ -1,64 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: obsidian-arrow-sandbox
|
|
3
|
-
description: Use when prototyping or building Arrow.js UI for an Obsidian plugin in the obsidian-arrow-sandbox project — covers running the sandbox, pulling Obsidian's real app.css from the local install, the dev/verify workflow, CSS scoping, and porting a finished component into a plugin's ItemView with near-zero refactoring.
|
|
4
|
-
---
|
|
5
|
-
|
|
6
|
-
# Obsidian Arrow Sandbox
|
|
7
|
-
|
|
8
|
-
A client-only Vite + TypeScript environment for building [Arrow.js](https://arrow-js.com/)
|
|
9
|
-
UI that drops into an Obsidian plugin. Components render against Obsidian's real
|
|
10
|
-
`app.css`, so what you see in the browser is what you get in a plugin view.
|
|
11
|
-
|
|
12
|
-
## Mental model
|
|
13
|
-
|
|
14
|
-
- **Client-only, no SSR.** Components use `@arrow-js/core` (`reactive`, `html`,
|
|
15
|
-
`component`, `watch`) plus `@arrow-js/framework` (`boundary` for async
|
|
16
|
-
sections), mounted via `template(container)` — the exact call an Obsidian
|
|
17
|
-
`ItemView.onOpen()` makes. Do **not** add `@arrow-js/ssr` or `@arrow-js/hydrate`;
|
|
18
|
-
an Obsidian plugin has no server and nothing to hydrate.
|
|
19
|
-
- **Styling is Obsidian's, not yours.** `index.html` puts Obsidian body classes
|
|
20
|
-
(`theme-dark mod-macos …`) on `<body>` and loads the extracted `app.css`, which
|
|
21
|
-
defines every `var(--…)` token and semantic class (`.setting-item`,
|
|
22
|
-
`.clickable-icon`, `.vertical-tab-*`).
|
|
23
|
-
|
|
24
|
-
## Run it
|
|
25
|
-
|
|
26
|
-
```sh
|
|
27
|
-
pnpm install
|
|
28
|
-
pnpm pull-css # extract Obsidian's app.css (macOS auto-detect)
|
|
29
|
-
pnpm dev # Vite + HMR; open the printed URL
|
|
30
|
-
```
|
|
31
|
-
|
|
32
|
-
`pnpm pull-css` reads `app.css` out of `obsidian.asar`. macOS is auto-detected;
|
|
33
|
-
elsewhere pass `--path <obsidian.asar|app.css>` or set `OBSIDIAN_ASAR=<path>`.
|
|
34
|
-
`public/app.css` is **git-ignored** (Obsidian's proprietary CSS — not
|
|
35
|
-
redistributed), so run `pnpm pull-css` once before `pnpm dev`.
|
|
36
|
-
|
|
37
|
-
## Build a component
|
|
38
|
-
|
|
39
|
-
Add `src/components/MyThing.ts` exporting an Arrow `component()`, then mount it
|
|
40
|
-
from `src/main.ts`. Use Obsidian classes + `var(--…)` tokens first; add custom
|
|
41
|
-
CSS only when there's no Obsidian class, and scope it under a container class +
|
|
42
|
-
element type (e.g. `.oas-frame button.oas-x`) so it beats Obsidian's global
|
|
43
|
-
`button:not(.clickable-icon)` rule. Sandbox-only chrome lives in
|
|
44
|
-
`src/sandbox/sandbox.css`.
|
|
45
|
-
|
|
46
|
-
For the template-writing rules and Arrow's hard footguns, use the companion
|
|
47
|
-
skill **arrow-js-obsidian-templates**.
|
|
48
|
-
|
|
49
|
-
## Verify before claiming done
|
|
50
|
-
|
|
51
|
-
```sh
|
|
52
|
-
pnpm typecheck && pnpm test && pnpm lint # or: pnpm check
|
|
53
|
-
```
|
|
54
|
-
|
|
55
|
-
Then open the `pnpm dev` URL and confirm the console is clean and the component
|
|
56
|
-
looks like a real Obsidian pane. Arrow's footguns only surface at render, so
|
|
57
|
-
typecheck passing is **not** proof a component works.
|
|
58
|
-
|
|
59
|
-
## Port into the plugin
|
|
60
|
-
|
|
61
|
-
Copy the component file into the plugin's view directory and mount it from
|
|
62
|
-
`ItemView.onOpen()` via `template(this.contentEl)`. If it uses `boundary()` /
|
|
63
|
-
async components, add `@arrow-js/framework` to the plugin and the side-effect
|
|
64
|
-
`import '@arrow-js/framework'`. Leave sandbox chrome (`src/sandbox/*`) behind.
|