@morit/cli 1.1.1 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -3
- package/assets/docs/README.md +18 -16
- package/assets/docs/ai-response-and-timeline.md +83 -5
- package/assets/docs/components.md +58 -1
- package/assets/docs/design-tokens-responsive.md +33 -10
- package/assets/docs/docs-index.json +1 -0
- package/assets/docs/examples-school-life.md +13 -8
- package/assets/docs/getting-started.md +1 -1
- package/assets/docs/lifecycle-and-api.md +11 -0
- package/assets/docs/local-cli.md +4 -3
- package/assets/docs/manifest.md +27 -1
- package/assets/docs/packaging-and-testing.md +8 -2
- package/assets/docs/permissions-and-data.md +23 -5
- package/assets/docs/plugin-storage.md +175 -0
- package/assets/docs/remote-mcp.md +9 -3
- package/assets/docs/school-life-privacy.md +7 -1
- package/assets/docs/sdk-and-mcp.md +20 -3
- package/assets/docs/tool-and-skill.md +10 -1
- package/assets/docs/troubleshooting.md +4 -0
- package/assets/docs/ui-extensions.md +7 -2
- package/assets/docs/ui-runtime-v2.md +72 -2
- package/assets/docs/verification.md +9 -4
- package/assets/plugin_contract.json +84 -4
- package/package.json +1 -1
- package/src/cli.js +9 -0
- package/src/preview.js +132 -0
- package/src/workspace.js +238 -16
package/src/cli.js
CHANGED
|
@@ -7,6 +7,7 @@ import { clearCredential, loadCredential, saveCredential } from "./secure-store.
|
|
|
7
7
|
import {
|
|
8
8
|
buildProjectDirectory,
|
|
9
9
|
internal as sdkInternal,
|
|
10
|
+
previewProjectDirectory,
|
|
10
11
|
readProjectDirectory,
|
|
11
12
|
scaffoldProjectDirectory,
|
|
12
13
|
validateProjectDirectory,
|
|
@@ -21,6 +22,7 @@ Usage:
|
|
|
21
22
|
morit plugin setup [directory] --id <plugin.id> --name <name> --publisher <publisher>
|
|
22
23
|
morit plugin add [directory]
|
|
23
24
|
morit plugin validate [directory]
|
|
25
|
+
morit plugin preview [directory] [--output <file.html>]
|
|
24
26
|
morit plugin build [directory] [--output <file.mplg>]
|
|
25
27
|
morit plugin sync [directory] [--pull] [--force]
|
|
26
28
|
morit plugin deploy [directory] [--visibility private|public] [--file-name <file.mplg>]
|
|
@@ -359,6 +361,13 @@ export async function runCli(argv, options = {}) {
|
|
|
359
361
|
output(stdout, await validateProjectDirectory(root), json);
|
|
360
362
|
return 0;
|
|
361
363
|
}
|
|
364
|
+
if (action === "preview") {
|
|
365
|
+
output(stdout, await previewProjectDirectory(
|
|
366
|
+
root,
|
|
367
|
+
parsed.flags.output ? resolve(cwd, parsed.flags.output) : undefined,
|
|
368
|
+
), json);
|
|
369
|
+
return 0;
|
|
370
|
+
}
|
|
362
371
|
if (action === "build") {
|
|
363
372
|
output(stdout, await buildProjectDirectory(root, {
|
|
364
373
|
output: parsed.flags.output ? resolve(cwd, parsed.flags.output) : undefined,
|
package/src/preview.js
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
const HEX = /^#(?:[0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})$/;
|
|
2
|
+
|
|
3
|
+
const COLORS = {
|
|
4
|
+
light: {
|
|
5
|
+
primary: "#006B58", on_primary: "#FFFFFF", primary_container: "#7CF8D6",
|
|
6
|
+
on_primary_container: "#002019", secondary_container: "#CDE9DE",
|
|
7
|
+
on_secondary_container: "#072019", tertiary_container: "#C2E8FC",
|
|
8
|
+
on_tertiary_container: "#001F29", error_container: "#FFDAD6",
|
|
9
|
+
on_error_container: "#410002", surface: "#F5FBF8", on_surface: "#171D1B",
|
|
10
|
+
surface_variant: "#DBE5E0", on_surface_variant: "#3F4945",
|
|
11
|
+
outline: "#6F7975", outline_variant: "#BEC9C4",
|
|
12
|
+
},
|
|
13
|
+
dark: {
|
|
14
|
+
primary: "#5CDBBA", on_primary: "#00382D", primary_container: "#005142",
|
|
15
|
+
on_primary_container: "#7CF8D6", secondary_container: "#344C45",
|
|
16
|
+
on_secondary_container: "#CDE9DE", tertiary_container: "#264B5B",
|
|
17
|
+
on_tertiary_container: "#C2E8FC", error_container: "#93000A",
|
|
18
|
+
on_error_container: "#FFDAD6", surface: "#0F1513", on_surface: "#DFE4E1",
|
|
19
|
+
surface_variant: "#3F4945", on_surface_variant: "#BEC9C4",
|
|
20
|
+
outline: "#89938F", outline_variant: "#3F4945",
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
function escape(value) {
|
|
25
|
+
return String(value ?? "")
|
|
26
|
+
.replaceAll("&", "&")
|
|
27
|
+
.replaceAll("<", "<")
|
|
28
|
+
.replaceAll(">", ">")
|
|
29
|
+
.replaceAll('"', """)
|
|
30
|
+
.replaceAll("'", "'");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function cssColor(value) {
|
|
34
|
+
if (typeof value !== "string" || !HEX.test(value)) return null;
|
|
35
|
+
return value.length === 9 ? "#" + value.slice(3) + value.slice(1, 3) : value;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function themeVariables(config) {
|
|
39
|
+
const theme = config?.theme || {};
|
|
40
|
+
const values = [];
|
|
41
|
+
for (const mode of ["light", "dark"]) {
|
|
42
|
+
const variant = { ...theme, ...(theme[mode] || {}) };
|
|
43
|
+
const colors = { ...COLORS[mode], ...(theme.color_scheme || {}), ...(variant.color_scheme || {}) };
|
|
44
|
+
for (const [role, fallback] of Object.entries(COLORS[mode])) {
|
|
45
|
+
values.push("--" + mode + "-" + role.replaceAll("_", "-") + ":" + (cssColor(colors[role]) || fallback));
|
|
46
|
+
}
|
|
47
|
+
values.push("--" + mode + "-radius:" + Number(variant.border?.radius ?? variant.radius ?? 18) + "px");
|
|
48
|
+
values.push("--" + mode + "-space:" + Number(variant.spacing ?? 12) + "px");
|
|
49
|
+
values.push("--" + mode + "-scale:" + Number(variant.typography?.scale ?? 1));
|
|
50
|
+
}
|
|
51
|
+
return values.join(";");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function nodeHtml(node) {
|
|
55
|
+
if (!node || typeof node !== "object") return "";
|
|
56
|
+
const type = String(node.type || "surface");
|
|
57
|
+
const props = node.props && typeof node.props === "object" ? node.props : {};
|
|
58
|
+
const children = Array.isArray(node.children) ? node.children.map(nodeHtml).join("") : "";
|
|
59
|
+
const label = escape(props.label || props.title || type);
|
|
60
|
+
const action = node.action ? ' data-action="' + escape(node.action.type) + '"' : "";
|
|
61
|
+
if (type === "text") return '<p class="ui-text ' + escape(props.style) + '">' + escape(props.text) + "</p>";
|
|
62
|
+
if (type === "icon") return '<span class="ui-icon" role="img" aria-label="' + escape(props.semantic_label || props.icon) + '">' + escape(props.icon) + "</span>";
|
|
63
|
+
if (type === "image") return '<div class="ui-image" role="img" aria-label="' + escape(props.semantic_label || "image") + '">🖼 ' + escape(props.asset || props.url || "image") + "</div>";
|
|
64
|
+
if (type === "avatar") return '<span class="ui-avatar">' + escape(props.label || "M").slice(0, 1) + "</span>";
|
|
65
|
+
if (type === "badge") return '<span class="ui-badge">' + label + children + "</span>";
|
|
66
|
+
if (type === "divider") return "<hr>";
|
|
67
|
+
if (type === "spacer") return '<span class="ui-spacer" aria-hidden="true"></span>';
|
|
68
|
+
if (type === "button") return "<button" + action + ">" + (props.icon ? escape(props.icon) + " " : "") + label + "</button>";
|
|
69
|
+
if (type === "chip") return '<span class="ui-chip"' + action + ">" + label + "</span>";
|
|
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") return '<label class="ui-field">' + label + '<input disabled placeholder="' + escape(props.hint || props.placeholder) + '"></label>';
|
|
73
|
+
if (type === "select") return '<label class="ui-field">' + label + '<select disabled><option>' + escape(props.placeholder || "선택") + "</option></select></label>";
|
|
74
|
+
if (type === "switch") return '<label class="ui-switch"><input type="checkbox" disabled> ' + label + "</label>";
|
|
75
|
+
if (type === "empty") return '<div class="ui-empty"><strong>' + escape(props.title || "표시할 내용이 없어요.") + "</strong><p>" + escape(props.supporting) + "</p></div>";
|
|
76
|
+
if (["list", "timeline", "calendar", "chart", "table"].includes(type)) {
|
|
77
|
+
return '<section class="ui-collection"><small>' + escape(type + (props.source ? " · " + props.source : "")) + "</small>" + (children || '<p>' + escape(props.empty_text || "데이터가 연결되면 표시됩니다.") + "</p>") + "</section>";
|
|
78
|
+
}
|
|
79
|
+
if (["dialog", "sheet"].includes(type)) return '<details class="ui-surface"><summary>' + label + "</summary>" + children + "</details>";
|
|
80
|
+
const layout = ["row", "wrap", "grid", "stack", "column"].includes(type);
|
|
81
|
+
const surface = ["card", "surface", "section", "form"].includes(type);
|
|
82
|
+
const className = layout ? "ui-layout " + type : surface ? "ui-surface " + type : "ui-layout " + type;
|
|
83
|
+
const heading = surface && (props.title || props.label)
|
|
84
|
+
? "<h3>" + label + "</h3>" + (props.subtitle ? "<p>" + escape(props.subtitle) + "</p>" : "")
|
|
85
|
+
: "";
|
|
86
|
+
return '<div class="' + className + '"' + action + ">" + heading + children + "</div>";
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function chromeHtml(config) {
|
|
90
|
+
const appBar = config.app_bar;
|
|
91
|
+
const navigation = config.navigation;
|
|
92
|
+
const bar = appBar
|
|
93
|
+
? '<header class="app-bar"><div><strong>' + escape(appBar.title) + "</strong>" +
|
|
94
|
+
(appBar.subtitle ? "<small>" + escape(appBar.subtitle) + "</small>" : "") +
|
|
95
|
+
'</div><div class="app-actions">' + (appBar.actions || []).map((item) => "<button>" + escape(item.icon || item.label) + "</button>").join("") + "</div></header>"
|
|
96
|
+
: "";
|
|
97
|
+
const nav = navigation
|
|
98
|
+
? '<nav class="ui-nav" aria-label="Plugin navigation">' + navigation.items.map((item) => '<span class="ui-chip">' + escape(item.icon ? item.icon + " " + item.label : item.label) + "</span>").join("") + "</nav>"
|
|
99
|
+
: "";
|
|
100
|
+
return bar + nav;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const CSS = [
|
|
104
|
+
"*{box-sizing:border-box}body{margin:0;font:15px/1.5 system-ui,sans-serif;background:#0d1211}",
|
|
105
|
+
".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}",
|
|
106
|
+
"#preview-dark{position:fixed;opacity:0}.preview{min-height:100vh;padding:72px 20px 56px;background:#eef3f1}",
|
|
107
|
+
".preview-head{max-width:980px;margin:0 auto 22px}.preview-head h1{margin:0}.screens{display:grid;gap:24px;max-width:980px;margin:auto}",
|
|
108
|
+
".screen{--primary:var(--light-primary);--on-primary:var(--light-on-primary);--primary-container:var(--light-primary-container);--on-primary-container:var(--light-on-primary-container);--secondary-container:var(--light-secondary-container);--on-secondary-container:var(--light-on-secondary-container);--tertiary-container:var(--light-tertiary-container);--on-tertiary-container:var(--light-on-tertiary-container);--error-container:var(--light-error-container);--on-error-container:var(--light-on-error-container);--surface:var(--light-surface);--on-surface:var(--light-on-surface);--surface-variant:var(--light-surface-variant);--on-surface-variant:var(--light-on-surface-variant);--outline:var(--light-outline);--outline-variant:var(--light-outline-variant);--radius:var(--light-radius);--space:var(--light-space);--scale:var(--light-scale);overflow:hidden;border:1px solid var(--outline-variant);border-radius:calc(var(--radius) + 6px);background:var(--surface);color:var(--on-surface);font-size:calc(1rem * var(--scale));box-shadow:0 12px 34px #0002}",
|
|
109
|
+
"#preview-dark:checked~.preview{background:#090d0c;color:#e1e5e2}#preview-dark:checked~.preview .screen{--primary:var(--dark-primary);--on-primary:var(--dark-on-primary);--primary-container:var(--dark-primary-container);--on-primary-container:var(--dark-on-primary-container);--secondary-container:var(--dark-secondary-container);--on-secondary-container:var(--dark-on-secondary-container);--tertiary-container:var(--dark-tertiary-container);--on-tertiary-container:var(--dark-on-tertiary-container);--error-container:var(--dark-error-container);--on-error-container:var(--dark-on-error-container);--surface:var(--dark-surface);--on-surface:var(--dark-on-surface);--surface-variant:var(--dark-surface-variant);--on-surface-variant:var(--dark-on-surface-variant);--outline:var(--dark-outline);--outline-variant:var(--dark-outline-variant);--radius:var(--dark-radius);--space:var(--dark-space);--scale:var(--dark-scale)}",
|
|
110
|
+
".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
|
+
".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
|
+
"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)}",
|
|
113
|
+
].join("");
|
|
114
|
+
|
|
115
|
+
export function renderPreviewHtml(manifest) {
|
|
116
|
+
const extensions = manifest.ui_extensions.filter((item) => item.config?.ui_schema === 2);
|
|
117
|
+
const screens = extensions.length
|
|
118
|
+
? extensions.map((extension) => {
|
|
119
|
+
const config = extension.config;
|
|
120
|
+
return '<article class="screen" style="' + themeVariables(config) + '">' +
|
|
121
|
+
'<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) + "</main></article>";
|
|
123
|
+
}).join("")
|
|
124
|
+
: '<article class="screen"><div class="ui-empty"><strong>UI extension 없음</strong><p>UI Runtime v2 화면을 추가하면 여기에 표시됩니다.</p></div></article>';
|
|
125
|
+
const title = escape(manifest.name);
|
|
126
|
+
const content = '<!doctype html><html lang="ko"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>' +
|
|
127
|
+
title + " preview</title><style>" + CSS +
|
|
128
|
+
'</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
|
+
title + "</h1><p>" + escape(manifest.description || "") +
|
|
130
|
+
'</p></header><section class="screens">' + screens + "</section></div></body></html>";
|
|
131
|
+
return Buffer.from(content, "utf8");
|
|
132
|
+
}
|
package/src/workspace.js
CHANGED
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
signEntries,
|
|
23
23
|
verifySignedEntries,
|
|
24
24
|
} from "./archive.js";
|
|
25
|
+
import { renderPreviewHtml } from "./preview.js";
|
|
25
26
|
|
|
26
27
|
const contract = JSON.parse(
|
|
27
28
|
readFileSync(new URL("../assets/plugin_contract.json", import.meta.url), "utf8"),
|
|
@@ -63,6 +64,10 @@ const CONNECTOR_FIELDS = new Set(contract.object_fields.connector);
|
|
|
63
64
|
const DEPENDENCY_FIELDS = new Set(contract.object_fields.dependency);
|
|
64
65
|
const DEVELOPER_FIELDS = new Set(contract.object_fields.developer);
|
|
65
66
|
const REQUIRED_SECRET_FIELDS = new Set(contract.object_fields.required_secret);
|
|
67
|
+
const STORAGE_FIELDS = new Set(contract.object_fields.storage);
|
|
68
|
+
const STORAGE_NAMESPACE_FIELDS = new Set(contract.object_fields.storage_namespace);
|
|
69
|
+
const STORAGE_MIGRATION_FIELDS = new Set(contract.object_fields.storage_migration);
|
|
70
|
+
const STORAGE_TOOLS = new Set(contract.storage_tools);
|
|
66
71
|
const CAPABILITY_RUNTIME_SHORTHAND = contract.capability_runtime_shorthand;
|
|
67
72
|
const UI_POINTS = new Set(contract.ui_points);
|
|
68
73
|
const RUNTIME_ADAPTERS = new Set(contract.runtime_adapters);
|
|
@@ -147,7 +152,7 @@ export class LocalWorkspace {
|
|
|
147
152
|
version: "1.0.0",
|
|
148
153
|
cloud_project_id: null,
|
|
149
154
|
required_secrets: [],
|
|
150
|
-
min_morit_version: "1.7.
|
|
155
|
+
min_morit_version: "1.7.8",
|
|
151
156
|
max_morit_version: "1.999.999",
|
|
152
157
|
permissions: [],
|
|
153
158
|
capabilities: [],
|
|
@@ -269,6 +274,7 @@ export class LocalWorkspace {
|
|
|
269
274
|
revision: project.revision,
|
|
270
275
|
valid: true,
|
|
271
276
|
message: `validated ${manifest.id} ${manifest.version} (${packageEntries.size} package entries)`,
|
|
277
|
+
warnings: collectThemeWarnings(manifest),
|
|
272
278
|
workspace_path: project.workspace_path,
|
|
273
279
|
};
|
|
274
280
|
}
|
|
@@ -515,6 +521,7 @@ export async function verifyPackageFile(packagePath) {
|
|
|
515
521
|
size_bytes: content.length,
|
|
516
522
|
sha256: sha256(content),
|
|
517
523
|
signature: verified.signature,
|
|
524
|
+
warnings: collectThemeWarnings(verified.manifest),
|
|
518
525
|
};
|
|
519
526
|
}
|
|
520
527
|
|
|
@@ -535,6 +542,7 @@ function projectDirectoryResult({ root, manifest, entries }) {
|
|
|
535
542
|
publisher: manifest.publisher,
|
|
536
543
|
version: manifest.version,
|
|
537
544
|
package_entries: entries.size + 1,
|
|
545
|
+
warnings: collectThemeWarnings(manifest),
|
|
538
546
|
};
|
|
539
547
|
}
|
|
540
548
|
|
|
@@ -719,14 +727,10 @@ async function signingKeyForPublisher(keyDirectory, publisher) {
|
|
|
719
727
|
}
|
|
720
728
|
}
|
|
721
729
|
|
|
722
|
-
function previewHtml(manifest) {
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
"utf8",
|
|
727
|
-
);
|
|
728
|
-
}
|
|
729
|
-
|
|
730
|
+
function previewHtml(manifest) {
|
|
731
|
+
return renderPreviewHtml(manifest);
|
|
732
|
+
}
|
|
733
|
+
|
|
730
734
|
function validateMetadata(pluginId, name, publisher, description) {
|
|
731
735
|
if (!PLUGIN_ID.test(pluginId) || pluginId.length > 120) throw new Error("plugin_id must be a reverse-domain lowercase identifier");
|
|
732
736
|
if (typeof name !== "string" || name.trim().length < 1 || name.trim().length > 80) throw new Error("name must contain 1 to 80 characters");
|
|
@@ -790,6 +794,7 @@ function validateManifest(manifest, files) {
|
|
|
790
794
|
for (const capability of capabilities) {
|
|
791
795
|
rejectUnknownFields(capability, CAPABILITY_FIELDS, "capability");
|
|
792
796
|
requireUniqueIdentifier(capability.id, capabilityIds, "capability");
|
|
797
|
+
if (STORAGE_TOOLS.has(capability.id)) throw new Error(`capability ${capability.id} is reserved by Morit`);
|
|
793
798
|
capabilityIds.add(capability.id);
|
|
794
799
|
if (!CAPABILITY_KINDS.has(capability.kind)) throw new Error(`unknown capability kind: ${capability.kind}`);
|
|
795
800
|
requireText(capability.title, 80, `capability ${capability.id} title`);
|
|
@@ -832,7 +837,8 @@ function validateManifest(manifest, files) {
|
|
|
832
837
|
// level before semantic UI validation runs.
|
|
833
838
|
assertJsonSize(extensionConfig, 32 * 1024, `UI ${extension.id} config`, 40);
|
|
834
839
|
}
|
|
835
|
-
|
|
840
|
+
const storage = validateStorage(manifest, requestedPermissions);
|
|
841
|
+
for (const extension of uiExtensions) validateUiConfig(extension, capabilities, uiIds, files, storage !== null);
|
|
836
842
|
|
|
837
843
|
const credentialIds = validateCredentials(credentials, requestedPermissions);
|
|
838
844
|
const secretIds = validateRequiredSecrets(requiredSecrets);
|
|
@@ -879,6 +885,79 @@ function validatePermissions(values, label) {
|
|
|
879
885
|
return new Set(values);
|
|
880
886
|
}
|
|
881
887
|
|
|
888
|
+
function validateStorage(manifest, permissions) {
|
|
889
|
+
if (manifest.storage === undefined) {
|
|
890
|
+
if (permissions.has("ai_storage")) throw new Error("ai_storage requires manifest.storage");
|
|
891
|
+
return permissions.has("storage") ? { version: 1, namespaces: [{ id: "default", max_bytes: 128 * 1024, ai_access: "none" }], migrations: [] } : null;
|
|
892
|
+
}
|
|
893
|
+
if (manifest.schema_version !== 2 || !permissions.has("storage")) {
|
|
894
|
+
throw new Error("manifest.storage requires schema 2 and storage permission");
|
|
895
|
+
}
|
|
896
|
+
const storage = manifest.storage;
|
|
897
|
+
assertObject(storage, "manifest.storage");
|
|
898
|
+
rejectUnknownFields(storage, STORAGE_FIELDS, "manifest.storage");
|
|
899
|
+
if (!Number.isInteger(storage.version) || storage.version < 1 || storage.version > 1000) {
|
|
900
|
+
throw new Error("manifest.storage.version is invalid");
|
|
901
|
+
}
|
|
902
|
+
const namespaces = objectCollection(storage.namespaces, "manifest.storage.namespaces", 16);
|
|
903
|
+
if (!namespaces.length) throw new Error("manifest.storage.namespaces must not be empty");
|
|
904
|
+
const namespaceIds = new Set();
|
|
905
|
+
let hasAiAccess = false;
|
|
906
|
+
for (const namespace of namespaces) {
|
|
907
|
+
rejectUnknownFields(namespace, STORAGE_NAMESPACE_FIELDS, "storage namespace");
|
|
908
|
+
requireUniqueIdentifier(namespace.id, namespaceIds, "storage namespace");
|
|
909
|
+
namespaceIds.add(namespace.id);
|
|
910
|
+
const maxBytes = namespace.max_bytes === undefined ? 128 * 1024 : namespace.max_bytes;
|
|
911
|
+
if (!Number.isInteger(maxBytes) || maxBytes < 1024 || maxBytes > 256 * 1024) {
|
|
912
|
+
throw new Error(`storage namespace ${namespace.id} max_bytes is invalid`);
|
|
913
|
+
}
|
|
914
|
+
const aiAccess = namespace.ai_access === undefined ? "none" : namespace.ai_access;
|
|
915
|
+
if (!contract.storage_ai_access.includes(aiAccess)) throw new Error(`storage namespace ${namespace.id} ai_access is invalid`);
|
|
916
|
+
hasAiAccess ||= aiAccess !== "none";
|
|
917
|
+
}
|
|
918
|
+
const migrations = objectCollection(storage.migrations === undefined ? [] : storage.migrations, "manifest.storage.migrations", 64);
|
|
919
|
+
const migrationSteps = new Set();
|
|
920
|
+
for (const migration of migrations) {
|
|
921
|
+
rejectUnknownFields(migration, STORAGE_MIGRATION_FIELDS, "storage migration");
|
|
922
|
+
if (!Number.isInteger(migration.from) || migration.from < 1 || migration.to !== migration.from + 1 || migration.to > storage.version) {
|
|
923
|
+
throw new Error("storage migration version is invalid");
|
|
924
|
+
}
|
|
925
|
+
if (!namespaceIds.has(migration.namespace)) throw new Error("storage migration namespace is unknown");
|
|
926
|
+
const step = `${migration.from}\0${migration.namespace}`;
|
|
927
|
+
if (migrationSteps.has(step)) throw new Error("storage migration step is duplicated");
|
|
928
|
+
migrationSteps.add(step);
|
|
929
|
+
const rename = migration.rename === undefined ? {} : migration.rename;
|
|
930
|
+
assertObject(rename, "storage migration rename");
|
|
931
|
+
const deleted = migration.delete === undefined ? [] : migration.delete;
|
|
932
|
+
if (Object.keys(rename).length > 32 || !Array.isArray(deleted) || deleted.length > 32 || new Set(deleted).size !== deleted.length) {
|
|
933
|
+
throw new Error("storage migration operations are invalid");
|
|
934
|
+
}
|
|
935
|
+
const targets = new Set();
|
|
936
|
+
for (const [source, target] of Object.entries(rename)) {
|
|
937
|
+
validateStorageKey(source);
|
|
938
|
+
validateStorageKey(target);
|
|
939
|
+
if (source === target || targets.has(target) || deleted.includes(source)) throw new Error("storage migration rename is invalid");
|
|
940
|
+
targets.add(target);
|
|
941
|
+
}
|
|
942
|
+
for (const key of deleted) validateStorageKey(key);
|
|
943
|
+
}
|
|
944
|
+
if (permissions.has("ai_storage") !== hasAiAccess) {
|
|
945
|
+
throw new Error("AI-accessible storage and ai_storage permission must be declared together");
|
|
946
|
+
}
|
|
947
|
+
return storage;
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
function validateStorageKey(value) {
|
|
951
|
+
if (typeof value !== "string" || !/^[A-Za-z0-9][A-Za-z0-9_.-]{0,79}$/.test(value) || value.startsWith("_")) {
|
|
952
|
+
throw new Error("storage key is invalid");
|
|
953
|
+
}
|
|
954
|
+
const compact = value.toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
955
|
+
if (/(^|[_.-])(authorization|credential|password|secret|token|api[_-]?key|private[_-]?key)($|[_.-])/i.test(value)
|
|
956
|
+
|| ["authorization", "credential", "credentials", "password", "secret", "token", "accesstoken", "refreshtoken", "apikey", "privatekey"].includes(compact)) {
|
|
957
|
+
throw new Error("credential-shaped storage keys are not allowed");
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
|
|
882
961
|
function assertObject(value, label) {
|
|
883
962
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
884
963
|
throw new Error(`${label} must be an object`);
|
|
@@ -1160,12 +1239,15 @@ function validateRuntimeReferences(capabilities, connectors, connectorIds, depen
|
|
|
1160
1239
|
}
|
|
1161
1240
|
}
|
|
1162
1241
|
|
|
1163
|
-
function validateUiConfig(extension, capabilities, routeIds, files) {
|
|
1242
|
+
function validateUiConfig(extension, capabilities, routeIds, files, hasStorage = false) {
|
|
1164
1243
|
const config = extension.config === undefined ? {} : extension.config;
|
|
1165
1244
|
const executable = new Set(capabilities.filter((value) => ["tool", "skill", "provider", "notification"].includes(value.kind)).map((value) => value.id));
|
|
1245
|
+
if (hasStorage) for (const capability of STORAGE_TOOLS) executable.add(capability);
|
|
1246
|
+
if (extension.point === "response" && config.ui_schema !== 2) throw new Error("response UI requires UI Runtime v2");
|
|
1166
1247
|
if (config.ui_schema === 2) {
|
|
1167
1248
|
if (config.placement != null && !["card", "action"].includes(extension.point)) throw new Error("UI placement is only valid for home extensions");
|
|
1168
1249
|
validateUiRuntimeV2(config, executable, routeIds);
|
|
1250
|
+
validateResponseComponent(config, extension.point === "response");
|
|
1169
1251
|
for (const asset of uiRuntimeAssetPaths(config.view)) {
|
|
1170
1252
|
if (!(asset in files)) throw new Error(`UI image asset is not packaged: ${asset}`);
|
|
1171
1253
|
}
|
|
@@ -1199,6 +1281,72 @@ function validateUiConfig(extension, capabilities, routeIds, files) {
|
|
|
1199
1281
|
if (config.form !== undefined) validateLegacyUiForm(config.form, executable, extension.id);
|
|
1200
1282
|
}
|
|
1201
1283
|
|
|
1284
|
+
function validateResponseComponent(config, responsePoint) {
|
|
1285
|
+
if (!responsePoint) {
|
|
1286
|
+
if (Object.hasOwn(config, "a2ui")) throw new Error("A2UI metadata is only valid for response UI");
|
|
1287
|
+
return;
|
|
1288
|
+
}
|
|
1289
|
+
const value = config.a2ui;
|
|
1290
|
+
assertObject(value, "response A2UI metadata");
|
|
1291
|
+
rejectUnknownFields(value, new Set(["version", "description", "schema"]), "response A2UI metadata");
|
|
1292
|
+
if (Object.keys(value).length !== 3 || value.version !== "v0.9") throw new Error("response UI requires complete A2UI v0.9 metadata");
|
|
1293
|
+
requireText(value.description, 500, "response A2UI description");
|
|
1294
|
+
assertObject(value.schema, "response A2UI schema");
|
|
1295
|
+
if (Buffer.byteLength(JSON.stringify(value.schema), "utf8") > 16 * 1024) throw new Error("response A2UI schema is too large");
|
|
1296
|
+
validateResponseSchema(value.schema, 0, true);
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
function validateResponseSchema(schema, depth, root = false) {
|
|
1300
|
+
const fields = new Set([
|
|
1301
|
+
"type", "properties", "required", "additionalProperties", "items", "enum",
|
|
1302
|
+
"minItems", "maxItems", "minLength", "maxLength", "minimum", "maximum",
|
|
1303
|
+
]);
|
|
1304
|
+
if (depth > 6) throw new Error("response A2UI schema is too deep");
|
|
1305
|
+
rejectUnknownFields(schema, fields, "response A2UI schema");
|
|
1306
|
+
const types = new Set(["object", "array", "string", "number", "integer", "boolean", "null"]);
|
|
1307
|
+
if (!types.has(schema.type) || (root && schema.type !== "object")) throw new Error("response A2UI schema type is invalid");
|
|
1308
|
+
if (schema.enum !== undefined) {
|
|
1309
|
+
if (!Array.isArray(schema.enum) || schema.enum.length < 1 || schema.enum.length > 32 || schema.enum.some((item) => item !== null && typeof item === "object")) throw new Error("response A2UI schema enum is invalid");
|
|
1310
|
+
for (const item of schema.enum) validateUiJson(item, 0);
|
|
1311
|
+
}
|
|
1312
|
+
const byType = {
|
|
1313
|
+
object: new Set(["properties", "required", "additionalProperties"]),
|
|
1314
|
+
array: new Set(["items", "minItems", "maxItems"]),
|
|
1315
|
+
string: new Set(["minLength", "maxLength"]),
|
|
1316
|
+
number: new Set(["minimum", "maximum"]),
|
|
1317
|
+
integer: new Set(["minimum", "maximum"]),
|
|
1318
|
+
boolean: new Set(),
|
|
1319
|
+
null: new Set(),
|
|
1320
|
+
}[schema.type];
|
|
1321
|
+
if (Object.keys(schema).some((key) => !["type", "enum"].includes(key) && !byType.has(key))) throw new Error("response A2UI schema keyword does not match its type");
|
|
1322
|
+
if (schema.type === "object") {
|
|
1323
|
+
const properties = schema.properties === undefined ? {} : schema.properties;
|
|
1324
|
+
const required = schema.required === undefined ? [] : schema.required;
|
|
1325
|
+
const additional = schema.additionalProperties === undefined ? true : schema.additionalProperties;
|
|
1326
|
+
assertObject(properties, "response A2UI properties");
|
|
1327
|
+
if (Object.keys(properties).length > 64 || !Array.isArray(required) || required.length > 64 || new Set(required).size !== required.length || typeof additional !== "boolean") throw new Error("response A2UI object schema is invalid");
|
|
1328
|
+
for (const [key, child] of Object.entries(properties)) {
|
|
1329
|
+
if (!UI_STATE_KEY.test(key)) throw new Error("response A2UI property is invalid");
|
|
1330
|
+
assertObject(child, "response A2UI property schema");
|
|
1331
|
+
validateResponseSchema(child, depth + 1);
|
|
1332
|
+
}
|
|
1333
|
+
if (required.some((key) => typeof key !== "string" || !Object.hasOwn(properties, key))) throw new Error("response A2UI required property is invalid");
|
|
1334
|
+
} else if (schema.type === "array") {
|
|
1335
|
+
assertObject(schema.items, "response A2UI item schema");
|
|
1336
|
+
const minimum = schema.minItems === undefined ? 0 : schema.minItems;
|
|
1337
|
+
const maximum = schema.maxItems === undefined ? 64 : schema.maxItems;
|
|
1338
|
+
if (!Number.isInteger(minimum) || !Number.isInteger(maximum) || minimum < 0 || minimum > maximum || maximum > 64) throw new Error("response A2UI array schema is invalid");
|
|
1339
|
+
validateResponseSchema(schema.items, depth + 1);
|
|
1340
|
+
} else if (schema.type === "string") {
|
|
1341
|
+
const minimum = schema.minLength === undefined ? 0 : schema.minLength;
|
|
1342
|
+
const maximum = schema.maxLength === undefined ? 2000 : schema.maxLength;
|
|
1343
|
+
if (!Number.isInteger(minimum) || !Number.isInteger(maximum) || minimum < 0 || minimum > maximum || maximum > 2000) throw new Error("response A2UI string schema is invalid");
|
|
1344
|
+
} else if (["number", "integer"].includes(schema.type)) {
|
|
1345
|
+
for (const bound of [schema.minimum, schema.maximum]) if (bound !== undefined && (typeof bound !== "number" || !Number.isFinite(bound))) throw new Error("response A2UI number bound is invalid");
|
|
1346
|
+
if (schema.minimum !== undefined && schema.maximum !== undefined && schema.minimum > schema.maximum) throw new Error("response A2UI number range is invalid");
|
|
1347
|
+
}
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1202
1350
|
function validateLegacyUiForm(form, executable, extensionId) {
|
|
1203
1351
|
rejectUnknownFields(form, new Set(["submit_capability", "submit_label", "fields"]), `UI ${extensionId} form`);
|
|
1204
1352
|
if (!executable.has(form.submit_capability)) throw new Error(`UI ${extensionId} form targets an unknown capability`);
|
|
@@ -1283,9 +1431,9 @@ function validateUiRuntimeV2(config, executable, routes) {
|
|
|
1283
1431
|
validateUiNode(config.view, 0, { value: 0 }, executable, routes, sourceIds, stateKeys, null);
|
|
1284
1432
|
}
|
|
1285
1433
|
|
|
1286
|
-
function validateUiTheme(theme) {
|
|
1434
|
+
function validateUiTheme(theme, variant = false) {
|
|
1287
1435
|
if (theme == null) return;
|
|
1288
|
-
rejectUnknownFields(theme, new Set(contract.ui_runtime.theme_fields), "UI theme");
|
|
1436
|
+
rejectUnknownFields(theme, new Set(variant ? contract.ui_runtime.theme_variant_fields : contract.ui_runtime.theme_fields), "UI theme");
|
|
1289
1437
|
const colors = theme.color_scheme === undefined ? {} : theme.color_scheme;
|
|
1290
1438
|
assertObject(colors, "UI color scheme");
|
|
1291
1439
|
const roles = new Set([...UI_COLOR_TOKENS].filter((value) => value !== "transparent"));
|
|
@@ -1297,6 +1445,79 @@ function validateUiTheme(theme) {
|
|
|
1297
1445
|
if (theme.spacing !== undefined) validateUiNumber(theme.spacing, "UI theme spacing", 0, 32);
|
|
1298
1446
|
const density = theme.density === undefined ? "standard" : theme.density;
|
|
1299
1447
|
if (!["compact", "standard", "comfortable"].includes(density)) throw new Error("UI theme density is invalid");
|
|
1448
|
+
const typography = theme.typography === undefined ? {} : theme.typography;
|
|
1449
|
+
rejectUnknownFields(typography, new Set(contract.ui_runtime.theme_typography_fields), "UI theme typography");
|
|
1450
|
+
if (typography.scale !== undefined) validateUiNumber(typography.scale, "UI theme typography scale", 0.8, 1.4);
|
|
1451
|
+
for (const key of ["body_weight", "title_weight"]) {
|
|
1452
|
+
if (typography[key] !== undefined && ![400, 500, 600, 700].includes(typography[key])) throw new Error("UI theme typography weight is invalid");
|
|
1453
|
+
}
|
|
1454
|
+
validateThemeComponent(theme.surface, contract.ui_runtime.theme_surface_fields, ["color", "container_color"], { elevation: [0, 24] }, "surface");
|
|
1455
|
+
validateThemeComponent(theme.border, contract.ui_runtime.theme_border_fields, ["color"], { width: [0, 8], radius: [0, 64] }, "border");
|
|
1456
|
+
validateThemeComponent(theme.icon, contract.ui_runtime.theme_icon_fields, ["color"], { size: [12, 64] }, "icon");
|
|
1457
|
+
validateThemeComponent(theme.states, contract.ui_runtime.theme_state_fields, ["selected_color", "focus_color"], { disabled_opacity: [0.2, 0.8] }, "states");
|
|
1458
|
+
if (!variant) for (const mode of ["light", "dark"]) if (theme[mode] !== undefined) validateUiTheme(theme[mode], true);
|
|
1459
|
+
validateThemeContrast(theme);
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1462
|
+
function validateThemeComponent(value, fields, colors, numbers, label) {
|
|
1463
|
+
if (value === undefined) return;
|
|
1464
|
+
rejectUnknownFields(value, new Set(fields), `UI theme ${label}`);
|
|
1465
|
+
for (const key of colors) if (value[key] !== undefined && (typeof value[key] !== "string" || !UI_HEX_COLOR.test(value[key]))) throw new Error(`UI theme ${label} color is invalid`);
|
|
1466
|
+
for (const [key, [minimum, maximum]] of Object.entries(numbers)) if (value[key] !== undefined) validateUiNumber(value[key], `UI theme ${label} ${key}`, minimum, maximum);
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
function hexRgb(value) {
|
|
1470
|
+
if (typeof value !== "string" || !UI_HEX_COLOR.test(value)) return null;
|
|
1471
|
+
let raw = value.slice(1);
|
|
1472
|
+
if (raw.length === 8) {
|
|
1473
|
+
if (Number.parseInt(raw.slice(0, 2), 16) < 242) return null;
|
|
1474
|
+
raw = raw.slice(2);
|
|
1475
|
+
}
|
|
1476
|
+
return [0, 2, 4].map((index) => Number.parseInt(raw.slice(index, index + 2), 16) / 255);
|
|
1477
|
+
}
|
|
1478
|
+
|
|
1479
|
+
function contrastRatio(first, second) {
|
|
1480
|
+
const values = [hexRgb(first), hexRgb(second)];
|
|
1481
|
+
if (values.some((value) => value === null)) return null;
|
|
1482
|
+
const luminance = (rgb) => {
|
|
1483
|
+
const [r, g, b] = rgb.map((value) => value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4);
|
|
1484
|
+
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
|
1485
|
+
};
|
|
1486
|
+
const [one, two] = values.map(luminance);
|
|
1487
|
+
return (Math.max(one, two) + 0.05) / (Math.min(one, two) + 0.05);
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1490
|
+
const THEME_CONTRAST_PAIRS = [
|
|
1491
|
+
["primary", "on_primary"], ["primary_container", "on_primary_container"],
|
|
1492
|
+
["secondary", "on_secondary"], ["secondary_container", "on_secondary_container"],
|
|
1493
|
+
["tertiary", "on_tertiary"], ["error", "on_error"],
|
|
1494
|
+
["error_container", "on_error_container"], ["surface", "on_surface"],
|
|
1495
|
+
["surface_variant", "on_surface_variant"],
|
|
1496
|
+
];
|
|
1497
|
+
|
|
1498
|
+
function validateThemeContrast(theme) {
|
|
1499
|
+
const colors = theme.color_scheme || {};
|
|
1500
|
+
for (const [background, foreground] of THEME_CONTRAST_PAIRS) {
|
|
1501
|
+
const ratio = contrastRatio(colors[background], colors[foreground]);
|
|
1502
|
+
if (ratio !== null && ratio < 1.5) throw new Error(`UI theme ${foreground} is not visible on ${background}`);
|
|
1503
|
+
}
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1506
|
+
function collectThemeWarnings(manifest) {
|
|
1507
|
+
const warnings = [];
|
|
1508
|
+
for (const extension of manifest.ui_extensions || []) {
|
|
1509
|
+
const theme = extension?.config?.theme;
|
|
1510
|
+
if (!theme || typeof theme !== "object" || Array.isArray(theme)) continue;
|
|
1511
|
+
for (const [mode, variant] of [["base", theme], ["light", theme.light], ["dark", theme.dark]]) {
|
|
1512
|
+
if (!variant || typeof variant !== "object" || Array.isArray(variant)) continue;
|
|
1513
|
+
const colors = variant.color_scheme || {};
|
|
1514
|
+
for (const [background, foreground] of [["primary", "on_primary"], ["surface", "on_surface"]]) {
|
|
1515
|
+
const ratio = contrastRatio(colors[background], colors[foreground]);
|
|
1516
|
+
if (ratio !== null && ratio < 4.5) warnings.push(`${extension.id} ${mode} theme ${foreground}/${background} contrast is ${ratio.toFixed(2)}:1; verify text accessibility`);
|
|
1517
|
+
}
|
|
1518
|
+
}
|
|
1519
|
+
}
|
|
1520
|
+
return warnings;
|
|
1300
1521
|
}
|
|
1301
1522
|
|
|
1302
1523
|
function validateUiAppBar(appBar, executable, routes, sources, stateKeys) {
|
|
@@ -1385,7 +1606,7 @@ function validateUiNode(node, depth, counter, executable, routes, sources, state
|
|
|
1385
1606
|
validateUiAction(node.action, executable, routes, sources, stateKeys);
|
|
1386
1607
|
const children = node.children === undefined ? [] : node.children;
|
|
1387
1608
|
if (!Array.isArray(children) || children.length > 32) throw new Error("UI component children are invalid");
|
|
1388
|
-
const leaves = new Set(["text", "icon", "image", "avatar", "divider", "spacer", "button", "chip", "metric", "progress", "calendar", "chart", "field", "select", "switch", "empty"]);
|
|
1609
|
+
const leaves = new Set(["text", "icon", "image", "avatar", "divider", "spacer", "button", "chip", "metric", "progress", "calendar", "chart", "table", "field", "select", "switch", "empty"]);
|
|
1389
1610
|
if (leaves.has(node.type) && children.length) throw new Error("leaf UI component cannot have children");
|
|
1390
1611
|
if (["list", "timeline"].includes(node.type) && children.length !== 1) throw new Error("list UI component requires one item template");
|
|
1391
1612
|
if (["positioned", "scroll", "padding", "center", "expanded", "badge"].includes(node.type) && children.length !== 1) throw new Error("UI component requires exactly one child");
|
|
@@ -1460,11 +1681,11 @@ function validateUiProps(nodeType, props, sources, stateKeys) {
|
|
|
1460
1681
|
if (nodeType !== "positioned" && ["left", "top", "right", "bottom"].some((key) => props[key] !== undefined)) throw new Error("position offsets require a positioned UI component");
|
|
1461
1682
|
if (nodeType !== "expanded" && props.flex !== undefined) throw new Error("flex requires an expanded UI component");
|
|
1462
1683
|
if (nodeType !== "scroll" && ["scroll_direction", "shrink_wrap"].some((key) => props[key] !== undefined)) throw new Error("scroll properties require a scroll UI component");
|
|
1463
|
-
if (["list", "timeline", "calendar", "chart"].includes(nodeType)) {
|
|
1684
|
+
if (["list", "timeline", "calendar", "chart", "table"].includes(nodeType)) {
|
|
1464
1685
|
if (typeof props.source !== "string" || !UI_BINDING.test(props.source) || !props.source.startsWith("data.") || !sources.has(props.source.split(".")[1])) throw new Error("UI list source is invalid");
|
|
1465
1686
|
}
|
|
1466
1687
|
const chartType = props.chart_type === undefined ? "bar" : props.chart_type;
|
|
1467
|
-
if (nodeType === "chart" && !["bar", "line", "donut"].includes(chartType)) throw new Error("UI chart type is invalid");
|
|
1688
|
+
if (nodeType === "chart" && !["bar", "line", "donut", "scatter"].includes(chartType)) throw new Error("UI chart type is invalid");
|
|
1468
1689
|
if (nodeType === "calendar" && props.state_key != null && !stateKeys.has(props.state_key)) throw new Error("UI calendar state is unknown");
|
|
1469
1690
|
if (["field", "select", "switch"].includes(nodeType) && !stateKeys.has(props.state_key)) throw new Error("UI state field is unknown");
|
|
1470
1691
|
if (props.options != null) {
|
|
@@ -1832,6 +2053,7 @@ function escapeHtml(value) {
|
|
|
1832
2053
|
|
|
1833
2054
|
export const internal = {
|
|
1834
2055
|
artifactFileName,
|
|
2056
|
+
collectThemeWarnings,
|
|
1835
2057
|
compileManifest,
|
|
1836
2058
|
normalizeEntrypointPath,
|
|
1837
2059
|
projectFileBytes,
|