undercity 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +26 -0
- package/README.md +58 -0
- package/actions/AGENTS.md +41 -0
- package/actions/_shared/container.js +16 -0
- package/actions/auth/ask-login/action.json +15 -0
- package/actions/auth/ask-signup/action.json +14 -0
- package/actions/display/clear/action.json +18 -0
- package/actions/display/clear/action.test.js +32 -0
- package/actions/display/markdown/action.json +12 -0
- package/actions/display/markdown/action.test.js +32 -0
- package/actions/display/rawHtml/action.json +24 -0
- package/actions/display/rawHtml/action.test.js +32 -0
- package/actions/display/safeHtml/action.json +24 -0
- package/actions/display/safeHtml/action.test.js +32 -0
- package/actions/display/text/action.js +9 -0
- package/actions/display/text/action.json +12 -0
- package/actions/display/text/action.test.js +40 -0
- package/actions/display/value/action.json +24 -0
- package/actions/display/value/action.test.js +32 -0
- package/actions/dom/addClass/action.json +23 -0
- package/actions/dom/addClass/action.test.js +32 -0
- package/actions/dom/focus/action.json +17 -0
- package/actions/dom/focus/action.test.js +32 -0
- package/actions/dom/hide/action.json +18 -0
- package/actions/dom/hide/action.test.js +32 -0
- package/actions/dom/removeClass/action.json +22 -0
- package/actions/dom/removeClass/action.test.js +32 -0
- package/actions/dom/scroll/action.json +29 -0
- package/actions/dom/scroll/action.test.js +32 -0
- package/actions/dom/setAttr/action.json +29 -0
- package/actions/dom/setAttr/action.test.js +32 -0
- package/actions/dom/setHtml/action.json +22 -0
- package/actions/dom/setHtml/action.test.js +32 -0
- package/actions/dom/setStyle/action.json +29 -0
- package/actions/dom/setStyle/action.test.js +32 -0
- package/actions/dom/setText/action.json +24 -0
- package/actions/dom/setText/action.test.js +32 -0
- package/actions/dom/show/action.json +18 -0
- package/actions/dom/show/action.test.js +32 -0
- package/actions/dom/toggle/action.json +17 -0
- package/actions/dom/toggle/action.test.js +32 -0
- package/actions/dom/toggleClass/action.json +22 -0
- package/actions/dom/toggleClass/action.test.js +32 -0
- package/actions/event/emit/action.json +24 -0
- package/actions/event/emit/action.test.js +32 -0
- package/actions/event/on/action.json +23 -0
- package/actions/event/on/action.test.js +32 -0
- package/actions/event/waitFor/action.json +28 -0
- package/actions/event/waitFor/action.test.js +32 -0
- package/actions/forms/bindField/action.js +27 -0
- package/actions/forms/bindField/action.json +24 -0
- package/actions/forms/bindField/action.test.js +20 -0
- package/actions/forms/check/action.json +22 -0
- package/actions/forms/check/action.test.js +32 -0
- package/actions/forms/clearErrors/action.json +11 -0
- package/actions/forms/clearErrors/action.test.js +35 -0
- package/actions/forms/clearField/action.json +17 -0
- package/actions/forms/clearField/action.test.js +32 -0
- package/actions/forms/getField/action.json +24 -0
- package/actions/forms/getField/action.test.js +32 -0
- package/actions/forms/getRange/action.json +22 -0
- package/actions/forms/getRange/action.test.js +32 -0
- package/actions/forms/getSelect/action.json +22 -0
- package/actions/forms/getSelect/action.test.js +32 -0
- package/actions/forms/index.js +140 -0
- package/actions/forms/serialize/action.json +24 -0
- package/actions/forms/serialize/action.test.js +32 -0
- package/actions/forms/setCheck/action.json +23 -0
- package/actions/forms/setCheck/action.test.js +32 -0
- package/actions/forms/setError/action.json +22 -0
- package/actions/forms/setError/action.test.js +32 -0
- package/actions/forms/setField/action.js +14 -0
- package/actions/forms/setField/action.json +24 -0
- package/actions/forms/setField/action.test.js +32 -0
- package/actions/forms/submit/action.json +18 -0
- package/actions/forms/submit/action.test.js +32 -0
- package/actions/forms/validate/action.json +24 -0
- package/actions/forms/validate/action.test.js +32 -0
- package/actions/http/delete/action.json +22 -0
- package/actions/http/delete/action.test.js +32 -0
- package/actions/http/get/action.json +24 -0
- package/actions/http/get/action.test.js +32 -0
- package/actions/http/post/action.json +30 -0
- package/actions/http/post/action.test.js +32 -0
- package/actions/http/put/action.json +27 -0
- package/actions/http/put/action.test.js +32 -0
- package/actions/http/upload/action.json +35 -0
- package/actions/http/upload/action.test.js +32 -0
- package/actions/index.js +306 -0
- package/actions/index.json +5 -0
- package/actions/input/askChoice/action.json +29 -0
- package/actions/input/askChoice/action.test.js +32 -0
- package/actions/input/askConfirm/action.json +24 -0
- package/actions/input/askConfirm/action.test.js +32 -0
- package/actions/input/askDate/action.json +24 -0
- package/actions/input/askDate/action.test.js +32 -0
- package/actions/input/askEmail/action.json +24 -0
- package/actions/input/askEmail/action.test.js +32 -0
- package/actions/input/askNumber/action.json +35 -0
- package/actions/input/askNumber/action.test.js +32 -0
- package/actions/input/askPassword/action.json +24 -0
- package/actions/input/askPassword/action.test.js +32 -0
- package/actions/input/askText/action.json +36 -0
- package/actions/input/askText/action.test.js +32 -0
- package/actions/logic/delay/action.json +18 -0
- package/actions/logic/delay/action.test.js +32 -0
- package/actions/logic/if/action.json +28 -0
- package/actions/logic/if/action.test.js +32 -0
- package/actions/logic/log/action.json +18 -0
- package/actions/logic/log/action.test.js +32 -0
- package/actions/logic/random/action.json +36 -0
- package/actions/logic/random/action.test.js +32 -0
- package/actions/logic/transform/action.json +24 -0
- package/actions/logic/transform/action.test.js +32 -0
- package/actions/media/askAudioUpload/action.json +30 -0
- package/actions/media/askAudioUpload/action.test.js +32 -0
- package/actions/media/askFileUpload/action.json +36 -0
- package/actions/media/askFileUpload/action.test.js +32 -0
- package/actions/media/askImageUpload/action.json +37 -0
- package/actions/media/askImageUpload/action.test.js +32 -0
- package/actions/media/askVideoUpload/action.js +74 -0
- package/actions/media/askVideoUpload/action.json +44 -0
- package/actions/media/askVideoUpload/action.test.js +51 -0
- package/actions/media/captureWebcam/action.json +24 -0
- package/actions/media/captureWebcam/action.test.js +32 -0
- package/actions/nav/back/action.json +11 -0
- package/actions/nav/back/action.test.js +35 -0
- package/actions/nav/goto/action.json +18 -0
- package/actions/nav/goto/action.test.js +32 -0
- package/actions/nav/redirect/action.json +28 -0
- package/actions/nav/redirect/action.test.js +32 -0
- package/actions/nav/reload/action.json +11 -0
- package/actions/nav/reload/action.test.js +35 -0
- package/actions/nav/reset/action.json +11 -0
- package/actions/nav/reset/action.test.js +35 -0
- package/actions/render/alert/action.js +12 -0
- package/actions/render/alert/action.json +36 -0
- package/actions/render/alert/action.test.js +32 -0
- package/actions/render/button/action.js +15 -0
- package/actions/render/button/action.json +44 -0
- package/actions/render/button/action.test.js +37 -0
- package/actions/render/clear/action.js +7 -0
- package/actions/render/clear/action.json +11 -0
- package/actions/render/clear/action.test.js +35 -0
- package/actions/render/divider/action.js +8 -0
- package/actions/render/divider/action.json +11 -0
- package/actions/render/divider/action.test.js +35 -0
- package/actions/render/field/action.js +17 -0
- package/actions/render/field/action.json +59 -0
- package/actions/render/field/action.test.js +57 -0
- package/actions/render/link/action.js +20 -0
- package/actions/render/link/action.json +30 -0
- package/actions/render/link/action.test.js +32 -0
- package/actions/render/markdown/action.json +18 -0
- package/actions/render/markdown/action.test.js +32 -0
- package/actions/render/paragraph/action.js +9 -0
- package/actions/render/paragraph/action.json +32 -0
- package/actions/render/paragraph/action.test.js +32 -0
- package/actions/render/section/action.js +10 -0
- package/actions/render/section/action.json +18 -0
- package/actions/render/section/action.test.js +32 -0
- package/actions/render/subtitle/action.js +9 -0
- package/actions/render/subtitle/action.json +18 -0
- package/actions/render/subtitle/action.test.js +32 -0
- package/actions/render/title/action.js +9 -0
- package/actions/render/title/action.json +30 -0
- package/actions/render/title/action.test.js +44 -0
- package/actions/session/clear/action.json +11 -0
- package/actions/session/clear/action.test.js +35 -0
- package/actions/session/load/action.json +22 -0
- package/actions/session/load/action.test.js +32 -0
- package/actions/session/local/action.json +22 -0
- package/actions/session/local/action.test.js +32 -0
- package/actions/session/save/action.json +22 -0
- package/actions/session/save/action.test.js +32 -0
- package/actions/ui/accordion/action.json +23 -0
- package/actions/ui/accordion/action.test.js +32 -0
- package/actions/ui/badge/action.json +22 -0
- package/actions/ui/badge/action.test.js +32 -0
- package/actions/ui/collapse/action.json +23 -0
- package/actions/ui/collapse/action.test.js +32 -0
- package/actions/ui/hideModal/action.json +17 -0
- package/actions/ui/hideModal/action.test.js +32 -0
- package/actions/ui/loading/action.json +18 -0
- package/actions/ui/loading/action.test.js +32 -0
- package/actions/ui/modal/action.json +18 -0
- package/actions/ui/modal/action.test.js +32 -0
- package/actions/ui/progress/action.json +24 -0
- package/actions/ui/progress/action.test.js +32 -0
- package/actions/ui/toast/action.json +29 -0
- package/actions/ui/toast/action.test.js +32 -0
- package/actions/ui/tooltip/action.json +17 -0
- package/actions/ui/tooltip/action.test.js +32 -0
- package/actions/user/carry/action.json +25 -0
- package/actions/user/carry/action.test.js +32 -0
- package/actions/user/check/action.json +24 -0
- package/actions/user/check/action.test.js +32 -0
- package/actions/user/clear/action.json +11 -0
- package/actions/user/clear/action.test.js +35 -0
- package/actions/user/delete/action.json +17 -0
- package/actions/user/delete/action.test.js +32 -0
- package/actions/user/dump/action.json +11 -0
- package/actions/user/dump/action.test.js +35 -0
- package/actions/user/get/action.json +24 -0
- package/actions/user/get/action.test.js +32 -0
- package/actions/user/merge/action.json +18 -0
- package/actions/user/merge/action.test.js +32 -0
- package/actions/user/set/action.json +24 -0
- package/actions/user/set/action.test.js +32 -0
- package/generator/base/css/bootstrap.min.css +6 -0
- package/generator/base/icons/app-indicator.svg +4 -0
- package/generator/base/icons/backpack.svg +4 -0
- package/generator/base/icons/broadcast.svg +3 -0
- package/generator/base/icons/bullseye.svg +6 -0
- package/generator/base/icons/chat-dots.svg +4 -0
- package/generator/base/icons/check-circle.svg +4 -0
- package/generator/base/icons/clipboard-check.svg +5 -0
- package/generator/base/icons/clipboard.svg +4 -0
- package/generator/base/icons/copy.svg +3 -0
- package/generator/base/icons/cursor.svg +3 -0
- package/generator/base/icons/diamond.svg +3 -0
- package/generator/base/icons/exclamation-triangle.svg +4 -0
- package/generator/base/icons/film.svg +3 -0
- package/generator/base/icons/floppy.svg +4 -0
- package/generator/base/icons/gear-wide-connected.svg +3 -0
- package/generator/base/icons/gear.svg +4 -0
- package/generator/base/icons/globe.svg +3 -0
- package/generator/base/icons/image.svg +4 -0
- package/generator/base/icons/layout-text-window.svg +4 -0
- package/generator/base/icons/lightning-charge.svg +3 -0
- package/generator/base/icons/magic.svg +3 -0
- package/generator/base/icons/pencil-square.svg +4 -0
- package/generator/base/icons/record-circle.svg +4 -0
- package/generator/base/icons/robot.svg +4 -0
- package/generator/base/icons/shield-check.svg +4 -0
- package/generator/base/icons/shield-lock.svg +4 -0
- package/generator/base/icons/signpost.svg +3 -0
- package/generator/base/icons/stars.svg +3 -0
- package/generator/base/icons/type.svg +3 -0
- package/generator/base/js/bootstrap.bundle.min.js +7 -0
- package/package.json +14 -0
- package/packages/undercity-http-server/index.js +249 -0
- package/packages/undercity-http-server/package.json +10 -0
- package/packages/undercity-parser/index.js +323 -0
- package/packages/undercity-parser/lexer.js +128 -0
- package/packages/undercity-parser/package.json +11 -0
- package/plugins/forms.js +397 -0
- package/plugins/index.js +83 -0
- package/plugins/multipage.js +165 -0
- package/plugins/wizard.js +239 -0
- package/projects/asd/project.json +1031 -0
- package/projects/test-1/project.json +335 -0
- package/projects/test-a/project.json +456 -0
- package/public/icons/arrows-angle-expand.svg +3 -0
- package/public/icons/arrows-fullscreen.svg +3 -0
- package/public/icons/bezier2.svg +3 -0
- package/public/icons/bootstrap/app-indicator.svg +4 -0
- package/public/icons/bootstrap/arrow-clockwise.svg +4 -0
- package/public/icons/bootstrap/arrow-counterclockwise.svg +4 -0
- package/public/icons/bootstrap/arrow-left.svg +3 -0
- package/public/icons/bootstrap/arrows-angle-expand.svg +3 -0
- package/public/icons/bootstrap/arrows-fullscreen.svg +3 -0
- package/public/icons/bootstrap/backpack.svg +4 -0
- package/public/icons/bootstrap/bezier2.svg +3 -0
- package/public/icons/bootstrap/bookmark-check.svg +4 -0
- package/public/icons/bootstrap/bookmark-plus.svg +4 -0
- package/public/icons/bootstrap/box-arrow-right.svg +4 -0
- package/public/icons/bootstrap/box-arrow-up.svg +4 -0
- package/public/icons/bootstrap/broadcast.svg +3 -0
- package/public/icons/bootstrap/bullseye.svg +6 -0
- package/public/icons/bootstrap/chat-dots.svg +4 -0
- package/public/icons/bootstrap/check-circle.svg +4 -0
- package/public/icons/bootstrap/check2.svg +3 -0
- package/public/icons/bootstrap/clipboard-check.svg +5 -0
- package/public/icons/bootstrap/clipboard.svg +4 -0
- package/public/icons/bootstrap/clock-history.svg +5 -0
- package/public/icons/bootstrap/command.svg +3 -0
- package/public/icons/bootstrap/copy.svg +3 -0
- package/public/icons/bootstrap/cursor.svg +3 -0
- package/public/icons/bootstrap/diagram-3.svg +3 -0
- package/public/icons/bootstrap/diamond.svg +3 -0
- package/public/icons/bootstrap/exclamation-triangle.svg +4 -0
- package/public/icons/bootstrap/eye.svg +4 -0
- package/public/icons/bootstrap/file-earmark-plus.svg +4 -0
- package/public/icons/bootstrap/film.svg +3 -0
- package/public/icons/bootstrap/floppy.svg +4 -0
- package/public/icons/bootstrap/folder2-open.svg +3 -0
- package/public/icons/bootstrap/gear-wide-connected.svg +3 -0
- package/public/icons/bootstrap/gear.svg +4 -0
- package/public/icons/bootstrap/globe.svg +3 -0
- package/public/icons/bootstrap/grid-3x3-gap.svg +3 -0
- package/public/icons/bootstrap/house-door.svg +3 -0
- package/public/icons/bootstrap/image.svg +4 -0
- package/public/icons/bootstrap/layout-text-window.svg +4 -0
- package/public/icons/bootstrap/lightning-charge.svg +3 -0
- package/public/icons/bootstrap/magic.svg +3 -0
- package/public/icons/bootstrap/pencil-square.svg +4 -0
- package/public/icons/bootstrap/pencil.svg +3 -0
- package/public/icons/bootstrap/play.svg +3 -0
- package/public/icons/bootstrap/plus-circle.svg +4 -0
- package/public/icons/bootstrap/plus-lg.svg +3 -0
- package/public/icons/bootstrap/record-circle.svg +4 -0
- package/public/icons/bootstrap/robot.svg +4 -0
- package/public/icons/bootstrap/save.svg +3 -0
- package/public/icons/bootstrap/scissors.svg +3 -0
- package/public/icons/bootstrap/shield-check.svg +4 -0
- package/public/icons/bootstrap/shield-lock.svg +4 -0
- package/public/icons/bootstrap/signpost.svg +3 -0
- package/public/icons/bootstrap/stars.svg +3 -0
- package/public/icons/bootstrap/stop-circle.svg +4 -0
- package/public/icons/bootstrap/terminal.svg +4 -0
- package/public/icons/bootstrap/trash3.svg +3 -0
- package/public/icons/bootstrap/type.svg +3 -0
- package/public/icons/bootstrap/x-circle.svg +4 -0
- package/public/icons/bootstrap/x-lg.svg +3 -0
- package/public/icons/bootstrap/zoom-in.svg +5 -0
- package/public/icons/bootstrap/zoom-out.svg +5 -0
- package/public/icons/bullseye.svg +6 -0
- package/public/icons/check2.svg +3 -0
- package/public/icons/cursor.svg +3 -0
- package/public/icons/diamond.svg +3 -0
- package/public/icons/eye.svg +4 -0
- package/public/icons/file-earmark-plus.svg +4 -0
- package/public/icons/floppy.svg +4 -0
- package/public/icons/gear.svg +4 -0
- package/public/icons/lightning-charge.svg +3 -0
- package/public/icons/pencil.svg +3 -0
- package/public/icons/play.svg +3 -0
- package/public/icons/plus-circle.svg +4 -0
- package/public/icons/record-circle.svg +4 -0
- package/public/icons/robot.svg +4 -0
- package/public/icons/save.svg +3 -0
- package/public/icons/stop-circle.svg +4 -0
- package/public/icons/terminal.svg +4 -0
- package/public/icons/trash3.svg +3 -0
- package/public/icons/x-circle.svg +4 -0
- package/public/icons/zoom-in.svg +5 -0
- package/public/icons/zoom-out.svg +5 -0
- package/public/index.html +424 -0
- package/public/testbench.html +899 -0
- package/scripts/extract-actions.js +128 -0
- package/server.js +11 -0
- package/src/emitter.js +48 -0
- package/src/generator/css.js +135 -0
- package/src/generator/index.js +122 -0
- package/src/generator/md-renderer-src.js +77 -0
- package/src/generator/page.js +300 -0
- package/src/generator/runtime.js +1632 -0
- package/src/generator/templates.js +508 -0
- package/src/ide/action-library.js +856 -0
- package/src/ide/af-icons.js +127 -0
- package/src/ide/app.js +1375 -0
- package/src/ide/command-line/commands.js +242 -0
- package/src/ide/command-line/index.js +329 -0
- package/src/ide/command-line/parser.js +21 -0
- package/src/ide/css/ide.css +1501 -0
- package/src/ide/graph.js +282 -0
- package/src/ide/history.js +46 -0
- package/src/ide/map-builder.js +583 -0
- package/src/ide/project-api.js +39 -0
- package/src/ide/savant-chat.js +513 -0
- package/src/ide/savant.js +1287 -0
- package/src/ide/thing-library.js +89 -0
- package/src/ide/undercity-map.js +978 -0
- package/src/lib/icons.js +72 -0
- package/src/lib/scope.js +88 -0
- package/src/lib/signal.js +155 -0
- package/src/lib/state-machine.js +113 -0
- package/src/server/index.js +96 -0
- package/src/server/routes/actions.js +144 -0
- package/src/server/routes/ai.js +176 -0
- package/src/server/routes/generate.js +54 -0
- package/src/server/routes/projects.js +106 -0
- package/src/server/routes/reset.js +30 -0
- package/src/server/routes/submit.js +30 -0
- package/src/server/routes/templates.js +139 -0
- package/src/server/routes/things.js +33 -0
- package/templates/auth-flow.json +335 -0
- package/templates/blank.json +39 -0
- package/things/auth-server/thing.json +17 -0
- package/things/persona-live/thing.json +20 -0
- package/things/workflow/thing.json +15 -0
package/src/lib/icons.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
const ICON_NAME_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
2
|
+
|
|
3
|
+
export const DEFAULT_ICON_NAME = 'stars';
|
|
4
|
+
|
|
5
|
+
export const LEGACY_ICON_MAP = Object.freeze({
|
|
6
|
+
'🔐': 'shield-lock',
|
|
7
|
+
'⬜': 'app-indicator',
|
|
8
|
+
'✦': 'stars',
|
|
9
|
+
'✅': 'check-circle',
|
|
10
|
+
'✓': 'check-circle',
|
|
11
|
+
'⚠️': 'exclamation-triangle',
|
|
12
|
+
'⚠': 'exclamation-triangle',
|
|
13
|
+
'📋': 'clipboard-check',
|
|
14
|
+
'✎': 'pencil-square',
|
|
15
|
+
'✏️': 'pencil-square',
|
|
16
|
+
'🤖': 'robot',
|
|
17
|
+
'✨': 'magic',
|
|
18
|
+
'⚡': 'lightning-charge',
|
|
19
|
+
'⌘': 'command',
|
|
20
|
+
'⊡': 'arrows-angle-expand',
|
|
21
|
+
'⟳': 'cursor',
|
|
22
|
+
'●': 'record-circle',
|
|
23
|
+
'◆': 'diamond',
|
|
24
|
+
'◎': 'bullseye',
|
|
25
|
+
'→': 'box-arrow-right',
|
|
26
|
+
'✕': 'x-lg',
|
|
27
|
+
'⧉': 'copy',
|
|
28
|
+
'🧭': 'signpost',
|
|
29
|
+
'🎒': 'backpack',
|
|
30
|
+
'🖼': 'image',
|
|
31
|
+
'🎬': 'film',
|
|
32
|
+
'🌐': 'globe',
|
|
33
|
+
'💬': 'chat-dots',
|
|
34
|
+
'📡': 'broadcast',
|
|
35
|
+
'⚙️': 'gear',
|
|
36
|
+
'💾': 'floppy',
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
function escapeAttr(str) {
|
|
40
|
+
return String(str ?? '')
|
|
41
|
+
.replace(/&/g, '&')
|
|
42
|
+
.replace(/"/g, '"')
|
|
43
|
+
.replace(/</g, '<')
|
|
44
|
+
.replace(/>/g, '>');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function normalizeIconName(value, fallback = DEFAULT_ICON_NAME) {
|
|
48
|
+
if (typeof value !== 'string') return fallback;
|
|
49
|
+
const trimmed = value.trim();
|
|
50
|
+
if (!trimmed) return fallback;
|
|
51
|
+
|
|
52
|
+
const normalized = trimmed.toLowerCase();
|
|
53
|
+
if (ICON_NAME_RE.test(normalized)) return normalized;
|
|
54
|
+
|
|
55
|
+
return LEGACY_ICON_MAP[trimmed] ?? fallback;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function renderAfIcon(value, attrs = {}, fallback = DEFAULT_ICON_NAME) {
|
|
59
|
+
const name = normalizeIconName(value, fallback);
|
|
60
|
+
const parts = [`name="${escapeAttr(name)}"`];
|
|
61
|
+
|
|
62
|
+
for (const [key, rawValue] of Object.entries(attrs)) {
|
|
63
|
+
if (rawValue === false || rawValue === null || rawValue === undefined) continue;
|
|
64
|
+
if (rawValue === true) {
|
|
65
|
+
parts.push(key);
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
parts.push(`${key}="${escapeAttr(rawValue)}"`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return `<af-icon ${parts.join(' ')}></af-icon>`;
|
|
72
|
+
}
|
package/src/lib/scope.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scope.js — Hierarchical resource management.
|
|
3
|
+
*
|
|
4
|
+
* Inspired by dirge (github.com/nicholasgasior/dirge).
|
|
5
|
+
* A Scope holds cleanup callbacks in a named tree.
|
|
6
|
+
* On dispose(), children run first (depth-first), then local resources LIFO.
|
|
7
|
+
*
|
|
8
|
+
* Accepts three cleanup resource shapes:
|
|
9
|
+
* function — called directly
|
|
10
|
+
* { dispose } — Disposable from signal.js (subscription, event handle)
|
|
11
|
+
* { [Symbol.dispose] } — TC39 explicit resource management
|
|
12
|
+
*
|
|
13
|
+
* Usage — package lifecycle:
|
|
14
|
+
*
|
|
15
|
+
* let _scope = null;
|
|
16
|
+
*
|
|
17
|
+
* export function activate(env) {
|
|
18
|
+
* _scope = new Scope('my-package');
|
|
19
|
+
*
|
|
20
|
+
* const opener = env.workspace.addOpener(uri => { ... });
|
|
21
|
+
* _scope.add(() => opener.dispose());
|
|
22
|
+
*
|
|
23
|
+
* const handler = () => { ... };
|
|
24
|
+
* document.addEventListener('my-event', handler);
|
|
25
|
+
* _scope.add(() => document.removeEventListener('my-event', handler));
|
|
26
|
+
* }
|
|
27
|
+
*
|
|
28
|
+
* export function deactivate() {
|
|
29
|
+
* _scope?.dispose(); // all resources cleaned up in one call
|
|
30
|
+
* _scope = null;
|
|
31
|
+
* }
|
|
32
|
+
*
|
|
33
|
+
* Usage — child scopes for different lifetimes:
|
|
34
|
+
*
|
|
35
|
+
* // Per-render listeners that must be replaced on next render:
|
|
36
|
+
* const perRender = this.#scope.scope('per-render');
|
|
37
|
+
* perRender.dispose(); // remove previous listeners
|
|
38
|
+
* perRender.add(() => el.removeEventListener('dragover', onDragover));
|
|
39
|
+
*
|
|
40
|
+
* // The parent scope disposes all children on its own dispose().
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
export class Scope {
|
|
44
|
+
#fns = [];
|
|
45
|
+
#children = new Map(); // name → Scope
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Add a cleanup resource. Returns `this` for fluent chaining.
|
|
49
|
+
* scope.add(signal.subscribe(fn))
|
|
50
|
+
* scope.add(() => el.removeEventListener('click', handler))
|
|
51
|
+
*/
|
|
52
|
+
add(resource) {
|
|
53
|
+
this.#fns.push(resource);
|
|
54
|
+
return this;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Get or create a named child scope.
|
|
59
|
+
* The child disposes before this scope's own resources.
|
|
60
|
+
* Calling scope.scope(name).dispose() clears only that child;
|
|
61
|
+
* scope.scope(name) again returns the same (now empty) child.
|
|
62
|
+
*/
|
|
63
|
+
scope(name) {
|
|
64
|
+
if (!this.#children.has(name)) this.#children.set(name, new Scope());
|
|
65
|
+
return this.#children.get(name);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Dispose all children (depth-first), then local resources (LIFO).
|
|
70
|
+
* After disposal the scope is empty and can be reused.
|
|
71
|
+
*/
|
|
72
|
+
dispose() {
|
|
73
|
+
for (const child of this.#children.values()) child.dispose();
|
|
74
|
+
// Don't clear children Map — named children remain accessible for reuse.
|
|
75
|
+
// (Their resources are empty after dispose; adding to them works again.)
|
|
76
|
+
|
|
77
|
+
for (let i = this.#fns.length - 1; i >= 0; i--) {
|
|
78
|
+
const r = this.#fns[i];
|
|
79
|
+
try {
|
|
80
|
+
if (typeof r === 'function') r();
|
|
81
|
+
else r?.dispose?.() ?? r?.[Symbol.dispose]?.();
|
|
82
|
+
} catch (err) {
|
|
83
|
+
console.error('[Scope] dispose error:', err);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
this.#fns.length = 0;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* signal.js — Reactive primitives
|
|
3
|
+
*
|
|
4
|
+
* Signal — reactive value cell. subscribe(fn, autorun=true) fires fn immediately
|
|
5
|
+
* by default, then on every change. autorun=false defers first fire.
|
|
6
|
+
* Static helpers: combineLatest(), derive(), from(). Instance: map().
|
|
7
|
+
* Emitter — typed event emitter.
|
|
8
|
+
* Disposable — single cleanup handle.
|
|
9
|
+
* CompositeDisposable — group of disposables, disposed in reverse order.
|
|
10
|
+
* Repeater — keyed list renderer (reconciles add/remove/reorder without clearing).
|
|
11
|
+
* on() — DOM addEventListener returning a Disposable.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export class Signal {
|
|
15
|
+
#value;
|
|
16
|
+
#subs = new Set();
|
|
17
|
+
#collected = [];
|
|
18
|
+
|
|
19
|
+
constructor(init) { this.#value = init; }
|
|
20
|
+
|
|
21
|
+
get value() { return this.#value; }
|
|
22
|
+
set value(v) {
|
|
23
|
+
if (v === this.#value) return;
|
|
24
|
+
this.#value = v;
|
|
25
|
+
for (const fn of [...this.#subs]) fn(v);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Subscribe to changes. autorun=true fires fn immediately with current value. */
|
|
29
|
+
subscribe(fn, autorun = true) {
|
|
30
|
+
this.#subs.add(fn);
|
|
31
|
+
if (autorun) fn(this.#value);
|
|
32
|
+
return new Disposable(() => this.#subs.delete(fn));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Re-fire all subscribers with the current value (used after in-place mutations). */
|
|
36
|
+
notify() {
|
|
37
|
+
for (const fn of [...this.#subs]) fn(this.#value);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
peek() { return this.#value; }
|
|
41
|
+
|
|
42
|
+
/** Attach a cleanup function that runs when this signal is disposed. */
|
|
43
|
+
collect(fn) { this.#collected.push(fn); }
|
|
44
|
+
|
|
45
|
+
/** Run all collected cleanup functions and clear the list. */
|
|
46
|
+
dispose() {
|
|
47
|
+
const fns = this.#collected.splice(0);
|
|
48
|
+
for (const fn of fns) fn();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Combine multiple signals into one Signal whose value is an array of
|
|
53
|
+
* the current values of all inputs. Fires whenever any input changes.
|
|
54
|
+
* The returned signal has a dispose() that cleans up all subscriptions.
|
|
55
|
+
*/
|
|
56
|
+
static combineLatest(signals) {
|
|
57
|
+
const out = new Signal(signals.map(s => s.value));
|
|
58
|
+
const subs = signals.map((s, i) => s.subscribe(v => {
|
|
59
|
+
const next = out.value.slice();
|
|
60
|
+
next[i] = v;
|
|
61
|
+
out.value = next;
|
|
62
|
+
}, false));
|
|
63
|
+
out.collect(() => subs.forEach(sub => sub.dispose()));
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Create a derived Signal that updates when source changes. Disposable via .dispose(). */
|
|
68
|
+
static derive(source, transform) {
|
|
69
|
+
const derived = new Signal(transform(source.value));
|
|
70
|
+
const sub = source.subscribe(v => { derived.value = transform(v); }, false);
|
|
71
|
+
derived.collect(() => sub.dispose());
|
|
72
|
+
return derived;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Wrap a Promise as a Signal. Starts at `initial`, resolves to the value when ready. */
|
|
76
|
+
static from(promise, initial = null) {
|
|
77
|
+
const sig = new Signal(initial);
|
|
78
|
+
Promise.resolve(promise).then(v => { sig.value = v; }).catch(() => {});
|
|
79
|
+
return sig;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Shorthand for Signal.derive(this, fn). */
|
|
83
|
+
map(fn) { return Signal.derive(this, fn); }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export class Emitter {
|
|
87
|
+
#map = new Map();
|
|
88
|
+
|
|
89
|
+
on(event, fn) {
|
|
90
|
+
let set = this.#map.get(event);
|
|
91
|
+
if (!set) { set = new Set(); this.#map.set(event, set); }
|
|
92
|
+
set.add(fn);
|
|
93
|
+
return new Disposable(() => set.delete(fn));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
emit(event, data) {
|
|
97
|
+
for (const fn of this.#map.get(event) ?? []) fn(data);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export class Disposable {
|
|
102
|
+
#fn; #done = false;
|
|
103
|
+
constructor(fn) { this.#fn = fn; }
|
|
104
|
+
dispose() { if (this.#done) return; this.#done = true; this.#fn(); }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export class CompositeDisposable {
|
|
108
|
+
#items = []; #done = false;
|
|
109
|
+
add(...items) { this.#items.push(...items); return this; }
|
|
110
|
+
dispose() {
|
|
111
|
+
if (this.#done) return; this.#done = true;
|
|
112
|
+
for (const d of [...this.#items].reverse()) d.dispose();
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Keyed list renderer — reconciles add/remove/reorder without clearing the container.
|
|
118
|
+
* Implements dispose() so it can be passed directly to a Scope:
|
|
119
|
+
* scope.add(new Repeater(container, signal, render))
|
|
120
|
+
*/
|
|
121
|
+
export class Repeater {
|
|
122
|
+
#container; #render; #key; #nodes = new Map();
|
|
123
|
+
#sub;
|
|
124
|
+
|
|
125
|
+
constructor(container, signal, render, { key = 'id' } = {}) {
|
|
126
|
+
this.#container = container;
|
|
127
|
+
this.#render = render;
|
|
128
|
+
this.#key = typeof key === 'function' ? key : item => item[key];
|
|
129
|
+
this.#sub = signal.subscribe(items => this.#update(items));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
dispose() {
|
|
133
|
+
this.#sub.dispose();
|
|
134
|
+
this.#nodes.clear();
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
#update(items) {
|
|
138
|
+
const incoming = new Map(items.map(item => [this.#key(item), item]));
|
|
139
|
+
for (const [k, node] of this.#nodes) {
|
|
140
|
+
if (!incoming.has(k)) { node.remove(); this.#nodes.delete(k); }
|
|
141
|
+
}
|
|
142
|
+
for (const item of items) {
|
|
143
|
+
const k = this.#key(item);
|
|
144
|
+
let node = this.#nodes.get(k);
|
|
145
|
+
if (!node) { node = this.#render(item); this.#nodes.set(k, node); }
|
|
146
|
+
this.#container.appendChild(node);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** DOM addEventListener returning a Disposable. */
|
|
152
|
+
export function on(target, type, handler, opts) {
|
|
153
|
+
target.addEventListener(type, handler, opts);
|
|
154
|
+
return new Disposable(() => target.removeEventListener(type, handler, opts));
|
|
155
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* state-machine — JSON-declared hybrid state machine.
|
|
3
|
+
*
|
|
4
|
+
* Each context key in `config.state` becomes a named Signal on the machine,
|
|
5
|
+
* so consumers subscribe directly: `machine.dirty.subscribe(v => ...)`.
|
|
6
|
+
*
|
|
7
|
+
* The event bus (framework Emitter) drives transitions automatically.
|
|
8
|
+
* Calling `machine.emit('save')` fires the bus; the machine reads the current
|
|
9
|
+
* state, finds the next state in `states[current].on.save`, and enters it.
|
|
10
|
+
*
|
|
11
|
+
* `enter` patches can be plain values or functions:
|
|
12
|
+
* enter: { filename: ({ payload }) => payload ?? '' }
|
|
13
|
+
*
|
|
14
|
+
* Usage:
|
|
15
|
+
* import { createMachine } from 'state-machine';
|
|
16
|
+
*
|
|
17
|
+
* const machine = createMachine({
|
|
18
|
+
* state: { dirty: false, saving: false },
|
|
19
|
+
* initial: 'idle',
|
|
20
|
+
* states: {
|
|
21
|
+
* idle: { enter: { dirty: false, saving: false }, on: { edit: 'dirty' } },
|
|
22
|
+
* dirty: { enter: { dirty: true }, on: { save: 'saving' } },
|
|
23
|
+
* saving: { enter: { saving: true }, on: { 'save-ok': 'idle', 'save-fail': 'dirty' } },
|
|
24
|
+
* },
|
|
25
|
+
* });
|
|
26
|
+
*
|
|
27
|
+
* machine.dirty.subscribe(v => console.log('dirty:', v));
|
|
28
|
+
* machine.emit('edit'); // dirty → true
|
|
29
|
+
* machine.emit('save'); // saving → true
|
|
30
|
+
* machine.emit('save-ok');// dirty → false, saving → false
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import { Signal, Emitter } from 'framework';
|
|
34
|
+
|
|
35
|
+
// ── MapSet ─────────────────────────────────────────────────────────────────────
|
|
36
|
+
// Map<key, Set<value>> — useful for multi-listener registries.
|
|
37
|
+
|
|
38
|
+
export class MapSet {
|
|
39
|
+
#map = new Map();
|
|
40
|
+
|
|
41
|
+
add(key, value) {
|
|
42
|
+
let set = this.#map.get(key);
|
|
43
|
+
if (!set) { set = new Set(); this.#map.set(key, set); }
|
|
44
|
+
set.add(value);
|
|
45
|
+
return this;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
delete(key, value) {
|
|
49
|
+
const set = this.#map.get(key);
|
|
50
|
+
if (!set) return false;
|
|
51
|
+
const deleted = set.delete(value);
|
|
52
|
+
if (set.size === 0) this.#map.delete(key);
|
|
53
|
+
return deleted;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
get(key) { return this.#map.get(key); }
|
|
57
|
+
keys() { return this.#map.keys(); }
|
|
58
|
+
values() { return this.#map.values(); }
|
|
59
|
+
entries() { return this.#map.entries(); }
|
|
60
|
+
has(key) { return this.#map.has(key); }
|
|
61
|
+
get size() { return this.#map.size; }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ── createMachine ──────────────────────────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
export function createMachine(config) {
|
|
67
|
+
const bus = new Emitter();
|
|
68
|
+
|
|
69
|
+
const machine = {
|
|
70
|
+
bus,
|
|
71
|
+
current: new Signal(config.initial),
|
|
72
|
+
signals: {},
|
|
73
|
+
/** Dispatch an event into the machine. payload is optional. */
|
|
74
|
+
emit(event, payload) { bus.emit(event, payload); },
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
// Create a named Signal for each context key.
|
|
78
|
+
for (const [key, initialValue] of Object.entries(config.state)) {
|
|
79
|
+
const sig = new Signal(initialValue);
|
|
80
|
+
machine.signals[key] = sig;
|
|
81
|
+
machine[key] = sig;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function applyPatch(patch = {}, payload) {
|
|
85
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
86
|
+
if (!machine.signals[key]) throw new Error(`state-machine: unknown signal "${key}" in enter patch`);
|
|
87
|
+
machine.signals[key].value = typeof value === 'function'
|
|
88
|
+
? value({ payload, machine, current: machine.current.value })
|
|
89
|
+
: value;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function enterState(name, payload) {
|
|
94
|
+
const stateDef = config.states[name];
|
|
95
|
+
if (!stateDef) throw new Error(`state-machine: unknown state "${name}"`);
|
|
96
|
+
machine.current.value = name;
|
|
97
|
+
applyPatch(stateDef.enter ?? {}, payload);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Auto-discover all event names and wire them to transitions.
|
|
101
|
+
const events = new Set(
|
|
102
|
+
Object.values(config.states).flatMap(s => Object.keys(s.on ?? {}))
|
|
103
|
+
);
|
|
104
|
+
for (const event of events) {
|
|
105
|
+
bus.on(event, payload => {
|
|
106
|
+
const next = config.states[machine.current.value]?.on?.[event];
|
|
107
|
+
if (next) enterState(next, payload);
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
enterState(config.initial);
|
|
112
|
+
return machine;
|
|
113
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/server/index.js — HTTP server setup.
|
|
3
|
+
*
|
|
4
|
+
* createServer(options?) builds the Express-compatible app.
|
|
5
|
+
* listen(app, port?) starts it and returns {server, port, url, close}.
|
|
6
|
+
*
|
|
7
|
+
* Options accepted by createServer():
|
|
8
|
+
* projDir — absolute path to the projects directory (default: <root>/projects)
|
|
9
|
+
* genDir — absolute path to the generated directory (default: <root>/generated)
|
|
10
|
+
*
|
|
11
|
+
* Passing explicit directories is how integration tests achieve full isolation:
|
|
12
|
+
* const app = createServer({ projDir: tmpDir, genDir: tmpGenDir });
|
|
13
|
+
* const { url, close } = await listen(app, 0); // port 0 → OS picks one
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { join, dirname } from 'path';
|
|
17
|
+
import { fileURLToPath } from 'url';
|
|
18
|
+
|
|
19
|
+
import express from '../../packages/undercity-http-server/index.js';
|
|
20
|
+
|
|
21
|
+
import { registerProjectRoutes } from './routes/projects.js';
|
|
22
|
+
import { registerGenerateRoute } from './routes/generate.js';
|
|
23
|
+
import { registerAIRoute } from './routes/ai.js';
|
|
24
|
+
import { registerSubmitRoute } from './routes/submit.js';
|
|
25
|
+
import { registerTemplatesRoute } from './routes/templates.js';
|
|
26
|
+
import { registerActionsRoute } from './routes/actions.js';
|
|
27
|
+
import { registerResetRoute } from './routes/reset.js';
|
|
28
|
+
import { registerThingsRoute } from './routes/things.js';
|
|
29
|
+
|
|
30
|
+
const __dir = dirname(fileURLToPath(import.meta.url));
|
|
31
|
+
const ROOT = join(__dir, '..', '..');
|
|
32
|
+
|
|
33
|
+
const DEFAULT_PROJ = join(ROOT, 'projects');
|
|
34
|
+
const DEFAULT_GENDIR = join(ROOT, 'generated');
|
|
35
|
+
const DEFAULT_PORT = process.env.PORT ?? 3000;
|
|
36
|
+
|
|
37
|
+
// ── App factory ────────────────────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
export function createServer({ projDir = DEFAULT_PROJ, genDir = DEFAULT_GENDIR } = {}) {
|
|
40
|
+
const app = express();
|
|
41
|
+
|
|
42
|
+
// Middleware
|
|
43
|
+
app.use(express.json({ limit: '4mb' }));
|
|
44
|
+
|
|
45
|
+
// Static assets
|
|
46
|
+
app.use('/lib/bootstrap', express.static(join(ROOT, 'generator', 'base')));
|
|
47
|
+
app.use('/packages', express.static(join(ROOT, 'packages')));
|
|
48
|
+
app.use('/src', express.static(join(ROOT, 'src')));
|
|
49
|
+
app.use('/generated', express.static(genDir));
|
|
50
|
+
app.use('/actions', express.static(join(ROOT, 'actions')));
|
|
51
|
+
app.use('/', express.static(join(ROOT, 'public')));
|
|
52
|
+
|
|
53
|
+
// API routes
|
|
54
|
+
registerProjectRoutes(app, projDir);
|
|
55
|
+
registerGenerateRoute(app, projDir, genDir);
|
|
56
|
+
registerAIRoute(app);
|
|
57
|
+
registerSubmitRoute(app);
|
|
58
|
+
registerTemplatesRoute(app);
|
|
59
|
+
registerActionsRoute(app);
|
|
60
|
+
registerResetRoute(app, projDir, genDir);
|
|
61
|
+
registerThingsRoute(app);
|
|
62
|
+
|
|
63
|
+
// Serve thing.json and associated assets
|
|
64
|
+
app.use('/things', express.static(join(ROOT, 'things')));
|
|
65
|
+
|
|
66
|
+
return app;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ── Server lifecycle ───────────────────────────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Start the app listening. Returns a promise that resolves once the server
|
|
73
|
+
* is ready, yielding { server, port, url, close }.
|
|
74
|
+
*
|
|
75
|
+
* Pass port = 0 for a random available port (useful in tests).
|
|
76
|
+
*/
|
|
77
|
+
export function listen(app, port) {
|
|
78
|
+
const listenPort = port ?? Number(DEFAULT_PORT);
|
|
79
|
+
return new Promise((resolve, reject) => {
|
|
80
|
+
const srv = app.listen(listenPort, () => {
|
|
81
|
+
const addr = srv.address();
|
|
82
|
+
const p = addr.port;
|
|
83
|
+
if (!port) {
|
|
84
|
+
// Production boot — emit to stdout
|
|
85
|
+
console.log(`\n Undercity IDE → http://localhost:${p}\n`);
|
|
86
|
+
}
|
|
87
|
+
resolve({
|
|
88
|
+
server: srv,
|
|
89
|
+
port: p,
|
|
90
|
+
url: `http://localhost:${p}`,
|
|
91
|
+
close: () => new Promise((res, rej) => srv.close(err => err ? rej(err) : res())),
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
srv.on('error', reject);
|
|
95
|
+
});
|
|
96
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* routes/actions.js — Action plugin discovery API.
|
|
3
|
+
*
|
|
4
|
+
* GET /api/actions/plugins
|
|
5
|
+
* Scans <root>/actions/<category>/<action-name>/action.json and returns
|
|
6
|
+
* all plugin definitions grouped by category, ready to merge into the IDE's
|
|
7
|
+
* ACTION_LIBRARY.
|
|
8
|
+
*
|
|
9
|
+
* Response shape:
|
|
10
|
+
* {
|
|
11
|
+
* "<categoryId>": {
|
|
12
|
+
* label: string,
|
|
13
|
+
* icon: string,
|
|
14
|
+
* color: string,
|
|
15
|
+
* actions: {
|
|
16
|
+
* "<action.id>": { label, desc, params, ... }
|
|
17
|
+
* }
|
|
18
|
+
* },
|
|
19
|
+
* ...
|
|
20
|
+
* }
|
|
21
|
+
*
|
|
22
|
+
* Each action.json must have at minimum: id, category, label, desc, params[].
|
|
23
|
+
* Optional fields: categoryLabel, icon, color, version.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { readdir, readFile } from 'fs/promises';
|
|
27
|
+
import { join, dirname } from 'path';
|
|
28
|
+
import { fileURLToPath } from 'url';
|
|
29
|
+
|
|
30
|
+
const __dir = dirname(fileURLToPath(import.meta.url));
|
|
31
|
+
const ACTIONS_DIR = join(__dir, '..', '..', '..', 'actions');
|
|
32
|
+
|
|
33
|
+
// Default icon/color per category (fallback when action.json omits them)
|
|
34
|
+
const CATEGORY_DEFAULTS = {
|
|
35
|
+
display: { icon: 'type', color: 'var(--sol-cyan)' },
|
|
36
|
+
render: { icon: 'layout-text-window', color: 'var(--sol-violet)' },
|
|
37
|
+
auth: { icon: 'shield-lock', color: 'var(--sol-blue)' },
|
|
38
|
+
media: { icon: 'film', color: 'var(--sol-magenta)' },
|
|
39
|
+
data: { icon: 'database', color: 'var(--sol-yellow)' },
|
|
40
|
+
notification: { icon: 'bell', color: 'var(--sol-orange)' },
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export function registerActionsRoute(app) {
|
|
44
|
+
/**
|
|
45
|
+
* GET /api/actions/tests
|
|
46
|
+
* Returns a list of { actionId, url } objects for all discovered action.test.js files.
|
|
47
|
+
* The testbench can dynamically import these to run per-action tests.
|
|
48
|
+
*/
|
|
49
|
+
app.get('/api/actions/tests', async (_req, res) => {
|
|
50
|
+
try {
|
|
51
|
+
const tests = [];
|
|
52
|
+
let catDirs;
|
|
53
|
+
try { catDirs = await readdir(ACTIONS_DIR, { withFileTypes: true }); }
|
|
54
|
+
catch { return res.json([]); }
|
|
55
|
+
|
|
56
|
+
for (const catEnt of catDirs) {
|
|
57
|
+
if (!catEnt.isDirectory()) continue;
|
|
58
|
+
const catDir = join(ACTIONS_DIR, catEnt.name);
|
|
59
|
+
let actionDirs;
|
|
60
|
+
try { actionDirs = await readdir(catDir, { withFileTypes: true }); }
|
|
61
|
+
catch { continue; }
|
|
62
|
+
|
|
63
|
+
for (const actEnt of actionDirs) {
|
|
64
|
+
if (!actEnt.isDirectory()) continue;
|
|
65
|
+
const testPath = join(catDir, actEnt.name, 'action.test.js');
|
|
66
|
+
try {
|
|
67
|
+
await readFile(testPath); // check existence
|
|
68
|
+
tests.push({
|
|
69
|
+
actionId: `${catEnt.name}.${actEnt.name}`,
|
|
70
|
+
url: `/actions/${catEnt.name}/${actEnt.name}/action.test.js`,
|
|
71
|
+
});
|
|
72
|
+
} catch { /* no test file */ }
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
res.json(tests);
|
|
76
|
+
} catch (err) {
|
|
77
|
+
console.error('[actions/tests]', err);
|
|
78
|
+
res.status(500).json({ error: err.message });
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* GET /api/actions/plugins
|
|
84
|
+
* Returns all discovered plugins merged into ACTION_LIBRARY-compatible shape.
|
|
85
|
+
*/
|
|
86
|
+
app.get('/api/actions/plugins', async (_req, res) => {
|
|
87
|
+
try {
|
|
88
|
+
const result = {};
|
|
89
|
+
|
|
90
|
+
// Each sub-directory of actions/ is a category
|
|
91
|
+
let catDirs;
|
|
92
|
+
try {
|
|
93
|
+
catDirs = await readdir(ACTIONS_DIR, { withFileTypes: true });
|
|
94
|
+
} catch {
|
|
95
|
+
return res.json({}); // actions/ dir missing → no plugins
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
for (const catEnt of catDirs) {
|
|
99
|
+
if (!catEnt.isDirectory()) continue;
|
|
100
|
+
const catId = catEnt.name;
|
|
101
|
+
const catDir = join(ACTIONS_DIR, catId);
|
|
102
|
+
|
|
103
|
+
let actionDirs;
|
|
104
|
+
try { actionDirs = await readdir(catDir, { withFileTypes: true }); }
|
|
105
|
+
catch { continue; }
|
|
106
|
+
|
|
107
|
+
for (const actEnt of actionDirs) {
|
|
108
|
+
if (!actEnt.isDirectory()) continue;
|
|
109
|
+
const manifestPath = join(catDir, actEnt.name, 'action.json');
|
|
110
|
+
|
|
111
|
+
let manifest;
|
|
112
|
+
try {
|
|
113
|
+
manifest = JSON.parse(await readFile(manifestPath, 'utf8'));
|
|
114
|
+
} catch { continue; } // skip malformed / missing manifests
|
|
115
|
+
|
|
116
|
+
if (!manifest.id || !manifest.label) continue;
|
|
117
|
+
|
|
118
|
+
// Initialise category bucket if first action from this category
|
|
119
|
+
if (!result[catId]) {
|
|
120
|
+
const defaults = CATEGORY_DEFAULTS[catId] ?? {};
|
|
121
|
+
result[catId] = {
|
|
122
|
+
label: manifest.categoryLabel ?? catId.charAt(0).toUpperCase() + catId.slice(1),
|
|
123
|
+
icon: manifest.icon ?? defaults.icon ?? 'puzzle',
|
|
124
|
+
color: manifest.color ?? defaults.color ?? 'var(--sol-base1)',
|
|
125
|
+
actions: {},
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
result[catId].actions[manifest.id] = {
|
|
130
|
+
label: manifest.label,
|
|
131
|
+
desc: manifest.desc ?? '',
|
|
132
|
+
params: manifest.params ?? [],
|
|
133
|
+
...(manifest.version ? { version: manifest.version } : {}),
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
res.json(result);
|
|
139
|
+
} catch (err) {
|
|
140
|
+
console.error('[actions/plugins]', err);
|
|
141
|
+
res.status(500).json({ error: err.message });
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
}
|