uniweb 0.2.11 → 0.2.12
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/package.json +3 -3
- package/src/index.js +26 -4
- package/src/templates/fetchers/release.js +240 -0
- package/src/templates/index.js +81 -56
- package/src/templates/processor.js +247 -0
- package/src/templates/validator.js +206 -0
- package/src/versions.js +142 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "uniweb",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.12",
|
|
4
4
|
"description": "Create structured Vite + React sites with content/code separation",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -31,9 +31,9 @@
|
|
|
31
31
|
"node": ">=20.19"
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
|
+
"handlebars": "^4.7.8",
|
|
34
35
|
"prompts": "^2.4.2",
|
|
35
36
|
"tar": "^7.0.0",
|
|
36
|
-
"@uniweb/
|
|
37
|
-
"@uniweb/build": "0.1.0"
|
|
37
|
+
"@uniweb/build": "0.1.4"
|
|
38
38
|
}
|
|
39
39
|
}
|
package/src/index.js
CHANGED
|
@@ -18,6 +18,7 @@ import { resolve, join, dirname } from 'node:path'
|
|
|
18
18
|
import { fileURLToPath } from 'node:url'
|
|
19
19
|
import prompts from 'prompts'
|
|
20
20
|
import { build } from './commands/build.js'
|
|
21
|
+
import { getVersionsForTemplates, getVersion } from './versions.js'
|
|
21
22
|
import {
|
|
22
23
|
resolveTemplate,
|
|
23
24
|
applyExternalTemplate,
|
|
@@ -109,6 +110,20 @@ async function main() {
|
|
|
109
110
|
}
|
|
110
111
|
}
|
|
111
112
|
|
|
113
|
+
// Check for --variant flag
|
|
114
|
+
let variant = null
|
|
115
|
+
const variantIndex = args.indexOf('--variant')
|
|
116
|
+
if (variantIndex !== -1 && args[variantIndex + 1]) {
|
|
117
|
+
variant = args[variantIndex + 1]
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Check for --name flag (used for project display name)
|
|
121
|
+
let displayName = null
|
|
122
|
+
const nameIndex = args.indexOf('--name')
|
|
123
|
+
if (nameIndex !== -1 && args[nameIndex + 1]) {
|
|
124
|
+
displayName = args[nameIndex + 1]
|
|
125
|
+
}
|
|
126
|
+
|
|
112
127
|
// Interactive prompts
|
|
113
128
|
const response = await prompts([
|
|
114
129
|
{
|
|
@@ -190,7 +205,11 @@ async function main() {
|
|
|
190
205
|
|
|
191
206
|
log(`\nCreating project from ${resolved.name || resolved.package || `${resolved.owner}/${resolved.repo}`}...`)
|
|
192
207
|
|
|
193
|
-
await applyExternalTemplate(resolved, projectDir, {
|
|
208
|
+
await applyExternalTemplate(resolved, projectDir, {
|
|
209
|
+
projectName: displayName || projectName,
|
|
210
|
+
versions: getVersionsForTemplates(),
|
|
211
|
+
}, {
|
|
212
|
+
variant,
|
|
194
213
|
onProgress: (msg) => log(` ${colors.dim}${msg}${colors.reset}`),
|
|
195
214
|
onWarning: (msg) => log(` ${colors.yellow}Warning: ${msg}${colors.reset}`),
|
|
196
215
|
})
|
|
@@ -223,6 +242,8 @@ ${colors.bright}Commands:${colors.reset}
|
|
|
223
242
|
|
|
224
243
|
${colors.bright}Create Options:${colors.reset}
|
|
225
244
|
--template <type> Project template
|
|
245
|
+
--variant <name> Template variant (e.g., tailwind3 for legacy)
|
|
246
|
+
--name <name> Project display name
|
|
226
247
|
|
|
227
248
|
${colors.bright}Build Options:${colors.reset}
|
|
228
249
|
--target <type> Build target (foundation, site) - auto-detected if not specified
|
|
@@ -242,6 +263,7 @@ ${colors.bright}Examples:${colors.reset}
|
|
|
242
263
|
npx uniweb create my-project
|
|
243
264
|
npx uniweb create my-project --template single
|
|
244
265
|
npx uniweb create my-project --template marketing
|
|
266
|
+
npx uniweb create my-project --template marketing --variant tailwind3
|
|
245
267
|
npx uniweb create my-project --template github:myorg/template
|
|
246
268
|
npx uniweb build
|
|
247
269
|
npx uniweb build --target foundation
|
|
@@ -600,11 +622,11 @@ async function createSite(projectDir, projectName, isWorkspace = false) {
|
|
|
600
622
|
preview: 'vite preview',
|
|
601
623
|
},
|
|
602
624
|
dependencies: {
|
|
603
|
-
'@uniweb/runtime': '
|
|
625
|
+
'@uniweb/runtime': getVersion('@uniweb/runtime'),
|
|
604
626
|
...(isWorkspace ? {} : { 'foundation-example': '^0.1.0' }),
|
|
605
627
|
},
|
|
606
628
|
devDependencies: {
|
|
607
|
-
'@uniweb/build': '
|
|
629
|
+
'@uniweb/build': getVersion('@uniweb/build'),
|
|
608
630
|
'@vitejs/plugin-react': '^5.0.0',
|
|
609
631
|
autoprefixer: '^10.4.18',
|
|
610
632
|
'js-yaml': '^4.1.0',
|
|
@@ -904,7 +926,7 @@ async function createFoundation(projectDir, projectName, isWorkspace = false) {
|
|
|
904
926
|
react: '^18.2.0',
|
|
905
927
|
'react-dom': '^18.2.0',
|
|
906
928
|
tailwindcss: '^3.4.1',
|
|
907
|
-
uniweb: '
|
|
929
|
+
uniweb: getVersion('uniweb'),
|
|
908
930
|
vite: '^7.0.0',
|
|
909
931
|
'vite-plugin-svgr': '^4.2.0',
|
|
910
932
|
},
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GitHub Release fetcher - downloads official templates from GitHub releases
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { pipeline } from 'node:stream/promises'
|
|
6
|
+
import { createGunzip } from 'node:zlib'
|
|
7
|
+
import { mkdtemp, rm } from 'node:fs/promises'
|
|
8
|
+
import { tmpdir } from 'node:os'
|
|
9
|
+
import { join } from 'node:path'
|
|
10
|
+
import * as tar from 'tar'
|
|
11
|
+
|
|
12
|
+
// GitHub repository for official templates
|
|
13
|
+
const TEMPLATES_REPO = 'uniweb/templates'
|
|
14
|
+
const GITHUB_API = 'https://api.github.com'
|
|
15
|
+
|
|
16
|
+
// Cache for manifest (avoid re-fetching in same session)
|
|
17
|
+
let manifestCache = null
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Fetch the manifest.json from the latest release
|
|
21
|
+
*
|
|
22
|
+
* @param {Object} options - Fetch options
|
|
23
|
+
* @param {string} options.version - Specific version tag (default: latest)
|
|
24
|
+
* @param {Function} options.onProgress - Progress callback
|
|
25
|
+
* @returns {Promise<Object>} { version, templates, downloadUrl }
|
|
26
|
+
*/
|
|
27
|
+
export async function fetchManifest(options = {}) {
|
|
28
|
+
const { version, onProgress } = options
|
|
29
|
+
|
|
30
|
+
// Return cached manifest if available and no specific version requested
|
|
31
|
+
if (manifestCache && !version) {
|
|
32
|
+
return manifestCache
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
onProgress?.('Fetching template manifest...')
|
|
36
|
+
|
|
37
|
+
// Get release info
|
|
38
|
+
const releaseUrl = version
|
|
39
|
+
? `${GITHUB_API}/repos/${TEMPLATES_REPO}/releases/tags/${version}`
|
|
40
|
+
: `${GITHUB_API}/repos/${TEMPLATES_REPO}/releases/latest`
|
|
41
|
+
|
|
42
|
+
const releaseResponse = await fetchWithRetry(releaseUrl, {
|
|
43
|
+
headers: getGitHubHeaders(),
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
if (!releaseResponse.ok) {
|
|
47
|
+
if (releaseResponse.status === 404) {
|
|
48
|
+
throw new Error(
|
|
49
|
+
version
|
|
50
|
+
? `Release ${version} not found for ${TEMPLATES_REPO}`
|
|
51
|
+
: `No releases found for ${TEMPLATES_REPO}`
|
|
52
|
+
)
|
|
53
|
+
}
|
|
54
|
+
await handleGitHubError(releaseResponse)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const release = await releaseResponse.json()
|
|
58
|
+
|
|
59
|
+
// Find manifest.json asset
|
|
60
|
+
const manifestAsset = release.assets?.find(a => a.name === 'manifest.json')
|
|
61
|
+
if (!manifestAsset) {
|
|
62
|
+
throw new Error(
|
|
63
|
+
`Release ${release.tag_name} does not contain manifest.json. ` +
|
|
64
|
+
`This may be an older release format.`
|
|
65
|
+
)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Download manifest
|
|
69
|
+
const manifestResponse = await fetchWithRetry(manifestAsset.browser_download_url, {
|
|
70
|
+
headers: getGitHubHeaders(),
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
if (!manifestResponse.ok) {
|
|
74
|
+
throw new Error(`Failed to download manifest: ${manifestResponse.status}`)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const manifest = await manifestResponse.json()
|
|
78
|
+
|
|
79
|
+
// Build result with download URL base
|
|
80
|
+
const result = {
|
|
81
|
+
version: release.tag_name,
|
|
82
|
+
templates: manifest.templates || {},
|
|
83
|
+
// Base URL for downloading template tarballs
|
|
84
|
+
downloadUrlBase: `https://github.com/${TEMPLATES_REPO}/releases/download/${release.tag_name}`,
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Cache if this was a "latest" fetch
|
|
88
|
+
if (!version) {
|
|
89
|
+
manifestCache = result
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return result
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Fetch a specific template from GitHub releases
|
|
97
|
+
*
|
|
98
|
+
* @param {string} name - Template name (e.g., 'marketing')
|
|
99
|
+
* @param {Object} options - Fetch options
|
|
100
|
+
* @param {string} options.version - Specific version tag (default: latest)
|
|
101
|
+
* @param {Function} options.onProgress - Progress callback
|
|
102
|
+
* @returns {Promise<Object>} { tempDir, version, metadata }
|
|
103
|
+
*/
|
|
104
|
+
export async function fetchOfficialTemplate(name, options = {}) {
|
|
105
|
+
const { version, onProgress } = options
|
|
106
|
+
|
|
107
|
+
// Get manifest first
|
|
108
|
+
const manifest = await fetchManifest({ version, onProgress })
|
|
109
|
+
|
|
110
|
+
// Check if template exists
|
|
111
|
+
const templateInfo = manifest.templates[name]
|
|
112
|
+
if (!templateInfo) {
|
|
113
|
+
const available = Object.keys(manifest.templates).join(', ')
|
|
114
|
+
throw new Error(
|
|
115
|
+
`Template "${name}" not found in release ${manifest.version}.\n` +
|
|
116
|
+
`Available templates: ${available || 'none'}`
|
|
117
|
+
)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
onProgress?.(`Downloading ${name} template (${manifest.version})...`)
|
|
121
|
+
|
|
122
|
+
// Download template tarball
|
|
123
|
+
const tarballUrl = `${manifest.downloadUrlBase}/${name}.tar.gz`
|
|
124
|
+
const tarballResponse = await fetchWithRetry(tarballUrl, {
|
|
125
|
+
headers: getGitHubHeaders(),
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
if (!tarballResponse.ok) {
|
|
129
|
+
if (tarballResponse.status === 404) {
|
|
130
|
+
throw new Error(
|
|
131
|
+
`Template tarball not found: ${name}.tar.gz\n` +
|
|
132
|
+
`The release may be incomplete or corrupted.`
|
|
133
|
+
)
|
|
134
|
+
}
|
|
135
|
+
throw new Error(`Failed to download template: ${tarballResponse.status}`)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Extract to temp directory
|
|
139
|
+
const tempDir = await mkdtemp(join(tmpdir(), 'uniweb-template-'))
|
|
140
|
+
|
|
141
|
+
try {
|
|
142
|
+
onProgress?.('Extracting template...')
|
|
143
|
+
|
|
144
|
+
await pipeline(
|
|
145
|
+
tarballResponse.body,
|
|
146
|
+
createGunzip(),
|
|
147
|
+
tar.extract({ cwd: tempDir, strip: 0 })
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
// Tarball contains template in a subdirectory named after the template
|
|
151
|
+
// e.g., marketing.tar.gz extracts to marketing/template.json
|
|
152
|
+
return {
|
|
153
|
+
tempDir: join(tempDir, name),
|
|
154
|
+
baseTempDir: tempDir, // For cleanup
|
|
155
|
+
version: manifest.version,
|
|
156
|
+
metadata: templateInfo,
|
|
157
|
+
}
|
|
158
|
+
} catch (err) {
|
|
159
|
+
// Clean up on error
|
|
160
|
+
await rm(tempDir, { recursive: true, force: true }).catch(() => {})
|
|
161
|
+
throw err
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* List available templates from the latest release
|
|
167
|
+
*
|
|
168
|
+
* @param {Object} options - Fetch options
|
|
169
|
+
* @param {Function} options.onProgress - Progress callback
|
|
170
|
+
* @returns {Promise<Array>} List of template metadata
|
|
171
|
+
*/
|
|
172
|
+
export async function listOfficialTemplates(options = {}) {
|
|
173
|
+
try {
|
|
174
|
+
const manifest = await fetchManifest(options)
|
|
175
|
+
return Object.entries(manifest.templates).map(([id, info]) => ({
|
|
176
|
+
id,
|
|
177
|
+
...info,
|
|
178
|
+
}))
|
|
179
|
+
} catch {
|
|
180
|
+
// Return empty list if can't fetch
|
|
181
|
+
return []
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Clear the manifest cache
|
|
187
|
+
*/
|
|
188
|
+
export function clearManifestCache() {
|
|
189
|
+
manifestCache = null
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Get GitHub API headers
|
|
194
|
+
*/
|
|
195
|
+
function getGitHubHeaders() {
|
|
196
|
+
return {
|
|
197
|
+
'Accept': 'application/vnd.github+json',
|
|
198
|
+
'User-Agent': 'uniweb-cli',
|
|
199
|
+
// Support private repos or higher rate limits if GITHUB_TOKEN is set
|
|
200
|
+
...(process.env.GITHUB_TOKEN && {
|
|
201
|
+
'Authorization': `Bearer ${process.env.GITHUB_TOKEN}`
|
|
202
|
+
}),
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Handle GitHub API errors
|
|
208
|
+
*/
|
|
209
|
+
async function handleGitHubError(response) {
|
|
210
|
+
if (response.status === 403) {
|
|
211
|
+
const remaining = response.headers.get('x-ratelimit-remaining')
|
|
212
|
+
if (remaining === '0') {
|
|
213
|
+
throw new Error(
|
|
214
|
+
'GitHub API rate limit exceeded.\n' +
|
|
215
|
+
'Set GITHUB_TOKEN environment variable for higher limits.'
|
|
216
|
+
)
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
throw new Error(`GitHub API error: ${response.status}`)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Fetch with retry and timeout
|
|
224
|
+
*/
|
|
225
|
+
async function fetchWithRetry(url, options = {}, maxRetries = 3) {
|
|
226
|
+
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
227
|
+
try {
|
|
228
|
+
const response = await fetch(url, {
|
|
229
|
+
...options,
|
|
230
|
+
redirect: 'follow',
|
|
231
|
+
signal: AbortSignal.timeout(60000), // 60s timeout
|
|
232
|
+
})
|
|
233
|
+
return response
|
|
234
|
+
} catch (err) {
|
|
235
|
+
if (attempt === maxRetries) throw err
|
|
236
|
+
const delay = Math.min(1000 * Math.pow(2, attempt), 10000)
|
|
237
|
+
await new Promise(r => setTimeout(r, delay))
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
package/src/templates/index.js
CHANGED
|
@@ -3,21 +3,19 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import { rm } from 'node:fs/promises'
|
|
6
|
-
import {
|
|
7
|
-
import { join, dirname } from 'node:path'
|
|
8
|
-
import { fileURLToPath } from 'node:url'
|
|
6
|
+
import { join } from 'node:path'
|
|
9
7
|
|
|
10
8
|
import { parseTemplateId, getTemplateDisplayName, BUILTIN_TEMPLATES, OFFICIAL_TEMPLATES } from './resolver.js'
|
|
11
9
|
import { fetchNpmTemplate } from './fetchers/npm.js'
|
|
12
10
|
import { fetchGitHubTemplate } from './fetchers/github.js'
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
}
|
|
11
|
+
import { fetchOfficialTemplate, listOfficialTemplates } from './fetchers/release.js'
|
|
12
|
+
import { validateTemplate } from './validator.js'
|
|
13
|
+
import {
|
|
14
|
+
copyTemplateDirectory,
|
|
15
|
+
registerVersions,
|
|
16
|
+
getMissingVersions,
|
|
17
|
+
clearMissingVersions
|
|
18
|
+
} from './processor.js'
|
|
21
19
|
|
|
22
20
|
/**
|
|
23
21
|
* Resolve a template identifier and return the template path
|
|
@@ -55,36 +53,24 @@ export async function resolveTemplate(identifier, options = {}) {
|
|
|
55
53
|
}
|
|
56
54
|
|
|
57
55
|
/**
|
|
58
|
-
* Resolve an official template from
|
|
56
|
+
* Resolve an official template from GitHub releases
|
|
59
57
|
*/
|
|
60
58
|
async function resolveOfficialTemplate(name, options = {}) {
|
|
61
59
|
const { onProgress } = options
|
|
62
60
|
|
|
63
|
-
|
|
64
|
-
throw new Error(
|
|
65
|
-
`Official template "${name}" requires @uniweb/templates package.\n` +
|
|
66
|
-
`Install it with: npm install @uniweb/templates`
|
|
67
|
-
)
|
|
68
|
-
}
|
|
61
|
+
const { tempDir, baseTempDir, version } = await fetchOfficialTemplate(name, { onProgress })
|
|
69
62
|
|
|
70
|
-
|
|
71
|
-
const available = await templatesPackage.listBuiltinTemplates()
|
|
72
|
-
const names = available.map(t => t.id).join(', ')
|
|
73
|
-
throw new Error(
|
|
74
|
-
`Official template "${name}" not found.\n` +
|
|
75
|
-
`Available templates: ${names || 'none'}`
|
|
76
|
-
)
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
const templatePath = templatesPackage.getTemplatePath(name)
|
|
80
|
-
|
|
81
|
-
onProgress?.(`Using official template: ${name}`)
|
|
63
|
+
onProgress?.(`Using official template: ${name} (${version})`)
|
|
82
64
|
|
|
83
65
|
return {
|
|
84
66
|
type: 'official',
|
|
85
67
|
name,
|
|
86
|
-
|
|
87
|
-
|
|
68
|
+
version,
|
|
69
|
+
path: tempDir,
|
|
70
|
+
cleanup: async () => {
|
|
71
|
+
// Clean up the base temp directory (parent of the template)
|
|
72
|
+
await rm(baseTempDir, { recursive: true, force: true }).catch(() => {})
|
|
73
|
+
},
|
|
88
74
|
}
|
|
89
75
|
}
|
|
90
76
|
|
|
@@ -132,6 +118,54 @@ async function resolveGitHubTemplate(parsed, options = {}) {
|
|
|
132
118
|
}
|
|
133
119
|
}
|
|
134
120
|
|
|
121
|
+
/**
|
|
122
|
+
* Apply a template to a target directory
|
|
123
|
+
*
|
|
124
|
+
* @param {string} templatePath - Path to the template root (contains template.json)
|
|
125
|
+
* @param {string} targetPath - Destination directory for the scaffolded project
|
|
126
|
+
* @param {Object} data - Template variables
|
|
127
|
+
* @param {Object} options - Apply options
|
|
128
|
+
* @param {string} options.variant - Template variant to use
|
|
129
|
+
* @param {string} options.uniwebVersion - Current Uniweb version for compatibility check
|
|
130
|
+
* @param {Function} options.onWarning - Warning callback
|
|
131
|
+
* @param {Function} options.onProgress - Progress callback
|
|
132
|
+
* @returns {Promise<Object>} Template metadata
|
|
133
|
+
*/
|
|
134
|
+
export async function applyTemplate(templatePath, targetPath, data = {}, options = {}) {
|
|
135
|
+
const { uniwebVersion, variant, onWarning, onProgress } = options
|
|
136
|
+
|
|
137
|
+
// Validate the template
|
|
138
|
+
const metadata = await validateTemplate(templatePath, { uniwebVersion })
|
|
139
|
+
|
|
140
|
+
// Register versions for the {{version}} helper
|
|
141
|
+
if (data.versions) {
|
|
142
|
+
registerVersions(data.versions)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Apply default variables
|
|
146
|
+
const templateData = {
|
|
147
|
+
year: new Date().getFullYear(),
|
|
148
|
+
...data
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Copy template files
|
|
152
|
+
await copyTemplateDirectory(
|
|
153
|
+
metadata.templateDir,
|
|
154
|
+
targetPath,
|
|
155
|
+
templateData,
|
|
156
|
+
{ variant, onWarning, onProgress }
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
// Check for missing versions and warn
|
|
160
|
+
const missingVersions = getMissingVersions()
|
|
161
|
+
if (missingVersions.length > 0 && onWarning) {
|
|
162
|
+
onWarning(`Missing version data for packages: ${missingVersions.join(', ')}. Using fallback version.`)
|
|
163
|
+
}
|
|
164
|
+
clearMissingVersions()
|
|
165
|
+
|
|
166
|
+
return metadata
|
|
167
|
+
}
|
|
168
|
+
|
|
135
169
|
/**
|
|
136
170
|
* Apply an external template to a target directory
|
|
137
171
|
*
|
|
@@ -141,21 +175,14 @@ async function resolveGitHubTemplate(parsed, options = {}) {
|
|
|
141
175
|
* @param {Object} options - Apply options
|
|
142
176
|
*/
|
|
143
177
|
export async function applyExternalTemplate(resolved, targetPath, data, options = {}) {
|
|
144
|
-
const { onProgress, onWarning } = options
|
|
145
|
-
|
|
146
|
-
if (!templatesPackage) {
|
|
147
|
-
throw new Error(
|
|
148
|
-
'External template application requires @uniweb/templates package.\n' +
|
|
149
|
-
'Install it with: npm install @uniweb/templates'
|
|
150
|
-
)
|
|
151
|
-
}
|
|
178
|
+
const { variant, onProgress, onWarning } = options
|
|
152
179
|
|
|
153
180
|
try {
|
|
154
|
-
const metadata = await
|
|
181
|
+
const metadata = await applyTemplate(
|
|
155
182
|
resolved.path,
|
|
156
183
|
targetPath,
|
|
157
184
|
data,
|
|
158
|
-
{ onProgress, onWarning }
|
|
185
|
+
{ variant, onProgress, onWarning }
|
|
159
186
|
)
|
|
160
187
|
|
|
161
188
|
return metadata
|
|
@@ -185,21 +212,19 @@ export async function listAvailableTemplates() {
|
|
|
185
212
|
})
|
|
186
213
|
}
|
|
187
214
|
|
|
188
|
-
// Official templates from
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
})
|
|
199
|
-
}
|
|
200
|
-
} catch {
|
|
201
|
-
// Ignore errors listing official templates
|
|
215
|
+
// Official templates from GitHub releases
|
|
216
|
+
try {
|
|
217
|
+
const official = await listOfficialTemplates()
|
|
218
|
+
for (const t of official) {
|
|
219
|
+
templates.push({
|
|
220
|
+
type: 'official',
|
|
221
|
+
id: t.id,
|
|
222
|
+
name: t.name || t.id,
|
|
223
|
+
description: t.description || '',
|
|
224
|
+
})
|
|
202
225
|
}
|
|
226
|
+
} catch {
|
|
227
|
+
// Ignore errors - templates just won't be listed
|
|
203
228
|
}
|
|
204
229
|
|
|
205
230
|
return templates
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Template processor - handles file copying and Handlebars substitution
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import fs from 'node:fs/promises'
|
|
6
|
+
import { existsSync } from 'node:fs'
|
|
7
|
+
import path from 'node:path'
|
|
8
|
+
import Handlebars from 'handlebars'
|
|
9
|
+
|
|
10
|
+
// Cache for compiled templates
|
|
11
|
+
const templateCache = new Map()
|
|
12
|
+
|
|
13
|
+
// Store for version data (set by registerVersions)
|
|
14
|
+
let versionData = {}
|
|
15
|
+
|
|
16
|
+
// Track missing versions during processing
|
|
17
|
+
const missingVersions = new Set()
|
|
18
|
+
|
|
19
|
+
// Default fallback version when a package version is unknown
|
|
20
|
+
const DEFAULT_FALLBACK_VERSION = '^0.1.0'
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Register version data for the {{version}} helper
|
|
24
|
+
*
|
|
25
|
+
* @param {Object} versions - Map of package names to version specs
|
|
26
|
+
*/
|
|
27
|
+
export function registerVersions(versions) {
|
|
28
|
+
versionData = versions || {}
|
|
29
|
+
missingVersions.clear()
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Get the list of missing versions encountered during processing
|
|
34
|
+
*
|
|
35
|
+
* @returns {string[]} Array of package names that were missing versions
|
|
36
|
+
*/
|
|
37
|
+
export function getMissingVersions() {
|
|
38
|
+
return [...missingVersions]
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Clear the missing versions set
|
|
43
|
+
*/
|
|
44
|
+
export function clearMissingVersions() {
|
|
45
|
+
missingVersions.clear()
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Handlebars helper to get a package version
|
|
50
|
+
* Usage: {{version "@uniweb/build"}} or {{version "build"}}
|
|
51
|
+
*/
|
|
52
|
+
Handlebars.registerHelper('version', function(packageName) {
|
|
53
|
+
// Try exact match first
|
|
54
|
+
if (versionData[packageName]) {
|
|
55
|
+
return versionData[packageName]
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Try with @uniweb/ prefix
|
|
59
|
+
if (!packageName.startsWith('@') && versionData[`@uniweb/${packageName}`]) {
|
|
60
|
+
return versionData[`@uniweb/${packageName}`]
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Track the missing version and return a fallback
|
|
64
|
+
missingVersions.add(packageName)
|
|
65
|
+
return DEFAULT_FALLBACK_VERSION
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
// Text file extensions that should be processed for variables
|
|
69
|
+
const TEXT_EXTENSIONS = new Set([
|
|
70
|
+
'.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs',
|
|
71
|
+
'.json', '.yml', '.yaml', '.md', '.mdx',
|
|
72
|
+
'.html', '.htm', '.css', '.scss', '.less',
|
|
73
|
+
'.txt', '.xml', '.svg', '.vue', '.astro'
|
|
74
|
+
])
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Check if string contains unresolved Handlebars placeholders
|
|
78
|
+
*/
|
|
79
|
+
function findUnresolvedPlaceholders(content) {
|
|
80
|
+
const patterns = [
|
|
81
|
+
/\{\{([^#/}>][^}]*)\}\}/g, // {{variable}} - not blocks or partials
|
|
82
|
+
]
|
|
83
|
+
|
|
84
|
+
const unresolved = []
|
|
85
|
+
for (const pattern of patterns) {
|
|
86
|
+
const matches = content.matchAll(pattern)
|
|
87
|
+
for (const match of matches) {
|
|
88
|
+
const varName = match[1].trim()
|
|
89
|
+
// Skip helpers and expressions with spaces (likely intentional)
|
|
90
|
+
if (!varName.includes(' ')) {
|
|
91
|
+
unresolved.push(varName)
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return [...new Set(unresolved)]
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Load and compile a Handlebars template with caching
|
|
100
|
+
*/
|
|
101
|
+
async function loadTemplate(templatePath) {
|
|
102
|
+
if (templateCache.has(templatePath)) {
|
|
103
|
+
return templateCache.get(templatePath)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const template = await fs.readFile(templatePath, 'utf8')
|
|
107
|
+
const compiled = Handlebars.compile(template)
|
|
108
|
+
templateCache.set(templatePath, compiled)
|
|
109
|
+
return compiled
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Process a single file - either copy or apply Handlebars
|
|
114
|
+
*/
|
|
115
|
+
async function processFile(sourcePath, targetPath, data, options = {}) {
|
|
116
|
+
const isHbs = sourcePath.endsWith('.hbs')
|
|
117
|
+
const ext = path.extname(isHbs ? sourcePath.slice(0, -4) : sourcePath)
|
|
118
|
+
const isTextFile = TEXT_EXTENSIONS.has(ext)
|
|
119
|
+
|
|
120
|
+
if (isHbs) {
|
|
121
|
+
// Process Handlebars template
|
|
122
|
+
const template = await loadTemplate(sourcePath)
|
|
123
|
+
const content = template(data)
|
|
124
|
+
|
|
125
|
+
// Check for unresolved placeholders
|
|
126
|
+
const unresolved = findUnresolvedPlaceholders(content)
|
|
127
|
+
if (unresolved.length > 0 && options.onWarning) {
|
|
128
|
+
options.onWarning(`Unresolved placeholders in ${path.basename(targetPath)}: ${unresolved.join(', ')}`)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
await fs.writeFile(targetPath, content)
|
|
132
|
+
} else if (isTextFile && options.processAllText) {
|
|
133
|
+
// Optionally process non-hbs text files for simple replacements
|
|
134
|
+
let content = await fs.readFile(sourcePath, 'utf8')
|
|
135
|
+
// Simple {{var}} replacement without full Handlebars
|
|
136
|
+
for (const [key, value] of Object.entries(data)) {
|
|
137
|
+
if (typeof value === 'string') {
|
|
138
|
+
content = content.replaceAll(`{{${key}}}`, value)
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
await fs.writeFile(targetPath, content)
|
|
142
|
+
} else {
|
|
143
|
+
// Binary or non-template file - just copy
|
|
144
|
+
await fs.copyFile(sourcePath, targetPath)
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Copy a directory structure recursively, processing templates
|
|
150
|
+
*
|
|
151
|
+
* @param {string} sourcePath - Source template directory
|
|
152
|
+
* @param {string} targetPath - Destination directory
|
|
153
|
+
* @param {Object} data - Template variables
|
|
154
|
+
* @param {Object} options - Processing options
|
|
155
|
+
* @param {string|null} options.variant - Template variant to use
|
|
156
|
+
* @param {Function} options.onWarning - Warning callback
|
|
157
|
+
* @param {Function} options.onProgress - Progress callback
|
|
158
|
+
*/
|
|
159
|
+
export async function copyTemplateDirectory(sourcePath, targetPath, data, options = {}) {
|
|
160
|
+
const { variant = null, onWarning, onProgress } = options
|
|
161
|
+
|
|
162
|
+
await fs.mkdir(targetPath, { recursive: true })
|
|
163
|
+
const entries = await fs.readdir(sourcePath, { withFileTypes: true })
|
|
164
|
+
|
|
165
|
+
// Build a set of base names that have variant-specific directories
|
|
166
|
+
const variantBases = new Set()
|
|
167
|
+
for (const entry of entries) {
|
|
168
|
+
if (entry.isDirectory()) {
|
|
169
|
+
const variantMatch = entry.name.match(/^(.+)\.([^.]+)$/)
|
|
170
|
+
if (variantMatch) {
|
|
171
|
+
variantBases.add(variantMatch[1]) // e.g., 'foundation' from 'foundation.tailwind4'
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
for (const entry of entries) {
|
|
177
|
+
const sourceName = entry.name
|
|
178
|
+
|
|
179
|
+
// Check if this is a variant-specific item (e.g., "dir.variant")
|
|
180
|
+
const variantMatch = entry.isDirectory()
|
|
181
|
+
? sourceName.match(/^(.+)\.([^.]+)$/)
|
|
182
|
+
: null
|
|
183
|
+
|
|
184
|
+
if (entry.isDirectory()) {
|
|
185
|
+
if (variantMatch) {
|
|
186
|
+
const [, baseName, dirVariant] = variantMatch
|
|
187
|
+
|
|
188
|
+
// When no variant is specified, skip all variant directories
|
|
189
|
+
if (!variant) {
|
|
190
|
+
continue
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// When variant is specified, skip directories that don't match
|
|
194
|
+
if (dirVariant !== variant) {
|
|
195
|
+
continue
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Use the base name without variant suffix for the target
|
|
199
|
+
const sourceFullPath = path.join(sourcePath, sourceName)
|
|
200
|
+
const targetFullPath = path.join(targetPath, baseName)
|
|
201
|
+
|
|
202
|
+
await copyTemplateDirectory(sourceFullPath, targetFullPath, data, options)
|
|
203
|
+
} else {
|
|
204
|
+
// Regular directory - skip if a variant override exists and we're using that variant
|
|
205
|
+
if (variant && variantBases.has(sourceName)) {
|
|
206
|
+
// Skip this directory because a variant-specific version exists
|
|
207
|
+
continue
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const sourceFullPath = path.join(sourcePath, sourceName)
|
|
211
|
+
const targetFullPath = path.join(targetPath, sourceName)
|
|
212
|
+
|
|
213
|
+
await copyTemplateDirectory(sourceFullPath, targetFullPath, data, options)
|
|
214
|
+
}
|
|
215
|
+
} else {
|
|
216
|
+
// File processing
|
|
217
|
+
// Remove .hbs extension for target filename
|
|
218
|
+
const targetName = sourceName.endsWith('.hbs')
|
|
219
|
+
? sourceName.slice(0, -4)
|
|
220
|
+
: sourceName
|
|
221
|
+
|
|
222
|
+
const sourceFullPath = path.join(sourcePath, sourceName)
|
|
223
|
+
const targetFullPath = path.join(targetPath, targetName)
|
|
224
|
+
|
|
225
|
+
// Skip if target already exists (don't overwrite)
|
|
226
|
+
if (existsSync(targetFullPath)) {
|
|
227
|
+
if (onWarning) {
|
|
228
|
+
onWarning(`Skipping ${targetFullPath} - file already exists`)
|
|
229
|
+
}
|
|
230
|
+
continue
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (onProgress) {
|
|
234
|
+
onProgress(`Creating ${targetName}`)
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
await processFile(sourceFullPath, targetFullPath, data, { onWarning })
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Clear the template cache
|
|
244
|
+
*/
|
|
245
|
+
export function clearCache() {
|
|
246
|
+
templateCache.clear()
|
|
247
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Template validation - checks template.json and compatibility
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import fs from 'node:fs/promises'
|
|
6
|
+
import { existsSync } from 'node:fs'
|
|
7
|
+
import path from 'node:path'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Simple semver satisfaction check
|
|
11
|
+
* Supports: >=x.y.z, ^x.y.z, ~x.y.z, x.y.z, x.y.x, *, latest
|
|
12
|
+
*/
|
|
13
|
+
export function satisfiesVersion(version, range) {
|
|
14
|
+
if (!range || range === '*' || range === 'latest') {
|
|
15
|
+
return true
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const parseVersion = (v) => {
|
|
19
|
+
const match = v.match(/^(\d+)\.(\d+)\.(\d+)/)
|
|
20
|
+
if (!match) return null
|
|
21
|
+
return {
|
|
22
|
+
major: parseInt(match[1], 10),
|
|
23
|
+
minor: parseInt(match[2], 10),
|
|
24
|
+
patch: parseInt(match[3], 10)
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const current = parseVersion(version)
|
|
29
|
+
if (!current) return true // Can't parse, assume compatible
|
|
30
|
+
|
|
31
|
+
// Handle different range formats
|
|
32
|
+
if (range.startsWith('>=')) {
|
|
33
|
+
const min = parseVersion(range.slice(2))
|
|
34
|
+
if (!min) return true
|
|
35
|
+
if (current.major > min.major) return true
|
|
36
|
+
if (current.major < min.major) return false
|
|
37
|
+
if (current.minor > min.minor) return true
|
|
38
|
+
if (current.minor < min.minor) return false
|
|
39
|
+
return current.patch >= min.patch
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (range.startsWith('^')) {
|
|
43
|
+
// ^x.y.z means >=x.y.z and <(x+1).0.0
|
|
44
|
+
const min = parseVersion(range.slice(1))
|
|
45
|
+
if (!min) return true
|
|
46
|
+
if (current.major !== min.major) return current.major > min.major && min.major === 0
|
|
47
|
+
if (current.minor > min.minor) return true
|
|
48
|
+
if (current.minor < min.minor) return false
|
|
49
|
+
return current.patch >= min.patch
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (range.startsWith('~')) {
|
|
53
|
+
// ~x.y.z means >=x.y.z and <x.(y+1).0
|
|
54
|
+
const min = parseVersion(range.slice(1))
|
|
55
|
+
if (!min) return true
|
|
56
|
+
if (current.major !== min.major) return false
|
|
57
|
+
if (current.minor !== min.minor) return false
|
|
58
|
+
return current.patch >= min.patch
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Exact version or x.y.x pattern
|
|
62
|
+
if (range.includes('x')) {
|
|
63
|
+
const parts = range.split('.')
|
|
64
|
+
const min = parseVersion(range.replace(/x/g, '0'))
|
|
65
|
+
if (!min) return true
|
|
66
|
+
if (parts[0] !== 'x' && current.major !== min.major) return false
|
|
67
|
+
if (parts[1] !== 'x' && current.minor !== min.minor) return false
|
|
68
|
+
return true
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Exact match
|
|
72
|
+
const exact = parseVersion(range)
|
|
73
|
+
if (!exact) return true
|
|
74
|
+
return current.major === exact.major &&
|
|
75
|
+
current.minor === exact.minor &&
|
|
76
|
+
current.patch === exact.patch
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Validation error with structured details
|
|
81
|
+
*/
|
|
82
|
+
export class ValidationError extends Error {
|
|
83
|
+
constructor(message, code, details = {}) {
|
|
84
|
+
super(message)
|
|
85
|
+
this.name = 'ValidationError'
|
|
86
|
+
this.code = code
|
|
87
|
+
this.details = details
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export const ErrorCodes = {
|
|
92
|
+
MISSING_TEMPLATE_JSON: 'MISSING_TEMPLATE_JSON',
|
|
93
|
+
INVALID_TEMPLATE_JSON: 'INVALID_TEMPLATE_JSON',
|
|
94
|
+
MISSING_TEMPLATE_DIR: 'MISSING_TEMPLATE_DIR',
|
|
95
|
+
MISSING_REQUIRED_FIELD: 'MISSING_REQUIRED_FIELD',
|
|
96
|
+
VERSION_MISMATCH: 'VERSION_MISMATCH',
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Validate a template directory structure and metadata
|
|
101
|
+
*
|
|
102
|
+
* @param {string} templateRoot - Path to the template root (contains template.json)
|
|
103
|
+
* @param {Object} options - Validation options
|
|
104
|
+
* @param {string} options.uniwebVersion - Current Uniweb version to check compatibility
|
|
105
|
+
* @returns {Object} Parsed and validated template metadata
|
|
106
|
+
*/
|
|
107
|
+
export async function validateTemplate(templateRoot, options = {}) {
|
|
108
|
+
const { uniwebVersion } = options
|
|
109
|
+
|
|
110
|
+
// Check for template.json
|
|
111
|
+
const metadataPath = path.join(templateRoot, 'template.json')
|
|
112
|
+
if (!existsSync(metadataPath)) {
|
|
113
|
+
throw new ValidationError(
|
|
114
|
+
`Missing template.json in ${templateRoot}`,
|
|
115
|
+
ErrorCodes.MISSING_TEMPLATE_JSON,
|
|
116
|
+
{ path: templateRoot }
|
|
117
|
+
)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Parse template.json
|
|
121
|
+
let metadata
|
|
122
|
+
try {
|
|
123
|
+
const content = await fs.readFile(metadataPath, 'utf8')
|
|
124
|
+
metadata = JSON.parse(content)
|
|
125
|
+
} catch (err) {
|
|
126
|
+
throw new ValidationError(
|
|
127
|
+
`Invalid template.json: ${err.message}`,
|
|
128
|
+
ErrorCodes.INVALID_TEMPLATE_JSON,
|
|
129
|
+
{ path: metadataPath, error: err.message }
|
|
130
|
+
)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Check required fields
|
|
134
|
+
if (!metadata.name) {
|
|
135
|
+
throw new ValidationError(
|
|
136
|
+
'template.json missing required field: name',
|
|
137
|
+
ErrorCodes.MISSING_REQUIRED_FIELD,
|
|
138
|
+
{ field: 'name' }
|
|
139
|
+
)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Check for template/ directory
|
|
143
|
+
const templateDir = path.join(templateRoot, 'template')
|
|
144
|
+
if (!existsSync(templateDir)) {
|
|
145
|
+
throw new ValidationError(
|
|
146
|
+
`Missing template/ directory in ${templateRoot}`,
|
|
147
|
+
ErrorCodes.MISSING_TEMPLATE_DIR,
|
|
148
|
+
{ path: templateRoot }
|
|
149
|
+
)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Check version compatibility
|
|
153
|
+
if (uniwebVersion && metadata.uniweb) {
|
|
154
|
+
if (!satisfiesVersion(uniwebVersion, metadata.uniweb)) {
|
|
155
|
+
throw new ValidationError(
|
|
156
|
+
`Template requires Uniweb ${metadata.uniweb}, but current version is ${uniwebVersion}`,
|
|
157
|
+
ErrorCodes.VERSION_MISMATCH,
|
|
158
|
+
{ required: metadata.uniweb, current: uniwebVersion }
|
|
159
|
+
)
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return {
|
|
164
|
+
...metadata,
|
|
165
|
+
templateDir,
|
|
166
|
+
metadataPath
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Get list of available templates in a templates directory
|
|
172
|
+
*
|
|
173
|
+
* @param {string} templatesDir - Path to directory containing templates
|
|
174
|
+
* @returns {Array<Object>} List of template metadata
|
|
175
|
+
*/
|
|
176
|
+
export async function listTemplates(templatesDir) {
|
|
177
|
+
if (!existsSync(templatesDir)) {
|
|
178
|
+
return []
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const entries = await fs.readdir(templatesDir, { withFileTypes: true })
|
|
182
|
+
const templates = []
|
|
183
|
+
|
|
184
|
+
for (const entry of entries) {
|
|
185
|
+
if (!entry.isDirectory()) continue
|
|
186
|
+
|
|
187
|
+
const templatePath = path.join(templatesDir, entry.name)
|
|
188
|
+
const metadataPath = path.join(templatePath, 'template.json')
|
|
189
|
+
|
|
190
|
+
if (existsSync(metadataPath)) {
|
|
191
|
+
try {
|
|
192
|
+
const content = await fs.readFile(metadataPath, 'utf8')
|
|
193
|
+
const metadata = JSON.parse(content)
|
|
194
|
+
templates.push({
|
|
195
|
+
id: entry.name,
|
|
196
|
+
...metadata,
|
|
197
|
+
path: templatePath
|
|
198
|
+
})
|
|
199
|
+
} catch {
|
|
200
|
+
// Skip invalid templates
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return templates
|
|
206
|
+
}
|
package/src/versions.js
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Version resolution utility
|
|
3
|
+
*
|
|
4
|
+
* Reads package versions from the CLI's own dependencies to ensure
|
|
5
|
+
* generated projects use compatible versions.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { readFileSync } from 'node:fs'
|
|
9
|
+
import { dirname, join } from 'node:path'
|
|
10
|
+
import { fileURLToPath } from 'node:url'
|
|
11
|
+
|
|
12
|
+
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
13
|
+
|
|
14
|
+
// Cache for resolved versions
|
|
15
|
+
let resolvedVersions = null
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Get the CLI's own package.json
|
|
19
|
+
*/
|
|
20
|
+
function getCliPackageJson() {
|
|
21
|
+
const packagePath = join(__dirname, '..', 'package.json')
|
|
22
|
+
return JSON.parse(readFileSync(packagePath, 'utf8'))
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Extract version number from version spec (e.g., "^0.1.4" -> "0.1.4")
|
|
27
|
+
*/
|
|
28
|
+
function extractVersion(spec) {
|
|
29
|
+
if (!spec) return null
|
|
30
|
+
// Remove ^, ~, >=, etc. prefixes
|
|
31
|
+
return spec.replace(/^[\^~>=<]+/, '')
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Resolve a version spec to an npm-compatible version
|
|
36
|
+
* Handles workspace:* and other pnpm-specific protocols
|
|
37
|
+
*
|
|
38
|
+
* @param {string} spec - Version spec (e.g., "workspace:*", "^0.1.0")
|
|
39
|
+
* @param {string} fallback - Fallback version if spec is not resolvable
|
|
40
|
+
* @returns {string} npm-compatible version spec
|
|
41
|
+
*/
|
|
42
|
+
function resolveVersionSpec(spec, fallback) {
|
|
43
|
+
if (!spec) return fallback
|
|
44
|
+
// workspace:* is pnpm-specific, use fallback for npm compatibility
|
|
45
|
+
if (spec.startsWith('workspace:')) return fallback
|
|
46
|
+
return spec
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Get resolved versions for @uniweb/* packages
|
|
51
|
+
*
|
|
52
|
+
* Returns versions that should be used in generated projects,
|
|
53
|
+
* based on the CLI's own dependencies.
|
|
54
|
+
*
|
|
55
|
+
* Note: In development (pnpm workspace), versions may be "workspace:*"
|
|
56
|
+
* which we convert to npm-compatible fallback versions.
|
|
57
|
+
*
|
|
58
|
+
* @returns {Object} Map of package names to version specs
|
|
59
|
+
*/
|
|
60
|
+
export function getResolvedVersions() {
|
|
61
|
+
if (resolvedVersions) return resolvedVersions
|
|
62
|
+
|
|
63
|
+
const pkg = getCliPackageJson()
|
|
64
|
+
const deps = { ...pkg.dependencies, ...pkg.devDependencies }
|
|
65
|
+
|
|
66
|
+
resolvedVersions = {
|
|
67
|
+
// Direct CLI dependencies - use fallbacks for workspace:* versions
|
|
68
|
+
'@uniweb/build': resolveVersionSpec(deps['@uniweb/build'], '^0.1.4'),
|
|
69
|
+
'@uniweb/templates': resolveVersionSpec(deps['@uniweb/templates'], '^0.1.6'),
|
|
70
|
+
|
|
71
|
+
// These come from @uniweb/build's dependencies, use compatible versions
|
|
72
|
+
'@uniweb/runtime': '^0.1.0',
|
|
73
|
+
'@uniweb/core': '^0.1.0',
|
|
74
|
+
|
|
75
|
+
// CLI itself (use current version)
|
|
76
|
+
'uniweb': `^${pkg.version}`,
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return resolvedVersions
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Get a specific package version
|
|
84
|
+
*
|
|
85
|
+
* @param {string} packageName - Package name (e.g., "@uniweb/build")
|
|
86
|
+
* @returns {string} Version spec (e.g., "^0.1.4")
|
|
87
|
+
*/
|
|
88
|
+
export function getVersion(packageName) {
|
|
89
|
+
const versions = getResolvedVersions()
|
|
90
|
+
return versions[packageName] || null
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Get all versions as a flat object for template data
|
|
95
|
+
*
|
|
96
|
+
* @returns {Object} Versions keyed by simplified names
|
|
97
|
+
*/
|
|
98
|
+
export function getVersionsForTemplates() {
|
|
99
|
+
const versions = getResolvedVersions()
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
// Full package names
|
|
103
|
+
...versions,
|
|
104
|
+
|
|
105
|
+
// Simplified names for templates (e.g., {{versions.build}})
|
|
106
|
+
build: versions['@uniweb/build'],
|
|
107
|
+
runtime: versions['@uniweb/runtime'],
|
|
108
|
+
core: versions['@uniweb/core'],
|
|
109
|
+
templates: versions['@uniweb/templates'],
|
|
110
|
+
cli: versions['uniweb'],
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Update @uniweb/* versions in a package.json object
|
|
116
|
+
*
|
|
117
|
+
* @param {Object} pkg - Package.json object
|
|
118
|
+
* @returns {Object} Updated package.json object
|
|
119
|
+
*/
|
|
120
|
+
export function updatePackageVersions(pkg) {
|
|
121
|
+
const versions = getResolvedVersions()
|
|
122
|
+
|
|
123
|
+
const updateDeps = (deps) => {
|
|
124
|
+
if (!deps) return deps
|
|
125
|
+
const updated = { ...deps }
|
|
126
|
+
for (const [name, version] of Object.entries(updated)) {
|
|
127
|
+
if (name.startsWith('@uniweb/') || name === 'uniweb') {
|
|
128
|
+
if (versions[name]) {
|
|
129
|
+
updated[name] = versions[name]
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return updated
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return {
|
|
137
|
+
...pkg,
|
|
138
|
+
dependencies: updateDeps(pkg.dependencies),
|
|
139
|
+
devDependencies: updateDeps(pkg.devDependencies),
|
|
140
|
+
peerDependencies: updateDeps(pkg.peerDependencies),
|
|
141
|
+
}
|
|
142
|
+
}
|