uniweb 0.2.1 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -40,7 +40,7 @@ my-project/
40
40
  └── index.jsx
41
41
  ```
42
42
 
43
- **Pages are folders.** Create `pages/about/` with a markdown file inside → visit `/about`. That's the whole routing model.
43
+ **Pages are folders.** Create `pages/about/` with markdown files inside → visit `/about`. That's the whole routing model.
44
44
 
45
45
  ### Content as Markdown
46
46
 
@@ -240,7 +240,7 @@ A monorepo for multi-site or multi-foundation development.
240
240
  ```
241
241
  my-workspace/
242
242
  ├── package.json # Workspace root (includes workspaces field for npm)
243
- ├── pnpm-workspace.yaml # Same config as site template
243
+ ├── pnpm-workspace.yaml # Same config as single template
244
244
 
245
245
  ├── sites/
246
246
  │ ├── marketing/ # Main marketing site
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "uniweb",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "description": "Create structured Vite + React sites with content/code separation",
5
5
  "type": "module",
6
6
  "bin": {
@@ -32,6 +32,8 @@
32
32
  },
33
33
  "dependencies": {
34
34
  "@uniweb/build": "^0.1.0",
35
- "prompts": "^2.4.2"
35
+ "@uniweb/templates": "workspace:*",
36
+ "prompts": "^2.4.2",
37
+ "tar": "^7.0.0"
36
38
  }
37
39
  }
package/src/index.js CHANGED
@@ -18,6 +18,13 @@ 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 {
22
+ resolveTemplate,
23
+ applyExternalTemplate,
24
+ parseTemplateId,
25
+ listAvailableTemplates,
26
+ BUILTIN_TEMPLATES,
27
+ } from './templates/index.js'
21
28
 
22
29
  const __dirname = dirname(fileURLToPath(import.meta.url))
23
30
 
@@ -93,9 +100,11 @@ async function main() {
93
100
  const templateIndex = args.indexOf('--template')
94
101
  if (templateIndex !== -1 && args[templateIndex + 1]) {
95
102
  templateType = args[templateIndex + 1]
96
- if (!templates[templateType]) {
97
- error(`Unknown template: ${templateType}`)
98
- log(`Available templates: ${Object.keys(templates).join(', ')}`)
103
+ // Validate template identifier (will throw if invalid)
104
+ try {
105
+ parseTemplateId(templateType)
106
+ } catch (err) {
107
+ error(`Invalid template: ${err.message}`)
99
108
  process.exit(1)
100
109
  }
101
110
  }
@@ -155,16 +164,40 @@ async function main() {
155
164
  process.exit(1)
156
165
  }
157
166
 
158
- log(`\nCreating ${templates[templateType].name.toLowerCase()}...`)
167
+ // Resolve and create project based on template
168
+ const parsed = parseTemplateId(templateType)
159
169
 
160
- // Generate project based on template
161
- switch (templateType) {
162
- case 'single':
163
- await createSingleProject(projectDir, projectName)
164
- break
165
- case 'multi':
166
- await createMultiProject(projectDir, projectName)
167
- break
170
+ if (parsed.type === 'builtin') {
171
+ log(`\nCreating ${templates[templateType].name.toLowerCase()}...`)
172
+
173
+ // Generate project based on built-in template
174
+ switch (templateType) {
175
+ case 'single':
176
+ await createSingleProject(projectDir, projectName)
177
+ break
178
+ case 'multi':
179
+ await createMultiProject(projectDir, projectName)
180
+ break
181
+ }
182
+ } else {
183
+ // External template (official, npm, or github)
184
+ log(`\nResolving template: ${templateType}...`)
185
+
186
+ try {
187
+ const resolved = await resolveTemplate(templateType, {
188
+ onProgress: (msg) => log(` ${colors.dim}${msg}${colors.reset}`),
189
+ })
190
+
191
+ log(`\nCreating project from ${resolved.name || resolved.package || `${resolved.owner}/${resolved.repo}`}...`)
192
+
193
+ await applyExternalTemplate(resolved, projectDir, { projectName }, {
194
+ onProgress: (msg) => log(` ${colors.dim}${msg}${colors.reset}`),
195
+ onWarning: (msg) => log(` ${colors.yellow}Warning: ${msg}${colors.reset}`),
196
+ })
197
+ } catch (err) {
198
+ error(`Failed to apply template: ${err.message}`)
199
+ process.exit(1)
200
+ }
168
201
  }
169
202
 
170
203
  // Success message
@@ -189,22 +222,27 @@ ${colors.bright}Commands:${colors.reset}
189
222
  build Build the current project
190
223
 
191
224
  ${colors.bright}Create Options:${colors.reset}
192
- --template <type> Project template (single, multi)
225
+ --template <type> Project template
193
226
 
194
227
  ${colors.bright}Build Options:${colors.reset}
195
228
  --target <type> Build target (foundation, site) - auto-detected if not specified
196
229
  --platform <name> Deployment platform (e.g., vercel) for platform-specific output
197
230
 
231
+ ${colors.bright}Template Types:${colors.reset}
232
+ single One site + one foundation (default)
233
+ multi Multiple sites and foundations
234
+ marketing Official marketing template
235
+ @scope/template-name npm package
236
+ github:user/repo GitHub repository
237
+ https://github.com/user/repo GitHub URL
238
+
198
239
  ${colors.bright}Examples:${colors.reset}
199
240
  npx uniweb create my-project
200
241
  npx uniweb create my-project --template single
201
- npx uniweb create my-workspace --template multi
242
+ npx uniweb create my-project --template marketing
243
+ npx uniweb create my-project --template github:myorg/template
202
244
  npx uniweb build
203
245
  npx uniweb build --target foundation
204
-
205
- ${colors.bright}Templates:${colors.reset}
206
- single One site + one foundation in site/ and foundation/ (default)
207
- multi Multiple sites and foundations in sites/* and foundations/*
208
246
  `)
209
247
  }
210
248
 
@@ -347,6 +385,7 @@ Each markdown file specifies which component to use:
347
385
  \`\`\`markdown
348
386
  ---
349
387
  component: Hero
388
+ theme: dark
350
389
  ---
351
390
 
352
391
  # Your Title
@@ -719,6 +758,7 @@ order: 1
719
758
  // pages/home/1-hero.md
720
759
  writeFile(join(projectDir, 'pages/home/1-hero.md'), `---
721
760
  component: Hero
761
+ theme: dark
722
762
  ---
723
763
 
724
764
  # Welcome to ${projectName}
@@ -841,7 +881,7 @@ async function createFoundation(projectDir, projectName, isWorkspace = false) {
841
881
  files: ['dist', 'src'],
842
882
  scripts: {
843
883
  dev: 'vite',
844
- build: 'npx uniweb build',
884
+ build: 'uniweb build',
845
885
  'build:vite': 'vite build',
846
886
  preview: 'vite preview',
847
887
  },
@@ -856,6 +896,7 @@ async function createFoundation(projectDir, projectName, isWorkspace = false) {
856
896
  react: '^18.2.0',
857
897
  'react-dom': '^18.2.0',
858
898
  tailwindcss: '^3.4.1',
899
+ uniweb: '^0.2.0',
859
900
  vite: '^5.1.0',
860
901
  'vite-plugin-svgr': '^4.2.0',
861
902
  },
@@ -0,0 +1,98 @@
1
+ /**
2
+ * GitHub fetcher - downloads templates from GitHub repositories
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
+ /**
13
+ * Fetch a template from a GitHub repository
14
+ *
15
+ * @param {string} owner - Repository owner
16
+ * @param {string} repo - Repository name
17
+ * @param {Object} options - Fetch options
18
+ * @param {string} options.ref - Branch, tag, or commit (default: HEAD)
19
+ * @param {Function} options.onProgress - Progress callback
20
+ * @returns {Promise<Object>} { tempDir, ref }
21
+ */
22
+ export async function fetchGitHubTemplate(owner, repo, options = {}) {
23
+ const { ref = 'HEAD', onProgress } = options
24
+
25
+ const displayRef = ref === 'HEAD' ? 'latest' : ref
26
+ onProgress?.(`Fetching ${owner}/${repo}@${displayRef} from GitHub...`)
27
+
28
+ // GitHub provides tarballs without requiring git
29
+ const tarballUrl = `https://api.github.com/repos/${owner}/${repo}/tarball/${ref}`
30
+
31
+ const tempDir = await mkdtemp(join(tmpdir(), 'uniweb-template-'))
32
+
33
+ try {
34
+ const response = await fetchWithRetry(tarballUrl, {
35
+ headers: {
36
+ 'Accept': 'application/vnd.github+json',
37
+ 'User-Agent': 'uniweb-cli',
38
+ // Support private repos if GITHUB_TOKEN is set
39
+ ...(process.env.GITHUB_TOKEN && {
40
+ 'Authorization': `Bearer ${process.env.GITHUB_TOKEN}`
41
+ }),
42
+ },
43
+ })
44
+
45
+ if (!response.ok) {
46
+ if (response.status === 404) {
47
+ throw new Error(`Repository not found: ${owner}/${repo}`)
48
+ }
49
+ if (response.status === 403) {
50
+ const remaining = response.headers.get('x-ratelimit-remaining')
51
+ if (remaining === '0') {
52
+ throw new Error(
53
+ 'GitHub API rate limit exceeded. Set GITHUB_TOKEN environment variable for higher limits.'
54
+ )
55
+ }
56
+ }
57
+ throw new Error(`GitHub API error: ${response.status}`)
58
+ }
59
+
60
+ onProgress?.(`Downloading and extracting...`)
61
+
62
+ await pipeline(
63
+ response.body,
64
+ createGunzip(),
65
+ tar.extract({ cwd: tempDir, strip: 1 }) // strip 'owner-repo-sha/' prefix
66
+ )
67
+
68
+ onProgress?.(`Extracted to ${tempDir}`)
69
+
70
+ return {
71
+ tempDir,
72
+ ref: displayRef,
73
+ }
74
+ } catch (err) {
75
+ // Clean up on error
76
+ await rm(tempDir, { recursive: true, force: true }).catch(() => {})
77
+ throw err
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Fetch with retry and timeout
83
+ */
84
+ async function fetchWithRetry(url, options = {}, maxRetries = 3) {
85
+ for (let attempt = 1; attempt <= maxRetries; attempt++) {
86
+ try {
87
+ const response = await fetch(url, {
88
+ ...options,
89
+ signal: AbortSignal.timeout(60000), // 60s timeout for GitHub (can be slow)
90
+ })
91
+ return response
92
+ } catch (err) {
93
+ if (attempt === maxRetries) throw err
94
+ const delay = Math.min(1000 * Math.pow(2, attempt), 10000)
95
+ await new Promise(r => setTimeout(r, delay))
96
+ }
97
+ }
98
+ }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * npm package fetcher - downloads and extracts templates from npm registry
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
+ /**
13
+ * Fetch a template from npm registry
14
+ *
15
+ * @param {string} packageName - npm package name (e.g., '@uniweb/template-marketing')
16
+ * @param {Object} options - Fetch options
17
+ * @param {Function} options.onProgress - Progress callback
18
+ * @returns {Promise<Object>} { tempDir, version, metadata }
19
+ */
20
+ export async function fetchNpmTemplate(packageName, options = {}) {
21
+ const { onProgress } = options
22
+
23
+ onProgress?.(`Fetching package info for ${packageName}...`)
24
+
25
+ // Query npm registry for package metadata
26
+ const registryUrl = `https://registry.npmjs.org/${encodeURIComponent(packageName).replace('%40', '@')}`
27
+
28
+ const metaResponse = await fetchWithRetry(registryUrl)
29
+ if (!metaResponse.ok) {
30
+ if (metaResponse.status === 404) {
31
+ throw new Error(`Package not found: ${packageName}`)
32
+ }
33
+ throw new Error(`npm registry error: ${metaResponse.status}`)
34
+ }
35
+
36
+ const pkgMeta = await metaResponse.json()
37
+ const version = pkgMeta['dist-tags']?.latest
38
+ if (!version) {
39
+ throw new Error(`No published versions found for ${packageName}`)
40
+ }
41
+
42
+ const versionMeta = pkgMeta.versions[version]
43
+ const tarballUrl = versionMeta?.dist?.tarball
44
+ if (!tarballUrl) {
45
+ throw new Error(`No tarball URL found for ${packageName}@${version}`)
46
+ }
47
+
48
+ onProgress?.(`Downloading ${packageName}@${version}...`)
49
+
50
+ // Download and extract tarball
51
+ const tempDir = await mkdtemp(join(tmpdir(), 'uniweb-template-'))
52
+
53
+ try {
54
+ const tarballResponse = await fetchWithRetry(tarballUrl)
55
+ if (!tarballResponse.ok) {
56
+ throw new Error(`Failed to download tarball: ${tarballResponse.status}`)
57
+ }
58
+
59
+ await pipeline(
60
+ tarballResponse.body,
61
+ createGunzip(),
62
+ tar.extract({ cwd: tempDir, strip: 1 }) // strip 'package/' prefix
63
+ )
64
+
65
+ onProgress?.(`Extracted to ${tempDir}`)
66
+
67
+ return {
68
+ tempDir,
69
+ version,
70
+ metadata: versionMeta,
71
+ }
72
+ } catch (err) {
73
+ // Clean up on error
74
+ await rm(tempDir, { recursive: true, force: true }).catch(() => {})
75
+ throw err
76
+ }
77
+ }
78
+
79
+ /**
80
+ * Fetch with retry and timeout
81
+ */
82
+ async function fetchWithRetry(url, options = {}, maxRetries = 3) {
83
+ for (let attempt = 1; attempt <= maxRetries; attempt++) {
84
+ try {
85
+ const response = await fetch(url, {
86
+ ...options,
87
+ signal: AbortSignal.timeout(30000), // 30s timeout
88
+ })
89
+ return response
90
+ } catch (err) {
91
+ if (attempt === maxRetries) throw err
92
+ const delay = Math.min(1000 * Math.pow(2, attempt), 10000)
93
+ await new Promise(r => setTimeout(r, delay))
94
+ }
95
+ }
96
+ }
@@ -0,0 +1,209 @@
1
+ /**
2
+ * Template resolution and application for the CLI
3
+ */
4
+
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'
9
+
10
+ import { parseTemplateId, getTemplateDisplayName, BUILTIN_TEMPLATES, OFFICIAL_TEMPLATES } from './resolver.js'
11
+ import { fetchNpmTemplate } from './fetchers/npm.js'
12
+ 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
+ }
21
+
22
+ /**
23
+ * Resolve a template identifier and return the template path
24
+ *
25
+ * @param {string} identifier - Template identifier
26
+ * @param {Object} options - Resolution options
27
+ * @param {Function} options.onProgress - Progress callback
28
+ * @returns {Promise<Object>} { type, path, metadata, cleanup }
29
+ */
30
+ export async function resolveTemplate(identifier, options = {}) {
31
+ const { onProgress } = options
32
+ const parsed = parseTemplateId(identifier)
33
+
34
+ switch (parsed.type) {
35
+ case 'builtin':
36
+ return {
37
+ type: 'builtin',
38
+ name: parsed.name,
39
+ // Built-in templates are handled programmatically by the CLI
40
+ // No path or cleanup needed
41
+ }
42
+
43
+ case 'official':
44
+ return resolveOfficialTemplate(parsed.name, options)
45
+
46
+ case 'npm':
47
+ return resolveNpmTemplate(parsed.package, options)
48
+
49
+ case 'github':
50
+ return resolveGitHubTemplate(parsed, options)
51
+
52
+ default:
53
+ throw new Error(`Unknown template type: ${parsed.type}`)
54
+ }
55
+ }
56
+
57
+ /**
58
+ * Resolve an official template from @uniweb/templates
59
+ */
60
+ async function resolveOfficialTemplate(name, options = {}) {
61
+ const { onProgress } = options
62
+
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
+ }
69
+
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}`)
82
+
83
+ return {
84
+ type: 'official',
85
+ name,
86
+ path: templatePath,
87
+ cleanup: async () => {}, // Nothing to clean up
88
+ }
89
+ }
90
+
91
+ /**
92
+ * Resolve a template from npm
93
+ */
94
+ async function resolveNpmTemplate(packageName, options = {}) {
95
+ const { onProgress } = options
96
+
97
+ onProgress?.(`Resolving npm template: ${packageName}`)
98
+
99
+ const { tempDir, version, metadata } = await fetchNpmTemplate(packageName, { onProgress })
100
+
101
+ return {
102
+ type: 'npm',
103
+ package: packageName,
104
+ version,
105
+ path: tempDir,
106
+ cleanup: async () => {
107
+ await rm(tempDir, { recursive: true, force: true }).catch(() => {})
108
+ },
109
+ }
110
+ }
111
+
112
+ /**
113
+ * Resolve a template from GitHub
114
+ */
115
+ async function resolveGitHubTemplate(parsed, options = {}) {
116
+ const { onProgress } = options
117
+ const { owner, repo, ref } = parsed
118
+
119
+ onProgress?.(`Resolving GitHub template: ${owner}/${repo}`)
120
+
121
+ const { tempDir } = await fetchGitHubTemplate(owner, repo, { ref, onProgress })
122
+
123
+ return {
124
+ type: 'github',
125
+ owner,
126
+ repo,
127
+ ref,
128
+ path: tempDir,
129
+ cleanup: async () => {
130
+ await rm(tempDir, { recursive: true, force: true }).catch(() => {})
131
+ },
132
+ }
133
+ }
134
+
135
+ /**
136
+ * Apply an external template to a target directory
137
+ *
138
+ * @param {Object} resolved - Resolved template from resolveTemplate()
139
+ * @param {string} targetPath - Target directory
140
+ * @param {Object} data - Template variables
141
+ * @param {Object} options - Apply options
142
+ */
143
+ 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
+ }
152
+
153
+ try {
154
+ const metadata = await templatesPackage.applyTemplate(
155
+ resolved.path,
156
+ targetPath,
157
+ data,
158
+ { onProgress, onWarning }
159
+ )
160
+
161
+ return metadata
162
+ } finally {
163
+ // Clean up temp directory if there is one
164
+ if (resolved.cleanup) {
165
+ await resolved.cleanup()
166
+ }
167
+ }
168
+ }
169
+
170
+ /**
171
+ * List all available templates
172
+ */
173
+ export async function listAvailableTemplates() {
174
+ const templates = []
175
+
176
+ // Built-in templates
177
+ for (const name of BUILTIN_TEMPLATES) {
178
+ templates.push({
179
+ type: 'builtin',
180
+ id: name,
181
+ name: name.charAt(0).toUpperCase() + name.slice(1),
182
+ description: name === 'single'
183
+ ? 'One site + one foundation'
184
+ : 'Multiple sites and foundations',
185
+ })
186
+ }
187
+
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
202
+ }
203
+ }
204
+
205
+ return templates
206
+ }
207
+
208
+ // Re-export for convenience
209
+ export { parseTemplateId, getTemplateDisplayName, BUILTIN_TEMPLATES, OFFICIAL_TEMPLATES }
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Template resolver - parses template identifiers and determines source type
3
+ */
4
+
5
+ // Built-in templates that are generated programmatically
6
+ export const BUILTIN_TEMPLATES = ['single', 'multi']
7
+
8
+ // Official templates from @uniweb/templates package
9
+ export const OFFICIAL_TEMPLATES = ['marketing', 'docs', 'learning']
10
+
11
+ /**
12
+ * Parse a template identifier and determine its source type
13
+ *
14
+ * @param {string} identifier - Template identifier (e.g., 'single', 'marketing', 'github:user/repo')
15
+ * @returns {Object} Parsed template info
16
+ */
17
+ export function parseTemplateId(identifier) {
18
+ if (!identifier || typeof identifier !== 'string') {
19
+ throw new Error('Template identifier is required')
20
+ }
21
+
22
+ identifier = identifier.trim()
23
+
24
+ // Built-in templates
25
+ if (BUILTIN_TEMPLATES.includes(identifier)) {
26
+ return {
27
+ type: 'builtin',
28
+ name: identifier,
29
+ }
30
+ }
31
+
32
+ // Official templates from @uniweb/templates
33
+ if (OFFICIAL_TEMPLATES.includes(identifier)) {
34
+ return {
35
+ type: 'official',
36
+ name: identifier,
37
+ }
38
+ }
39
+
40
+ // GitHub shorthand: github:user/repo or github:user/repo#ref
41
+ if (identifier.startsWith('github:')) {
42
+ const rest = identifier.slice(7) // Remove 'github:'
43
+ return parseGitHubIdentifier(rest)
44
+ }
45
+
46
+ // GitHub URL: https://github.com/user/repo
47
+ if (identifier.startsWith('https://github.com/') || identifier.startsWith('http://github.com/')) {
48
+ const url = new URL(identifier)
49
+ const pathParts = url.pathname.split('/').filter(Boolean)
50
+ if (pathParts.length >= 2) {
51
+ const [owner, repo] = pathParts
52
+ // Check for tree/branch in URL
53
+ const treeIndex = pathParts.indexOf('tree')
54
+ const ref = treeIndex >= 0 && pathParts[treeIndex + 1] ? pathParts[treeIndex + 1] : undefined
55
+ return {
56
+ type: 'github',
57
+ owner,
58
+ repo: repo.replace(/\.git$/, ''),
59
+ ref,
60
+ }
61
+ }
62
+ throw new Error(`Invalid GitHub URL: ${identifier}`)
63
+ }
64
+
65
+ // Scoped npm package: @scope/package-name
66
+ if (identifier.startsWith('@')) {
67
+ return {
68
+ type: 'npm',
69
+ package: identifier,
70
+ }
71
+ }
72
+
73
+ // Unscoped name - assume it's an npm package with @uniweb/template- prefix
74
+ // This allows users to type `uniweb create foo --template blog` for @uniweb/template-blog
75
+ return {
76
+ type: 'npm',
77
+ package: `@uniweb/template-${identifier}`,
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Parse GitHub identifier: user/repo or user/repo#ref
83
+ */
84
+ function parseGitHubIdentifier(identifier) {
85
+ const [repoPath, ref] = identifier.split('#')
86
+ const [owner, repo] = repoPath.split('/')
87
+
88
+ if (!owner || !repo) {
89
+ throw new Error(`Invalid GitHub identifier: ${identifier}. Expected format: user/repo or user/repo#ref`)
90
+ }
91
+
92
+ return {
93
+ type: 'github',
94
+ owner,
95
+ repo: repo.replace(/\.git$/, ''),
96
+ ref: ref || undefined,
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Get a display name for a template identifier
102
+ */
103
+ export function getTemplateDisplayName(parsed) {
104
+ switch (parsed.type) {
105
+ case 'builtin':
106
+ return `Built-in: ${parsed.name}`
107
+ case 'official':
108
+ return `Official: ${parsed.name}`
109
+ case 'npm':
110
+ return parsed.package
111
+ case 'github':
112
+ return `${parsed.owner}/${parsed.repo}${parsed.ref ? `#${parsed.ref}` : ''}`
113
+ default:
114
+ return 'Unknown'
115
+ }
116
+ }