@morit/cli 1.3.0 → 1.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -5
- package/assets/plugin_contract.json +33 -8
- package/bin/morit.js +0 -0
- package/package.json +1 -1
- package/src/cli.js +5 -0
- package/src/preview.js +254 -17
- package/src/workspace.js +218 -19
- package/assets/docs/README.md +0 -107
- package/assets/docs/ai-response-and-timeline.md +0 -203
- package/assets/docs/ai-skill-and-docx-workflow.md +0 -83
- package/assets/docs/app-builder.md +0 -56
- package/assets/docs/authentication.md +0 -140
- package/assets/docs/components.md +0 -216
- package/assets/docs/design-tokens-responsive.md +0 -171
- package/assets/docs/docs-index.json +0 -94
- package/assets/docs/examples-notion.md +0 -83
- package/assets/docs/examples-school-life.md +0 -79
- package/assets/docs/getting-started.md +0 -132
- package/assets/docs/information-hierarchy.md +0 -81
- package/assets/docs/instances-and-connectors.md +0 -93
- package/assets/docs/lifecycle-and-api.md +0 -169
- package/assets/docs/local-cli.md +0 -125
- package/assets/docs/manifest.md +0 -234
- package/assets/docs/packaging-and-testing.md +0 -121
- package/assets/docs/permissions-and-data.md +0 -149
- package/assets/docs/platform-compatibility.md +0 -62
- package/assets/docs/plugin-storage.md +0 -175
- package/assets/docs/project-structure.md +0 -102
- package/assets/docs/remote-mcp.md +0 -158
- package/assets/docs/school-life-privacy.md +0 -55
- package/assets/docs/screens-layout-navigation.md +0 -95
- package/assets/docs/sdk-and-mcp.md +0 -199
- package/assets/docs/tool-and-skill.md +0 -172
- package/assets/docs/troubleshooting.md +0 -121
- package/assets/docs/ui-extensions.md +0 -75
- package/assets/docs/ui-runtime-v2.md +0 -343
- package/assets/docs/verification.md +0 -133
package/README.md
CHANGED
|
@@ -46,11 +46,9 @@ npx -y @morit/cli plugin sync . --pull # Cloud → local
|
|
|
46
46
|
## SDK와 문서
|
|
47
47
|
|
|
48
48
|
Node SDK는 `@morit/cli/sdk` export에서 같은 contract, validator, preview, build 기능을 제공합니다.
|
|
49
|
-
배포 package
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
Plugin MCP 설정과 Local/Remote Tool 흐름은 bundled `assets/docs/sdk-and-mcp.md`, manifest와 UI
|
|
53
|
-
계약은 `assets/docs/manifest.md`, `assets/docs/ui-runtime-v2.md`를 참고하세요.
|
|
49
|
+
배포 package에는 `assets/plugin_contract.json` 계약이 들어갑니다. 개발 문서는
|
|
50
|
+
`https://developers.moring.co/docs/morit-plugin/start`가 canonical 원본이며 Local MCP의
|
|
51
|
+
`morit_docs_search`와 `morit_docs_get`도 같은 원본을 ETag 캐시로 읽습니다.
|
|
54
52
|
|
|
55
53
|
## 파일과 보안 경계
|
|
56
54
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"sdk_version": "1.7.
|
|
2
|
+
"sdk_version": "1.7.12",
|
|
3
3
|
"manifest_schema_versions": [1, 2],
|
|
4
4
|
"recommended_schema_version": 2,
|
|
5
5
|
"package_content_type": "application/vnd.morit.plugin+zip",
|
|
@@ -45,7 +45,8 @@
|
|
|
45
45
|
"slash_commands": {"target": "slash_commands", "kind": null}
|
|
46
46
|
},
|
|
47
47
|
"object_fields": {
|
|
48
|
-
"
|
|
48
|
+
"http_json_response": ["collection_path", "id_path", "url_path", "title_paths", "content_paths", "max_items", "summary_prefix", "followup", "followups", "summary_path", "data_path"],
|
|
49
|
+
"capability": ["id", "kind", "title", "description", "permissions", "timeout_seconds", "runtime", "input_schema", "output_schema"],
|
|
49
50
|
"ui_extension": ["id", "point", "title", "order", "permissions", "config"],
|
|
50
51
|
"credential": ["id", "label", "description", "kind", "required", "oauth_provider", "allow_multiple"],
|
|
51
52
|
"slash_command": ["id", "command", "title", "description", "capability", "argument_template"],
|
|
@@ -116,10 +117,14 @@
|
|
|
116
117
|
"catalog_id": "https://moring.co/a2ui/catalogs/morit-ai/v1",
|
|
117
118
|
"selection": "ai_explicit_host_validated",
|
|
118
119
|
"selection_limit": 4,
|
|
119
|
-
|
|
120
|
-
|
|
120
|
+
"skill": "morit_a2ui",
|
|
121
|
+
"tool": "render_a2ui",
|
|
122
|
+
"model_selection_fields": ["candidate_id", "component", "values", "chart_type"],
|
|
123
|
+
"host_injects_tool_data": false,
|
|
124
|
+
"tool_values_must_equal_candidate": false,
|
|
125
|
+
"tool_values_must_be_candidate_grounded": true,
|
|
121
126
|
"fallback": "text_only",
|
|
122
|
-
"built_in_components": ["weather", "chart", "image_gallery", "exchange_rate", "article_list", "article_card", "file_result", "image_preview", "video_preview"],
|
|
127
|
+
"built_in_components": ["weather", "world_clock", "chart", "image_gallery", "exchange_rate", "article_list", "article_card", "file_result", "image_preview", "video_preview"],
|
|
123
128
|
"validation_gates": ["completed_or_partial", "non_empty", "component_data", "answer_relevance", "not_duplicate", "plugin_schema", "plugin_permission", "plugin_version"],
|
|
124
129
|
"plugin_component_prefix": "plugin.",
|
|
125
130
|
"plugin_metadata_fields": ["version", "description", "schema"],
|
|
@@ -278,6 +283,17 @@
|
|
|
278
283
|
"top",
|
|
279
284
|
"right",
|
|
280
285
|
"bottom"
|
|
286
|
+
,"required"
|
|
287
|
+
,"min_length"
|
|
288
|
+
,"max_length"
|
|
289
|
+
,"minimum"
|
|
290
|
+
,"maximum"
|
|
291
|
+
,"error_text"
|
|
292
|
+
,"suggestions"
|
|
293
|
+
,"suggestions_source"
|
|
294
|
+
,"searchable"
|
|
295
|
+
,"options_source"
|
|
296
|
+
,"submit_label"
|
|
281
297
|
],
|
|
282
298
|
"app_bar_fields": ["title", "subtitle", "center_title", "pinned", "leading", "actions"],
|
|
283
299
|
"navigation_fields": ["type", "items", "selected_state_key", "persist", "label_behavior", "rail_breakpoint"],
|
|
@@ -289,8 +305,11 @@
|
|
|
289
305
|
"theme_border_fields": ["color", "width", "radius"],
|
|
290
306
|
"theme_icon_fields": ["color", "size"],
|
|
291
307
|
"theme_state_fields": ["disabled_opacity", "selected_color", "focus_color"],
|
|
292
|
-
"action_fields": ["type", "capability", "query", "arguments", "store", "target", "source", "values", "persist", "transition", "replace"],
|
|
293
|
-
"actions": ["invoke", "navigate", "set_state", "refresh", "back"],
|
|
308
|
+
"action_fields": ["type", "capability", "query", "arguments", "store", "target", "source", "values", "persist", "transition", "replace", "mode", "actions", "continue_on_error", "validate", "parameters", "result_state", "result"],
|
|
309
|
+
"actions": ["invoke", "navigate", "set_state", "refresh", "back", "flow"],
|
|
310
|
+
"condition_fields": ["path", "equals", "not_equals", "exists", "all", "any", "not"],
|
|
311
|
+
"condition_context_roots": ["platform", "screen", "permissions", "connections", "connected", "loading", "errors", "route"],
|
|
312
|
+
"action_flow_modes": ["sequential", "parallel"],
|
|
294
313
|
"navigation_transitions": ["platform", "fade", "slide", "none"],
|
|
295
314
|
"chart_types": ["bar", "line", "donut", "scatter"],
|
|
296
315
|
"navigation_types": ["tabs", "bar", "rail", "drawer", "adaptive"],
|
|
@@ -408,7 +427,13 @@
|
|
|
408
427
|
"data_sources": 8,
|
|
409
428
|
"state_keys": 32,
|
|
410
429
|
"navigation_items": 8,
|
|
411
|
-
"app_bar_actions": 6
|
|
430
|
+
"app_bar_actions": 6,
|
|
431
|
+
"condition_depth": 4,
|
|
432
|
+
"condition_nodes": 32,
|
|
433
|
+
"action_flow_depth": 4,
|
|
434
|
+
"action_flow_actions": 16,
|
|
435
|
+
"actions_per_flow": 8,
|
|
436
|
+
"input_options": 32
|
|
412
437
|
}
|
|
413
438
|
},
|
|
414
439
|
"signature": {
|
package/bin/morit.js
CHANGED
|
File without changes
|
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -19,6 +19,7 @@ const HELP = `Official Morit Developer CLI
|
|
|
19
19
|
Usage:
|
|
20
20
|
morit login [--token <access-token>] [--api-url <origin>]
|
|
21
21
|
morit logout
|
|
22
|
+
morit docs
|
|
22
23
|
morit plugin setup [directory] --id <plugin.id> --name <name> --publisher <publisher>
|
|
23
24
|
morit plugin add [directory]
|
|
24
25
|
morit plugin validate [directory]
|
|
@@ -348,6 +349,10 @@ export async function runCli(argv, options = {}) {
|
|
|
348
349
|
output(stdout, "Signed out of Morit CLI");
|
|
349
350
|
return 0;
|
|
350
351
|
}
|
|
352
|
+
if (area === "docs") {
|
|
353
|
+
output(stdout, { url: `${DEFAULT_API_URL}/docs/morit-plugin/start` }, argv.includes("--json"));
|
|
354
|
+
return 0;
|
|
355
|
+
}
|
|
351
356
|
const parsed = parseArguments(rest);
|
|
352
357
|
const json = Boolean(parsed.flags.json);
|
|
353
358
|
|
package/src/preview.js
CHANGED
|
@@ -51,27 +51,53 @@ function themeVariables(config) {
|
|
|
51
51
|
return values.join(";");
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
function jsonAttribute(value) {
|
|
55
|
+
return escape(JSON.stringify(value));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function nodeAttributes(node) {
|
|
59
|
+
const attributes = [];
|
|
60
|
+
if (node.visible_when) attributes.push('data-visible="' + jsonAttribute(node.visible_when) + '"');
|
|
61
|
+
if (node.action) attributes.push('data-action="' + jsonAttribute(node.action) + '"');
|
|
62
|
+
return attributes.length ? " " + attributes.join(" ") : "";
|
|
63
|
+
}
|
|
64
|
+
|
|
54
65
|
function nodeHtml(node) {
|
|
55
66
|
if (!node || typeof node !== "object") return "";
|
|
56
67
|
const type = String(node.type || "surface");
|
|
57
68
|
const props = node.props && typeof node.props === "object" ? node.props : {};
|
|
58
69
|
const children = Array.isArray(node.children) ? node.children.map(nodeHtml).join("") : "";
|
|
59
70
|
const label = escape(props.label || props.title || type);
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
if (type === "
|
|
63
|
-
if (type === "
|
|
64
|
-
if (type === "
|
|
65
|
-
if (type === "
|
|
71
|
+
const attributes = nodeAttributes(node);
|
|
72
|
+
const template = (value) => ' data-template="' + escape(value ?? "") + '"';
|
|
73
|
+
if (type === "text") return '<p class="ui-text ' + escape(props.style) + '"' + attributes + template(props.text) + '>' + escape(props.text) + "</p>";
|
|
74
|
+
if (type === "icon") return '<span class="ui-icon" role="img" aria-label="' + escape(props.semantic_label || props.icon) + '"' + attributes + '>' + escape(props.icon) + "</span>";
|
|
75
|
+
if (type === "image") return '<div class="ui-image" role="img" aria-label="' + escape(props.semantic_label || "image") + '"' + attributes + '>🖼 ' + escape(props.asset || props.url || "image") + "</div>";
|
|
76
|
+
if (type === "avatar") return '<span class="ui-avatar"' + attributes + '>' + escape(props.label || "M").slice(0, 1) + "</span>";
|
|
77
|
+
if (type === "badge") return '<span class="ui-badge"' + attributes + '>' + label + children + "</span>";
|
|
66
78
|
if (type === "divider") return "<hr>";
|
|
67
79
|
if (type === "spacer") return '<span class="ui-spacer" aria-hidden="true"></span>';
|
|
68
|
-
if (type === "button") return "<button" +
|
|
69
|
-
if (type === "chip") return '<
|
|
70
|
-
if (type === "metric") return '<div class="ui-metric"><strong>' + escape(props.value) + "</strong><span>" + label + "</span></div>";
|
|
71
|
-
if (type === "progress") return '<div class="ui-progress"><span>' + label + '</span><progress value="' + Number(props.value ?? .5) + '" max="1"></progress></div>';
|
|
72
|
-
if (type === "field")
|
|
73
|
-
|
|
74
|
-
|
|
80
|
+
if (type === "button") return "<button type=\"button\"" + attributes + ">" + (props.icon ? escape(props.icon) + " " : "") + label + "</button>";
|
|
81
|
+
if (type === "chip") return '<button type="button" class="ui-chip"' + attributes + '>' + label + "</button>";
|
|
82
|
+
if (type === "metric") return '<div class="ui-metric"' + attributes + '><strong' + template(props.value) + '>' + escape(props.value) + "</strong><span>" + label + "</span></div>";
|
|
83
|
+
if (type === "progress") return '<div class="ui-progress"' + attributes + '><span>' + label + '</span><progress data-value-template="' + escape(props.value ?? .5) + '" value="' + Number(props.value ?? .5) + '" max="1"></progress></div>';
|
|
84
|
+
if (type === "field") {
|
|
85
|
+
const inputType = props.input_type === "number" ? "number" : props.input_type === "email" ? "email" : props.input_type === "url" ? "url" : "text";
|
|
86
|
+
const constraints = (props.required ? " required" : "") + (props.min_length != null ? ' minlength="' + Number(props.min_length) + '"' : "") + (props.max_length != null ? ' maxlength="' + Number(props.max_length) + '"' : "") + (props.minimum != null ? ' min="' + Number(props.minimum) + '"' : "") + (props.maximum != null ? ' max="' + Number(props.maximum) + '"' : "");
|
|
87
|
+
const suggestions = Array.isArray(props.suggestions) ? '<datalist id="list-' + escape(props.state_key) + '">' + props.suggestions.map((value) => '<option value="' + escape(value) + '"></option>').join("") + "</datalist>" : "";
|
|
88
|
+
const listId = props.suggestions || props.suggestions_source ? "list-" + props.state_key : "";
|
|
89
|
+
return '<label class="ui-field"' + attributes + '>' + label + '<input type="' + inputType + '" data-state-key="' + escape(props.state_key) + '" data-suggestions-source="' + escape(props.suggestions_source || "") + '" placeholder="' + escape(props.hint || props.placeholder) + '"' + constraints + (listId ? ' list="' + escape(listId) + '"' : "") + ">" + (suggestions || (listId ? '<datalist id="' + escape(listId) + '"></datalist>' : "")) + "</label>";
|
|
90
|
+
}
|
|
91
|
+
if (type === "select") {
|
|
92
|
+
const options = Array.isArray(props.options) ? props.options : [];
|
|
93
|
+
if (props.searchable) {
|
|
94
|
+
const listId = "select-" + props.state_key;
|
|
95
|
+
return '<label class="ui-field"' + attributes + '>' + label + '<input data-search-select="true" data-state-key="' + escape(props.state_key) + '" data-options-source="' + escape(props.options_source || "") + '" data-options="' + jsonAttribute(options) + '" list="' + escape(listId) + '" placeholder="' + escape(props.placeholder || "검색하여 선택") + '"' + (props.required ? " required" : "") + '><datalist id="' + escape(listId) + '">' + options.map((option) => '<option value="' + escape(option.label) + '"></option>').join("") + "</datalist></label>";
|
|
96
|
+
}
|
|
97
|
+
const optionHtml = options.map((option, index) => '<option value="' + index + '" data-value="' + jsonAttribute(option.value) + '">' + escape(option.label) + "</option>").join("");
|
|
98
|
+
return '<label class="ui-field"' + attributes + '>' + label + '<select data-state-key="' + escape(props.state_key) + '" data-options-source="' + escape(props.options_source || "") + '"' + (props.required ? " required" : "") + '><option value="">' + escape(props.placeholder || "선택") + "</option>" + optionHtml + "</select></label>";
|
|
99
|
+
}
|
|
100
|
+
if (type === "switch") return '<label class="ui-switch"' + attributes + '><input type="checkbox" data-state-key="' + escape(props.state_key) + '"' + (props.required ? " required" : "") + "> " + label + "</label>";
|
|
75
101
|
if (type === "empty") return '<div class="ui-empty"><strong>' + escape(props.title || "표시할 내용이 없어요.") + "</strong><p>" + escape(props.supporting) + "</p></div>";
|
|
76
102
|
if (["list", "timeline", "calendar", "chart", "table"].includes(type)) {
|
|
77
103
|
return '<section class="ui-collection"><small>' + escape(type + (props.source ? " · " + props.source : "")) + "</small>" + (children || '<p>' + escape(props.empty_text || "데이터가 연결되면 표시됩니다.") + "</p>") + "</section>";
|
|
@@ -83,7 +109,8 @@ function nodeHtml(node) {
|
|
|
83
109
|
const heading = surface && (props.title || props.label)
|
|
84
110
|
? "<h3>" + label + "</h3>" + (props.subtitle ? "<p>" + escape(props.subtitle) + "</p>" : "")
|
|
85
111
|
: "";
|
|
86
|
-
return '<
|
|
112
|
+
if (type === "form") return '<form class="' + className + '"' + attributes + ">" + heading + children + (props.submit_label ? '<button type="submit">' + escape(props.submit_label) + "</button>" : "") + "</form>";
|
|
113
|
+
return '<div class="' + className + '"' + attributes + ">" + heading + children + "</div>";
|
|
87
114
|
}
|
|
88
115
|
|
|
89
116
|
function chromeHtml(config) {
|
|
@@ -100,6 +127,214 @@ function chromeHtml(config) {
|
|
|
100
127
|
return bar + nav;
|
|
101
128
|
}
|
|
102
129
|
|
|
130
|
+
export function resolvePreviewPath(roots, path) {
|
|
131
|
+
const parts = String(path || "").split(".");
|
|
132
|
+
let value = roots[parts.shift()];
|
|
133
|
+
for (const part of parts) {
|
|
134
|
+
if (value == null) return null;
|
|
135
|
+
if (Array.isArray(value) && part === "length") value = value.length;
|
|
136
|
+
else if (Array.isArray(value) && /^\d+$/.test(part)) value = value[Number(part)];
|
|
137
|
+
else if (typeof value === "object") value = value[part];
|
|
138
|
+
else return null;
|
|
139
|
+
}
|
|
140
|
+
return value ?? null;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function previewValuesEqual(left, right) {
|
|
144
|
+
if (Object.is(left, right)) return true;
|
|
145
|
+
if (Array.isArray(left) && Array.isArray(right)) {
|
|
146
|
+
return left.length === right.length && left.every((value, index) => previewValuesEqual(value, right[index]));
|
|
147
|
+
}
|
|
148
|
+
if (left && right && typeof left === "object" && typeof right === "object" && !Array.isArray(left) && !Array.isArray(right)) {
|
|
149
|
+
const keys = Object.keys(left);
|
|
150
|
+
return keys.length === Object.keys(right).length && keys.every((key) => Object.hasOwn(right, key) && previewValuesEqual(left[key], right[key]));
|
|
151
|
+
}
|
|
152
|
+
return typeof left === "number" && typeof right === "number" && left === right;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function resolvePreviewValue(value, roots) {
|
|
156
|
+
if (Array.isArray(value)) return value.map((item) => resolvePreviewValue(item, roots));
|
|
157
|
+
if (value && typeof value === "object") {
|
|
158
|
+
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, resolvePreviewValue(item, roots)]));
|
|
159
|
+
}
|
|
160
|
+
if (typeof value !== "string") return value;
|
|
161
|
+
const exact = value.match(/^\{\{((?:state|data|context|item)\.[A-Za-z0-9_.-]+)\}\}$/);
|
|
162
|
+
if (exact) return resolvePreviewPath(roots, exact[1]);
|
|
163
|
+
return value.replace(/\{\{((?:state|data|context|item)\.[A-Za-z0-9_.-]+)\}\}/g, (_, path) => String(resolvePreviewPath(roots, path) ?? ""));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function evaluatePreviewCondition(condition, roots) {
|
|
167
|
+
if (!condition) return true;
|
|
168
|
+
if (Array.isArray(condition.all)) return condition.all.every((item) => evaluatePreviewCondition(item, roots));
|
|
169
|
+
if (Array.isArray(condition.any)) return condition.any.some((item) => evaluatePreviewCondition(item, roots));
|
|
170
|
+
if (condition.not) return !evaluatePreviewCondition(condition.not, roots);
|
|
171
|
+
const value = resolvePreviewPath(roots, condition.path);
|
|
172
|
+
if (Object.hasOwn(condition, "equals")) return previewValuesEqual(value, resolvePreviewValue(condition.equals, roots));
|
|
173
|
+
if (Object.hasOwn(condition, "not_equals")) return !previewValuesEqual(value, resolvePreviewValue(condition.not_equals, roots));
|
|
174
|
+
if (Object.hasOwn(condition, "exists")) return condition.exists === (value !== null);
|
|
175
|
+
return true;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function previewRuntime() {
|
|
179
|
+
const parse = (element, key) => element.dataset[key] ? JSON.parse(element.dataset[key]) : null;
|
|
180
|
+
const same = previewValuesEqual;
|
|
181
|
+
for (const article of document.querySelectorAll(".screen[data-extension]")) {
|
|
182
|
+
const config = JSON.parse(article.querySelector(".runtime-config").textContent);
|
|
183
|
+
const state = structuredClone(config.initial_state || {});
|
|
184
|
+
const data = {};
|
|
185
|
+
const context = {
|
|
186
|
+
platform: "web",
|
|
187
|
+
permissions: {}, connections: {}, connected: false,
|
|
188
|
+
loading: {}, errors: {}, route: {},
|
|
189
|
+
screen: { width: article.clientWidth, height: innerHeight, size_class: article.clientWidth < 600 ? "compact" : article.clientWidth < 840 ? "medium" : "expanded" },
|
|
190
|
+
};
|
|
191
|
+
const roots = () => ({ state, data, context, item: {} });
|
|
192
|
+
const inspector = article.querySelector(".runtime-inspector pre");
|
|
193
|
+
const log = [];
|
|
194
|
+
|
|
195
|
+
function optionValue(option) {
|
|
196
|
+
if (!option?.dataset.value) return option?.value ?? null;
|
|
197
|
+
return JSON.parse(option.dataset.value);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function update() {
|
|
201
|
+
for (const element of article.querySelectorAll("[data-visible]")) {
|
|
202
|
+
element.hidden = !evaluatePreviewCondition(parse(element, "visible"), roots());
|
|
203
|
+
}
|
|
204
|
+
for (const element of article.querySelectorAll("[data-template]")) {
|
|
205
|
+
element.textContent = String(resolvePreviewValue(element.dataset.template, roots()) ?? "");
|
|
206
|
+
}
|
|
207
|
+
for (const element of article.querySelectorAll("progress[data-value-template]")) {
|
|
208
|
+
element.value = Number(resolvePreviewValue(element.dataset.valueTemplate, roots()) ?? 0);
|
|
209
|
+
}
|
|
210
|
+
for (const input of article.querySelectorAll("input[data-suggestions-source]")) {
|
|
211
|
+
if (!input.dataset.suggestionsSource) continue;
|
|
212
|
+
const values = resolvePreviewPath(roots(), input.dataset.suggestionsSource);
|
|
213
|
+
const list = document.getElementById(input.getAttribute("list"));
|
|
214
|
+
if (list && Array.isArray(values)) list.replaceChildren(...values.filter((value) => typeof value === "string").map((value) => Object.assign(document.createElement("option"), { value })));
|
|
215
|
+
}
|
|
216
|
+
for (const select of article.querySelectorAll("select[data-options-source]")) {
|
|
217
|
+
if (!select.dataset.optionsSource) continue;
|
|
218
|
+
const values = resolvePreviewPath(roots(), select.dataset.optionsSource);
|
|
219
|
+
const placeholder = select.options[0]?.cloneNode(true) || document.createElement("option");
|
|
220
|
+
const options = Array.isArray(values) ? values.filter((value) => value && typeof value === "object" && typeof value.label === "string") : [];
|
|
221
|
+
select.replaceChildren(placeholder, ...options.map((value, index) => {
|
|
222
|
+
const option = document.createElement("option");
|
|
223
|
+
option.value = String(index);
|
|
224
|
+
option.textContent = value.label;
|
|
225
|
+
option.dataset.value = JSON.stringify(value.value);
|
|
226
|
+
return option;
|
|
227
|
+
}));
|
|
228
|
+
}
|
|
229
|
+
for (const input of article.querySelectorAll("input[data-search-select]")) {
|
|
230
|
+
const values = input.dataset.optionsSource ? resolvePreviewPath(roots(), input.dataset.optionsSource) : JSON.parse(input.dataset.options || "[]");
|
|
231
|
+
input._previewOptions = Array.isArray(values) ? values.filter((value) => value && typeof value === "object" && typeof value.label === "string") : [];
|
|
232
|
+
const list = document.getElementById(input.getAttribute("list"));
|
|
233
|
+
if (list) list.replaceChildren(...input._previewOptions.map((value) => Object.assign(document.createElement("option"), { value: value.label })));
|
|
234
|
+
}
|
|
235
|
+
for (const input of article.querySelectorAll("[data-state-key]")) {
|
|
236
|
+
const value = state[input.dataset.stateKey];
|
|
237
|
+
if (input.type === "checkbox") input.checked = value === true;
|
|
238
|
+
else if (input.dataset.searchSelect) {
|
|
239
|
+
const match = (input._previewOptions || []).find((option) => same(option.value, value));
|
|
240
|
+
if (document.activeElement !== input && match && input.value !== match.label) input.value = match.label;
|
|
241
|
+
}
|
|
242
|
+
else if (input.tagName === "SELECT") {
|
|
243
|
+
const match = [...input.options].find((option) => same(optionValue(option), value));
|
|
244
|
+
if (match && input.value !== match.value) input.value = match.value;
|
|
245
|
+
} else if (document.activeElement !== input && input.value !== String(value ?? "")) input.value = String(value ?? "");
|
|
246
|
+
}
|
|
247
|
+
inspector.textContent = JSON.stringify({ state, data, context, log: log.slice(-8) }, null, 2);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
async function perform(action) {
|
|
251
|
+
if (!action) return false;
|
|
252
|
+
if (action.validate && ![...article.querySelectorAll("form")].every((form) => form.reportValidity())) return false;
|
|
253
|
+
if (action.type === "flow") {
|
|
254
|
+
const tasks = action.actions || [];
|
|
255
|
+
if (action.mode === "parallel") {
|
|
256
|
+
const results = await Promise.all(tasks.map(perform));
|
|
257
|
+
return action.continue_on_error || results.every(Boolean);
|
|
258
|
+
}
|
|
259
|
+
for (const child of tasks) if (!await perform(child) && !action.continue_on_error) return false;
|
|
260
|
+
return true;
|
|
261
|
+
}
|
|
262
|
+
if (action.type === "set_state") Object.assign(state, resolvePreviewValue(action.values || {}, roots()));
|
|
263
|
+
else if (action.type === "invoke") {
|
|
264
|
+
const detail = { capability: action.capability, query: resolvePreviewValue(action.query, roots()), arguments: resolvePreviewValue(action.arguments || {}, roots()), result: null };
|
|
265
|
+
article.dispatchEvent(new CustomEvent("morit-preview-invoke", { detail, bubbles: true }));
|
|
266
|
+
if (action.store && detail.result != null) data[action.store] = detail.result;
|
|
267
|
+
log.push({ invoke: detail.capability, arguments: detail.arguments });
|
|
268
|
+
} else if (action.type === "refresh") {
|
|
269
|
+
const detail = { source: action.source, result: null };
|
|
270
|
+
article.dispatchEvent(new CustomEvent("morit-preview-refresh", { detail, bubbles: true }));
|
|
271
|
+
if (detail.result != null) data[action.source] = detail.result;
|
|
272
|
+
log.push({ refresh: action.source });
|
|
273
|
+
} else if (action.type === "navigate") {
|
|
274
|
+
const detail = { target: action.target, parameters: resolvePreviewValue(action.parameters || {}, roots()), result: null };
|
|
275
|
+
article.dispatchEvent(new CustomEvent("morit-preview-navigate", { detail, bubbles: true }));
|
|
276
|
+
context.route = detail.parameters;
|
|
277
|
+
document.querySelector('.screen[data-extension="' + CSS.escape(action.target) + '"]')?.scrollIntoView({ behavior: "smooth" });
|
|
278
|
+
if (action.result_state && detail.result != null) state[action.result_state] = detail.result;
|
|
279
|
+
log.push({ navigate: action.target, parameters: detail.parameters });
|
|
280
|
+
} else if (action.type === "back") {
|
|
281
|
+
article.dispatchEvent(new CustomEvent("morit-preview-back", { detail: { result: resolvePreviewValue(action.result || {}, roots()) }, bubbles: true }));
|
|
282
|
+
log.push({ back: true });
|
|
283
|
+
}
|
|
284
|
+
update();
|
|
285
|
+
return true;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
article.addEventListener("input", (event) => {
|
|
289
|
+
const input = event.target.closest("[data-state-key]");
|
|
290
|
+
if (!input) return;
|
|
291
|
+
if (input.dataset.searchSelect) return;
|
|
292
|
+
state[input.dataset.stateKey] = input.type === "checkbox" ? input.checked : input.type === "number" && input.value !== "" ? Number(input.value) : input.value;
|
|
293
|
+
update();
|
|
294
|
+
});
|
|
295
|
+
article.addEventListener("change", (event) => {
|
|
296
|
+
const input = event.target.closest("[data-state-key]");
|
|
297
|
+
if (!input) return;
|
|
298
|
+
if (input.dataset.searchSelect) {
|
|
299
|
+
const match = (input._previewOptions || []).find((option) => option.label === input.value);
|
|
300
|
+
if (!match) { input.setCustomValidity("목록에서 선택해 주세요."); input.reportValidity(); return; }
|
|
301
|
+
input.setCustomValidity("");
|
|
302
|
+
state[input.dataset.stateKey] = match.value;
|
|
303
|
+
} else state[input.dataset.stateKey] = input.tagName === "SELECT" ? optionValue(input.selectedOptions[0]) : input.type === "checkbox" ? input.checked : input.type === "number" && input.value !== "" ? Number(input.value) : input.value;
|
|
304
|
+
const actionHost = input.closest("[data-action]");
|
|
305
|
+
if (actionHost) perform(parse(actionHost, "action"));
|
|
306
|
+
update();
|
|
307
|
+
});
|
|
308
|
+
article.addEventListener("submit", (event) => {
|
|
309
|
+
event.preventDefault();
|
|
310
|
+
perform(parse(event.target, "action"));
|
|
311
|
+
});
|
|
312
|
+
article.addEventListener("click", (event) => {
|
|
313
|
+
const host = event.target.closest("button[data-action], .ui-surface[data-action], .ui-layout[data-action]");
|
|
314
|
+
if (host) perform(parse(host, "action"));
|
|
315
|
+
});
|
|
316
|
+
article.addEventListener("keydown", (event) => {
|
|
317
|
+
if (event.key !== "Enter" || event.isComposing) return;
|
|
318
|
+
const field = event.target.closest(".ui-field[data-action]");
|
|
319
|
+
if (field) { event.preventDefault(); perform(parse(field, "action")); }
|
|
320
|
+
});
|
|
321
|
+
new ResizeObserver(() => {
|
|
322
|
+
context.screen.width = article.clientWidth;
|
|
323
|
+
context.screen.size_class = article.clientWidth < 600 ? "compact" : article.clientWidth < 840 ? "medium" : "expanded";
|
|
324
|
+
update();
|
|
325
|
+
}).observe(article);
|
|
326
|
+
update();
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const RUNTIME_SCRIPT = [
|
|
331
|
+
resolvePreviewPath.toString(),
|
|
332
|
+
previewValuesEqual.toString(),
|
|
333
|
+
resolvePreviewValue.toString(),
|
|
334
|
+
evaluatePreviewCondition.toString(),
|
|
335
|
+
"(" + previewRuntime.toString() + ")();",
|
|
336
|
+
].join("\n");
|
|
337
|
+
|
|
103
338
|
const CSS = [
|
|
104
339
|
"*{box-sizing:border-box}body{margin:0;font:15px/1.5 system-ui,sans-serif;background:#0d1211}",
|
|
105
340
|
".theme-toggle{position:fixed;right:18px;top:16px;z-index:3;padding:9px 13px;border-radius:999px;background:#fff;color:#17211f;box-shadow:0 4px 18px #0003;cursor:pointer}",
|
|
@@ -110,6 +345,7 @@ const CSS = [
|
|
|
110
345
|
".screen-meta,.app-bar,.screen-body,.ui-nav{padding:var(--space)}.screen-meta{display:flex;gap:8px;background:var(--surface-variant);color:var(--on-surface-variant)}.screen-meta span{margin-right:auto}.app-bar{display:flex;align-items:center;border-bottom:1px solid var(--outline-variant)}.app-bar div:first-child{display:grid}.app-bar small{color:var(--on-surface-variant)}.app-actions{margin-left:auto;display:flex;gap:6px}",
|
|
111
346
|
".screen-body,.ui-layout,.ui-surface,.ui-collection{display:flex;flex-direction:column;gap:var(--space)}.ui-layout.row{flex-direction:row;align-items:center}.ui-layout.wrap,.ui-nav{display:flex;flex-flow:row wrap;gap:8px}.ui-layout.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr))}.ui-surface,.ui-collection,.ui-empty{padding:var(--space);border:1px solid var(--outline-variant);border-radius:var(--radius);background:color-mix(in srgb,var(--surface),var(--primary-container) 9%)}",
|
|
112
347
|
"h3,p{margin:0}.ui-text.heading{font-size:1.25em;font-weight:750}.ui-text.caption,small{color:var(--on-surface-variant)}button,.ui-chip{border:0;border-radius:999px;padding:8px 13px;background:var(--primary-container);color:var(--on-primary-container);font:inherit}.ui-icon{font:600 .75em monospace;color:var(--primary)}.ui-image{min-height:90px;display:grid;place-items:center;border-radius:var(--radius);background:var(--surface-variant);color:var(--on-surface-variant)}.ui-avatar{display:grid;place-items:center;width:42px;height:42px;border-radius:50%;background:var(--primary);color:var(--on-primary)}.ui-badge{padding:3px 7px;border-radius:99px;background:var(--error-container);color:var(--on-error-container)}.ui-metric{display:grid;padding:12px;border-radius:var(--radius);background:var(--secondary-container);color:var(--on-secondary-container)}.ui-metric strong{font-size:1.35em}.ui-field{display:grid;gap:5px}input,select{width:100%;padding:11px;border:1px solid var(--outline);border-radius:var(--radius);background:var(--surface);color:var(--on-surface)}progress{accent-color:var(--primary);width:100%}hr{width:100%;border:0;border-top:1px solid var(--outline-variant)}.ui-spacer{min-height:12px}.ui-nav{border-top:1px solid var(--outline-variant)}",
|
|
348
|
+
"[hidden]{display:none!important}[data-action]{cursor:pointer}.runtime-inspector{margin:0 var(--space) var(--space);border-top:1px solid var(--outline-variant);padding-top:8px}.runtime-inspector pre{max-height:260px;overflow:auto;white-space:pre-wrap;font:12px/1.4 ui-monospace,monospace;color:var(--on-surface-variant)}",
|
|
113
349
|
].join("");
|
|
114
350
|
|
|
115
351
|
export function renderPreviewHtml(manifest) {
|
|
@@ -117,9 +353,10 @@ export function renderPreviewHtml(manifest) {
|
|
|
117
353
|
const screens = extensions.length
|
|
118
354
|
? extensions.map((extension) => {
|
|
119
355
|
const config = extension.config;
|
|
120
|
-
|
|
356
|
+
const safeConfig = JSON.stringify(config).replaceAll("<", "\\u003c").replaceAll(">", "\\u003e").replaceAll("&", "\\u0026");
|
|
357
|
+
return '<article class="screen" data-extension="' + escape(extension.id) + '" style="' + themeVariables(config) + '">' +
|
|
121
358
|
'<div class="screen-meta"><span>' + escape(extension.point) + "</span><strong>" + escape(extension.title) + "</strong></div>" +
|
|
122
|
-
chromeHtml(config) + '<main class="screen-body">' + nodeHtml(config.view) + "</
|
|
359
|
+
chromeHtml(config) + '<main class="screen-body">' + nodeHtml(config.view) + '</main><details class="runtime-inspector"><summary>Runtime Inspector</summary><pre></pre></details><script type="application/json" class="runtime-config">' + safeConfig + "</script></article>";
|
|
123
360
|
}).join("")
|
|
124
361
|
: '<article class="screen"><div class="ui-empty"><strong>UI extension 없음</strong><p>UI Runtime v2 화면을 추가하면 여기에 표시됩니다.</p></div></article>';
|
|
125
362
|
const title = escape(manifest.name);
|
|
@@ -127,6 +364,6 @@ export function renderPreviewHtml(manifest) {
|
|
|
127
364
|
title + " preview</title><style>" + CSS +
|
|
128
365
|
'</style></head><body><input id="preview-dark" type="checkbox"><label class="theme-toggle" for="preview-dark">☀︎ / ☾</label><div class="preview"><header class="preview-head"><small>Morit UI Runtime v2 · 안전한 계약 Preview</small><h1>' +
|
|
129
366
|
title + "</h1><p>" + escape(manifest.description || "") +
|
|
130
|
-
'</p></header><section class="screens">' + screens + "</section></div></body></html>";
|
|
367
|
+
'</p></header><section class="screens">' + screens + "</section></div><script>" + RUNTIME_SCRIPT + "</script></body></html>";
|
|
131
368
|
return Buffer.from(content, "utf8");
|
|
132
369
|
}
|