@utopia-studio-design/design-system-cli 0.3.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 +64 -24
- package/data/docs/community-template-contributions.md +85 -0
- package/data/docs/foundations.md +24 -4
- package/data/manifests/catalog.json +6 -3
- 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 +40 -5
- package/data/manifests/themes.json +33 -16
- package/data/templates/saas-solution-homepage/main.tsx +6 -2
- package/lib/api.mjs +33 -6
- package/lib/template-submission.mjs +137 -0
- package/package.json +1 -1
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.
|
|
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
|