uniweb 0.2.10 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "uniweb",
3
- "version": "0.2.10",
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
- "@uniweb/build": "^0.1.0",
34
+ "handlebars": "^4.7.8",
35
35
  "prompts": "^2.4.2",
36
36
  "tar": "^7.0.0",
37
- "@uniweb/templates": "0.1.6"
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, { projectName }, {
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': '^0.1.0',
625
+ '@uniweb/runtime': getVersion('@uniweb/runtime'),
604
626
  ...(isWorkspace ? {} : { 'foundation-example': '^0.1.0' }),
605
627
  },
606
628
  devDependencies: {
607
- '@uniweb/build': '^0.1.3',
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: '^0.2.0',
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
+ }
@@ -3,21 +3,19 @@
3
3
  */
4
4
 
5
5
  import { rm } from 'node:fs/promises'
6
- import { existsSync } from 'node:fs'
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
- // Try to import from @uniweb/templates if available
15
- let templatesPackage = null
16
- try {
17
- templatesPackage = await import('@uniweb/templates')
18
- } catch {
19
- // @uniweb/templates not installed - official templates won't be available locally
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 @uniweb/templates
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
- if (!templatesPackage) {
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
- if (!templatesPackage.hasTemplate(name)) {
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
- path: templatePath,
87
- cleanup: async () => {}, // Nothing to clean up
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 templatesPackage.applyTemplate(
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 @uniweb/templates
189
- if (templatesPackage) {
190
- try {
191
- const official = await templatesPackage.listBuiltinTemplates()
192
- for (const t of official) {
193
- templates.push({
194
- type: 'official',
195
- id: t.id,
196
- name: t.name,
197
- description: t.description,
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
+ }
@@ -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
+ }