@utopia-studio-design/design-system-cli 0.4.0 → 0.5.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 +23 -0
- package/bin/utopia-ds-mcp.mjs +9 -2
- package/bin/utopia-ds.mjs +115 -7
- package/data/blocks/activity-feed/ActivityFeedBlock.tsx +255 -0
- package/data/blocks/activity-feed/README.md +16 -0
- package/data/blocks/activity-feed/activity-feed-contract.ts +96 -0
- package/data/blocks/activity-feed/activity-feed-fixture.ts +22 -0
- package/data/blocks/activity-feed/activity-feed.css +154 -0
- package/data/blocks/agent-status/AgentStatusBlock.tsx +313 -0
- package/data/blocks/agent-status/README.md +15 -0
- package/data/blocks/agent-status/agent-status-contract.ts +133 -0
- package/data/blocks/agent-status/agent-status-fixture.ts +79 -0
- package/data/blocks/agent-status/agent-status.css +216 -0
- package/data/blocks/analytics-dashboard/AnalyticsDashboardBlocks.tsx +107 -0
- package/data/blocks/analytics-dashboard/AnalyticsMetricBlocks.tsx +87 -0
- package/data/blocks/analytics-dashboard/README.md +17 -0
- package/data/blocks/analytics-dashboard/analytics-dashboard.css +92 -0
- package/data/blocks/calendar-application/CalendarApplicationBlocks.tsx +519 -0
- package/data/blocks/calendar-application/README.md +20 -0
- package/data/blocks/calendar-application/calendar-application.css +216 -0
- package/data/blocks/calendar-application/calendar-localization.ts +90 -0
- package/data/blocks/calendar-application/calendar-panel-contract.ts +72 -0
- package/data/blocks/dashboard-composer/CeramicPuckAdapter.tsx +380 -0
- package/data/blocks/dashboard-composer/DashboardComposerBlocks.tsx +337 -0
- package/data/blocks/dashboard-composer/README.md +16 -0
- package/data/blocks/dashboard-composer/RegistryDataBlocks.tsx +238 -0
- package/data/blocks/dashboard-composer/dashboard-composer.css +328 -0
- package/data/blocks/database-workspace/DatabaseWorkspaceBlocks.tsx +240 -0
- package/data/blocks/database-workspace/README.md +9 -0
- package/data/blocks/database-workspace/database-workspace.css +107 -0
- package/data/blocks/interaction-workbench/InteractionWorkbench.tsx +271 -0
- package/data/blocks/interaction-workbench/README.md +9 -0
- package/data/blocks/interaction-workbench/interaction-workbench.css +226 -0
- package/data/docs/calendar.md +164 -0
- package/data/docs/charts.md +95 -0
- package/data/docs/composition-registry.md +174 -0
- package/data/docs/dashboard-composer.md +94 -0
- package/data/docs/database.md +68 -0
- package/data/docs/examples/dashboard-layout.v1.json +65 -0
- package/data/docs/foundations.md +2 -1
- package/data/docs/interaction-foundation.md +109 -0
- package/data/docs/observability.md +165 -0
- package/data/docs/quick-start-ai.md +55 -1
- package/data/manifests/blocks.json +392 -0
- package/data/manifests/catalog.json +7 -4
- package/data/manifests/components.json +191 -3
- package/data/manifests/composition-registry.json +427 -0
- package/data/manifests/motion-profiles.json +1 -1
- package/data/manifests/patterns.json +113 -1
- package/data/manifests/templates.json +346 -0
- package/data/manifests/theme-renacore.json +142 -0
- package/data/manifests/themes.json +314 -28
- package/data/templates/analytics-dashboard/AnalyticsDashboardBlocks.tsx +107 -0
- package/data/templates/analytics-dashboard/AnalyticsMetricBlocks.tsx +87 -0
- package/data/templates/analytics-dashboard/README.md +6 -0
- package/data/templates/analytics-dashboard/analytics-dashboard.css +92 -0
- package/data/templates/analytics-dashboard/index.html +13 -0
- package/data/templates/analytics-dashboard/main.tsx +196 -0
- package/data/templates/analytics-dashboard/styles.css +120 -0
- package/data/templates/analytics-dashboard/template.manifest.json +17 -0
- package/data/templates/calendar-application/CalendarApplicationBlocks.tsx +519 -0
- package/data/templates/calendar-application/README.md +10 -0
- package/data/templates/calendar-application/calendar-application.css +216 -0
- package/data/templates/calendar-application/calendar-localization.ts +90 -0
- package/data/templates/calendar-application/index.html +14 -0
- package/data/templates/calendar-application/main.tsx +409 -0
- package/data/templates/calendar-application/styles.css +248 -0
- package/data/templates/calendar-application/template.manifest.json +27 -0
- package/data/templates/composition-registry-lab/README.md +9 -0
- package/data/templates/composition-registry-lab/index.html +14 -0
- package/data/templates/composition-registry-lab/main.tsx +217 -0
- package/data/templates/composition-registry-lab/sample-layout.json +63 -0
- package/data/templates/composition-registry-lab/styles.css +343 -0
- package/data/templates/composition-registry-lab/template.manifest.json +34 -0
- package/data/templates/dashboard-composer/ActivityFeedBlock.tsx +255 -0
- package/data/templates/dashboard-composer/AgentStatusBlock.tsx +313 -0
- package/data/templates/dashboard-composer/AnalyticsDashboardBlocks.tsx +107 -0
- package/data/templates/dashboard-composer/AnalyticsMetricBlocks.tsx +87 -0
- package/data/templates/dashboard-composer/CalendarApplicationBlocks.tsx +519 -0
- package/data/templates/dashboard-composer/CeramicPuckAdapter.tsx +380 -0
- package/data/templates/dashboard-composer/DashboardComposerBlocks.tsx +337 -0
- package/data/templates/dashboard-composer/DatabaseWorkspaceBlocks.tsx +240 -0
- package/data/templates/dashboard-composer/README.md +53 -0
- package/data/templates/dashboard-composer/RegistryDataBlocks.tsx +238 -0
- package/data/templates/dashboard-composer/activity-feed-contract.ts +96 -0
- package/data/templates/dashboard-composer/activity-feed-fixture.ts +22 -0
- package/data/templates/dashboard-composer/activity-feed.css +154 -0
- package/data/templates/dashboard-composer/agent-status-contract.ts +133 -0
- package/data/templates/dashboard-composer/agent-status-fixture.ts +79 -0
- package/data/templates/dashboard-composer/agent-status-lab/index.html +19 -0
- package/data/templates/dashboard-composer/agent-status-lab/main.tsx +151 -0
- package/data/templates/dashboard-composer/agent-status-lab/styles.css +139 -0
- package/data/templates/dashboard-composer/agent-status.css +216 -0
- package/data/templates/dashboard-composer/analytics-dashboard.css +92 -0
- package/data/templates/dashboard-composer/calendar-application.css +216 -0
- package/data/templates/dashboard-composer/calendar-localization.ts +90 -0
- package/data/templates/dashboard-composer/calendar-panel-contract.ts +72 -0
- package/data/templates/dashboard-composer/dashboard-composer.css +328 -0
- package/data/templates/dashboard-composer/database-workspace.css +107 -0
- package/data/templates/dashboard-composer/index.html +14 -0
- package/data/templates/dashboard-composer/main.tsx +550 -0
- package/data/templates/dashboard-composer/sample-layout.json +147 -0
- package/data/templates/dashboard-composer/styles.css +198 -0
- package/data/templates/dashboard-composer/template.manifest.json +34 -0
- package/data/templates/dashboard-composer/visual-editor/PuckEditorSurface.tsx +132 -0
- package/data/templates/dashboard-composer/visual-editor/VisualEditorApp.tsx +343 -0
- package/data/templates/dashboard-composer/visual-editor/index.html +17 -0
- package/data/templates/dashboard-composer/visual-editor/main.tsx +95 -0
- package/data/templates/dashboard-composer/visual-editor/styles.css +310 -0
- package/data/templates/database-workspace/DatabaseWorkspaceBlocks.tsx +240 -0
- package/data/templates/database-workspace/README.md +5 -0
- package/data/templates/database-workspace/database-workspace.css +107 -0
- package/data/templates/database-workspace/index.html +14 -0
- package/data/templates/database-workspace/main.tsx +247 -0
- package/data/templates/database-workspace/styles.css +121 -0
- package/data/templates/database-workspace/template.manifest.json +19 -0
- package/data/templates/interaction-lab/InteractionWorkbench.tsx +271 -0
- package/data/templates/interaction-lab/README.md +7 -0
- package/data/templates/interaction-lab/index.html +14 -0
- package/data/templates/interaction-lab/interaction-workbench.css +226 -0
- package/data/templates/interaction-lab/main.tsx +104 -0
- package/data/templates/interaction-lab/styles.css +224 -0
- package/data/templates/interaction-lab/template.manifest.json +36 -0
- package/lib/api.mjs +72 -5
- package/lib/composition-runtime.mjs +382 -0
- package/lib/telemetry.mjs +74 -0
- package/package.json +4 -1
package/lib/api.mjs
CHANGED
|
@@ -1,14 +1,20 @@
|
|
|
1
1
|
import { existsSync, readFileSync, readdirSync } from 'node:fs'
|
|
2
2
|
import { dirname, join, resolve } from 'node:path'
|
|
3
3
|
import { fileURLToPath } from 'node:url'
|
|
4
|
+
import {
|
|
5
|
+
serializeLayoutDocument,
|
|
6
|
+
validateAgentLayoutMutation,
|
|
7
|
+
validateLayoutDocument,
|
|
8
|
+
} from './composition-runtime.mjs'
|
|
4
9
|
|
|
5
10
|
const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
|
6
11
|
const workspaceRoot = resolve(packageRoot, '../..')
|
|
7
12
|
const packagedDataRoot = join(packageRoot, 'data')
|
|
8
13
|
const hasWorkspaceSource = existsSync(join(workspaceRoot, 'packages/design-system/src/manifests/components.json'))
|
|
14
|
+
const packageMetadata = JSON.parse(readFileSync(join(packageRoot, 'package.json'), 'utf8'))
|
|
9
15
|
|
|
10
16
|
export const apiVersion = 1
|
|
11
|
-
export const cliVersion =
|
|
17
|
+
export const cliVersion = packageMetadata.version
|
|
12
18
|
export const mcpLaunch = {
|
|
13
19
|
command: 'npx',
|
|
14
20
|
args: ['-y', '--package', '@utopia-studio-design/design-system-cli', 'utopia-ds', 'mcp'],
|
|
@@ -52,6 +58,44 @@ export function listTemplates() {
|
|
|
52
58
|
return readJson(join(paths().manifests, 'templates.json')).templates
|
|
53
59
|
}
|
|
54
60
|
|
|
61
|
+
export function listBlocks() {
|
|
62
|
+
return readJson(join(paths().manifests, 'blocks.json')).blocks
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function getCompositionRegistry() {
|
|
66
|
+
return readJson(join(paths().manifests, 'composition-registry.json'))
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function listBlockDefinitions({ renderableOnly = false } = {}) {
|
|
70
|
+
return getCompositionRegistry().definitions.filter(
|
|
71
|
+
(definition) => !renderableOnly || definition.availability === 'available',
|
|
72
|
+
)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function getBlockDefinition(name) {
|
|
76
|
+
const key = normalize(name)
|
|
77
|
+
return listBlockDefinitions().find(
|
|
78
|
+
(item) => normalize(item.id) === key || normalize(item.title) === key,
|
|
79
|
+
) ?? null
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function validateLayout(document) {
|
|
83
|
+
return validateLayoutDocument(getCompositionRegistry(), document)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function validateAgentMutation(current, next) {
|
|
87
|
+
return validateAgentLayoutMutation(getCompositionRegistry(), current, next)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function serializeLayout(document) {
|
|
91
|
+
return serializeLayoutDocument(getCompositionRegistry(), document)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function getBlock(name) {
|
|
95
|
+
const key = normalize(name)
|
|
96
|
+
return listBlocks().find((item) => normalize(item.id) === key || normalize(item.title) === key) ?? null
|
|
97
|
+
}
|
|
98
|
+
|
|
55
99
|
export function getTemplate(name) {
|
|
56
100
|
const key = normalize(name)
|
|
57
101
|
return listTemplates().find((item) => normalize(item.id) === key || normalize(item.title) === key) ?? null
|
|
@@ -104,6 +148,8 @@ export function search(query) {
|
|
|
104
148
|
const score = (text) => terms.reduce((total, term) => total + (text.toLowerCase().includes(term) ? 1 : 0), 0)
|
|
105
149
|
const results = [
|
|
106
150
|
...listComponents().map((item) => ({ kind: 'component', id: item.name, title: item.name, summary: [...item.useWhen, item.category].join(' '), item })),
|
|
151
|
+
...listBlockDefinitions().map((item) => ({ kind: 'block-definition', id: item.id, title: item.title, summary: `${item.category} ${item.description} ${item.availability}`, item })),
|
|
152
|
+
...listBlocks().map((item) => ({ kind: 'block', id: item.id, title: item.title, summary: `${item.category} ${item.purpose}`, item })),
|
|
107
153
|
...listTemplates().map((item) => ({ kind: 'template', id: item.id, title: item.title, summary: `${item.category} ${item.purpose}`, item })),
|
|
108
154
|
...listThemes().map((item) => ({ kind: 'theme', id: item.id, title: item.name, summary: item.role, item })),
|
|
109
155
|
...listMotionProfiles().map((item) => ({ kind: 'motion-profile', id: item.id, title: item.label, summary: `${item.description} ${item.rules.join(' ')}`, item })),
|
|
@@ -130,6 +176,9 @@ export function capabilityManifest() {
|
|
|
130
176
|
{ name: 'init', args: ['[directory]'], flags: ['--yes', '--theme <id>'], responseType: 'init-result' },
|
|
131
177
|
{ name: 'search', args: ['<query>'], flags: [], responseType: 'search-results' },
|
|
132
178
|
{ name: 'component', args: ['<name>|--list'], flags: [], responseType: 'component|component-list' },
|
|
179
|
+
{ name: 'registry', args: ['<block-id>|--list'], flags: ['--renderable'], responseType: 'block-definition|block-definition-list' },
|
|
180
|
+
{ name: 'layout', args: ['validate <file>', 'serialize <file>'], flags: [], responseType: 'layout-validation-result|layout-serialization-result' },
|
|
181
|
+
{ name: 'block', args: ['<id>|--list'], flags: ['--copy <directory>', '--force'], responseType: 'block|block-list|block-copy-result' },
|
|
133
182
|
{ name: 'template', args: ['<id>|--list', 'validate [directory]', 'submit [directory]'], flags: ['--skeleton', '--copy <directory>', '--theme <id>', '--force'], responseType: 'template|template-list|template-copy-result|template-validation-result|template-submit-result' },
|
|
134
183
|
{ name: 'theme', args: ['<id>|--list', 'create <id> [directory]'], flags: ['--force'], responseType: 'theme|theme-list|theme-create-result' },
|
|
135
184
|
{ name: 'motion', args: ['<id>|--list'], flags: [], responseType: 'motion-profile|motion-profile-list' },
|
|
@@ -140,16 +189,16 @@ export function capabilityManifest() {
|
|
|
140
189
|
],
|
|
141
190
|
mcp: {
|
|
142
191
|
...mcpLaunch,
|
|
143
|
-
tools: ['search', 'list_components', 'get_component', 'list_templates', 'get_template', 'list_themes', 'get_theme', 'list_motion_profiles', 'get_motion_profile', 'list_docs', 'get_docs', 'doctor'],
|
|
192
|
+
tools: ['search', 'list_components', 'get_component', 'list_block_definitions', 'get_block_definition', 'validate_layout', 'validate_agent_layout_mutation', 'list_blocks', 'get_block', 'list_templates', 'get_template', 'list_themes', 'get_theme', 'list_motion_profiles', 'get_motion_profile', 'list_docs', 'get_docs', 'doctor'],
|
|
144
193
|
},
|
|
145
194
|
}
|
|
146
195
|
}
|
|
147
196
|
|
|
148
197
|
export function repositoryDoctor() {
|
|
149
198
|
const required = [
|
|
150
|
-
'catalog.json', 'components.json', 'templates.json', 'themes.json', 'motion-profiles.json',
|
|
199
|
+
'catalog.json', 'components.json', 'blocks.json', 'composition-registry.json', 'templates.json', 'themes.json', 'motion-profiles.json',
|
|
151
200
|
].map((name) => join(paths().manifests, name))
|
|
152
|
-
const docs = ['quick-start-ai', 'arabic-friendly', 'theme-authoring'].map((name) => join(paths().docs, `${name}.md`))
|
|
201
|
+
const docs = ['quick-start-ai', 'arabic-friendly', 'theme-authoring', 'composition-registry'].map((name) => join(paths().docs, `${name}.md`))
|
|
153
202
|
const missing = [...required, ...docs].filter((path) => !existsSync(path))
|
|
154
203
|
const motionRegistry = missing.includes(join(paths().manifests, 'motion-profiles.json')) ? null : getMotionRegistry()
|
|
155
204
|
const motionContract = motionRegistry?.profiles?.every((profile) =>
|
|
@@ -158,12 +207,30 @@ export function repositoryDoctor() {
|
|
|
158
207
|
)
|
|
159
208
|
) === true
|
|
160
209
|
const contractIssues = motionContract ? [] : ['motion-profiles.json: incomplete semantic recipe contract']
|
|
210
|
+
const compositionRegistry = missing.includes(join(paths().manifests, 'composition-registry.json'))
|
|
211
|
+
? null
|
|
212
|
+
: getCompositionRegistry()
|
|
213
|
+
const compositionContract = compositionRegistry?.version === 1
|
|
214
|
+
&& compositionRegistry.layoutSchemaVersion === 1
|
|
215
|
+
&& compositionRegistry.definitions?.every((definition) =>
|
|
216
|
+
definition.id
|
|
217
|
+
&& definition.version === 1
|
|
218
|
+
&& Array.isArray(definition.allowedSpans)
|
|
219
|
+
&& definition.propsSchema?.additionalProperties === false
|
|
220
|
+
&& Array.isArray(definition.states)
|
|
221
|
+
&& definition.agentPermissions
|
|
222
|
+
) === true
|
|
223
|
+
if (!compositionContract) contractIssues.push('composition-registry.json: incomplete block or layout contract')
|
|
161
224
|
return {
|
|
162
|
-
ok: missing.length === 0 && motionContract,
|
|
225
|
+
ok: missing.length === 0 && motionContract && compositionContract,
|
|
163
226
|
checks: {
|
|
164
227
|
manifests: required.every(existsSync),
|
|
165
228
|
docs: docs.every(existsSync),
|
|
166
229
|
components: listComponents().length,
|
|
230
|
+
blocks: listBlocks().length,
|
|
231
|
+
blockDefinitions: compositionRegistry?.definitions?.length ?? 0,
|
|
232
|
+
compositionContract,
|
|
233
|
+
layoutSchemaVersion: compositionRegistry?.layoutSchemaVersion ?? null,
|
|
167
234
|
templates: listTemplates().length,
|
|
168
235
|
themes: listThemes().length,
|
|
169
236
|
motionProfiles: listMotionProfiles().length,
|
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
const forbiddenStyleKeys = new Set([
|
|
2
|
+
'background',
|
|
3
|
+
'border',
|
|
4
|
+
'borderColor',
|
|
5
|
+
'borderRadius',
|
|
6
|
+
'borderWidth',
|
|
7
|
+
'boxShadow',
|
|
8
|
+
'className',
|
|
9
|
+
'color',
|
|
10
|
+
'css',
|
|
11
|
+
'fill',
|
|
12
|
+
'gap',
|
|
13
|
+
'margin',
|
|
14
|
+
'padding',
|
|
15
|
+
'radius',
|
|
16
|
+
'shadow',
|
|
17
|
+
'spacing',
|
|
18
|
+
'style',
|
|
19
|
+
'stroke',
|
|
20
|
+
'sx',
|
|
21
|
+
'tokenOverrides',
|
|
22
|
+
'tokens',
|
|
23
|
+
])
|
|
24
|
+
|
|
25
|
+
function isRecord(value) {
|
|
26
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function addIssue(issues, code, path, message, severity = 'error') {
|
|
30
|
+
issues.push({ code, path, message, severity })
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function stableValue(value) {
|
|
34
|
+
if (Array.isArray(value)) return value.map(stableValue)
|
|
35
|
+
if (!isRecord(value)) return value
|
|
36
|
+
return Object.fromEntries(
|
|
37
|
+
Object.keys(value).sort().map((key) => [key, stableValue(value[key])]),
|
|
38
|
+
)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function equalValue(left, right) {
|
|
42
|
+
return JSON.stringify(stableValue(left)) === JSON.stringify(stableValue(right))
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function findDefinition(registry, type) {
|
|
46
|
+
return registry.definitions.find((definition) => definition.id === type) ?? null
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function inspectForbiddenStyles(value, path, issues) {
|
|
50
|
+
if (Array.isArray(value)) {
|
|
51
|
+
value.forEach((entry, index) => inspectForbiddenStyles(entry, `${path}[${index}]`, issues))
|
|
52
|
+
return
|
|
53
|
+
}
|
|
54
|
+
if (!isRecord(value)) return
|
|
55
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
56
|
+
if (forbiddenStyleKeys.has(key)) {
|
|
57
|
+
addIssue(
|
|
58
|
+
issues,
|
|
59
|
+
'ARBITRARY_STYLE',
|
|
60
|
+
`${path}.${key}`,
|
|
61
|
+
`Arbitrary style key "${key}" is not part of the Ceramic semantic contract.`,
|
|
62
|
+
)
|
|
63
|
+
}
|
|
64
|
+
inspectForbiddenStyles(entry, `${path}.${key}`, issues)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function validateProp(rule, value, path, issues) {
|
|
69
|
+
if (rule.type === 'string') {
|
|
70
|
+
if (typeof value !== 'string') {
|
|
71
|
+
addIssue(issues, 'INVALID_PROP_TYPE', path, 'Expected a string.')
|
|
72
|
+
return
|
|
73
|
+
}
|
|
74
|
+
if (rule.enum && !rule.enum.includes(value)) {
|
|
75
|
+
addIssue(issues, 'INVALID_PROP_VALUE', path, `Expected one of: ${rule.enum.join(', ')}.`)
|
|
76
|
+
}
|
|
77
|
+
return
|
|
78
|
+
}
|
|
79
|
+
if (rule.type === 'number') {
|
|
80
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
|
81
|
+
addIssue(issues, 'INVALID_PROP_TYPE', path, 'Expected a finite number.')
|
|
82
|
+
return
|
|
83
|
+
}
|
|
84
|
+
if (rule.minimum !== undefined && value < rule.minimum) {
|
|
85
|
+
addIssue(issues, 'INVALID_PROP_VALUE', path, `Expected a value greater than or equal to ${rule.minimum}.`)
|
|
86
|
+
}
|
|
87
|
+
if (rule.maximum !== undefined && value > rule.maximum) {
|
|
88
|
+
addIssue(issues, 'INVALID_PROP_VALUE', path, `Expected a value less than or equal to ${rule.maximum}.`)
|
|
89
|
+
}
|
|
90
|
+
return
|
|
91
|
+
}
|
|
92
|
+
if (rule.type === 'boolean') {
|
|
93
|
+
if (typeof value !== 'boolean') addIssue(issues, 'INVALID_PROP_TYPE', path, 'Expected a boolean.')
|
|
94
|
+
return
|
|
95
|
+
}
|
|
96
|
+
if (rule.type === 'array') {
|
|
97
|
+
if (!Array.isArray(value)) {
|
|
98
|
+
addIssue(issues, 'INVALID_PROP_TYPE', path, 'Expected an array.')
|
|
99
|
+
return
|
|
100
|
+
}
|
|
101
|
+
if (rule.minItems !== undefined && value.length < rule.minItems) {
|
|
102
|
+
addIssue(issues, 'INVALID_PROP_VALUE', path, `Expected at least ${rule.minItems} item(s).`)
|
|
103
|
+
}
|
|
104
|
+
value.forEach((entry, index) => validateProp(rule.items, entry, `${path}[${index}]`, issues))
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function validateProps(definition, props, path, issues) {
|
|
109
|
+
if (!isRecord(props)) {
|
|
110
|
+
addIssue(issues, 'INVALID_PROPS', path, 'Block props must be an object.')
|
|
111
|
+
return
|
|
112
|
+
}
|
|
113
|
+
inspectForbiddenStyles(props, path, issues)
|
|
114
|
+
const schema = definition.propsSchema
|
|
115
|
+
for (const required of schema.required ?? []) {
|
|
116
|
+
if (!(required in props)) addIssue(issues, 'MISSING_PROP', `${path}.${required}`, 'Required block prop is missing.')
|
|
117
|
+
}
|
|
118
|
+
for (const [key, value] of Object.entries(props)) {
|
|
119
|
+
const rule = schema.properties[key]
|
|
120
|
+
if (!rule) {
|
|
121
|
+
addIssue(issues, 'UNKNOWN_PROP', `${path}.${key}`, `Prop "${key}" is not registered for ${definition.id}.`)
|
|
122
|
+
continue
|
|
123
|
+
}
|
|
124
|
+
validateProp(rule, value, `${path}.${key}`, issues)
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function validatePlacement(definition, placement, columns, path, issues) {
|
|
129
|
+
if (!isRecord(placement)) {
|
|
130
|
+
addIssue(issues, 'INVALID_PLACEMENT', path, 'Block placement must be an object.')
|
|
131
|
+
return
|
|
132
|
+
}
|
|
133
|
+
const allowed = new Set(['column', 'span', 'order', 'compactSpan'])
|
|
134
|
+
for (const key of Object.keys(placement)) {
|
|
135
|
+
if (!allowed.has(key)) addIssue(issues, 'UNKNOWN_PLACEMENT_KEY', `${path}.${key}`, `Unknown placement key "${key}".`)
|
|
136
|
+
}
|
|
137
|
+
for (const key of ['column', 'span', 'order']) {
|
|
138
|
+
if (!Number.isInteger(placement[key])) {
|
|
139
|
+
addIssue(issues, 'INVALID_PLACEMENT', `${path}.${key}`, 'Expected an integer.')
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
if (!Number.isInteger(placement.column) || !Number.isInteger(placement.span)) return
|
|
143
|
+
if (placement.column < 1 || placement.span < 1 || placement.column + placement.span - 1 > columns) {
|
|
144
|
+
addIssue(issues, 'PLACEMENT_OUT_OF_BOUNDS', path, `Placement must fit inside the ${columns}-column layout.`)
|
|
145
|
+
}
|
|
146
|
+
if (!definition.allowedSpans.includes(placement.span)) {
|
|
147
|
+
addIssue(
|
|
148
|
+
issues,
|
|
149
|
+
'UNSUPPORTED_SPAN',
|
|
150
|
+
`${path}.span`,
|
|
151
|
+
`${definition.id} supports spans ${definition.allowedSpans.join(', ')}.`,
|
|
152
|
+
)
|
|
153
|
+
}
|
|
154
|
+
if (placement.compactSpan !== undefined) {
|
|
155
|
+
if (!Number.isInteger(placement.compactSpan) || placement.compactSpan < 1 || placement.compactSpan > columns) {
|
|
156
|
+
addIssue(issues, 'INVALID_COMPACT_SPAN', `${path}.compactSpan`, `Compact span must be between 1 and ${columns}.`)
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function validateRegions(document, blockIds, issues) {
|
|
162
|
+
if (document.regions === undefined) return
|
|
163
|
+
if (!Array.isArray(document.regions)) {
|
|
164
|
+
addIssue(issues, 'INVALID_REGIONS', 'regions', 'Layout regions must be an array.')
|
|
165
|
+
return
|
|
166
|
+
}
|
|
167
|
+
const regionIds = new Set()
|
|
168
|
+
document.regions.forEach((region, index) => {
|
|
169
|
+
const path = `regions[${index}]`
|
|
170
|
+
if (!isRecord(region)) {
|
|
171
|
+
addIssue(issues, 'INVALID_REGION', path, 'Region must be an object.')
|
|
172
|
+
return
|
|
173
|
+
}
|
|
174
|
+
if (typeof region.id !== 'string' || !region.id) addIssue(issues, 'INVALID_REGION_ID', `${path}.id`, 'Region id is required.')
|
|
175
|
+
else if (regionIds.has(region.id)) addIssue(issues, 'DUPLICATE_REGION_ID', `${path}.id`, `Duplicate region id "${region.id}".`)
|
|
176
|
+
else regionIds.add(region.id)
|
|
177
|
+
if (typeof region.locked !== 'boolean') addIssue(issues, 'INVALID_REGION_LOCK', `${path}.locked`, 'Region locked must be a boolean.')
|
|
178
|
+
if (!Array.isArray(region.blockIds)) {
|
|
179
|
+
addIssue(issues, 'INVALID_REGION_BLOCKS', `${path}.blockIds`, 'Region blockIds must be an array.')
|
|
180
|
+
return
|
|
181
|
+
}
|
|
182
|
+
for (const blockId of region.blockIds) {
|
|
183
|
+
if (!blockIds.has(blockId)) {
|
|
184
|
+
addIssue(issues, 'UNKNOWN_REGION_BLOCK', `${path}.blockIds`, `Region references unknown block "${blockId}".`)
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
})
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function validateLayoutDocument(registry, document) {
|
|
191
|
+
const issues = []
|
|
192
|
+
if (!isRecord(document)) {
|
|
193
|
+
addIssue(issues, 'INVALID_DOCUMENT', '$', 'Ceramic layout must be an object.')
|
|
194
|
+
return { ok: false, issues, document: null }
|
|
195
|
+
}
|
|
196
|
+
const rootKeys = new Set(['schemaVersion', 'id', 'theme', 'columns', 'blocks', 'regions'])
|
|
197
|
+
for (const key of Object.keys(document)) {
|
|
198
|
+
if (!rootKeys.has(key)) addIssue(issues, 'UNKNOWN_DOCUMENT_KEY', key, `Unknown layout key "${key}".`)
|
|
199
|
+
}
|
|
200
|
+
if (document.schemaVersion !== registry.layoutSchemaVersion) {
|
|
201
|
+
addIssue(
|
|
202
|
+
issues,
|
|
203
|
+
'UNSUPPORTED_SCHEMA_VERSION',
|
|
204
|
+
'schemaVersion',
|
|
205
|
+
`Supported layout schema version is ${registry.layoutSchemaVersion}.`,
|
|
206
|
+
)
|
|
207
|
+
}
|
|
208
|
+
if (typeof document.id !== 'string' || !document.id) addIssue(issues, 'INVALID_LAYOUT_ID', 'id', 'Layout id is required.')
|
|
209
|
+
if (typeof document.theme !== 'string' || !document.theme) addIssue(issues, 'INVALID_THEME', 'theme', 'A Ceramic theme id is required.')
|
|
210
|
+
if (!Number.isInteger(document.columns) || document.columns < 1 || document.columns > 24) {
|
|
211
|
+
addIssue(issues, 'INVALID_COLUMNS', 'columns', 'Columns must be an integer between 1 and 24.')
|
|
212
|
+
}
|
|
213
|
+
if (!Array.isArray(document.blocks)) {
|
|
214
|
+
addIssue(issues, 'INVALID_BLOCKS', 'blocks', 'Layout blocks must be an array.')
|
|
215
|
+
return { ok: false, issues, document: null }
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const blockIds = new Set()
|
|
219
|
+
const columns = Number.isInteger(document.columns) ? document.columns : 12
|
|
220
|
+
document.blocks.forEach((block, index) => {
|
|
221
|
+
const path = `blocks[${index}]`
|
|
222
|
+
if (!isRecord(block)) {
|
|
223
|
+
addIssue(issues, 'INVALID_BLOCK', path, 'Block must be an object.')
|
|
224
|
+
return
|
|
225
|
+
}
|
|
226
|
+
const allowed = new Set(['id', 'type', 'version', 'placement', 'props', 'locked'])
|
|
227
|
+
for (const key of Object.keys(block)) {
|
|
228
|
+
if (!allowed.has(key)) addIssue(issues, 'UNKNOWN_BLOCK_KEY', `${path}.${key}`, `Unknown block key "${key}".`)
|
|
229
|
+
}
|
|
230
|
+
if (typeof block.id !== 'string' || !block.id) addIssue(issues, 'INVALID_BLOCK_ID', `${path}.id`, 'Block id is required.')
|
|
231
|
+
else if (blockIds.has(block.id)) addIssue(issues, 'DUPLICATE_BLOCK_ID', `${path}.id`, `Duplicate block id "${block.id}".`)
|
|
232
|
+
else blockIds.add(block.id)
|
|
233
|
+
if (typeof block.type !== 'string') {
|
|
234
|
+
addIssue(issues, 'INVALID_BLOCK_TYPE', `${path}.type`, 'Block type is required.')
|
|
235
|
+
return
|
|
236
|
+
}
|
|
237
|
+
const definition = findDefinition(registry, block.type)
|
|
238
|
+
if (!definition) {
|
|
239
|
+
addIssue(issues, 'UNKNOWN_BLOCK_TYPE', `${path}.type`, `Block type "${block.type}" is not registered.`)
|
|
240
|
+
return
|
|
241
|
+
}
|
|
242
|
+
if (block.version !== definition.version) {
|
|
243
|
+
addIssue(issues, 'UNSUPPORTED_BLOCK_VERSION', `${path}.version`, `${definition.id} requires version ${definition.version}.`)
|
|
244
|
+
}
|
|
245
|
+
if (definition.availability !== 'available') {
|
|
246
|
+
addIssue(
|
|
247
|
+
issues,
|
|
248
|
+
'BLOCK_NOT_RENDERABLE',
|
|
249
|
+
`${path}.type`,
|
|
250
|
+
`${definition.id} is registered for planning but has no production renderer yet.`,
|
|
251
|
+
'warning',
|
|
252
|
+
)
|
|
253
|
+
}
|
|
254
|
+
if (block.locked !== undefined && typeof block.locked !== 'boolean') {
|
|
255
|
+
addIssue(issues, 'INVALID_BLOCK_LOCK', `${path}.locked`, 'Block locked must be a boolean.')
|
|
256
|
+
}
|
|
257
|
+
validatePlacement(definition, block.placement, columns, `${path}.placement`, issues)
|
|
258
|
+
validateProps(definition, block.props, `${path}.props`, issues)
|
|
259
|
+
})
|
|
260
|
+
validateRegions(document, blockIds, issues)
|
|
261
|
+
return {
|
|
262
|
+
ok: !issues.some((issue) => issue.severity === 'error'),
|
|
263
|
+
issues,
|
|
264
|
+
document,
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function lockedBlockIds(document) {
|
|
269
|
+
const ids = new Set(document.blocks.filter((block) => block.locked).map((block) => block.id))
|
|
270
|
+
for (const region of document.regions ?? []) {
|
|
271
|
+
if (region.locked) for (const blockId of region.blockIds) ids.add(blockId)
|
|
272
|
+
}
|
|
273
|
+
return ids
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
export function validateAgentLayoutMutation(registry, current, next) {
|
|
277
|
+
const currentResult = validateLayoutDocument(registry, current)
|
|
278
|
+
const nextResult = validateLayoutDocument(registry, next)
|
|
279
|
+
const issues = [...currentResult.issues, ...nextResult.issues]
|
|
280
|
+
if (!currentResult.ok || !nextResult.ok) return { ok: false, issues }
|
|
281
|
+
|
|
282
|
+
const currentById = new Map(current.blocks.map((block) => [block.id, block]))
|
|
283
|
+
const nextById = new Map(next.blocks.map((block) => [block.id, block]))
|
|
284
|
+
const locked = lockedBlockIds(current)
|
|
285
|
+
|
|
286
|
+
for (const block of current.blocks) {
|
|
287
|
+
const nextBlock = nextById.get(block.id)
|
|
288
|
+
const definition = findDefinition(registry, block.type)
|
|
289
|
+
if (!nextBlock) {
|
|
290
|
+
if (locked.has(block.id)) addIssue(issues, 'BLOCK_LOCKED', `blocks.${block.id}`, 'Locked blocks cannot be removed by an agent.')
|
|
291
|
+
else if (!definition.agentPermissions.remove) addIssue(issues, 'AGENT_REMOVE_DENIED', `blocks.${block.id}`, 'Agent removal is not allowed.')
|
|
292
|
+
continue
|
|
293
|
+
}
|
|
294
|
+
if (locked.has(block.id) && !equalValue(block, nextBlock)) {
|
|
295
|
+
addIssue(issues, 'BLOCK_LOCKED', `blocks.${block.id}`, 'Locked blocks cannot be changed by an agent.')
|
|
296
|
+
continue
|
|
297
|
+
}
|
|
298
|
+
if (block.type !== nextBlock.type || block.version !== nextBlock.version) {
|
|
299
|
+
addIssue(
|
|
300
|
+
issues,
|
|
301
|
+
'BLOCK_IDENTITY_CHANGED',
|
|
302
|
+
`blocks.${block.id}`,
|
|
303
|
+
'An existing block id cannot be reassigned to a different type or version.',
|
|
304
|
+
)
|
|
305
|
+
continue
|
|
306
|
+
}
|
|
307
|
+
if (Boolean(block.locked) !== Boolean(nextBlock.locked)) {
|
|
308
|
+
addIssue(
|
|
309
|
+
issues,
|
|
310
|
+
'AGENT_LOCK_DENIED',
|
|
311
|
+
`blocks.${block.id}.locked`,
|
|
312
|
+
'Block locks are controlled by the user or Studio, not by an agent.',
|
|
313
|
+
)
|
|
314
|
+
}
|
|
315
|
+
if (!equalValue(block.placement, nextBlock.placement) && !definition.agentPermissions.move) {
|
|
316
|
+
addIssue(issues, 'AGENT_MOVE_DENIED', `blocks.${block.id}.placement`, 'Agent movement is not allowed.')
|
|
317
|
+
}
|
|
318
|
+
const propKeys = new Set([...Object.keys(block.props), ...Object.keys(nextBlock.props)])
|
|
319
|
+
for (const key of propKeys) {
|
|
320
|
+
if (!equalValue(block.props[key], nextBlock.props[key])
|
|
321
|
+
&& !definition.agentPermissions.editableProps.includes(key)) {
|
|
322
|
+
addIssue(issues, 'AGENT_PROP_DENIED', `blocks.${block.id}.props.${key}`, `Agent editing is not allowed for prop "${key}".`)
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
for (const block of next.blocks) {
|
|
328
|
+
if (currentById.has(block.id)) continue
|
|
329
|
+
const definition = findDefinition(registry, block.type)
|
|
330
|
+
if (!definition?.agentPermissions.create) {
|
|
331
|
+
addIssue(issues, 'AGENT_CREATE_DENIED', `blocks.${block.id}`, `Agent creation is not allowed for ${block.type}.`)
|
|
332
|
+
}
|
|
333
|
+
if (block.locked) {
|
|
334
|
+
addIssue(
|
|
335
|
+
issues,
|
|
336
|
+
'AGENT_LOCK_DENIED',
|
|
337
|
+
`blocks.${block.id}.locked`,
|
|
338
|
+
'An agent cannot create a user-locked block.',
|
|
339
|
+
)
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
for (const region of current.regions ?? []) {
|
|
344
|
+
const nextRegion = (next.regions ?? []).find((candidate) => candidate.id === region.id)
|
|
345
|
+
if (region.locked && !equalValue(region, nextRegion)) {
|
|
346
|
+
addIssue(issues, 'REGION_LOCKED', `regions.${region.id}`, 'Locked regions cannot be changed by an agent.')
|
|
347
|
+
continue
|
|
348
|
+
}
|
|
349
|
+
if (nextRegion && Boolean(region.locked) !== Boolean(nextRegion.locked)) {
|
|
350
|
+
addIssue(
|
|
351
|
+
issues,
|
|
352
|
+
'AGENT_LOCK_DENIED',
|
|
353
|
+
`regions.${region.id}.locked`,
|
|
354
|
+
'Region locks are controlled by the user or Studio, not by an agent.',
|
|
355
|
+
)
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const currentRegionIds = new Set((current.regions ?? []).map((region) => region.id))
|
|
360
|
+
for (const region of next.regions ?? []) {
|
|
361
|
+
if (!currentRegionIds.has(region.id) && region.locked) {
|
|
362
|
+
addIssue(
|
|
363
|
+
issues,
|
|
364
|
+
'AGENT_LOCK_DENIED',
|
|
365
|
+
`regions.${region.id}.locked`,
|
|
366
|
+
'An agent cannot create a user-locked region.',
|
|
367
|
+
)
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
return { ok: !issues.some((issue) => issue.severity === 'error'), issues }
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
export function serializeLayoutDocument(registry, document) {
|
|
375
|
+
const result = validateLayoutDocument(registry, document)
|
|
376
|
+
if (!result.ok) {
|
|
377
|
+
const error = new Error('Ceramic layout validation failed.')
|
|
378
|
+
error.issues = result.issues
|
|
379
|
+
throw error
|
|
380
|
+
}
|
|
381
|
+
return `${JSON.stringify(stableValue(document), null, 2)}\n`
|
|
382
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
function safeId(value) {
|
|
2
|
+
return typeof value === 'string' && /^[a-zA-Z0-9][a-zA-Z0-9._:-]{0,127}$/.test(value)
|
|
3
|
+
? value
|
|
4
|
+
: undefined
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function randomId() {
|
|
8
|
+
return globalThis.crypto?.randomUUID?.() ?? `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 14)}`
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const SAFE_COMMANDS = new Set([
|
|
12
|
+
'component',
|
|
13
|
+
'doctor',
|
|
14
|
+
'docs',
|
|
15
|
+
'help',
|
|
16
|
+
'init',
|
|
17
|
+
'manifest',
|
|
18
|
+
'motion',
|
|
19
|
+
'search',
|
|
20
|
+
'template',
|
|
21
|
+
'theme',
|
|
22
|
+
])
|
|
23
|
+
|
|
24
|
+
export function installCliTelemetry({ command, packageVersion, getErrorCode }) {
|
|
25
|
+
const endpoint = process.env.CERAMIC_TELEMETRY_ENDPOINT?.replace(/\/$/, '')
|
|
26
|
+
const ingestKey = process.env.CERAMIC_TELEMETRY_INGEST_KEY
|
|
27
|
+
const appId = safeId(process.env.CERAMIC_TELEMETRY_APP_ID)
|
|
28
|
+
const consented = process.env.CERAMIC_TELEMETRY_MODE === 'consented'
|
|
29
|
+
&& process.env.CERAMIC_TELEMETRY_CONSENT === 'granted'
|
|
30
|
+
const enabled = process.env.CERAMIC_TELEMETRY_ENABLED === 'true'
|
|
31
|
+
&& endpoint
|
|
32
|
+
&& ingestKey
|
|
33
|
+
&& appId
|
|
34
|
+
&& command !== 'mcp'
|
|
35
|
+
if (!enabled) return
|
|
36
|
+
|
|
37
|
+
let sent = false
|
|
38
|
+
process.on('beforeExit', async () => {
|
|
39
|
+
if (sent) return
|
|
40
|
+
sent = true
|
|
41
|
+
const errorCode = getErrorCode()
|
|
42
|
+
const success = !errorCode && (process.exitCode ?? 0) === 0
|
|
43
|
+
if (!consented && success) return
|
|
44
|
+
const event = {
|
|
45
|
+
schemaVersion: 1,
|
|
46
|
+
id: randomId(),
|
|
47
|
+
occurredAt: new Date().toISOString(),
|
|
48
|
+
name: 'cli_command_completed',
|
|
49
|
+
appId,
|
|
50
|
+
source: 'cli',
|
|
51
|
+
sessionId: randomId(),
|
|
52
|
+
accountId: consented ? safeId(process.env.CERAMIC_TELEMETRY_ACCOUNT_ID) : undefined,
|
|
53
|
+
actorId: consented ? safeId(process.env.CERAMIC_TELEMETRY_ACTOR_ID) : undefined,
|
|
54
|
+
errorCode,
|
|
55
|
+
operation: SAFE_COMMANDS.has(command) ? command : 'unknown',
|
|
56
|
+
packageVersion,
|
|
57
|
+
release: process.env.CERAMIC_RELEASE?.slice(0, 128),
|
|
58
|
+
properties: consented ? { success } : undefined,
|
|
59
|
+
}
|
|
60
|
+
try {
|
|
61
|
+
await fetch(`${endpoint}/v1/events`, {
|
|
62
|
+
method: 'POST',
|
|
63
|
+
headers: {
|
|
64
|
+
'content-type': 'application/json',
|
|
65
|
+
'x-ceramic-ingest-key': ingestKey,
|
|
66
|
+
},
|
|
67
|
+
body: JSON.stringify(event),
|
|
68
|
+
signal: AbortSignal.timeout(750),
|
|
69
|
+
})
|
|
70
|
+
} catch {
|
|
71
|
+
// Telemetry is best effort and must never change CLI behavior.
|
|
72
|
+
}
|
|
73
|
+
})
|
|
74
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@utopia-studio-design/design-system-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "AI-readable CLI and MCP server for Ceramic Design System",
|
|
5
|
+
"ceramic": {
|
|
6
|
+
"designSystem": "^0.7.0"
|
|
7
|
+
},
|
|
5
8
|
"type": "module",
|
|
6
9
|
"license": "MIT",
|
|
7
10
|
"bin": {
|