uniweb 0.2.11 → 0.2.13

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.11",
3
+ "version": "0.2.13",
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/templates": "0.1.6",
37
- "@uniweb/build": "0.1.0"
37
+ "@uniweb/build": "0.1.5"
38
38
  }
39
39
  }
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Docs Command
3
+ *
4
+ * Generates markdown documentation from foundation schema.
5
+ *
6
+ * Usage:
7
+ * uniweb docs # Generate docs for current directory
8
+ * uniweb docs --output README.md # Custom output filename
9
+ * uniweb docs --from-source # Build schema from source (no build required)
10
+ */
11
+
12
+ import { existsSync } from 'node:fs'
13
+ import { resolve, join } from 'node:path'
14
+ import { generateDocs } from '@uniweb/build'
15
+
16
+ // Colors for terminal output
17
+ const colors = {
18
+ reset: '\x1b[0m',
19
+ bright: '\x1b[1m',
20
+ dim: '\x1b[2m',
21
+ cyan: '\x1b[36m',
22
+ green: '\x1b[32m',
23
+ yellow: '\x1b[33m',
24
+ red: '\x1b[31m',
25
+ }
26
+
27
+ function log(message) {
28
+ console.log(message)
29
+ }
30
+
31
+ function success(message) {
32
+ console.log(`${colors.green}✓${colors.reset} ${message}`)
33
+ }
34
+
35
+ function error(message) {
36
+ console.error(`${colors.red}✗${colors.reset} ${message}`)
37
+ }
38
+
39
+ function info(message) {
40
+ console.log(`${colors.cyan}→${colors.reset} ${message}`)
41
+ }
42
+
43
+ /**
44
+ * Parse command line arguments
45
+ */
46
+ function parseArgs(args) {
47
+ const options = {
48
+ output: 'COMPONENTS.md',
49
+ fromSource: false,
50
+ }
51
+
52
+ for (let i = 0; i < args.length; i++) {
53
+ const arg = args[i]
54
+
55
+ if (arg === '--output' || arg === '-o') {
56
+ options.output = args[++i]
57
+ } else if (arg === '--from-source' || arg === '-s') {
58
+ options.fromSource = true
59
+ } else if (arg === '--help' || arg === '-h') {
60
+ options.help = true
61
+ }
62
+ }
63
+
64
+ return options
65
+ }
66
+
67
+ /**
68
+ * Show help message
69
+ */
70
+ function showHelp() {
71
+ log(`
72
+ ${colors.bright}uniweb docs${colors.reset} - Generate component documentation
73
+
74
+ ${colors.dim}Usage:${colors.reset}
75
+ uniweb docs Generate COMPONENTS.md from schema.json
76
+ uniweb docs --output DOCS.md Custom output filename
77
+ uniweb docs --from-source Build schema from source (no build required)
78
+
79
+ ${colors.dim}Options:${colors.reset}
80
+ -o, --output <file> Output filename (default: COMPONENTS.md)
81
+ -s, --from-source Read meta.js files directly instead of schema.json
82
+ -h, --help Show this help message
83
+
84
+ ${colors.dim}Notes:${colors.reset}
85
+ By default, docs are generated from dist/schema.json (requires build).
86
+ Use --from-source to generate without building first.
87
+ `)
88
+ }
89
+
90
+ /**
91
+ * Detect if current directory is a foundation
92
+ */
93
+ function isFoundation(dir) {
94
+ const srcDir = join(dir, 'src')
95
+ const componentsDir = join(srcDir, 'components')
96
+ return existsSync(componentsDir)
97
+ }
98
+
99
+ /**
100
+ * Main docs command
101
+ */
102
+ export async function docs(args) {
103
+ const options = parseArgs(args)
104
+
105
+ if (options.help) {
106
+ showHelp()
107
+ return
108
+ }
109
+
110
+ const projectDir = resolve(process.cwd())
111
+
112
+ // Verify this is a foundation
113
+ if (!isFoundation(projectDir)) {
114
+ error('This directory does not appear to be a foundation.')
115
+ log(`${colors.dim}Foundations have a src/components/ directory with component folders.${colors.reset}`)
116
+ process.exit(1)
117
+ }
118
+
119
+ // Check if schema.json exists (if not using --from-source)
120
+ const schemaPath = join(projectDir, 'dist', 'schema.json')
121
+ if (!options.fromSource && !existsSync(schemaPath)) {
122
+ log(`${colors.yellow}⚠${colors.reset} No dist/schema.json found.`)
123
+ log(`${colors.dim}Run 'uniweb build' first, or use '--from-source' to read meta.js files directly.${colors.reset}`)
124
+ log('')
125
+ info('Falling back to --from-source mode')
126
+ options.fromSource = true
127
+ }
128
+
129
+ try {
130
+ info('Generating documentation...')
131
+
132
+ const result = await generateDocs(projectDir, {
133
+ output: options.output,
134
+ fromSource: options.fromSource,
135
+ })
136
+
137
+ success(`Generated ${result.outputPath}`)
138
+ log(`${colors.dim}Documented ${result.componentCount} components${colors.reset}`)
139
+ } catch (err) {
140
+ error(`Failed to generate docs: ${err.message}`)
141
+ process.exit(1)
142
+ }
143
+ }
package/src/index.js CHANGED
@@ -3,14 +3,13 @@
3
3
  /**
4
4
  * Uniweb CLI
5
5
  *
6
- * Scaffolds new Uniweb sites and foundations, and builds projects.
6
+ * Scaffolds new Uniweb sites and foundations, builds projects, and generates docs.
7
7
  *
8
8
  * Usage:
9
9
  * npx uniweb create [project-name]
10
- * npx uniweb create --template single # site/ + foundation/ (default)
11
- * npx uniweb create --template multi # sites/* + foundations/*
10
+ * npx uniweb create --template marketing
12
11
  * npx uniweb build
13
- * npx uniweb build --target foundation
12
+ * npx uniweb docs # Generate COMPONENTS.md from schema
14
13
  */
15
14
 
16
15
  import { existsSync, mkdirSync, writeFileSync, readFileSync } from 'node:fs'
@@ -18,6 +17,8 @@ import { resolve, join, dirname } from 'node:path'
18
17
  import { fileURLToPath } from 'node:url'
19
18
  import prompts from 'prompts'
20
19
  import { build } from './commands/build.js'
20
+ import { docs } from './commands/docs.js'
21
+ import { getVersionsForTemplates, getVersion } from './versions.js'
21
22
  import {
22
23
  resolveTemplate,
23
24
  applyExternalTemplate,
@@ -83,6 +84,12 @@ async function main() {
83
84
  return
84
85
  }
85
86
 
87
+ // Handle docs command
88
+ if (command === 'docs') {
89
+ await docs(args.slice(1))
90
+ return
91
+ }
92
+
86
93
  // Handle create command
87
94
  if (command !== 'create') {
88
95
  error(`Unknown command: ${command}`)
@@ -109,6 +116,20 @@ async function main() {
109
116
  }
110
117
  }
111
118
 
119
+ // Check for --variant flag
120
+ let variant = null
121
+ const variantIndex = args.indexOf('--variant')
122
+ if (variantIndex !== -1 && args[variantIndex + 1]) {
123
+ variant = args[variantIndex + 1]
124
+ }
125
+
126
+ // Check for --name flag (used for project display name)
127
+ let displayName = null
128
+ const nameIndex = args.indexOf('--name')
129
+ if (nameIndex !== -1 && args[nameIndex + 1]) {
130
+ displayName = args[nameIndex + 1]
131
+ }
132
+
112
133
  // Interactive prompts
113
134
  const response = await prompts([
114
135
  {
@@ -190,7 +211,11 @@ async function main() {
190
211
 
191
212
  log(`\nCreating project from ${resolved.name || resolved.package || `${resolved.owner}/${resolved.repo}`}...`)
192
213
 
193
- await applyExternalTemplate(resolved, projectDir, { projectName }, {
214
+ await applyExternalTemplate(resolved, projectDir, {
215
+ projectName: displayName || projectName,
216
+ versions: getVersionsForTemplates(),
217
+ }, {
218
+ variant,
194
219
  onProgress: (msg) => log(` ${colors.dim}${msg}${colors.reset}`),
195
220
  onWarning: (msg) => log(` ${colors.yellow}Warning: ${msg}${colors.reset}`),
196
221
  })
@@ -220,9 +245,12 @@ ${colors.bright}Usage:${colors.reset}
220
245
  ${colors.bright}Commands:${colors.reset}
221
246
  create [name] Create a new project
222
247
  build Build the current project
248
+ docs Generate component documentation
223
249
 
224
250
  ${colors.bright}Create Options:${colors.reset}
225
251
  --template <type> Project template
252
+ --variant <name> Template variant (e.g., tailwind3 for legacy)
253
+ --name <name> Project display name
226
254
 
227
255
  ${colors.bright}Build Options:${colors.reset}
228
256
  --target <type> Build target (foundation, site) - auto-detected if not specified
@@ -230,6 +258,10 @@ ${colors.bright}Build Options:${colors.reset}
230
258
  --foundation-dir Path to foundation directory (for prerendering)
231
259
  --platform <name> Deployment platform (e.g., vercel) for platform-specific output
232
260
 
261
+ ${colors.bright}Docs Options:${colors.reset}
262
+ --output <file> Output filename (default: COMPONENTS.md)
263
+ --from-source Read meta.js files directly instead of schema.json
264
+
233
265
  ${colors.bright}Template Types:${colors.reset}
234
266
  single One site + one foundation (default)
235
267
  multi Multiple sites and foundations
@@ -242,10 +274,12 @@ ${colors.bright}Examples:${colors.reset}
242
274
  npx uniweb create my-project
243
275
  npx uniweb create my-project --template single
244
276
  npx uniweb create my-project --template marketing
277
+ npx uniweb create my-project --template marketing --variant tailwind3
245
278
  npx uniweb create my-project --template github:myorg/template
246
279
  npx uniweb build
247
280
  npx uniweb build --target foundation
248
281
  npx uniweb build --prerender # Build site + pre-render to static HTML
282
+ cd foundation && npx uniweb docs # Generate COMPONENTS.md
249
283
  `)
250
284
  }
251
285
 
@@ -600,11 +634,11 @@ async function createSite(projectDir, projectName, isWorkspace = false) {
600
634
  preview: 'vite preview',
601
635
  },
602
636
  dependencies: {
603
- '@uniweb/runtime': '^0.1.0',
637
+ '@uniweb/runtime': getVersion('@uniweb/runtime'),
604
638
  ...(isWorkspace ? {} : { 'foundation-example': '^0.1.0' }),
605
639
  },
606
640
  devDependencies: {
607
- '@uniweb/build': '^0.1.3',
641
+ '@uniweb/build': getVersion('@uniweb/build'),
608
642
  '@vitejs/plugin-react': '^5.0.0',
609
643
  autoprefixer: '^10.4.18',
610
644
  'js-yaml': '^4.1.0',
@@ -904,7 +938,7 @@ async function createFoundation(projectDir, projectName, isWorkspace = false) {
904
938
  react: '^18.2.0',
905
939
  'react-dom': '^18.2.0',
906
940
  tailwindcss: '^3.4.1',
907
- uniweb: '^0.2.0',
941
+ uniweb: getVersion('uniweb'),
908
942
  vite: '^7.0.0',
909
943
  'vite-plugin-svgr': '^4.2.0',
910
944
  },
@@ -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,146 @@
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
+ // Foundation utility library (used by official templates)
76
+ '@uniweb/kit': '^0.1.0',
77
+
78
+ // CLI itself (use current version)
79
+ 'uniweb': `^${pkg.version}`,
80
+ }
81
+
82
+ return resolvedVersions
83
+ }
84
+
85
+ /**
86
+ * Get a specific package version
87
+ *
88
+ * @param {string} packageName - Package name (e.g., "@uniweb/build")
89
+ * @returns {string} Version spec (e.g., "^0.1.4")
90
+ */
91
+ export function getVersion(packageName) {
92
+ const versions = getResolvedVersions()
93
+ return versions[packageName] || null
94
+ }
95
+
96
+ /**
97
+ * Get all versions as a flat object for template data
98
+ *
99
+ * @returns {Object} Versions keyed by simplified names
100
+ */
101
+ export function getVersionsForTemplates() {
102
+ const versions = getResolvedVersions()
103
+
104
+ return {
105
+ // Full package names
106
+ ...versions,
107
+
108
+ // Simplified names for templates (e.g., {{versions.build}})
109
+ build: versions['@uniweb/build'],
110
+ runtime: versions['@uniweb/runtime'],
111
+ core: versions['@uniweb/core'],
112
+ kit: versions['@uniweb/kit'],
113
+ templates: versions['@uniweb/templates'],
114
+ cli: versions['uniweb'],
115
+ }
116
+ }
117
+
118
+ /**
119
+ * Update @uniweb/* versions in a package.json object
120
+ *
121
+ * @param {Object} pkg - Package.json object
122
+ * @returns {Object} Updated package.json object
123
+ */
124
+ export function updatePackageVersions(pkg) {
125
+ const versions = getResolvedVersions()
126
+
127
+ const updateDeps = (deps) => {
128
+ if (!deps) return deps
129
+ const updated = { ...deps }
130
+ for (const [name, version] of Object.entries(updated)) {
131
+ if (name.startsWith('@uniweb/') || name === 'uniweb') {
132
+ if (versions[name]) {
133
+ updated[name] = versions[name]
134
+ }
135
+ }
136
+ }
137
+ return updated
138
+ }
139
+
140
+ return {
141
+ ...pkg,
142
+ dependencies: updateDeps(pkg.dependencies),
143
+ devDependencies: updateDeps(pkg.devDependencies),
144
+ peerDependencies: updateDeps(pkg.peerDependencies),
145
+ }
146
+ }