@utopia-studio-design/design-system-cli 0.3.0 → 0.3.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/lib/api.mjs CHANGED
@@ -8,7 +8,7 @@ const packagedDataRoot = join(packageRoot, 'data')
8
8
  const hasWorkspaceSource = existsSync(join(workspaceRoot, 'packages/design-system/src/manifests/components.json'))
9
9
 
10
10
  export const apiVersion = 1
11
- export const cliVersion = '0.3.0'
11
+ export const cliVersion = '0.3.1'
12
12
  export const mcpLaunch = {
13
13
  command: 'npx',
14
14
  args: ['-y', '--package', '@utopia-studio-design/design-system-cli', 'utopia-ds', 'mcp'],
@@ -71,6 +71,21 @@ export function getTheme(name) {
71
71
  return { ...entry, policy: readJson(policyPath) }
72
72
  }
73
73
 
74
+ export function getMotionRegistry() {
75
+ return readJson(join(paths().manifests, 'motion-profiles.json'))
76
+ }
77
+
78
+ export function listMotionProfiles() {
79
+ return getMotionRegistry().profiles
80
+ }
81
+
82
+ export function getMotionProfile(name) {
83
+ const key = normalize(name)
84
+ const registry = getMotionRegistry()
85
+ const profile = registry.profiles.find((item) => normalize(item.id) === key)
86
+ return profile ? { ...profile, adapters: registry.adapters, contract: registry.contract } : null
87
+ }
88
+
74
89
  export function listDocs() {
75
90
  return readdirSync(paths().docs)
76
91
  .filter((name) => name.endsWith('.md'))
@@ -91,6 +106,7 @@ export function search(query) {
91
106
  ...listComponents().map((item) => ({ kind: 'component', id: item.name, title: item.name, summary: [...item.useWhen, item.category].join(' '), item })),
92
107
  ...listTemplates().map((item) => ({ kind: 'template', id: item.id, title: item.title, summary: `${item.category} ${item.purpose}`, item })),
93
108
  ...listThemes().map((item) => ({ kind: 'theme', id: item.id, title: item.name, summary: item.role, item })),
109
+ ...listMotionProfiles().map((item) => ({ kind: 'motion-profile', id: item.id, title: item.label, summary: `${item.description} ${item.rules.join(' ')}`, item })),
94
110
  ...listDocs().map((topic) => ({ kind: 'docs', id: topic, title: topic, summary: getDoc(topic)?.content.slice(0, 500) ?? '', item: { topic } })),
95
111
  ]
96
112
  return results
@@ -114,8 +130,9 @@ export function capabilityManifest() {
114
130
  { name: 'init', args: ['[directory]'], flags: ['--yes', '--theme <id>'], responseType: 'init-result' },
115
131
  { name: 'search', args: ['<query>'], flags: [], responseType: 'search-results' },
116
132
  { name: 'component', args: ['<name>|--list'], flags: [], responseType: 'component|component-list' },
117
- { name: 'template', args: ['<id>|--list'], flags: ['--skeleton', '--copy <directory>', '--theme <id>', '--force'], responseType: 'template|template-list|template-copy-result' },
133
+ { 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' },
118
134
  { name: 'theme', args: ['<id>|--list', 'create <id> [directory]'], flags: ['--force'], responseType: 'theme|theme-list|theme-create-result' },
135
+ { name: 'motion', args: ['<id>|--list'], flags: [], responseType: 'motion-profile|motion-profile-list' },
119
136
  { name: 'docs', args: ['<topic>|--list'], flags: [], responseType: 'docs|docs-list' },
120
137
  { name: 'manifest', args: [], flags: [], responseType: 'manifest' },
121
138
  { name: 'doctor', args: [], flags: [], responseType: 'doctor-result' },
@@ -123,30 +140,40 @@ export function capabilityManifest() {
123
140
  ],
124
141
  mcp: {
125
142
  ...mcpLaunch,
126
- tools: ['search', 'list_components', 'get_component', 'list_templates', 'get_template', 'list_themes', 'get_theme', 'list_docs', 'get_docs', 'doctor'],
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'],
127
144
  },
128
145
  }
129
146
  }
130
147
 
131
148
  export function repositoryDoctor() {
132
149
  const required = [
133
- 'catalog.json', 'components.json', 'templates.json', 'themes.json',
150
+ 'catalog.json', 'components.json', 'templates.json', 'themes.json', 'motion-profiles.json',
134
151
  ].map((name) => join(paths().manifests, name))
135
152
  const docs = ['quick-start-ai', 'arabic-friendly', 'theme-authoring'].map((name) => join(paths().docs, `${name}.md`))
136
153
  const missing = [...required, ...docs].filter((path) => !existsSync(path))
154
+ const motionRegistry = missing.includes(join(paths().manifests, 'motion-profiles.json')) ? null : getMotionRegistry()
155
+ const motionContract = motionRegistry?.profiles?.every((profile) =>
156
+ ['feedback', 'page', 'surface', 'layout'].every((recipe) =>
157
+ ['enter', 'exit'].every((phase) => profile.recipes?.[recipe]?.[phase]?.timing?.ease?.length === 4)
158
+ )
159
+ ) === true
160
+ const contractIssues = motionContract ? [] : ['motion-profiles.json: incomplete semantic recipe contract']
137
161
  return {
138
- ok: missing.length === 0,
162
+ ok: missing.length === 0 && motionContract,
139
163
  checks: {
140
164
  manifests: required.every(existsSync),
141
165
  docs: docs.every(existsSync),
142
166
  components: listComponents().length,
143
167
  templates: listTemplates().length,
144
168
  themes: listThemes().length,
169
+ motionProfiles: listMotionProfiles().length,
170
+ motionAdapters: getMotionRegistry().adapters.length,
171
+ motionContract,
145
172
  mcp: existsSync(join(packageRoot, 'bin/utopia-ds-mcp.mjs'))
146
173
  && mcpLaunch.command === 'npx'
147
174
  && mcpLaunch.args.includes('utopia-ds')
148
175
  && mcpLaunch.args.at(-1) === 'mcp',
149
176
  },
150
- missing,
177
+ missing: [...missing, ...contractIssues],
151
178
  }
152
179
  }
@@ -0,0 +1,137 @@
1
+ import { existsSync, lstatSync, readFileSync, readdirSync, realpathSync } from 'node:fs'
2
+ import { isAbsolute, relative, resolve } from 'node:path'
3
+
4
+ const manifestName = 'ceramic.template.json'
5
+ const sourceExtensions = /\.(css|html|js|jsx|md|mjs|ts|tsx)$/i
6
+ const secretName = /(^|\/)(\.env($|\.)|.*\.(pem|key|p12|pfx))$/i
7
+ const installScript = /^(preinstall|install|postinstall)$/
8
+
9
+ function issue(code, message, path = manifestName) {
10
+ return { code, message, path }
11
+ }
12
+
13
+ function inside(root, candidate) {
14
+ const path = relative(root, candidate)
15
+ return path === '' || (!path.startsWith('..') && !isAbsolute(path))
16
+ }
17
+
18
+ function walk(directory, root = directory, state = { files: [], symlinks: [] }) {
19
+ for (const name of readdirSync(directory)) {
20
+ if (['.git', 'dist', 'node_modules'].includes(name)) continue
21
+ const path = resolve(directory, name)
22
+ const relativePath = relative(root, path)
23
+ const stats = lstatSync(path)
24
+ if (stats.isSymbolicLink()) state.symlinks.push(relativePath)
25
+ else if (stats.isDirectory()) walk(path, root, state)
26
+ else state.files.push(relativePath)
27
+ }
28
+ return state
29
+ }
30
+
31
+ export function validateTemplateSubmission(directory = process.cwd()) {
32
+ const root = resolve(directory)
33
+ const manifestPath = resolve(root, manifestName)
34
+ const errors = []
35
+ const warnings = []
36
+
37
+ if (!existsSync(manifestPath)) {
38
+ return { ok: false, root, manifestPath, manifest: null, files: [], errors: [issue('MANIFEST_MISSING', `${manifestName} is required.`)], warnings }
39
+ }
40
+
41
+ let manifest
42
+ try {
43
+ manifest = JSON.parse(readFileSync(manifestPath, 'utf8'))
44
+ } catch (error) {
45
+ return { ok: false, root, manifestPath, manifest: null, files: [], errors: [issue('MANIFEST_JSON', `Manifest is not valid JSON: ${error.message}`)], warnings }
46
+ }
47
+
48
+ const requiredStrings = ['id', 'name', 'version', 'summary', 'category', 'license', 'repository', 'entry']
49
+ for (const field of requiredStrings) {
50
+ if (typeof manifest[field] !== 'string' || !manifest[field].trim()) errors.push(issue('FIELD_REQUIRED', `${field} must be a non-empty string.`))
51
+ }
52
+ if (manifest.schemaVersion !== 1) errors.push(issue('SCHEMA_VERSION', 'schemaVersion must be 1.'))
53
+ if (manifest.id && !/^[a-z][a-z0-9-]*$/.test(manifest.id)) errors.push(issue('ID_FORMAT', 'id must use lowercase kebab-case.'))
54
+ if (manifest.version && !/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(manifest.version)) errors.push(issue('VERSION_FORMAT', 'version must use semantic versioning.'))
55
+ if (manifest.summary && manifest.summary.trim().length < 20) errors.push(issue('SUMMARY_LENGTH', 'summary must contain at least 20 characters.'))
56
+ if (manifest.category && !['application', 'dashboard', 'website', 'workflow', 'commerce', 'content', 'other'].includes(manifest.category)) errors.push(issue('CATEGORY_VALUE', 'category is not supported by the community catalog.'))
57
+ if (manifest.repository && !/^https:\/\/github\.com\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\/?$/.test(manifest.repository)) errors.push(issue('REPOSITORY_FORMAT', 'repository must be a public GitHub repository URL.'))
58
+ if (manifest.preview && !/^https:\/\//.test(manifest.preview)) errors.push(issue('PREVIEW_FORMAT', 'preview must be an HTTPS URL.'))
59
+ if (!manifest.author || typeof manifest.author.name !== 'string' || !manifest.author.name.trim()) errors.push(issue('AUTHOR_REQUIRED', 'author.name is required.'))
60
+ if (manifest.designSystem?.package !== '@utopia-studio-design/design-system' || typeof manifest.designSystem?.version !== 'string') {
61
+ errors.push(issue('DESIGN_SYSTEM_CONTRACT', 'designSystem must declare the Ceramic package and compatible version.'))
62
+ }
63
+ if (!Array.isArray(manifest.files) || manifest.files.length === 0 || manifest.files.some((file) => typeof file !== 'string')) errors.push(issue('FILES_REQUIRED', 'files must list at least one source path.'))
64
+ if (!Array.isArray(manifest.themes) || manifest.themes.length === 0) errors.push(issue('THEMES_REQUIRED', 'themes must list at least one tested Ceramic theme.'))
65
+ for (const feature of ['responsive', 'darkMode', 'rtl']) {
66
+ if (manifest.features?.[feature] !== true) errors.push(issue('FEATURE_REQUIRED', `features.${feature} must be true.`))
67
+ }
68
+
69
+ for (const field of [manifest.entry, ...(Array.isArray(manifest.files) ? manifest.files : [])]) {
70
+ if (typeof field !== 'string') continue
71
+ const path = resolve(root, field)
72
+ if (!inside(root, path)) errors.push(issue('PATH_TRAVERSAL', `Path must stay inside the template: ${field}`, field))
73
+ else if (!existsSync(path)) errors.push(issue('FILE_MISSING', `Declared path does not exist: ${field}`, field))
74
+ else if (!inside(realpathSync(root), realpathSync(path))) errors.push(issue('PATH_SYMLINK', `Declared path resolves outside the template: ${field}`, field))
75
+ }
76
+
77
+ const scan = walk(root)
78
+ const files = scan.files
79
+ for (const file of scan.symlinks) errors.push(issue('SYMLINK', 'Symbolic links cannot be submitted.', file))
80
+ for (const file of files) {
81
+ if (secretName.test(file) && !file.endsWith('.example')) errors.push(issue('SECRET_FILE', 'Secret or private-key files cannot be submitted.', file))
82
+ }
83
+
84
+ const packagePath = resolve(root, 'package.json')
85
+ if (existsSync(packagePath)) {
86
+ try {
87
+ const packageJson = JSON.parse(readFileSync(packagePath, 'utf8'))
88
+ for (const script of Object.keys(packageJson.scripts ?? {})) {
89
+ if (installScript.test(script)) errors.push(issue('INSTALL_SCRIPT', `package.json cannot include a ${script} script.`, 'package.json'))
90
+ }
91
+ } catch (error) {
92
+ errors.push(issue('PACKAGE_JSON', `package.json is not valid JSON: ${error.message}`, 'package.json'))
93
+ }
94
+ }
95
+
96
+ let importsCeramic = false
97
+ let rawColors = 0
98
+ let physicalLayout = 0
99
+ for (const file of files.filter((name) => sourceExtensions.test(name))) {
100
+ const content = readFileSync(resolve(root, file), 'utf8')
101
+ if (content.includes('@utopia-studio-design/design-system')) importsCeramic = true
102
+ rawColors += content.match(/#[\da-fA-F]{3,8}\b/g)?.length ?? 0
103
+ physicalLayout += content.match(/\b(margin|padding|border)-(left|right)\b|\b(left|right)\s*:/g)?.length ?? 0
104
+ }
105
+ if (!importsCeramic) errors.push(issue('CERAMIC_IMPORT', 'Template source must import @utopia-studio-design/design-system.'))
106
+ if (rawColors > 0) warnings.push(issue('RAW_COLOR', `${rawColors} raw color value(s) found. Use semantic tokens unless the value belongs to approved theme-owned media.`))
107
+ if (physicalLayout > 0) warnings.push(issue('PHYSICAL_LAYOUT', `${physicalLayout} physical left/right declaration(s) found. Use logical properties for RTL.`))
108
+
109
+ return { ok: errors.length === 0, root, manifestPath, manifest, files, errors, warnings }
110
+ }
111
+
112
+ export function createTemplateSubmissionUrl(result) {
113
+ if (!result.ok || !result.manifest) return null
114
+ const manifest = result.manifest
115
+ const title = `[Community template] ${manifest.name} ${manifest.version}`
116
+ const body = [
117
+ '## Community template submission', '',
118
+ `- Template: ${manifest.name}`,
119
+ `- ID: \`${manifest.id}\``,
120
+ `- Version: \`${manifest.version}\``,
121
+ `- Author: ${manifest.author.name}`,
122
+ `- Repository: ${manifest.repository}`,
123
+ `- Preview: ${manifest.preview ?? 'Not provided'}`,
124
+ `- Ceramic compatibility: \`${manifest.designSystem.version}\``,
125
+ `- Tested themes: ${manifest.themes.join(', ')}`, '',
126
+ '## Contributor declaration', '',
127
+ '- [x] `npx utopia-ds template validate .` passes.',
128
+ '- [x] I have the right to submit this source and its assets.',
129
+ '- [x] Demo claims and data are clearly identified and are not presented as production facts.',
130
+ '- [x] The template supports responsive layout, dark mode, keyboard use, and RTL.', '',
131
+ 'Ceramic maintainers: review the pinned commit or release from the repository before approving this template.',
132
+ ].join('\n')
133
+ const query = new URLSearchParams({ title, body })
134
+ return `https://github.com/The-Utopia-Studio/Ceramic-Design-System/issues/new?${query}`
135
+ }
136
+
137
+ export const communityTemplateManifestName = manifestName
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@utopia-studio-design/design-system-cli",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "description": "AI-readable CLI and MCP server for Ceramic Design System",
5
5
  "type": "module",
6
6
  "license": "MIT",