@utopia-studio-design/design-system-cli 0.2.0 → 0.3.1
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 +2 -0
- package/bin/utopia-ds-mcp.mjs +5 -3
- package/bin/utopia-ds.mjs +67 -27
- package/data/docs/ai-platform-plan.md +1 -1
- package/data/docs/community-template-contributions.md +85 -0
- package/data/docs/foundations.md +24 -4
- package/data/docs/quick-start-ai.md +1 -1
- package/data/manifests/catalog.json +10 -6
- package/data/manifests/community-template.schema.json +51 -0
- package/data/manifests/components.json +72 -6
- package/data/manifests/motion-profiles.json +121 -0
- package/data/manifests/theme-barrier-intelligence.json +83 -4
- package/data/manifests/theme-dextrum.json +140 -180
- package/data/manifests/theme-utopia-default.json +41 -6
- package/data/manifests/themes.json +35 -18
- package/data/templates/saas-solution-homepage/main.tsx +6 -2
- package/lib/api.mjs +42 -9
- package/lib/template-submission.mjs +137 -0
- package/package.json +13 -4
package/lib/api.mjs
CHANGED
|
@@ -8,7 +8,11 @@ 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.
|
|
11
|
+
export const cliVersion = '0.3.1'
|
|
12
|
+
export const mcpLaunch = {
|
|
13
|
+
command: 'npx',
|
|
14
|
+
args: ['-y', '--package', '@utopia-studio-design/design-system-cli', 'utopia-ds', 'mcp'],
|
|
15
|
+
}
|
|
12
16
|
|
|
13
17
|
export function paths(root = hasWorkspaceSource ? workspaceRoot : packagedDataRoot) {
|
|
14
18
|
return {
|
|
@@ -67,6 +71,21 @@ export function getTheme(name) {
|
|
|
67
71
|
return { ...entry, policy: readJson(policyPath) }
|
|
68
72
|
}
|
|
69
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
|
+
|
|
70
89
|
export function listDocs() {
|
|
71
90
|
return readdirSync(paths().docs)
|
|
72
91
|
.filter((name) => name.endsWith('.md'))
|
|
@@ -87,6 +106,7 @@ export function search(query) {
|
|
|
87
106
|
...listComponents().map((item) => ({ kind: 'component', id: item.name, title: item.name, summary: [...item.useWhen, item.category].join(' '), item })),
|
|
88
107
|
...listTemplates().map((item) => ({ kind: 'template', id: item.id, title: item.title, summary: `${item.category} ${item.purpose}`, item })),
|
|
89
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 })),
|
|
90
110
|
...listDocs().map((topic) => ({ kind: 'docs', id: topic, title: topic, summary: getDoc(topic)?.content.slice(0, 500) ?? '', item: { topic } })),
|
|
91
111
|
]
|
|
92
112
|
return results
|
|
@@ -110,37 +130,50 @@ export function capabilityManifest() {
|
|
|
110
130
|
{ name: 'init', args: ['[directory]'], flags: ['--yes', '--theme <id>'], responseType: 'init-result' },
|
|
111
131
|
{ name: 'search', args: ['<query>'], flags: [], responseType: 'search-results' },
|
|
112
132
|
{ name: 'component', args: ['<name>|--list'], flags: [], responseType: 'component|component-list' },
|
|
113
|
-
{ 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' },
|
|
114
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' },
|
|
115
136
|
{ name: 'docs', args: ['<topic>|--list'], flags: [], responseType: 'docs|docs-list' },
|
|
116
137
|
{ name: 'manifest', args: [], flags: [], responseType: 'manifest' },
|
|
117
138
|
{ name: 'doctor', args: [], flags: [], responseType: 'doctor-result' },
|
|
118
139
|
{ name: 'mcp', args: [], flags: [], responseType: 'stdio-server' },
|
|
119
140
|
],
|
|
120
141
|
mcp: {
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
tools: ['search', 'list_components', 'get_component', 'list_templates', 'get_template', 'list_themes', 'get_theme', 'list_docs', 'get_docs', 'doctor'],
|
|
142
|
+
...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'],
|
|
124
144
|
},
|
|
125
145
|
}
|
|
126
146
|
}
|
|
127
147
|
|
|
128
148
|
export function repositoryDoctor() {
|
|
129
149
|
const required = [
|
|
130
|
-
'catalog.json', 'components.json', 'templates.json', 'themes.json',
|
|
150
|
+
'catalog.json', 'components.json', 'templates.json', 'themes.json', 'motion-profiles.json',
|
|
131
151
|
].map((name) => join(paths().manifests, name))
|
|
132
152
|
const docs = ['quick-start-ai', 'arabic-friendly', 'theme-authoring'].map((name) => join(paths().docs, `${name}.md`))
|
|
133
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']
|
|
134
161
|
return {
|
|
135
|
-
ok: missing.length === 0,
|
|
162
|
+
ok: missing.length === 0 && motionContract,
|
|
136
163
|
checks: {
|
|
137
164
|
manifests: required.every(existsSync),
|
|
138
165
|
docs: docs.every(existsSync),
|
|
139
166
|
components: listComponents().length,
|
|
140
167
|
templates: listTemplates().length,
|
|
141
168
|
themes: listThemes().length,
|
|
142
|
-
|
|
169
|
+
motionProfiles: listMotionProfiles().length,
|
|
170
|
+
motionAdapters: getMotionRegistry().adapters.length,
|
|
171
|
+
motionContract,
|
|
172
|
+
mcp: existsSync(join(packageRoot, 'bin/utopia-ds-mcp.mjs'))
|
|
173
|
+
&& mcpLaunch.command === 'npx'
|
|
174
|
+
&& mcpLaunch.args.includes('utopia-ds')
|
|
175
|
+
&& mcpLaunch.args.at(-1) === 'mcp',
|
|
143
176
|
},
|
|
144
|
-
missing,
|
|
177
|
+
missing: [...missing, ...contractIssues],
|
|
145
178
|
}
|
|
146
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
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "AI-readable CLI and MCP server for Ceramic Design System",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -13,11 +13,20 @@
|
|
|
13
13
|
"./api": "./lib/api.mjs",
|
|
14
14
|
"./mcp": "./bin/utopia-ds-mcp.mjs"
|
|
15
15
|
},
|
|
16
|
-
"files": [
|
|
16
|
+
"files": [
|
|
17
|
+
"bin",
|
|
18
|
+
"lib",
|
|
19
|
+
"data",
|
|
20
|
+
"README.md"
|
|
21
|
+
],
|
|
17
22
|
"scripts": {
|
|
18
23
|
"sync-data": "node scripts/sync-data.mjs",
|
|
19
24
|
"prepack": "npm run sync-data"
|
|
20
25
|
},
|
|
21
|
-
"publishConfig": {
|
|
22
|
-
|
|
26
|
+
"publishConfig": {
|
|
27
|
+
"access": "public"
|
|
28
|
+
},
|
|
29
|
+
"engines": {
|
|
30
|
+
"node": ">=20"
|
|
31
|
+
}
|
|
23
32
|
}
|