Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b502dbf727 | ||
|
|
0a3e92234f | ||
|
|
3d18829e54 | ||
|
|
89c16624db | ||
|
|
12e5bb5b45 | ||
|
|
90f549259d | ||
|
|
7b0b207fea | ||
|
|
7476ee95c7 | ||
|
|
fa6c4a1612 | ||
|
|
d6521983c6 | ||
|
|
11da54aaa1 | ||
|
|
eecb38e85f | ||
|
|
a6a6ecf7d3 | ||
|
|
ede8645637 | ||
|
|
bc1d21e8aa | ||
|
|
7f6f5b7192 | ||
|
|
c6795e7b5e | ||
|
|
2fa04a2818 | ||
|
|
a821832663 | ||
|
|
79525b0789 | ||
|
|
693e978433 | ||
|
|
0bcca4ee46 | ||
|
|
9d3c6a9851 | ||
|
|
966ab4b1aa | ||
|
|
2f489002e1 | ||
|
|
51a859b095 | ||
|
|
9e05e1fb66 | ||
|
|
6942159d6a | ||
|
|
dee9fae328 | ||
|
|
c91299e684 | ||
|
|
9364eb84f5 | ||
|
|
4fb2c99771 | ||
|
|
11ac437fa3 | ||
|
|
bb88a8c324 | ||
|
|
45c2fa9bc4 | ||
|
|
cd8d23a750 | ||
|
|
c246360b97 | ||
|
|
f51b752fd7 | ||
|
|
e594bc2adf | ||
|
|
2e2ae15875 | ||
|
|
60f911a0f8 | ||
|
|
abae630cc7 | ||
|
|
a03ce72c81 | ||
|
|
975581e111 | ||
|
|
e4ab2186e3 | ||
|
|
1d13048982 | ||
|
|
84fc961fc2 | ||
|
|
df5d38d625 | ||
|
|
30fed89ce3 | ||
|
|
03d2b7bde5 | ||
|
|
678a0eb403 | ||
|
|
f1add58631 | ||
|
|
928e33cc5f | ||
|
|
80cc323f31 | ||
|
|
a5dfe52511 | ||
|
|
8e28b40655 | ||
|
|
370fdd8388 | ||
|
|
d84bd2d60b | ||
|
|
d50dd8d601 | ||
|
|
4c603a20db | ||
|
|
054a777ec1 | ||
|
|
6a5bbb40ef | ||
|
|
f18be1eaff | ||
|
|
02a526a53c | ||
|
|
0dcb562801 | ||
|
|
1b7576a55f | ||
|
|
8c03e72d69 | ||
|
|
a16c5324e2 | ||
|
|
bd58aa679a | ||
|
|
6dc860ea5c | ||
|
|
9d8a5ab00e | ||
|
|
8a1b820ab3 | ||
|
|
0722da2a98 | ||
|
|
133c8df491 | ||
|
|
19727ade47 | ||
|
|
fab8d0d20f | ||
|
|
e49df411d7 | ||
|
|
82b568efbb | ||
|
|
32f0513420 | ||
|
|
22c29aa090 | ||
|
|
5e8d0428d4 | ||
|
|
33542e1239 | ||
|
|
42f30efbe7 | ||
|
|
f3a10664ab | ||
|
|
76bcd3629d | ||
|
|
f5652f4286 | ||
|
|
1fc705d8da | ||
|
|
4cfe1f6d14 | ||
|
|
a20a7b058b | ||
|
|
8abab1ee3d | ||
|
|
af66ca263e | ||
|
|
79163fce79 | ||
|
|
86c36e0f52 | ||
|
|
7a7b632f06 | ||
|
|
d80fd24254 | ||
|
|
2917c04c38 | ||
|
|
7a4703546e | ||
|
|
82b6c6eb37 | ||
|
|
a12ee2087c | ||
|
|
51c17565b8 | ||
|
|
625cab7682 | ||
|
|
48ee214f96 | ||
|
|
89b7827534 | ||
|
|
9a98626dd7 | ||
|
|
f4bf3d4635 | ||
|
|
2eeb90052e | ||
|
|
6626aca3d7 | ||
|
|
da0daf47e0 | ||
|
|
f33ac4c980 | ||
|
|
9d183d6779 | ||
|
|
346e43caa1 | ||
|
|
c4ca4472ba | ||
|
|
746f3ad2db | ||
|
|
759626b914 | ||
|
|
6c29f738c5 | ||
|
|
3810a37348 | ||
|
|
e4aabe6313 | ||
|
|
c5d11c03ec | ||
|
|
52165471c0 | ||
|
|
03f61a2b5e | ||
|
|
79bdb457cb |
@@ -0,0 +1,77 @@
|
||||
name: Bundle size delta
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "src/**"
|
||||
- "package.json"
|
||||
- "pnpm-lock.yaml"
|
||||
- "vite.config.*"
|
||||
- "scripts/check-bundle-size.mjs"
|
||||
- ".github/workflows/bundle-size-pr.yml"
|
||||
|
||||
# Compute the gzip-bundle-size delta between the PR head and the base branch
|
||||
# and post it as a single sticky comment on the pull request. Lets reviewers
|
||||
# spot regressions ("did this 5 KB ship intentionally?") without digging
|
||||
# through CI logs.
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
|
||||
jobs:
|
||||
size-delta:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
# Setting permissions: explicitly REMOVES default scopes — must list
|
||||
# contents: read so actions/checkout can fetch the repo, otherwise
|
||||
# the runner gets "Repository not found" with a 404.
|
||||
contents: read
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Checkout PR head
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
path: head
|
||||
- name: Checkout base
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.base.sha }}
|
||||
path: base
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
# Explicit version because both checkouts land in subdirs
|
||||
# (head/, base/) so there's no package.json at the runner's CWD
|
||||
# root for the action to autodetect "packageManager" from.
|
||||
version: 9
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: pnpm
|
||||
cache-dependency-path: head/pnpm-lock.yaml
|
||||
- name: Build + measure HEAD
|
||||
working-directory: head
|
||||
run: |
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm build:extension
|
||||
node scripts/check-bundle-size.mjs --json ../sizes/head.json
|
||||
- name: Build + measure BASE
|
||||
working-directory: base
|
||||
run: |
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm build:extension
|
||||
# The size script resolves ../js relative to its own file URL,
|
||||
# so we overwrite base/scripts/check-bundle-size.mjs with the head
|
||||
# version to guarantee the JSON manifest format matches what
|
||||
# bundle-size-diff expects, while keeping the relative-path read.
|
||||
mkdir -p scripts
|
||||
cp ../head/scripts/check-bundle-size.mjs scripts/check-bundle-size.mjs
|
||||
node scripts/check-bundle-size.mjs --json ../sizes/base.json
|
||||
continue-on-error: true
|
||||
- name: Diff manifests
|
||||
run: node head/scripts/bundle-size-diff.mjs sizes/base.json sizes/head.json > report.md
|
||||
- name: Upsert PR comment
|
||||
uses: marocchino/sticky-pull-request-comment@v2
|
||||
with:
|
||||
header: bundle-size
|
||||
path: report.md
|
||||
@@ -23,9 +23,19 @@ jobs:
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- run: pip install -e ".[dev]"
|
||||
- run: pip install -e ".[dev]" pytest-cov
|
||||
- run: ruff check .
|
||||
- run: pytest
|
||||
- run: pytest --ignore=tests/syntax/test_fuzz.py --ignore=tests/syntax/test_perf.py
|
||||
|
||||
- name: Python syntax tests with coverage
|
||||
run: >
|
||||
pytest tests/syntax/
|
||||
--ignore=tests/syntax/test_fuzz.py
|
||||
--ignore=tests/syntax/test_perf.py
|
||||
-v --cov=engine/syntax --cov-fail-under=80
|
||||
|
||||
- name: Determinism + parity gate
|
||||
run: pytest tests/syntax/test_determinism.py -v
|
||||
|
||||
frontend:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -39,7 +49,61 @@ jobs:
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm typecheck
|
||||
- run: pnpm lint
|
||||
- run: pnpm test
|
||||
- run: pnpm test:coverage
|
||||
- name: TS syntax tests (parity + snapshot)
|
||||
run: pnpm vitest run src/manager/__tests__/syntax/
|
||||
- run: pnpm build:extension
|
||||
- run: pnpm size
|
||||
- run: pnpm build:manager
|
||||
- name: Upload coverage report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: coverage
|
||||
path: coverage/
|
||||
retention-days: 14
|
||||
|
||||
# Perf regression — single-CPU pin for variance reduction
|
||||
# Non-blocking until budgets are recalibrated against real-world Linux runner
|
||||
# numbers. Pure-Python single-pass tokenize/resolve is ~1-3M chars/sec; the
|
||||
# spec's 250ms/500ms targets assumed C-extension-class perf which we do not
|
||||
# have. Treat as advisory benchmark for now.
|
||||
perf:
|
||||
name: Perf gate (advisory)
|
||||
runs-on: ubuntu-latest
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.12"
|
||||
- run: pip install -e ".[dev]"
|
||||
- name: Perf regression (single-CPU pin)
|
||||
run: |
|
||||
if command -v taskset >/dev/null 2>&1; then
|
||||
taskset -c 0 pytest tests/syntax/test_perf.py -m perf -v --strict-markers
|
||||
else
|
||||
pytest tests/syntax/test_perf.py -m perf -v --strict-markers
|
||||
fi
|
||||
|
||||
# Fuzz — opt-in, non-blocking (failures are advisory)
|
||||
fuzz:
|
||||
name: Fuzz (non-blocking)
|
||||
runs-on: ubuntu-latest
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.12"
|
||||
- run: pip install -e ".[dev]"
|
||||
- name: Python fuzz
|
||||
run: pytest tests/syntax/test_fuzz.py -m fuzz -v --strict-markers
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: pnpm
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- name: TS fuzz
|
||||
run: pnpm vitest run src/manager/__tests__/syntax/fuzz.test.ts
|
||||
|
||||
@@ -14,8 +14,10 @@ pnpm-debug.log
|
||||
|
||||
# Build outputs
|
||||
js/
|
||||
web_dist/
|
||||
web/*
|
||||
!web/index.html
|
||||
coverage/
|
||||
.vitest-cache/
|
||||
|
||||
# Editor / OS
|
||||
.vscode/
|
||||
@@ -25,7 +27,13 @@ Thumbs.db
|
||||
|
||||
# Claude / Superpowers workspace artifacts
|
||||
.superpowers/
|
||||
.claude/worktrees/
|
||||
.claude/
|
||||
# Backups dropped by /caveman:compress next to compressed files
|
||||
*.original.md
|
||||
|
||||
# Docs — ignore everything except per-node help pages
|
||||
/docs/*
|
||||
!/docs/help/
|
||||
|
||||
# Semantic release
|
||||
.releaserc.local.js
|
||||
|
||||
@@ -3,6 +3,12 @@ pnpm lint || exit 1
|
||||
pnpm typecheck || exit 1
|
||||
pnpm test || exit 1
|
||||
|
||||
# Bundle size budget — build the extension chunk and run the size check so
|
||||
# regressions get caught locally before the CI flags them on PR. Skips when
|
||||
# js/ output already matches the source by mtime to keep repeat commits fast.
|
||||
pnpm build:extension || exit 1
|
||||
pnpm size || exit 1
|
||||
|
||||
# Python checks — only if ruff/pytest reachable on PATH
|
||||
if command -v ruff >/dev/null 2>&1; then
|
||||
ruff check . || exit 1
|
||||
|
||||
@@ -1,3 +1,132 @@
|
||||
## [1.4.2](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/compare/v1.4.1...v1.4.2) (2026-04-28)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **spa:** @ autocomplete inserts UUID, editor cross-refs, derivation $ vars ([89c1662](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/89c16624db8c8859a9ba2c683afb5e549b6ecd5b))
|
||||
* **spa:** @ autocomplete UUIDs, editor cross-refs, derivation $ vars ([0a3e922](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/0a3e92234ff1a4dd24905942ceb407a1b8ee3ec7))
|
||||
* **spa:** extract 8hex UUID suffix from module DB id for `@{uuid}` refs ([3d18829](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/3d18829e54b52eaac64d7a854aabe40906a39da6))
|
||||
|
||||
## [1.4.1](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/compare/v1.4.0...v1.4.1) (2026-04-28)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **engine:** bump now_iso() to microsecond precision ([fa6c4a1](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/fa6c4a1612ec94d2397a6db92a716d1288c09d89))
|
||||
* post-merge CI + docs/ ignore ([90f5492](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/90f549259ded6726948738fdea2d4eefdf3c313e))
|
||||
|
||||
# [1.4.0](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/compare/v1.3.0...v1.4.0) (2026-04-28)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **api:** atomic import + recursion guard + negative pagination + dry now_iso ([82b6c6e](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/82b6c6eb37469b1f515639d0883b69f06be8f85b))
|
||||
* **api:** run migrations on register_routes so production db has tables ([1fc705d](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/1fc705d8da7b1ea7a2ae77f3449816bc6d9e4847))
|
||||
* **api:** use streamresponse return type for spa fallback handlers ([a12ee20](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/a12ee2087c079ac3cab13020abaecd023f107bfd))
|
||||
* **ci:** python 3.10 datetime compat + rebaseline perf budgets ([eecb38e](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/eecb38e85f170b807f109f7149d25eb6d60406e3))
|
||||
* **db:** drop utcnow + atomic migrations + row factory test ([3810a37](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/3810a373489fbcbaed204017b6ad9626e1706938))
|
||||
* **db:** id entropy + like escaping + ms timestamps + sentinel typing ([759626b](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/759626b9148345af319e877699e85856e13d084b))
|
||||
* **db:** unset sentinel for required fields + slug validation + delete consistency ([c4ca447](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/c4ca4472ba44bab4259fd5590f25bd8e5570d91c))
|
||||
* **engine:** explicit type guard in resolve_module for malformed snapshots ([da0daf4](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/da0daf47e0fc8f781179d6bb35d185ac9f503f29))
|
||||
* **engine:** isinstance guard avoids dataclass typeguard issue in handlers ([f4bf3d4](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/f4bf3d4635a53c57214340a6c00a5a38923f942b))
|
||||
* **engine:** read module id from raw dict to satisfy union narrowing ([9a98626](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/9a98626dd767ae50fc6c6e03ced9a34a68b19f9a))
|
||||
* **manager:** favorites filter as wp-input box matching prototype ([c246360](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/c246360b97aba838e782e5c4dc19852c7baf7a62))
|
||||
* **spa:** align theme tokens with extension source of truth ([2917c04](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/2917c04c3897b40d23287ec152883ac77488d84f))
|
||||
* **spa:** compact buttons + click-to-copy id + logo backdrop + dollar icon + hex color picker ([19727ad](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/19727ade47e057a10cb441342d5b0bab377435cf))
|
||||
* **spa:** enable primevue dark mode via .wp-dark class on documentelement ([fab8d0d](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/fab8d0d20fcd4eb1ca5d6998c984b8ba424e9435))
|
||||
* **spa:** error handling on store actions + exact-active-class + matchmedia setup ([4cfe1f6](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/4cfe1f6d1487c4bb12b5daa0bbd4b4db57ac6106))
|
||||
* **spa:** light-mode + dropdown polish for chips, pills, selects ([cd8d23a](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/cd8d23a750cc5a8dd7c8f0aa195f95fe61e14a72)), closes [#a78bfa](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/issues/a78bfa) [#c026d3](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/issues/c026d3)
|
||||
* **spa:** wrap tailwind in named layers so primevue specificity orders correctly ([f5652f4](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/f5652f4286e6b0cbcd6290bf0704f9c335ab8ba2))
|
||||
* **syntax:** drop vacuous "comment" check in richTokenize.test ([dee9fae](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/dee9fae32808697aeaeb28a52caa24baeaa12801))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **api:** /wp/api/modules crud endpoints ([89b7827](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/89b7827534e9814f93cac73d4c4c9261f3b4c9c6))
|
||||
* **api:** snapshot + match + duplicate + favorite + categories endpoints ([48ee214](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/48ee214f9667eadae372a17b6f25dc58eb8cf42f))
|
||||
* **api:** spa fallback route + comfyui registration + coverage gates ([51c1756](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/51c17565b8ea28864d076e5829d9689ef4f15967))
|
||||
* **api:** test runner + library import/export endpoints ([625cab7](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/625cab76820eaa6aa6b31cff91d3284f5a40c5b9))
|
||||
* **db:** category repository with crud ([746f3ad](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/746f3ad2db6e2e5430c2dde79671bbecbeb0322c))
|
||||
* **db:** module repository with crud and filtered list ([6c29f73](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/6c29f738c50575329ce240c1cc7cc4c6990370dc))
|
||||
* **db:** sqlite library foundation + migration runner ([e4aabe6](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/e4aabe6313265a263bca1d3790d43b645f4366c0))
|
||||
* **engine:** fixedvalueshandler + route legacy dispatch through it ([2eeb900](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/2eeb90052e34952d648e3b8e4456c2b2a0439164))
|
||||
* **engine:** module dispatcher + modulehandler abc ([f33ac4c](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/f33ac4c9803b011f4efb26694f0d674c72970677))
|
||||
* **engine:** scaffold combine/derivation/constraint/pipeline module types ([a16c532](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/a16c5324e281ffba6a08d3eda46f3deecf2faefa))
|
||||
* **engine:** snapshot freezing + payload hash + legacy coercion ([9d183d6](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/9d183d6779a9a6ce1a9ebbdd558478420883000d))
|
||||
* **engine:** wildcardhandler with weighted rng and @{ref} resolution ([6626aca](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/6626aca3d7cde2661d6276e867c362e6a9fecb34))
|
||||
* **manager:** favorites filter as chip toggle box in filter panel ([f51b752](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/f51b752fd7a5ad84d3e9e785c4f2c9e98b4b1c84))
|
||||
* **manager:** surface-conditional @ autocomplete + warning highlights ([7f6f5b7](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/7f6f5b719276019e3409e213cd4e44997977c7a6))
|
||||
* **manager:** wildcard.max_ref_depth setting (1-32, default 8) ([bc1d21e](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/bc1d21e8aa040c6d6bef0c932b16c9b9bfa88663))
|
||||
* **spa:** add constraints to soon-disabled module types ([9d8a5ab](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/9d8a5ab00e565c163c93bba7ce2e5acdce04e96f))
|
||||
* **spa:** categories color picker (hex + swatch + presets) + sticky-header scroll ([f18be1e](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/f18be1eaff1cc197ef03251a1a23f4bfdddb2115))
|
||||
* **spa:** categories manager + import/export + test runner ([a20a7b0](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/a20a7b058b64f4b5347de39491e4828c15af0066))
|
||||
* **spa:** community tab — discover/detail/upload/profile + offline/404 (mock) ([f1add58](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/f1add58631286260cde31332e2a1bb40f8a256e7))
|
||||
* **spa:** dashboard hero + branded categories/import/test pages ([e49df41](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/e49df411d7f56dc89a309f5f5fff057fa32c324a))
|
||||
* **spa:** datatable expansion + category color chip + tags column ([133c8df](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/133c8df49166fda2f75faba5d79fac44a295781c))
|
||||
* **spa:** entity list view redesign with active-filter chips + pagination ([370fdd8](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/370fdd838828d96667d48d21c047d128d69325dd))
|
||||
* **spa:** entitylistview shared list skeleton ([5e8d042](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/5e8d0428d4da0fac6c5dcbe2da6c495ab96a79c7))
|
||||
* **spa:** fixedvalues.vue per-type list page ([32f0513](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/32f05134202cc035e992f5f4b064971e1a9d318a))
|
||||
* **spa:** full-page wildcard + fixedvalue forms with sticky footer ([82b568e](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/82b568efbb1c75f89980048c5cc09858ea5a42ba))
|
||||
* **spa:** import/export with per-item selection + conflict resolution ([a5dfe52](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/a5dfe52511e81251d333d6b7cfbfd37e48f8ff00))
|
||||
* **spa:** kind-aware Test Runner with per-type result panels ([8e28b40](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/8e28b40655930db4f48042f21f7c589149b1440d))
|
||||
* **spa:** library datatable view with filters + row actions ([af66ca2](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/af66ca263e49198fe46efb7e187d6fce2133408a))
|
||||
* **spa:** per-module version history with last-3 snapshots and restore panel ([678a0eb](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/678a0eb40317cfad442005908378ccb98e6439dc))
|
||||
* **spa:** per-type routes + drop polymorphic modules.vue ([33542e1](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/33542e123994f462060b341de2c1c25f2a222bb5))
|
||||
* **spa:** pinia stores for modules + categories + ui ([86c36e0](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/86c36e0f52eba4abfb37a4f1621c928ee94e777d))
|
||||
* **spa:** polish fixed-value form with \$name InputGroup + per-row identifier validation ([928e33c](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/928e33cc5ff445ca11e219fee2fb1a9acadd9a64))
|
||||
* **spa:** port community sub-screens + drop PrimeVue dependency (Wave 7+8) ([e4ab218](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/e4ab2186e33f04908ff43b0699635bc3f3a982c8))
|
||||
* **spa:** port prototype dashboard + utilities views (Wave 5) ([1d13048](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/1d1304898267ca0f9521adf16b19712cdef8e1a9))
|
||||
* **spa:** port prototype editors with EditorFrame scaffold (Wave 4) ([84fc961](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/84fc961fc2f4c31be250ef011e1c6f88430439f4))
|
||||
* **spa:** port prototype ModuleListView + drop PrimeVue from list screens (Wave 3) ([df5d38d](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/df5d38d6250c7856e34b2ac2a7b80ff649da8e0d))
|
||||
* **spa:** port prototype shell — topbar + sidebar 1:1, drop PrimeVue (Wave 2) ([30fed89](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/30fed89ce3aad313faad8b138eed808766763f56))
|
||||
* **spa:** port prototype ui primitives + full styles.css (Wave 1) ([03d2b7b](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/03d2b7bde50cd984bf312e73c6f0d200fc731a82))
|
||||
* **spa:** primevue brand preset derived from --wp-* tokens ([76bcd36](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/76bcd3629d609ce79652109e63025b7027104c6e))
|
||||
* **spa:** real Combine editor with template + RichTextInput + detected inputs ([d50dd8d](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/d50dd8d6015fa01466c34470833ac7ed6b9bf2e8))
|
||||
* **spa:** real Constraint editor with matrix grid + factor tune popovers ([02a526a](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/02a526a53c7972382ca06742f7d3f8ad73bdfdeb))
|
||||
* **spa:** real Derivation editor with multi-rule IF/ELIF/ELSE ([4c603a2](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/4c603a20db432d9bb4a5c785411af2aa543817ba))
|
||||
* **spa:** real Pipeline editor with steps stack + module picker modal ([054a777](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/054a777ec1522d07fe017f81bacbfdc302df6100))
|
||||
* **spa:** redesign Dashboard with brand hero + 6 kind stats + recent/favorites ([0dcb562](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/0dcb562801dd1d0fe395bbe1aedbda037ef87e85))
|
||||
* **spa:** rich text input + preview with token chips and autocomplete ([6a5bbb4](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/6a5bbb40ef56f66fa1ea1063aa8ddc5f4cedc700))
|
||||
* **spa:** rich-text input — restore $/@ autocomplete dropdown with keyboard nav ([60f911a](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/60f911a0f8e2e32805e3153b698939e99e90123f))
|
||||
* **spa:** sakai-style topbar + sidebar layout ([79163fc](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/79163fce79e558c61f201cc44de6ab7405635a0b))
|
||||
* **spa:** scaffold combine/derivation/constraint/pipeline list + edit views ([8c03e72](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/8c03e72d69785a7ae2b7101250cd35987e307231))
|
||||
* **spa:** shared typepill + relativedate components ([f3a1066](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/f3a10664ab64bc1da83914629dbfe07d1adcda71))
|
||||
* **spa:** tags editor on forms + sort dropdown + category/tags filters ([0722da2](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/0722da2a98ae4f544a94df241faf17dad40ac0d2))
|
||||
* **spa:** theme toggle (dark/light/auto) with flash-suppression ([1b7576a](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/1b7576a55fb33af05b2f4ca6355354d1a032ab2e))
|
||||
* **spa:** topbar logo + 4-section sakai sidebar with primeicons ([42f30ef](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/42f30efbe7c699d9f7ab2565cee37ce48ec123a2))
|
||||
* **spa:** tweaks panel — accent palette + density + sidebar runtime switcher (Wave 6) ([975581e](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/975581e111f1acfb13ec421a925af68a6fda8592))
|
||||
* **spa:** typed api client wrapping /wp/api/* ([7a7b632](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/7a7b632f0693976b20b16ed5ef4c2aacb8dd7890))
|
||||
* **spa:** vite + tailwind + primevue scaffold ([7a47035](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/7a4703546edc2467a75b4c132915bdf666e4dd70))
|
||||
* **spa:** vue router + 7 stub views ([d80fd24](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/d80fd242545ef794aa0b013f1ff1178f65242919))
|
||||
* **spa:** wildcard + fixed-values editors ([8abab1e](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/8abab1ee3d0e83604030c2e97e777e23275dc86a))
|
||||
* **spa:** wildcard form gains varBinding field + RichTextInput on option values ([d84bd2d](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/d84bd2d60b048fe6790d629d3b46067304050e8c))
|
||||
* **spa:** wildcard sub-categories define + assign per option ([8a1b820](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/8a1b820ab3f3998d1abcb7160d19c549daa74c52))
|
||||
* **spa:** wildcards list — syntax indicator column + nested-ref filters ([80cc323](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/80cc323f31b394c95a4de6f9e429bbf6fd6cd1db))
|
||||
* **spa:** wildcards.vue with full datatable + filter + bulk select ([22c29aa](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/22c29aa090511b352c80035c60702435f0da431a))
|
||||
* **syntax:** _resolve_inline_pick — uniform random branch + recurse ([2f48900](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/2f489002e17de9ea092a05dde1b069f3edf0fbdc))
|
||||
* **syntax:** _resolve_multi_pick — weighted without-replacement + sep join ([966ab4b](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/966ab4b1aac965f7b8601517a8ddfeb534aeb7a5))
|
||||
* **syntax:** _resolve_ref with surface/depth/cycle checks + weighted pick ([51a859b](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/51a859b0956091fee9e296097b9b6779dd99b52d))
|
||||
* **syntax:** foundation types, error classes, ResolveContext Protocol ([45c2fa9](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/45c2fa9bc48cecb25410aa04fd3ee993b442afb8))
|
||||
* **syntax:** resolve_text dispatch loop with text/escape/var resolution ([9e05e1f](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/9e05e1fb668edc9bcabbcfeda154e90c1682e4ea))
|
||||
* **syntax:** resolveTokens TS twin of Python resolve_text ([9d3c6a9](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/9d3c6a9851be55d60ea24df8abc9953f662eda38))
|
||||
* **syntax:** tokenize $var and @{uuid} kinds + corpus cases ([4fb2c99](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/4fb2c997714d9bfc51f806b65a02b3bba8c0f4be))
|
||||
* **syntax:** tokenize dp_brace + dp_multi with nesting support ([9364eb8](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/9364eb84f5899ab7d2e2dc2111f4c769d3c2c384))
|
||||
* **syntax:** tokenize_text — text + escape kinds with lossless invariant ([11ac437](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/11ac437fa391edcacdf210b8cb3e9d73e7c48cf7))
|
||||
|
||||
# [1.3.0](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/compare/v1.2.0...v1.3.0) (2026-04-26)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* i18n locales (en) + per-node help pages ([#2](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/issues/2)) ([5216547](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/52165471c0da315db59d2b2fb72537d7b06d62ef))
|
||||
|
||||
# [1.2.0](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/compare/v1.1.0...v1.2.0) (2026-04-26)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* polish suite — MVP frontend + subgraph support + DX + a11y ([79bdb45](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/commit/79bdb457cbc98e191766e3fa7c84df2e1106ecc2))
|
||||
|
||||
# [1.1.0](https://github.com/DumiFlex/ComfyUI-WildcardPipeline/compare/v1.0.0...v1.1.0) (2026-04-24)
|
||||
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
# CLAUDE.md — project conventions
|
||||
|
||||
This file teaches future Claude (and human) contributors the load-bearing conventions of this codebase. Keep it short and honest.
|
||||
File teach future Claude (and human) contributors load-bearing conventions of codebase. Keep short, honest.
|
||||
|
||||
## Stack
|
||||
|
||||
- Python ≥ 3.10 (target 3.10/3.11/3.12 in CI; embedded runtime is 3.12.10).
|
||||
- Python ≥ 3.10 (target 3.10/3.11/3.12 in CI; embedded runtime 3.12.10).
|
||||
- ComfyUI V3 API (`comfy_api.latest`) — no V1 `NODE_CLASS_MAPPINGS`.
|
||||
- Vue 3 + Vite 5 + pnpm. Strict TypeScript.
|
||||
- Vitest for frontend, pytest for Python.
|
||||
@@ -12,11 +12,12 @@ This file teaches future Claude (and human) contributors the load-bearing conven
|
||||
## Directory contract
|
||||
|
||||
- `engine/` — pure Python, **zero ComfyUI imports**. Testable with vanilla pytest.
|
||||
- `nodes/` — ComfyUI V3 nodes. All ComfyUI imports wrapped in `try/except ImportError` so the package remains importable outside ComfyUI for tests.
|
||||
- `src/` — Vue/TS frontend. Two Vite targets: `extension` (critical path, budget-gated) and `manager` (post-MVP SPA).
|
||||
- `tests/` — Python tests. Uses `conftest.py` sys.path shim.
|
||||
- `scripts/` — Node helpers (bundle-size gate, release helpers).
|
||||
- `docs/superpowers/{specs,plans}/` — design docs and implementation plans.
|
||||
- `wp_nodes/` — ComfyUI V3 nodes. Named `wp_nodes` (not `nodes`) because ComfyUI repo-level `nodes.py` shadow any top-level `nodes/` package on `sys.path`.
|
||||
- `src/` — Vue/TS frontend. Two Vite targets: `extension` (critical path, budget-gated) + `manager` (post-MVP SPA).
|
||||
- `tests/` — Python tests. Use `conftest.py` sys.path shim.
|
||||
- `scripts/` — Node helpers (bundle-size gate, size-delta diff, release helpers).
|
||||
- `docs/widget-context-contract.md` — single canonical engine ↔ widget JSON shape doc. Update both Python (`engine/modules.py`) + TS (`src/widgets/_shared.ts`) sides in one PR when shape change.
|
||||
- `docs/superpowers/{specs,plans}/` — gitignored per-contributor design docs + impl plans.
|
||||
|
||||
## Commands
|
||||
|
||||
@@ -24,31 +25,45 @@ This file teaches future Claude (and human) contributors the load-bearing conven
|
||||
pnpm build:extension # produces js/main.js
|
||||
pnpm build:manager # produces web_dist/
|
||||
pnpm test # Vitest
|
||||
pnpm test:coverage # Vitest + v8 coverage (with thresholds)
|
||||
pnpm typecheck # vue-tsc --noEmit
|
||||
pnpm lint # ESLint flat config
|
||||
pnpm size # bundle-size gate
|
||||
pnpm size # bundle-size gate (--json <path> for manifest)
|
||||
pytest # Python unit tests
|
||||
ruff check . # Python lint
|
||||
```
|
||||
|
||||
## Load-bearing conventions
|
||||
|
||||
- **Engine isolation** — never import anything from `nodes/`, `comfy_api`, or `torch` in `engine/`. Engine is dict-free of ComfyUI globals. Breaking this breaks tests.
|
||||
- **Internal keys** — context keys starting with `__` are engine-internal; strip them at socket boundaries (see `nodes/types.py:strip_internals`).
|
||||
- **Module IDs** — 8-hex-char short UUIDs. Matches the wildcard `@{uuid}` reference syntax planned for the next spec.
|
||||
- **Engine isolation** — never import from `wp_nodes/`, `comfy_api`, or `torch` in `engine/`. Engine dict-free of ComfyUI globals. Break this break tests.
|
||||
- **Internal keys** — context keys starting with `__` engine-internal; strip at socket boundaries (see `wp_nodes/types.py:strip_internals`).
|
||||
- **Module IDs** — 8-hex-char short UUIDs. Match wildcard `@{uuid}` reference syntax planned for next spec.
|
||||
- **V3 node IDs** — prefix `WP_`, category `wildcard-pipeline`.
|
||||
- **Custom widget types** — `WP_CONTEXT_MODULES`, `WP_DEBUG_VIEWER`. Registered via `app.registerExtension({ getCustomWidgets })`. The Assembler helper widget is injected via `beforeRegisterNodeDef → onNodeCreated`, not `getCustomWidgets`, because it sits alongside a native STRING template widget.
|
||||
- **Conflict scanner** — advisory only; never block execution. Runtime is last-write-wins.
|
||||
- **Bundle-size gate** — entry `main.js` must stay ≤ 30 KB gzipped; total `js/` ≤ 250 KB. Raising the budget requires explicit PR discussion.
|
||||
- **Caching** — `WP_Context` and `WP_Debug` set `not_idempotent=True` so seed cycling always re-executes.
|
||||
- **Conventional Commits** — enforced by commitlint. semantic-release reads the log to cut versions.
|
||||
- **Custom widget types** — `WP_CONTEXT_MODULES`, `WP_DEBUG_VIEWER`. Registered via `app.registerExtension({ getCustomWidgets })`. Assembler helper widget injected via `beforeRegisterNodeDef → onNodeCreated`, not `getCustomWidgets`, because sit alongside native STRING template widget.
|
||||
- **Toast singleton** — one Vue app mounted to body-level div renders all toasts pushed via `src/components/shared/toast-store.ts`. Every Context node import same store so toasts surface from anywhere. Position mirror ComfyUI native PrimeVue toast anchor (rect-relative to `.graph-canvas-container`).
|
||||
- **Subgraph traversal** — `collectUpstreamVariables` / `collectUpstreamValues` / `findDownstreamAssemblers` cross subgraph boundaries via `-10` SubgraphInputNode + `-20` SubgraphOutputNode sentinel ids. Walkers expect `app.graph` (root); per-call `buildSubgraphParents` map handle step-out. Use `walkAllNodes(rootGraph)` for graph-wide scans (e.g. pre-run validation).
|
||||
- **Subgraph badges** — `extension/subgraph-badge.ts` attach `LGraphBadge` (via `window.LGraphBadge`) to any SubgraphNode containing WP nodes. Badge surface worst inner conflict severity with human label (e.g. `missing $foo`, `2 conflicts`). Wire via `nodeCreated` + `loadedGraphNode` hooks — `beforeRegisterNodeDef` does NOT fire for SubgraphNodes.
|
||||
- **Conflict scanner** — advisory only; never block execution. Runtime last-write-wins. Three types: `shadows_upstream` (info), `duplicate_variable` (warning), `missing_template_variable` (warning).
|
||||
- **Bundle-size gate** — entry `main.js` must stay ≤ 30 KB gzipped; total `js/` ≤ 250 KB. Pre-commit run `pnpm build:extension && pnpm size`. Raising budget require explicit PR discussion.
|
||||
- **Coverage gate** — vitest v8 coverage with thresholds (lines/statements 50%, functions 40%, branches 65%). Anchored to current baseline; ratchet up alongside new tests.
|
||||
- **Caching** — `WP_Context` + `WP_Debug` set `not_idempotent=True` so seed cycling always re-executes.
|
||||
- **Conventional Commits** — enforced by commitlint. semantic-release read log to cut versions.
|
||||
|
||||
## Frontend overview
|
||||
|
||||
- **Entry** `src/main.ts` → `js/main.js` (~1.6 KB gzip). Top-level await preload widget chunks so `getCustomWidgets` factories run sync (Promise return → `{widget: undefined}` and widget never mount). Register extension + lazy-import widget code only when ComfyUI hand matching node.
|
||||
- **Lazy chunks** `src/widgets/{context,debug,assembler}.ts` — one mount glue per widget. Each `import()` become own asset chunk, plus sibling SFC chunk (`ContextWidget`, `DebugViewer`, `AssemblerHelper`).
|
||||
- **Shared** `src/widgets/_shared.ts` expose `createDomWidgetHost`, JSON helpers (`parseWidgetJson`, `parseWidgetJsonWithRecovery` for corrupt-workflow recovery path), shared types. Largest shared chunk (`_shared`, ~33 KB gzip) is Vue runtime + plugin-vue helper, pulled in by every widget on first use.
|
||||
- **Graph + conflicts** `src/extension/{graph,conflicts,subgraph-badge,reactive,graph-events}.ts` — pure logic, no DOM. Imported by widgets, easy to unit-test isolated.
|
||||
- **Reactivity** `extension/reactive.ts:reactiveFromGraph` — wires `onConnectionsChange` chain + 400ms polling fallback + `afterConfigureGraph` re-sync so widgets recompute on graph edits + workflow loads without flashing stale state.
|
||||
- **Adding new widget**: (1) write SFC under `src/components/<name>/`, (2) add `src/widgets/<name>.ts` exposing `create(node, inputName)` that return `createDomWidgetHost(...)`, (3) wire in `src/main.ts` — either as `getCustomWidgets` entry (for inputs declared with matching widget-type) or via `beforeRegisterNodeDef → onNodeCreated` (for free-floating helpers). Use `import("./widgets/<name>")` so stay out of entry chunk + size gate keep holding.
|
||||
|
||||
## Anti-patterns (inherited from reference project `ComfyUI-Wildcard-Pipeline`)
|
||||
|
||||
- Never use V1 `NODE_CLASS_MAPPINGS` / `EXTENSION_WEB_DIRS`.
|
||||
- Never `console.log` for user warnings — use the ComfyUI toast API.
|
||||
- Never `console.log` for user warnings — use ComfyUI toast API.
|
||||
- Never use `getInputNode()` / `findInputSlot()` — walk via `node.inputs[].link → app.graph.links[linkId] → graph.getNodeById(link.origin_id)`.
|
||||
- Never use `requestAnimationFrame` in `onConnectionsChange`; the link is already established when the callback fires.
|
||||
- Never register an SPA catch-all route before API routes.
|
||||
- Never use `requestAnimationFrame` in `onConnectionsChange`; link already established when callback fires.
|
||||
- Never register SPA catch-all route before API routes.
|
||||
- Never suppress types with `as any`, `@ts-ignore`, `@ts-expect-error`.
|
||||
- Never `cssInjectedByJsPlugin` on the extension bundle — inflates critical-path load.
|
||||
- `cssInjectedByJsPlugin` required, not optional. Vite library mode emit CSS files but never inject `<link>` tags, and ComfyUI load our `main.js` as bare ES module so `<link rel="modulepreload">`-style auto-loading does not apply. Per-chunk runtime injection (`relativeCSSInjection: true`) keep entry chunk CSS-free — styles only get injected when owning lazy chunk loads. This what every Vue-based ComfyUI extension in wild does (see `ComfyUI-Easy-Use/web_version/v2/easyuse.js`).
|
||||
@@ -1,4 +1,10 @@
|
||||
# ComfyUI-WildcardPipeline
|
||||
<p align="center">
|
||||
<img src="public/images/favicon.svg" width="120" alt="wildcard-pipeline logo" />
|
||||
</p>
|
||||
|
||||
<h1 align="center">wildcard-pipeline</h1>
|
||||
|
||||
<p align="center"><em>Composable prompt context for ComfyUI.</em></p>
|
||||
|
||||
Modular procedural prompt generation for ComfyUI — wildcards, constraints, derivations, and context pipelines.
|
||||
|
||||
|
||||
@@ -1,23 +1,55 @@
|
||||
"""ComfyUI-WildcardPipeline — modular procedural prompt generation.
|
||||
"""ComfyUI-WildcardPipeline — modular procedural prompt generation."""
|
||||
|
||||
MVP scaffold. Real node registration lands in Plan P3. This stub exists
|
||||
so ComfyUI's custom-node loader doesn't error on directory load.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
from __future__ import annotations
|
||||
# Tells ComfyUI to serve files from this folder under
|
||||
# /extensions/<package-name>/ and auto-load any `*.js` it finds.
|
||||
WEB_DIRECTORY = "./js"
|
||||
|
||||
# Expose engine/ and wp_nodes/ as top-level packages so internal absolute imports
|
||||
# (``from engine.X import ...``, ``from wp_nodes.X import ...``) resolve the same
|
||||
# whether ComfyUI's custom-node loader or pytest invokes us. ComfyUI sets up
|
||||
# ``submodule_search_locations`` on this package but does not add the package
|
||||
# dir to ``sys.path``, so absolute imports to ``engine``/``wp_nodes`` would fail
|
||||
# without this shim. ``wp_nodes`` (not ``nodes``) avoids collision with
|
||||
# ComfyUI's top-level ``nodes.py`` module.
|
||||
_PKG_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
if _PKG_DIR not in sys.path:
|
||||
sys.path.insert(0, _PKG_DIR)
|
||||
|
||||
# pyright: reportMissingImports=false
|
||||
# ruff: noqa: E402 — imports must follow the sys.path shim above
|
||||
from comfy_api.latest import ComfyExtension
|
||||
|
||||
from wp_nodes.assembler_node import WPPromptAssembler
|
||||
from wp_nodes.context_node import WPContext
|
||||
from wp_nodes.debug_node import WPDebug
|
||||
|
||||
|
||||
class WildcardPipelineExtension(ComfyExtension):
|
||||
"""Registers all WP nodes with ComfyUI."""
|
||||
|
||||
async def get_node_list(self):
|
||||
return [WPContext, WPPromptAssembler, WPDebug]
|
||||
|
||||
|
||||
async def comfy_entrypoint() -> WildcardPipelineExtension:
|
||||
return WildcardPipelineExtension()
|
||||
|
||||
|
||||
# Register SPA + REST API routes on ComfyUI's aiohttp app.
|
||||
# Imported lazily inside the try-block so static analysis tools that
|
||||
# don't have ComfyUI's `server` module on the path don't fail.
|
||||
try:
|
||||
from comfy_api.latest import ComfyExtension # pyright: ignore[reportMissingImports]
|
||||
from server import PromptServer # type: ignore[import-not-found]
|
||||
|
||||
class WildcardPipelineExtension(ComfyExtension):
|
||||
"""Empty scaffold extension — nodes added in P3."""
|
||||
from wp_api import register_routes as _register_wp_routes
|
||||
|
||||
async def get_node_list(self):
|
||||
return []
|
||||
_register_wp_routes(PromptServer.instance.app)
|
||||
except Exception: # noqa: BLE001 - never crash ComfyUI on route registration failure
|
||||
import logging
|
||||
|
||||
async def comfy_entrypoint() -> WildcardPipelineExtension:
|
||||
return WildcardPipelineExtension()
|
||||
|
||||
except ImportError:
|
||||
# Running outside ComfyUI (e.g. pytest) — nothing to register.
|
||||
pass
|
||||
logging.getLogger(__name__).exception(
|
||||
"wildcard-pipeline: route registration failed",
|
||||
)
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
"""Root conftest — lets pytest import engine/nodes without installing the package.
|
||||
"""Root conftest — lets pytest import engine/wp_nodes without installing the package.
|
||||
|
||||
Also collects-ignores ComfyUI-dependent directories when comfy_api is absent.
|
||||
If ``comfy_api`` is not importable (running outside ComfyUI), inject the
|
||||
``tests/stubs/comfy_api_stub`` module into ``sys.modules`` as both
|
||||
``comfy_api`` and ``comfy_api.latest`` so production code that does
|
||||
``from comfy_api.latest import ComfyExtension, io`` succeeds in tests.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -9,13 +12,31 @@ import importlib.util
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).parent
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
if importlib.util.find_spec("comfy_api") is None:
|
||||
from tests.stubs import comfy_api_stub as _stub
|
||||
|
||||
sys.modules.setdefault("comfy_api", _stub)
|
||||
sys.modules.setdefault("comfy_api.latest", _stub)
|
||||
sys.modules.setdefault("comfy_api.latest._io", _stub.io._io)
|
||||
|
||||
from engine.db.connection import get_connection # noqa: E402
|
||||
from engine.db.migrations import migrate # noqa: E402
|
||||
|
||||
collect_ignore: list[str] = []
|
||||
|
||||
# Tests for node wrappers require comfy_api; skip them when it's unavailable
|
||||
# (e.g. running pytest outside ComfyUI's Python environment).
|
||||
if importlib.util.find_spec("comfy_api") is None:
|
||||
collect_ignore.append("tests/test_nodes.py")
|
||||
|
||||
@pytest.fixture
|
||||
def wp_db(tmp_path):
|
||||
"""Fresh, migrated SQLite DB. Yields a sqlite3.Connection."""
|
||||
conn = get_connection(tmp_path / "wp.db")
|
||||
migrate(conn)
|
||||
try:
|
||||
yield conn
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
# WP Context
|
||||
|
||||
Hold a list of named values you can use later in your prompt. Each value gets a name (like `style` or `subject`) and the value you want it to be (like `watercolor` or `a cat`). Then a **WP Prompt Assembler** can plug those values into your prompt wherever you write `$style` or `$subject`.
|
||||
|
||||
## Adding values
|
||||
|
||||
A **module** is one small group of values. Click the big purple **Add your first module** button, or **+ add module** if you already have some.
|
||||
|
||||
Each module appears as a small card. Right-click the card and pick **Edit** to:
|
||||
|
||||
- Give it a name (just a label so you remember what's inside, like "Character details")
|
||||
- Add as many `name = value` lines as you need
|
||||
- Paste a bunch of lines at once if you have them ready
|
||||
|
||||
Press **Enter** on the value field to add another row quickly. **Ctrl+Enter** saves and closes.
|
||||
|
||||
## What the icons on a card mean
|
||||
|
||||
- **☰** Drag handle — drag a card to reorder, or drag onto another Context node to move it there
|
||||
- **▾ / ▸** Show or hide the values preview
|
||||
- **Toggle (checkbox)** — turn a module off without deleting it. Useful for trying variations.
|
||||
- **✕** Remove the module. Don't worry about misclicks — an **Undo** button appears for a few seconds.
|
||||
|
||||
## Stacking multiple Contexts
|
||||
|
||||
Connect another **WP Context** node into the **upstream** input. The upstream Context's values flow through. If both define the same name, this Context wins (it's the closer one to your prompt).
|
||||
|
||||
This lets you build a library: "character" Context → "scene" Context → "style" Context → Prompt Assembler.
|
||||
|
||||
## Conflict dots
|
||||
|
||||
A card may show a small colored dot:
|
||||
|
||||
- 🔵 **Blue (info)** — You're overriding something an upstream Context already provides. Usually that's exactly what you want.
|
||||
- 🟡 **Amber (warning)** — Two of your modules in this Context use the same name. One quietly overwrites the other; check that's intentional.
|
||||
|
||||
Hover the dot to see exactly which variable.
|
||||
|
||||
## Output
|
||||
|
||||
Wire **context** out into a **WP Prompt Assembler** (or another **WP Context** to chain).
|
||||
@@ -0,0 +1,22 @@
|
||||
# WP Debug
|
||||
|
||||
Drop this anywhere in your pipeline to see exactly what variables are flowing through that point. Like a window into the chain.
|
||||
|
||||
## How to use
|
||||
|
||||
1. Connect the **context** input to any WP node (Context or Prompt Assembler).
|
||||
2. Run the workflow once.
|
||||
3. The viewer below shows every variable name and its value at that point in the chain.
|
||||
|
||||
That's it. There's nothing to wire on the output side — Debug is read-only.
|
||||
|
||||
## When it's useful
|
||||
|
||||
- **"Why is `$style` showing as empty?"** Drop a Debug after each Context to see where the value gets lost or overridden.
|
||||
- **"Did my new module override the old one correctly?"** The trace shows which module set each variable.
|
||||
- **"Are upstream variables coming through the chain?"** Drop a Debug right before your Prompt Assembler to confirm.
|
||||
|
||||
## Tips
|
||||
|
||||
- The viewer fills the node body. Drag the bottom-right corner of the node to make it bigger if you have lots of variables.
|
||||
- Debug nodes always re-run, even when nothing visually changed. Helpful when you're cycling seeds and want to see the values shift.
|
||||
@@ -0,0 +1,47 @@
|
||||
# WP Prompt Assembler
|
||||
|
||||
Write your prompt as a fill-in-the-blank template. Anywhere you put `$something`, the Assembler swaps in the matching value from the upstream Context.
|
||||
|
||||
## Writing a template
|
||||
|
||||
Type your prompt in the **template** box. Use `$name` to reference any variable from the connected Context.
|
||||
|
||||
**Example template:**
|
||||
```
|
||||
a portrait of $subject, $style, $mood
|
||||
```
|
||||
|
||||
If the upstream Context provides:
|
||||
- `subject = a cat`
|
||||
- `style = watercolor`
|
||||
- `mood = sleepy`
|
||||
|
||||
…the final prompt becomes:
|
||||
```
|
||||
a portrait of a cat, watercolor, sleepy
|
||||
```
|
||||
|
||||
## The helper below the template
|
||||
|
||||
**Variables** lists every value available from the upstream Context. Click any chip to insert `$name` into your template at the cursor — saves typing and avoids typos.
|
||||
|
||||
**Preview** shows what the final prompt will look like, color-coded:
|
||||
|
||||
- **Filled in (purple highlight)** — the variable is resolved, you see its actual value.
|
||||
- **Outlined chip** — the name will resolve, just briefly during loading.
|
||||
- **Amber wavy underline** — missing! Nothing upstream provides this name, so it'll render empty.
|
||||
|
||||
If your template is empty, the preview shows a placeholder instead.
|
||||
|
||||
## Special characters
|
||||
|
||||
- Use `$$` if you want a literal `$` in your prompt (e.g. `$$10` to mean "ten dollars" rather than a variable named `10`).
|
||||
- Variable names can contain letters, numbers, and underscores only. `$my_var` works; `$my-var` doesn't.
|
||||
|
||||
## Output
|
||||
|
||||
**prompt** is the final assembled string. Connect it to a **CLIP Text Encode** node (or any text input).
|
||||
|
||||
## Pre-flight warnings
|
||||
|
||||
When you press the **Run** button, the extension scans your template for `$variables` that aren't provided anywhere upstream. If it finds any, you'll see one toast at the top of the screen listing them. The run still happens — missing variables just become empty strings — but it's a heads-up in case you have a typo.
|
||||
@@ -1,733 +0,0 @@
|
||||
# ComfyUI-WildcardPipeline — MVP Walking Skeleton (Design)
|
||||
|
||||
- **Date:** 2026-04-24
|
||||
- **Author:** DumiFlex
|
||||
- **Status:** Approved — ready for implementation planning
|
||||
- **Scope class:** MVP (follow-up sub-specs listed in §9)
|
||||
- **Reference project (not to be copied wholesale):** `E:\AI\ComfyUI\custom_nodes\ComfyUI-Wildcard-Pipeline`
|
||||
|
||||
## 1. Vision & MVP Goals
|
||||
|
||||
**Project vision.** Modular, procedural prompt generation for ComfyUI — *not* chaotic random wildcards. Users compose reusable logic (wildcards, constraints, derivations, combines, fixed values) inside a `WP_Context` node, chain an unlimited number of context nodes for layered generation, and feed the resulting structured context to a `WP_PromptAssembler` that fills `$var` placeholders in a user-authored template. A separate SPA (post-MVP) makes modules and whole pipelines reusable/shareable. Workflows always remain portable because module data is serialized inline into the workflow JSON.
|
||||
|
||||
**MVP goals (this spec).** Prove the walking skeleton end-to-end:
|
||||
|
||||
1. Render a Vue-powered custom DOM widget inside a ComfyUI V3 node.
|
||||
2. Manage an ordered list of modules of a single type — **`fixed_values`** — via that widget.
|
||||
3. Serialize module list into `node.widgets_values` so workflow save/load round-trips.
|
||||
4. Custom `PIPELINE_CONTEXT` socket type carries context across an unlimited chain of `WP_Context` nodes.
|
||||
5. Downstream-overwrite semantics: runtime silently last-write-wins; UI warns via conflict scanner.
|
||||
6. `WP_PromptAssembler` consumes context, resolves `$var` and `$$` escape.
|
||||
7. `WP_Debug` node renders context as a readable Vue viewer widget.
|
||||
8. Engine stays pure Python (`engine/` with zero ComfyUI imports), typed via dataclasses + `TypedDict`.
|
||||
9. Tests: engine unit tests + conflict scanner tests, no ComfyUI runtime required.
|
||||
10. Extension frontend bundle cold-loads fast (hard budget, see §6).
|
||||
|
||||
**MVP non-goals (deferred to named follow-up specs in §9).** Wildcards, constraints, derivations, combines, `WP_ContextInject` node, sqlite library, SPA manager, pipeline presets, `WP_DEBUG` env socket, instance-override UI, save-to-library context menu, module version history.
|
||||
|
||||
## 2. Architecture Overview
|
||||
|
||||
Three-layer split, same isolation principle as the reference project.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Frontend (Vue 3 + Vite + PrimeVue + Pinia) │
|
||||
│ src/main.ts — ComfyUI extension entry │
|
||||
│ src/extension/widgets.ts — Vue-in-DOMWidget factory │
|
||||
│ src/extension/graph.ts — upstream/downstream walkers │
|
||||
│ src/extension/conflicts.ts — conflict scanner │
|
||||
│ src/components/context/* — Context widget + module list │
|
||||
│ src/components/assembler/* — Assembler helper widget │
|
||||
│ src/components/debug/* — Debug viewer widget │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
↕ serialize JSON string
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Nodes (ComfyUI V3 — comfy_api.latest) │
|
||||
│ nodes/context_node.py — WP_Context │
|
||||
│ nodes/assembler_node.py — WP_PromptAssembler │
|
||||
│ nodes/debug_node.py — WP_Debug │
|
||||
│ nodes/types.py — PipelineContext custom type │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
↕ dataclass from_dict / to_dict
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Engine (pure Python, zero ComfyUI imports) │
|
||||
│ engine/context.py — Context TypedDict + helpers │
|
||||
│ engine/modules.py — Module dataclasses + union │
|
||||
│ engine/pipeline.py — PipelineEngine.run() │
|
||||
│ engine/handlers.py — fixed_values handler │
|
||||
│ engine/template.py — resolve_variables ($var,$$) │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Boundary contracts.**
|
||||
|
||||
- Frontend ⇄ Nodes: only via ComfyUI widget serialization (a single JSON string per node stored in `widgets_values`).
|
||||
- Nodes ⇄ Engine: nodes deserialize the widget JSON into engine dataclasses, call `PipelineEngine.run()`, then wrap the returned context into a `ContextPayload` and emit it on the `PIPELINE_CONTEXT` socket.
|
||||
- Engine ⇄ anything else: pure. Imports stdlib only. Testable with plain `pytest`, no ComfyUI required.
|
||||
|
||||
**Execution dataflow per workflow run.**
|
||||
|
||||
```
|
||||
upstream WP_Context output ──▶ PIPELINE_CONTEXT socket ──┐
|
||||
▼
|
||||
WP_Context.execute()
|
||||
├── inherit upstream ctx
|
||||
├── deserialize modules widget
|
||||
├── engine.run(modules, ctx, seed)
|
||||
└── emit ContextPayload(ctx, debug)
|
||||
│
|
||||
▼
|
||||
WP_PromptAssembler.execute()
|
||||
└── resolve $vars in template
|
||||
│
|
||||
▼
|
||||
STRING output
|
||||
```
|
||||
|
||||
No cross-cutting side channels. Every boundary is a single serialized payload.
|
||||
|
||||
## 3. Data Model
|
||||
|
||||
### 3.1 Context (engine-side)
|
||||
|
||||
Flat dict of string-keyed vars plus `__`-prefixed internal keys. A `TypedDict` documents the reserved internals; user vars remain open.
|
||||
|
||||
```python
|
||||
# engine/context.py
|
||||
from typing import TypedDict, Any
|
||||
|
||||
class ContextInternals(TypedDict, total=False):
|
||||
__wp_node_seed__: int # current node's seed
|
||||
__wp_internal_flags__: dict[str, bool] # varname → internal?
|
||||
__wp_trace__: list[dict[str, Any]] # per-module execution log
|
||||
|
||||
Context = dict[str, Any] # union of user vars + ContextInternals keys
|
||||
```
|
||||
|
||||
### 3.2 Modules (engine-side)
|
||||
|
||||
Dataclass per type + discriminated union via `type: Literal[...]`. MVP ships one type plus a shared metadata block.
|
||||
|
||||
```python
|
||||
# engine/modules.py
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Literal, Union
|
||||
|
||||
@dataclass
|
||||
class ModuleMeta:
|
||||
name: str = "" # free-form display label, duplicates allowed
|
||||
description: str = ""
|
||||
category: str = "" # user-chosen grouping
|
||||
tags: list[str] = field(default_factory=list)
|
||||
|
||||
@dataclass
|
||||
class FixedValueEntry:
|
||||
variable_name: str
|
||||
value: str
|
||||
|
||||
@dataclass
|
||||
class FixedValueModule:
|
||||
id: str # 8-hex-char short UUID (~4.3B space)
|
||||
type: Literal["fixed_values"] = "fixed_values"
|
||||
enabled: bool = True
|
||||
meta: ModuleMeta = field(default_factory=ModuleMeta)
|
||||
entries: list[FixedValueEntry] = field(default_factory=list)
|
||||
|
||||
Module = Union[FixedValueModule] # widens as new types land
|
||||
|
||||
def module_from_dict(d: dict) -> Module: ... # dispatch on d["type"]
|
||||
def module_to_dict(m: Module) -> dict: ... # stdlib asdict
|
||||
```
|
||||
|
||||
Short UUID format matches the reference project's `@{uuid}` nested-wildcard reference syntax — forward-compatible when the Wildcards spec lands.
|
||||
|
||||
### 3.3 Widget payload (frontend ↔ node)
|
||||
|
||||
One DOM widget per `WP_Context` node holds the ordered module list as a JSON string in `widgets_values`.
|
||||
|
||||
```jsonc
|
||||
// widget value (JSON string)
|
||||
{
|
||||
"version": 1,
|
||||
"modules": [
|
||||
{
|
||||
"id": "…",
|
||||
"type": "fixed_values",
|
||||
"enabled": true,
|
||||
"meta": { "name": "style defaults", "tags": ["character"] },
|
||||
"entries": [{ "variable_name": "style", "value": "photorealistic" }]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`version` enables forward-compatible migrations when new module types land.
|
||||
|
||||
### 3.4 `PIPELINE_CONTEXT` socket payload
|
||||
|
||||
```python
|
||||
# nodes/types.py
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
from comfy_api.latest._io import comfytype, ComfyTypeIO
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ContextPayload:
|
||||
context: dict[str, Any] = field(default_factory=dict) # user vars only
|
||||
debug: dict[str, Any] = field(default_factory=dict) # trace + meta
|
||||
|
||||
@comfytype(io_type="PIPELINE_CONTEXT")
|
||||
class PipelineContext(ComfyTypeIO):
|
||||
Type = ContextPayload
|
||||
```
|
||||
|
||||
Internals (`__…`) are stripped at the socket boundary so downstream nodes can't accidentally depend on private state.
|
||||
|
||||
### 3.5 Seed model
|
||||
|
||||
`WP_Context` has a standard `io.Int` seed widget (with `control_after_generate`). Engine stores it as `ctx["__wp_node_seed__"]` and passes through. `fixed_values` doesn't consume it. The Wildcards follow-up spec will hash `(node_seed, capture_name)` via SHA-256 as the reference project does.
|
||||
|
||||
### 3.6 Debug JSON shape (MVP)
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"node_seed": 12345,
|
||||
"modules": [
|
||||
{
|
||||
"id": "…",
|
||||
"type": "fixed_values",
|
||||
"enabled": true,
|
||||
"writes": [
|
||||
{ "variable": "style", "value": "photorealistic", "source": "fixed_values" }
|
||||
]
|
||||
}
|
||||
],
|
||||
"conflicts": []
|
||||
}
|
||||
```
|
||||
|
||||
## 4. Engine Internals
|
||||
|
||||
Pure Python, stdlib only. One file per concern.
|
||||
|
||||
### 4.1 `engine/template.py`
|
||||
|
||||
```python
|
||||
def resolve_variables(template: str, ctx: dict[str, Any]) -> str:
|
||||
"""Replace $var with ctx[var]. $$ escapes literal $. __-keys skipped."""
|
||||
```
|
||||
|
||||
Rules: `$var` → `str(ctx[var])` when present, otherwise left as-is; `$$` → literal `$`; `$__internal` never substituted.
|
||||
|
||||
### 4.2 `engine/handlers.py`
|
||||
|
||||
```python
|
||||
ModuleHandler = Callable[[Module, Context, random.Random], Context]
|
||||
|
||||
def handle_fixed_values(module: FixedValueModule, ctx: Context, rng: random.Random) -> Context:
|
||||
for entry in module.entries:
|
||||
name = entry.variable_name.lstrip("$")
|
||||
if not name:
|
||||
continue
|
||||
ctx[name] = entry.value
|
||||
return ctx
|
||||
```
|
||||
|
||||
`fixed_values` ignores `rng`. Signature keeps parity with future seed-consuming handlers (wildcard, derivation).
|
||||
|
||||
### 4.3 `engine/pipeline.py`
|
||||
|
||||
```python
|
||||
class PipelineEngine:
|
||||
HANDLERS: dict[str, ModuleHandler] = {
|
||||
"fixed_values": handle_fixed_values,
|
||||
}
|
||||
|
||||
def run(self, modules: list[Module], ctx: Context | None = None,
|
||||
seed: int = 0) -> Context:
|
||||
ctx = ctx if ctx is not None else {}
|
||||
ctx["__wp_node_seed__"] = seed
|
||||
ctx.setdefault("__wp_trace__", [])
|
||||
rng = random.Random(seed)
|
||||
|
||||
for index, module in enumerate(modules):
|
||||
if not module.enabled:
|
||||
continue
|
||||
handler = self.HANDLERS.get(module.type)
|
||||
if handler is None:
|
||||
logger.warning("Unknown module type '%s' at index %s", module.type, index)
|
||||
continue
|
||||
before = dict(ctx)
|
||||
ctx = handler(module, ctx, rng)
|
||||
ctx["__wp_trace__"].append(self._trace_entry(module, before, ctx))
|
||||
|
||||
return ctx
|
||||
```
|
||||
|
||||
### 4.4 Tracing
|
||||
|
||||
`_trace_entry(module, before, after)` yields one dict per handled module:
|
||||
|
||||
```python
|
||||
{
|
||||
"id": module.id,
|
||||
"type": module.type,
|
||||
"enabled": module.enabled,
|
||||
"writes": (
|
||||
[
|
||||
{"variable": k, "value": after[k], "source": module.type}
|
||||
for k in after.keys() - before.keys()
|
||||
if not k.startswith("__")
|
||||
]
|
||||
+ [
|
||||
{"variable": k, "value": after[k], "source": module.type, "overwrite": True}
|
||||
for k in after.keys() & before.keys()
|
||||
if not k.startswith("__") and after[k] != before[k]
|
||||
]
|
||||
),
|
||||
}
|
||||
```
|
||||
|
||||
### 4.5 Chain behaviour
|
||||
|
||||
`WP_Context.execute(upstream, seed, modules_json)`:
|
||||
|
||||
1. `ctx = dict(upstream.context) if upstream else {}` — seed from upstream user vars.
|
||||
2. `PipelineEngine().run(deserialize(modules_json), ctx, seed=seed)` — mutate with this node's modules.
|
||||
3. Emit `ContextPayload(context=strip_internals(ctx), debug={…})`.
|
||||
|
||||
Runtime silently last-write-wins. Conflict scanner (§7) warns in UI pre-execution.
|
||||
|
||||
### 4.6 Isolation invariants
|
||||
|
||||
- `engine/` imports stdlib only — no `torch`, no ComfyUI, no frontend.
|
||||
- No `print`; only `logging.getLogger(__name__)`.
|
||||
- No global mutable state. `PipelineEngine.HANDLERS` is a class attribute; extending requires subclass or a future registration API.
|
||||
- Pure functions where possible; handlers mutate context in place (performance over immutability — chains get long).
|
||||
|
||||
## 5. ComfyUI V3 Nodes
|
||||
|
||||
All three under category `wildcard-pipeline`; node IDs prefixed `WP_`.
|
||||
|
||||
### 5.1 Registration
|
||||
|
||||
```python
|
||||
# __init__.py
|
||||
from comfy_api.latest import ComfyExtension, io
|
||||
from .nodes.context_node import WPContext
|
||||
from .nodes.assembler_node import WPPromptAssembler
|
||||
from .nodes.debug_node import WPDebug
|
||||
|
||||
class WildcardPipelineExtension(ComfyExtension):
|
||||
async def get_node_list(self):
|
||||
return [WPContext, WPPromptAssembler, WPDebug]
|
||||
|
||||
async def comfy_entrypoint():
|
||||
return WildcardPipelineExtension()
|
||||
```
|
||||
|
||||
All ComfyUI imports are wrapped in `try/except ImportError` so `pytest` can import the package outside ComfyUI (ref. project pattern).
|
||||
|
||||
### 5.2 `WP_Context`
|
||||
|
||||
```python
|
||||
ContextModulesInput = io.Custom("WP_CONTEXT_MODULES") # widget-only custom type
|
||||
|
||||
class WPContext(io.ComfyNode):
|
||||
@classmethod
|
||||
def define_schema(cls):
|
||||
return io.Schema(
|
||||
node_id="WP_Context",
|
||||
display_name="WP Context",
|
||||
category="wildcard-pipeline",
|
||||
inputs=[
|
||||
PipelineContext.Input("upstream", optional=True),
|
||||
io.Int.Input("seed", default=0, min=0,
|
||||
max=0xFFFFFFFFFFFFFFFF, control_after_generate=True),
|
||||
ContextModulesInput.Input("modules", socketless=True),
|
||||
],
|
||||
outputs=[PipelineContext.Output("context")],
|
||||
not_idempotent=True,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def execute(cls, seed, modules, upstream=None):
|
||||
# `modules` arrives as the serialized JSON string produced by the DOM widget
|
||||
upstream_ctx = upstream.context if upstream else {}
|
||||
upstream_dbg = upstream.debug if upstream else {}
|
||||
module_list = deserialize_modules(modules)
|
||||
ctx = dict(upstream_ctx)
|
||||
ctx = PipelineEngine().run(module_list, ctx, seed=seed)
|
||||
payload = ContextPayload(
|
||||
context=strip_internals(ctx),
|
||||
debug={"upstream": upstream_dbg, "node_seed": seed,
|
||||
"trace": ctx.get("__wp_trace__", [])},
|
||||
)
|
||||
return io.NodeOutput(payload)
|
||||
```
|
||||
|
||||
- `upstream` is optional — root context nodes work without an input connection.
|
||||
- `modules` is a **custom-typed, `socketless=True` input** named `WP_CONTEXT_MODULES`. The frontend registers a widget factory under that exact type key via `app.registerExtension({ getCustomWidgets() })`. The factory creates the DOM element, mounts Vue, and manages JSON serialization into `widgets_values` via `node.addDOMWidget(inputName, "custom", container, { serialize: true, getValue, setValue })`.
|
||||
- `not_idempotent=True` prevents ComfyUI caching so seed cycling always re-executes.
|
||||
- Internals stripped at the socket boundary (§3.4).
|
||||
|
||||
### 5.3 `WP_PromptAssembler`
|
||||
|
||||
```python
|
||||
class WPPromptAssembler(io.ComfyNode):
|
||||
@classmethod
|
||||
def define_schema(cls):
|
||||
return io.Schema(
|
||||
node_id="WP_PromptAssembler",
|
||||
display_name="WP Prompt Assembler",
|
||||
category="wildcard-pipeline",
|
||||
inputs=[
|
||||
PipelineContext.Input("context"),
|
||||
io.String.Input("template", multiline=True,
|
||||
default="A $style portrait of $subject"),
|
||||
],
|
||||
outputs=[io.String.Output("prompt")],
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def execute(cls, context, template):
|
||||
return io.NodeOutput(resolve_variables(template, context.context))
|
||||
```
|
||||
|
||||
**DOM helper widget.** Template stays a native multiline STRING widget — *explicitly kept* so users can right-click → *Convert to Input* and wire a string-producing node into it. A separate read-only Vue helper widget is injected *after* the template widget via the `beforeRegisterNodeDef` lifecycle hook (matches reference project's assembler pattern — `getCustomWidgets` can't add widgets to existing typed inputs, but `onNodeCreated` can call `node.addDOMWidget(...)` directly after the node's default widgets are built):
|
||||
|
||||
```ts
|
||||
// simplified — full code in src/extension/widgets.ts
|
||||
app.registerExtension({
|
||||
async beforeRegisterNodeDef(nodeType, nodeData) {
|
||||
if (nodeData.name !== "WP_PromptAssembler") return;
|
||||
const origOnCreated = nodeType.prototype.onNodeCreated;
|
||||
nodeType.prototype.onNodeCreated = function (...args) {
|
||||
origOnCreated?.apply(this, args);
|
||||
import("./widgets/assembler").then(m => m.mountHelper(this));
|
||||
};
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
The helper widget contains:
|
||||
|
||||
- **Variable chips row.** Pills for each variable captured by upstream chain (collected via `collectUpstreamVariables`). Click → inserts `$var` into the textarea at cursor position. Hover shows origin node name. When template is converted-to-input (no textarea in DOM), chip-insert handler feature-detects and disables; chips remain visible in read-only mode.
|
||||
- **Preview pane.** Static analysis only: renders template text with each `$var` tagged ✅ *defined* if found in upstream captured set, ⚠️ *unresolved* otherwise. No actual value substitution in MVP (that requires running the engine client-side or an API call — deferred to Wildcards spec via a dedicated preview endpoint).
|
||||
|
||||
Helper widget calls `addDOMWidget(..., { serialize: false })` — template value is owned exclusively by the native template widget, preventing double-source-of-truth.
|
||||
|
||||
### 5.4 `WP_Debug`
|
||||
|
||||
```python
|
||||
DebugViewerInput = io.Custom("WP_DEBUG_VIEWER") # widget-only custom type
|
||||
|
||||
class WPDebug(io.ComfyNode):
|
||||
@classmethod
|
||||
def define_schema(cls):
|
||||
return io.Schema(
|
||||
node_id="WP_Debug",
|
||||
display_name="WP Debug",
|
||||
category="wildcard-pipeline",
|
||||
inputs=[
|
||||
PipelineContext.Input("context"),
|
||||
DebugViewerInput.Input("viewer", socketless=True),
|
||||
],
|
||||
outputs=[],
|
||||
is_output_node=True,
|
||||
not_idempotent=True,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def execute(cls, context, viewer):
|
||||
import json
|
||||
dumped = json.dumps(
|
||||
{"context": context.context, "debug": context.debug},
|
||||
default=str, indent=2,
|
||||
)
|
||||
return io.NodeOutput(ui={"wp_debug_snapshot": [dumped]})
|
||||
```
|
||||
|
||||
**DOM viewer widget (`WP_DEBUG_VIEWER`).** Registered via `getCustomWidgets` keyed by the custom input type name. Two tabs:
|
||||
|
||||
1. **Context tab.** Tree / key-value viewer of user vars. Collapsible, type badges (string/number/bool/object). Copy-value button per row.
|
||||
2. **Trace tab.** Ordered list of module executions with per-module writes (variable, value, overwrite flag), node seed. Filter/search bar. Colour-coded by source module type.
|
||||
|
||||
Data source: the `ui.wp_debug_snapshot` payload. Widget subscribes to the ComfyUI `executed` event bus, filters by its own node id, parses the snapshot, re-renders. Serialises nothing into `widgets_values` — debug output is transient per run (widget uses `addDOMWidget(..., { serialize: false })`).
|
||||
|
||||
## 6. DOM Widgets, Vue Mount Pattern, Bundle Strategy
|
||||
|
||||
Frontend cold-load is a first-class constraint: the reference project takes ~30–60 s to load on first canvas open. MVP hard budget: **entry bundle ≤ 30 KB gzipped; widget chunks lazy-loaded; total extension assets ≤ 250 KB gzipped**.
|
||||
|
||||
### 6.1 Bundle targets
|
||||
|
||||
| Target | Entry | Output | Loaded when | Budget |
|
||||
|---|---|---|---|---|
|
||||
| **Extension** | `src/main.ts` | `js/main.js` + `js/assets/*.js` | ComfyUI graph opens (critical path) | ≤ 30 KB gzipped entry; widget chunks lazy |
|
||||
| **Manager SPA** | `src/manager.ts` | `web_dist/` | User visits `/wildcard-pipeline/` | Route-split; no hard cap (MVP ships empty stub) |
|
||||
|
||||
### 6.2 Reference-project cold-load diagnosis
|
||||
|
||||
- `minify: false` shipped un-minified JS.
|
||||
- `cssInjectedByJsPlugin` forced CSS through JS parser.
|
||||
- No manual chunking — one monolithic `main.js` carried Vue + Pinia + every widget + PrimeVue even when no WP nodes were on canvas.
|
||||
- `sourcemap: true` inline inflated download.
|
||||
- Pinia + PrimeVue imported eagerly for widgets that didn't need them.
|
||||
|
||||
### 6.3 Extension bundle rules (hard requirements)
|
||||
|
||||
1. **Tiny eager entry.** `src/main.ts` only calls `app.registerExtension({name, getCustomWidgets})`. No Vue imports at top level. No widget component imports at top level.
|
||||
2. **Lazy-load widget modules.** Two injection paths, both dynamic-imported so first use pulls the chunk:
|
||||
```ts
|
||||
app.registerExtension({
|
||||
name: "wildcard-pipeline",
|
||||
|
||||
// Custom-typed inputs → widget factory keyed by type name
|
||||
async getCustomWidgets() {
|
||||
return {
|
||||
WP_CONTEXT_MODULES: (node, inputName) =>
|
||||
import("./widgets/context").then(m => m.create(node, inputName)),
|
||||
WP_DEBUG_VIEWER: (node, inputName) =>
|
||||
import("./widgets/debug").then(m => m.create(node, inputName)),
|
||||
};
|
||||
},
|
||||
|
||||
// Native-typed inputs on a specific node → mount helper after default widgets
|
||||
async beforeRegisterNodeDef(nodeType, nodeData) {
|
||||
if (nodeData.name !== "WP_PromptAssembler") return;
|
||||
const origOnCreated = nodeType.prototype.onNodeCreated;
|
||||
nodeType.prototype.onNodeCreated = function (...args) {
|
||||
origOnCreated?.apply(this, args);
|
||||
import("./widgets/assembler").then(m => m.mountHelper(this));
|
||||
};
|
||||
},
|
||||
});
|
||||
```
|
||||
3. **No PrimeVue in extension bundle.** Widgets use plain HTML + scoped CSS. PrimeVue + its Aria/theme peers are reserved for the manager SPA.
|
||||
4. **No Pinia in extension bundle.** Widget state is local (`ref` / `reactive`). Cross-widget coordination via graph walking.
|
||||
5. **Vue runtime-only.** Vite alias `vue → vue/dist/vue.runtime.esm-bundler.js`. Templates pre-compiled from `.vue` files.
|
||||
6. **`minify: "esbuild"` + `sourcemap: "hidden"`.** Production ships minified JS with an out-of-band sourcemap file.
|
||||
7. **`cssCodeSplit: true`.** Per-chunk CSS loaded via dynamic-import auto `<link>`, not inlined in JS.
|
||||
8. **Externalise where possible.** Check `/scripts/app.js` interop. If ComfyUI exposes a global Vue, add to `rollupOptions.external`; otherwise bundle Vue exactly once.
|
||||
9. **Tree-shakeable imports.** No barrel files. Named imports only.
|
||||
|
||||
### 6.4 Shared utility chunk
|
||||
|
||||
`src/widgets/_shared.ts` holds the Vue-in-DOMWidget factory, JSON serialiser, and conflict-scanner glue. Imported by each widget module → Vite de-duplicates into one shared chunk, so Vue exists exactly once in memory.
|
||||
|
||||
### 6.5 Manager SPA (stub now, budgeted from day one)
|
||||
|
||||
- Separate HTML entry (`src/manager.html` + `src/manager.ts`).
|
||||
- Route-based code splitting via dynamic `import()`.
|
||||
- PrimeVue + Pinia permitted; `unplugin-vue-components` with `PrimeVueResolver` for tree-shaking.
|
||||
- Served via aiohttp static handler with gzip response.
|
||||
- MVP ships an empty stub to validate the build target; fully implemented in the **Manager SPA** sub-spec.
|
||||
|
||||
### 6.6 CI-enforced performance gate
|
||||
|
||||
`scripts/check-bundle-size.mjs` runs in CI after `pnpm build:extension`. Fails the build if:
|
||||
|
||||
- entry gzip > 30 KB, **or**
|
||||
- total `js/` gzip > 250 KB.
|
||||
|
||||
Hard gate prevents regression. Numbers reviewable per PR.
|
||||
|
||||
## 7. Conflict Scanner (MVP scope)
|
||||
|
||||
Pure static analysis in the browser, recomputed on widget change. Three conflict types apply while only `fixed_values` exists:
|
||||
|
||||
| Type | Severity | Trigger | Scope |
|
||||
|---|---|---|---|
|
||||
| `context_overwrite` | error | New entry writes a var that an **upstream node** already exported | cross-node (chain) |
|
||||
| `duplicate_variable` | warning | Entry writes a var that an **earlier module in the same node** already wrote | intra-node |
|
||||
| `missing_template_variable` | warning | Assembler template uses `$var` with no writer in its upstream chain | template resolution |
|
||||
|
||||
Mutually exclusive per capture (upstream check → same-node check → else clean), matching the reference project's `conflicts.ts` `if/else if` pattern.
|
||||
|
||||
**Examples.**
|
||||
|
||||
```
|
||||
[Context A: style=photo] → [Context B: style=cinematic]
|
||||
→ B raises context_overwrite on its entry.
|
||||
|
||||
[Context A with 2 fixed_values modules both writing $style]
|
||||
→ 2nd module raises duplicate_variable.
|
||||
|
||||
[Context A: style=photo] → [Assembler: "a $stle image"]
|
||||
→ Assembler raises missing_template_variable on $stle.
|
||||
```
|
||||
|
||||
**Severity rationale.** `context_overwrite` is an *error* because silently stomping upstream is almost never intentional. `duplicate_variable` is a *warning* because intra-node overrides are sometimes deliberate. `missing_template_variable` is a *warning* because it's common during editing.
|
||||
|
||||
All advisory. No runtime enforcement — matches the last-write-wins runtime behaviour approved for the chain.
|
||||
|
||||
**Implementation notes.** Graph walkers live in `src/extension/graph.ts` under the same API names as the reference project (`collectUpstreamVariables`, `findDownstreamAssemblers`) but reimplemented avoiding reference-project anti-patterns: walk via `node.inputs[].link → app.graph.links[linkId] → graph.getNodeById(link.origin_id)`; never via `getInputNode` / `findInputSlot`. Deferred conflict types: `unresolved_constraint_target`, `unresolved_constraint_when_variable`, wildcard-specific rules.
|
||||
|
||||
Scanner output (`Conflict[]`) feeds widget UI as per-module-card badges + hover tooltips. Optional "Jump to module" click scrolls the offending module into view.
|
||||
|
||||
## 8. Repo Layout, Build, Tooling, Tests
|
||||
|
||||
### 8.1 Repo layout
|
||||
|
||||
```
|
||||
ComfyUI-WildcardPipeline/
|
||||
├── __init__.py # V3 comfy_entrypoint + extension
|
||||
├── pyproject.toml # package metadata + ruff + pytest
|
||||
├── package.json # pnpm, Vue deps, scripts
|
||||
├── pnpm-workspace.yaml # single-package workspace (future-proof)
|
||||
├── vite.config.mts # --mode extension | manager
|
||||
├── tsconfig.json # strict, alias @→./src
|
||||
├── conftest.py # sys.path shim for test-without-ComfyUI
|
||||
├── .releaserc.js # semantic-release config
|
||||
├── commitlint.config.js # conventional commits
|
||||
├── .husky/ # pre-commit + commit-msg hooks
|
||||
├── .github/workflows/
|
||||
│ ├── ci.yml # pytest matrix + frontend typecheck/build + size gate
|
||||
│ └── release.yml # semantic-release on main push
|
||||
├── .gitignore # js/, web_dist/, node_modules/, .pytest_cache/, __pycache__/, .superpowers/
|
||||
│
|
||||
├── engine/
|
||||
│ ├── __init__.py
|
||||
│ ├── context.py
|
||||
│ ├── modules.py
|
||||
│ ├── handlers.py
|
||||
│ ├── pipeline.py
|
||||
│ └── template.py
|
||||
│
|
||||
├── nodes/
|
||||
│ ├── __init__.py
|
||||
│ ├── types.py
|
||||
│ ├── context_node.py
|
||||
│ ├── assembler_node.py
|
||||
│ └── debug_node.py
|
||||
│
|
||||
├── src/
|
||||
│ ├── main.ts
|
||||
│ ├── manager.ts # stub for post-MVP
|
||||
│ ├── env.d.ts
|
||||
│ ├── typings/comfyui.d.ts
|
||||
│ ├── widgets/
|
||||
│ │ ├── _shared.ts
|
||||
│ │ ├── context.ts
|
||||
│ │ ├── assembler.ts
|
||||
│ │ └── debug.ts
|
||||
│ ├── extension/
|
||||
│ │ ├── graph.ts
|
||||
│ │ └── conflicts.ts
|
||||
│ └── components/
|
||||
│ ├── context/ContextWidget.vue
|
||||
│ ├── assembler/AssemblerHelper.vue
|
||||
│ └── debug/DebugViewer.vue
|
||||
│
|
||||
├── tests/
|
||||
│ ├── test_template.py
|
||||
│ ├── test_modules.py
|
||||
│ ├── test_engine.py
|
||||
│ └── test_nodes.py
|
||||
│
|
||||
├── docs/
|
||||
│ └── superpowers/specs/
|
||||
│
|
||||
├── js/ # built extension (gitignored)
|
||||
└── web_dist/ # built manager (gitignored, empty in MVP)
|
||||
```
|
||||
|
||||
### 8.2 Package manager & scripts
|
||||
|
||||
**pnpm** is the package manager. `packageManager` field pinned in `package.json`. `pnpm-workspace.yaml` declares the root package.
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"scripts": {
|
||||
"dev": "vite build --watch --mode extension",
|
||||
"build:extension": "vite build --mode extension",
|
||||
"build:manager": "vite build --mode manager",
|
||||
"build": "pnpm build:extension && pnpm build:manager",
|
||||
"typecheck": "vue-tsc --noEmit",
|
||||
"lint": "eslint src --ext .ts,.vue",
|
||||
"size": "node scripts/check-bundle-size.mjs",
|
||||
"test": "vitest run",
|
||||
"test:py": "pytest",
|
||||
"prepare": "husky"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 8.3 Python tooling
|
||||
|
||||
- Python ≥ 3.10 (floor). Runtime target on this machine: embedded 3.12.10 at `E:\ComfyUIDev\ComfyUI-Easy-Install\python_embeded\python.exe`.
|
||||
- `pyproject.toml` with `[project]` metadata, `[tool.ruff]` (line-length 100, select `E/F/I/UP/B`), `[tool.pytest.ini_options]`.
|
||||
- No runtime deps beyond stdlib in `engine/`. Nodes depend on `comfy_api` (provided by ComfyUI host, not a pip install).
|
||||
- Dev deps (extras group `dev`): `pytest`, `pytest-asyncio`, `ruff`.
|
||||
|
||||
### 8.4 Dev-runtime environment separation
|
||||
|
||||
| Context | Python used | Purpose |
|
||||
|---|---|---|
|
||||
| **Runtime** (ComfyUI loads package) | embedded `python_embeded\python.exe` 3.12.10 | Runs nodes |
|
||||
| **Dev / tests** | `.venv` with system Python OR embedded | `pytest`, `ruff`, tooling |
|
||||
| **CI** (GitHub Actions) | `setup-python` matrix 3.10/3.11/3.12 | Cross-version pytest + typecheck + build |
|
||||
|
||||
Engine's stdlib-only constraint means embedded Python loads the package without any `pip install`. CI's lowest matrix row (3.10) is authoritative for what ships.
|
||||
|
||||
Two dev paths documented in `CONTRIBUTING.md`:
|
||||
|
||||
- **Recommended:** `python -m venv .venv` with system Python ≥ 3.10, then `.venv\Scripts\pip install -e ".[dev]"`.
|
||||
- **Zero-install:** `"$EMBED\python.exe" -m pip install pytest ruff` into embedded. Works, but mixes dev deps into ComfyUI's interpreter.
|
||||
|
||||
Husky pre-commit resolves Python via `PATH`; if missing, fails fast with a helpful message. Helper batch script `scripts/run-tests.cmd` auto-detects `.venv` then falls back to embedded.
|
||||
|
||||
### 8.5 Open-source tooling
|
||||
|
||||
**`.releaserc.js` — semantic-release.** Branches: `main`. Plugins: `@semantic-release/commit-analyzer`, `release-notes-generator`, `changelog`, `github`, `git`. Versioning via Conventional Commits → SemVer bump. Artifacts published to GitHub Releases; ComfyUI Registry publish via `comfy-cli` in `release.yml` after tag.
|
||||
|
||||
**`commitlint.config.js`.** `extends: ["@commitlint/config-conventional"]`. Enforced on `commit-msg` hook.
|
||||
|
||||
**Husky hooks (`.husky/`).**
|
||||
- `pre-commit`: `pnpm typecheck && pnpm lint && pnpm test` (frontend) + `ruff check` + `pytest -q` (backend).
|
||||
- `commit-msg`: `commitlint --edit $1`.
|
||||
|
||||
**GitHub Actions.**
|
||||
- `ci.yml` — pytest on Py 3.10/3.11/3.12, frontend typecheck + build + bundle-size gate.
|
||||
- `release.yml` — on `main` push, semantic-release → tag + changelog + Registry publish.
|
||||
|
||||
**Repo hygiene.** `CODEOWNERS → @DumiFlex`. `LICENSE` (to confirm; reference project used GPL-3.0). `CODE_OF_CONDUCT.md`, `CONTRIBUTING.md` ported and adapted.
|
||||
|
||||
### 8.6 Tests (MVP coverage)
|
||||
|
||||
- **engine/** — unit tests for `resolve_variables`, `module_from_dict`, `PipelineEngine.run` with `fixed_values`, chain merge behaviour, trace shape.
|
||||
- **nodes/** — mock `comfy_api` stubs in `conftest.py`; verify `execute()` wires upstream → engine → `ContextPayload` correctly.
|
||||
- **src/extension/conflicts.ts** — Vitest unit tests for the 3 conflict types.
|
||||
- **src/widgets/** — no UI tests in MVP; manual QA + later Playwright sub-spec.
|
||||
|
||||
CI badge + coverage badge added to `README.md`.
|
||||
|
||||
### 8.7 CLAUDE.md
|
||||
|
||||
Written at the **start of implementation** (not now). Captures: build/test commands, conventions, anti-patterns inherited from the reference project, engine isolation invariant, bundle-size gate, custom type name, embedded-vs-dev Python split. Writing earlier risks encoding decisions that haven't survived first contact with code.
|
||||
|
||||
## 9. Out of Scope / Future Sub-Specs
|
||||
|
||||
Every item below is a distinct follow-up brainstorming session → spec → plan → implementation cycle. Ordering is by dependency; items marked *flexible* can swap with neighbours based on demand.
|
||||
|
||||
1. **Wildcards module + seed propagation.** `wildcard` module type with weighted options + categories. Nested `@{uuid}` refs with cycle detection + depth limit. Per-capture seed via `sha256(node_seed, capture_name)`. Pick-mode (pin a specific value).
|
||||
2. **Combine module.** *(flexible with #3)* `combine` handler: `template + capture_as`. Adds `missing_combine_variable` to scanner.
|
||||
3. **Constraints module.** *(flexible with #2)* Category rule matrix + exceptions. Applied to wildcard options pre-sampling. Adds `unresolved_constraint_target` + `unresolved_constraint_when_variable` to scanner.
|
||||
4. **Derivations module.** Conditional mutations after wildcards. To brainstorm: any/all conditions, priority/rounds, output templates, `sample_from`.
|
||||
5. **`WP_ContextInject` node.** 3 optional socket inputs + autogrow-style slot-to-variable mapping. Widget: connected-slot detector + variable-name editor.
|
||||
6. **Library — sqlite backend + REST API.** Schema (alembic migrations): modules, pipelines, history (≤3 versions). aiohttp routes under `/wildcard-pipeline/api/*`. Inline serialised module remains authoritative in workflows; library `id` is a weak link; UI warns on missing. Context menu: *Save module to library*, *Link instance to library entry*, *Edit instance overrides*.
|
||||
7. **Manager SPA.** Vue 3 + PrimeVue + Pinia at `/wildcard-pipeline/`. Routes: modules list (per type), module editor, pipelines list, pipeline editor, test playground, import/export. Route-based code splitting. Playwright e2e.
|
||||
8. **Pipeline presets.** Node right-click menu *Save as pipeline* / *Import pipeline* (append/replace). Sharable JSON + library storage. Rename node on import.
|
||||
9. **`WP_DEBUG` env socket + advanced debug UX.** `os.environ["WP_DEBUG"]` toggles an extra DEBUG_JSON socket output on every node. `WP_Debug` gains a second input accepting debug JSON directly. Viewer: timeline view, diff view between runs.
|
||||
10. **Instance overrides UI.** Per-instance overrides on library-linked modules (weight tweaks, enable/disable values). Diff indicator in widget. Revert-to-library button.
|
||||
|
||||
### Explicitly not planned
|
||||
|
||||
- Workflow migration from `ComfyUI-Wildcard-Pipeline` (users re-author).
|
||||
- Multi-user / collaboration features.
|
||||
- Cloud sync for library.
|
||||
- Non-Vue widgets (React/Svelte).
|
||||
|
||||
### Governance
|
||||
|
||||
Each spec starts with its own brainstorming → spec doc in `docs/superpowers/specs/` → implementation plan → PR. No implementation of #1 begins until MVP lands and has surfaced real constraints from use.
|
||||
@@ -0,0 +1,19 @@
|
||||
"""Shared utility helpers used across the engine + wp_api packages."""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as _dt
|
||||
|
||||
|
||||
def now_iso() -> str:
|
||||
"""UTC ISO timestamp with microsecond precision and ``Z`` suffix.
|
||||
|
||||
Used for `created_at`, `updated_at`, `library_snapshot_at`,
|
||||
migration `applied_at`, and import-export `exported_at`. Single source
|
||||
of truth so the format never drifts between callers.
|
||||
|
||||
Microsecond precision (vs. millisecond) prevents collisions when
|
||||
two ops fire inside the same millisecond on fast CI runners — the
|
||||
`updated_at > created_at` invariant in `ModuleRepository.update`
|
||||
must hold for any sequential create+update pair.
|
||||
"""
|
||||
return _dt.datetime.now(_dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ")
|
||||
@@ -0,0 +1,4 @@
|
||||
"""SQLite-backed library DB."""
|
||||
from engine.db.connection import get_connection, resolve_db_path
|
||||
|
||||
__all__ = ["get_connection", "resolve_db_path"]
|
||||
@@ -0,0 +1,33 @@
|
||||
"""SQLite connection factory.
|
||||
|
||||
DB path resolution order:
|
||||
1. ``WP_DB_PATH`` env var (explicit override)
|
||||
2. ``$COMFYUI_USER_DIR/wildcard-pipeline.db``
|
||||
3. Fallback: ``~/.comfyui/wildcard-pipeline.db``
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def resolve_db_path() -> Path:
|
||||
override = os.environ.get("WP_DB_PATH")
|
||||
if override:
|
||||
return Path(override)
|
||||
user_dir = os.environ.get("COMFYUI_USER_DIR")
|
||||
if user_dir:
|
||||
return Path(user_dir) / "wildcard-pipeline.db"
|
||||
return Path.home() / ".comfyui" / "wildcard-pipeline.db"
|
||||
|
||||
|
||||
def get_connection(path: Path | None = None) -> sqlite3.Connection:
|
||||
"""Open a SQLite connection with WAL mode and FK enforcement."""
|
||||
db_path = path or resolve_db_path()
|
||||
db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
conn = sqlite3.connect(str(db_path), check_same_thread=False)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA journal_mode=WAL;")
|
||||
conn.execute("PRAGMA foreign_keys=ON;")
|
||||
return conn
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Versioned, forward-only migration runner.
|
||||
|
||||
SQL files live in ``engine/db/migrations_sql/<NNN>_<name>.sql``. Filenames
|
||||
must start with a 3-digit zero-padded version number; the runner applies
|
||||
them in numeric order.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
from engine._utils import now_iso
|
||||
|
||||
_SQL_DIR = Path(__file__).parent / "migrations_sql"
|
||||
_VERSION_RE = re.compile(r"^(\d{3})_.+\.sql$")
|
||||
|
||||
|
||||
def _migrations_table_exists(conn: sqlite3.Connection) -> bool:
|
||||
row = conn.execute(
|
||||
"SELECT name FROM sqlite_master "
|
||||
"WHERE type='table' AND name='migrations';"
|
||||
).fetchone()
|
||||
return row is not None
|
||||
|
||||
|
||||
def current_version(conn: sqlite3.Connection) -> int:
|
||||
if not _migrations_table_exists(conn):
|
||||
return 0
|
||||
row = conn.execute("SELECT MAX(version) FROM migrations;").fetchone()
|
||||
return int(row[0]) if row and row[0] is not None else 0
|
||||
|
||||
|
||||
def _discover() -> list[tuple[int, Path]]:
|
||||
pairs: list[tuple[int, Path]] = []
|
||||
for entry in _SQL_DIR.iterdir():
|
||||
if not entry.is_file():
|
||||
continue
|
||||
m = _VERSION_RE.match(entry.name)
|
||||
if not m:
|
||||
continue
|
||||
pairs.append((int(m.group(1)), entry))
|
||||
pairs.sort(key=lambda p: p[0])
|
||||
return pairs
|
||||
|
||||
|
||||
def migrate(conn: sqlite3.Connection) -> None:
|
||||
"""Apply all pending migrations. Safe to call repeatedly."""
|
||||
applied = current_version(conn)
|
||||
for version, sql_file in _discover():
|
||||
if version <= applied:
|
||||
continue
|
||||
sql = sql_file.read_text(encoding="utf-8")
|
||||
with conn:
|
||||
for stmt in sql.split(";"):
|
||||
stmt = stmt.strip()
|
||||
if stmt:
|
||||
conn.execute(stmt)
|
||||
conn.execute(
|
||||
"INSERT INTO migrations(version, applied_at) VALUES(?, ?);",
|
||||
(version, now_iso()),
|
||||
)
|
||||
@@ -0,0 +1,30 @@
|
||||
CREATE TABLE IF NOT EXISTS migrations (
|
||||
version INTEGER PRIMARY KEY,
|
||||
applied_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS module_categories (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL UNIQUE COLLATE NOCASE,
|
||||
color TEXT,
|
||||
icon TEXT,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS modules (
|
||||
id TEXT PRIMARY KEY,
|
||||
type TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
category_id TEXT REFERENCES module_categories(id) ON DELETE SET NULL,
|
||||
tags TEXT NOT NULL DEFAULT '[]',
|
||||
is_favorite INTEGER NOT NULL DEFAULT 0,
|
||||
payload TEXT NOT NULL,
|
||||
version INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_modules_type ON modules(type);
|
||||
CREATE INDEX IF NOT EXISTS idx_modules_category ON modules(category_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_modules_updated_at ON modules(updated_at DESC);
|
||||
@@ -0,0 +1 @@
|
||||
PRAGMA user_version = 2;
|
||||
@@ -0,0 +1,309 @@
|
||||
"""Repository classes wrapping SQL access to library tables.
|
||||
|
||||
Repositories take a ``sqlite3.Connection``. Each public method opens a
|
||||
transaction via ``with conn:`` so callers don't need explicit commit
|
||||
calls. Rows are returned as plain dicts (sqlite3.Row converted).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import secrets
|
||||
import sqlite3
|
||||
from typing import Any
|
||||
|
||||
from engine._utils import now_iso as _now
|
||||
|
||||
_TYPE_PREFIX = {
|
||||
"wildcard": "wc",
|
||||
"fixed_values": "fv",
|
||||
"combine": "cb",
|
||||
"derivation": "dr",
|
||||
"constraint": "ct",
|
||||
"pipeline": "pl",
|
||||
}
|
||||
_VALID_TYPES: frozenset[str] = frozenset(_TYPE_PREFIX.keys())
|
||||
_SLUG_RE = re.compile(r"[^a-z0-9]+")
|
||||
|
||||
|
||||
class _Unset:
|
||||
"""Sentinel for 'argument not provided' where ``None`` is a valid value."""
|
||||
_instance: _Unset | None = None
|
||||
|
||||
def __new__(cls) -> _Unset:
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
return cls._instance
|
||||
|
||||
|
||||
_UNSET = _Unset()
|
||||
|
||||
|
||||
class ModuleNotFound(LookupError):
|
||||
"""Raised when a requested module id does not exist."""
|
||||
|
||||
|
||||
def _slug(name: str) -> str:
|
||||
s = _SLUG_RE.sub("_", name.lower()).strip("_")
|
||||
return s[:24] or "module"
|
||||
|
||||
|
||||
def _row_to_module(row: sqlite3.Row) -> dict[str, Any]:
|
||||
return {
|
||||
"id": row["id"],
|
||||
"type": row["type"],
|
||||
"name": row["name"],
|
||||
"description": row["description"],
|
||||
"category_id": row["category_id"],
|
||||
"tags": json.loads(row["tags"]),
|
||||
"is_favorite": bool(row["is_favorite"]),
|
||||
"payload": json.loads(row["payload"]),
|
||||
"version": row["version"],
|
||||
"created_at": row["created_at"],
|
||||
"updated_at": row["updated_at"],
|
||||
}
|
||||
|
||||
|
||||
class ModuleRepository:
|
||||
def __init__(self, conn: sqlite3.Connection) -> None:
|
||||
self._conn = conn
|
||||
|
||||
def _gen_id(self, type: str, name: str) -> str:
|
||||
prefix = _TYPE_PREFIX.get(type, "mod")
|
||||
return f"{prefix}_{_slug(name)}_{secrets.token_hex(4)}"
|
||||
|
||||
def create(
|
||||
self,
|
||||
*,
|
||||
type: str,
|
||||
name: str,
|
||||
description: str,
|
||||
category_id: str | None,
|
||||
tags: list[str],
|
||||
payload: dict[str, Any],
|
||||
is_favorite: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
if type not in _VALID_TYPES:
|
||||
raise ValueError(
|
||||
f"unknown module type {type!r}; expected one of {sorted(_VALID_TYPES)}"
|
||||
)
|
||||
mid = self._gen_id(type, name)
|
||||
now = _now()
|
||||
with self._conn:
|
||||
self._conn.execute(
|
||||
"INSERT INTO modules("
|
||||
"id, type, name, description, category_id, tags, "
|
||||
"is_favorite, payload, version, created_at, updated_at"
|
||||
") VALUES(?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?);",
|
||||
(
|
||||
mid, type, name, description, category_id,
|
||||
json.dumps(tags), int(is_favorite),
|
||||
json.dumps(payload), now, now,
|
||||
),
|
||||
)
|
||||
return self.get(mid)
|
||||
|
||||
def get(self, module_id: str) -> dict[str, Any]:
|
||||
row = self._conn.execute(
|
||||
"SELECT * FROM modules WHERE id = ?;", (module_id,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise ModuleNotFound(module_id)
|
||||
return _row_to_module(row)
|
||||
|
||||
def update(
|
||||
self,
|
||||
module_id: str,
|
||||
*,
|
||||
name: str | _Unset = _UNSET,
|
||||
description: str | _Unset = _UNSET,
|
||||
category_id: str | None | _Unset = _UNSET,
|
||||
tags: list[str] | _Unset = _UNSET,
|
||||
payload: dict[str, Any] | _Unset = _UNSET,
|
||||
is_favorite: bool | _Unset = _UNSET,
|
||||
) -> dict[str, Any]:
|
||||
existing = self.get(module_id)
|
||||
new = {
|
||||
"name": existing["name"] if isinstance(name, _Unset) else name,
|
||||
"description": (
|
||||
existing["description"] if isinstance(description, _Unset) else description
|
||||
),
|
||||
"category_id": (
|
||||
existing["category_id"] if isinstance(category_id, _Unset) else category_id
|
||||
),
|
||||
"tags": existing["tags"] if isinstance(tags, _Unset) else tags,
|
||||
"payload": existing["payload"] if isinstance(payload, _Unset) else payload,
|
||||
"is_favorite": (
|
||||
existing["is_favorite"] if isinstance(is_favorite, _Unset) else is_favorite
|
||||
),
|
||||
}
|
||||
now = _now()
|
||||
with self._conn:
|
||||
self._conn.execute(
|
||||
"UPDATE modules SET "
|
||||
"name = ?, description = ?, category_id = ?, tags = ?, "
|
||||
"is_favorite = ?, payload = ?, version = version + 1, "
|
||||
"updated_at = ? "
|
||||
"WHERE id = ?;",
|
||||
(
|
||||
new["name"], new["description"], new["category_id"],
|
||||
json.dumps(new["tags"]), int(new["is_favorite"]),
|
||||
json.dumps(new["payload"]), now, module_id,
|
||||
),
|
||||
)
|
||||
return self.get(module_id)
|
||||
|
||||
def delete(self, module_id: str) -> None:
|
||||
with self._conn:
|
||||
cur = self._conn.execute("DELETE FROM modules WHERE id = ?;", (module_id,))
|
||||
if cur.rowcount == 0:
|
||||
raise ModuleNotFound(module_id)
|
||||
|
||||
def list(
|
||||
self,
|
||||
*,
|
||||
type: str | None = None,
|
||||
category_id: str | None = None,
|
||||
query: str | None = None,
|
||||
favorites_only: bool = False,
|
||||
limit: int | None = None,
|
||||
offset: int = 0,
|
||||
) -> list[dict[str, Any]]:
|
||||
clauses: list[str] = []
|
||||
params: list[Any] = []
|
||||
if type is not None:
|
||||
clauses.append("type = ?")
|
||||
params.append(type)
|
||||
if category_id is not None:
|
||||
clauses.append("category_id = ?")
|
||||
params.append(category_id)
|
||||
if query:
|
||||
escaped = (
|
||||
query.replace("\\", "\\\\")
|
||||
.replace("%", "\\%")
|
||||
.replace("_", "\\_")
|
||||
)
|
||||
clauses.append("name LIKE ? ESCAPE '\\' COLLATE NOCASE")
|
||||
params.append(f"%{escaped}%")
|
||||
if favorites_only:
|
||||
clauses.append("is_favorite = 1")
|
||||
sql = "SELECT * FROM modules"
|
||||
if clauses:
|
||||
sql += " WHERE " + " AND ".join(clauses)
|
||||
sql += " ORDER BY updated_at DESC, id DESC"
|
||||
if limit is not None:
|
||||
sql += " LIMIT ? OFFSET ?"
|
||||
params.extend([limit, offset])
|
||||
elif offset:
|
||||
# SQLite uses LIMIT -1 to mean "unlimited" while still honoring OFFSET.
|
||||
sql += " LIMIT -1 OFFSET ?"
|
||||
params.append(offset)
|
||||
rows = self._conn.execute(sql, params).fetchall()
|
||||
return [_row_to_module(r) for r in rows]
|
||||
|
||||
|
||||
class CategoryNotFound(LookupError):
|
||||
"""Raised when a requested category id does not exist."""
|
||||
|
||||
|
||||
def _row_to_category(row: sqlite3.Row) -> dict[str, Any]:
|
||||
return {
|
||||
"id": row["id"],
|
||||
"name": row["name"],
|
||||
"color": row["color"],
|
||||
"icon": row["icon"],
|
||||
"sort_order": row["sort_order"],
|
||||
}
|
||||
|
||||
|
||||
class CategoryRepository:
|
||||
def __init__(self, conn: sqlite3.Connection) -> None:
|
||||
self._conn = conn
|
||||
|
||||
def create(
|
||||
self,
|
||||
*,
|
||||
name: str,
|
||||
color: str | None,
|
||||
icon: str | None,
|
||||
sort_order: int = 0,
|
||||
) -> dict[str, Any]:
|
||||
cid = _slug(name)
|
||||
if cid == "module":
|
||||
# _slug returned its fallback because `name` had no alphanumeric chars.
|
||||
raise ValueError(
|
||||
f"name {name!r} has no alphanumeric characters; "
|
||||
f"category name must produce a usable slug"
|
||||
)
|
||||
try:
|
||||
with self._conn:
|
||||
self._conn.execute(
|
||||
"INSERT INTO module_categories("
|
||||
"id, name, color, icon, sort_order"
|
||||
") VALUES(?, ?, ?, ?, ?);",
|
||||
(cid, name, color, icon, sort_order),
|
||||
)
|
||||
except sqlite3.IntegrityError as e:
|
||||
err = str(e).lower()
|
||||
if "module_categories.id" in err:
|
||||
raise ValueError(
|
||||
f"category id collision: {name!r} produces slug {cid!r} "
|
||||
f"which is already taken"
|
||||
) from e
|
||||
raise ValueError(f"category name not unique: {name!r}") from e
|
||||
return self.get(cid)
|
||||
|
||||
def get(self, category_id: str) -> dict[str, Any]:
|
||||
row = self._conn.execute(
|
||||
"SELECT * FROM module_categories WHERE id = ?;",
|
||||
(category_id,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise CategoryNotFound(category_id)
|
||||
return _row_to_category(row)
|
||||
|
||||
def update(
|
||||
self,
|
||||
category_id: str,
|
||||
*,
|
||||
name: str | _Unset = _UNSET,
|
||||
color: str | None | _Unset = _UNSET,
|
||||
icon: str | None | _Unset = _UNSET,
|
||||
sort_order: int | _Unset = _UNSET,
|
||||
) -> dict[str, Any]:
|
||||
existing = self.get(category_id)
|
||||
new = {
|
||||
"name": existing["name"] if isinstance(name, _Unset) else name,
|
||||
"color": existing["color"] if isinstance(color, _Unset) else color,
|
||||
"icon": existing["icon"] if isinstance(icon, _Unset) else icon,
|
||||
"sort_order": (
|
||||
existing["sort_order"] if isinstance(sort_order, _Unset) else sort_order
|
||||
),
|
||||
}
|
||||
with self._conn:
|
||||
self._conn.execute(
|
||||
"UPDATE module_categories SET "
|
||||
"name = ?, color = ?, icon = ?, sort_order = ? "
|
||||
"WHERE id = ?;",
|
||||
(
|
||||
new["name"], new["color"], new["icon"],
|
||||
new["sort_order"], category_id,
|
||||
),
|
||||
)
|
||||
return self.get(category_id)
|
||||
|
||||
def delete(self, category_id: str) -> None:
|
||||
with self._conn:
|
||||
cur = self._conn.execute(
|
||||
"DELETE FROM module_categories WHERE id = ?;",
|
||||
(category_id,),
|
||||
)
|
||||
if cur.rowcount == 0:
|
||||
raise CategoryNotFound(category_id)
|
||||
|
||||
def list(self) -> list[dict[str, Any]]:
|
||||
rows = self._conn.execute(
|
||||
"SELECT * FROM module_categories "
|
||||
"ORDER BY sort_order ASC, name COLLATE NOCASE ASC;"
|
||||
).fetchall()
|
||||
return [_row_to_category(r) for r in rows]
|
||||
@@ -1,40 +0,0 @@
|
||||
"""Per-module-type handlers. One function per module type.
|
||||
|
||||
Handler signature is ``(module, ctx, rng) -> Context``. Returns the (possibly
|
||||
mutated) context. ``rng`` is a ``random.Random`` supplied by the pipeline —
|
||||
``fixed_values`` ignores it; seed-consuming handlers (wildcard, derivation)
|
||||
use it in later specs.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import random
|
||||
from collections.abc import Callable
|
||||
from typing import Any, TypeAlias
|
||||
|
||||
from .context import Context
|
||||
from .modules import FixedValueModule
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
ModuleHandler: TypeAlias = Callable[[Any, Context, random.Random], Context]
|
||||
|
||||
|
||||
def handle_fixed_values(
|
||||
module: FixedValueModule,
|
||||
ctx: Context,
|
||||
_rng: random.Random,
|
||||
) -> Context:
|
||||
"""Write each entry's value into ``ctx``. Mutates and returns ``ctx``.
|
||||
|
||||
``_rng`` is accepted for signature parity with future seed-consuming
|
||||
handlers (wildcard, derivation); ``fixed_values`` doesn't use randomness.
|
||||
"""
|
||||
for entry in module.entries:
|
||||
name = entry.variable_name.lstrip("$")
|
||||
if not name:
|
||||
logger.warning(
|
||||
"Skipping entry with empty variable_name in module %s", module.id
|
||||
)
|
||||
continue
|
||||
ctx[name] = entry.value
|
||||
return ctx
|
||||
@@ -0,0 +1,136 @@
|
||||
"""Module type registry + resolver context factory."""
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from engine.syntax.types import ResolveContext, SurfaceKind
|
||||
|
||||
|
||||
@dataclass
|
||||
class _RuntimeResolveContext:
|
||||
"""Concrete adapter from a pipeline run's ctx dict to a ResolveContext.
|
||||
|
||||
Constructed via build_resolve_ctx; used by handlers when calling resolve_text.
|
||||
"""
|
||||
|
||||
rng: random.Random
|
||||
max_ref_depth: int
|
||||
strict: bool
|
||||
surface: SurfaceKind
|
||||
developer_mode: bool
|
||||
warnings: list[dict[str, Any]]
|
||||
_vars: dict[str, Any]
|
||||
_catalog: dict[str, dict[str, Any]]
|
||||
|
||||
def get_var(self, name: str) -> str | None:
|
||||
if name in self._vars:
|
||||
return str(self._vars[name])
|
||||
return None
|
||||
|
||||
def get_module(self, uuid: str) -> dict[str, Any] | None:
|
||||
return self._catalog.get(uuid)
|
||||
|
||||
|
||||
def build_resolve_ctx(
|
||||
ctx: dict[str, Any],
|
||||
surface: SurfaceKind,
|
||||
*,
|
||||
strict: bool = False,
|
||||
) -> ResolveContext:
|
||||
"""Build a ResolveContext from a pipeline run's ctx dict + a surface label.
|
||||
|
||||
Handlers call this with their type-appropriate surface ("wildcard" /
|
||||
"combine" / "derivation" / "assembler") then pass the result to resolve_text.
|
||||
"""
|
||||
return _RuntimeResolveContext( # type: ignore[return-value]
|
||||
rng=ctx["__wp_rng__"],
|
||||
max_ref_depth=int(ctx.get("__wp_max_ref_depth__", 8)),
|
||||
strict=strict,
|
||||
surface=surface,
|
||||
developer_mode=bool(ctx.get("__wp_developer_mode__", False)),
|
||||
warnings=ctx["__wp_warnings__"],
|
||||
_vars={k: v for k, v in ctx.items() if not k.startswith("__")},
|
||||
_catalog=ctx.get("__wp_catalog__", {}),
|
||||
)
|
||||
|
||||
|
||||
from engine.modules.dispatcher import ( # noqa: E402
|
||||
ModuleHandler,
|
||||
UnknownModuleType,
|
||||
get_handler,
|
||||
register_handler,
|
||||
resolve_module,
|
||||
)
|
||||
from engine.modules.snapshot import ( # noqa: E402
|
||||
coerce_legacy_module,
|
||||
freeze_snapshot,
|
||||
payload_hash,
|
||||
)
|
||||
from engine.modules.types import ( # noqa: E402
|
||||
FixedValueEntry,
|
||||
FixedValueModule,
|
||||
Module,
|
||||
ModuleMeta,
|
||||
module_from_dict,
|
||||
module_to_dict,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"FixedValueEntry",
|
||||
"FixedValueModule",
|
||||
"Module",
|
||||
"ModuleMeta",
|
||||
"module_from_dict",
|
||||
"module_to_dict",
|
||||
]
|
||||
|
||||
__all__ += [
|
||||
"coerce_legacy_module",
|
||||
"freeze_snapshot",
|
||||
"payload_hash",
|
||||
]
|
||||
|
||||
__all__ += [
|
||||
"ModuleHandler",
|
||||
"UnknownModuleType",
|
||||
"get_handler",
|
||||
"register_handler",
|
||||
"resolve_module",
|
||||
]
|
||||
|
||||
from engine.modules.wildcard_handler import WildcardHandler # noqa: E402
|
||||
from engine.syntax import RecursionLimitExceeded # noqa: E402
|
||||
|
||||
register_handler(WildcardHandler)
|
||||
|
||||
__all__ += [
|
||||
"RecursionLimitExceeded",
|
||||
"WildcardHandler",
|
||||
]
|
||||
|
||||
from engine.modules.fixed_values_handler import FixedValuesHandler # noqa: E402
|
||||
|
||||
register_handler(FixedValuesHandler)
|
||||
|
||||
__all__ += ["FixedValuesHandler"]
|
||||
|
||||
from engine.modules.combine_handler import CombineHandler # noqa: E402
|
||||
from engine.modules.constraint_handler import ConstraintHandler # noqa: E402
|
||||
from engine.modules.derivation_handler import DerivationHandler # noqa: E402
|
||||
from engine.modules.pipeline_handler import PipelineHandler # noqa: E402
|
||||
|
||||
register_handler(CombineHandler)
|
||||
register_handler(DerivationHandler)
|
||||
register_handler(ConstraintHandler)
|
||||
register_handler(PipelineHandler)
|
||||
|
||||
__all__ += [
|
||||
"CombineHandler",
|
||||
"ConstraintHandler",
|
||||
"DerivationHandler",
|
||||
"PipelineHandler",
|
||||
]
|
||||
|
||||
__all__ += ["build_resolve_ctx"]
|
||||
@@ -0,0 +1,57 @@
|
||||
"""Combine module resolver — syntax-aware template fill.
|
||||
|
||||
Reads payload.template, resolves all $var / @{uuid} / {a|b|c} / {N$$sep$$...}
|
||||
constructs against the runtime ctx, binds the result to payload.output_var.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from engine.modules import build_resolve_ctx
|
||||
from engine.modules.dispatcher import ModuleHandler
|
||||
from engine.syntax import resolve_text
|
||||
|
||||
_IDENT_RE = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_]*$")
|
||||
|
||||
|
||||
class CombineHandler(ModuleHandler):
|
||||
"""Template-fill module: full syntax surface, output bound."""
|
||||
|
||||
type_id = "combine"
|
||||
|
||||
@classmethod
|
||||
def validate_payload(cls, payload: dict[str, Any]) -> None:
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError("combine payload must be an object")
|
||||
template = payload.get("template")
|
||||
if not isinstance(template, str):
|
||||
raise ValueError("combine payload.template must be a string")
|
||||
output_var = payload.get("output_var")
|
||||
if not isinstance(output_var, str) or not output_var:
|
||||
raise ValueError("combine payload.output_var must be a non-empty string")
|
||||
if not _IDENT_RE.match(output_var):
|
||||
raise ValueError(
|
||||
f"combine payload.output_var {output_var!r} is not a valid identifier"
|
||||
)
|
||||
input_vars = payload.get("input_vars", [])
|
||||
if not isinstance(input_vars, list):
|
||||
raise ValueError("combine payload.input_vars must be a list")
|
||||
for v in input_vars:
|
||||
if not isinstance(v, str):
|
||||
raise ValueError("combine payload.input_vars entries must be strings")
|
||||
|
||||
@classmethod
|
||||
def resolve(
|
||||
cls,
|
||||
payload: dict[str, Any],
|
||||
instance: dict[str, Any],
|
||||
ctx: Any,
|
||||
) -> dict[str, str]:
|
||||
cls.validate_payload(payload)
|
||||
template: str = payload["template"]
|
||||
output_var: str = payload["output_var"]
|
||||
|
||||
resolve_ctx = build_resolve_ctx(ctx, surface="combine")
|
||||
result = resolve_text(template, resolve_ctx)
|
||||
return {output_var: result}
|
||||
@@ -0,0 +1,146 @@
|
||||
"""Constraint module resolver — pass-through stub.
|
||||
|
||||
A constraint module declares a re-weighting matrix from one wildcard module's
|
||||
options to another's. The actual mutation is intentionally **not** done here:
|
||||
WildcardHandler will eventually inspect ``ctx["_constraints"]`` and apply the
|
||||
matrix when picking options. This handler only validates the payload and
|
||||
records metadata in the context for the wildcard handler to consume.
|
||||
|
||||
TODO: When WildcardHandler grows constraint awareness, switch from recording
|
||||
metadata to applying it here, or keep it as a pure metadata channel and let
|
||||
the consumer drive resolution. The current decision is metadata-only.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from engine.modules.dispatcher import ModuleHandler
|
||||
|
||||
_VALID_MODES = {"allow", "exclude", "boost", "reduce"}
|
||||
|
||||
|
||||
def _ctx_set_constraint(ctx: Any, meta: dict[str, Any]) -> None:
|
||||
"""Append ``meta`` to ``ctx['_constraints']`` (best-effort)."""
|
||||
if ctx is None:
|
||||
return
|
||||
# Try setter-based access first (engine Context API).
|
||||
setter = getattr(ctx, "set", None)
|
||||
getter = getattr(ctx, "get", None)
|
||||
if callable(setter) and callable(getter):
|
||||
try:
|
||||
existing = getter("_constraints", None)
|
||||
except TypeError:
|
||||
existing = None
|
||||
bucket = list(existing) if isinstance(existing, list) else []
|
||||
bucket.append(meta)
|
||||
try:
|
||||
setter("_constraints", bucket)
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
# Fall back to dict-like __getitem__/__setitem__.
|
||||
try:
|
||||
existing = ctx["_constraints"] if "_constraints" in ctx else [] # type: ignore[operator,index]
|
||||
except Exception:
|
||||
existing = []
|
||||
bucket = list(existing) if isinstance(existing, list) else []
|
||||
bucket.append(meta)
|
||||
try:
|
||||
ctx["_constraints"] = bucket # type: ignore[index]
|
||||
except Exception:
|
||||
return
|
||||
|
||||
|
||||
def _validate_cell(cell: Any, where: str) -> None:
|
||||
if not isinstance(cell, dict):
|
||||
raise ValueError(f"constraint {where} must be an object")
|
||||
mode = cell.get("mode")
|
||||
if mode not in _VALID_MODES:
|
||||
raise ValueError(
|
||||
f"constraint {where}.mode must be one of {sorted(_VALID_MODES)}"
|
||||
)
|
||||
factor = cell.get("factor")
|
||||
if not isinstance(factor, (int, float)) or isinstance(factor, bool):
|
||||
raise ValueError(f"constraint {where}.factor must be a number")
|
||||
if float(factor) <= 0:
|
||||
raise ValueError(f"constraint {where}.factor must be a positive number")
|
||||
|
||||
|
||||
class ConstraintHandler(ModuleHandler):
|
||||
"""Records a constraint matrix into the context for downstream consumers."""
|
||||
|
||||
type_id = "constraint"
|
||||
|
||||
@classmethod
|
||||
def validate_payload(cls, payload: dict[str, Any]) -> None:
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError("constraint payload must be an object")
|
||||
for key in ("source_wildcard_id", "target_wildcard_id"):
|
||||
value = payload.get(key)
|
||||
if not isinstance(value, str) or not value:
|
||||
raise ValueError(f"constraint payload.{key} must be a non-empty string")
|
||||
matrix = payload.get("matrix", {})
|
||||
if not isinstance(matrix, dict):
|
||||
raise ValueError("constraint payload.matrix must be an object")
|
||||
for source, sub in matrix.items():
|
||||
if not isinstance(source, str):
|
||||
raise ValueError("constraint payload.matrix keys must be strings")
|
||||
if not isinstance(sub, dict):
|
||||
raise ValueError(
|
||||
f"constraint payload.matrix[{source!r}] must be an object"
|
||||
)
|
||||
for target, cell in sub.items():
|
||||
if not isinstance(target, str):
|
||||
raise ValueError(
|
||||
f"constraint payload.matrix[{source!r}] keys must be strings"
|
||||
)
|
||||
_validate_cell(cell, f"matrix[{source!r}][{target!r}]")
|
||||
exceptions = payload.get("exceptions", [])
|
||||
if not isinstance(exceptions, list):
|
||||
raise ValueError("constraint payload.exceptions must be a list")
|
||||
for i, exc in enumerate(exceptions):
|
||||
if not isinstance(exc, dict):
|
||||
raise ValueError(
|
||||
f"constraint payload.exceptions[{i}] must be an object"
|
||||
)
|
||||
for key in ("source", "target"):
|
||||
v = exc.get(key)
|
||||
if not isinstance(v, str) or not v:
|
||||
raise ValueError(
|
||||
f"constraint payload.exceptions[{i}].{key} must be a "
|
||||
f"non-empty string"
|
||||
)
|
||||
mode = exc.get("mode")
|
||||
if mode not in _VALID_MODES:
|
||||
raise ValueError(
|
||||
f"constraint payload.exceptions[{i}].mode must be one of "
|
||||
f"{sorted(_VALID_MODES)}"
|
||||
)
|
||||
factor = exc.get("factor")
|
||||
if not isinstance(factor, (int, float)) or isinstance(factor, bool):
|
||||
raise ValueError(
|
||||
f"constraint payload.exceptions[{i}].factor must be a number"
|
||||
)
|
||||
if float(factor) <= 0:
|
||||
raise ValueError(
|
||||
f"constraint payload.exceptions[{i}].factor must be positive"
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def resolve(
|
||||
cls,
|
||||
payload: dict[str, Any],
|
||||
instance: dict[str, Any],
|
||||
ctx: Any,
|
||||
) -> dict[str, str]:
|
||||
cls.validate_payload(payload)
|
||||
meta = {
|
||||
"source_wildcard_id": payload["source_wildcard_id"],
|
||||
"target_wildcard_id": payload["target_wildcard_id"],
|
||||
"matrix": payload.get("matrix", {}),
|
||||
"exceptions": payload.get("exceptions", []),
|
||||
}
|
||||
_ctx_set_constraint(ctx, meta)
|
||||
# Pass-through stub: return no bindings — WildcardHandler is expected
|
||||
# to consume ctx["_constraints"] when it grows constraint awareness.
|
||||
return {}
|
||||
@@ -0,0 +1,203 @@
|
||||
"""Derivation module resolver — IF/ELIF/ELSE rules over the runtime context.
|
||||
|
||||
Each rule has an ordered list of branches (index 0 == IF, the rest == ELIF)
|
||||
and an optional ``else`` clause. Rules evaluate independently top-to-bottom:
|
||||
the first branch whose condition matches wins; if none match, the ``else``
|
||||
action (if any) fires; otherwise the rule is a no-op.
|
||||
|
||||
Actions mutate the runtime context directly. Multiple rules can target the
|
||||
same variable; later rules see earlier mutations. Action ``value`` strings
|
||||
are resolved through resolve_text with surface="derivation" so $var tokens
|
||||
and {a|b|c} picks work inside derivation action values.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from engine.modules import build_resolve_ctx
|
||||
from engine.modules.dispatcher import ModuleHandler
|
||||
from engine.syntax import resolve_text
|
||||
|
||||
_VALID_OPS = {"equals", "not_equals", "contains", "matches"}
|
||||
_VALID_MODES = {"replace", "append", "prepend"}
|
||||
|
||||
|
||||
def _ctx_get(ctx: Any, name: str) -> str:
|
||||
if ctx is None:
|
||||
return ""
|
||||
getter = getattr(ctx, "get", None)
|
||||
if callable(getter):
|
||||
try:
|
||||
value = getter(name, "")
|
||||
except TypeError:
|
||||
try:
|
||||
value = getter(name)
|
||||
except Exception:
|
||||
return ""
|
||||
return "" if value is None else str(value)
|
||||
try:
|
||||
if name in ctx: # type: ignore[operator]
|
||||
return str(ctx[name]) # type: ignore[index]
|
||||
except Exception:
|
||||
return ""
|
||||
return ""
|
||||
|
||||
|
||||
def _ctx_set(ctx: Any, name: str, value: str) -> None:
|
||||
if ctx is None:
|
||||
return
|
||||
setter = getattr(ctx, "set", None)
|
||||
if callable(setter):
|
||||
setter(name, value)
|
||||
return
|
||||
try:
|
||||
ctx[name] = value # type: ignore[index]
|
||||
except Exception:
|
||||
return
|
||||
|
||||
|
||||
def _match_condition(condition: dict[str, Any], ctx: Any) -> bool:
|
||||
var = condition.get("var", "")
|
||||
op = condition.get("op", "")
|
||||
value = condition.get("value", "")
|
||||
actual = _ctx_get(ctx, var)
|
||||
if op == "equals":
|
||||
return actual == value
|
||||
if op == "not_equals":
|
||||
return actual != value
|
||||
if op == "contains":
|
||||
return value in actual
|
||||
if op == "matches":
|
||||
try:
|
||||
return re.search(value, actual) is not None
|
||||
except re.error:
|
||||
return False
|
||||
return False
|
||||
|
||||
|
||||
def _apply_action(
|
||||
action: dict[str, Any],
|
||||
ctx: Any,
|
||||
resolve_ctx: Any,
|
||||
) -> tuple[str, str] | None:
|
||||
target = action.get("target_var", "")
|
||||
if not target:
|
||||
return None
|
||||
mode = action.get("mode", "replace")
|
||||
raw_value = str(action.get("value", ""))
|
||||
new_value = resolve_text(raw_value, resolve_ctx)
|
||||
if mode == "replace":
|
||||
result = new_value
|
||||
elif mode == "append":
|
||||
result = _ctx_get(ctx, target) + new_value
|
||||
elif mode == "prepend":
|
||||
result = new_value + _ctx_get(ctx, target)
|
||||
else:
|
||||
return None
|
||||
_ctx_set(ctx, target, result)
|
||||
return target, result
|
||||
|
||||
|
||||
def _validate_condition(condition: Any, where: str) -> None:
|
||||
if not isinstance(condition, dict):
|
||||
raise ValueError(f"derivation {where}.condition must be an object")
|
||||
var = condition.get("var")
|
||||
if not isinstance(var, str) or not var:
|
||||
raise ValueError(f"derivation {where}.condition.var must be a non-empty string")
|
||||
op = condition.get("op")
|
||||
if op not in _VALID_OPS:
|
||||
raise ValueError(
|
||||
f"derivation {where}.condition.op must be one of {sorted(_VALID_OPS)}"
|
||||
)
|
||||
if not isinstance(condition.get("value", ""), str):
|
||||
raise ValueError(f"derivation {where}.condition.value must be a string")
|
||||
|
||||
|
||||
def _validate_action(action: Any, where: str) -> None:
|
||||
if not isinstance(action, dict):
|
||||
raise ValueError(f"derivation {where}.action must be an object")
|
||||
target = action.get("target_var")
|
||||
if not isinstance(target, str) or not target:
|
||||
raise ValueError(
|
||||
f"derivation {where}.action.target_var must be a non-empty string"
|
||||
)
|
||||
mode = action.get("mode", "replace")
|
||||
if mode not in _VALID_MODES:
|
||||
raise ValueError(
|
||||
f"derivation {where}.action.mode must be one of {sorted(_VALID_MODES)}"
|
||||
)
|
||||
if not isinstance(action.get("value", ""), str):
|
||||
raise ValueError(f"derivation {where}.action.value must be a string")
|
||||
|
||||
|
||||
class DerivationHandler(ModuleHandler):
|
||||
"""IF / ELIF / ELSE rule evaluator that mutates ctx."""
|
||||
|
||||
type_id = "derivation"
|
||||
|
||||
@classmethod
|
||||
def validate_payload(cls, payload: dict[str, Any]) -> None:
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError("derivation payload must be an object")
|
||||
rules = payload.get("rules")
|
||||
if not isinstance(rules, list):
|
||||
raise ValueError("derivation payload.rules must be a list")
|
||||
for ri, rule in enumerate(rules):
|
||||
if not isinstance(rule, dict):
|
||||
raise ValueError(f"derivation payload.rules[{ri}] must be an object")
|
||||
rule_id = rule.get("id")
|
||||
if not isinstance(rule_id, str) or not rule_id:
|
||||
raise ValueError(
|
||||
f"derivation payload.rules[{ri}].id must be a non-empty string"
|
||||
)
|
||||
branches = rule.get("branches")
|
||||
if not isinstance(branches, list) or not branches:
|
||||
raise ValueError(
|
||||
f"derivation payload.rules[{ri}].branches must be a non-empty list"
|
||||
)
|
||||
for bi, branch in enumerate(branches):
|
||||
if not isinstance(branch, dict):
|
||||
raise ValueError(
|
||||
f"derivation payload.rules[{ri}].branches[{bi}] must be an object"
|
||||
)
|
||||
_validate_condition(
|
||||
branch.get("condition"), f"rules[{ri}].branches[{bi}]"
|
||||
)
|
||||
_validate_action(branch.get("action"), f"rules[{ri}].branches[{bi}]")
|
||||
else_clause = rule.get("else")
|
||||
if else_clause is not None:
|
||||
if not isinstance(else_clause, dict):
|
||||
raise ValueError(
|
||||
f"derivation payload.rules[{ri}].else must be an object"
|
||||
)
|
||||
_validate_action(else_clause.get("action"), f"rules[{ri}].else")
|
||||
|
||||
@classmethod
|
||||
def resolve(
|
||||
cls,
|
||||
payload: dict[str, Any],
|
||||
instance: dict[str, Any],
|
||||
ctx: Any,
|
||||
) -> dict[str, str]:
|
||||
cls.validate_payload(payload)
|
||||
resolve_ctx = build_resolve_ctx(ctx, surface="derivation")
|
||||
out: dict[str, str] = {}
|
||||
for rule in payload.get("rules", []):
|
||||
applied = False
|
||||
for branch in rule.get("branches", []):
|
||||
if _match_condition(branch.get("condition", {}), ctx):
|
||||
pair = _apply_action(branch.get("action", {}), ctx, resolve_ctx)
|
||||
if pair is not None:
|
||||
out[pair[0]] = pair[1]
|
||||
applied = True
|
||||
break
|
||||
if not applied:
|
||||
else_clause = rule.get("else")
|
||||
if isinstance(else_clause, dict):
|
||||
pair = _apply_action(
|
||||
else_clause.get("action", {}), ctx, resolve_ctx
|
||||
)
|
||||
if pair is not None:
|
||||
out[pair[0]] = pair[1]
|
||||
return out
|
||||
@@ -0,0 +1,70 @@
|
||||
"""Module-type dispatcher.
|
||||
|
||||
Each module type registers a ``ModuleHandler`` subclass keyed by ``type_id``.
|
||||
Graph execution calls ``resolve_module(snapshot, ctx)`` which routes to the
|
||||
matching handler. Handlers receive only the snapshot's payload + per-instance
|
||||
overrides + the runtime context — never the library DB.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any
|
||||
|
||||
|
||||
class UnknownModuleType(KeyError):
|
||||
"""Raised when a snapshot's ``type`` has no registered handler."""
|
||||
|
||||
|
||||
class ModuleHandler(ABC):
|
||||
"""Base class for per-type module handlers."""
|
||||
|
||||
type_id: str = ""
|
||||
|
||||
@classmethod
|
||||
@abstractmethod
|
||||
def resolve(
|
||||
cls,
|
||||
payload: dict[str, Any],
|
||||
instance: dict[str, Any],
|
||||
ctx: Any,
|
||||
) -> dict[str, str]:
|
||||
"""Return a mapping of variable_name → resolved string."""
|
||||
|
||||
@classmethod
|
||||
def validate_payload(cls, payload: dict[str, Any]) -> None:
|
||||
"""Validate the payload shape. Default is no-op.
|
||||
|
||||
Handlers override this to reject malformed payloads at create/update
|
||||
time; raise ``ValueError`` with a helpful message on failure.
|
||||
"""
|
||||
return None
|
||||
|
||||
|
||||
_HANDLERS: dict[str, type[ModuleHandler]] = {}
|
||||
|
||||
|
||||
def register_handler(handler: type[ModuleHandler]) -> None:
|
||||
"""Register a handler under its ``type_id``. Re-registration replaces."""
|
||||
if not handler.type_id:
|
||||
raise ValueError(f"{handler.__name__} missing type_id")
|
||||
_HANDLERS[handler.type_id] = handler
|
||||
|
||||
|
||||
def get_handler(type_id: str) -> type[ModuleHandler] | None:
|
||||
"""Return the handler registered for ``type_id`` or ``None`` if unknown."""
|
||||
return _HANDLERS.get(type_id)
|
||||
|
||||
|
||||
def resolve_module(snapshot: dict[str, Any], ctx: Any) -> dict[str, str]:
|
||||
"""Route a snapshot to its registered handler and return the resolution."""
|
||||
type_id = snapshot.get("type")
|
||||
if not isinstance(type_id, str):
|
||||
raise UnknownModuleType(type_id)
|
||||
handler = _HANDLERS.get(type_id)
|
||||
if handler is None:
|
||||
raise UnknownModuleType(type_id)
|
||||
return handler.resolve(
|
||||
snapshot.get("payload", {}),
|
||||
snapshot.get("instance", {}),
|
||||
ctx,
|
||||
)
|
||||
@@ -0,0 +1,30 @@
|
||||
"""Fixed-values module resolver. Emits one binding per named value."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from engine.modules.dispatcher import ModuleHandler
|
||||
|
||||
|
||||
class FixedValuesHandler(ModuleHandler):
|
||||
type_id = "fixed_values"
|
||||
|
||||
@classmethod
|
||||
def resolve(
|
||||
cls,
|
||||
payload: dict[str, Any],
|
||||
instance: dict[str, Any],
|
||||
ctx: Any,
|
||||
) -> dict[str, str]:
|
||||
values: list[dict[str, Any]] = list(payload.get("values", []))
|
||||
enabled = instance.get("enabled_options")
|
||||
if enabled is not None:
|
||||
allowed = set(enabled)
|
||||
values = [v for v in values if v.get("id") in allowed]
|
||||
out: dict[str, str] = {}
|
||||
for v in values:
|
||||
name = (v.get("name") or "").strip()
|
||||
if not name:
|
||||
continue
|
||||
out[name] = str(v.get("value", ""))
|
||||
return out
|
||||
@@ -0,0 +1,99 @@
|
||||
"""Pipeline module resolver — runs an ordered list of sub-modules.
|
||||
|
||||
Each step references a module in the library by id. The repository is
|
||||
threaded through ``ctx["_repo"]``; without it the handler can still validate
|
||||
its payload but ``resolve`` will raise ``RuntimeError`` because it cannot load
|
||||
sub-modules. Steps marked ``enabled=False`` are skipped.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from engine.modules.dispatcher import ModuleHandler, resolve_module
|
||||
|
||||
|
||||
def _ctx_get(ctx: Any, name: str, default: Any = None) -> Any:
|
||||
if ctx is None:
|
||||
return default
|
||||
getter = getattr(ctx, "get", None)
|
||||
if callable(getter):
|
||||
try:
|
||||
return getter(name, default)
|
||||
except TypeError:
|
||||
try:
|
||||
return getter(name)
|
||||
except Exception:
|
||||
return default
|
||||
try:
|
||||
if name in ctx: # type: ignore[operator]
|
||||
return ctx[name] # type: ignore[index]
|
||||
except Exception:
|
||||
return default
|
||||
return default
|
||||
|
||||
|
||||
class PipelineHandler(ModuleHandler):
|
||||
"""Composes other modules into an ordered execution list."""
|
||||
|
||||
type_id = "pipeline"
|
||||
|
||||
@classmethod
|
||||
def validate_payload(cls, payload: dict[str, Any]) -> None:
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError("pipeline payload must be an object")
|
||||
steps = payload.get("steps")
|
||||
if not isinstance(steps, list):
|
||||
raise ValueError("pipeline payload.steps must be a list")
|
||||
for i, step in enumerate(steps):
|
||||
if not isinstance(step, dict):
|
||||
raise ValueError(f"pipeline payload.steps[{i}] must be an object")
|
||||
module_id = step.get("module_id")
|
||||
if not isinstance(module_id, str) or not module_id:
|
||||
raise ValueError(
|
||||
f"pipeline payload.steps[{i}].module_id must be a non-empty string"
|
||||
)
|
||||
step_id = step.get("id")
|
||||
if not isinstance(step_id, str) or not step_id:
|
||||
raise ValueError(
|
||||
f"pipeline payload.steps[{i}].id must be a non-empty string"
|
||||
)
|
||||
enabled = step.get("enabled", True)
|
||||
if not isinstance(enabled, bool):
|
||||
raise ValueError(
|
||||
f"pipeline payload.steps[{i}].enabled must be a boolean"
|
||||
)
|
||||
instance = step.get("instance", {})
|
||||
if not isinstance(instance, dict):
|
||||
raise ValueError(
|
||||
f"pipeline payload.steps[{i}].instance must be an object"
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def resolve(
|
||||
cls,
|
||||
payload: dict[str, Any],
|
||||
instance: dict[str, Any],
|
||||
ctx: Any,
|
||||
) -> dict[str, str]:
|
||||
cls.validate_payload(payload)
|
||||
repo = _ctx_get(ctx, "_repo", None)
|
||||
if repo is None:
|
||||
raise RuntimeError(
|
||||
"pipeline handler requires ctx['_repo'] to load sub-modules"
|
||||
)
|
||||
|
||||
out: dict[str, str] = {}
|
||||
for step in payload.get("steps", []):
|
||||
if not step.get("enabled", True):
|
||||
continue
|
||||
module_id = step["module_id"]
|
||||
row = repo.get(module_id)
|
||||
sub_snapshot = {
|
||||
"type": row["type"],
|
||||
"payload": row["payload"],
|
||||
"instance": step.get("instance", {}),
|
||||
}
|
||||
bindings = resolve_module(sub_snapshot, ctx)
|
||||
if bindings:
|
||||
out.update(bindings)
|
||||
return out
|
||||
@@ -0,0 +1,86 @@
|
||||
"""Snapshot freezing, content hashing, and legacy-shape coercion.
|
||||
|
||||
A *snapshot* is the JSON shape embedded in a ComfyUI workflow's WP_Context
|
||||
node when a user picks a library row. The snapshot is decoupled from the
|
||||
library: graph execution reads only the snapshot, never the live DB.
|
||||
|
||||
Backward-compat: pre-SPA fixed_values modules in saved workflows lack the
|
||||
``library_*`` and ``instance`` keys. ``coerce_legacy_module`` upgrades them
|
||||
in-place to the canonical shape (still detached, ``library_id is None``).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from engine._utils import now_iso as _now_iso
|
||||
|
||||
|
||||
def _fresh_instance() -> dict[str, Any]:
|
||||
"""Default per-snapshot instance overrides. Fresh dict per call."""
|
||||
return {
|
||||
"variable_binding": "",
|
||||
"enabled_options": None,
|
||||
"category_filter": None,
|
||||
}
|
||||
|
||||
|
||||
def payload_hash(payload: dict[str, Any]) -> str:
|
||||
"""SHA-256 of canonical JSON. Stable across key order + whitespace."""
|
||||
canonical = json.dumps(
|
||||
payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False,
|
||||
)
|
||||
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def freeze_snapshot(library_row: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Build the JSON shape that gets embedded in a node's modules array."""
|
||||
return {
|
||||
"library_id": library_row["id"],
|
||||
"library_snapshot_at": _now_iso(),
|
||||
"library_version_at_snapshot": library_row["version"],
|
||||
"type": library_row["type"],
|
||||
"name": library_row["name"],
|
||||
"category_id": library_row.get("category_id"),
|
||||
"payload": library_row["payload"],
|
||||
"instance": _fresh_instance(),
|
||||
}
|
||||
|
||||
|
||||
def coerce_legacy_module(raw: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Upgrade a legacy module dict to canonical snapshot shape.
|
||||
|
||||
Idempotent: returns ``raw`` unchanged if it already has both
|
||||
``library_id`` and ``instance`` keys.
|
||||
"""
|
||||
if "instance" in raw and "library_id" in raw:
|
||||
return raw
|
||||
|
||||
payload: dict[str, Any]
|
||||
if raw.get("type") == "fixed_values" and "entries" in raw:
|
||||
legacy_entries = raw.get("entries", [])
|
||||
payload = {
|
||||
"values": [
|
||||
{
|
||||
"id": f"val_{i:04x}",
|
||||
"name": e.get("variable_name", ""),
|
||||
"value": e.get("value", ""),
|
||||
}
|
||||
for i, e in enumerate(legacy_entries)
|
||||
],
|
||||
}
|
||||
else:
|
||||
payload = raw.get("payload", {})
|
||||
|
||||
meta = raw.get("meta") or {}
|
||||
return {
|
||||
"library_id": raw.get("library_id"),
|
||||
"library_snapshot_at": raw.get("library_snapshot_at"),
|
||||
"library_version_at_snapshot": raw.get("library_version_at_snapshot"),
|
||||
"type": raw.get("type", "fixed_values"),
|
||||
"name": raw.get("name") or meta.get("name", ""),
|
||||
"category_id": raw.get("category_id"),
|
||||
"payload": payload,
|
||||
"instance": _fresh_instance(),
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
"""Wildcard module resolver — weighted RNG over options, syntax via resolve_text.
|
||||
|
||||
Each option's `value` may contain $var, @{uuid}, {a|b|c}, {N$$sep$$...}, and
|
||||
escapes. After picking the option, resolve_text expands the syntax against
|
||||
the runtime context.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from engine.modules import build_resolve_ctx
|
||||
from engine.modules.dispatcher import ModuleHandler
|
||||
from engine.syntax import resolve_text
|
||||
|
||||
|
||||
def _pick_weighted(options: list[dict[str, Any]], rng) -> dict[str, Any] | None:
|
||||
"""Pick one option weighted by `weight`. Uses rng (no module-global random)."""
|
||||
if not options:
|
||||
return None
|
||||
total = sum(max(0.0, float(o.get("weight", 1))) for o in options)
|
||||
if total <= 0:
|
||||
return options[0]
|
||||
r = rng.random() * total
|
||||
acc = 0.0
|
||||
for opt in options:
|
||||
acc += max(0.0, float(opt.get("weight", 1)))
|
||||
if r <= acc:
|
||||
return opt
|
||||
return options[-1]
|
||||
|
||||
|
||||
class WildcardHandler(ModuleHandler):
|
||||
type_id = "wildcard"
|
||||
|
||||
@classmethod
|
||||
def resolve(
|
||||
cls,
|
||||
payload: dict[str, Any],
|
||||
instance: dict[str, Any],
|
||||
ctx: Any,
|
||||
) -> dict[str, str]:
|
||||
binding = instance.get("variable_binding") or payload.get("var_binding") or ""
|
||||
if not binding:
|
||||
return {}
|
||||
|
||||
options: list[dict[str, Any]] = list(payload.get("options", []))
|
||||
enabled = instance.get("enabled_options")
|
||||
if enabled is not None:
|
||||
allowed = set(enabled)
|
||||
options = [o for o in options if o.get("id") in allowed]
|
||||
|
||||
if not options:
|
||||
return {binding: ""}
|
||||
|
||||
rng = ctx["__wp_rng__"]
|
||||
chosen = _pick_weighted(options, rng)
|
||||
if chosen is None:
|
||||
return {binding: ""}
|
||||
|
||||
value = str(chosen.get("value", ""))
|
||||
if not value:
|
||||
return {binding: ""}
|
||||
|
||||
resolve_ctx = build_resolve_ctx(ctx, surface="wildcard")
|
||||
resolved = resolve_text(value, resolve_ctx)
|
||||
return {binding: resolved}
|
||||
@@ -1,12 +1,20 @@
|
||||
"""PipelineEngine — dispatches modules through per-type handlers, records trace."""
|
||||
"""PipelineEngine — runs ordered modules through engine/modules/dispatcher.
|
||||
|
||||
Manages per-run state (rng, warnings, trace) and delegates per-module
|
||||
resolution to dispatcher.resolve_module. Pre-Phase-5 the pipeline routed
|
||||
fixed_values directly; Phase 5 routes everything via dispatcher and threads
|
||||
ctx.rng for deterministic randomness.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import logging
|
||||
import random
|
||||
from typing import Any
|
||||
|
||||
from .context import Context
|
||||
from .handlers import ModuleHandler, handle_fixed_values
|
||||
from .modules import Module
|
||||
from engine.context import Context
|
||||
from engine.modules import Module
|
||||
from engine.modules.dispatcher import UnknownModuleType, resolve_module
|
||||
from engine.modules.snapshot import coerce_legacy_module
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -14,84 +22,134 @@ logger = logging.getLogger(__name__)
|
||||
class PipelineEngine:
|
||||
"""Runs an ordered list of modules against a context dict."""
|
||||
|
||||
HANDLERS: dict[str, ModuleHandler] = {
|
||||
"fixed_values": handle_fixed_values,
|
||||
}
|
||||
|
||||
def run(
|
||||
self,
|
||||
modules: list[Module],
|
||||
ctx: Context | None = None,
|
||||
seed: int = 0,
|
||||
) -> Context:
|
||||
"""Execute ``modules`` top-to-bottom against ``ctx``.
|
||||
|
||||
Initializes internal keys (``__wp_node_seed__``, ``__wp_trace__``,
|
||||
``__wp_internal_flags__``) if absent. Skips modules with
|
||||
``enabled is False``. Unknown module types are logged and skipped.
|
||||
Returns the mutated context.
|
||||
"""
|
||||
"""Execute modules top-to-bottom, mutating ctx, returning ctx."""
|
||||
ctx = {} if ctx is None else ctx
|
||||
ctx["__wp_node_seed__"] = seed
|
||||
ctx["__wp_rng__"] = random.Random(seed)
|
||||
ctx.setdefault("__wp_warnings__", [])
|
||||
ctx.setdefault("__wp_trace__", [])
|
||||
ctx.setdefault("__wp_internal_flags__", {})
|
||||
|
||||
rng = random.Random(seed)
|
||||
|
||||
for index, module in enumerate(modules):
|
||||
if getattr(module, "enabled", True) is False:
|
||||
if not getattr(module, "enabled", True):
|
||||
ctx["__wp_trace__"].append({
|
||||
"id": getattr(module, "id", ""),
|
||||
"type": getattr(module, "type", ""),
|
||||
"enabled": False,
|
||||
"status": "skipped_disabled",
|
||||
"writes": [],
|
||||
"error": None,
|
||||
})
|
||||
continue
|
||||
|
||||
module_type = getattr(module, "type", None)
|
||||
handler = self.HANDLERS.get(module_type or "")
|
||||
if handler is None:
|
||||
module_type = getattr(module, "type", None) or ""
|
||||
try:
|
||||
# coerce_legacy_module expects a dict; convert dataclasses or
|
||||
# duck-typed objects by reading known attrs via getattr.
|
||||
if dataclasses.is_dataclass(module) and not isinstance(module, type):
|
||||
raw = dataclasses.asdict(module) # type: ignore[arg-type]
|
||||
elif isinstance(module, dict):
|
||||
raw = module
|
||||
else:
|
||||
# Generic object: pull known snapshot keys via getattr so
|
||||
# class-level attributes (common in tests) are visible.
|
||||
raw = {
|
||||
k: getattr(module, k)
|
||||
for k in (
|
||||
"id", "type", "enabled", "payload", "entries",
|
||||
"meta", "library_id", "library_snapshot_at",
|
||||
"library_version_at_snapshot", "name", "category_id",
|
||||
"instance",
|
||||
)
|
||||
if hasattr(module, k)
|
||||
}
|
||||
snapshot = coerce_legacy_module(raw)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Failed to coerce module %r at index %s: %s", module, index, e
|
||||
)
|
||||
ctx["__wp_trace__"].append({
|
||||
"id": getattr(module, "id", ""),
|
||||
"type": module_type,
|
||||
"enabled": True,
|
||||
"status": "failed",
|
||||
"writes": [],
|
||||
"error": {"type": type(e).__name__, "message": str(e)},
|
||||
})
|
||||
continue
|
||||
|
||||
try:
|
||||
bindings = resolve_module(snapshot, ctx)
|
||||
except UnknownModuleType:
|
||||
logger.warning(
|
||||
"Unknown module type %r at index %s — skipped",
|
||||
module_type,
|
||||
index,
|
||||
)
|
||||
ctx["__wp_trace__"].append({
|
||||
"id": getattr(module, "id", ""),
|
||||
"type": module_type,
|
||||
"enabled": True,
|
||||
"status": "skipped_unknown_type",
|
||||
"writes": [],
|
||||
"error": None,
|
||||
})
|
||||
continue
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
"Handler %r failed at index %s", module_type, index
|
||||
)
|
||||
ctx["__wp_warnings__"].append({
|
||||
"type": "handler_error",
|
||||
"severity": "error",
|
||||
"module_id": getattr(module, "id", ""),
|
||||
"source_field": "payload",
|
||||
"position": 0,
|
||||
"token_index": None,
|
||||
"detail": {
|
||||
"exception_type": type(e).__name__,
|
||||
"exception_message": str(e),
|
||||
"traceback": None,
|
||||
},
|
||||
"message": (
|
||||
f"Module {module_type} failed: {type(e).__name__}: {e}"
|
||||
),
|
||||
})
|
||||
ctx["__wp_trace__"].append({
|
||||
"id": getattr(module, "id", ""),
|
||||
"type": module_type,
|
||||
"enabled": True,
|
||||
"status": "failed",
|
||||
"writes": [],
|
||||
"error": {"type": type(e).__name__, "message": str(e)},
|
||||
})
|
||||
continue
|
||||
|
||||
before = dict(ctx)
|
||||
ctx = handler(module, ctx, rng)
|
||||
ctx["__wp_trace__"].append(self._trace_entry(module, before, ctx))
|
||||
writes = []
|
||||
for var, value in (bindings or {}).items():
|
||||
key = var.lstrip("$")
|
||||
before = ctx.get(key)
|
||||
ctx[key] = value
|
||||
writes.append({
|
||||
"variable": key,
|
||||
"value": value,
|
||||
"source": module_type,
|
||||
"overwrite": before is not None and before != value,
|
||||
})
|
||||
|
||||
ctx["__wp_trace__"].append({
|
||||
"id": getattr(module, "id", ""),
|
||||
"type": module_type,
|
||||
"enabled": True,
|
||||
"status": "ok",
|
||||
"writes": writes,
|
||||
"error": None,
|
||||
})
|
||||
|
||||
return ctx
|
||||
|
||||
@staticmethod
|
||||
def _trace_entry(
|
||||
module: Module,
|
||||
before: dict[str, Any],
|
||||
after: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
"""Build a trace record describing what ``module`` wrote."""
|
||||
module_type = getattr(module, "type", "")
|
||||
writes: list[dict[str, Any]] = []
|
||||
|
||||
for key in after.keys() - before.keys():
|
||||
if key.startswith("__"):
|
||||
continue
|
||||
writes.append(
|
||||
{"variable": key, "value": after[key], "source": module_type}
|
||||
)
|
||||
|
||||
for key in after.keys() & before.keys():
|
||||
if key.startswith("__"):
|
||||
continue
|
||||
if after[key] == before[key]:
|
||||
continue
|
||||
writes.append(
|
||||
{
|
||||
"variable": key,
|
||||
"value": after[key],
|
||||
"source": module_type,
|
||||
"overwrite": True,
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"id": getattr(module, "id", ""),
|
||||
"type": module_type,
|
||||
"enabled": getattr(module, "enabled", True),
|
||||
"writes": writes,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Engine syntax: tokenizer + resolver shared between runtime and Test Runner.
|
||||
|
||||
See `docs/superpowers/specs/2026-04-28-engine-syntax-design.md` for the
|
||||
language contract and parity rules.
|
||||
"""
|
||||
from engine.syntax.resolve import resolve_text
|
||||
from engine.syntax.tokenize import tokenize_text
|
||||
from engine.syntax.types import (
|
||||
CycleDetectedError,
|
||||
MalformedSyntaxError,
|
||||
RecursionLimitExceeded,
|
||||
RefOutOfSurfaceError,
|
||||
ResolveContext,
|
||||
SurfaceKind,
|
||||
SyntaxError,
|
||||
Token,
|
||||
TokenKind,
|
||||
UnknownRefError,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"CycleDetectedError",
|
||||
"MalformedSyntaxError",
|
||||
"RecursionLimitExceeded",
|
||||
"RefOutOfSurfaceError",
|
||||
"ResolveContext",
|
||||
"SurfaceKind",
|
||||
"SyntaxError",
|
||||
"Token",
|
||||
"TokenKind",
|
||||
"UnknownRefError",
|
||||
"resolve_text",
|
||||
"tokenize_text",
|
||||
]
|
||||
@@ -0,0 +1,312 @@
|
||||
"""Token-walking resolver. Mirrors `resolveTokens.ts` (Task 12 will land that).
|
||||
|
||||
Each token kind has a `_resolve_<kind>` helper. The main loop walks tokens
|
||||
left-to-right, dispatches by kind, and concatenates results. Recursion
|
||||
happens inside `_resolve_ref` (Task 9) and brace resolvers (Tasks 10–11).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from engine.syntax.tokenize import tokenize_text
|
||||
from engine.syntax.types import (
|
||||
CycleDetectedError,
|
||||
RecursionLimitExceeded,
|
||||
RefOutOfSurfaceError,
|
||||
ResolveContext,
|
||||
Token,
|
||||
TokenKind,
|
||||
UnknownRefError,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def resolve_text(text: str, ctx: ResolveContext) -> str:
|
||||
"""Resolve `text` against `ctx`, returning the final string.
|
||||
|
||||
Walks tokens once; for each token, dispatches to a per-kind resolver.
|
||||
`depth` and `visited` are managed internally during ref recursion.
|
||||
"""
|
||||
tokens = tokenize_text(text)
|
||||
return _resolve_tokens(tokens, ctx, depth=0, visited=())
|
||||
|
||||
|
||||
def _resolve_tokens(
|
||||
tokens: list[Token],
|
||||
ctx: ResolveContext,
|
||||
depth: int,
|
||||
visited: tuple[str, ...],
|
||||
) -> str:
|
||||
parts: list[str] = []
|
||||
for tok in tokens:
|
||||
if tok.kind == TokenKind.TEXT:
|
||||
parts.append(tok.raw)
|
||||
elif tok.kind == TokenKind.ESCAPE:
|
||||
parts.append(tok.meta.get("literal", ""))
|
||||
elif tok.kind == TokenKind.VAR:
|
||||
parts.append(_resolve_var(tok, ctx))
|
||||
elif tok.kind == TokenKind.REF:
|
||||
parts.append(_resolve_ref(tok, ctx, depth, visited))
|
||||
elif tok.kind == TokenKind.DP_BRACE:
|
||||
parts.append(_resolve_inline_pick(tok, ctx, depth, visited))
|
||||
elif tok.kind == TokenKind.DP_MULTI:
|
||||
parts.append(_resolve_multi_pick(tok, ctx, depth, visited))
|
||||
elif tok.kind == TokenKind.DP_PIPE:
|
||||
# DP_PIPE is never emitted as a top-level token by the current
|
||||
# tokenizer (pipes are absorbed into branch boundaries). If one
|
||||
# appears here, that's a tokenizer bug.
|
||||
raise AssertionError("DP_PIPE should never be top-level")
|
||||
else:
|
||||
raise AssertionError(f"unknown token kind: {tok.kind}")
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
def _resolve_var(tok: Token, ctx: ResolveContext) -> str:
|
||||
"""Look up a variable in ctx. Returns empty string for missing or internal-key vars."""
|
||||
name = tok.meta.get("name", "")
|
||||
if name.startswith("__"):
|
||||
# Engine-internal keys are never substituted.
|
||||
return ""
|
||||
value = ctx.get_var(name)
|
||||
if value is None:
|
||||
# TODO(task-9-onward): push warning when unknown var? Spec doesn't
|
||||
# explicitly call out unknown vars as warnings; they emit "" silently.
|
||||
# Revisit if user feedback shows confusion.
|
||||
return ""
|
||||
return str(value)
|
||||
|
||||
|
||||
def _push_warning(ctx: ResolveContext, **fields) -> None:
|
||||
"""Append a warning dict to ctx.warnings. Helper to keep call sites tidy."""
|
||||
ctx.warnings.append(fields)
|
||||
|
||||
|
||||
def _pick_weighted(options: list[dict], rng) -> dict | None:
|
||||
"""Pick one option weighted by `weight`. Returns None for empty list.
|
||||
|
||||
Mirrors the pre-Phase-5 wildcard_handler._pick_weighted but takes `rng`
|
||||
as an explicit parameter instead of using random.random().
|
||||
"""
|
||||
if not options:
|
||||
return None
|
||||
total = sum(max(0.0, float(o.get("weight", 1))) for o in options)
|
||||
if total <= 0:
|
||||
return options[0]
|
||||
r = rng.random() * total
|
||||
acc = 0.0
|
||||
for opt in options:
|
||||
acc += max(0.0, float(opt.get("weight", 1)))
|
||||
if r <= acc:
|
||||
return opt
|
||||
return options[-1]
|
||||
|
||||
|
||||
def _resolve_inline_pick(
|
||||
tok: Token,
|
||||
ctx: ResolveContext,
|
||||
depth: int,
|
||||
visited: tuple[str, ...],
|
||||
) -> str:
|
||||
"""Pick one branch uniformly at random; recursively resolve its content."""
|
||||
branches: list[str] = tok.meta.get("branches", [])
|
||||
if not branches:
|
||||
return ""
|
||||
chosen_idx = ctx.rng.randrange(len(branches))
|
||||
chosen = branches[chosen_idx]
|
||||
if not chosen:
|
||||
return ""
|
||||
nested_tokens = tokenize_text(chosen)
|
||||
return _resolve_tokens(nested_tokens, ctx, depth=depth, visited=visited)
|
||||
|
||||
|
||||
def _parse_branch_weight(branch: str) -> float:
|
||||
"""Parse `N::value` micro-syntax. Returns 1.0 if no prefix."""
|
||||
if "::" not in branch:
|
||||
return 1.0
|
||||
prefix, _, _ = branch.partition("::")
|
||||
try:
|
||||
n = float(prefix)
|
||||
if n <= 0:
|
||||
return 0.0
|
||||
return n
|
||||
except ValueError:
|
||||
return 1.0
|
||||
|
||||
|
||||
def _strip_branch_weight(branch: str) -> str:
|
||||
"""Return branch text without the `N::` weight prefix, if present."""
|
||||
if "::" not in branch:
|
||||
return branch
|
||||
prefix, _, rest = branch.partition("::")
|
||||
try:
|
||||
float(prefix)
|
||||
return rest
|
||||
except ValueError:
|
||||
return branch
|
||||
|
||||
|
||||
def _weighted_pick_index(weights: list[float], rng) -> int:
|
||||
"""Return an index into `weights` weighted by value. Total > 0 assumed."""
|
||||
total = sum(max(0.0, w) for w in weights)
|
||||
if total <= 0:
|
||||
return 0
|
||||
r = rng.random() * total
|
||||
acc = 0.0
|
||||
for idx, w in enumerate(weights):
|
||||
acc += max(0.0, w)
|
||||
if r <= acc:
|
||||
return idx
|
||||
return len(weights) - 1
|
||||
|
||||
|
||||
def _resolve_multi_pick(
|
||||
tok: Token,
|
||||
ctx: ResolveContext,
|
||||
depth: int,
|
||||
visited: tuple[str, ...],
|
||||
) -> str:
|
||||
"""Pick `count` branches without replacement, weighted, joined by `sep`.
|
||||
|
||||
Each chosen branch is recursively resolved.
|
||||
"""
|
||||
count: int = tok.meta.get("count", 0)
|
||||
sep: str = tok.meta.get("sep", "")
|
||||
branches: list[str] = list(tok.meta.get("branches", []))
|
||||
|
||||
if count <= 0:
|
||||
return ""
|
||||
|
||||
# Clamp count to available branches (without-replacement constraint)
|
||||
n_picks = min(count, len(branches))
|
||||
if n_picks == 0:
|
||||
return ""
|
||||
|
||||
# Weighted without-replacement: branch weights default to 1; if a branch
|
||||
# uses the `N::value` form (a feature of the inline-pick weight micro-syntax),
|
||||
# the weight is parsed from the prefix. For simplicity, this implementation
|
||||
# treats all branches as weight=1 unless they begin with `<int>::`.
|
||||
weights = [_parse_branch_weight(b) for b in branches]
|
||||
branch_values = [_strip_branch_weight(b) for b in branches]
|
||||
|
||||
chosen_indices: list[int] = []
|
||||
available_indices = list(range(len(branches)))
|
||||
available_weights = list(weights)
|
||||
|
||||
for _ in range(n_picks):
|
||||
if not available_indices:
|
||||
break
|
||||
idx = _weighted_pick_index(available_weights, ctx.rng)
|
||||
chosen_indices.append(available_indices.pop(idx))
|
||||
available_weights.pop(idx)
|
||||
|
||||
resolved: list[str] = []
|
||||
for idx in chosen_indices:
|
||||
branch_text = branch_values[idx]
|
||||
if not branch_text:
|
||||
resolved.append("")
|
||||
continue
|
||||
nested = tokenize_text(branch_text)
|
||||
resolved.append(_resolve_tokens(nested, ctx, depth=depth, visited=visited))
|
||||
|
||||
return sep.join(resolved)
|
||||
|
||||
|
||||
def _resolve_ref(
|
||||
tok: Token,
|
||||
ctx: ResolveContext,
|
||||
depth: int,
|
||||
visited: tuple[str, ...],
|
||||
) -> str:
|
||||
uuid = tok.meta.get("uuid", "")
|
||||
|
||||
# Depth check (first — prevents infinite recursion even on cyclic graphs)
|
||||
if depth >= ctx.max_ref_depth:
|
||||
chain = list(visited) + [uuid]
|
||||
if ctx.strict:
|
||||
raise RecursionLimitExceeded(
|
||||
f"max ref depth {ctx.max_ref_depth} exceeded: "
|
||||
+ " → ".join(f"@{{{u}}}" for u in chain)
|
||||
)
|
||||
_push_warning(
|
||||
ctx,
|
||||
type="recursion_limit",
|
||||
severity="error",
|
||||
module_id="",
|
||||
source_field="",
|
||||
position=tok.start,
|
||||
token_index=None,
|
||||
detail={"chain": chain, "limit": ctx.max_ref_depth},
|
||||
message=f"Recursion limit reached at depth {ctx.max_ref_depth}",
|
||||
)
|
||||
return ""
|
||||
|
||||
# Cycle check
|
||||
if uuid in visited:
|
||||
chain = list(visited) + [uuid]
|
||||
if ctx.strict:
|
||||
raise CycleDetectedError(chain)
|
||||
_push_warning(
|
||||
ctx,
|
||||
type="cycle_detected",
|
||||
severity="error",
|
||||
module_id="",
|
||||
source_field="",
|
||||
position=tok.start,
|
||||
token_index=None,
|
||||
detail={"chain": chain},
|
||||
message="Cycle: " + " → ".join(f"@{{{u}}}" for u in chain),
|
||||
)
|
||||
return ""
|
||||
|
||||
# Surface check
|
||||
if ctx.surface != "wildcard":
|
||||
if ctx.strict:
|
||||
raise RefOutOfSurfaceError(uuid, ctx.surface)
|
||||
# Try to resolve module name for the warning detail (best-effort)
|
||||
module = ctx.get_module(uuid)
|
||||
name = module.get("var_binding") if module else None
|
||||
_push_warning(
|
||||
ctx,
|
||||
type="ref_out_of_surface",
|
||||
severity="info",
|
||||
module_id="",
|
||||
source_field="",
|
||||
position=tok.start,
|
||||
token_index=None,
|
||||
detail={"uuid": uuid, "name": name, "surface": ctx.surface},
|
||||
message=f"@{{{uuid}}} ignored in {ctx.surface!r} surface",
|
||||
)
|
||||
return ""
|
||||
|
||||
# Catalog lookup
|
||||
module = ctx.get_module(uuid)
|
||||
if module is None:
|
||||
if ctx.strict:
|
||||
raise UnknownRefError(uuid)
|
||||
_push_warning(
|
||||
ctx,
|
||||
type="unknown_ref",
|
||||
severity="warn",
|
||||
module_id="",
|
||||
source_field="",
|
||||
position=tok.start,
|
||||
token_index=None,
|
||||
detail={"uuid": uuid, "name": None},
|
||||
message=f"Unknown wildcard ref @{{{uuid}}}",
|
||||
)
|
||||
return ""
|
||||
|
||||
# Pick weighted option
|
||||
options = module.get("options", [])
|
||||
chosen = _pick_weighted(options, ctx.rng)
|
||||
if chosen is None:
|
||||
return ""
|
||||
|
||||
chosen_value = str(chosen.get("value", ""))
|
||||
if not chosen_value:
|
||||
return ""
|
||||
|
||||
# Recursive resolve with depth+1, visited extended
|
||||
nested_tokens = tokenize_text(chosen_value)
|
||||
return _resolve_tokens(nested_tokens, ctx, depth=depth + 1, visited=visited + (uuid,))
|
||||
@@ -0,0 +1,216 @@
|
||||
"""Single-grammar tokenizer for wildcard syntax. Mirrors `richTokenize.ts`.
|
||||
|
||||
Returns a flat list of `Token` covering the input contiguously (joining all
|
||||
`.raw` fields reproduces the input). The "lossless" invariant is asserted
|
||||
in tests + fuzz.
|
||||
|
||||
This file ships in increments — Task 3 lands text + escape, Task 4 adds
|
||||
var + ref, Task 5 adds dp_brace + dp_pipe + dp_multi.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from engine.syntax.types import Token, TokenKind
|
||||
|
||||
# `$name` — identifier starts with letter or underscore, then word chars
|
||||
_VAR_RE = re.compile(r"\$([A-Za-z_][A-Za-z0-9_]*)")
|
||||
# `@{8hex}` — exactly 8 lowercase hex chars in braces
|
||||
_REF_RE = re.compile(r"@\{([0-9a-f]{8})\}")
|
||||
# Multi-pick prefix: {N$$sep$$ where N is one or more digits and sep can be empty.
|
||||
_MULTI_PREFIX_RE = re.compile(r"^\{(\d+)\$\$(.*?)\$\$", flags=re.DOTALL)
|
||||
|
||||
|
||||
def _split_top_level_pipes(s: str) -> list[str]:
|
||||
"""Split `s` on `|` at brace-depth zero only. Nested `{a|b}` stays intact."""
|
||||
parts: list[str] = []
|
||||
depth = 0
|
||||
last = 0
|
||||
for idx, c in enumerate(s):
|
||||
if c == "{":
|
||||
depth += 1
|
||||
elif c == "}":
|
||||
depth -= 1
|
||||
elif c == "|" and depth == 0:
|
||||
parts.append(s[last:idx])
|
||||
last = idx + 1
|
||||
parts.append(s[last:])
|
||||
return parts
|
||||
|
||||
|
||||
def _scan_brace_block(
|
||||
text: str, start: int
|
||||
) -> tuple[int, list[str], int | None, str | None] | None:
|
||||
"""Try to scan a `{...}` block starting at `text[start] == '{'`.
|
||||
|
||||
Returns:
|
||||
(end_index, branches, count_or_None, sep_or_None)
|
||||
|
||||
where end_index is the position AFTER the closing brace, branches is
|
||||
the list of branch source strings (split at top-level `|`), count is
|
||||
the parsed integer count for multi-pick or None for single-pick, and
|
||||
sep is the separator string for multi-pick or None for single-pick.
|
||||
|
||||
Returns None if the block is malformed (unclosed, no pipes for single-pick).
|
||||
The caller falls back to literal text.
|
||||
"""
|
||||
n = len(text)
|
||||
if start >= n or text[start] != "{":
|
||||
return None
|
||||
|
||||
# Walk to the matching `}` while tracking brace depth so nested
|
||||
# {a|{b|c}|d} works.
|
||||
i = start + 1
|
||||
depth = 1
|
||||
while i < n and depth > 0:
|
||||
c = text[i]
|
||||
if c == "{":
|
||||
depth += 1
|
||||
elif c == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
break
|
||||
i += 1
|
||||
if depth != 0:
|
||||
return None # unclosed
|
||||
|
||||
body_start = start + 1
|
||||
body_end = i # index of closing brace
|
||||
end_index = i + 1
|
||||
body = text[body_start:body_end]
|
||||
|
||||
# Detect multi-pick prefix: N$$sep$$
|
||||
body_with_braces = "{" + body # match against `{N$$sep$$...`
|
||||
multi_match = _MULTI_PREFIX_RE.match(body_with_braces)
|
||||
if multi_match:
|
||||
count = int(multi_match.group(1))
|
||||
sep = multi_match.group(2)
|
||||
# Body remaining after the prefix is the branch list
|
||||
prefix_len = multi_match.end() - 1 # subtract the leading `{` we added
|
||||
rest = body[prefix_len:]
|
||||
branches = _split_top_level_pipes(rest)
|
||||
return (end_index, branches, count, sep)
|
||||
|
||||
# Single-pick: must contain at least one top-level `|`
|
||||
branches = _split_top_level_pipes(body)
|
||||
if len(branches) < 2:
|
||||
return None # not a pick — fall back to literal
|
||||
return (end_index, branches, None, None)
|
||||
|
||||
|
||||
def tokenize_text(text: str) -> list[Token]:
|
||||
"""Tokenize `text` into a flat list of Token. Single-pass, O(n)."""
|
||||
if not text:
|
||||
return []
|
||||
|
||||
out: list[Token] = []
|
||||
i = 0
|
||||
n = len(text)
|
||||
text_start: int | None = None
|
||||
|
||||
def _flush_text(end_at: int) -> None:
|
||||
nonlocal text_start
|
||||
if text_start is None:
|
||||
return
|
||||
if end_at > text_start:
|
||||
out.append(Token(
|
||||
kind=TokenKind.TEXT,
|
||||
raw=text[text_start:end_at],
|
||||
start=text_start,
|
||||
end=end_at,
|
||||
meta={},
|
||||
))
|
||||
text_start = None
|
||||
|
||||
while i < n:
|
||||
ch = text[i]
|
||||
|
||||
# Escape sequences: $$ → literal $, @@ → literal @
|
||||
if ch == "$" and i + 1 < n and text[i + 1] == "$":
|
||||
_flush_text(i)
|
||||
out.append(Token(
|
||||
kind=TokenKind.ESCAPE,
|
||||
raw="$$",
|
||||
start=i,
|
||||
end=i + 2,
|
||||
meta={"literal": "$"},
|
||||
))
|
||||
i += 2
|
||||
continue
|
||||
|
||||
if ch == "@" and i + 1 < n and text[i + 1] == "@":
|
||||
_flush_text(i)
|
||||
out.append(Token(
|
||||
kind=TokenKind.ESCAPE,
|
||||
raw="@@",
|
||||
start=i,
|
||||
end=i + 2,
|
||||
meta={"literal": "@"},
|
||||
))
|
||||
i += 2
|
||||
continue
|
||||
|
||||
# Variable: $name (only when followed by a valid identifier start)
|
||||
if ch == "$":
|
||||
m = _VAR_RE.match(text, i)
|
||||
if m:
|
||||
_flush_text(i)
|
||||
out.append(Token(
|
||||
kind=TokenKind.VAR,
|
||||
raw=m.group(0),
|
||||
start=i,
|
||||
end=m.end(),
|
||||
meta={"name": m.group(1)},
|
||||
))
|
||||
i = m.end()
|
||||
continue
|
||||
# Lone $ followed by non-ident — fall through to text accumulation
|
||||
|
||||
# Ref: @{8hex} (only when exactly 8 lowercase hex chars in braces)
|
||||
if ch == "@":
|
||||
m = _REF_RE.match(text, i)
|
||||
if m:
|
||||
_flush_text(i)
|
||||
out.append(Token(
|
||||
kind=TokenKind.REF,
|
||||
raw=m.group(0),
|
||||
start=i,
|
||||
end=m.end(),
|
||||
meta={"uuid": m.group(1)},
|
||||
))
|
||||
i = m.end()
|
||||
continue
|
||||
|
||||
# Brace block: {...} for inline pick or {N$$sep$$...} for multi-pick
|
||||
if ch == "{":
|
||||
scanned = _scan_brace_block(text, i)
|
||||
if scanned is not None:
|
||||
end_index, branches, count, sep = scanned
|
||||
_flush_text(i)
|
||||
if count is None:
|
||||
out.append(Token(
|
||||
kind=TokenKind.DP_BRACE,
|
||||
raw=text[i:end_index],
|
||||
start=i,
|
||||
end=end_index,
|
||||
meta={"branches": branches},
|
||||
))
|
||||
else:
|
||||
out.append(Token(
|
||||
kind=TokenKind.DP_MULTI,
|
||||
raw=text[i:end_index],
|
||||
start=i,
|
||||
end=end_index,
|
||||
meta={"count": count, "sep": sep, "branches": branches},
|
||||
))
|
||||
i = end_index
|
||||
continue
|
||||
# Malformed brace — fall through to literal text
|
||||
|
||||
# Default: accumulate literal text
|
||||
if text_start is None:
|
||||
text_start = i
|
||||
i += 1
|
||||
|
||||
_flush_text(n)
|
||||
return out
|
||||
@@ -0,0 +1,127 @@
|
||||
"""Foundational types for the engine syntax module.
|
||||
|
||||
Defines:
|
||||
- Token + TokenKind (the tokenizer's output shape)
|
||||
- TokenMeta (per-token metadata payload)
|
||||
- ResolveContext Protocol (the interface resolvers depend on)
|
||||
- SyntaxError hierarchy (UnknownRefError, RefOutOfSurfaceError,
|
||||
RecursionLimitExceeded, CycleDetectedError, MalformedSyntaxError)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import enum
|
||||
import random
|
||||
from dataclasses import dataclass, field
|
||||
from typing import (
|
||||
Any,
|
||||
Literal,
|
||||
Protocol,
|
||||
runtime_checkable,
|
||||
)
|
||||
|
||||
SurfaceKind = Literal["wildcard", "combine", "derivation", "assembler"]
|
||||
|
||||
|
||||
class TokenKind(enum.Enum):
|
||||
"""All token kinds emitted by tokenize_text. Single grammar, no surface variants."""
|
||||
|
||||
TEXT = "text"
|
||||
VAR = "var"
|
||||
REF = "ref"
|
||||
DP_BRACE = "dp_brace"
|
||||
DP_PIPE = "dp_pipe"
|
||||
DP_MULTI = "dp_multi"
|
||||
ESCAPE = "escape"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Token:
|
||||
"""A single tokenizer output. `meta` is kind-specific:
|
||||
|
||||
- VAR: {"name": str}
|
||||
- REF: {"uuid": str}
|
||||
- DP_MULTI: {"count": int, "sep": str, "branches": list[str]}
|
||||
- DP_BRACE: {"branches": list[str]}
|
||||
- ESCAPE: {"literal": str} (one of "$" or "@")
|
||||
- TEXT, DP_PIPE: {} (empty)
|
||||
"""
|
||||
|
||||
kind: TokenKind
|
||||
raw: str
|
||||
start: int
|
||||
end: int
|
||||
meta: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class ResolveContext(Protocol):
|
||||
"""Interface the resolver depends on. Construct via build_resolve_ctx in
|
||||
engine/modules/__init__.py at pipeline start; in tests use a duck-typed
|
||||
fake (any class with these attrs and methods).
|
||||
"""
|
||||
|
||||
rng: random.Random
|
||||
max_ref_depth: int
|
||||
strict: bool
|
||||
surface: SurfaceKind
|
||||
developer_mode: bool
|
||||
warnings: list[dict[str, Any]]
|
||||
|
||||
def get_var(self, name: str) -> str | None:
|
||||
"""Return the bound value of `name`, or None if unbound."""
|
||||
...
|
||||
|
||||
def get_module(self, uuid: str) -> dict[str, Any] | None:
|
||||
"""Return the module catalog row for `uuid`, or None if missing.
|
||||
|
||||
Row shape (only fields the resolver uses): `{"type": str,
|
||||
"var_binding": str, "options": list[{"value": str, "weight": float}]}`.
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
# ---------- Error hierarchy ---------------------------------------------------
|
||||
|
||||
|
||||
class SyntaxError(Exception):
|
||||
"""Base for all engine syntax errors. Distinct from Python's built-in
|
||||
SyntaxError (which is for source-code parse errors); this one is for
|
||||
wildcard syntax."""
|
||||
|
||||
|
||||
class UnknownRefError(SyntaxError):
|
||||
"""@{uuid} points to a module not in the catalog."""
|
||||
|
||||
def __init__(self, uuid: str) -> None:
|
||||
self.uuid = uuid
|
||||
super().__init__(f"unknown wildcard ref: @{{{uuid}}}")
|
||||
|
||||
|
||||
class RefOutOfSurfaceError(SyntaxError):
|
||||
"""@{uuid} appears in a non-wildcard surface (combine/derivation/assembler)."""
|
||||
|
||||
def __init__(self, uuid: str, surface: str) -> None:
|
||||
self.uuid = uuid
|
||||
self.surface = surface
|
||||
super().__init__(
|
||||
f"ref @{{{uuid}}} not allowed in {surface!r} surface; "
|
||||
f"only wildcard option values"
|
||||
)
|
||||
|
||||
|
||||
class RecursionLimitExceeded(SyntaxError):
|
||||
"""@{uuid} chain reached configured max_ref_depth."""
|
||||
|
||||
|
||||
class CycleDetectedError(SyntaxError):
|
||||
"""@{uuid} chain returned to an already-visiting UUID."""
|
||||
|
||||
def __init__(self, chain: list[str]) -> None:
|
||||
self.chain = chain
|
||||
super().__init__(
|
||||
"cycle: " + " → ".join(f"@{{{u}}}" for u in chain)
|
||||
)
|
||||
|
||||
|
||||
class MalformedSyntaxError(SyntaxError):
|
||||
"""Tokenizer / resolver received structurally invalid token meta."""
|
||||
@@ -1,29 +1,44 @@
|
||||
"""Template variable substitution: $var, $$ escape, __internal skip."""
|
||||
"""Template variable substitution shim. Wraps engine/syntax/resolve_text.
|
||||
|
||||
Pre-Phase-5 this file held an ad-hoc `$var` regex. Phase 5 unified all
|
||||
template resolution into engine/syntax. This module preserves the public
|
||||
function name + signature so `wp_nodes/assembler_node.py` doesn't change.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
_VAR_RE = re.compile(r"\$(\w+)")
|
||||
_SENTINEL = "\x00DOLLAR\x00"
|
||||
from engine.modules import build_resolve_ctx
|
||||
from engine.syntax import resolve_text
|
||||
|
||||
_WS_RUN = re.compile(r"[ \t]{2,}")
|
||||
_COMMA_GAP = re.compile(r",\s*,")
|
||||
_TRIM_PUNCT = re.compile(r"\s+([,.;:!?])")
|
||||
|
||||
|
||||
def resolve_variables(template: str, ctx: dict[str, Any]) -> str:
|
||||
"""Replace ``$var`` with ``str(ctx[var])``. ``$$`` escapes a literal ``$``.
|
||||
"""Resolve `$var` / `@{uuid}` / `{a|b|c}` / `{N$$sep$$...}` against ctx.
|
||||
|
||||
Keys beginning with ``__`` are engine internals and are never substituted.
|
||||
Missing vars are left unchanged.
|
||||
Surface defaults to "assembler" (matches wp_nodes/assembler_node.py
|
||||
usage). For a different surface, call resolve_text directly with a
|
||||
custom ResolveContext.
|
||||
"""
|
||||
result = template.replace("$$", _SENTINEL)
|
||||
if not template:
|
||||
return ""
|
||||
|
||||
def _replace(match: re.Match[str]) -> str:
|
||||
name = match.group(1)
|
||||
if name.startswith("__"):
|
||||
return match.group(0)
|
||||
if name in ctx:
|
||||
return str(ctx[name])
|
||||
return match.group(0)
|
||||
# Ensure required ctx keys exist (callers from the old API may not provide them)
|
||||
if "__wp_rng__" not in ctx:
|
||||
import random # noqa: PLC0415
|
||||
ctx["__wp_rng__"] = random.Random(int(ctx.get("__wp_node_seed__", 0)))
|
||||
ctx.setdefault("__wp_warnings__", [])
|
||||
|
||||
result = _VAR_RE.sub(_replace, result)
|
||||
return result.replace(_SENTINEL, "$")
|
||||
rctx = build_resolve_ctx(ctx, surface="assembler")
|
||||
out = resolve_text(template, rctx)
|
||||
|
||||
# Whitespace cleanup post-pass (preserves the original API's behavior
|
||||
# for templates that drop missing variables)
|
||||
out = _WS_RUN.sub(" ", out)
|
||||
out = _COMMA_GAP.sub(",", out)
|
||||
out = _TRIM_PUNCT.sub(r"\1", out)
|
||||
return out.strip()
|
||||
|
||||
@@ -2,10 +2,33 @@ import vue from "eslint-plugin-vue";
|
||||
import tsParser from "@typescript-eslint/parser";
|
||||
import tsPlugin from "@typescript-eslint/eslint-plugin";
|
||||
import vueParser from "vue-eslint-parser";
|
||||
import a11y from "eslint-plugin-vuejs-accessibility";
|
||||
|
||||
// Rules picked from typescript-eslint's "strict" preset that are SAFE
|
||||
// without `parserOptions.project` (so we don't pay the cost of a full
|
||||
// type-checker pass on every lint). The type-checked variants are skipped;
|
||||
// they need a TS project, and our typecheck script already covers them.
|
||||
const STRICTER_TS_RULES = {
|
||||
"@typescript-eslint/ban-ts-comment": "error",
|
||||
"@typescript-eslint/no-explicit-any": "warn",
|
||||
"@typescript-eslint/no-non-null-assertion": "warn",
|
||||
"@typescript-eslint/no-empty-object-type": "error",
|
||||
"@typescript-eslint/no-unsafe-function-type": "error",
|
||||
"@typescript-eslint/no-wrapper-object-types": "error",
|
||||
"@typescript-eslint/no-extraneous-class": "error",
|
||||
"@typescript-eslint/no-namespace": "error",
|
||||
};
|
||||
|
||||
const GENERAL_STRICT_RULES = {
|
||||
eqeqeq: ["error", "always", { null: "ignore" }],
|
||||
"prefer-const": "error",
|
||||
"no-var": "error",
|
||||
"no-console": ["warn", { allow: ["warn", "error", "info"] }],
|
||||
};
|
||||
|
||||
export default [
|
||||
{
|
||||
ignores: ["js/**", "web_dist/**", "node_modules/**", "dist/**"],
|
||||
ignores: ["js/**", "web_dist/**", "node_modules/**", "dist/**", "coverage/**"],
|
||||
},
|
||||
{
|
||||
files: ["src/**/*.ts"],
|
||||
@@ -19,6 +42,8 @@ export default [
|
||||
plugins: { "@typescript-eslint": tsPlugin },
|
||||
rules: {
|
||||
"@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }],
|
||||
...STRICTER_TS_RULES,
|
||||
...GENERAL_STRICT_RULES,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -31,9 +56,27 @@ export default [
|
||||
sourceType: "module",
|
||||
},
|
||||
},
|
||||
plugins: { vue },
|
||||
plugins: { vue, "vuejs-accessibility": a11y, "@typescript-eslint": tsPlugin },
|
||||
rules: {
|
||||
...vue.configs["vue3-recommended"].rules,
|
||||
...a11y.configs.recommended.rules,
|
||||
...STRICTER_TS_RULES,
|
||||
...GENERAL_STRICT_RULES,
|
||||
// Single-word component names are fine for our generic shared components
|
||||
// (Logo, Toast); enforcing the multi-word rule would force renames with
|
||||
// no ergonomic benefit.
|
||||
"vue/multi-word-component-names": "off",
|
||||
// Our context menu / hero / drag handles are intentionally ARIA-light —
|
||||
// raise to "warn" so issues surface in CI but don't block development
|
||||
// until we audit each one individually.
|
||||
"vuejs-accessibility/click-events-have-key-events": "warn",
|
||||
"vuejs-accessibility/no-static-element-interactions": "warn",
|
||||
"vuejs-accessibility/anchor-has-content": "warn",
|
||||
// Wrap-style labels (`<label><input/></label>`) are accessible but the
|
||||
// rule prefers explicit `for=`. Downgrade to warn pending an audit.
|
||||
"vuejs-accessibility/label-has-for": "warn",
|
||||
"vuejs-accessibility/interactive-supports-focus": "warn",
|
||||
"vuejs-accessibility/mouse-events-have-key-events": "warn",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"settingsCategories": {
|
||||
"Wildcard Pipeline": "Wildcard Pipeline",
|
||||
"Accessibility": "Accessibility"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
{
|
||||
"WP_Context": {
|
||||
"display_name": "WP Context",
|
||||
"description": "Holds named values you can plug into a prompt template. Wire into a WP Prompt Assembler to use them. Stack multiple Contexts to layer values from different sources.",
|
||||
"inputs": {
|
||||
"upstream": {
|
||||
"name": "upstream",
|
||||
"tooltip": "Optional: another WP Context to inherit values from. Values you set here override matching ones from upstream."
|
||||
},
|
||||
"seed": {
|
||||
"name": "seed",
|
||||
"tooltip": "Reserved for upcoming randomized module types. Has no effect on the current fixed-values modules."
|
||||
},
|
||||
"modules": {
|
||||
"name": "modules",
|
||||
"tooltip": "Your stack of value modules. Right-click a card → Edit to add or change values."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "context",
|
||||
"tooltip": "Wire into a WP Prompt Assembler, or chain into another WP Context."
|
||||
}
|
||||
}
|
||||
},
|
||||
"WP_PromptAssembler": {
|
||||
"display_name": "WP Prompt Assembler",
|
||||
"description": "Write your prompt as a template with $variable tokens. The Assembler fills them in from the connected Context, then outputs the final prompt string.",
|
||||
"inputs": {
|
||||
"context": {
|
||||
"name": "context",
|
||||
"tooltip": "The WP Context that provides values for your $variables."
|
||||
},
|
||||
"template": {
|
||||
"name": "template",
|
||||
"tooltip": "Your prompt template. Use $name to reference variables. Click chips below to insert at the cursor."
|
||||
}
|
||||
},
|
||||
"outputs": {
|
||||
"0": {
|
||||
"name": "prompt",
|
||||
"tooltip": "Final prompt string with all $variables filled in. Connect to a CLIP Text Encode (or any text input)."
|
||||
}
|
||||
}
|
||||
},
|
||||
"WP_Debug": {
|
||||
"display_name": "WP Debug",
|
||||
"description": "Read-only window into a WP Context. Shows every variable + value flowing through this point of the chain. Useful for confirming what your Prompt Assembler will see.",
|
||||
"inputs": {
|
||||
"context": {
|
||||
"name": "context",
|
||||
"tooltip": "The WP Context (or Assembler input) to inspect."
|
||||
},
|
||||
"viewer": {
|
||||
"name": "viewer",
|
||||
"tooltip": "Read-only display. Nothing to edit here."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"wildcardPipeline_a11y_reduceMotion": {
|
||||
"name": "Reduce motion",
|
||||
"tooltip": "Disables Wildcard Pipeline animations and transitions. Match system honors the OS prefers-reduced-motion setting.",
|
||||
"options": {
|
||||
"auto": "Match system (prefers-reduced-motion)",
|
||||
"on": "Always reduce",
|
||||
"off": "Always allow"
|
||||
}
|
||||
},
|
||||
"wildcardPipeline_a11y_contrast": {
|
||||
"name": "Contrast",
|
||||
"tooltip": "Bumps border + text contrast on Wildcard Pipeline widgets. Match system honors the OS prefers-contrast setting.",
|
||||
"options": {
|
||||
"auto": "Match system (prefers-contrast)",
|
||||
"on": "High contrast",
|
||||
"off": "Standard"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "comfyui-wildcard-pipeline",
|
||||
"version": "1.1.0",
|
||||
"version": "1.4.2",
|
||||
"private": true,
|
||||
"description": "Modular procedural prompt generation for ComfyUI",
|
||||
"author": "DumiFlex",
|
||||
@@ -9,21 +9,30 @@
|
||||
"packageManager": "pnpm@9.12.0",
|
||||
"scripts": {
|
||||
"dev": "vite build --watch --mode extension",
|
||||
"build:extension": "vite build --mode extension",
|
||||
"build:extension": "vite build --mode extension && pnpm docs:copy",
|
||||
"build:manager": "vite build --mode manager",
|
||||
"build": "pnpm build:extension && pnpm build:manager",
|
||||
"docs:copy": "node scripts/copy-help-docs.mjs",
|
||||
"typecheck": "vue-tsc --noEmit",
|
||||
"lint": "eslint \"src/**/*.{ts,vue}\"",
|
||||
"size": "node scripts/check-bundle-size.mjs",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage --passWithNoTests",
|
||||
"test:py": "pytest",
|
||||
"prepare": "husky"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "^3.5.12"
|
||||
"vue": "^3.5.12",
|
||||
"vue-router": "^4.4.5",
|
||||
"pinia": "^2.2.6",
|
||||
"primeicons": "^7.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"tailwindcss": "^3.4.14",
|
||||
"postcss": "^8.4.47",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"@comfyorg/comfyui-frontend-types": "^1.42.15",
|
||||
"@commitlint/cli": "^19.5.0",
|
||||
"@commitlint/config-conventional": "^19.5.0",
|
||||
"@semantic-release/changelog": "^6.0.3",
|
||||
@@ -32,13 +41,18 @@
|
||||
"@typescript-eslint/eslint-plugin": "^8.59.0",
|
||||
"@typescript-eslint/parser": "^8.59.0",
|
||||
"@vitejs/plugin-vue": "^5.1.4",
|
||||
"@vitest/coverage-v8": "^2.1.9",
|
||||
"@vue/test-utils": "^2.4.8",
|
||||
"@vue/tsconfig": "^0.5.1",
|
||||
"eslint": "^9.14.0",
|
||||
"eslint-plugin-vue": "^9.31.0",
|
||||
"eslint-plugin-vuejs-accessibility": "^2.5.0",
|
||||
"husky": "^9.1.6",
|
||||
"jsdom": "^29.0.2",
|
||||
"semantic-release": "^25.0.3",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^5.4.10",
|
||||
"vite-plugin-css-injected-by-js": "^3.5.2",
|
||||
"vitest": "^2.1.4",
|
||||
"vue-eslint-parser": "^10.4.0",
|
||||
"vue-tsc": "^2.1.10"
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 6.4 KiB |
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1,35 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" id="Layer_1" data-name="Layer 1" viewBox="0 0 1024 1024" width="1024" height="1024"><defs>
|
||||
<style>
|
||||
.cls-1 {
|
||||
fill: url(#linear-gradient-2);
|
||||
}
|
||||
|
||||
.cls-2 {
|
||||
fill: #6366f1;
|
||||
}
|
||||
|
||||
.cls-3 {
|
||||
fill: #33d6c6;
|
||||
}
|
||||
|
||||
.cls-4 {
|
||||
fill: #a970ff;
|
||||
}
|
||||
|
||||
.cls-5 {
|
||||
fill: url(#linear-gradient);
|
||||
}
|
||||
</style>
|
||||
<linearGradient id="linear-gradient" x1="312.56" y1="897.19" x2="312.56" y2="2" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#a970ff"></stop>
|
||||
<stop offset="1" stop-color="#6366f1"></stop>
|
||||
</linearGradient>
|
||||
<linearGradient id="linear-gradient-2" x1="711.44" y1="126.81" x2="711.44" y2="1022" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#6366f1"></stop>
|
||||
<stop offset="1" stop-color="#33d6c6"></stop>
|
||||
</linearGradient>
|
||||
</defs><path class="cls-5" d="m512,2c-53.6,0-98.47,38.16-108.84,88.73-75.07,19.29-143.32,58.26-198.74,113.68-55.42,55.42-94.39,123.68-113.68,198.74-50.58,10.38-88.73,55.24-88.73,108.84s38.16,98.47,88.73,108.84c13.62,53.14,37.03,102.64,69.77,147.47,37.14,50.86,85.83,93.86,140.81,124.33,5.54,3.08,11.56,4.54,17.48,4.54,12.7,0,25.02-6.72,31.61-18.61,9.68-17.44,3.38-39.42-14.07-49.09-85.32-47.32-147.22-125.71-173.38-218.21,36.3-18.29,61.27-55.92,61.27-99.28s-24.94-80.97-61.23-99.27c34.22-119.96,129.79-215.52,249.74-249.74,18.3,36.29,55.92,61.23,99.27,61.23,61.27,0,111.11-49.84,111.11-111.11S573.27,2,512,2ZM113.11,473.11c21.44,0,38.89,17.44,38.89,38.89,0,20.1-15.32,36.69-34.91,38.69h-.01c-.18.02-.36.03-.53.04-1.13.1-2.28.16-3.43.16-21.44,0-38.89-17.44-38.89-38.89s17.44-38.89,38.89-38.89Zm398.89-321.11c-21.44,0-38.89-17.44-38.89-38.89s17.44-38.89,38.89-38.89,38.89,17.44,38.89,38.89-17.44,38.89-38.89,38.89Z"></path><path class="cls-1" d="m933.27,403.16c-13.62-53.14-37.03-102.64-69.77-147.47-37.14-50.86-85.83-93.86-140.81-124.33-17.44-9.68-39.42-3.38-49.09,14.07-9.68,17.44-3.38,39.42,14.07,49.09,85.32,47.32,147.22,125.71,173.38,218.21-36.3,18.29-61.27,55.92-61.27,99.28s24.94,80.97,61.23,99.27c-34.22,119.96-129.79,215.52-249.74,249.74-18.3-36.29-55.92-61.23-99.27-61.23-61.27,0-111.11,49.84-111.11,111.11s49.84,111.11,111.11,111.11c53.6,0,98.47-38.16,108.84-88.73,75.07-19.29,143.32-58.26,198.74-113.68,55.42-55.42,94.39-123.68,113.68-198.74,50.58-10.38,88.73-55.24,88.73-108.84s-38.16-98.47-88.73-108.84Zm-421.27,546.62c-21.44,0-38.89-17.44-38.89-38.89s17.44-38.89,38.89-38.89,38.89,17.44,38.89,38.89-17.44,38.89-38.89,38.89Zm398.89-398.89c-21.44,0-38.89-17.44-38.89-38.89,0-20.03,15.22-36.58,34.72-38.67.38-.04.77-.08,1.16-.11h.02c.99-.08,1.99-.11,2.99-.11,21.44,0,38.89,17.44,38.89,38.89s-17.44,38.89-38.89,38.89Z"></path><g>
|
||||
<path class="cls-4" d="m735.02,517.56c-153.82,30.87-186.59,63.64-217.46,217.46-1.22,6.08-9.9,6.08-11.12,0-30.87-153.82-63.64-186.59-217.46-217.46-6.08-1.22-6.08-9.9,0-11.12,153.82-30.87,186.59-63.64,217.46-217.46,1.22-6.08,9.9-6.08,11.12,0,30.87,153.82,63.64,186.59,217.46,217.46,6.08,1.22,6.08,9.9,0,11.12Z"></path>
|
||||
<path class="cls-2" d="m434.06,665.85c-51.08,10.25-61.96,21.13-72.22,72.22-.41,2.02-3.29,2.02-3.69,0-10.25-51.08-21.13-61.96-72.22-72.22-2.02-.41-2.02-3.29,0-3.69,51.08-10.25,61.96-21.13,72.22-72.22.41-2.02,3.29-2.02,3.69,0,10.25,51.08,21.13,61.96,72.22,72.22,2.02.41,2.02,3.29,0,3.69Z"></path>
|
||||
<path class="cls-3" d="m738.07,361.84c-51.08,10.25-61.96,21.13-72.22,72.22-.41,2.02-3.29,2.02-3.69,0-10.25-51.08-21.13-61.96-72.22-72.22-2.02-.41-2.02-3.29,0-3.69,51.08-10.25,61.96-21.13,72.22-72.22.41-2.02,3.29-2.02,3.69,0,10.25,51.08,21.13,61.96,72.22,72.22,2.02.41,2.02,3.29,0,3.69Z"></path>
|
||||
</g></svg>
|
||||
|
After Width: | Height: | Size: 3.6 KiB |
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "MyWebSite",
|
||||
"short_name": "MySite",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/web-app-manifest-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
},
|
||||
{
|
||||
"src": "/web-app-manifest-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
],
|
||||
"theme_color": "#ffffff",
|
||||
"background_color": "#ffffff",
|
||||
"display": "standalone"
|
||||
}
|
||||
|
After Width: | Height: | Size: 9.9 KiB |
|
After Width: | Height: | Size: 31 KiB |
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "comfyui-wildcard-pipeline"
|
||||
version = "1.1.0"
|
||||
version = "1.4.2"
|
||||
description = "Modular procedural prompt generation for ComfyUI — wildcards, constraints, derivations, and context pipelines."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
@@ -20,6 +20,7 @@ dependencies = []
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=8.0",
|
||||
"pytest-aiohttp>=1.0",
|
||||
"pytest-asyncio>=0.23",
|
||||
"ruff>=0.5",
|
||||
]
|
||||
@@ -47,7 +48,7 @@ ignore = []
|
||||
# conftest.py prepends the project root to sys.path; mirror that for Pyright
|
||||
# so `from engine.template import ...` resolves in tests and editor.
|
||||
extraPaths = ["."]
|
||||
include = ["engine", "nodes", "tests", "conftest.py"]
|
||||
include = ["engine", "wp_nodes", "tests", "conftest.py"]
|
||||
exclude = ["node_modules", "js", "web_dist", ".venv", "**/__pycache__"]
|
||||
|
||||
# Dispatch-pattern handlers (engine.handlers.handle_*) share a uniform
|
||||
@@ -59,12 +60,30 @@ reportUnusedParameter = "none"
|
||||
[tool.pytest.ini_options]
|
||||
minversion = "8.0"
|
||||
testpaths = ["tests"]
|
||||
addopts = "-ra -q"
|
||||
addopts = "-ra -q -m 'not fuzz and not perf'"
|
||||
python_files = ["test_*.py"]
|
||||
python_classes = ["Test*"]
|
||||
python_functions = ["test_*"]
|
||||
asyncio_mode = "auto"
|
||||
markers = [
|
||||
"fuzz: long-running fuzz tests (opt-in via -m fuzz)",
|
||||
"perf: performance regression tests (opt-in via -m perf)",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
requires = ["setuptools>=68"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[tool.coverage.run]
|
||||
source = ["engine", "wp_api"]
|
||||
omit = [
|
||||
"engine/db/migrations_sql/*",
|
||||
"engine/handlers.py",
|
||||
"engine/template.py",
|
||||
"engine/pipeline.py",
|
||||
"engine/context.py",
|
||||
]
|
||||
|
||||
[tool.coverage.report]
|
||||
fail_under = 80
|
||||
show_missing = true
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
#!/usr/bin/env node
|
||||
// Diff two bundle-size manifests (produced by check-bundle-size.mjs --json)
|
||||
// and emit a Markdown report on stdout. Used by the bundle-size-pr workflow
|
||||
// to comment on PRs with the size delta.
|
||||
//
|
||||
// node scripts/bundle-size-diff.mjs <base.json> <head.json> > report.md
|
||||
//
|
||||
// Output is compact: total + per-file delta table. Files unchanged are
|
||||
// omitted; new files show as "+" and removed as "−".
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
const [basePath, headPath] = process.argv.slice(2);
|
||||
if (!basePath || !headPath) {
|
||||
console.error("Usage: bundle-size-diff <base.json> <head.json>");
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const base = JSON.parse(readFileSync(basePath, "utf-8"));
|
||||
const head = JSON.parse(readFileSync(headPath, "utf-8"));
|
||||
|
||||
function fmtKB(bytes) {
|
||||
return `${(bytes / 1024).toFixed(2)} KB`;
|
||||
}
|
||||
function fmtDelta(delta) {
|
||||
if (delta === 0) return "—";
|
||||
const sign = delta > 0 ? "+" : "−";
|
||||
const kb = Math.abs(delta) / 1024;
|
||||
return `${sign}${kb.toFixed(2)} KB`;
|
||||
}
|
||||
|
||||
const allKeys = new Set([...Object.keys(base.files), ...Object.keys(head.files)]);
|
||||
const rows = [];
|
||||
for (const key of [...allKeys].sort()) {
|
||||
const b = base.files[key] ?? 0;
|
||||
const h = head.files[key] ?? 0;
|
||||
if (b === h) continue;
|
||||
const status = b === 0 ? "🆕 new" : h === 0 ? "🗑️ removed" : "📦 changed";
|
||||
rows.push({ key, base: b, head: h, delta: h - b, status });
|
||||
}
|
||||
|
||||
const totalDelta = head.total - base.total;
|
||||
const entryDelta = (head.entry ?? 0) - (base.entry ?? 0);
|
||||
|
||||
let md = "## Bundle size report\n\n";
|
||||
md += `| | base | head | delta |\n`;
|
||||
md += `|---|---:|---:|---:|\n`;
|
||||
md += `| **Total (gzip)** | ${fmtKB(base.total)} | ${fmtKB(head.total)} | **${fmtDelta(totalDelta)}** |\n`;
|
||||
md += `| **Entry (main.js)** | ${fmtKB(base.entry ?? 0)} | ${fmtKB(head.entry ?? 0)} | ${fmtDelta(entryDelta)} |\n`;
|
||||
|
||||
if (rows.length === 0) {
|
||||
md += `\n_No per-chunk changes._\n`;
|
||||
} else {
|
||||
md += `\n### Per-chunk changes\n\n`;
|
||||
md += `| file | base | head | delta | |\n`;
|
||||
md += `|---|---:|---:|---:|---|\n`;
|
||||
for (const r of rows) {
|
||||
md += `| \`${r.key}\` | ${fmtKB(r.base)} | ${fmtKB(r.head)} | ${fmtDelta(r.delta)} | ${r.status} |\n`;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(md);
|
||||
@@ -1,11 +1,18 @@
|
||||
#!/usr/bin/env node
|
||||
import { gzipSync } from "node:zlib";
|
||||
import { readFileSync, readdirSync, statSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { readFileSync, readdirSync, statSync, writeFileSync, mkdirSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const JS_DIR = fileURLToPath(new URL("../js", import.meta.url));
|
||||
|
||||
// `--json <path>` writes a machine-readable manifest alongside the human
|
||||
// console report. Used by .github/workflows/bundle-size-pr.yml to compute
|
||||
// the delta between base and head.
|
||||
const args = process.argv.slice(2);
|
||||
const jsonIdx = args.indexOf("--json");
|
||||
const jsonPath = jsonIdx >= 0 ? args[jsonIdx + 1] : null;
|
||||
|
||||
// Budgets (gzipped bytes) — tune only with explicit review
|
||||
const ENTRY_LIMIT = 30 * 1024; // 30 KB
|
||||
const TOTAL_LIMIT = 250 * 1024; // 250 KB
|
||||
@@ -31,18 +38,28 @@ let failed = false;
|
||||
|
||||
const files = allJsFiles(JS_DIR);
|
||||
let total = 0;
|
||||
const manifest = { entry: null, total: 0, files: {} };
|
||||
console.log(`\nBundle size report (gzipped):\n`);
|
||||
for (const file of files) {
|
||||
const size = gzipSize(file);
|
||||
total += size;
|
||||
const rel = file.substring(JS_DIR.length + 1);
|
||||
// Strip the rolled-up content hash so PR ↔ base file names match. Vite
|
||||
// emits `assets/<name>-<hash>.js` per chunk; the hash changes with any
|
||||
// content edit and would otherwise show every chunk as added/removed.
|
||||
// Normalize Windows backslashes so the manifest diffs cleanly against
|
||||
// CI runs (which always emit forward slashes on Linux).
|
||||
const stable = rel.replace(/-[A-Za-z0-9_-]{8,}\.js$/, ".js").replaceAll("\\", "/");
|
||||
manifest.files[stable] = size;
|
||||
console.log(` ${rel.padEnd(40)} ${(size / 1024).toFixed(2).padStart(7)} KB`);
|
||||
}
|
||||
manifest.total = total;
|
||||
console.log(` ${"TOTAL".padEnd(40)} ${(total / 1024).toFixed(2).padStart(7)} KB`);
|
||||
|
||||
const entry = files.find((f) => f.endsWith("main.js"));
|
||||
if (entry) {
|
||||
const entrySize = gzipSize(entry);
|
||||
manifest.entry = entrySize;
|
||||
if (entrySize > ENTRY_LIMIT) {
|
||||
console.error(`\n✗ Entry main.js gzip ${entrySize} bytes exceeds budget ${ENTRY_LIMIT}`);
|
||||
failed = true;
|
||||
@@ -58,4 +75,10 @@ if (total > TOTAL_LIMIT) {
|
||||
console.log(`✓ Total within budget (${total} / ${TOTAL_LIMIT} bytes gzipped)`);
|
||||
}
|
||||
|
||||
if (jsonPath) {
|
||||
mkdirSync(dirname(jsonPath), { recursive: true });
|
||||
writeFileSync(jsonPath, JSON.stringify(manifest, null, 2));
|
||||
console.log(`\nWrote size manifest to ${jsonPath}`);
|
||||
}
|
||||
|
||||
process.exit(failed ? 1 : 0);
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
#!/usr/bin/env node
|
||||
// Copy markdown help pages from docs/help/ to js/docs/ so ComfyUI's
|
||||
// node-help-page system can serve them under /extensions/<package>/docs/.
|
||||
// ComfyUI auto-scans `${WEB_DIRECTORY}/docs/<NodeId>.md` (plus optional
|
||||
// `<NodeId>/<locale>.md` overrides) — see
|
||||
// https://docs.comfy.org/custom-nodes/help_page
|
||||
//
|
||||
// Run as part of `pnpm build:extension` so a fresh `js/` always has the
|
||||
// latest docs alongside the bundled JS chunks.
|
||||
|
||||
import { readdirSync, statSync, mkdirSync, copyFileSync, rmSync } from "node:fs";
|
||||
import { join, relative } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const ROOT = fileURLToPath(new URL("..", import.meta.url));
|
||||
const SRC = join(ROOT, "docs", "help");
|
||||
const DST = join(ROOT, "js", "docs");
|
||||
|
||||
function walk(dir) {
|
||||
const out = [];
|
||||
for (const name of readdirSync(dir)) {
|
||||
const full = join(dir, name);
|
||||
if (statSync(full).isDirectory()) out.push(...walk(full));
|
||||
else out.push(full);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
try {
|
||||
rmSync(DST, { recursive: true, force: true });
|
||||
const files = walk(SRC);
|
||||
for (const file of files) {
|
||||
const rel = relative(SRC, file);
|
||||
const target = join(DST, rel);
|
||||
mkdirSync(join(target, ".."), { recursive: true });
|
||||
copyFileSync(file, target);
|
||||
console.log(` docs/help/${rel} → js/docs/${rel}`);
|
||||
}
|
||||
console.log(`Copied ${files.length} help file(s).`);
|
||||
} catch (err) {
|
||||
console.error("[copy-help-docs] failed:", err);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { mount } from "@vue/test-utils";
|
||||
import AssemblerHelper from "./AssemblerHelper.vue";
|
||||
|
||||
const baseProps = {
|
||||
upstreamVars: [] as string[],
|
||||
upstreamValues: {} as Record<string, string>,
|
||||
template: "",
|
||||
onInsert: () => {},
|
||||
};
|
||||
|
||||
describe("AssemblerHelper.vue", () => {
|
||||
it("renders a chip for each upstream variable", () => {
|
||||
const wrapper = mount(AssemblerHelper, {
|
||||
props: { ...baseProps, upstreamVars: ["style", "subject"] },
|
||||
});
|
||||
expect(wrapper.findAll('[data-testid="chip"]')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("calls onInsert with $var when chip is clicked", async () => {
|
||||
let inserted = "";
|
||||
const wrapper = mount(AssemblerHelper, {
|
||||
props: { ...baseProps, upstreamVars: ["style"], onInsert: (s: string) => { inserted = s; } },
|
||||
});
|
||||
await wrapper.find('[data-testid="chip"]').trigger("click");
|
||||
expect(inserted).toBe("$style");
|
||||
});
|
||||
|
||||
it("renders an empty hint when no upstream vars", () => {
|
||||
const wrapper = mount(AssemblerHelper, { props: baseProps });
|
||||
expect(wrapper.text()).toContain("Connect a Pipeline Context");
|
||||
});
|
||||
|
||||
it("highlights resolved values in the preview HTML", () => {
|
||||
const wrapper = mount(AssemblerHelper, {
|
||||
props: {
|
||||
...baseProps,
|
||||
upstreamVars: ["style", "subject"],
|
||||
upstreamValues: { style: "cinematic", subject: "fox" },
|
||||
template: "A $style portrait of $subject",
|
||||
},
|
||||
});
|
||||
const html = wrapper.find(".wp-asm__rendered").html();
|
||||
expect(html).toContain("wp-tok-resolved");
|
||||
expect(html).toContain("cinematic");
|
||||
expect(html).toContain("fox");
|
||||
});
|
||||
|
||||
it("flags unresolved $name with missing token + UNRESOLVED chip", () => {
|
||||
const wrapper = mount(AssemblerHelper, {
|
||||
props: {
|
||||
...baseProps,
|
||||
upstreamValues: { style: "noir" },
|
||||
upstreamVars: ["style"],
|
||||
template: "$style and $missing",
|
||||
},
|
||||
});
|
||||
const rendered = wrapper.find(".wp-asm__rendered").html();
|
||||
expect(rendered).toContain("wp-tok-resolved");
|
||||
expect(rendered).toContain("wp-tok-miss");
|
||||
expect(wrapper.find('[data-testid="missing-chip"]').text()).toBe("$missing");
|
||||
});
|
||||
|
||||
it("marks chips of in-template upstream vars as `used` (green) and others as `available` (blue)", () => {
|
||||
const wrapper = mount(AssemblerHelper, {
|
||||
props: {
|
||||
...baseProps,
|
||||
upstreamVars: ["style", "subject"],
|
||||
upstreamValues: { style: "noir", subject: "knight" },
|
||||
template: "$style only",
|
||||
},
|
||||
});
|
||||
const chips = wrapper.findAll('[data-testid="chip"]');
|
||||
expect(chips[0].classes()).toContain("used");
|
||||
expect(chips[1].classes()).toContain("available");
|
||||
});
|
||||
|
||||
it("$$ escape stays literal and does not produce a missing chip", () => {
|
||||
const wrapper = mount(AssemblerHelper, {
|
||||
props: { ...baseProps, template: "$$cost is 5" },
|
||||
});
|
||||
expect(wrapper.find(".wp-asm__rendered").text()).toContain("$cost is 5");
|
||||
expect(wrapper.find('[data-testid="missing-chip"]').exists()).toBe(false);
|
||||
});
|
||||
|
||||
it("escapes HTML in upstream values to prevent injection", () => {
|
||||
const wrapper = mount(AssemblerHelper, {
|
||||
props: {
|
||||
...baseProps,
|
||||
upstreamVars: ["x"],
|
||||
upstreamValues: { x: "<script>alert(1)</script>" },
|
||||
template: "$x",
|
||||
},
|
||||
});
|
||||
const html = wrapper.find(".wp-asm__rendered").html();
|
||||
expect(html).not.toContain("<script>");
|
||||
expect(html).toContain("<script>");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,328 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
|
||||
const props = defineProps<{
|
||||
upstreamVars: string[];
|
||||
upstreamValues: Record<string, string>;
|
||||
template: string;
|
||||
onInsert: (token: string) => void;
|
||||
}>();
|
||||
|
||||
// Sentinel survives both regex passes — � is the Unicode replacement
|
||||
// character; doubled, it can't appear in real prompts.
|
||||
const ESCAPE_PLACEHOLDER = "��";
|
||||
const ESCAPE_RE = /��/g;
|
||||
const VAR_RE = /\$([A-Za-z_][A-Za-z0-9_]*)/g;
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
// Stable per-name hue. djb2-ish hash → 0..359°. Deterministic across runs
|
||||
// so the same variable always gets the same color in chip + preview.
|
||||
function hueFor(name: string): number {
|
||||
let h = 5381;
|
||||
for (let i = 0; i < name.length; i++) h = ((h << 5) + h + name.charCodeAt(i)) >>> 0;
|
||||
return h % 360;
|
||||
}
|
||||
|
||||
function chipColor(name: string): string {
|
||||
return `hsl(${hueFor(name)}, 65%, 65%)`;
|
||||
}
|
||||
function chipBg(name: string): string {
|
||||
return `hsla(${hueFor(name)}, 65%, 55%, 0.15)`;
|
||||
}
|
||||
|
||||
const templateVars = computed(() => {
|
||||
const sanitized = props.template.replace(/\$\$/g, ESCAPE_PLACEHOLDER);
|
||||
const matches = sanitized.match(VAR_RE);
|
||||
if (!matches) return [];
|
||||
return [...new Set(matches.map((m) => m.slice(1)).filter((v) => !v.startsWith("__")))];
|
||||
});
|
||||
|
||||
const missingVars = computed(() =>
|
||||
templateVars.value.filter((v) => !Object.prototype.hasOwnProperty.call(props.upstreamValues, v)),
|
||||
);
|
||||
|
||||
function isUsed(v: string): boolean {
|
||||
return templateVars.value.includes(v);
|
||||
}
|
||||
|
||||
const previewHtml = computed(() => {
|
||||
if (!props.template) {
|
||||
return '<span class="wp-asm-empty-inline">Template is empty.</span>';
|
||||
}
|
||||
const escaped = props.template.replace(/\$\$/g, ESCAPE_PLACEHOLDER);
|
||||
const safe = escapeHtml(escaped).replace(VAR_RE, (full, name: string) => {
|
||||
if (name.startsWith("__")) return full;
|
||||
const hue = hueFor(name);
|
||||
const colorStyle = `style="color: hsl(${hue}, 65%, 70%); background: hsla(${hue}, 65%, 55%, 0.15);"`;
|
||||
if (Object.prototype.hasOwnProperty.call(props.upstreamValues, name)) {
|
||||
return `<span class="wp-tok-resolved" ${colorStyle}>${escapeHtml(props.upstreamValues[name])}</span>`;
|
||||
}
|
||||
if (props.upstreamVars.includes(name)) {
|
||||
return `<span class="wp-tok-ok" ${colorStyle}>$${name}</span>`;
|
||||
}
|
||||
return `<span class="wp-tok-miss">$${name}</span>`;
|
||||
});
|
||||
return safe.replace(ESCAPE_RE, "$");
|
||||
});
|
||||
|
||||
// Ripple effect — track most-recent click coords per chip element to drive
|
||||
// the radial-gradient animation. Cleared after the animation duration.
|
||||
const ripples = ref<Map<string, { x: number; y: number; t: number }>>(new Map());
|
||||
|
||||
function onChipClick(ev: MouseEvent, v: string) {
|
||||
const el = ev.currentTarget as HTMLElement;
|
||||
const rect = el.getBoundingClientRect();
|
||||
const x = ev.clientX - rect.left;
|
||||
const y = ev.clientY - rect.top;
|
||||
ripples.value.set(v, { x, y, t: Date.now() });
|
||||
// Clear after animation finishes so the chip re-renders without it.
|
||||
setTimeout(() => {
|
||||
const cur = ripples.value.get(v);
|
||||
if (cur && Date.now() - cur.t >= 380) ripples.value.delete(v);
|
||||
}, 420);
|
||||
props.onInsert(`$${v}`);
|
||||
}
|
||||
|
||||
function rippleStyle(v: string): Record<string, string> {
|
||||
const r = ripples.value.get(v);
|
||||
if (!r) return {};
|
||||
return {
|
||||
"--wp-ripple-x": `${r.x}px`,
|
||||
"--wp-ripple-y": `${r.y}px`,
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="wp-asm">
|
||||
<div v-if="upstreamVars.length" class="wp-asm__section">
|
||||
<label class="wp-asm__label">VARIABLES</label>
|
||||
<div class="wp-asm__chips">
|
||||
<button
|
||||
v-for="v in upstreamVars" :key="v"
|
||||
type="button"
|
||||
class="wp-asm__chip"
|
||||
:class="[isUsed(v) ? 'used' : 'available', { 'wp-asm__chip--ripple': ripples.has(v) }]"
|
||||
:style="{ ...rippleStyle(v), color: chipColor(v), background: chipBg(v) }"
|
||||
data-testid="chip"
|
||||
:title="upstreamValues[v] ?? ''"
|
||||
@click="(ev) => onChipClick(ev, v)"
|
||||
>${{ v }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="wp-asm__section wp-asm__section--empty">
|
||||
<span class="wp-asm__empty">Connect a Pipeline Context to see available variables</span>
|
||||
</div>
|
||||
|
||||
<div class="wp-asm__section">
|
||||
<label class="wp-asm__label">PREVIEW</label>
|
||||
<div v-if="!template" class="wp-asm__skeleton">
|
||||
<span class="wp-asm__skel-line"></span>
|
||||
<span class="wp-asm__skel-line wp-asm__skel-line--short"></span>
|
||||
</div>
|
||||
<div v-else class="wp-asm__rendered" v-html="previewHtml"></div>
|
||||
</div>
|
||||
|
||||
<div v-if="missingVars.length" class="wp-asm__section">
|
||||
<label class="wp-asm__label wp-asm__label--warn">UNRESOLVED · dropped from prompt</label>
|
||||
<div class="wp-asm__chips">
|
||||
<span
|
||||
v-for="v in missingVars"
|
||||
:key="v"
|
||||
class="wp-asm__chip missing"
|
||||
data-testid="missing-chip"
|
||||
>${{ v }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
@import "../shared/theme.css";
|
||||
</style>
|
||||
|
||||
<style scoped>
|
||||
.wp-asm, .wp-asm * { box-sizing: border-box; }
|
||||
.wp-asm {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
font-family: var(--wp-font-sans, sans-serif);
|
||||
font-size: 12px;
|
||||
color: var(--wp-text);
|
||||
cursor: default;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.wp-asm__section {
|
||||
padding: 8px 10px;
|
||||
}
|
||||
.wp-asm__section + .wp-asm__section {
|
||||
border-top: 1px solid var(--wp-border);
|
||||
}
|
||||
|
||||
.wp-asm__label {
|
||||
font-size: 9px;
|
||||
font-family: var(--wp-font-mono, monospace);
|
||||
color: var(--wp-text3);
|
||||
letter-spacing: 0.08em;
|
||||
margin-bottom: 6px;
|
||||
display: block;
|
||||
}
|
||||
.wp-asm__label--warn { color: var(--wp-amber); }
|
||||
|
||||
.wp-asm__chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.wp-asm__chip {
|
||||
font-size: 10px;
|
||||
font-family: var(--wp-font-mono, monospace);
|
||||
padding: 2px 7px;
|
||||
border-radius: 3px;
|
||||
user-select: none;
|
||||
border: none;
|
||||
transition: background 0.12s, color 0.12s, transform 0.12s;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.wp-asm__chip.available { cursor: pointer; }
|
||||
.wp-asm__chip.used { cursor: pointer; }
|
||||
.wp-asm__chip.used::after {
|
||||
/* Tiny indicator pinned to the top-right corner. No padding override —
|
||||
* chip stays the same width as available chips. The dot sits in the
|
||||
* tight corner gap above the variable glyphs' ascender. */
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
right: 2px;
|
||||
width: 3px;
|
||||
height: 3px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
}
|
||||
.wp-asm__chip.available:hover,
|
||||
.wp-asm__chip.used:hover {
|
||||
/* filter brightens whatever inline hsla() the chip got from its name hash —
|
||||
* works without overriding the inline style. */
|
||||
filter: brightness(1.3) saturate(1.15);
|
||||
}
|
||||
.wp-asm__chip.missing {
|
||||
background: var(--wp-amber-bg) !important;
|
||||
color: var(--wp-amber) !important;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* Ripple — radial expanding overlay anchored to click coords */
|
||||
.wp-asm__chip--ripple::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: var(--wp-ripple-x, 50%);
|
||||
top: var(--wp-ripple-y, 50%);
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.35);
|
||||
transform: translate(-50%, -50%);
|
||||
animation: wp-asm-ripple 0.4s ease-out;
|
||||
pointer-events: none;
|
||||
}
|
||||
@keyframes wp-asm-ripple {
|
||||
0% { width: 0; height: 0; opacity: 0.6; }
|
||||
100% { width: 120px; height: 120px; opacity: 0; }
|
||||
}
|
||||
|
||||
.wp-asm__rendered {
|
||||
background: var(--wp-bg3);
|
||||
border: 1px solid var(--wp-border);
|
||||
border-radius: var(--wp-radius-sm);
|
||||
padding: 6px 8px;
|
||||
font-family: var(--wp-font-mono, monospace);
|
||||
font-size: 11px;
|
||||
line-height: 1.6;
|
||||
color: var(--wp-text2);
|
||||
word-break: break-word;
|
||||
white-space: pre-wrap;
|
||||
min-height: 32px;
|
||||
}
|
||||
|
||||
/* Skeleton when template is empty (also briefly visible during async chunk
|
||||
* resolution before Vue patches the props in). */
|
||||
.wp-asm__skeleton {
|
||||
background: var(--wp-bg3);
|
||||
border: 1px solid var(--wp-border);
|
||||
border-radius: var(--wp-radius-sm);
|
||||
padding: 8px;
|
||||
min-height: 32px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.wp-asm__skel-line {
|
||||
display: block;
|
||||
height: 8px;
|
||||
border-radius: 3px;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
var(--wp-bg2) 0%,
|
||||
var(--wp-bg4) 50%,
|
||||
var(--wp-bg2) 100%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: wp-asm-shimmer 1.4s ease-in-out infinite;
|
||||
}
|
||||
.wp-asm__skel-line--short { width: 60%; }
|
||||
@keyframes wp-asm-shimmer {
|
||||
0% { background-position: 100% 0; }
|
||||
100% { background-position: -100% 0; }
|
||||
}
|
||||
|
||||
.wp-asm__section--empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 32px;
|
||||
}
|
||||
.wp-asm__empty {
|
||||
color: var(--wp-text3);
|
||||
font-size: 11px;
|
||||
font-style: italic;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
/* Unscoped — rendered via v-html. */
|
||||
.wp-tok-resolved {
|
||||
padding: 1px 4px;
|
||||
border-radius: 3px;
|
||||
/* 600 not 500 — we only bundle Inter 400 + 600 to keep the chunk small.
|
||||
* 500 would substitute via system fallback and look out of family. */
|
||||
font-weight: 600;
|
||||
}
|
||||
.wp-tok-ok {
|
||||
padding: 1px 3px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.wp-tok-miss {
|
||||
color: var(--wp-amber, #d4a843);
|
||||
background: var(--wp-amber-bg, rgba(212, 168, 67, 0.1));
|
||||
padding: 1px 3px;
|
||||
border-radius: 3px;
|
||||
text-decoration: underline wavy;
|
||||
}
|
||||
.wp-asm-empty-inline {
|
||||
color: var(--wp-text3, #5a5a72);
|
||||
font-style: italic;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,90 @@
|
||||
import { describe, it, expect, afterEach } from "vitest";
|
||||
import { mount } from "@vue/test-utils";
|
||||
import ContextWidget from "./ContextWidget.vue";
|
||||
|
||||
afterEach(() => {
|
||||
// Modals teleport to body — strip leftover overlays between tests.
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
const moduleJson = (vars: Array<{ name: string; value: string }>): string =>
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
modules: [
|
||||
{
|
||||
id: "m1",
|
||||
type: "fixed_values",
|
||||
enabled: true,
|
||||
meta: { name: "test mod", description: "", tags: [] },
|
||||
entries: vars.map((v) => ({ variable_name: v.name, value: v.value })),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
describe("ContextWidget.vue", () => {
|
||||
it("renders an empty state when no modules", () => {
|
||||
const wrapper = mount(ContextWidget, {
|
||||
props: { nodeId: 1, initialJson: '{"version":1,"modules":[]}', upstreamVars: [], onChange: () => {} },
|
||||
});
|
||||
// Empty hero shows the brand title + the "Add your first module" CTA.
|
||||
expect(wrapper.text()).toContain("Wildcard Pipeline");
|
||||
expect(wrapper.text()).toContain("Add your first module");
|
||||
});
|
||||
|
||||
it("opens picker, picks fixed_values, emits new JSON via onChange", async () => {
|
||||
let last = "";
|
||||
const wrapper = mount(ContextWidget, {
|
||||
attachTo: document.body,
|
||||
props: { nodeId: 2, initialJson: '{"version":1,"modules":[]}', upstreamVars: [], onChange: (v: string) => { last = v; } },
|
||||
});
|
||||
await wrapper.find('[data-testid="open-picker"]').trigger("click");
|
||||
const pick = document.querySelector<HTMLButtonElement>('[data-testid="pick-fixed_values"]');
|
||||
expect(pick).not.toBeNull();
|
||||
pick!.click();
|
||||
await wrapper.vm.$nextTick();
|
||||
expect(last).toMatch(/"type":"fixed_values"/);
|
||||
});
|
||||
|
||||
it("renders a summary line per module — first 2 vars + '+N more' for the rest", () => {
|
||||
const wrapper = mount(ContextWidget, {
|
||||
props: {
|
||||
nodeId: 3,
|
||||
initialJson: moduleJson([
|
||||
{ name: "style", value: "noir" },
|
||||
{ name: "subject", value: "fox" },
|
||||
{ name: "mood", value: "calm" },
|
||||
{ name: "light", value: "soft" },
|
||||
]),
|
||||
upstreamVars: [],
|
||||
onChange: () => {},
|
||||
},
|
||||
});
|
||||
const summary = wrapper.find(".wp-summary");
|
||||
expect(summary.exists()).toBe(true);
|
||||
expect(summary.text()).toBe("$style, $subject, +2 more");
|
||||
});
|
||||
|
||||
it("module name renders read-only as text (not an input)", () => {
|
||||
const wrapper = mount(ContextWidget, {
|
||||
props: { nodeId: 4, initialJson: moduleJson([]), upstreamVars: [], onChange: () => {} },
|
||||
});
|
||||
expect(wrapper.find("input.wp-module-name").exists()).toBe(false);
|
||||
expect(wrapper.find(".wp-module-name").text()).toBe("test mod");
|
||||
});
|
||||
|
||||
it("summary line is read-only — not a button, no click handler", () => {
|
||||
const wrapper = mount(ContextWidget, {
|
||||
props: {
|
||||
nodeId: 5,
|
||||
initialJson: moduleJson([{ name: "style", value: "noir" }]),
|
||||
upstreamVars: [],
|
||||
onChange: () => {},
|
||||
},
|
||||
});
|
||||
const summary = wrapper.find(".wp-summary");
|
||||
expect(summary.exists()).toBe(true);
|
||||
// Read-only div, not a button — edit access is via right-click → Edit
|
||||
// or Enter on the focused card.
|
||||
expect(summary.element.tagName).toBe("DIV");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,504 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, nextTick, onBeforeUnmount } from "vue";
|
||||
import ModalShell from "../shared/ModalShell.vue";
|
||||
import type { ModuleEntry } from "../../widgets/_shared";
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean;
|
||||
module: ModuleEntry | null;
|
||||
/** Variable names defined upstream — used for autocomplete + validity checks. */
|
||||
upstreamVars?: string[];
|
||||
/** Variable names defined by OTHER modules in the same node. */
|
||||
siblingVars?: string[];
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "save", value: ModuleEntry): void;
|
||||
(e: "close"): void;
|
||||
}>();
|
||||
|
||||
// Draft state — owned by the modal. Cancel discards, Save commits via emit.
|
||||
// `module` prop is the source-of-truth snapshot at open time; we deep-clone
|
||||
// via JSON round-trip (Proxy-safe at every depth, unlike structuredClone).
|
||||
const draft = ref<ModuleEntry | null>(null);
|
||||
const firstNameInput = ref<HTMLInputElement | null>(null);
|
||||
const moduleNameInput = ref<HTMLInputElement | null>(null);
|
||||
|
||||
watch(() => props.visible, async (v) => {
|
||||
if (v && props.module) {
|
||||
draft.value = JSON.parse(JSON.stringify(props.module));
|
||||
await nextTick();
|
||||
if (draft.value && draft.value.entries.length > 0) {
|
||||
firstNameInput.value?.focus();
|
||||
firstNameInput.value?.select();
|
||||
} else {
|
||||
moduleNameInput.value?.focus();
|
||||
moduleNameInput.value?.select();
|
||||
}
|
||||
window.addEventListener("keydown", onKeydown);
|
||||
} else {
|
||||
window.removeEventListener("keydown", onKeydown);
|
||||
draft.value = null;
|
||||
}
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => window.removeEventListener("keydown", onKeydown));
|
||||
|
||||
function onKeydown(ev: KeyboardEvent) {
|
||||
if (!props.visible) return;
|
||||
if ((ev.ctrlKey || ev.metaKey) && ev.key === "Enter") {
|
||||
ev.preventDefault();
|
||||
save();
|
||||
}
|
||||
}
|
||||
|
||||
function iconFor(type: ModuleEntry["type"]): string {
|
||||
if (type === "fixed_values") return "pi-tag";
|
||||
return "pi-question";
|
||||
}
|
||||
|
||||
// Per-entry validity:
|
||||
// ok — name is set and unique
|
||||
// empty — name not set yet (no glyph)
|
||||
// shadow — name exists upstream OR in another module in this node
|
||||
// (informational; runtime is last-write-wins anyway)
|
||||
// dup — same name appears earlier in THIS module (real bug — the
|
||||
// second write inside the same module is silently ignored)
|
||||
type EntryStatus = "ok" | "empty" | "shadow" | "dup";
|
||||
|
||||
const entryStatuses = computed<EntryStatus[]>(() => {
|
||||
if (!draft.value) return [];
|
||||
const known = new Set([...(props.upstreamVars ?? []), ...(props.siblingVars ?? [])]);
|
||||
const seen = new Set<string>();
|
||||
return draft.value.entries.map((e) => {
|
||||
const name = e.variable_name.trim();
|
||||
if (!name) return "empty";
|
||||
if (seen.has(name)) return "dup";
|
||||
seen.add(name);
|
||||
if (known.has(name)) return "shadow";
|
||||
return "ok";
|
||||
});
|
||||
});
|
||||
|
||||
function statusTooltip(s: EntryStatus): string {
|
||||
if (s === "ok") return "Unique name";
|
||||
if (s === "shadow") return "Already defined upstream or in another module here";
|
||||
if (s === "dup") return "Duplicate name in this module";
|
||||
return "";
|
||||
}
|
||||
|
||||
function addEntry() {
|
||||
if (!draft.value) return;
|
||||
draft.value.entries.push({ variable_name: "", value: "" });
|
||||
}
|
||||
|
||||
function removeEntry(idx: number) {
|
||||
if (!draft.value) return;
|
||||
draft.value.entries.splice(idx, 1);
|
||||
}
|
||||
|
||||
function updateVarName(idx: number, v: string) {
|
||||
if (!draft.value) return;
|
||||
draft.value.entries[idx].variable_name = v.replace(/^\$+/, "");
|
||||
}
|
||||
|
||||
function onValueEnter(idx: number, ev: KeyboardEvent) {
|
||||
if (!draft.value) return;
|
||||
ev.preventDefault();
|
||||
const isLast = idx === draft.value.entries.length - 1;
|
||||
if (isLast) {
|
||||
addEntry();
|
||||
nextTick(() => {
|
||||
const inputs = document.querySelectorAll<HTMLInputElement>(".wp-medit__entry-var");
|
||||
inputs[inputs.length - 1]?.focus();
|
||||
});
|
||||
} else {
|
||||
nextTick(() => {
|
||||
const inputs = document.querySelectorAll<HTMLInputElement>(".wp-medit__entry-var");
|
||||
inputs[idx + 1]?.focus();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Bulk paste — multi-line `name=value` text becomes N entries auto-parsed.
|
||||
// Triggered when the user pastes into a variable-name input. If the paste
|
||||
// is single-line, defer to native input behavior.
|
||||
function onNamePaste(idx: number, ev: ClipboardEvent) {
|
||||
if (!draft.value) return;
|
||||
const text = ev.clipboardData?.getData("text") ?? "";
|
||||
if (!text.includes("\n") && !text.includes("=")) return; // single token — let browser paste
|
||||
const lines = text.split(/\r?\n/).map((l) => l.trim()).filter((l) => l.length > 0);
|
||||
if (lines.length === 0) return;
|
||||
// Each line: name=value, name : value, or just name
|
||||
const parsed = lines.map((line) => {
|
||||
const m = /^\s*\$?([A-Za-z_][A-Za-z0-9_]*)\s*[=:]\s*(.*?)\s*$/.exec(line);
|
||||
if (m) return { variable_name: m[1], value: m[2] };
|
||||
const nameOnly = /^\s*\$?([A-Za-z_][A-Za-z0-9_]*)\s*$/.exec(line);
|
||||
if (nameOnly) return { variable_name: nameOnly[1], value: "" };
|
||||
return null;
|
||||
}).filter((x): x is { variable_name: string; value: string } => x !== null);
|
||||
if (parsed.length === 0) return;
|
||||
ev.preventDefault();
|
||||
// Replace the current row with first parsed entry, splice the rest after.
|
||||
draft.value.entries[idx] = parsed[0];
|
||||
if (parsed.length > 1) {
|
||||
draft.value.entries.splice(idx + 1, 0, ...parsed.slice(1));
|
||||
}
|
||||
}
|
||||
|
||||
function save() {
|
||||
if (!draft.value) return;
|
||||
emit("save", JSON.parse(JSON.stringify(draft.value)));
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
emit("close");
|
||||
}
|
||||
|
||||
// HTML <datalist> id needs to be unique per modal mount so multiple Context
|
||||
// nodes don't share a list (though only one modal is visible at a time).
|
||||
const datalistId = `wp-medit-vars-${Math.random().toString(36).slice(2, 8)}`;
|
||||
|
||||
// Autocomplete options = upstream + sibling modules' variables, deduped.
|
||||
const autocompleteOptions = computed<string[]>(() => {
|
||||
const set = new Set([...(props.upstreamVars ?? []), ...(props.siblingVars ?? [])]);
|
||||
return [...set].sort();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ModalShell :visible="visible" @close="cancel">
|
||||
<div v-if="draft" class="wp-medit">
|
||||
<header class="wp-medit__head">
|
||||
<i :class="['pi', iconFor(draft.type), 'wp-medit__head-icon', `type-${draft.type}`]" aria-hidden="true"></i>
|
||||
<input
|
||||
ref="moduleNameInput"
|
||||
v-model="draft.meta.name"
|
||||
class="wp-medit__name-input"
|
||||
placeholder="module name"
|
||||
spellcheck="false"
|
||||
/>
|
||||
<button type="button" class="wp-medit__close" aria-label="Close" @click="cancel">
|
||||
<i class="pi pi-times" aria-hidden="true"></i>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="wp-medit__body">
|
||||
<section class="wp-medit__section">
|
||||
<label class="wp-medit__section-label">DESCRIPTION</label>
|
||||
<input
|
||||
v-model="draft.meta.description"
|
||||
class="wp-medit__meta-input"
|
||||
placeholder="optional — what this module is for"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section class="wp-medit__section">
|
||||
<label class="wp-medit__section-label">ENTRIES</label>
|
||||
<p class="wp-medit__hint-line">
|
||||
Tip — paste multi-line <code>name=value</code> text into a name field to bulk-add.
|
||||
</p>
|
||||
<div v-if="draft.entries.length === 0" class="wp-medit__empty">
|
||||
No entries yet.
|
||||
</div>
|
||||
<TransitionGroup v-else tag="div" name="wp-medit-list" class="wp-medit__entries">
|
||||
<datalist :id="datalistId" key="datalist">
|
||||
<option v-for="v in autocompleteOptions" :key="v" :value="v"></option>
|
||||
</datalist>
|
||||
<div v-for="(e, i) in draft.entries" :key="`row-${i}`" class="wp-medit__entry">
|
||||
<span
|
||||
class="wp-medit__status"
|
||||
:class="`wp-medit__status--${entryStatuses[i]}`"
|
||||
:title="statusTooltip(entryStatuses[i])"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<i v-if="entryStatuses[i] === 'ok'" class="pi pi-check"></i>
|
||||
<i v-else-if="entryStatuses[i] === 'dup'" class="pi pi-exclamation-circle"></i>
|
||||
<i v-else-if="entryStatuses[i] === 'shadow'" class="pi pi-info-circle"></i>
|
||||
</span>
|
||||
<div class="wp-medit__entry-var-wrap">
|
||||
<span class="wp-medit__entry-prefix" aria-hidden="true">$</span>
|
||||
<input
|
||||
:ref="(el) => { if (i === 0) firstNameInput = el as HTMLInputElement }"
|
||||
class="wp-medit__entry-input wp-medit__entry-var"
|
||||
:value="e.variable_name"
|
||||
placeholder="name"
|
||||
spellcheck="false"
|
||||
:list="datalistId"
|
||||
@input="(ev) => updateVarName(i, (ev.target as HTMLInputElement).value)"
|
||||
@paste="(ev) => onNamePaste(i, ev)"
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
v-model="e.value"
|
||||
class="wp-medit__entry-input wp-medit__entry-value"
|
||||
placeholder="value"
|
||||
@keydown.enter="(ev) => onValueEnter(i, ev)"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="remove entry"
|
||||
title="Remove entry"
|
||||
class="wp-medit__entry-remove"
|
||||
@click="removeEntry(i)"
|
||||
><i class="pi pi-times" aria-hidden="true"></i></button>
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
<button type="button" class="wp-medit__add-entry" @click="addEntry">
|
||||
<i class="pi pi-plus" aria-hidden="true"></i> add entry
|
||||
</button>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<footer class="wp-medit__foot">
|
||||
<span class="wp-medit__hint">Esc to cancel · Ctrl+Enter to save</span>
|
||||
<div class="wp-medit__buttons">
|
||||
<button type="button" class="wp-medit__btn" @click="cancel">Cancel</button>
|
||||
<button type="button" class="wp-medit__btn wp-medit__btn--primary" @click="save">Save</button>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</ModalShell>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
@import "../shared/theme.css";
|
||||
</style>
|
||||
|
||||
<style scoped>
|
||||
.wp-medit, .wp-medit * { box-sizing: border-box; }
|
||||
.wp-medit {
|
||||
background: var(--wp-bg2);
|
||||
border: 1px solid var(--wp-border);
|
||||
border-radius: var(--wp-radius);
|
||||
width: 540px;
|
||||
max-width: 100%;
|
||||
max-height: 80vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
color: var(--wp-text);
|
||||
font-family: var(--wp-font-sans, sans-serif);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.wp-medit__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px 14px;
|
||||
border-bottom: 1px solid var(--wp-border);
|
||||
background: var(--wp-brand-gradient);
|
||||
position: relative;
|
||||
}
|
||||
.wp-medit__head::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(35, 35, 35, 0.85);
|
||||
pointer-events: none;
|
||||
}
|
||||
.wp-medit__head > * { position: relative; z-index: 1; }
|
||||
.wp-medit__head-icon {
|
||||
font-size: 14px;
|
||||
color: var(--wp-text2);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.wp-medit__head-icon.type-fixed_values { color: var(--wp-rose); }
|
||||
.wp-medit__name-input {
|
||||
flex: 1;
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
border: 1px solid var(--wp-border);
|
||||
border-radius: var(--wp-radius-sm);
|
||||
color: var(--wp-text);
|
||||
font-family: var(--wp-font-sans, sans-serif);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
padding: 4px 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
.wp-medit__name-input:focus { outline: none; border-color: var(--wp-accent); }
|
||||
.wp-medit__close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--wp-text3);
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
padding: 4px 6px;
|
||||
}
|
||||
.wp-medit__close:hover { color: var(--wp-text); }
|
||||
|
||||
.wp-medit__body {
|
||||
padding: 12px 14px;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
}
|
||||
.wp-medit__section { display: flex; flex-direction: column; gap: 6px; }
|
||||
.wp-medit__section-label {
|
||||
font-size: 9px;
|
||||
font-family: var(--wp-font-mono, monospace);
|
||||
color: var(--wp-text3);
|
||||
letter-spacing: 0.08em;
|
||||
font-weight: 600;
|
||||
}
|
||||
.wp-medit__hint-line {
|
||||
font-size: 11px;
|
||||
color: var(--wp-text3);
|
||||
margin: 0 0 2px;
|
||||
}
|
||||
.wp-medit__hint-line code {
|
||||
font-family: var(--wp-font-mono, monospace);
|
||||
background: var(--wp-bg);
|
||||
padding: 0 4px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.wp-medit__meta-input {
|
||||
background: var(--wp-bg);
|
||||
border: 1px solid var(--wp-border);
|
||||
border-radius: var(--wp-radius-sm);
|
||||
color: var(--wp-text);
|
||||
font-family: var(--wp-font-sans, sans-serif);
|
||||
font-size: 12px;
|
||||
padding: 5px 8px;
|
||||
width: 100%;
|
||||
}
|
||||
.wp-medit__meta-input:focus { outline: none; border-color: var(--wp-accent); }
|
||||
|
||||
.wp-medit__empty {
|
||||
color: var(--wp-text3);
|
||||
font-style: italic;
|
||||
padding: 6px 0;
|
||||
}
|
||||
|
||||
.wp-medit__entries {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
width: 100%;
|
||||
}
|
||||
.wp-medit__entry {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
width: 100%;
|
||||
}
|
||||
.wp-medit__entry > .wp-medit__entry-var-wrap,
|
||||
.wp-medit__entry > .wp-medit__entry-value {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Per-entry validity glyph */
|
||||
.wp-medit__status {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 11px;
|
||||
flex-shrink: 0;
|
||||
cursor: help;
|
||||
}
|
||||
.wp-medit__status--empty,
|
||||
.wp-medit__status--empty i { display: none; }
|
||||
.wp-medit__status--ok i { color: var(--wp-green); }
|
||||
.wp-medit__status--shadow i { color: var(--wp-accent); }
|
||||
.wp-medit__status--dup i { color: var(--wp-red); }
|
||||
|
||||
.wp-medit__entry-var-wrap {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
}
|
||||
.wp-medit__entry-prefix {
|
||||
position: absolute;
|
||||
left: 8px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: var(--wp-accent);
|
||||
font-family: var(--wp-font-mono, monospace);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
pointer-events: none;
|
||||
}
|
||||
.wp-medit__entry-var { padding-left: 16px !important; }
|
||||
.wp-medit__entry-input {
|
||||
background: var(--wp-bg);
|
||||
border: 1px solid var(--wp-border);
|
||||
border-radius: var(--wp-radius-sm);
|
||||
color: var(--wp-text);
|
||||
font-family: var(--wp-font-mono, monospace);
|
||||
font-size: 12px;
|
||||
padding: 5px 8px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
.wp-medit__entry-input:focus { outline: none; border-color: var(--wp-accent); }
|
||||
.wp-medit__entry-remove {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--wp-text3);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
padding: 0 4px;
|
||||
}
|
||||
.wp-medit__entry-remove:hover { color: var(--wp-red); }
|
||||
|
||||
.wp-medit__add-entry {
|
||||
background: none;
|
||||
border: 1px dashed var(--wp-border2);
|
||||
border-radius: var(--wp-radius-sm);
|
||||
color: var(--wp-text2);
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
padding: 5px;
|
||||
align-self: flex-start;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.wp-medit__add-entry:hover { color: var(--wp-accent); border-color: var(--wp-accent); }
|
||||
|
||||
.wp-medit__foot {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 10px 14px;
|
||||
border-top: 1px solid var(--wp-border);
|
||||
gap: 12px;
|
||||
}
|
||||
.wp-medit__hint {
|
||||
font-size: 10px;
|
||||
color: var(--wp-text3);
|
||||
font-family: var(--wp-font-mono, monospace);
|
||||
}
|
||||
.wp-medit__buttons { display: flex; gap: 8px; }
|
||||
.wp-medit__btn {
|
||||
background: var(--wp-bg3);
|
||||
border: 1px solid var(--wp-border);
|
||||
border-radius: var(--wp-radius-sm);
|
||||
color: var(--wp-text);
|
||||
font-family: var(--wp-font-sans, sans-serif);
|
||||
font-size: 12px;
|
||||
padding: 5px 14px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.wp-medit__btn:hover { border-color: var(--wp-border2); }
|
||||
.wp-medit__btn--primary {
|
||||
background: var(--wp-accent);
|
||||
border-color: var(--wp-accent);
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
.wp-medit__btn--primary:hover { background: var(--wp-accent2); border-color: var(--wp-accent2); }
|
||||
|
||||
/* Entry-row enter + reorder animations. Leave is instant — fade-out felt
|
||||
* laggy when chained with a FLIP move to fill the gap. */
|
||||
.wp-medit-list-move { transition: transform 0.2s ease-out; }
|
||||
.wp-medit-list-enter-active { transition: opacity 0.18s, transform 0.18s; }
|
||||
.wp-medit-list-enter-from { opacity: 0; transform: translateY(-6px); }
|
||||
</style>
|
||||
@@ -0,0 +1,163 @@
|
||||
<template>
|
||||
<ModalShell :visible="visible" @close="$emit('close')">
|
||||
<div class="wp-picker">
|
||||
<header class="wp-picker__head">
|
||||
<Logo :size="32" />
|
||||
<div class="wp-picker__head-text">
|
||||
<div class="wp-picker__title">Add module</div>
|
||||
<div class="wp-picker__subtitle">Composable prompt context for ComfyUI</div>
|
||||
</div>
|
||||
<button type="button" class="wp-picker__close" aria-label="Close" @click="$emit('close')">
|
||||
<i class="pi pi-times" aria-hidden="true"></i>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="wp-picker__body">
|
||||
<button
|
||||
v-for="kind in kinds"
|
||||
:key="kind.type"
|
||||
type="button"
|
||||
class="wp-picker__item"
|
||||
:data-testid="`pick-${kind.type}`"
|
||||
@click="$emit('select', kind.type)"
|
||||
>
|
||||
<div class="wp-picker__item-row">
|
||||
<i
|
||||
:class="['pi', kind.icon, 'wp-picker__item-icon']"
|
||||
:style="{ color: kind.color }"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<span class="wp-picker__item-name">{{ kind.label }}</span>
|
||||
</div>
|
||||
<div class="wp-picker__item-desc">{{ kind.description }}</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</ModalShell>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ModalShell from "../shared/ModalShell.vue";
|
||||
import Logo from "../shared/Logo.vue";
|
||||
|
||||
defineProps<{ visible: boolean }>();
|
||||
defineEmits<{ (e: "select", type: "fixed_values"): void; (e: "close"): void }>();
|
||||
|
||||
// MVP only ships fixed_values. New types append here; the type-icon mapping
|
||||
// lives in shared/theme.css comments + this kinds array (color via CSS class
|
||||
// per type, icon via PrimeIcons name).
|
||||
const kinds = [
|
||||
{
|
||||
type: "fixed_values" as const,
|
||||
label: "Fixed values",
|
||||
icon: "pi-tag",
|
||||
color: "var(--wp-rose)",
|
||||
description: "Hard-coded $variable = value pairs.",
|
||||
},
|
||||
];
|
||||
</script>
|
||||
|
||||
<style>
|
||||
@import "../shared/theme.css";
|
||||
</style>
|
||||
|
||||
<style scoped>
|
||||
.wp-picker {
|
||||
background: var(--wp-bg2);
|
||||
border: 1px solid var(--wp-border);
|
||||
border-radius: var(--wp-radius);
|
||||
width: 480px;
|
||||
max-width: 100%;
|
||||
max-height: 80vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
color: var(--wp-text);
|
||||
}
|
||||
.wp-picker__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid var(--wp-border);
|
||||
background: var(--wp-brand-gradient);
|
||||
background-size: 200% 200%;
|
||||
position: relative;
|
||||
}
|
||||
.wp-picker__head::after {
|
||||
/* Translucent overlay so the gradient sits behind a dark wash — same
|
||||
* effect as the rest of the modal but with a hint of brand color. */
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(35, 35, 35, 0.85);
|
||||
pointer-events: none;
|
||||
}
|
||||
.wp-picker__head > * { position: relative; z-index: 1; }
|
||||
.wp-picker__head-text { flex: 1; min-width: 0; }
|
||||
.wp-picker__title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.wp-picker__subtitle {
|
||||
font-size: 11px;
|
||||
color: var(--wp-text2);
|
||||
margin-top: 2px;
|
||||
}
|
||||
.wp-picker__close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--wp-text3);
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
padding: 4px 6px;
|
||||
}
|
||||
.wp-picker__close:hover { color: var(--wp-text); }
|
||||
|
||||
.wp-picker__body {
|
||||
padding: 12px;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.wp-picker__item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
background: var(--wp-bg3);
|
||||
border: 1px solid var(--wp-border);
|
||||
border-radius: var(--wp-radius-sm);
|
||||
padding: 10px 12px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
color: var(--wp-text);
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.wp-picker__item:hover {
|
||||
border-color: var(--wp-accent);
|
||||
background: var(--wp-bg4);
|
||||
}
|
||||
.wp-picker__item-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.wp-picker__item-icon {
|
||||
font-size: 14px;
|
||||
color: var(--wp-text2);
|
||||
width: 16px;
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.wp-picker__item-name {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.wp-picker__item-desc {
|
||||
font-size: 11px;
|
||||
color: var(--wp-text2);
|
||||
padding-left: 26px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,15 @@
|
||||
import { ref } from "vue";
|
||||
import type { ModuleEntry } from "../../widgets/_shared";
|
||||
|
||||
// Module-scoped reactive state shared across every ContextWidget Vue app
|
||||
// instance on the page. Each Context node mounts its own Vue app, so we
|
||||
// can't use provide/inject — but plain module imports give us a single
|
||||
// source of truth for "what is currently being dragged".
|
||||
export interface DragPayload {
|
||||
sourceNodeId: number;
|
||||
module: ModuleEntry;
|
||||
/** Set by the receiving node on cross-node drop so source can clean up. */
|
||||
consumedBy?: number;
|
||||
}
|
||||
|
||||
export const dragState = ref<DragPayload | null>(null);
|
||||
@@ -0,0 +1,15 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { mount } from "@vue/test-utils";
|
||||
import DebugViewer from "./DebugViewer.vue";
|
||||
|
||||
describe("DebugViewer.vue", () => {
|
||||
it("renders a placeholder when no snapshot has been received", () => {
|
||||
const wrapper = mount(DebugViewer, { props: { snapshot: "" } });
|
||||
expect(wrapper.text()).toContain("Run the graph");
|
||||
});
|
||||
|
||||
it("renders the snapshot JSON pretty-printed", () => {
|
||||
const wrapper = mount(DebugViewer, { props: { snapshot: '{"context":{"a":1},"debug":{}}' } });
|
||||
expect(wrapper.text()).toContain('"a": 1');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
|
||||
const props = defineProps<{ snapshot: string }>();
|
||||
|
||||
const pretty = computed(() => {
|
||||
if (!props.snapshot) return "";
|
||||
try { return JSON.stringify(JSON.parse(props.snapshot), null, 2); }
|
||||
catch { return props.snapshot; }
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="wp-debug">
|
||||
<pre v-if="pretty" class="wp-debug__pre">{{ pretty }}</pre>
|
||||
<p v-else class="wp-debug__empty">Run the graph to capture a snapshot.</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
@import "../shared/theme.css";
|
||||
</style>
|
||||
|
||||
<style scoped>
|
||||
.wp-debug {
|
||||
font-family: var(--wp-font-mono, monospace);
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
padding: 6px;
|
||||
color: var(--wp-text);
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.wp-debug__pre {
|
||||
background: var(--wp-bg);
|
||||
color: var(--wp-text);
|
||||
border: 1px solid var(--wp-border);
|
||||
border-radius: var(--wp-radius-sm);
|
||||
padding: 8px;
|
||||
margin: 0;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
.wp-debug__empty {
|
||||
color: var(--wp-text3);
|
||||
font-style: italic;
|
||||
font-family: var(--wp-font-sans, sans-serif);
|
||||
text-align: center;
|
||||
margin: auto 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,182 @@
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, ref, watch, nextTick } from "vue";
|
||||
|
||||
export interface ContextMenuItem {
|
||||
label: string;
|
||||
/** PrimeIcons class (e.g. "pi-trash"). ComfyUI loads PrimeIcons globally. */
|
||||
icon?: string;
|
||||
/** Optional separator above the item. */
|
||||
divider?: boolean;
|
||||
/** Render the label in red (destructive action). */
|
||||
danger?: boolean;
|
||||
disabled?: boolean;
|
||||
onSelect: () => void;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean;
|
||||
x: number;
|
||||
y: number;
|
||||
items: ContextMenuItem[];
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{ (e: "close"): void }>();
|
||||
|
||||
const activeIndex = ref(-1);
|
||||
|
||||
function pickableIndices(): number[] {
|
||||
const out: number[] = [];
|
||||
for (let i = 0; i < props.items.length; i++) {
|
||||
const it = props.items[i];
|
||||
if (!it.disabled) out.push(i);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function moveActive(delta: 1 | -1) {
|
||||
const idxs = pickableIndices();
|
||||
if (!idxs.length) return;
|
||||
const cur = idxs.indexOf(activeIndex.value);
|
||||
const next = cur < 0 ? (delta > 0 ? 0 : idxs.length - 1) : (cur + delta + idxs.length) % idxs.length;
|
||||
activeIndex.value = idxs[next];
|
||||
}
|
||||
|
||||
function gotoEdge(edge: "first" | "last") {
|
||||
const idxs = pickableIndices();
|
||||
if (!idxs.length) return;
|
||||
activeIndex.value = edge === "first" ? idxs[0] : idxs[idxs.length - 1];
|
||||
}
|
||||
|
||||
function onWindowClick() {
|
||||
emit("close");
|
||||
}
|
||||
|
||||
function onKey(ev: KeyboardEvent) {
|
||||
switch (ev.key) {
|
||||
case "Escape": ev.preventDefault(); emit("close"); break;
|
||||
case "ArrowDown": ev.preventDefault(); moveActive(1); break;
|
||||
case "ArrowUp": ev.preventDefault(); moveActive(-1); break;
|
||||
case "Home": ev.preventDefault(); gotoEdge("first"); break;
|
||||
case "End": ev.preventDefault(); gotoEdge("last"); break;
|
||||
case "Enter":
|
||||
case " ": {
|
||||
ev.preventDefault();
|
||||
const it = props.items[activeIndex.value];
|
||||
if (it && !it.disabled) pick(it);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => props.visible, async (v) => {
|
||||
if (v) {
|
||||
activeIndex.value = -1;
|
||||
window.addEventListener("click", onWindowClick);
|
||||
window.addEventListener("keydown", onKey);
|
||||
// Defer one tick so the click that opened the menu doesn't immediately
|
||||
// close it — and so the first ArrowDown lands on the first item.
|
||||
await nextTick();
|
||||
} else {
|
||||
window.removeEventListener("click", onWindowClick);
|
||||
window.removeEventListener("keydown", onKey);
|
||||
}
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener("click", onWindowClick);
|
||||
window.removeEventListener("keydown", onKey);
|
||||
});
|
||||
|
||||
function pick(item: ContextMenuItem) {
|
||||
if (item.disabled) return;
|
||||
item.onSelect();
|
||||
emit("close");
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<ul
|
||||
v-if="visible"
|
||||
class="wp-ctxmenu"
|
||||
:style="{ left: `${x}px`, top: `${y}px` }"
|
||||
role="menu"
|
||||
@click.stop
|
||||
@contextmenu.prevent
|
||||
>
|
||||
<template v-for="(it, i) in items" :key="i">
|
||||
<li v-if="it.divider" class="wp-ctxmenu__sep" aria-hidden="true"></li>
|
||||
<li
|
||||
class="wp-ctxmenu__item"
|
||||
:class="{
|
||||
'wp-ctxmenu__item--danger': it.danger,
|
||||
'wp-ctxmenu__item--disabled': it.disabled,
|
||||
'wp-ctxmenu__item--active': activeIndex === i,
|
||||
}"
|
||||
role="menuitem"
|
||||
:aria-disabled="it.disabled || undefined"
|
||||
@mouseenter="!it.disabled && (activeIndex = i)"
|
||||
@click="pick(it)"
|
||||
>
|
||||
<i v-if="it.icon" :class="['pi', it.icon, 'wp-ctxmenu__icon']" aria-hidden="true"></i>
|
||||
<span v-else class="wp-ctxmenu__icon-spacer" aria-hidden="true"></span>
|
||||
<span class="wp-ctxmenu__label">{{ it.label }}</span>
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
@import "./theme.css";
|
||||
</style>
|
||||
|
||||
<style scoped>
|
||||
.wp-ctxmenu {
|
||||
position: fixed;
|
||||
z-index: 10000;
|
||||
background: var(--wp-bg2);
|
||||
border: 1px solid var(--wp-border);
|
||||
border-radius: var(--wp-radius-sm);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5);
|
||||
padding: 4px 0;
|
||||
min-width: 180px;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
font-family: var(--wp-font-sans, sans-serif);
|
||||
font-size: 12px;
|
||||
color: var(--wp-text);
|
||||
user-select: none;
|
||||
}
|
||||
.wp-ctxmenu__item {
|
||||
padding: 5px 12px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.wp-ctxmenu__item--active,
|
||||
.wp-ctxmenu__item:hover { background: var(--wp-accent-glow); color: var(--wp-accent); }
|
||||
.wp-ctxmenu__item--danger { color: var(--wp-red); }
|
||||
.wp-ctxmenu__item--danger.wp-ctxmenu__item--active,
|
||||
.wp-ctxmenu__item--danger:hover { background: var(--wp-red-bg); color: var(--wp-red); }
|
||||
.wp-ctxmenu__item--disabled {
|
||||
color: var(--wp-text3);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.wp-ctxmenu__item--disabled:hover { background: transparent; color: var(--wp-text3); }
|
||||
.wp-ctxmenu__icon,
|
||||
.wp-ctxmenu__icon-spacer {
|
||||
width: 14px;
|
||||
font-size: 11px;
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.wp-ctxmenu__label { flex: 1; }
|
||||
.wp-ctxmenu__sep {
|
||||
height: 1px;
|
||||
background: var(--wp-border);
|
||||
margin: 4px 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,35 @@
|
||||
<script setup lang="ts">
|
||||
withDefaults(defineProps<{ size?: number }>(), { size: 28 });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 1024 1024"
|
||||
:width="size"
|
||||
:height="size"
|
||||
role="img"
|
||||
aria-label="Wildcard Pipeline"
|
||||
class="wp-logo"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="wp-logo-grad-1" x1="312.56" y1="897.19" x2="312.56" y2="2" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#a970ff" />
|
||||
<stop offset="1" stop-color="#6366f1" />
|
||||
</linearGradient>
|
||||
<linearGradient id="wp-logo-grad-2" x1="711.44" y1="126.81" x2="711.44" y2="1022" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#6366f1" />
|
||||
<stop offset="1" stop-color="#33d6c6" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path fill="url(#wp-logo-grad-1)" d="m512,2c-53.6,0-98.47,38.16-108.84,88.73-75.07,19.29-143.32,58.26-198.74,113.68-55.42,55.42-94.39,123.68-113.68,198.74-50.58,10.38-88.73,55.24-88.73,108.84s38.16,98.47,88.73,108.84c13.62,53.14,37.03,102.64,69.77,147.47,37.14,50.86,85.83,93.86,140.81,124.33,5.54,3.08,11.56,4.54,17.48,4.54,12.7,0,25.02-6.72,31.61-18.61,9.68-17.44,3.38-39.42-14.07-49.09-85.32-47.32-147.22-125.71-173.38-218.21,36.3-18.29,61.27-55.92,61.27-99.28s-24.94-80.97-61.23-99.27c34.22-119.96,129.79-215.52,249.74-249.74,18.3,36.29,55.92,61.23,99.27,61.23,61.27,0,111.11-49.84,111.11-111.11S573.27,2,512,2ZM113.11,473.11c21.44,0,38.89,17.44,38.89,38.89,0,20.1-15.32,36.69-34.91,38.69h-.01c-.18.02-.36.03-.53.04-1.13.1-2.28.16-3.43.16-21.44,0-38.89-17.44-38.89-38.89s17.44-38.89,38.89-38.89Zm398.89-321.11c-21.44,0-38.89-17.44-38.89-38.89s17.44-38.89,38.89-38.89,38.89,17.44,38.89,38.89-17.44,38.89-38.89,38.89Z" />
|
||||
<path fill="url(#wp-logo-grad-2)" d="m933.27,403.16c-13.62-53.14-37.03-102.64-69.77-147.47-37.14-50.86-85.83-93.86-140.81-124.33-17.44-9.68-39.42-3.38-49.09,14.07-9.68,17.44-3.38,39.42,14.07,49.09,85.32,47.32,147.22,125.71,173.38,218.21-36.3,18.29-61.27,55.92-61.27,99.28s24.94,80.97,61.23,99.27c-34.22,119.96-129.79,215.52-249.74,249.74-18.3-36.29-55.92-61.23-99.27-61.23-61.27,0-111.11,49.84-111.11,111.11s49.84,111.11,111.11,111.11c53.6,0,98.47-38.16,108.84-88.73,75.07-19.29,143.32-58.26,198.74-113.68,55.42-55.42,94.39-123.68,113.68-198.74,50.58-10.38,88.73-55.24,88.73-108.84s-38.16-98.47-88.73-108.84Zm-421.27,546.62c-21.44,0-38.89-17.44-38.89-38.89s17.44-38.89,38.89-38.89,38.89,17.44,38.89,38.89-17.44,38.89-38.89,38.89Zm398.89-398.89c-21.44,0-38.89-17.44-38.89-38.89,0-20.03,15.22-36.58,34.72-38.67.38-.04.77-.08,1.16-.11h.02c.99-.08,1.99-.11,2.99-.11,21.44,0,38.89,17.44,38.89,38.89s-17.44,38.89-38.89,38.89Z" />
|
||||
<path fill="#a970ff" d="m735.02,517.56c-153.82,30.87-186.59,63.64-217.46,217.46-1.22,6.08-9.9,6.08-11.12,0-30.87-153.82-63.64-186.59-217.46-217.46-6.08-1.22-6.08-9.9,0-11.12,153.82-30.87,186.59-63.64,217.46-217.46,1.22-6.08,9.9-6.08,11.12,0,30.87,153.82,63.64,186.59,217.46,217.46,6.08,1.22,6.08,9.9,0,11.12Z" />
|
||||
<path fill="#6366f1" d="m434.06,665.85c-51.08,10.25-61.96,21.13-72.22,72.22-.41,2.02-3.29,2.02-3.69,0-10.25-51.08-21.13-61.96-72.22-72.22-2.02-.41-2.02-3.29,0-3.69,51.08-10.25,61.96-21.13,72.22-72.22.41-2.02,3.29-2.02,3.69,0,10.25,51.08,21.13,61.96,72.22,72.22,2.02.41,2.02,3.29,0,3.69Z" />
|
||||
<path fill="#33d6c6" d="m738.07,361.84c-51.08,10.25-61.96,21.13-72.22,72.22-.41,2.02-3.29,2.02-3.69,0-10.25-51.08-21.13-61.96-72.22-72.22-2.02-.41-2.02-3.29,0-3.69,51.08-10.25,61.96-21.13,72.22-72.22.41-2.02,3.29-2.02,3.69,0,10.25,51.08,21.13,61.96,72.22,72.22,2.02.41,2.02,3.29,0,3.69Z" />
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.wp-logo { display: block; flex-shrink: 0; }
|
||||
</style>
|
||||
@@ -0,0 +1,63 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div v-if="visible" class="wp-modal-overlay" @click="$emit('close')">
|
||||
<div class="wp-modal-wrapper" role="dialog" aria-modal="true" @click.stop>
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { watch, onBeforeUnmount } from "vue";
|
||||
|
||||
const props = defineProps<{ visible: boolean }>();
|
||||
const emit = defineEmits<{ (e: "close"): void }>();
|
||||
|
||||
function onKeydown(event: KeyboardEvent) {
|
||||
if (props.visible && event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
emit("close");
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.visible,
|
||||
(visible) => {
|
||||
if (visible) window.addEventListener("keydown", onKeydown);
|
||||
else window.removeEventListener("keydown", onKeydown);
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
onBeforeUnmount(() => window.removeEventListener("keydown", onKeydown));
|
||||
</script>
|
||||
|
||||
<style>
|
||||
@import "./theme.css";
|
||||
</style>
|
||||
|
||||
<style scoped>
|
||||
.wp-modal-overlay,
|
||||
.wp-modal-overlay * {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.wp-modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
background: rgba(0, 0, 0, 0.62);
|
||||
font-family: var(--wp-font-sans, sans-serif);
|
||||
cursor: default;
|
||||
}
|
||||
.wp-modal-wrapper {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
max-height: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,162 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onBeforeUnmount } from "vue";
|
||||
import { toasts, dismissToast, type Toast } from "./toast-store";
|
||||
|
||||
function severityIcon(s: Toast["severity"]): string {
|
||||
if (s === "success") return "pi-check-circle";
|
||||
if (s === "warning") return "pi-exclamation-triangle";
|
||||
if (s === "error") return "pi-times-circle";
|
||||
return "pi-info-circle";
|
||||
}
|
||||
|
||||
function pickAction(t: Toast) {
|
||||
t.action?.onSelect();
|
||||
dismissToast(t.id);
|
||||
}
|
||||
|
||||
// Anchor the stack relative to ComfyUI's graph canvas container, matching
|
||||
// the native PrimeVue Toast positioning. Reading GlobalToast.js: top = rect.top
|
||||
// + 100px (clears the menu bar), right = innerWidth - rect.right + 20px (sits
|
||||
// just inside the canvas right edge). Updates on resize + canvas mutations.
|
||||
const stackTop = ref<number>(56);
|
||||
const stackRight = ref<number>(16);
|
||||
|
||||
function updatePosition() {
|
||||
const canvas = document.querySelector<HTMLElement>(".graph-canvas-container");
|
||||
if (!canvas) return;
|
||||
const r = canvas.getBoundingClientRect();
|
||||
stackTop.value = r.top + 100;
|
||||
stackRight.value = Math.max(8, window.innerWidth - (r.left + r.width) + 20);
|
||||
}
|
||||
|
||||
let observer: ResizeObserver | null = null;
|
||||
onMounted(() => {
|
||||
updatePosition();
|
||||
window.addEventListener("resize", updatePosition);
|
||||
// Recompute when the canvas resizes (sidebar toggle, panel resize, etc.).
|
||||
const canvas = document.querySelector(".graph-canvas-container");
|
||||
if (canvas && typeof ResizeObserver !== "undefined") {
|
||||
observer = new ResizeObserver(updatePosition);
|
||||
observer.observe(canvas);
|
||||
}
|
||||
});
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener("resize", updatePosition);
|
||||
observer?.disconnect();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<TransitionGroup
|
||||
tag="div"
|
||||
name="wp-toast"
|
||||
class="wp-toast-stack"
|
||||
:style="{ top: `${stackTop}px`, right: `${stackRight}px` }"
|
||||
aria-live="polite"
|
||||
>
|
||||
<div
|
||||
v-for="t in toasts"
|
||||
:key="t.id"
|
||||
class="wp-toast"
|
||||
:class="`wp-toast--${t.severity}`"
|
||||
role="status"
|
||||
>
|
||||
<i :class="['pi', severityIcon(t.severity), 'wp-toast__icon']" aria-hidden="true"></i>
|
||||
<span class="wp-toast__msg">{{ t.message }}</span>
|
||||
<button
|
||||
v-if="t.action"
|
||||
type="button"
|
||||
class="wp-toast__action"
|
||||
@click="pickAction(t)"
|
||||
>{{ t.action.label }}</button>
|
||||
<button
|
||||
type="button"
|
||||
class="wp-toast__close"
|
||||
aria-label="Dismiss"
|
||||
@click="dismissToast(t.id)"
|
||||
><i class="pi pi-times" aria-hidden="true"></i></button>
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
@import "./theme.css";
|
||||
</style>
|
||||
|
||||
<style scoped>
|
||||
.wp-toast-stack {
|
||||
position: fixed;
|
||||
/* top + right set inline so we mirror ComfyUI's native toast anchor —
|
||||
* relative to .graph-canvas-container so sidebars don't push us off. */
|
||||
z-index: 10001;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 8px;
|
||||
max-width: min(420px, calc(100vw - 32px));
|
||||
pointer-events: none;
|
||||
}
|
||||
.wp-toast {
|
||||
pointer-events: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 10px;
|
||||
background: var(--wp-bg2);
|
||||
border: 1px solid var(--wp-border);
|
||||
border-left: 3px solid var(--wp-accent);
|
||||
border-radius: var(--wp-radius-sm);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
|
||||
font-family: var(--wp-font-sans, sans-serif);
|
||||
font-size: 12px;
|
||||
color: var(--wp-text);
|
||||
}
|
||||
.wp-toast--success { border-left-color: var(--wp-green); }
|
||||
.wp-toast--warning { border-left-color: var(--wp-amber); }
|
||||
.wp-toast--error { border-left-color: var(--wp-red); }
|
||||
|
||||
.wp-toast__icon {
|
||||
font-size: 14px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.wp-toast--info .wp-toast__icon { color: var(--wp-accent); }
|
||||
.wp-toast--success .wp-toast__icon { color: var(--wp-green); }
|
||||
.wp-toast--warning .wp-toast__icon { color: var(--wp-amber); }
|
||||
.wp-toast--error .wp-toast__icon { color: var(--wp-red); }
|
||||
|
||||
.wp-toast__msg {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.wp-toast__action {
|
||||
background: var(--wp-accent-glow);
|
||||
border: 1px solid var(--wp-accent);
|
||||
border-radius: var(--wp-radius-sm);
|
||||
color: var(--wp-accent);
|
||||
font-family: var(--wp-font-sans, sans-serif);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
padding: 3px 10px;
|
||||
cursor: pointer;
|
||||
transition: filter 0.12s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.wp-toast__action:hover { filter: brightness(1.2); }
|
||||
.wp-toast__close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--wp-text3);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
padding: 2px 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.wp-toast__close:hover { color: var(--wp-text); }
|
||||
|
||||
/* Slide-in from the right; instant out for snappiness. */
|
||||
.wp-toast-enter-active { transition: opacity 0.2s, transform 0.2s; }
|
||||
.wp-toast-enter-from { opacity: 0; transform: translateX(20px); }
|
||||
.wp-toast-move { transition: transform 0.2s ease-out; }
|
||||
</style>
|
||||
@@ -0,0 +1,53 @@
|
||||
/* Accessibility overrides driven by ComfyUI settings (see
|
||||
* src/extension/settings.ts). The `.wp-a11y-*` markers are toggled on
|
||||
* <body>; rules here scope the override to our own widgets so we don't
|
||||
* mutate ComfyUI core or other extensions. */
|
||||
|
||||
/* ── Reduce motion ──────────────────────────────────────────────────── */
|
||||
.wp-a11y-no-motion .wp-context,
|
||||
.wp-a11y-no-motion .wp-context *,
|
||||
.wp-a11y-no-motion .wp-asm,
|
||||
.wp-a11y-no-motion .wp-asm *,
|
||||
.wp-a11y-no-motion .wp-toast-stack,
|
||||
.wp-a11y-no-motion .wp-toast-stack *,
|
||||
.wp-a11y-no-motion .wp-emptystate,
|
||||
.wp-a11y-no-motion .wp-emptystate *,
|
||||
.wp-a11y-no-motion .wp-medit,
|
||||
.wp-a11y-no-motion .wp-medit *,
|
||||
.wp-a11y-no-motion .wp-picker,
|
||||
.wp-a11y-no-motion .wp-picker * {
|
||||
/* Keep just enough duration that Vue's transitionend listeners still
|
||||
* fire and cleanly tear down — instant 0ms can confuse them. */
|
||||
transition-duration: 0.01ms !important;
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
|
||||
/* ── High contrast — bump border + text tones, thicken focus outlines ── */
|
||||
.wp-a11y-high-contrast {
|
||||
--wp-border: #6e6e6e;
|
||||
--wp-border2: #9a9a9a;
|
||||
--wp-text2: #e6e6e6;
|
||||
--wp-text3: #b0b0b0;
|
||||
--wp-text4: #c5c5c5;
|
||||
}
|
||||
.wp-a11y-high-contrast .wp-module,
|
||||
.wp-a11y-high-contrast .wp-recovery,
|
||||
.wp-a11y-high-contrast .wp-asm__rendered,
|
||||
.wp-a11y-high-contrast .wp-recovery__btn,
|
||||
.wp-a11y-high-contrast .wp-emptystate__cta,
|
||||
.wp-a11y-high-contrast .wp-add-btn,
|
||||
.wp-a11y-high-contrast .wp-toast {
|
||||
border-width: 2px !important;
|
||||
}
|
||||
.wp-a11y-high-contrast .wp-module:focus,
|
||||
.wp-a11y-high-contrast .wp-module:focus-visible,
|
||||
.wp-a11y-high-contrast .wp-emptystate__cta:focus-visible,
|
||||
.wp-a11y-high-contrast .wp-add-btn:focus-visible {
|
||||
outline: 3px solid var(--wp-violet) !important;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.wp-a11y-high-contrast .wp-toggle-mark {
|
||||
border-width: 2px !important;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
/* Theme tokens shared across WP widgets.
|
||||
* Brand palette pinned to public/images/favicon.svg — indigo / violet / teal.
|
||||
*/
|
||||
:root {
|
||||
/* Backgrounds — match ComfyUI's node interior */
|
||||
--wp-bg: #1e1e1e;
|
||||
--wp-bg2: #232323;
|
||||
--wp-bg3: #2a2a2a;
|
||||
--wp-bg4: #333;
|
||||
|
||||
/* Borders */
|
||||
--wp-border: #3a3a3a;
|
||||
--wp-border2: #4a4a4a;
|
||||
|
||||
/* Text — four tiers from primary to ghost.
|
||||
* text3 stays #666 to preserve every existing usage; text4 #888 is the
|
||||
* new "slightly brighter ghost" available for cases that need a bit
|
||||
* more readability without bumping all the way to text2. */
|
||||
--wp-text: #ddd;
|
||||
--wp-text2: #aaa;
|
||||
--wp-text3: #666;
|
||||
--wp-text4: #888;
|
||||
|
||||
/* Brand — logo palette (favicon.svg) */
|
||||
--wp-accent: #6366f1; /* indigo, primary brand */
|
||||
--wp-accent2: #a970ff; /* violet, hover / second tier */
|
||||
--wp-accent-glow: rgba(99, 102, 241, 0.15);
|
||||
--wp-violet: #a970ff; /* alias for accent2 — used by module-type tags */
|
||||
--wp-violet-bg: rgba(169, 112, 255, 0.12);
|
||||
--wp-teal: #33d6c6; /* exact logo teal */
|
||||
--wp-teal-bg: rgba(51, 214, 198, 0.12);
|
||||
--wp-brand-gradient: linear-gradient(135deg, #6366f1, #33d6c6);
|
||||
|
||||
/* Semantic — crisper red/amber/green for sharper alerting on dark */
|
||||
--wp-green: #6bc96f;
|
||||
--wp-green-bg: rgba(107, 201, 111, 0.12);
|
||||
--wp-amber: #fbbf24;
|
||||
--wp-amber-bg: rgba(251, 191, 36, 0.12);
|
||||
--wp-red: #f87171;
|
||||
--wp-red-bg: rgba(248, 113, 113, 0.12);
|
||||
|
||||
/* Module-type colors — one hue per type so the icon alone identifies the
|
||||
* module at a glance. fixed_values uses rose because it's a tag/label
|
||||
* module; the other types reuse brand colors per the type-mapping spec. */
|
||||
--wp-rose: #f472b6;
|
||||
--wp-rose-bg: rgba(244, 114, 182, 0.12);
|
||||
|
||||
/* Geometry */
|
||||
--wp-radius: 6px;
|
||||
--wp-radius-sm: 4px;
|
||||
|
||||
/* Typography — bundled webfonts (Inter + JetBrains Mono) loaded via
|
||||
* src/extension/fonts.ts. System stacks remain as fallback so the UI
|
||||
* stays readable if the woff2 files fail to load. */
|
||||
--wp-font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
--wp-font-mono: 'JetBrains Mono', 'Consolas', 'Monaco', 'Courier New', monospace;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { ref } from "vue";
|
||||
|
||||
export type ToastSeverity = "info" | "success" | "warning" | "error";
|
||||
|
||||
export interface Toast {
|
||||
id: number;
|
||||
message: string;
|
||||
severity: ToastSeverity;
|
||||
/** Optional inline action — e.g. "Undo" with a callback. */
|
||||
action?: { label: string; onSelect: () => void };
|
||||
/** Auto-dismiss after this many ms. 0 = sticky (must be dismissed manually). */
|
||||
lifeMs: number;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
let nextId = 1;
|
||||
export const toasts = ref<Toast[]>([]);
|
||||
|
||||
interface PushToastOptions {
|
||||
severity?: ToastSeverity;
|
||||
action?: Toast["action"];
|
||||
lifeMs?: number;
|
||||
}
|
||||
|
||||
export function pushToast(message: string, options: PushToastOptions = {}): number {
|
||||
const id = nextId++;
|
||||
const t: Toast = {
|
||||
id,
|
||||
message,
|
||||
severity: options.severity ?? "info",
|
||||
action: options.action,
|
||||
lifeMs: options.lifeMs ?? 5000,
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
toasts.value = [...toasts.value, t];
|
||||
if (t.lifeMs > 0) {
|
||||
window.setTimeout(() => dismissToast(id), t.lifeMs);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
export function dismissToast(id: number): void {
|
||||
toasts.value = toasts.value.filter((t) => t.id !== id);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" id="Layer_1" data-name="Layer 1" viewBox="0 0 1024 1024" width="1024" height="1024"><defs>
|
||||
<style>
|
||||
.cls-1 {
|
||||
fill: url(#linear-gradient-2);
|
||||
}
|
||||
|
||||
.cls-2 {
|
||||
fill: #6366f1;
|
||||
}
|
||||
|
||||
.cls-3 {
|
||||
fill: #33d6c6;
|
||||
}
|
||||
|
||||
.cls-4 {
|
||||
fill: #a970ff;
|
||||
}
|
||||
|
||||
.cls-5 {
|
||||
fill: url(#linear-gradient);
|
||||
}
|
||||
</style>
|
||||
<linearGradient id="linear-gradient" x1="312.56" y1="897.19" x2="312.56" y2="2" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#a970ff"></stop>
|
||||
<stop offset="1" stop-color="#6366f1"></stop>
|
||||
</linearGradient>
|
||||
<linearGradient id="linear-gradient-2" x1="711.44" y1="126.81" x2="711.44" y2="1022" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#6366f1"></stop>
|
||||
<stop offset="1" stop-color="#33d6c6"></stop>
|
||||
</linearGradient>
|
||||
</defs><path class="cls-5" d="m512,2c-53.6,0-98.47,38.16-108.84,88.73-75.07,19.29-143.32,58.26-198.74,113.68-55.42,55.42-94.39,123.68-113.68,198.74-50.58,10.38-88.73,55.24-88.73,108.84s38.16,98.47,88.73,108.84c13.62,53.14,37.03,102.64,69.77,147.47,37.14,50.86,85.83,93.86,140.81,124.33,5.54,3.08,11.56,4.54,17.48,4.54,12.7,0,25.02-6.72,31.61-18.61,9.68-17.44,3.38-39.42-14.07-49.09-85.32-47.32-147.22-125.71-173.38-218.21,36.3-18.29,61.27-55.92,61.27-99.28s-24.94-80.97-61.23-99.27c34.22-119.96,129.79-215.52,249.74-249.74,18.3,36.29,55.92,61.23,99.27,61.23,61.27,0,111.11-49.84,111.11-111.11S573.27,2,512,2ZM113.11,473.11c21.44,0,38.89,17.44,38.89,38.89,0,20.1-15.32,36.69-34.91,38.69h-.01c-.18.02-.36.03-.53.04-1.13.1-2.28.16-3.43.16-21.44,0-38.89-17.44-38.89-38.89s17.44-38.89,38.89-38.89Zm398.89-321.11c-21.44,0-38.89-17.44-38.89-38.89s17.44-38.89,38.89-38.89,38.89,17.44,38.89,38.89-17.44,38.89-38.89,38.89Z"></path><path class="cls-1" d="m933.27,403.16c-13.62-53.14-37.03-102.64-69.77-147.47-37.14-50.86-85.83-93.86-140.81-124.33-17.44-9.68-39.42-3.38-49.09,14.07-9.68,17.44-3.38,39.42,14.07,49.09,85.32,47.32,147.22,125.71,173.38,218.21-36.3,18.29-61.27,55.92-61.27,99.28s24.94,80.97,61.23,99.27c-34.22,119.96-129.79,215.52-249.74,249.74-18.3-36.29-55.92-61.23-99.27-61.23-61.27,0-111.11,49.84-111.11,111.11s49.84,111.11,111.11,111.11c53.6,0,98.47-38.16,108.84-88.73,75.07-19.29,143.32-58.26,198.74-113.68,55.42-55.42,94.39-123.68,113.68-198.74,50.58-10.38,88.73-55.24,88.73-108.84s-38.16-98.47-88.73-108.84Zm-421.27,546.62c-21.44,0-38.89-17.44-38.89-38.89s17.44-38.89,38.89-38.89,38.89,17.44,38.89,38.89-17.44,38.89-38.89,38.89Zm398.89-398.89c-21.44,0-38.89-17.44-38.89-38.89,0-20.03,15.22-36.58,34.72-38.67.38-.04.77-.08,1.16-.11h.02c.99-.08,1.99-.11,2.99-.11,21.44,0,38.89,17.44,38.89,38.89s-17.44,38.89-38.89,38.89Z"></path><g>
|
||||
<path class="cls-4" d="m735.02,517.56c-153.82,30.87-186.59,63.64-217.46,217.46-1.22,6.08-9.9,6.08-11.12,0-30.87-153.82-63.64-186.59-217.46-217.46-6.08-1.22-6.08-9.9,0-11.12,153.82-30.87,186.59-63.64,217.46-217.46,1.22-6.08,9.9-6.08,11.12,0,30.87,153.82,63.64,186.59,217.46,217.46,6.08,1.22,6.08,9.9,0,11.12Z"></path>
|
||||
<path class="cls-2" d="m434.06,665.85c-51.08,10.25-61.96,21.13-72.22,72.22-.41,2.02-3.29,2.02-3.69,0-10.25-51.08-21.13-61.96-72.22-72.22-2.02-.41-2.02-3.29,0-3.69,51.08-10.25,61.96-21.13,72.22-72.22.41-2.02,3.29-2.02,3.69,0,10.25,51.08,21.13,61.96,72.22,72.22,2.02.41,2.02,3.29,0,3.69Z"></path>
|
||||
<path class="cls-3" d="m738.07,361.84c-51.08,10.25-61.96,21.13-72.22,72.22-.41,2.02-3.29,2.02-3.69,0-10.25-51.08-21.13-61.96-72.22-72.22-2.02-.41-2.02-3.29,0-3.69,51.08-10.25,61.96-21.13,72.22-72.22.41-2.02,3.29-2.02,3.69,0,10.25,51.08,21.13,61.96,72.22,72.22,2.02.41,2.02,3.29,0,3.69Z"></path>
|
||||
</g></svg>
|
||||
|
After Width: | Height: | Size: 3.6 KiB |
@@ -2,6 +2,12 @@
|
||||
|
||||
declare module "*.vue" {
|
||||
import type { DefineComponent } from "vue";
|
||||
const component: DefineComponent<{}, {}, any>;
|
||||
// `Record<string, never>` instead of `{}` — same intent (no defined props
|
||||
// to constrain the SFC default-export typing) but doesn't trip
|
||||
// typescript-eslint's no-empty-object-type rule.
|
||||
const component: DefineComponent<Record<string, never>, Record<string, never>, unknown>;
|
||||
export default component;
|
||||
}
|
||||
|
||||
// Side-effect CSS imports (cssInjectedByJsPlugin handles runtime injection).
|
||||
declare module "*.css";
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
// Single ComfyUI About-panel badge for the extension. Other extensions
|
||||
// (rgthree-comfy, EasyUse, ComfyUI-Manager, …) follow the convention
|
||||
// "<extension name> v<version>" with monochrome icons that pick up the
|
||||
// panel's text color. We mirror that so the row blends with surrounding
|
||||
// badges instead of standing out as a colored sticker.
|
||||
//
|
||||
// The icon slot accepts a CSS class name; PrimeIcons (`pi pi-github`)
|
||||
// work out of the box but here we register a custom rule that uses our
|
||||
// favicon SVG as a CSS mask — `background-color: currentColor` then
|
||||
// fills it with whatever color the panel applies to the icon, so the
|
||||
// shape stays branded but the tone matches surrounding badges.
|
||||
//
|
||||
// Docs: https://docs.comfy.org/custom-nodes/js/javascript_about_panel_badges
|
||||
|
||||
// `?raw` returns the file contents as a string — Vite inlines into the
|
||||
// JS chunk so we don't depend on a runtime asset URL. Stays self-contained.
|
||||
import wpLogoSvg from "../components/shared/wp-logo.svg?raw";
|
||||
import pkg from "../../package.json" with { type: "json" };
|
||||
|
||||
export interface AboutPageBadge {
|
||||
label: string;
|
||||
url: string;
|
||||
/** CSS class — PrimeIcons or our own. */
|
||||
icon: string;
|
||||
}
|
||||
|
||||
const REPO = "https://github.com/DumiFlex/ComfyUI-WildcardPipeline";
|
||||
const ICON_CLASS = "wp-about-badge-icon";
|
||||
let styleInjected = false;
|
||||
|
||||
/**
|
||||
* Inject the icon-class rule once. Idempotent — safe to call from main
|
||||
* boot even though we only register a single badge today.
|
||||
*/
|
||||
function ensureIconStyle(): void {
|
||||
if (styleInjected || typeof document === "undefined") return;
|
||||
styleInjected = true;
|
||||
const dataUri = `url("data:image/svg+xml;utf8,${encodeURIComponent(wpLogoSvg)}")`;
|
||||
const style = document.createElement("style");
|
||||
style.id = "wp-about-badge-style";
|
||||
// mask-image renders the SVG silhouette only; background-color fills
|
||||
// it with currentColor so the icon picks up the panel's badge text
|
||||
// color and matches the surrounding monochrome icons (pi pi-github,
|
||||
// pi pi-discord, etc.). 14px square matches PrimeIcons sizing on the
|
||||
// About panel badges. -webkit-* prefixed properties cover Safari /
|
||||
// older WebKit which haven't standardized the unprefixed names yet.
|
||||
style.textContent = `
|
||||
.${ICON_CLASS} {
|
||||
display: inline-block;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
background-color: currentColor;
|
||||
mask-image: ${dataUri};
|
||||
-webkit-mask-image: ${dataUri};
|
||||
mask-size: contain;
|
||||
-webkit-mask-size: contain;
|
||||
mask-repeat: no-repeat;
|
||||
-webkit-mask-repeat: no-repeat;
|
||||
mask-position: center;
|
||||
-webkit-mask-position: center;
|
||||
vertical-align: -2px;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
ensureIconStyle();
|
||||
|
||||
export const ABOUT_BADGES: AboutPageBadge[] = [
|
||||
{
|
||||
label: `Wildcard Pipeline v${pkg.version}`,
|
||||
url: REPO,
|
||||
icon: ICON_CLASS,
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { scanConflicts } from "./conflicts";
|
||||
import type { ContextWidgetValue } from "../widgets/_shared";
|
||||
|
||||
const mod = (id: string, vars: string[]): ContextWidgetValue["modules"][number] => ({
|
||||
id, type: "fixed_values", enabled: true, meta: { name: "" },
|
||||
entries: vars.map((v) => ({ variable_name: v, value: "x" })),
|
||||
});
|
||||
|
||||
describe("scanConflicts", () => {
|
||||
it("flags shadows_upstream (info) when a module writes a var present upstream", () => {
|
||||
const value: ContextWidgetValue = { version: 1, modules: [mod("m1", ["style"])] };
|
||||
const out = scanConflicts(value, ["style"]);
|
||||
expect(out).toEqual([{ moduleId: "m1", variable: "style", type: "shadows_upstream", severity: "info" }]);
|
||||
});
|
||||
|
||||
it("flags duplicate_variable (warning) when two modules in same node both write the same var", () => {
|
||||
const value: ContextWidgetValue = { version: 1, modules: [mod("m1", ["style"]), mod("m2", ["style"])] };
|
||||
const out = scanConflicts(value, []);
|
||||
expect(out).toEqual([{ moduleId: "m2", variable: "style", type: "duplicate_variable", severity: "warning" }]);
|
||||
});
|
||||
|
||||
it("upstream shadow wins over in-node duplicate (mutually exclusive)", () => {
|
||||
const value: ContextWidgetValue = { version: 1, modules: [mod("m1", ["style"]), mod("m2", ["style"])] };
|
||||
const out = scanConflicts(value, ["style"]);
|
||||
expect(out.every((c) => c.type === "shadows_upstream")).toBe(true);
|
||||
expect(out).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("does not flag unrelated names — `sty` does not shadow `style`", () => {
|
||||
const value: ContextWidgetValue = { version: 1, modules: [mod("m1", ["sty"])] };
|
||||
expect(scanConflicts(value, ["style"])).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { ContextWidgetValue } from "../widgets/_shared";
|
||||
|
||||
// `shadows_upstream` — informational: this module overrides a value that
|
||||
// already exists upstream. Last-write-wins is the *intended* runtime
|
||||
// behavior, so this is a low-key hint, not an error.
|
||||
// `duplicate_variable` — warning: two enabled modules in the SAME node both
|
||||
// write the same name. Almost always a bug in this node's local config.
|
||||
// `missing_template_variable` — warning: assembler template references a
|
||||
// variable nothing upstream provides; it'll render literally as `$name`.
|
||||
export type ConflictType = "shadows_upstream" | "duplicate_variable" | "missing_template_variable";
|
||||
export type Severity = "info" | "warning" | "error";
|
||||
export interface Conflict {
|
||||
moduleId: string;
|
||||
variable: string;
|
||||
type: ConflictType;
|
||||
severity: Severity;
|
||||
}
|
||||
|
||||
export function scanConflicts(value: ContextWidgetValue, upstreamVars: string[]): Conflict[] {
|
||||
const upstream = new Set(upstreamVars);
|
||||
const written = new Set<string>();
|
||||
const out: Conflict[] = [];
|
||||
for (const m of value.modules) {
|
||||
if (!m.enabled) continue;
|
||||
for (const e of m.entries) {
|
||||
const name = e.variable_name.replace(/^\$/, "").trim();
|
||||
if (!name) continue;
|
||||
if (upstream.has(name)) {
|
||||
// Intended override — surface it but don't scream.
|
||||
out.push({ moduleId: m.id, variable: name, type: "shadows_upstream", severity: "info" });
|
||||
} else if (written.has(name)) {
|
||||
out.push({ moduleId: m.id, variable: name, type: "duplicate_variable", severity: "warning" });
|
||||
} else {
|
||||
written.add(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const TEMPLATE_VAR = /(?<!\$)\$([A-Za-z_][A-Za-z0-9_]*)/g;
|
||||
|
||||
export function scanTemplateConflicts(template: string, knownVars: string[]): Conflict[] {
|
||||
const known = new Set(knownVars);
|
||||
const out: Conflict[] = [];
|
||||
for (const m of template.matchAll(TEMPLATE_VAR)) {
|
||||
const v = m[1];
|
||||
if (!known.has(v)) {
|
||||
out.push({ moduleId: "", variable: v, type: "missing_template_variable", severity: "warning" });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
// Webfonts (Inter + JetBrains Mono, latin variable axis 100-900) injected
|
||||
// at extension load. We side-step `cssInjectedByJsPlugin`'s URL-inlining
|
||||
// behavior by wiring the @font-face rules in JS — `new URL(path,
|
||||
// import.meta.url)` keeps woff2 files as separate assets (NOT
|
||||
// base64-inlined into the JS chunk) and resolves the URL at runtime
|
||||
// relative to the loaded module's location, which ComfyUI serves at
|
||||
// `/extensions/wildcard-pipeline/assets/...`.
|
||||
//
|
||||
// Trade-off vs. fontsource @import:
|
||||
// - Single latin subset only — no cyrillic/greek/vietnamese coverage.
|
||||
// If a future module entry uses non-latin glyphs they'll fall back
|
||||
// to the system stack, which is acceptable for the dev tool surface.
|
||||
// - Variable axis covers every weight from 100..900 in one file each,
|
||||
// so we don't pay per-weight asset cost.
|
||||
|
||||
const interUrl = new URL("../assets/fonts/inter-latin.woff2", import.meta.url).href;
|
||||
const jetBrainsMonoUrl = new URL("../assets/fonts/jetbrains-mono-latin.woff2", import.meta.url).href;
|
||||
|
||||
let injected = false;
|
||||
|
||||
function ensureFontFaces(): void {
|
||||
if (injected || typeof document === "undefined") return;
|
||||
injected = true;
|
||||
const style = document.createElement("style");
|
||||
style.id = "wp-webfonts";
|
||||
// font-display: swap renders the system stack first then re-paints when
|
||||
// the woff2 lands — avoids invisible text during the 100–300ms fetch on
|
||||
// first load. font-weight `100 900` is the variable axis range.
|
||||
style.textContent = `
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 100 900;
|
||||
font-display: swap;
|
||||
src: url(${interUrl}) format('woff2-variations'),
|
||||
url(${interUrl}) format('woff2');
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'JetBrains Mono';
|
||||
font-style: normal;
|
||||
font-weight: 100 900;
|
||||
font-display: swap;
|
||||
src: url(${jetBrainsMonoUrl}) format('woff2-variations'),
|
||||
url(${jetBrainsMonoUrl}) format('woff2');
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
ensureFontFaces();
|
||||
@@ -0,0 +1,14 @@
|
||||
// Vanilla-JS event registry shared between main.ts (publisher) and the Vue
|
||||
// widget chunks (subscribers). Lives in its own module so importing it from
|
||||
// main.ts doesn't drag Vue into the entry bundle.
|
||||
|
||||
const graphLoadListeners = new Set<() => void>();
|
||||
|
||||
export function onGraphLoaded(listener: () => void): () => void {
|
||||
graphLoadListeners.add(listener);
|
||||
return () => graphLoadListeners.delete(listener);
|
||||
}
|
||||
|
||||
export function notifyGraphLoaded(): void {
|
||||
for (const fn of graphLoadListeners) fn();
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
collectUpstreamVariables,
|
||||
findDownstreamAssemblers,
|
||||
type LiteGraphLike,
|
||||
type LiteNodeLike,
|
||||
} from "./graph";
|
||||
|
||||
function fakeContextNode(id: number, vars: string[], upstreamLink?: number): LiteNodeLike {
|
||||
return {
|
||||
id,
|
||||
type: "WP_Context",
|
||||
inputs: [{ name: "upstream", link: upstreamLink ?? null }],
|
||||
outputs: [{ name: "context", links: [], type: "PIPELINE_CONTEXT" }],
|
||||
widgets: [{
|
||||
name: "modules",
|
||||
value: JSON.stringify({
|
||||
version: 1,
|
||||
modules: [{
|
||||
id: "a",
|
||||
type: "fixed_values",
|
||||
enabled: true,
|
||||
meta: { name: "" },
|
||||
entries: vars.map((v) => ({ variable_name: v, value: "x" })),
|
||||
}],
|
||||
}),
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
||||
describe("collectUpstreamVariables", () => {
|
||||
it("returns the upstream chain's writes only — never the starting node's own", () => {
|
||||
const a = fakeContextNode(1, ["style"]);
|
||||
const b = fakeContextNode(2, ["mood"], 100);
|
||||
const graph: LiteGraphLike = {
|
||||
_nodes: [a, b],
|
||||
links: { 100: { id: 100, origin_id: 1, origin_slot: 0, target_id: 2, target_slot: 0 } },
|
||||
getNodeById: (id) => ({ 1: a, 2: b } as Record<number, LiteNodeLike>)[id] ?? null,
|
||||
};
|
||||
// From b's POV: only "style" comes from upstream; b's own "mood" is not included.
|
||||
expect(collectUpstreamVariables(graph, b)).toEqual(["style"]);
|
||||
});
|
||||
|
||||
it("returns empty when no upstream link, regardless of own writes", () => {
|
||||
const a = fakeContextNode(1, ["style"]);
|
||||
const graph: LiteGraphLike = { _nodes: [a], links: {}, getNodeById: () => null };
|
||||
expect(collectUpstreamVariables(graph, a)).toEqual([]);
|
||||
});
|
||||
|
||||
it("walks via assembler's `context` input too — any PIPELINE_CONTEXT slot works", () => {
|
||||
const ctx = fakeContextNode(1, ["style", "subject"]);
|
||||
const asm: LiteNodeLike = {
|
||||
id: 2,
|
||||
type: "WP_PromptAssembler",
|
||||
// Note: input is named "context", not "upstream".
|
||||
inputs: [{ name: "context", link: 300 }],
|
||||
};
|
||||
const graph: LiteGraphLike = {
|
||||
_nodes: [ctx, asm],
|
||||
links: { 300: { id: 300, origin_id: 1, origin_slot: 0, target_id: 2, target_slot: 0 } },
|
||||
getNodeById: (id) => ({ 1: ctx, 2: asm } as Record<number, LiteNodeLike>)[id] ?? null,
|
||||
};
|
||||
expect(collectUpstreamVariables(graph, asm).sort()).toEqual(["style", "subject"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findDownstreamAssemblers", () => {
|
||||
it("returns assembler ids reachable via PIPELINE_CONTEXT outgoing links", () => {
|
||||
const ctx: LiteNodeLike = {
|
||||
id: 1,
|
||||
type: "WP_Context",
|
||||
outputs: [{ name: "context", links: [200], type: "PIPELINE_CONTEXT" }],
|
||||
};
|
||||
const asm: LiteNodeLike = { id: 2, type: "WP_PromptAssembler", inputs: [{ name: "context", link: 200 }] };
|
||||
const graph: LiteGraphLike = {
|
||||
_nodes: [ctx, asm],
|
||||
links: { 200: { id: 200, origin_id: 1, origin_slot: 0, target_id: 2, target_slot: 0 } },
|
||||
getNodeById: (id) => ({ 1: ctx, 2: asm } as Record<number, LiteNodeLike>)[id] ?? null,
|
||||
};
|
||||
expect(findDownstreamAssemblers(graph, ctx).map((n) => n.id)).toEqual([2]);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Subgraph boundary traversal ────────────────────────────────────────
|
||||
// The chain crosses litegraph subgraph boundaries via two proxy nodes:
|
||||
// - SubgraphInputNode (id = -10) — boundary inside the subgraph that
|
||||
// proxies external inputs.
|
||||
// - SubgraphOutputNode (id = -20) — boundary inside the subgraph that
|
||||
// proxies external outputs.
|
||||
// We model both shapes here and assert the walk doesn't get stuck on either.
|
||||
|
||||
describe("collectUpstreamVariables across subgraph boundaries", () => {
|
||||
it("steps OUT of a subgraph to reach an upstream Context in the parent graph", () => {
|
||||
// Layout:
|
||||
// root: ctx(1) ──[100]──▶ subNode(10)
|
||||
// inside subNode.subgraph: -10 ──[200]──▶ asm(2)
|
||||
// subNode's "ctx" input maps by name to the inputNode's "ctx" slot.
|
||||
const ctx = fakeContextNode(1, ["style"]);
|
||||
ctx.outputs = [{ name: "context", links: [100], type: "PIPELINE_CONTEXT" }];
|
||||
|
||||
const innerAsm: LiteNodeLike = {
|
||||
id: 2,
|
||||
type: "WP_PromptAssembler",
|
||||
inputs: [{ name: "context", link: 200 }],
|
||||
};
|
||||
const subgraph: LiteGraphLike = {
|
||||
id: "sg-1",
|
||||
_nodes: [innerAsm],
|
||||
links: { 200: { id: 200, origin_id: -10, origin_slot: 0, target_id: 2, target_slot: 0 } },
|
||||
getNodeById: (id) => ({ 2: innerAsm } as Record<number, LiteNodeLike>)[id] ?? null,
|
||||
inputNode: { id: -10, slots: [{ name: "ctx", linkIds: [200] }] },
|
||||
outputNode: { id: -20, slots: [] },
|
||||
};
|
||||
innerAsm.graph = subgraph;
|
||||
|
||||
const subNode: LiteNodeLike = {
|
||||
id: 10,
|
||||
type: "Subgraph",
|
||||
inputs: [{ name: "ctx", link: 100 }],
|
||||
outputs: [{ name: "ctx_out", links: [], type: "PIPELINE_CONTEXT" }],
|
||||
subgraph,
|
||||
isSubgraphNode: () => true,
|
||||
};
|
||||
const root: LiteGraphLike = {
|
||||
_nodes: [ctx, subNode],
|
||||
links: { 100: { id: 100, origin_id: 1, origin_slot: 0, target_id: 10, target_slot: 0 } },
|
||||
getNodeById: (id) => ({ 1: ctx, 10: subNode } as Record<number, LiteNodeLike>)[id] ?? null,
|
||||
};
|
||||
ctx.graph = root;
|
||||
subNode.graph = root;
|
||||
|
||||
expect(collectUpstreamVariables(root, innerAsm)).toEqual(["style"]);
|
||||
});
|
||||
|
||||
it("steps INTO a subgraph when the upstream Context lives inside one", () => {
|
||||
// Layout:
|
||||
// inside subgraph: ctx(1) ──[200]──▶ -20 (output proxy, slot "ctx_out")
|
||||
// root: subNode(10) ──[300]──▶ asm(2)
|
||||
const innerCtx = fakeContextNode(1, ["style"]);
|
||||
innerCtx.outputs = [{ name: "context", links: [200], type: "PIPELINE_CONTEXT" }];
|
||||
|
||||
const subgraph: LiteGraphLike = {
|
||||
id: "sg-1",
|
||||
_nodes: [innerCtx],
|
||||
links: { 200: { id: 200, origin_id: 1, origin_slot: 0, target_id: -20, target_slot: 0 } },
|
||||
getNodeById: (id) => ({ 1: innerCtx } as Record<number, LiteNodeLike>)[id] ?? null,
|
||||
inputNode: { id: -10, slots: [] },
|
||||
outputNode: { id: -20, slots: [{ name: "ctx_out", linkIds: [200] }] },
|
||||
};
|
||||
innerCtx.graph = subgraph;
|
||||
|
||||
const subNode: LiteNodeLike = {
|
||||
id: 10,
|
||||
type: "Subgraph",
|
||||
inputs: [],
|
||||
outputs: [{ name: "ctx_out", links: [300], type: "PIPELINE_CONTEXT" }],
|
||||
subgraph,
|
||||
isSubgraphNode: () => true,
|
||||
};
|
||||
const asm: LiteNodeLike = {
|
||||
id: 2,
|
||||
type: "WP_PromptAssembler",
|
||||
inputs: [{ name: "context", link: 300 }],
|
||||
};
|
||||
const root: LiteGraphLike = {
|
||||
_nodes: [subNode, asm],
|
||||
links: { 300: { id: 300, origin_id: 10, origin_slot: 0, target_id: 2, target_slot: 0 } },
|
||||
getNodeById: (id) => ({ 10: subNode, 2: asm } as Record<number, LiteNodeLike>)[id] ?? null,
|
||||
};
|
||||
subNode.graph = root;
|
||||
asm.graph = root;
|
||||
|
||||
expect(collectUpstreamVariables(root, asm)).toEqual(["style"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,403 @@
|
||||
import { parseWidgetJson, type ContextWidgetValue } from "../widgets/_shared";
|
||||
|
||||
// ── Subgraph boundary primer ────────────────────────────────────────────
|
||||
// ComfyUI subgraphs are nested LGraph instances attached to a parent node
|
||||
// via `node.subgraph`. They have boundary proxy nodes:
|
||||
// - SubgraphInputNode (id = -10) — sits inside the subgraph; inner nodes
|
||||
// wire INPUTs from it. Each slot maps by name to the parent SubgraphNode's
|
||||
// external input slot.
|
||||
// - SubgraphOutputNode (id = -20) — sits inside the subgraph; inner nodes
|
||||
// wire OUTPUTs into it. Each slot maps by name to the parent SubgraphNode's
|
||||
// external output slot.
|
||||
// - The IO nodes are NOT in `subgraph._nodes` — only on `subgraph.inputNode`
|
||||
// / `subgraph.outputNode`. Inner links can still reference them by id.
|
||||
// To traverse the PIPELINE_CONTEXT chain across boundaries we transparently
|
||||
// "step in" through SubgraphOutputNode and "step out" through SubgraphInputNode.
|
||||
|
||||
const SUBGRAPH_INPUT_NODE_ID = -10;
|
||||
const SUBGRAPH_OUTPUT_NODE_ID = -20;
|
||||
const BOUNDARY_RECURSION_LIMIT = 32;
|
||||
|
||||
export interface LiteLink {
|
||||
id: number;
|
||||
origin_id: number;
|
||||
origin_slot: number;
|
||||
target_id: number;
|
||||
target_slot: number;
|
||||
}
|
||||
|
||||
export interface SubgraphIOSlot {
|
||||
name: string;
|
||||
type?: string;
|
||||
/** Inner-graph link ids touching this slot. */
|
||||
linkIds?: number[];
|
||||
}
|
||||
|
||||
export interface SubgraphIONode {
|
||||
id: number;
|
||||
slots: SubgraphIOSlot[];
|
||||
}
|
||||
|
||||
export interface LiteNodeLike {
|
||||
id: number;
|
||||
type: string;
|
||||
inputs?: { name: string; link: number | null; type?: string }[];
|
||||
outputs?: { name: string; links?: number[] | null; type?: string }[];
|
||||
widgets?: { name: string; value: unknown }[];
|
||||
/** Inner LGraph for SubgraphNodes. Mirrors `node.subgraph` from litegraph. */
|
||||
subgraph?: LiteGraphLike;
|
||||
/** Litegraph SubgraphNode marker. Also detected via `subgraph != null`. */
|
||||
isSubgraphNode?(): boolean;
|
||||
/** Containing LGraph — set by litegraph on every node that's been added. */
|
||||
graph?: LiteGraphLike;
|
||||
}
|
||||
|
||||
/**
|
||||
* Litegraph LGraph surface we touch. Both root and subgraph instances satisfy
|
||||
* this. `id` is undefined on the root graph and a UUID on subgraphs.
|
||||
*/
|
||||
export interface LiteGraphLike {
|
||||
id?: string;
|
||||
_nodes: LiteNodeLike[];
|
||||
/** In current litegraph this is a Proxy over a Map; bracket access works. */
|
||||
links: Record<number, LiteLink>;
|
||||
getNodeById(id: number): LiteNodeLike | null;
|
||||
/** Boundary IO nodes — only populated on subgraph LGraphs. */
|
||||
inputNode?: SubgraphIONode;
|
||||
outputNode?: SubgraphIONode;
|
||||
}
|
||||
|
||||
function widgetValue(node: LiteNodeLike, name: string): string {
|
||||
const w = node.widgets?.find((x) => x.name === name);
|
||||
return typeof w?.value === "string" ? w.value : "";
|
||||
}
|
||||
|
||||
function moduleWrites(node: LiteNodeLike): string[] {
|
||||
const v = parseWidgetJson<ContextWidgetValue>(widgetValue(node, "modules"), { version: 1, modules: [] });
|
||||
const out: string[] = [];
|
||||
for (const m of v.modules) {
|
||||
if (!m.enabled) continue;
|
||||
for (const e of m.entries) {
|
||||
const name = e.variable_name.replace(/^\$/, "").trim();
|
||||
if (name) out.push(name);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function isSubgraphNode(n: LiteNodeLike): boolean {
|
||||
if (typeof n.isSubgraphNode === "function") {
|
||||
try { return n.isSubgraphNode() === true; } catch { /* fall through */ }
|
||||
}
|
||||
return n.subgraph != null;
|
||||
}
|
||||
|
||||
/** Containing LGraph — falls back to passed graph when litegraph hasn't set it. */
|
||||
function graphOf(node: LiteNodeLike, fallback: LiteGraphLike): LiteGraphLike {
|
||||
return node.graph ?? fallback;
|
||||
}
|
||||
|
||||
type SubgraphParent = { node: LiteNodeLike; graph: LiteGraphLike };
|
||||
|
||||
/**
|
||||
* Walk root → leaves once and record each subgraph's parent SubgraphNode +
|
||||
* the LGraph that contains it. Used to step OUT through SubgraphInputNode
|
||||
* boundaries: given a subgraph id, we know which outer node + graph to
|
||||
* resume traversal from.
|
||||
*/
|
||||
function buildSubgraphParents(root: LiteGraphLike): Map<string, SubgraphParent> {
|
||||
const out = new Map<string, SubgraphParent>();
|
||||
function descend(g: LiteGraphLike) {
|
||||
for (const n of g._nodes ?? []) {
|
||||
const sg = n.subgraph;
|
||||
if (sg && sg.id) {
|
||||
out.set(sg.id, { node: n, graph: g });
|
||||
descend(sg);
|
||||
}
|
||||
}
|
||||
}
|
||||
descend(root);
|
||||
return out;
|
||||
}
|
||||
|
||||
interface BoundaryStep {
|
||||
graph: LiteGraphLike;
|
||||
node: LiteNodeLike;
|
||||
/** Output slot index on `node` we just arrived through. */
|
||||
originSlot: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Step INTO a SubgraphNode: given the outer SubgraphNode and the output slot
|
||||
* we're walking against, find the inner producer feeding that output via the
|
||||
* SubgraphOutputNode slot of the same name.
|
||||
*/
|
||||
function stepIntoSubgraph(subNode: LiteNodeLike, originSlot: number): BoundaryStep | null {
|
||||
const out = subNode.outputs?.[originSlot];
|
||||
const subgraph = subNode.subgraph;
|
||||
const outputNode = subgraph?.outputNode;
|
||||
if (!out || !subgraph || !outputNode) return null;
|
||||
// Slot names match between the outer SubgraphNode and the inner
|
||||
// SubgraphOutputNode — that's how litegraph routes the boundary.
|
||||
const slot = outputNode.slots.find((s) => s.name === out.name);
|
||||
if (!slot || !slot.linkIds?.length) return null;
|
||||
// Take the first wired inner link. PIPELINE_CONTEXT is a single-source
|
||||
// chain so multi-write at this boundary would be a graph error anyway.
|
||||
const link = subgraph.links[slot.linkIds[0]];
|
||||
if (!link) return null;
|
||||
const inner = subgraph.getNodeById(link.origin_id);
|
||||
if (!inner) return null;
|
||||
return { graph: subgraph, node: inner, originSlot: link.origin_slot };
|
||||
}
|
||||
|
||||
/**
|
||||
* Step OUT through a SubgraphInputNode: given the inner LGraph and the
|
||||
* input-slot index on its inputNode that the link arrived from, resolve the
|
||||
* matching named input on the parent SubgraphNode and follow ITS link in
|
||||
* the parent graph.
|
||||
*/
|
||||
function stepOutOfSubgraph(
|
||||
innerGraph: LiteGraphLike,
|
||||
inputNodeSlot: number,
|
||||
parents: Map<string, SubgraphParent>,
|
||||
): BoundaryStep | null {
|
||||
if (!innerGraph.id) return null;
|
||||
const parent = parents.get(innerGraph.id);
|
||||
const inputNode = innerGraph.inputNode;
|
||||
if (!parent || !inputNode) return null;
|
||||
const innerSlot = inputNode.slots[inputNodeSlot];
|
||||
if (!innerSlot) return null;
|
||||
// Find the parent SubgraphNode's input slot with the same name.
|
||||
const outerSlot = parent.node.inputs?.find((s) => s.name === innerSlot.name);
|
||||
if (!outerSlot || outerSlot.link == null) return null;
|
||||
const outerLink = parent.graph.links[outerSlot.link];
|
||||
if (!outerLink) return null;
|
||||
const outer = parent.graph.getNodeById(outerLink.origin_id);
|
||||
if (!outer) return null;
|
||||
return { graph: parent.graph, node: outer, originSlot: outerLink.origin_slot };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a (graph, node, originSlot) tuple through any number of stacked
|
||||
* subgraph boundaries until it points at a real producer (not an IO proxy
|
||||
* and not a SubgraphNode wrapper). Returns null if any hop fails.
|
||||
*/
|
||||
function resolveThroughBoundaries(
|
||||
start: BoundaryStep,
|
||||
parents: Map<string, SubgraphParent>,
|
||||
): BoundaryStep | null {
|
||||
let cur: BoundaryStep | null = start;
|
||||
for (let i = 0; cur && i < BOUNDARY_RECURSION_LIMIT; i++) {
|
||||
if (cur.node.id === SUBGRAPH_INPUT_NODE_ID) {
|
||||
cur = stepOutOfSubgraph(cur.graph, cur.originSlot, parents);
|
||||
continue;
|
||||
}
|
||||
if (isSubgraphNode(cur.node)) {
|
||||
cur = stepIntoSubgraph(cur.node, cur.originSlot);
|
||||
continue;
|
||||
}
|
||||
return cur;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the upstream PIPELINE_CONTEXT producer for `node`, transparently
|
||||
* stepping through subgraph boundaries. Returns the node WITH its containing
|
||||
* graph since the chain can leave the starting subgraph.
|
||||
*/
|
||||
function pipelineUpstreamOf(
|
||||
node: LiteNodeLike,
|
||||
graph: LiteGraphLike,
|
||||
parents: Map<string, SubgraphParent>,
|
||||
): { graph: LiteGraphLike; node: LiteNodeLike } | null {
|
||||
for (const slot of node.inputs ?? []) {
|
||||
if (slot.link == null) continue;
|
||||
const link = graph.links[slot.link];
|
||||
if (!link) continue;
|
||||
|
||||
let step: BoundaryStep | null;
|
||||
if (link.origin_id === SUBGRAPH_INPUT_NODE_ID) {
|
||||
// Boundary at this very link — step OUT before we can even read origin.
|
||||
step = stepOutOfSubgraph(graph, link.origin_slot, parents);
|
||||
} else {
|
||||
const origin = graph.getNodeById(link.origin_id);
|
||||
if (!origin) continue;
|
||||
step = { graph, node: origin, originSlot: link.origin_slot };
|
||||
}
|
||||
|
||||
const resolved = step ? resolveThroughBoundaries(step, parents) : null;
|
||||
if (!resolved) continue;
|
||||
|
||||
// Confirm we landed on a PIPELINE_CONTEXT producer slot.
|
||||
const out = resolved.node.outputs?.[resolved.originSlot];
|
||||
if (out?.type === "PIPELINE_CONTEXT") return { graph: resolved.graph, node: resolved.node };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk upstream from `node` collecting variable names written by every
|
||||
* upstream WP_Context. Does NOT include the starting node's own writes — the
|
||||
* caller (a Context widget rendering its own conflict UI) wants to know what
|
||||
* is coming IN from upstream so it can compare against its own writes.
|
||||
*
|
||||
* Crosses subgraph boundaries transparently: if the upstream Context lives
|
||||
* outside a subgraph the starting node is in, we step OUT through the
|
||||
* SubgraphInputNode proxy; if the upstream chain crosses INTO a subgraph,
|
||||
* we step IN through the SubgraphOutputNode proxy.
|
||||
*
|
||||
* `rootGraph` should be `app.graph` (top-level). The starting node may live
|
||||
* anywhere in the nested graph tree.
|
||||
*/
|
||||
export function collectUpstreamVariables(rootGraph: LiteGraphLike, node: LiteNodeLike): string[] {
|
||||
const parents = buildSubgraphParents(rootGraph);
|
||||
const seen = new Set<string>([locator(graphOf(node, rootGraph), node)]);
|
||||
const vars = new Set<string>();
|
||||
let cur = pipelineUpstreamOf(node, graphOf(node, rootGraph), parents);
|
||||
while (cur && !seen.has(locator(cur.graph, cur.node))) {
|
||||
seen.add(locator(cur.graph, cur.node));
|
||||
if (cur.node.type === "WP_Context") moduleWrites(cur.node).forEach((v) => vars.add(v));
|
||||
cur = pipelineUpstreamOf(cur.node, cur.graph, parents);
|
||||
}
|
||||
return Array.from(vars);
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk upstream collecting (variable → value) pairs. Last-write-wins matches
|
||||
* runtime semantics: chain order is upstream-first → downstream-last; we
|
||||
* reverse the walk so downstream values override upstream ones in the map.
|
||||
*
|
||||
* Crosses subgraph boundaries identically to `collectUpstreamVariables`.
|
||||
*/
|
||||
export function collectUpstreamValues(rootGraph: LiteGraphLike, node: LiteNodeLike): Record<string, string> {
|
||||
const parents = buildSubgraphParents(rootGraph);
|
||||
const seen = new Set<string>([locator(graphOf(node, rootGraph), node)]);
|
||||
const chain: LiteNodeLike[] = [];
|
||||
let cur = pipelineUpstreamOf(node, graphOf(node, rootGraph), parents);
|
||||
while (cur && !seen.has(locator(cur.graph, cur.node))) {
|
||||
seen.add(locator(cur.graph, cur.node));
|
||||
chain.push(cur.node);
|
||||
cur = pipelineUpstreamOf(cur.node, cur.graph, parents);
|
||||
}
|
||||
const out: Record<string, string> = {};
|
||||
for (let i = chain.length - 1; i >= 0; i--) {
|
||||
const n = chain[i];
|
||||
if (n.type !== "WP_Context") continue;
|
||||
const v = parseWidgetJson<ContextWidgetValue>(widgetValue(n, "modules"), { version: 1, modules: [] });
|
||||
for (const m of v.modules) {
|
||||
if (!m.enabled) continue;
|
||||
for (const e of m.entries) {
|
||||
const name = e.variable_name.replace(/^\$/, "").trim();
|
||||
if (!name) continue;
|
||||
out[name] = e.value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Locator — node ids are unique only within a single LGraph; nested
|
||||
* subgraphs reuse the same id space. Pair `(graph.id, node.id)` to dedupe
|
||||
* across the whole nested tree.
|
||||
*/
|
||||
function locator(graph: LiteGraphLike, node: LiteNodeLike): string {
|
||||
return `${graph.id ?? "root"}:${node.id}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* BFS downstream from `node` along PIPELINE_CONTEXT edges, returning every
|
||||
* WP_PromptAssembler in reach. Walks through subgraph boundaries: a
|
||||
* SubgraphNode in the chain is unwrapped by following its inputNode slot
|
||||
* inside; an outgoing link to the SubgraphOutputNode (target_id === -20)
|
||||
* resumes from the parent SubgraphNode's matching named output in the
|
||||
* parent graph.
|
||||
*/
|
||||
export function findDownstreamAssemblers(rootGraph: LiteGraphLike, node: LiteNodeLike): LiteNodeLike[] {
|
||||
const parents = buildSubgraphParents(rootGraph);
|
||||
const out: LiteNodeLike[] = [];
|
||||
const seen = new Set<string>([locator(graphOf(node, rootGraph), node)]);
|
||||
const queue: { graph: LiteGraphLike; node: LiteNodeLike }[] = [
|
||||
{ graph: graphOf(node, rootGraph), node },
|
||||
];
|
||||
|
||||
while (queue.length) {
|
||||
const cur = queue.shift();
|
||||
if (!cur) break;
|
||||
for (const o of cur.node.outputs ?? []) {
|
||||
if (o.type !== "PIPELINE_CONTEXT") continue;
|
||||
for (const linkId of o.links ?? []) {
|
||||
const link = cur.graph.links[linkId];
|
||||
if (!link) continue;
|
||||
const stepped = downstreamStep(cur.graph, link, parents);
|
||||
if (!stepped) continue;
|
||||
const key = locator(stepped.graph, stepped.node);
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
if (stepped.node.type === "WP_PromptAssembler") out.push(stepped.node);
|
||||
else queue.push({ graph: stepped.graph, node: stepped.node });
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a downstream link target through any subgraph boundaries:
|
||||
* - target_id === -20: link enters the SubgraphOutputNode → resume from
|
||||
* the parent SubgraphNode's matching output in the parent graph.
|
||||
* - target is a SubgraphNode: link enters via SubgraphInputNode proxy →
|
||||
* fan out to each inner consumer (we pick the first; downstream walk
|
||||
* recurses anyway via the BFS queue).
|
||||
*/
|
||||
function downstreamStep(
|
||||
graph: LiteGraphLike,
|
||||
link: LiteLink,
|
||||
parents: Map<string, SubgraphParent>,
|
||||
): { graph: LiteGraphLike; node: LiteNodeLike } | null {
|
||||
if (link.target_id === SUBGRAPH_OUTPUT_NODE_ID) {
|
||||
// Find which output of the parent SubgraphNode this proxies, then resume.
|
||||
const innerSlot = graph.outputNode?.slots[link.target_slot];
|
||||
if (!innerSlot || !graph.id) return null;
|
||||
const parent = parents.get(graph.id);
|
||||
if (!parent) return null;
|
||||
return { graph: parent.graph, node: parent.node };
|
||||
}
|
||||
const target = graph.getNodeById(link.target_id);
|
||||
if (!target) return null;
|
||||
if (isSubgraphNode(target)) {
|
||||
// Step into subgraph — find SubgraphInputNode slot matching the input
|
||||
// slot's name and follow its first inner link.
|
||||
const targetInputName = target.inputs?.[link.target_slot]?.name;
|
||||
const inputNode = target.subgraph?.inputNode;
|
||||
if (!targetInputName || !target.subgraph || !inputNode) return null;
|
||||
const slot = inputNode.slots.find((s) => s.name === targetInputName);
|
||||
if (!slot || !slot.linkIds?.length) return null;
|
||||
const innerLink = target.subgraph.links[slot.linkIds[0]];
|
||||
if (!innerLink) return null;
|
||||
const inner = target.subgraph.getNodeById(innerLink.target_id);
|
||||
if (!inner) return null;
|
||||
return { graph: target.subgraph, node: inner };
|
||||
}
|
||||
return { graph, node: target };
|
||||
}
|
||||
|
||||
/**
|
||||
* Yield every node in `rootGraph` and any nested subgraph, paired with the
|
||||
* LGraph that contains it. Use for graph-wide scans (e.g. pre-run
|
||||
* validation finding every WP_PromptAssembler regardless of nesting depth).
|
||||
*/
|
||||
export function* walkAllNodes(
|
||||
rootGraph: LiteGraphLike,
|
||||
): Generator<{ graph: LiteGraphLike; node: LiteNodeLike }> {
|
||||
const stack: LiteGraphLike[] = [rootGraph];
|
||||
while (stack.length) {
|
||||
const g = stack.pop();
|
||||
if (!g) break;
|
||||
for (const n of g._nodes ?? []) {
|
||||
yield { graph: g, node: n };
|
||||
if (n.subgraph) stack.push(n.subgraph);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { ref, type Ref, onScopeDispose } from "vue";
|
||||
import { onGraphLoaded } from "./graph-events";
|
||||
|
||||
export { notifyGraphLoaded } from "./graph-events";
|
||||
|
||||
/**
|
||||
* Re-evaluate `compute` whenever the host node's input/output connections
|
||||
* change. Returns a reactive ref that mirrors the latest computation result.
|
||||
*
|
||||
* LiteGraph dispatches `onConnectionsChange(type, slot, connected, link, slot)`
|
||||
* on each affected node when a wire is added/removed. We chain onto whatever
|
||||
* was already there so other extensions keep working. We also re-run on a
|
||||
* coarse polling interval to catch upstream-chain mutations that don't fire
|
||||
* the local node's callback (e.g. a wire added two nodes upstream).
|
||||
*
|
||||
* Workflow-load races: the snapshot computed at first mount sees upstream
|
||||
* widgets in their pre-restore state. We subscribe to a global "graph just
|
||||
* fully loaded" signal (fired from main.ts via afterConfigureGraph) so every
|
||||
* reactive widget re-syncs the moment ComfyUI finishes restoring values —
|
||||
* no flash of stale state.
|
||||
*/
|
||||
export interface ConnectableNode {
|
||||
onConnectionsChange?: (...args: unknown[]) => void;
|
||||
}
|
||||
|
||||
export function reactiveFromGraph<T>(
|
||||
node: ConnectableNode,
|
||||
compute: () => T,
|
||||
equals: (a: T, b: T) => boolean = Object.is,
|
||||
pollMs = 400,
|
||||
): Ref<T> {
|
||||
const state = ref(compute()) as Ref<T>;
|
||||
|
||||
function refresh() {
|
||||
const next = compute();
|
||||
if (!equals(state.value, next)) state.value = next;
|
||||
}
|
||||
|
||||
const orig = node.onConnectionsChange;
|
||||
node.onConnectionsChange = function (...args: unknown[]) {
|
||||
orig?.apply(this, args);
|
||||
refresh();
|
||||
};
|
||||
|
||||
// Polling fallback for upstream-chain edits that don't fire on this node.
|
||||
const interval = window.setInterval(refresh, pollMs);
|
||||
|
||||
// Re-sync on workflow load. Several ticks because ComfyUI restores widget
|
||||
// values asynchronously through cascading callbacks; a single refresh can
|
||||
// still catch us mid-cascade. requestAnimationFrame chain settles within
|
||||
// two frames in practice — cheap insurance against a late restore.
|
||||
const unsubscribe = onGraphLoaded(() => {
|
||||
refresh();
|
||||
requestAnimationFrame(() => {
|
||||
refresh();
|
||||
requestAnimationFrame(refresh);
|
||||
});
|
||||
});
|
||||
|
||||
onScopeDispose(() => {
|
||||
window.clearInterval(interval);
|
||||
unsubscribe();
|
||||
node.onConnectionsChange = orig;
|
||||
});
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
/** Cheap structural equality for string[] (order matters). */
|
||||
export function stringArrayEqual(a: string[], b: string[]): boolean {
|
||||
if (a.length !== b.length) return false;
|
||||
for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
// Settings registered into ComfyUI's settings panel via
|
||||
// app.registerExtension({ settings: [...] }) — see
|
||||
// https://docs.comfy.org/custom-nodes/js/javascript_settings
|
||||
//
|
||||
// Two accessibility toggles, both defaulting to "auto" so they respect
|
||||
// OS-level prefers-reduced-motion / prefers-contrast media queries until
|
||||
// the user overrides them. Each setting writes a class onto <body>; CSS
|
||||
// targets `.wp-*` descendants of that class to apply or strip the
|
||||
// matching style.
|
||||
|
||||
// Side-effect import — owns the a11y CSS chunk so cssInjectedByJsPlugin
|
||||
// can attach the runtime injection to a JS module. A bare CSS import
|
||||
// from main.ts emits a chunk with no JS owner and the plugin warns.
|
||||
import "../components/shared/a11y.css";
|
||||
|
||||
export type A11yMode = "auto" | "on" | "off";
|
||||
|
||||
export interface ComfySetting {
|
||||
id: string;
|
||||
name: string;
|
||||
type: "boolean" | "text" | "number" | "slider" | "combo" | "color" | "image" | "hidden";
|
||||
defaultValue: unknown;
|
||||
tooltip?: string;
|
||||
options?: Array<{ text: string; value: string }>;
|
||||
category?: string[];
|
||||
onChange?: (newVal: unknown, oldVal: unknown) => void;
|
||||
}
|
||||
|
||||
const SETTING_ID_REDUCE_MOTION = "wildcardPipeline.a11y.reduceMotion";
|
||||
const SETTING_ID_HIGH_CONTRAST = "wildcardPipeline.a11y.contrast";
|
||||
|
||||
const MOTION_OPTIONS = [
|
||||
{ text: "Match system (prefers-reduced-motion)", value: "auto" },
|
||||
{ text: "Always reduce", value: "on" },
|
||||
{ text: "Always allow", value: "off" },
|
||||
];
|
||||
|
||||
const CONTRAST_OPTIONS = [
|
||||
{ text: "Match system (prefers-contrast)", value: "auto" },
|
||||
{ text: "High contrast", value: "on" },
|
||||
{ text: "Standard", value: "off" },
|
||||
];
|
||||
|
||||
interface ExtensionManager {
|
||||
setting?: { get(id: string): unknown };
|
||||
}
|
||||
interface AppLike {
|
||||
extensionManager?: ExtensionManager;
|
||||
}
|
||||
|
||||
// Module-state holds the current values directly. Updated synchronously
|
||||
// by onChange callbacks so the marker classes flip without waiting for
|
||||
// extensionManager.setting.get() to reflect the new value (it can lag
|
||||
// the onChange fire by a tick, which made settings only "stick" after a
|
||||
// hard refresh in the live UI).
|
||||
const state: { reduceMotion: A11yMode; contrast: A11yMode } = {
|
||||
reduceMotion: "auto",
|
||||
contrast: "auto",
|
||||
};
|
||||
|
||||
function asMode(v: unknown, fallback: A11yMode): A11yMode {
|
||||
return v === "on" || v === "off" || v === "auto" ? v : fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply current state + matchMedia to the body marker classes. Pure read
|
||||
* of the in-memory `state` map — no async layer. CSS keys off the markers:
|
||||
* .wp-a11y-no-motion .wp-* { transition-duration: 0 !important; }
|
||||
* .wp-a11y-high-contrast { --wp-border: ...; }
|
||||
*/
|
||||
function syncMarkers(): void {
|
||||
const reduceMotion = state.reduceMotion === "on"
|
||||
|| (state.reduceMotion === "auto" && window.matchMedia?.("(prefers-reduced-motion: reduce)").matches);
|
||||
const highContrast = state.contrast === "on"
|
||||
|| (state.contrast === "auto" && window.matchMedia?.("(prefers-contrast: more)").matches);
|
||||
|
||||
document.body.classList.toggle("wp-a11y-no-motion", !!reduceMotion);
|
||||
document.body.classList.toggle("wp-a11y-high-contrast", !!highContrast);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read both settings from extensionManager (used for initial sync at boot)
|
||||
* and re-apply markers. After this, `onChange` callbacks own state updates
|
||||
* directly via their newVal arg.
|
||||
*/
|
||||
export function applyA11yClasses(app: AppLike): void {
|
||||
state.reduceMotion = asMode(app.extensionManager?.setting?.get(SETTING_ID_REDUCE_MOTION), "auto");
|
||||
state.contrast = asMode(app.extensionManager?.setting?.get(SETTING_ID_HIGH_CONTRAST), "auto");
|
||||
syncMarkers();
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire matchMedia listeners so "auto" mode reflects OS-level changes
|
||||
* without requiring a page reload (e.g. user toggles "Reduce motion" in
|
||||
* macOS System Settings while ComfyUI is open).
|
||||
*/
|
||||
export function watchA11ySystemPrefs(): () => void {
|
||||
const motionMQ = window.matchMedia?.("(prefers-reduced-motion: reduce)");
|
||||
const contrastMQ = window.matchMedia?.("(prefers-contrast: more)");
|
||||
const handler = () => syncMarkers();
|
||||
motionMQ?.addEventListener?.("change", handler);
|
||||
contrastMQ?.addEventListener?.("change", handler);
|
||||
return () => {
|
||||
motionMQ?.removeEventListener?.("change", handler);
|
||||
contrastMQ?.removeEventListener?.("change", handler);
|
||||
};
|
||||
}
|
||||
|
||||
export function buildSettings(_app: AppLike): ComfySetting[] {
|
||||
return [
|
||||
{
|
||||
id: SETTING_ID_REDUCE_MOTION,
|
||||
name: "Reduce motion",
|
||||
type: "combo",
|
||||
options: MOTION_OPTIONS,
|
||||
defaultValue: "auto",
|
||||
tooltip:
|
||||
"Disables Wildcard Pipeline animations and transitions. " +
|
||||
"Match system honors the OS prefers-reduced-motion setting.",
|
||||
category: ["Wildcard Pipeline", "Accessibility", "Reduce motion"],
|
||||
// Use newVal directly — extensionManager.setting.get() can lag the
|
||||
// onChange fire by a tick, which delayed the marker swap until a
|
||||
// page reload in the live UI.
|
||||
onChange: (newVal) => {
|
||||
state.reduceMotion = asMode(newVal, "auto");
|
||||
syncMarkers();
|
||||
},
|
||||
},
|
||||
{
|
||||
id: SETTING_ID_HIGH_CONTRAST,
|
||||
name: "Contrast",
|
||||
type: "combo",
|
||||
options: CONTRAST_OPTIONS,
|
||||
defaultValue: "auto",
|
||||
tooltip:
|
||||
"Bumps border + text contrast on Wildcard Pipeline widgets. " +
|
||||
"Match system honors the OS prefers-contrast setting.",
|
||||
category: ["Wildcard Pipeline", "Accessibility", "Contrast"],
|
||||
onChange: (newVal) => {
|
||||
state.contrast = asMode(newVal, "auto");
|
||||
syncMarkers();
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
import { walkAllNodes, collectUpstreamVariables, type LiteGraphLike, type LiteNodeLike } from "./graph";
|
||||
import { scanConflicts, scanTemplateConflicts, type Conflict, type Severity } from "./conflicts";
|
||||
import { parseWidgetJson, type ContextWidgetValue } from "../widgets/_shared";
|
||||
import { onGraphLoaded } from "./graph-events";
|
||||
|
||||
// ── Subgraph conflict badge ────────────────────────────────────────────
|
||||
// Users can drop our nodes inside a subgraph. From the parent graph, the
|
||||
// SubgraphNode is opaque — opening it is the only way to spot a problem.
|
||||
// We push an LGraphBadge onto `node.badges` for every SubgraphNode that
|
||||
// contains WP_Context or WP_PromptAssembler descendants and surface the
|
||||
// worst severity found in their conflict scan.
|
||||
//
|
||||
// Why LGraphBadge and not onDrawForeground:
|
||||
// - Badges render ABOVE the title bar (drawBadges places them at
|
||||
// y = -(NODE_TITLE_HEIGHT + gap) - height), so they never overlap the
|
||||
// built-in enter_subgraph button which lives INSIDE the title bar.
|
||||
// - Built-in stacking + positioning handles multi-badge cases for free
|
||||
// if other extensions also want to badge the same node.
|
||||
|
||||
interface LGraphBadgeOpts {
|
||||
text?: string;
|
||||
fgColor?: string;
|
||||
bgColor?: string;
|
||||
fontSize?: number;
|
||||
padding?: number;
|
||||
height?: number;
|
||||
cornerRadius?: number;
|
||||
xOffset?: number;
|
||||
yOffset?: number;
|
||||
}
|
||||
|
||||
interface LGraphBadgeInstance {
|
||||
text: string;
|
||||
fgColor: string;
|
||||
bgColor: string;
|
||||
}
|
||||
|
||||
interface LGraphBadgeCtor {
|
||||
new (opts: LGraphBadgeOpts): LGraphBadgeInstance;
|
||||
}
|
||||
|
||||
/** ComfyUI exposes the badge class on window via useGlobalLitegraph(). */
|
||||
function getBadgeCtor(): LGraphBadgeCtor | null {
|
||||
return (window as unknown as { LGraphBadge?: LGraphBadgeCtor }).LGraphBadge ?? null;
|
||||
}
|
||||
|
||||
type SubgraphNodeLike = LiteNodeLike & {
|
||||
size?: [number, number] | number[];
|
||||
badges?: unknown[];
|
||||
setDirtyCanvas?: (foreground: boolean, background: boolean) => void;
|
||||
onConnectionsChange?: (...args: unknown[]) => void;
|
||||
onRemoved?: (...args: unknown[]) => void;
|
||||
/** Internal: latest computed severity so we can avoid pushing duplicate badges. */
|
||||
_wpBadgeSeverity?: Severity | null;
|
||||
/** Internal: badge instance currently in `node.badges` (or null). */
|
||||
_wpBadge?: LGraphBadgeInstance | null;
|
||||
/** Internal: cleanup handle so we can detach observers on node removal. */
|
||||
_wpBadgeCleanup?: () => void;
|
||||
};
|
||||
|
||||
interface SeverityColors { bg: string; fg: string }
|
||||
|
||||
const SEVERITY_COLORS: Record<Severity, SeverityColors> = {
|
||||
info: { bg: "#6366f1", fg: "#ffffff" }, // indigo, --wp-accent
|
||||
warning: { bg: "#fbbf24", fg: "#0F1F0F" }, // amber, --wp-amber
|
||||
error: { bg: "#f87171", fg: "#ffffff" }, // red, --wp-red
|
||||
};
|
||||
|
||||
interface BadgeState {
|
||||
severity: Severity;
|
||||
/** Human-readable label that explains the issue at a glance. */
|
||||
text: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format the badge label. Casual users won't recognize "i" or "!" — they
|
||||
* need a phrase. We pick the worst-severity conflict and either name the
|
||||
* specific variable (when there's a single issue) or report the count.
|
||||
*/
|
||||
function formatBadgeText(worst: Conflict, sameSeverityCount: number): string {
|
||||
if (sameSeverityCount > 1) {
|
||||
if (worst.severity === "error") return `${sameSeverityCount} errors`;
|
||||
if (worst.severity === "warning") return `${sameSeverityCount} conflicts`;
|
||||
return `${sameSeverityCount} overrides`;
|
||||
}
|
||||
// Single issue — name the variable so the user knows exactly what to fix.
|
||||
if (worst.type === "missing_template_variable") return `missing $${worst.variable}`;
|
||||
if (worst.type === "duplicate_variable") return `duplicate $${worst.variable}`;
|
||||
if (worst.type === "shadows_upstream") return `shadows $${worst.variable}`;
|
||||
return worst.severity;
|
||||
}
|
||||
|
||||
const SEVERITY_RANK: Record<Severity, number> = { info: 1, warning: 2, error: 3 };
|
||||
|
||||
function widgetValue(node: LiteNodeLike, name: string): string {
|
||||
const w = node.widgets?.find((x) => x.name === name);
|
||||
return typeof w?.value === "string" ? w.value : "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk every WP node nested anywhere inside `subgraph` (across nested
|
||||
* subgraphs too) and collect all conflicts found. `rootGraph` is needed
|
||||
* because Context/Assembler conflict scans walk upstream across subgraph
|
||||
* boundaries — they need the root to build the subgraph-parents map.
|
||||
*/
|
||||
function collectInnerConflicts(rootGraph: LiteGraphLike, subgraph: LiteGraphLike): Conflict[] {
|
||||
const out: Conflict[] = [];
|
||||
for (const { node } of walkAllNodes(subgraph)) {
|
||||
if (node.type === "WP_Context") {
|
||||
const v = parseWidgetJson<ContextWidgetValue>(widgetValue(node, "modules"), { version: 1, modules: [] });
|
||||
const upstream = collectUpstreamVariables(rootGraph, node);
|
||||
// Only enabled modules count — disabled ones don't ship to runtime.
|
||||
const enabledOnly: ContextWidgetValue = { ...v, modules: v.modules.filter((m) => m.enabled) };
|
||||
out.push(...scanConflicts(enabledOnly, upstream));
|
||||
} else if (node.type === "WP_PromptAssembler") {
|
||||
const tmpl = widgetValue(node, "template");
|
||||
if (!tmpl) continue;
|
||||
const upstream = collectUpstreamVariables(rootGraph, node);
|
||||
out.push(...scanTemplateConflicts(tmpl, upstream));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Roll a list of conflicts up into a single badge state — picks the
|
||||
* worst severity, formats a human-readable label. Returns null when
|
||||
* there's nothing to surface.
|
||||
*/
|
||||
function computeBadgeState(rootGraph: LiteGraphLike, subgraph: LiteGraphLike): BadgeState | null {
|
||||
const conflicts = collectInnerConflicts(rootGraph, subgraph);
|
||||
if (!conflicts.length) return null;
|
||||
let worst = conflicts[0];
|
||||
for (const c of conflicts) {
|
||||
if (SEVERITY_RANK[c.severity] > SEVERITY_RANK[worst.severity]) worst = c;
|
||||
}
|
||||
const sameSeverityCount = conflicts.filter((c) => c.severity === worst.severity).length;
|
||||
return { severity: worst.severity, text: formatBadgeText(worst, sameSeverityCount) };
|
||||
}
|
||||
|
||||
function removeBadge(node: SubgraphNodeLike) {
|
||||
if (!node._wpBadge || !Array.isArray(node.badges)) return;
|
||||
const idx = node.badges.indexOf(node._wpBadge);
|
||||
if (idx >= 0) node.badges.splice(idx, 1);
|
||||
node._wpBadge = null;
|
||||
}
|
||||
|
||||
function applyBadge(node: SubgraphNodeLike, state: BadgeState, BadgeCtor: LGraphBadgeCtor) {
|
||||
const colors = SEVERITY_COLORS[state.severity];
|
||||
// Reuse the existing instance when possible — mutating fields keeps
|
||||
// ComfyUI's draw loop happy without churning the badges array.
|
||||
if (node._wpBadge) {
|
||||
node._wpBadge.text = state.text;
|
||||
node._wpBadge.bgColor = colors.bg;
|
||||
node._wpBadge.fgColor = colors.fg;
|
||||
return;
|
||||
}
|
||||
const badge = new BadgeCtor({
|
||||
text: state.text,
|
||||
bgColor: colors.bg,
|
||||
fgColor: colors.fg,
|
||||
fontSize: 12,
|
||||
padding: 6,
|
||||
height: 18,
|
||||
cornerRadius: 4,
|
||||
});
|
||||
if (!Array.isArray(node.badges)) node.badges = [];
|
||||
node.badges.push(badge);
|
||||
node._wpBadge = badge;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach badge logic to a single SubgraphNode. Idempotent — a node already
|
||||
* attached gets its existing observer left alone. Cleanup is wired through
|
||||
* `node.onRemoved` so we don't leak intervals on node deletion.
|
||||
*/
|
||||
export function attachSubgraphBadge(node: SubgraphNodeLike, rootGraph: LiteGraphLike): void {
|
||||
if (node._wpBadgeCleanup) return; // already attached
|
||||
// Captured into a const here so the closure below sees a non-nullable
|
||||
// type — avoids the no-non-null-assertion lint while staying as cheap
|
||||
// as the previous `BadgeCtor!`.
|
||||
const Ctor: LGraphBadgeCtor | null = getBadgeCtor();
|
||||
if (!Ctor) {
|
||||
console.warn("[wildcard-pipeline] LGraphBadge not on window — badge skipped");
|
||||
return;
|
||||
}
|
||||
|
||||
function recompute() {
|
||||
if (!node.subgraph || !Ctor) return;
|
||||
const next = computeBadgeState(rootGraph, node.subgraph);
|
||||
// Cache key includes both severity AND text — same severity with a new
|
||||
// variable name (e.g. user fixed $foo but $bar is still missing) needs a
|
||||
// re-render even though severity is unchanged.
|
||||
const sameAsBefore =
|
||||
(next === null && node._wpBadgeSeverity === null) ||
|
||||
(!!next && next.severity === node._wpBadgeSeverity && next.text === node._wpBadge?.text);
|
||||
if (sameAsBefore) return;
|
||||
node._wpBadgeSeverity = next?.severity ?? null;
|
||||
if (!next) removeBadge(node);
|
||||
else applyBadge(node, next, Ctor);
|
||||
node.setDirtyCanvas?.(true, true);
|
||||
}
|
||||
|
||||
// Initial compute happens on next tick — workflow may still be loading
|
||||
// and the inner subgraph's nodes might not have widget values restored yet.
|
||||
queueMicrotask(recompute);
|
||||
|
||||
// Polling fallback. 800ms is coarse but the badge isn't latency-critical;
|
||||
// the user is already used to seeing conflict dots within ~1s elsewhere.
|
||||
const interval = window.setInterval(recompute, 800);
|
||||
|
||||
// Recompute on connection changes — wires added/removed inside the
|
||||
// subgraph or to/from the SubgraphNode itself.
|
||||
const origCC = node.onConnectionsChange;
|
||||
node.onConnectionsChange = function (...args: unknown[]) {
|
||||
origCC?.apply(this, args);
|
||||
recompute();
|
||||
};
|
||||
|
||||
// Recompute when ComfyUI finishes restoring a workflow — a node loaded
|
||||
// mid-cascade may not have widget values yet on first compute.
|
||||
const unsubGraphLoaded = onGraphLoaded(() => {
|
||||
recompute();
|
||||
requestAnimationFrame(recompute);
|
||||
});
|
||||
|
||||
// Cleanup wired through onRemoved so a deleted SubgraphNode tears down
|
||||
// its observers cleanly. We chain so other extensions still run.
|
||||
const origRemoved = node.onRemoved;
|
||||
node._wpBadgeCleanup = () => {
|
||||
window.clearInterval(interval);
|
||||
unsubGraphLoaded();
|
||||
node.onConnectionsChange = origCC;
|
||||
node.onRemoved = origRemoved;
|
||||
removeBadge(node);
|
||||
node._wpBadgeCleanup = undefined;
|
||||
};
|
||||
node.onRemoved = function (...args: unknown[]) {
|
||||
node._wpBadgeCleanup?.();
|
||||
origRemoved?.apply(this, args);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk the whole graph (root + nested subgraphs) and attach a badge to
|
||||
* every SubgraphNode. Used on workflow load, when nodes pre-exist.
|
||||
*/
|
||||
export function attachAllSubgraphBadges(rootGraph: LiteGraphLike): void {
|
||||
for (const { node } of walkAllNodes(rootGraph)) {
|
||||
if ((node as SubgraphNodeLike).subgraph) {
|
||||
attachSubgraphBadge(node as SubgraphNodeLike, rootGraph);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,178 @@
|
||||
import { app } from "#comfyui/app";
|
||||
import { createApp } from "vue";
|
||||
import type { LiteGraphLike } from "./extension/graph";
|
||||
|
||||
// ComfyUI's `getCustomWidgets` invokes each factory synchronously and destructures
|
||||
// the return value. Returning a Promise from `import()` yields `{ widget: undefined }`
|
||||
// and the widget never mounts. Resolve all widget chunks at module load via top-level
|
||||
// await so factories can run sync. Vite still emits the chunks separately, so the
|
||||
// entry stays small (~0.5 KB gzip) and the heavy `_shared` chunk only fetches once.
|
||||
const [
|
||||
ctxMod,
|
||||
dbgMod,
|
||||
asmMod,
|
||||
graphEventsMod,
|
||||
ToastModule,
|
||||
graphMod,
|
||||
toastStoreMod,
|
||||
badgeMod,
|
||||
settingsMod,
|
||||
aboutMod,
|
||||
] = await Promise.all([
|
||||
import("./widgets/context"),
|
||||
import("./widgets/debug"),
|
||||
import("./widgets/assembler"),
|
||||
import("./extension/graph-events"),
|
||||
import("./components/shared/Toast.vue"),
|
||||
import("./extension/graph"),
|
||||
import("./components/shared/toast-store"),
|
||||
import("./extension/subgraph-badge"),
|
||||
import("./extension/settings"),
|
||||
import("./extension/about-badges"),
|
||||
// Webfonts (Inter 400/600 + JetBrains Mono 400) — side-effect import,
|
||||
// the module just owns the @font-face CSS chunk.
|
||||
import("./extension/fonts"),
|
||||
]);
|
||||
|
||||
// Singleton toast container — one Vue app mounted to a body-level div renders
|
||||
// every toast pushed via shared/toast-store. Each Context node's Vue app
|
||||
// imports the same store module, so toasts surface from anywhere.
|
||||
const toastRoot = document.createElement("div");
|
||||
toastRoot.id = "wp-toast-root";
|
||||
document.body.appendChild(toastRoot);
|
||||
createApp(ToastModule.default).mount(toastRoot);
|
||||
|
||||
// ComfyUI hands us untyped LiteGraph nodes; we only care about the surface
|
||||
// the glue files import. Typing the param as the parameter of `create` /
|
||||
// `mountHelper` makes TS pick the right shape without explicit casts.
|
||||
type ContextCreateNode = Parameters<typeof ctxMod.create>[0];
|
||||
type DebugCreateNode = Parameters<typeof dbgMod.create>[0];
|
||||
type AssemblerHelperNode = Parameters<typeof asmMod.mountHelper>[0];
|
||||
|
||||
interface NodeData { name: string }
|
||||
interface NodeType { prototype: { onNodeCreated?: (this: AssemblerHelperNode, ...args: unknown[]) => void } }
|
||||
|
||||
// Build the settings array eagerly — `app` is the same singleton ComfyUI
|
||||
// will hand back at runtime, so onChange callbacks captured here read the
|
||||
// extensionManager via the same reference.
|
||||
const settings = settingsMod.buildSettings(app);
|
||||
|
||||
// Apply current a11y prefs on load + listen for OS-level changes so
|
||||
// "Match system" reflects toggles in OS settings without a page reload.
|
||||
// Note: ComfyUI also fires each setting's onChange on page load with the
|
||||
// stored value, so applyA11yClasses is mostly belt-and-suspenders for
|
||||
// the case where the extensionManager is ready before the load-fire.
|
||||
settingsMod.applyA11yClasses(app);
|
||||
settingsMod.watchA11ySystemPrefs();
|
||||
|
||||
app.registerExtension({
|
||||
name: "wildcard-pipeline",
|
||||
|
||||
// About panel — small badges under our extension's row, link out to
|
||||
// the repo, issue tracker, and docs. Uses PrimeIcons shipped by ComfyUI.
|
||||
aboutPageBadges: aboutMod.ABOUT_BADGES,
|
||||
|
||||
// ComfyUI Settings panel entries — accessibility tri-state combos. Their
|
||||
// onChange handlers re-run applyA11yClasses to flip the body markers.
|
||||
settings,
|
||||
|
||||
getCustomWidgets() {
|
||||
return {
|
||||
WP_CONTEXT_MODULES: (node: ContextCreateNode, inputName: string) =>
|
||||
ctxMod.create(node, inputName),
|
||||
WP_DEBUG_VIEWER: (node: DebugCreateNode, inputName: string) =>
|
||||
dbgMod.create(node, inputName),
|
||||
};
|
||||
},
|
||||
|
||||
beforeRegisterNodeDef(nodeType: unknown, nodeData: NodeData) {
|
||||
if (nodeData.name !== "WP_PromptAssembler") return;
|
||||
const nt = nodeType as NodeType;
|
||||
const orig = nt.prototype.onNodeCreated;
|
||||
nt.prototype.onNodeCreated = function (this: AssemblerHelperNode, ...args: unknown[]) {
|
||||
orig?.apply(this, args);
|
||||
asmMod.mountHelper(this);
|
||||
};
|
||||
},
|
||||
|
||||
// Fires after ComfyUI restores all widget values from the loaded workflow.
|
||||
// Without this, assembler's snapshot is computed BEFORE upstream Context
|
||||
// values land — preview briefly shows missing-var warnings then settles
|
||||
// ~400ms later when the polling fallback catches up. Notify all reactive
|
||||
// widgets to recompute immediately.
|
||||
afterConfigureGraph() {
|
||||
graphEventsMod.notifyGraphLoaded();
|
||||
// Re-attach badges in case the loaded workflow contained SubgraphNodes
|
||||
// that bypassed nodeCreated (loaded path uses different hooks).
|
||||
const root = (app as unknown as { graph?: LiteGraphLike }).graph;
|
||||
if (root) badgeMod.attachAllSubgraphBadges(root);
|
||||
},
|
||||
|
||||
// Fires for every node when it's instantiated — including SubgraphNodes,
|
||||
// which beforeRegisterNodeDef does NOT see (litegraph registers them at
|
||||
// runtime, not via ComfyUI's node-def pipeline). We attach a corner
|
||||
// conflict badge so users can spot WP issues nested inside a subgraph
|
||||
// without opening it.
|
||||
nodeCreated(node: unknown) {
|
||||
const n = node as { isSubgraphNode?: () => boolean };
|
||||
if (!n.isSubgraphNode?.()) return;
|
||||
const root = (app as unknown as { graph?: LiteGraphLike }).graph;
|
||||
if (root) badgeMod.attachSubgraphBadge(node as Parameters<typeof badgeMod.attachSubgraphBadge>[0], root);
|
||||
},
|
||||
|
||||
// Same intent as nodeCreated but fires for nodes restored from a saved
|
||||
// workflow. ComfyUI splits the lifecycle so we cover both paths.
|
||||
loadedGraphNode(node: unknown) {
|
||||
const n = node as { isSubgraphNode?: () => boolean };
|
||||
if (!n.isSubgraphNode?.()) return;
|
||||
const root = (app as unknown as { graph?: LiteGraphLike }).graph;
|
||||
if (root) badgeMod.attachSubgraphBadge(node as Parameters<typeof badgeMod.attachSubgraphBadge>[0], root);
|
||||
},
|
||||
});
|
||||
|
||||
// Pre-run validation — when the user queues a prompt, scan every
|
||||
// WP_PromptAssembler in the graph for $vars in its template that no
|
||||
// upstream Context provides. Surface a single toast listing them so the
|
||||
// user can fix-or-accept (the runtime drops missing vars cleanly anyway).
|
||||
interface AppWithQueue {
|
||||
queuePrompt?: (...args: unknown[]) => unknown;
|
||||
graph?: unknown;
|
||||
}
|
||||
const appQ = app as unknown as AppWithQueue;
|
||||
const origQueuePrompt = appQ.queuePrompt;
|
||||
if (typeof origQueuePrompt === "function") {
|
||||
appQ.queuePrompt = function (...args: unknown[]) {
|
||||
try {
|
||||
const graph = appQ.graph as unknown as LiteGraphLike;
|
||||
const TEMPLATE_VAR = /(?<!\$)\$([A-Za-z_][A-Za-z0-9_]*)/g;
|
||||
const allMissing = new Set<string>();
|
||||
// walkAllNodes recurses into nested subgraphs so assemblers inside a
|
||||
// subgraph still get their templates scanned. collectUpstreamVariables
|
||||
// crosses boundaries the other direction.
|
||||
for (const { node } of graphMod.walkAllNodes(graph)) {
|
||||
if (node.type !== "WP_PromptAssembler") continue;
|
||||
const w = node.widgets?.find((x: { name: string; value: unknown }) => x.name === "template");
|
||||
const tmpl = typeof w?.value === "string" ? w.value : "";
|
||||
if (!tmpl) continue;
|
||||
const upstream = new Set(graphMod.collectUpstreamVariables(graph, node));
|
||||
for (const m of tmpl.matchAll(TEMPLATE_VAR)) {
|
||||
const name = m[1];
|
||||
if (!name.startsWith("__") && !upstream.has(name)) allMissing.add(name);
|
||||
}
|
||||
}
|
||||
if (allMissing.size > 0) {
|
||||
const list = [...allMissing].map((v) => `$${v}`).join(", ");
|
||||
toastStoreMod.pushToast(`Unresolved in templates: ${list} — will render empty.`, {
|
||||
severity: "warning",
|
||||
lifeMs: 6000,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
// Validation must never block the queue — log and proceed.
|
||||
console.warn("[wildcard-pipeline] pre-run scan failed:", err);
|
||||
}
|
||||
return origQueuePrompt.apply(this, args);
|
||||
};
|
||||
}
|
||||
|
||||
console.info("[wildcard-pipeline] extension loaded");
|
||||
|
||||
@@ -2,7 +2,11 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Wildcard Pipeline Manager</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/wp/images/favicon.svg" />
|
||||
<link rel="apple-touch-icon" href="/wp/images/apple-touch-icon.png" />
|
||||
<link rel="manifest" href="/wp/images/site.webmanifest" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
@@ -1,4 +1 @@
|
||||
const root = document.getElementById("app");
|
||||
if (root) {
|
||||
root.textContent = "Wildcard Pipeline Manager — placeholder (post-MVP)";
|
||||
}
|
||||
import "./manager/main";
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { RouterView } from "vue-router";
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RouterView />
|
||||
</template>
|
||||
@@ -0,0 +1,110 @@
|
||||
import { mount, flushPromises } from "@vue/test-utils";
|
||||
import { setActivePinia, createPinia } from "pinia";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { createMemoryHistory, createRouter, type Router } from "vue-router";
|
||||
|
||||
import AppSidebar from "../layout/AppSidebar.vue";
|
||||
import { useUiStore } from "../stores/uiStore";
|
||||
|
||||
function makeRouter(start = "/wildcards"): Router {
|
||||
return createRouter({
|
||||
history: createMemoryHistory(start),
|
||||
routes: [
|
||||
{ path: "/", redirect: "/wildcards" },
|
||||
{ path: "/dashboard", name: "dashboard", component: { template: "<div/>" } },
|
||||
{ path: "/wildcards", name: "wildcards", component: { template: "<div/>" } },
|
||||
{ path: "/wildcards/:id/edit", name: "wildcards-edit", component: { template: "<div/>" } },
|
||||
{ path: "/pipelines", name: "pipelines", component: { template: "<div/>" } },
|
||||
{ path: "/fixed-values", name: "fixed-values", component: { template: "<div/>" } },
|
||||
{ path: "/combines", name: "combines", component: { template: "<div/>" } },
|
||||
{ path: "/derivations", name: "derivations", component: { template: "<div/>" } },
|
||||
{ path: "/constraints", name: "constraints", component: { template: "<div/>" } },
|
||||
{ path: "/categories", name: "categories", component: { template: "<div/>" } },
|
||||
{ path: "/import-export", name: "import-export", component: { template: "<div/>" } },
|
||||
{ path: "/test", name: "test", component: { template: "<div/>" } },
|
||||
{ path: "/community", name: "community", component: { template: "<div/>" } },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
async function mountSidebar(start = "/wildcards") {
|
||||
const router = makeRouter(start);
|
||||
await router.push(start);
|
||||
await router.isReady();
|
||||
const wrap = mount(AppSidebar, {
|
||||
global: { plugins: [router] },
|
||||
});
|
||||
await flushPromises();
|
||||
return { wrap, router };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
});
|
||||
|
||||
describe("AppSidebar.vue", () => {
|
||||
it("renders all four section headers", async () => {
|
||||
const { wrap } = await mountSidebar();
|
||||
const sections = wrap.findAll(".wp-sidebar__section").map((n) => n.text());
|
||||
expect(sections).toEqual(["Home", "Modules", "Library", "Get Started"]);
|
||||
});
|
||||
|
||||
it("renders all six modules items", async () => {
|
||||
const { wrap } = await mountSidebar();
|
||||
const labels = wrap.findAll(".wp-nav__label").map((n) => n.text());
|
||||
// Modules
|
||||
expect(labels).toContain("Pipelines");
|
||||
expect(labels).toContain("Wildcards");
|
||||
expect(labels).toContain("Fixed Values");
|
||||
expect(labels).toContain("Combines");
|
||||
expect(labels).toContain("Derivations");
|
||||
expect(labels).toContain("Constraints");
|
||||
// Library
|
||||
expect(labels).toContain("Categories");
|
||||
expect(labels).toContain("Import / Export");
|
||||
expect(labels).toContain("Test Runner");
|
||||
// Get Started
|
||||
expect(labels).toContain("Community");
|
||||
expect(labels).toContain("Documentation");
|
||||
expect(labels).toContain("View Source");
|
||||
// Home
|
||||
expect(labels).toContain("Dashboard");
|
||||
});
|
||||
|
||||
it("marks the matching item active when on its route", async () => {
|
||||
const { wrap } = await mountSidebar("/wildcards");
|
||||
const active = wrap.find('[data-nav-id="wildcards"]');
|
||||
expect(active.exists()).toBe(true);
|
||||
expect(active.attributes("data-active")).toBe("true");
|
||||
// Sibling not active
|
||||
const sib = wrap.find('[data-nav-id="pipelines"]');
|
||||
expect(sib.attributes("data-active")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("treats editor sub-routes as the parent kind active", async () => {
|
||||
const { wrap } = await mountSidebar("/wildcards/abc/edit");
|
||||
const active = wrap.find('[data-nav-id="wildcards"]');
|
||||
expect(active.attributes("data-active")).toBe("true");
|
||||
});
|
||||
|
||||
it("renders external links as anchors with target=_blank", async () => {
|
||||
const { wrap } = await mountSidebar();
|
||||
const docs = wrap
|
||||
.findAll("a.wp-nav")
|
||||
.find((a) => a.text().includes("Documentation"));
|
||||
if (!docs) throw new Error("Documentation link not found");
|
||||
expect(docs.attributes("target")).toBe("_blank");
|
||||
expect(docs.attributes("rel")).toContain("noopener");
|
||||
expect(docs.attributes("href")).toMatch(/^https?:\/\//);
|
||||
});
|
||||
|
||||
it("hides section labels when sidebar is collapsed", async () => {
|
||||
const { wrap } = await mountSidebar();
|
||||
const ui = useUiStore();
|
||||
ui.sidebarCollapsed = true;
|
||||
await flushPromises();
|
||||
expect(wrap.findAll(".wp-sidebar__section")).toHaveLength(0);
|
||||
// Labels also hidden
|
||||
expect(wrap.findAll(".wp-nav__label")).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
import { mount, flushPromises } from "@vue/test-utils";
|
||||
import { setActivePinia, createPinia } from "pinia";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createMemoryHistory, createRouter, type Router } from "vue-router";
|
||||
|
||||
vi.mock("../community/mockApi", () => ({
|
||||
getCurrentUser: vi.fn(() => null),
|
||||
getInstalled: vi.fn(() => []),
|
||||
getStarred: vi.fn(() => []),
|
||||
getMyUploads: vi.fn(() => []),
|
||||
getInstallHistory: vi.fn(() => []),
|
||||
getApiStatus: vi.fn(async () => "online"),
|
||||
signInWithGitHub: vi.fn(),
|
||||
signOut: vi.fn(),
|
||||
getFeatured: vi.fn(async () => []),
|
||||
searchModules: vi.fn(async () => []),
|
||||
getModule: vi.fn(),
|
||||
installModule: vi.fn(),
|
||||
uninstallModule: vi.fn(),
|
||||
starModule: vi.fn(),
|
||||
uploadModule: vi.fn(),
|
||||
}));
|
||||
|
||||
import AppTopbar from "../layout/AppTopbar.vue";
|
||||
import { useUiStore } from "../stores/uiStore";
|
||||
import { useCommunityStore } from "../stores/communityStore";
|
||||
|
||||
function makeRouter(start = "/wildcards"): Router {
|
||||
const r = createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [
|
||||
{ path: "/", component: { template: "<div/>" } },
|
||||
{ path: "/wildcards", name: "wildcards", component: { template: "<div/>" } },
|
||||
{ path: "/settings", name: "settings", component: { template: "<div/>" } },
|
||||
{ path: "/community", name: "community", component: { template: "<div/>" } },
|
||||
{ path: "/community/discover", name: "community-discover", component: { template: "<div/>" } },
|
||||
{ path: "/community/profile", name: "community-profile", component: { template: "<div/>" } },
|
||||
],
|
||||
});
|
||||
r.push(start);
|
||||
return r;
|
||||
}
|
||||
|
||||
async function mountTopbar(start = "/wildcards") {
|
||||
const router = makeRouter(start);
|
||||
await router.isReady();
|
||||
const wrap = mount(AppTopbar, {
|
||||
global: { plugins: [router] },
|
||||
});
|
||||
await flushPromises();
|
||||
return { wrap, router };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
});
|
||||
|
||||
describe("AppTopbar.vue", () => {
|
||||
it("renders the brand link with logo + version", async () => {
|
||||
const { wrap } = await mountTopbar();
|
||||
const brand = wrap.find('[data-test="topbar-brand"]');
|
||||
expect(brand.exists()).toBe(true);
|
||||
expect(brand.text()).toContain("Wildcard Pipeline");
|
||||
expect(brand.text()).toMatch(/v\d/);
|
||||
expect(brand.find("img").exists()).toBe(true);
|
||||
});
|
||||
|
||||
it("calls ui.toggleSidebar when the bars button is clicked", async () => {
|
||||
const { wrap } = await mountTopbar();
|
||||
const ui = useUiStore();
|
||||
expect(ui.sidebarCollapsed).toBe(false);
|
||||
await wrap.find('[data-test="topbar-toggle"]').trigger("click");
|
||||
expect(ui.sidebarCollapsed).toBe(true);
|
||||
});
|
||||
|
||||
it("calls ui.cycleTheme when the theme button is clicked", async () => {
|
||||
const { wrap } = await mountTopbar();
|
||||
const ui = useUiStore();
|
||||
const before = ui.themeMode;
|
||||
await wrap.find('[data-test="topbar-theme"]').trigger("click");
|
||||
expect(ui.themeMode).not.toBe(before);
|
||||
});
|
||||
|
||||
it("hides the community status pill on non-community routes", async () => {
|
||||
const { wrap } = await mountTopbar("/wildcards");
|
||||
expect(wrap.find('[data-test="topbar-status"]').exists()).toBe(false);
|
||||
expect(wrap.find('[data-test="topbar-signin"]').exists()).toBe(false);
|
||||
expect(wrap.find('[data-test="topbar-user"]').exists()).toBe(false);
|
||||
});
|
||||
|
||||
it("shows the status pill on community routes", async () => {
|
||||
const { wrap } = await mountTopbar("/community/discover");
|
||||
expect(wrap.find('[data-test="topbar-status"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it("shows the sign-in button on community routes when no user", async () => {
|
||||
const { wrap } = await mountTopbar("/community/discover");
|
||||
expect(wrap.find('[data-test="topbar-signin"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it("shows the user pill + dropdown when signed in on community routes", async () => {
|
||||
const { wrap } = await mountTopbar("/community/discover");
|
||||
const community = useCommunityStore();
|
||||
community.currentUser = {
|
||||
login: "octocat",
|
||||
avatar_url: "https://example.com/a.png",
|
||||
verified: true,
|
||||
name: "Octo Cat",
|
||||
};
|
||||
await flushPromises();
|
||||
const userBtn = wrap.find('[data-test="topbar-user"]');
|
||||
expect(userBtn.exists()).toBe(true);
|
||||
// Dropdown closed initially
|
||||
expect(wrap.find('[data-test="topbar-user-menu"]').exists()).toBe(false);
|
||||
await userBtn.trigger("click");
|
||||
expect(wrap.find('[data-test="topbar-user-menu"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it("navigates to /settings when settings cog clicked", async () => {
|
||||
const { wrap, router } = await mountTopbar();
|
||||
await wrap.find('[data-test="topbar-settings"]').trigger("click");
|
||||
await flushPromises();
|
||||
expect(router.currentRoute.value.path).toBe("/settings");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import { mount, flushPromises } from "@vue/test-utils";
|
||||
import { setActivePinia, createPinia } from "pinia";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
vi.mock("../api/client", () => ({
|
||||
api: {
|
||||
categories: {
|
||||
list: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
modules: {
|
||||
list: vi.fn().mockResolvedValue({ items: [], total: 0 }),
|
||||
},
|
||||
},
|
||||
// Re-export ApiError so the SFC's `import { ApiError } from "../api/client"`
|
||||
// resolves under the mock.
|
||||
ApiError: class ApiError extends Error {},
|
||||
}));
|
||||
|
||||
import { api } from "../api/client";
|
||||
import Categories from "../views/Categories.vue";
|
||||
|
||||
const apiCat = api.categories as unknown as Record<string, ReturnType<typeof vi.fn>>;
|
||||
const apiMod = api.modules as unknown as Record<string, ReturnType<typeof vi.fn>>;
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
Object.values(apiCat).forEach((fn) => fn.mockReset());
|
||||
Object.values(apiMod).forEach((fn) => fn.mockReset());
|
||||
apiMod.list.mockResolvedValue({ items: [], total: 0 });
|
||||
});
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
describe("Categories.vue", () => {
|
||||
it("renders the page header and 'New category' card", async () => {
|
||||
apiCat.list.mockResolvedValue({ items: [] });
|
||||
const wrap = mount(Categories, {
|
||||
global: { plugins: [] },
|
||||
});
|
||||
await flushPromises();
|
||||
expect(wrap.text()).toContain("Categories");
|
||||
expect(wrap.text()).toContain("New category");
|
||||
// Empty-state row.
|
||||
expect(wrap.text()).toContain("No categories yet.");
|
||||
});
|
||||
|
||||
it("renders existing rows from the mocked store", async () => {
|
||||
apiCat.list.mockResolvedValue({
|
||||
items: [
|
||||
{ id: "cat_a", name: "Style", color: "#a78bfa", icon: null, sort_order: 0 },
|
||||
{ id: "cat_b", name: "Lighting", color: "#22d3ee", icon: null, sort_order: 1 },
|
||||
],
|
||||
});
|
||||
const wrap = mount(Categories, {
|
||||
global: { plugins: [] },
|
||||
});
|
||||
await flushPromises();
|
||||
expect(wrap.find('[data-test="cat-row-cat_a"]').exists()).toBe(true);
|
||||
expect(wrap.find('[data-test="cat-row-cat_b"]').exists()).toBe(true);
|
||||
expect(wrap.text()).toContain("Style");
|
||||
expect(wrap.text()).toContain("Lighting");
|
||||
});
|
||||
|
||||
it("derives module counts from the moduleStore", async () => {
|
||||
apiCat.list.mockResolvedValue({
|
||||
items: [
|
||||
{ id: "cat_a", name: "Style", color: "#a78bfa", icon: null, sort_order: 0 },
|
||||
],
|
||||
});
|
||||
apiMod.list.mockResolvedValue({
|
||||
items: [
|
||||
{ id: "wc_1", type: "wildcard", category_id: "cat_a", name: "x", tags: [] },
|
||||
{ id: "wc_2", type: "wildcard", category_id: "cat_a", name: "y", tags: [] },
|
||||
{ id: "wc_3", type: "wildcard", category_id: null, name: "z", tags: [] },
|
||||
],
|
||||
total: 3,
|
||||
});
|
||||
const wrap = mount(Categories, {
|
||||
global: { plugins: [] },
|
||||
});
|
||||
await flushPromises();
|
||||
expect(wrap.get('[data-test="cat-count-cat_a"]').text()).toBe("2");
|
||||
});
|
||||
|
||||
it("Add button calls api.categories.create with name + color", async () => {
|
||||
apiCat.list.mockResolvedValue({ items: [] });
|
||||
apiCat.create.mockResolvedValue({
|
||||
id: "cat_new", name: "Mood", color: "#fbbf24", icon: null, sort_order: 0,
|
||||
});
|
||||
const wrap = mount(Categories, {
|
||||
global: { plugins: [] },
|
||||
});
|
||||
await flushPromises();
|
||||
await wrap.get('[data-test="new-cat-name"]').setValue("Mood");
|
||||
await wrap.get('[data-test="add-category-btn"]').trigger("click");
|
||||
await flushPromises();
|
||||
expect(apiCat.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: "Mood", color: expect.stringMatching(/^#/) }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import { mount } from "@vue/test-utils";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import ColorPicker from "../components/ColorPicker.vue";
|
||||
|
||||
function makeWrapper(modelValue = "#7c3aed") {
|
||||
return mount(ColorPicker, {
|
||||
props: { modelValue },
|
||||
global: { plugins: [] },
|
||||
});
|
||||
}
|
||||
|
||||
async function openPopover(wrapper: ReturnType<typeof makeWrapper>) {
|
||||
await wrapper.get('[data-test="color-swatch"]').trigger("click");
|
||||
}
|
||||
|
||||
describe("ColorPicker.vue", () => {
|
||||
it("renders the swatch with the bound color", () => {
|
||||
const wrap = makeWrapper("#ff0000");
|
||||
const swatch = wrap.get('[data-test="color-swatch"]');
|
||||
const style = swatch.attributes("style") ?? "";
|
||||
// jsdom normalizes hex to rgb(...) in style strings.
|
||||
expect(style.toLowerCase()).toMatch(/rgb\(255,\s*0,\s*0\)|#ff0000/);
|
||||
});
|
||||
|
||||
it("clicking a preset emits update:modelValue with that hex", async () => {
|
||||
const wrap = makeWrapper("#000000");
|
||||
await openPopover(wrap);
|
||||
const presets = wrap.findAll('[data-test="color-preset"]');
|
||||
expect(presets.length).toBe(12);
|
||||
await presets[0].trigger("click");
|
||||
const events = wrap.emitted("update:modelValue");
|
||||
expect(events).toBeTruthy();
|
||||
// First default preset is "#7c3aed".
|
||||
expect(events?.[0]).toEqual(["#7c3aed"]);
|
||||
});
|
||||
|
||||
it("invalid hex input does NOT emit update:modelValue", async () => {
|
||||
const wrap = makeWrapper("#000000");
|
||||
await openPopover(wrap);
|
||||
const hexInput = wrap.get('[data-test="color-hex-input"]');
|
||||
await hexInput.setValue("abc");
|
||||
expect(wrap.emitted("update:modelValue")).toBeFalsy();
|
||||
});
|
||||
|
||||
it("valid hex input emits update:modelValue with that hex", async () => {
|
||||
const wrap = makeWrapper("#000000");
|
||||
await openPopover(wrap);
|
||||
const hexInput = wrap.get('[data-test="color-hex-input"]');
|
||||
await hexInput.setValue("#ff0000");
|
||||
const events = wrap.emitted("update:modelValue");
|
||||
expect(events).toBeTruthy();
|
||||
expect(events?.[events!.length - 1]).toEqual(["#ff0000"]);
|
||||
});
|
||||
|
||||
it("uses provided ariaLabel on the swatch button", () => {
|
||||
const wrap = mount(ColorPicker, {
|
||||
props: { modelValue: "#abcdef", ariaLabel: "Pick brand color" },
|
||||
global: { plugins: [] },
|
||||
});
|
||||
expect(wrap.get('[data-test="color-swatch"]').attributes("aria-label"))
|
||||
.toBe("Pick brand color");
|
||||
});
|
||||
|
||||
it("supports custom presets prop", async () => {
|
||||
const wrap = mount(ColorPicker, {
|
||||
props: { modelValue: "#000000", presets: ["#111111", "#222222"] },
|
||||
global: { plugins: [] },
|
||||
});
|
||||
await openPopover(wrap);
|
||||
expect(wrap.findAll('[data-test="color-preset"]').length).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
import { mount, flushPromises } from "@vue/test-utils";
|
||||
import { setActivePinia, createPinia } from "pinia";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createMemoryHistory, createRouter } from "vue-router";
|
||||
vi.mock("../api/client", () => ({
|
||||
api: {
|
||||
modules: {
|
||||
create: vi.fn(),
|
||||
get: vi.fn(),
|
||||
update: vi.fn(),
|
||||
list: vi.fn().mockResolvedValue({ items: [], total: 0 }),
|
||||
},
|
||||
categories: { list: vi.fn().mockResolvedValue({ items: [] }) },
|
||||
},
|
||||
}));
|
||||
|
||||
import { api } from "../api/client";
|
||||
import CombineEditor from "../views/CombineEditor.vue";
|
||||
import type { CombinePayload } from "../api/types";
|
||||
|
||||
const apiMod = api.modules as unknown as Record<string, ReturnType<typeof vi.fn>>;
|
||||
const apiCat = api.categories as unknown as Record<string, ReturnType<typeof vi.fn>>;
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
Object.values(apiMod).forEach((fn) => fn.mockReset());
|
||||
apiMod.list.mockResolvedValue({ items: [], total: 0 });
|
||||
apiCat.list.mockResolvedValue({ items: [] });
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
function makeRouter() {
|
||||
return createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [
|
||||
{ path: "/", component: { template: "<div/>" } },
|
||||
{ path: "/combines", component: { template: "<div/>" } },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
describe("CombineEditor.vue", () => {
|
||||
it("renders 'New combine' heading when no id", async () => {
|
||||
const wrap = mount(CombineEditor, {
|
||||
global: { plugins: [makeRouter()] },
|
||||
});
|
||||
await flushPromises();
|
||||
expect(wrap.text()).toContain("New combine");
|
||||
});
|
||||
|
||||
it("loads existing module when id prop given", async () => {
|
||||
apiMod.get.mockResolvedValue({
|
||||
id: "cb_a", name: "Subject", description: "", category_id: null,
|
||||
tags: [], type: "combine",
|
||||
payload: {
|
||||
template: "$first_name",
|
||||
output_var: "subject_phrase",
|
||||
input_vars: ["first_name"],
|
||||
},
|
||||
version: 1, created_at: "", updated_at: "", is_favorite: false,
|
||||
});
|
||||
const wrap = mount(CombineEditor, {
|
||||
props: { id: "cb_a" },
|
||||
global: { plugins: [makeRouter()] },
|
||||
});
|
||||
await flushPromises();
|
||||
expect(wrap.text()).toContain("Edit combine");
|
||||
const outInput = wrap.find('[data-test="cb-output-var"]')
|
||||
.element as HTMLInputElement;
|
||||
expect(outInput.value).toBe("subject_phrase");
|
||||
});
|
||||
|
||||
it("save without name does not call api", async () => {
|
||||
const wrap = mount(CombineEditor, {
|
||||
global: { plugins: [makeRouter()] },
|
||||
});
|
||||
await flushPromises();
|
||||
const saveBtn = wrap.find('[data-test="save-btn"]');
|
||||
await saveBtn.trigger("click");
|
||||
await flushPromises();
|
||||
expect(apiMod.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("save creates combine with type/payload shape", async () => {
|
||||
apiMod.create.mockResolvedValue({
|
||||
id: "cb_a", type: "combine", name: "Subject",
|
||||
description: "", category_id: null, tags: [], is_favorite: false,
|
||||
payload: { template: "$first_name", output_var: "subject", input_vars: ["first_name"] },
|
||||
version: 1, created_at: "", updated_at: "",
|
||||
});
|
||||
const wrap = mount(CombineEditor, {
|
||||
global: { plugins: [makeRouter()] },
|
||||
});
|
||||
await flushPromises();
|
||||
const nameInput = wrap.find('[data-test="identity-name"]');
|
||||
await nameInput.setValue("Subject Phrase");
|
||||
await flushPromises();
|
||||
const ta = wrap.find('[data-test="cb-template"] textarea');
|
||||
await ta.setValue("$first_name with $hair_color hair");
|
||||
await flushPromises();
|
||||
const saveBtn = wrap.find('[data-test="save-btn"]');
|
||||
await saveBtn.trigger("click");
|
||||
await flushPromises();
|
||||
expect(apiMod.create).toHaveBeenCalledTimes(1);
|
||||
const call = apiMod.create.mock.calls[0]?.[0] as { type: string; payload: CombinePayload };
|
||||
expect(call.type).toBe("combine");
|
||||
expect(call.payload.template).toBe("$first_name with $hair_color hair");
|
||||
expect(call.payload.input_vars).toEqual(["first_name", "hair_color"]);
|
||||
expect(Object.keys(call.payload).sort()).toEqual(["input_vars", "output_var", "template"].sort());
|
||||
});
|
||||
|
||||
it("detected inputs panel shows the $vars from the template", async () => {
|
||||
const wrap = mount(CombineEditor, {
|
||||
global: { plugins: [makeRouter()] },
|
||||
});
|
||||
await flushPromises();
|
||||
const ta = wrap.find('[data-test="cb-template"] textarea');
|
||||
await ta.setValue("$alpha plus $beta and $alpha again, $$literal");
|
||||
await flushPromises();
|
||||
const detected = wrap.find('[data-test="cb-detected"]');
|
||||
expect(detected.exists()).toBe(true);
|
||||
expect(detected.text()).toContain("$alpha");
|
||||
expect(detected.text()).toContain("$beta");
|
||||
expect(detected.text()).not.toContain("$literal");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import { mount, flushPromises } from "@vue/test-utils";
|
||||
import { setActivePinia, createPinia } from "pinia";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createMemoryHistory, createRouter } from "vue-router";
|
||||
vi.mock("../api/client", () => ({
|
||||
api: {
|
||||
modules: {
|
||||
list: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
duplicate: vi.fn(),
|
||||
favorite: vi.fn(),
|
||||
},
|
||||
categories: { list: vi.fn().mockResolvedValue({ items: [] }) },
|
||||
},
|
||||
ApiError: class extends Error {
|
||||
constructor(public status: number, message: string) { super(message); }
|
||||
},
|
||||
}));
|
||||
|
||||
import { api } from "../api/client";
|
||||
import Combines from "../views/Combines.vue";
|
||||
|
||||
const apiMod = api.modules as unknown as Record<string, ReturnType<typeof vi.fn>>;
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
Object.values(apiMod).forEach((fn) => fn.mockReset());
|
||||
});
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
function makeRouter() {
|
||||
return createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [
|
||||
{ path: "/", component: { template: "<div/>" } },
|
||||
{ path: "/combines/new", component: { template: "<div/>" } },
|
||||
{ path: "/combines/:id/edit", name: "combines-edit", component: { template: "<div/>" } },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
function mountView() {
|
||||
return mount(Combines, {
|
||||
global: { plugins: [makeRouter()] },
|
||||
});
|
||||
}
|
||||
|
||||
describe("Combines.vue", () => {
|
||||
it("filters store by type=combine on mount", async () => {
|
||||
apiMod.list.mockResolvedValue({ items: [], total: 0 });
|
||||
mountView();
|
||||
await flushPromises();
|
||||
expect(apiMod.list).toHaveBeenCalledWith(expect.objectContaining({ type: "combine" }));
|
||||
});
|
||||
|
||||
it("renders rows", async () => {
|
||||
apiMod.list.mockResolvedValue({
|
||||
items: [{
|
||||
id: "cb_a", type: "combine", name: "Subject Phrase", category_id: null,
|
||||
tags: [], is_favorite: false,
|
||||
payload: { template: "$name, $age", output_var: "subject", input_vars: ["$name", "$age"] },
|
||||
version: 1, description: "", created_at: "", updated_at: "",
|
||||
}],
|
||||
total: 1,
|
||||
});
|
||||
const wrap = mountView();
|
||||
await flushPromises();
|
||||
expect(wrap.text()).toContain("Subject Phrase");
|
||||
});
|
||||
|
||||
it("empty state shows CTA", async () => {
|
||||
apiMod.list.mockResolvedValue({ items: [], total: 0 });
|
||||
const wrap = mountView();
|
||||
await flushPromises();
|
||||
expect(wrap.text()).toContain("No combines yet");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* CommunityDiscover.vue tests.
|
||||
*
|
||||
* Mocks the mock API at the module boundary so we can drive deterministic
|
||||
* results, then assert that filter / sort / kind controls forward the right
|
||||
* query options to the store.
|
||||
*/
|
||||
import { mount, flushPromises } from "@vue/test-utils";
|
||||
import { setActivePinia, createPinia } from "pinia";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createMemoryHistory, createRouter } from "vue-router";
|
||||
vi.mock("../community/mockApi", async () => {
|
||||
const actual = await vi.importActual<typeof import("../community/mockApi")>("../community/mockApi");
|
||||
return {
|
||||
...actual,
|
||||
searchModules: vi.fn(),
|
||||
getFeatured: vi.fn().mockResolvedValue([]),
|
||||
getApiStatus: vi.fn().mockResolvedValue("online"),
|
||||
};
|
||||
});
|
||||
|
||||
import * as mockApi from "../community/mockApi";
|
||||
import CommunityDiscover from "../views/community/CommunityDiscover.vue";
|
||||
|
||||
const seedAtom = (id: string, overrides: Record<string, unknown> = {}) => ({
|
||||
id,
|
||||
type: "wildcard",
|
||||
name: `Module ${id}`,
|
||||
description: "",
|
||||
category: "subject",
|
||||
tags: ["portrait"],
|
||||
author: { login: "u", avatar_url: "", verified: false },
|
||||
versions: [{ version: "1.0.0", published_at: new Date().toISOString() }],
|
||||
stars: 10,
|
||||
downloads: 100,
|
||||
rating: 4.5,
|
||||
rating_count: 5,
|
||||
rating_dist: [3, 1, 1, 0, 0],
|
||||
nsfw: false,
|
||||
engine_min_version: "1.4",
|
||||
license: "MIT",
|
||||
hero: "linear-gradient(135deg, #000 0%, #111 100%)",
|
||||
tagline: `Tagline ${id}`,
|
||||
readme: "# Hi",
|
||||
updated_at: new Date().toISOString(),
|
||||
preview_options: [],
|
||||
comments: [],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
mockApi._resetForTests();
|
||||
vi.mocked(mockApi.searchModules).mockReset();
|
||||
vi.mocked(mockApi.searchModules).mockResolvedValue([]);
|
||||
vi.mocked(mockApi.getFeatured).mockReset();
|
||||
vi.mocked(mockApi.getFeatured).mockResolvedValue([]);
|
||||
vi.mocked(mockApi.getApiStatus).mockReset();
|
||||
vi.mocked(mockApi.getApiStatus).mockResolvedValue("online");
|
||||
});
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
function makeRouter() {
|
||||
return createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [
|
||||
{ path: "/", component: { template: "<div/>" } },
|
||||
{ path: "/community/discover", name: "community-discover", component: { template: "<div/>" } },
|
||||
{ path: "/community/upload", name: "community-upload", component: { template: "<div/>" } },
|
||||
{ path: "/community/profile", name: "community-profile", component: { template: "<div/>" } },
|
||||
{ path: "/community/offline", name: "community-offline", component: { template: "<div/>" } },
|
||||
{ path: "/community/m/:id", name: "community-detail", component: { template: "<div/>" } },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
function mountView() {
|
||||
return mount(CommunityDiscover, {
|
||||
global: { plugins: [makeRouter()] },
|
||||
});
|
||||
}
|
||||
|
||||
describe("CommunityDiscover.vue", () => {
|
||||
it("renders the hero title + Publish button", async () => {
|
||||
const wrap = mountView();
|
||||
await flushPromises();
|
||||
expect(wrap.text()).toContain("Community");
|
||||
expect(wrap.text()).toContain("Publish");
|
||||
});
|
||||
|
||||
it("renders a card per result", async () => {
|
||||
vi.mocked(mockApi.searchModules).mockResolvedValue([
|
||||
seedAtom("a"),
|
||||
seedAtom("b"),
|
||||
seedAtom("c"),
|
||||
] as never);
|
||||
const wrap = mountView();
|
||||
await flushPromises();
|
||||
expect(wrap.findAll(".wp-comm-card").length).toBe(3);
|
||||
});
|
||||
|
||||
it("forwards search query to the API", async () => {
|
||||
const wrap = mountView();
|
||||
await flushPromises();
|
||||
|
||||
const input = wrap.find('input[aria-label="Search community"]');
|
||||
await input.setValue("hair");
|
||||
await flushPromises();
|
||||
|
||||
const calls = vi.mocked(mockApi.searchModules).mock.calls;
|
||||
const last = calls[calls.length - 1][0];
|
||||
expect(last?.q).toBe("hair");
|
||||
});
|
||||
|
||||
it("renders the empty state when feed is empty", async () => {
|
||||
vi.mocked(mockApi.searchModules).mockResolvedValue([]);
|
||||
const wrap = mountView();
|
||||
await flushPromises();
|
||||
expect(wrap.text()).toContain("Nothing to show");
|
||||
});
|
||||
});
|
||||